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), `deploy_tree_invalid`, `embedded_secret` (21-03), `destructive_change_refused` (23-02) (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), `module_not_registered` (16-01)
13//! | 4    | network        | `network_error`, `module_feed_unreachable` (15-01), `transport_failed` (22-01)
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), `module_release_not_found`, `module_digest_mismatch`, `module_feed_unusable` (15-01), `module_digest_changed` (15-02), `module_uninstall_denied` (19-01), `scan_lock_held` (20-02), `resource_signature_rejected` (20-02), `resource_exists` (20-REVIEW WR-01), `mode_exists` (20-REVIEW WR-01), `deploy_ledger_invalid` (22-03), `plan_stale`, `deploy_blocked`, `deploy_preflight_failed`, `deploy_journal_present`, `deploy_live_changed`, `deploy_run_invalid`, `rollback_failed` (23-02), `project_git_managed` (25-07), `deploy_unreadable` (quick-261029-ms3), `deploy_pair_tags_refused` (debug deploy-tag-redundancy-sync)
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 `ign top` (itop) TTY-refusal reason — the SAME usage class and
33/// slug as [`TUI_TTY_REFUSAL_REASON`] at one command over (the monitor
34/// is its own full-screen surface, so its refusal names ITS command —
35/// the RoutesNotDeployed rule: a hint must never name a command that
36/// does not fix it). [`CoreError::hint`] content-addresses this
37/// constant exactly like its tui sibling.
38pub const TOP_TTY_REFUSAL_REASON: &str = "ign top requires a terminal (stdout is not a TTY)";
39
40/// The TAGS-12 loss-gate refusal reason prefix (11-07 gap closure). The
41/// InvalidInput hint is content-addressed off this literal: the loss
42/// gate's reason is DYNAMIC prose (the CLI's `render_loss_prose` header
43/// `loss report ({label}): …` plus per-fact lines), so unlike
44/// [`TUI_TTY_REFUSAL_REASON`] the sentinel cannot be the whole reason —
45/// it is the stable header prefix instead. Same slug (`invalid_input`),
46/// same exit 2 (frozen taxonomy; only the hint differs) — the 06-07
47/// TTY-refusal pattern at one removal. The contract_tags loss-gate
48/// pins are the drift guard: if the prose header ever changes, the
49/// hint silently regresses to the generic default and those pins fail.
50pub const LOSS_GATE_REFUSAL_REASON_PREFIX: &str = "loss report (";
51
52/// The TESTING bundle's route prefix (QUICK-p0g). The
53/// [`CoreError::RoutesNotDeployed`] hint is content-addressed off it
54/// — same third instance of the 06-07 pattern: the frozen taxonomy
55/// keeps ONE absent-routes variant and one slug
56/// (`routes_not_deployed`, exit 6), but the testing bundle installs
57/// through a DIFFERENT command (`ign webdev deploy --with-testing` /
58/// `ign adopt --project NAME --testing`), so the generic
59/// `ign webdev deploy` hint would name a command that does not fix
60/// it. Raise sites pass `route: "testing/run"`.
61pub const TESTING_ROUTE_PREFIX: &str = "testing/";
62
63/// The api-call catch-all's body cap (09-01): a gateway 4xx body rides
64/// [`CoreError::GatewayClientError`] VERBATIM up to this many bytes; a
65/// larger body is truncated at [`truncate_api_body`] with the explicit
66/// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`]. 4 KiB is the plan-locked
67/// cap — an unbounded passthrough would let a pathological gateway
68/// page flood the agent's envelope.
69pub const GATEWAY_CLIENT_BODY_CAP_BYTES: usize = 4096;
70
71/// The truncation marker [`truncate_api_body`] appends when the api-call
72/// body exceeds [`GATEWAY_CLIENT_BODY_CAP_BYTES`]. ASCII-pinned (no
73/// multi-byte characters) so golden-file consumers never see an encoding
74/// surprise at the cut.
75pub const GATEWAY_CLIENT_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
76
77/// The ONE construction site for a capped api-call body: returns `body`
78/// verbatim when it fits [`GATEWAY_CLIENT_BODY_CAP_BYTES`], otherwise its
79/// first cap bytes (on a UTF-8 char boundary) plus
80/// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`]. The variant always carries
81/// its final form — construction cannot forget the cap.
82pub fn truncate_api_body(body: &str) -> String {
83    if body.len() <= GATEWAY_CLIENT_BODY_CAP_BYTES {
84        return body.to_string();
85    }
86    let mut end = GATEWAY_CLIENT_BODY_CAP_BYTES;
87    while !body.is_char_boundary(end) {
88        end -= 1;
89    }
90    let mut truncated = body[..end].to_string();
91    truncated.push_str(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER);
92    truncated
93}
94
95/// Every failure `ign` can report. One variant per contract class; `code()`,
96/// `exit_code()`, `hint()` are total functions over it.
97#[derive(Debug, thiserror::Error)]
98pub enum CoreError {
99    /// Unexpected runtime failure — the catch-all; report as a bug. Exit 1.
100    #[error("internal error: {0}")]
101    Internal(String),
102
103    /// Destructive operation invoked without `--yes`. Exit 2 (same class as
104    /// usage: it names a flag the caller must add; clap renders its own
105    /// usage errors with its exit 2 — never hook clap).
106    #[error("{operation} is destructive; rerun with --yes to confirm")]
107    ConfirmationRequired { operation: String },
108
109    /// JVM profiling invoked without `--i-accept-the-risk`. Exit 2
110    /// (usage class — it names a flag the caller must add, like
111    /// [`Self::ConfirmationRequired`]) but DELIBERATELY NOT satisfiable
112    /// by the global `--yes`/`IGNITION_YES`: active JVM instrumentation
113    /// is a stronger opt-in than the destructive-verb guard — the
114    /// caller must acknowledge THIS specific production risk, not
115    /// confirm generically.
116    #[error(
117        "{operation} actively instruments the gateway JVM; rerun with --i-accept-the-risk to acknowledge the production risk"
118    )]
119    ProfilingRiskNotAccepted { operation: String },
120
121    /// An import byte source the caller must fix (wrong file, too big,
122    /// unreadable). Exit 2 — usage class: it names what the CALLER must
123    /// change, like [`Self::ConfirmationRequired`] (03-02).
124    #[error("invalid import file: {reason}")]
125    InvalidImportFile { reason: String },
126
127    /// The gateway ANSWERED the import POST with HTTP 200 but the
128    /// body says `{"success": false, "problem": "…"}` — the
129    /// denial-rides-200 class the WebDev family handles (05-01)
130    /// applied to the project-import family (05-07, UAT Gap 1):
131    /// without this check the import caller reports ok while nothing
132    /// landed. Exit 6 — target state: the gateway refused the
133    /// import (the problem text names why, verbatim).
134    #[error("gateway rejected the project import for {project:?}: {problem}")]
135    ImportDenied {
136        /// The project the import was headed for.
137        project: String,
138        /// The gateway's own `problem` text (verbatim when present).
139        problem: String,
140        /// URL of the import request, when known.
141        endpoint: Option<String>,
142    },
143
144    /// A command input the caller must fix (unreadable `--file`, failed
145    /// stdin read). Exit 2 — usage class, the generic sibling of
146    /// [`Self::InvalidImportFile`] (03-03: `resource put`'s byte
147    /// source).
148    #[error("invalid input: {reason}")]
149    InvalidInput { reason: String },
150
151    /// Named profile absent from config. Exit 3.
152    #[error("profile {name:?} not found (known profiles: {known:?})")]
153    ProfileNotFound { name: String, known: Vec<String> },
154
155    /// No `--profile`, no `IGNITION_PROFILE`, no active profile in config.
156    /// Exit 3. Constructible from the CLI once config resolution lands
157    /// (01-03); the taxonomy is complete on day one.
158    #[error("no active profile configured")]
159    NoActiveProfile,
160
161    /// No credential resolvable for the profile (env, token_env, keyring all
162    /// missed or failed). Exit 3.
163    #[error("secret unavailable for profile {profile:?}")]
164    SecretUnavailable { profile: String },
165
166    /// The adopt bootstrap's login rung needs `IGNITION_PASSWORD` and it
167    /// was absent/empty (ADOPT-05 review round: the generic
168    /// [`Self::SecretUnavailable`] hint names the TOKEN paths, which is
169    /// exactly wrong for the PASSWORD gate — same exit class, own slug,
170    /// never a new exit code; the PollIntervalTooSmall precedent).
171    #[error("login password unavailable for profile {profile:?}")]
172    PasswordUnavailable { profile: String },
173
174    /// Config file unreadable or wrong shape. Exit 3.
175    #[error("invalid configuration: {reason}")]
176    ConfigInvalid { reason: String },
177
178    /// A profile's `poll_interval_secs` is below the 1-second floor
179    /// (08-01, TUIX-05 clamp): sub-second gateway polling is refused at
180    /// load time — the TUI's background refresh cadence may not hammer
181    /// the gateway. Exit 3 — the CONFIG class (the Phase-7 additive-slug
182    /// precedent, e.g. `eam_not_controller`: same exit class, own slug,
183    /// never a new exit code — the 1–7 taxonomy is frozen).
184    #[error("profile {profile:?}: poll_interval_secs must be >= 1 (sub-second polling refused)")]
185    PollIntervalTooSmall {
186        /// The profile carrying the refused value.
187        profile: String,
188    },
189
190    /// Gateway unreachable / timeout / TLS failure. Exit 4.
191    ///
192    /// `source: None` marks a POLL deadline expiry (02-04 `poll.rs`):
193    /// same class, same slug (`network_error`), no new variant — the
194    /// transport-error `source` a real failure carries is simply
195    /// absent, and `url` describes what was being waited on instead
196    /// (the poll's subject). The deadline's last observation rides
197    /// `observation` (09-07): when it is `Some` the gateway ANSWERED
198    /// with a concrete state, and the message leads with "no terminal
199    /// state" — it NEVER claims unreachability for an observed
200    /// answer; `None` keeps the plain unreachability wording.
201    #[error(
202        "{lead} at {url}{source_note}{observation_note}",
203        lead = if observation.is_some() {
204            "no terminal state"
205        } else {
206            "gateway unreachable"
207        },
208        source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default(),
209        observation_note = observation
210            .as_deref()
211            .map(|obs| format!("; last observation: {obs}"))
212            .unwrap_or_default()
213    )]
214    Network {
215        url: String,
216        #[source]
217        source: Option<reqwest::Error>,
218        /// The poll deadline's last concrete observation (e.g. the
219        /// gateway's reported state) — `None` for transport failures
220        /// and observation-less deadlines. `Some` ⇒ the gateway
221        /// answered; the Display never says "unreachable" then.
222        observation: Option<String>,
223    },
224
225    /// Gateway reachable but rejected credentials (401/403). Exit 5.
226    #[error("gateway rejected credentials (HTTP {status})")]
227    Auth {
228        status: u16,
229        /// URL/path of the request that was rejected, when known.
230        endpoint: Option<String>,
231    },
232
233    /// Gateway reachable but the command is invalid for its current state —
234    /// version below minimum, uncommissioned, mid-restart, or a missing
235    /// resource. Exit 6.
236    #[error("gateway version {found} is below minimum {minimum}")]
237    GatewayTooOld {
238        found: String,
239        minimum: String,
240        /// URL/path of the request that answered, when known.
241        endpoint: Option<String>,
242    },
243
244    /// Gateway reachable but uncommissioned — every `/data` route 302s to
245    /// `/welcome` (verified on a fresh 8.3.6 container; 02-RESEARCH
246    /// §Error-Body Sniffing). Exit 6.
247    #[error("gateway at {} is not commissioned", endpoint.as_deref().unwrap_or("unknown address"))]
248    GatewayNotCommissioned {
249        /// URL that was redirected to the commissioning wizard.
250        endpoint: Option<String>,
251    },
252
253    /// Gateway restarting — webserver answers (503) but services are down
254    /// (verified restart lifecycle: webserver never drops the connection).
255    /// Exit 6.
256    #[error("gateway is restarting (webserver up, services down)")]
257    GatewayRestarting {
258        /// URL that answered 503.
259        endpoint: Option<String>,
260    },
261
262    /// Named resource absent (404) — terminating a nonexistent session id,
263    /// an unknown path, or a pre-8.3 gateway's JSON
264    /// `{"message": "No route match for path: …"}`. Exit 6.
265    #[error("resource not found on the gateway")]
266    NotFound {
267        /// URL that answered 404.
268        endpoint: Option<String>,
269    },
270
271    /// A project of this name already exists and the import's collision
272    /// policy is abort — the CLI-side pre-check refused BEFORE any
273    /// upload (the server's own answer remains the backstop). Exit 6 —
274    /// target state: the command is invalid for the gateway's current
275    /// state (03-02, the GatewayTooOld action-built-variant precedent:
276    /// constructed by the actions layer, not classify).
277    #[error("project {name} already exists on the gateway")]
278    ProjectExists {
279        /// The colliding project name.
280        name: String,
281        /// URL of the pre-check request, when known.
282        endpoint: Option<String>,
283    },
284
285    /// A binary (data.bin-class) resource met the surgical JSON/text
286    /// loop — REFUSED rather than corrupted through it. Exit 6 —
287    /// target state: the command is invalid for that resource's
288    /// nature; the export/import family owns binary resources
289    /// (Pitfall 7).
290    #[error("resource {path:?} has binary content — not editable via the resource loop")]
291    ResourceBinary {
292        /// The resource path that was refused.
293        path: String,
294        /// URL of the request involved, when known.
295        endpoint: Option<String>,
296    },
297
298    /// The gateway refuses trial resets while the trial is still
299    /// active — live-discovered on 8.3.3 during 04-03's spike: the
300    /// reset POST answers 403 on a NON-expired trial (verified from
301    /// the browser page itself with the exact UI headers), and 200 +
302    /// the flip on an expired one. The action layer's expiry pre-check
303    /// turns that misleading auth-shaped 403 into the honest
304    /// target-state refusal. Exit 6 (the ProjectExists precedent:
305    /// action-constructed, not classify).
306    #[error(
307        "trial is not expired ({remaining_s}s left) — the gateway only honors resets once the trial expires"
308    )]
309    TrialNotExpired {
310        /// Seconds left on the active trial.
311        remaining_s: i64,
312        /// URL of the rig's trial endpoint, when known.
313        endpoint: Option<String>,
314    },
315
316    /// The WebDev route family a command depends on is not deployed —
317    /// the presence probe answered 405, the live-proven 8.3 absent
318    /// marker (missing routes AND missing projects both answer 405,
319    /// NOT 404; 05-RESEARCH Pitfall 1). Exit 6 — target state: the
320    /// command is invalid until `ign webdev deploy` installs the
321    /// routes (the TrialNotExpired precedent: action-constructed, not
322    /// classify).
323    #[error(
324        "webdev routes are not deployed (probe of {route:?} in project {project:?} answered 405)"
325    )]
326    RoutesNotDeployed {
327        /// The deploy project the probe targeted.
328        project: String,
329        /// The route folder the probe named.
330        route: String,
331        /// Path of the probe request, when known.
332        endpoint: Option<String>,
333    },
334
335    /// The WebDev module answered 402 — installed but unlicensed (a
336    /// trial-expired gateway; live-verified cross-version on 8.3.6,
337    /// 05-RESEARCH §Servlet). Exit 6 — no `/system/webdev` route can
338    /// answer until the gateway is licensed.
339    #[error(
340        "the WebDev module is unlicensed on this gateway (HTTP 402 — trial-expired rigs cannot serve /system/webdev routes)"
341    )]
342    WebdevUnlicensed {
343        /// Path of the probe request, when known.
344        endpoint: Option<String>,
345    },
346
347    /// A deployed route's handshake version differs from the embedded
348    /// bundle's — the CLI refuses rather than auto-upgrading either
349    /// side (roadmap-locked: actionable error, no auto-upgrade
350    /// magic). Exit 6.
351    #[error("route {route:?} version mismatch: deployed {deployed}, this CLI expects {expected}")]
352    RouteVersionMismatch {
353        /// The route folder that answered.
354        route: String,
355        /// The route's deployed `routeVersion`.
356        deployed: String,
357        /// The embedded bundle's version
358        /// ([`crate::webdev::ROUTE_BUNDLE_VERSION`]).
359        expected: String,
360        /// Path of the probe request, when known.
361        endpoint: Option<String>,
362    },
363
364    /// A tag provider of this name does not exist — the
365    /// find→signature→delete chain's find half missed (05-04,
366    /// TAGS-01). Exit 6 — target state: the named thing is absent
367    /// (the ProjectExists precedent family: action-constructed, not
368    /// classify — the honest, family-specific refusal over a bare
369    /// 404).
370    #[error("tag provider {name:?} not found on the gateway")]
371    ProviderNotFound {
372        /// The provider name that missed.
373        name: String,
374        /// URL of the find request, when known.
375        endpoint: Option<String>,
376    },
377
378    /// A WebDev route answered HTTP 200 with a body denial
379    /// (`{ok:false, error{code,message}}`) whose machine code this CLI
380    /// does not specifically map — code + message ride verbatim so
381    /// agents can branch on the stable route contract (05-01). Exit 6
382    /// — target state: the deployed route refused the action.
383    #[error("webdev route denied the call ({code}): {message}")]
384    WebdevRouteError {
385        /// The route's machine error code (stable contract).
386        code: String,
387        /// The route's human message.
388        message: String,
389        /// Path of the request, when known.
390        endpoint: Option<String>,
391    },
392
393    /// A tag import under abort policy found EXISTING tags at the
394    /// target provider (05-05, TAGS-09) — the browse pre-check
395    /// refuses BEFORE any route write (the LOCKED Phase-3 collision
396    /// matrix mapped onto configure's 'a'/'o'). Exit 6 — target
397    /// state: the named tags exist; overwrite is the explicit,
398    /// guarded opt-in.
399    #[error(
400        "tag collision importing into provider {provider:?}: {} already exist(s)",
401        names.join(", ")
402    )]
403    TagCollision {
404        /// The target provider the import was headed for.
405        provider: String,
406        /// The colliding top-level tag names the pre-check found.
407        names: Vec<String>,
408        /// URL of the pre-check browse request, when known.
409        endpoint: Option<String>,
410    },
411
412    /// The gateway has no alarm-journal profile configured — alarm
413    /// history has nowhere to read from. The alarms route's
414    /// structured `no_alarm_journal` denial maps here (the
415    /// denial_to_error seam, 05-06 TAGS-07): DEFAULT rigs hit this
416    /// ALWAYS, because the journal is a config-resource chain —
417    /// database connection + `ignition/alarm-journal` profile + the
418    /// `general-alarm-settings` singleton pointing at it. Exit 6 —
419    /// target state: the command is invalid for the gateway's
420    /// current state until that chain is provisioned (the honest,
421    /// actionable refusal over a bare route error).
422    #[error(
423        "no alarm journal profile is configured on this gateway — alarm history has nothing to read"
424    )]
425    AlarmJournalMissing {
426        /// URL of the alarms route request, when known.
427        endpoint: Option<String>,
428    },
429
430    /// Pruning a LIVE Designer session entry — the gateway's prune
431    /// route answers 409 (empty body, wire-verified on 8.3.3,
432    /// 06-UAT test 6): prune removes STALE entries only, so the
433    /// command is invalid while the Designer is still open. Exit 6 —
434    /// target state (additive slug in the frozen taxonomy's
435    /// established growth pattern; classify()'s ROUTE-SCOPED 409 arm
436    /// constructs this — the first classify-constructed refusal added
437    /// since the 02-01 set).
438    #[error("designer session {id} is live — the gateway refused the prune")]
439    SessionNotPrunable {
440        /// The Designer session id that was refused.
441        id: String,
442        /// URL of the refused DELETE, when known.
443        endpoint: Option<String>,
444    },
445
446    /// Docker/compose rig failure. Exit 7. Reserved — first used in Phase 4;
447    /// trivially constructible so the taxonomy enumerates completely today.
448    #[error("rig error: {0}")]
449    Rig(String),
450
451    /// The gateway's EAM module is not configured as a controller —
452    /// every `/data/eam/api/v1/*` runtime endpoint answers 403 with
453    /// "This operation can only be performed when EAM is configured
454    /// as a controller" on a stock gateway (live-proven 8.3.3,
455    /// 07-RESEARCH): a STATE refusal, not auth — the token is fine,
456    /// the module's role is not. Message-classified at the classify
457    /// seam, path-scoped to `/data/eam/` so generic 403s elsewhere
458    /// cannot shift (the trial_not_expired pattern, classify
459    /// edition). Exit 6 — target state.
460    #[error(
461        "EAM is not configured as a controller on this gateway — every EAM runtime \
462         operation refuses until the module's installMode is flipped"
463    )]
464    EamNotController {
465        /// URL of the refused request, when known.
466        endpoint: Option<String>,
467    },
468
469    /// A provider-ROOT tag path (`[default]` alone, or a bare first
470    /// segment that resolves to a provider) met the tagConfig route
471    /// — `system.tag.getConfiguration`/`exportTags` need an RPC
472    /// context WebDev threads don't carry (live-proven 8.3.3
473    /// b2026012009, both gateways; 07-UAT test 12). The route
474    /// refuses honestly (pre-call bracket detection + RpcContext
475    /// translation for the bare form) instead of surfacing the
476    /// IllegalStateException as a generic route error; subtree
477    /// paths (`[default]folder`) are the supported form. Exit 6 —
478    /// target state: a platform limitation, not a bug.
479    #[error(
480        "provider-root tag paths are not supported by the deployed route (the gateway needs an \
481         RPC context WebDev threads don't carry) — target a subtree like [provider]folder"
482    )]
483    ProviderRootUnsupported {
484        /// URL of the tagConfig route request, when known.
485        endpoint: Option<String>,
486    },
487
488    /// An `eam task new` whose type is in the REFUSED set —
489    /// `eam_restoreBackup`, `eam_installModules`, `eam_remoteUpgrade`
490    /// are fleet-destructive (they push backups/modules/upgrades to
491    /// every AGENT target), and the CLI refuses them outright over
492    /// guard-everything: honest refusal with the v2 scope pointer
493    /// (the planner-locked create ladder's top rung). Exit 6 —
494    /// target state (additive slug alongside Task 2's).
495    #[error(
496        "EAM task type {task_type} is fleet-destructive — refused (restore/install/upgrade \
497         are EXT-03 (v2) scope; run them from the EAM console)"
498    )]
499    EamTaskTypeRefused {
500        /// The refused `profile.type` token.
501        task_type: String,
502    },
503
504    /// An `eam task force` whose slot a leftover run occupies — the
505    /// force route answers 409 with the gateway's own Jetty page
506    /// ("Task 'X (forced)' already exists! It must be completed or
507    /// deleted before another task of this type can be force
508    /// executed."; live-captured 8.3.3, 07-UAT test 7). Exit 6 —
509    /// target state (the `session_not_prunable` precedent,
510    /// force-route edition): the gateway's state refused the command,
511    /// not a bug; classify()'s path-scoped 409 arm constructs this.
512    #[error("EAM task {task} has a run in flight — the gateway refused the force: {detail}")]
513    EamTaskInFlight {
514        /// The forced task's name (the force URL's last segment).
515        task: String,
516        /// The gateway's Jetty page message verbatim when sniffed;
517        /// the '(forced)' fallback text otherwise.
518        detail: String,
519        /// URL of the refused force POST, when known.
520        endpoint: Option<String>,
521    },
522
523    /// The scriptExec route is not configured for this profile — no
524    /// webdev secret is persisted, which can only mean `ign webdev
525    /// deploy --with-script-exec` has never run (deploy persists the
526    /// secret 0600 BEFORE upload, so a deployed route without a
527    /// stored secret is not a reachable state). `ign script run`'s
528    /// opt-in is STRUCTURAL — the deploy flag — so the verb carries
529    /// no `--yes` guard and refuses here instead (07-03, SCRPT-01:
530    /// the TrialNotExpired precedent — action-constructed, not
531    /// classify). Exit 6 — target state.
532    #[error(
533        "scriptExec is not configured for profile {profile:?} — the secret-gated route deploys \
534         only via the explicit opt-in"
535    )]
536    ScriptExecNotConfigured {
537        /// The profile whose secret store is empty.
538        profile: String,
539    },
540
541    /// `ign lint` found no `ignition-lint` executable on PATH — the
542    /// delegation has nothing to delegate to (07-04, INTR-02). The
543    /// hint carries the install command + repo. Exit 6 — target
544    /// state (additive slug; the environment lacks the tool, the
545    /// command is fine).
546    #[error("ignition-lint is not installed (no executable found on PATH)")]
547    LintToolAbsent,
548
549    /// `ign e2e init` found no `node` executable on PATH — the scaffold
550    /// it would write is unusable without one (QUICK-tg4).
551    ///
552    /// Exit 6, mirroring [`Self::LintToolAbsent`] line for line: this
553    /// is the SAME shape — a local external tool a verb delegates to is
554    /// absent, with an install hint. It deliberately does NOT join
555    /// exit 7, whose sole slug is `rig_error` for docker/compose rig
556    /// failures; putting a missing Node there would force a relabel of
557    /// a locked exit class.
558    #[error("{tool} is not installed (no executable found on PATH)")]
559    NodeToolAbsent {
560        /// The tool that was looked for (`node`).
561        tool: String,
562    },
563
564    /// The gateway answered the api call with a 4xx this CLI does not
565    /// curate — the caller's request is the problem, and the body is
566    /// theirs to read. Exit 2 (usage class; additive slug — the
567    /// poll_interval_too_small precedent: same class, own slug, no new
568    /// exit code). Constructed ONLY by the api-call-scoped classify arm
569    /// (09-01: the `api_call` parameter) — a curated command's 4xx keeps
570    /// its existing classification (the catch-all cannot fire without
571    /// the parameter). The body is the gateway's answer VERBATIM,
572    /// truncated at [`GATEWAY_CLIENT_BODY_CAP_BYTES`] with the explicit
573    /// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`] via [`truncate_api_body`]
574    /// at construction.
575    #[error("gateway rejected the api call (HTTP {status} from {endpoint}): {body}")]
576    GatewayClientError {
577        /// HTTP status the gateway answered with.
578        status: u16,
579        /// Full URL of the rejected request.
580        endpoint: String,
581        /// The response body VERBATIM, truncated at
582        /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] with an explicit marker.
583        body: String,
584    },
585
586    /// No diagnostics bundle is available — the status poll ANSWERED
587    /// with a captured TERMINAL steady state meaning "no current
588    /// bundle" (`Invalid`: live-proven on 8.3.6 rig ign-p9-836,
589    /// 2026-09-07 UAT / 09-UAT.md Gap 3 — a `Valid` bundle decays to
590    /// `Invalid` within ~2 minutes UNPROMPTED and stays `Invalid`;
591    /// only a fresh generate changes it, polling cannot). Exit 6 —
592    /// target state (the `ImportDenied` precedent: a gateway-answered
593    /// refusal riding its own class, action-constructed by `bundle
594    /// wait`'s probe, not classify).
595    #[error(
596        "no diagnostics bundle available (gateway reports state {state:?}) — run \
597         `ign diagnostics bundle generate` first; polling cannot change this state"
598    )]
599    BundleNotAvailable {
600        /// The observed steady state (the captured unavailable
601        /// vocabulary, e.g. `Invalid`).
602        state: String,
603    },
604
605    /// Transport failure reaching a third-party module release feed or
606    /// its asset CDN — DNS/connect/timeout/TLS (Phase 15, D-06). Exit 4:
607    /// same network class as [`Self::Network`], but a SEPARATE slug and
608    /// wording — `Network`'s Display hard-codes "gateway unreachable",
609    /// which is actively wrong here (`api.github.com` is not a gateway,
610    /// and the user's own gateway may be perfectly reachable). This is
611    /// the offline signal `fetch_and_verify` returns when the cache is
612    /// empty (SC-4's negative half).
613    #[error(
614        "module release feed at {url} is unreachable{source_note}",
615        source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default()
616    )]
617    ModuleFeedUnreachable {
618        /// The feed or CDN URL that could not be reached.
619        url: String,
620        #[source]
621        source: Option<reqwest::Error>,
622    },
623
624    /// No release exists for the pinned tag, or the release carries no
625    /// asset of the expected name (Phase 15, SC-1's "unknown version"
626    /// refusal). Exit 6 — target state: names BOTH the requested version
627    /// and the resolved URL, and (for a missing asset) both the expected
628    /// asset name and the names actually present, so the caller never
629    /// has to guess which lookup failed or falls back to "latest".
630    #[error("module {module:?} version {version:?} not found at {url}: {detail}")]
631    ModuleReleaseNotFound {
632        /// The [`crate::module::ModuleSpec::id`] being fetched.
633        module: String,
634        /// The requested version string.
635        version: String,
636        /// The tag-pinned releases URL that was queried.
637        url: String,
638        /// What specifically was missing (no release for the tag, or no
639        /// matching asset — names the asset actually present).
640        detail: String,
641    },
642
643    /// Downloaded bytes hash to something other than the release's
644    /// published digest — SC-2's hard refusal (Phase 15; this variant is
645    /// defined in 15-01, the branch is exercised starting 15-02). Exit 6
646    /// — target state: the feed answered and served bytes, but what it
647    /// served cannot be trusted. The bytes are discarded (never
648    /// persisted to the cache) and BOTH digests are named.
649    ///
650    /// Payload is BOXED: five inline `String`s put `CoreError` at
651    /// exactly clippy's 128-byte `result_large_err` threshold, which
652    /// fails `-D warnings` in every crate returning `Result<_,
653    /// CoreError>` — `ignition-tui` first. Error paths are cold, so one
654    /// allocation on construction is the cheap side of that trade.
655    #[error("{0}")]
656    ModuleDigestMismatch(Box<ModuleDigestMismatchDetails>),
657
658    /// The module release feed ANSWERED but not usably — rate-limited,
659    /// unauthenticated 401/403, 5xx, an unparseable body, an asset with
660    /// no published digest, or an insecure downgrade of the asset URL
661    /// (Phase 15). Exit 6 — target state: distinct from
662    /// [`Self::ModuleFeedUnreachable`] (exit 4 — transport never reached
663    /// the feed at all); here the feed DID answer, just not with
664    /// something `ign` can safely act on.
665    #[error("module release feed at {url} is unusable: {detail}")]
666    ModuleFeedUnusable {
667        /// The feed or asset URL that answered unusably.
668        url: String,
669        /// What specifically made the answer unusable.
670        detail: String,
671    },
672
673    /// A pinned version's upstream digest differs from what's already
674    /// cached — D-02's default refusal on `FetchPolicy::Refresh` /
675    /// `AcceptUpstreamChange` (Phase 15, 15-02): the feed answered
676    /// fine, but what it currently publishes for this version no
677    /// longer matches what was verified and cached before. Exit 6 —
678    /// target state: names BOTH full digests and the path of the
679    /// cached artifact, which is left on disk UNTOUCHED (no path in
680    /// `module/` deletes, truncates, or overwrites a cache entry).
681    /// `FetchPolicy::AcceptUpstreamChange` is the explicit, documented
682    /// override.
683    ///
684    /// Payload is BOXED for the same reason as
685    /// [`Self::ModuleDigestMismatch`] — see that variant.
686    #[error("{0}")]
687    ModuleDigestChanged(Box<ModuleDigestChangedDetails>),
688
689    /// A declared module id has no matching registry entry (Phase 16,
690    /// D-13). A well-formed id that simply isn't registered is a CONFIG
691    /// problem, not malformed input (exit 2 belongs to
692    /// `validate_module_id`/`validate_version`) and not a Docker
693    /// failure (exit 7 is [`Self::Rig`]) — every OTHER module
694    /// provisioning refusal (missing artifact, non-file artifact,
695    /// relative path, underivable service) stays `CoreError::Rig`.
696    /// Exit 3, structurally the [`Self::ProfileNotFound`] shape: names
697    /// both the bad id and every id that IS registered.
698    #[error("module {id:?} is not registered (known modules: {known:?})")]
699    ModuleNotRegistered {
700        /// The declared id with no matching [`crate::module::ModuleSpec`].
701        id: String,
702        /// Every currently registered [`crate::module::ModuleSpec::id`].
703        known: Vec<String>,
704    },
705
706    /// The gateway ANSWERED `DELETE /data/api/v1/modules/uninstall` with
707    /// HTTP 200 but the body says `{"success": false, "failedUninstalls":
708    /// {"uninstall": […]}}` — the denial-rides-200 class
709    /// ([`Self::ImportDenied`]'s precedent) applied to module uninstall
710    /// (Phase 19, D-19-03): live-verified that a stock module, a bogus
711    /// id, and a still-mounted module ALL answer 200 with
712    /// `success:false`. An unparseable body, a missing `success`, or an
713    /// absent `failedUninstalls` all map here too — absence of detail
714    /// must never degrade into a reported success. Exit 6 — target
715    /// state: names both the registry id and the gateway id that was
716    /// sent, plus every failed id the gateway reported (or the sent id
717    /// itself when the gateway gave no detail).
718    #[error(
719        "gateway refused to uninstall module {module_id:?} ({gateway_module_id}): {}",
720        failed.join(", ")
721    )]
722    ModuleUninstallDenied {
723        /// The registry slug (`git`) the caller named.
724        module_id: String,
725        /// The gateway's own id ([`crate::module::ModuleSpec::gateway_module_id`])
726        /// sent in the request.
727        gateway_module_id: String,
728        /// `failedUninstalls.uninstall` verbatim, or `[gateway_module_id]`
729        /// when the gateway's body carried no usable detail.
730        failed: Vec<String>,
731        /// URL of the refused DELETE, when known.
732        endpoint: Option<String>,
733    },
734
735    /// Another actor holds the gateway's scan-lock. Exit 6 — target
736    /// state. Constructed by the route-scoped scan-lock arm in
737    /// classify.rs (20-03); `kind` is `config` or `projects`. `actor`
738    /// is the holder's opaque identity string when the gateway named
739    /// it (`api-token:<name>:<ip>`), echoed and never parsed; `None`
740    /// when the refusal did not name one.
741    #[error(
742        "the {kind} scan-lock is held by {}",
743        actor.as_deref().unwrap_or("another actor")
744    )]
745    ScanLockHeld {
746        /// The holder's identity string, when the gateway named it.
747        actor: Option<String>,
748        /// Which scan-lock: `config` or `projects`.
749        kind: String,
750        /// URL of the refused request, when known.
751        endpoint: Option<String>,
752    },
753
754    /// The gateway refused a resource write over its signature. Exit 6 —
755    /// target state. Constructed ONLY by the deploy-scoped write helper
756    /// in client/deploy_api.rs (20-04), never by classify.rs, so the
757    /// v1.1 EAM 500 pin stays exit 1 `internal`. The payload
758    /// deliberately carries no signature and no gateway problem text:
759    /// 8.3.3 problem messages leak the live signature.
760    #[error("gateway refused the write to resource {name:?}: {reason} signature")]
761    ResourceSignatureRejected {
762        /// The resource name that was being written.
763        name: String,
764        /// Why the signature was refused.
765        reason: SignatureReason,
766        /// URL of the refused request, when known.
767        endpoint: Option<String>,
768    },
769
770    /// A resource create named a resource that already exists. Exit 6 —
771    /// target state. Constructed ONLY by the deploy-scoped create helper
772    /// in client/deploy_api.rs from the live 409 JSON `problem` body
773    /// (20-LIVE-CAPTURES D10), never by classify.rs. The payload carries
774    /// no gateway problem text or stacktrace.
775    #[error("resource {name:?} already exists")]
776    ResourceExists {
777        /// The resource name that was being created.
778        name: String,
779        /// URL of the refused request, when known.
780        endpoint: Option<String>,
781    },
782
783    /// A deployment-mode create named a mode that is already defined.
784    /// Exit 6 — target state. Constructed ONLY by the deploy-scoped mode
785    /// create in client/deploy_api.rs from the live 409 (D6).
786    #[error("deployment mode {name:?} is already defined")]
787    ModeExists {
788        /// The mode name that was being created.
789        name: String,
790        /// URL of the refused request, when known.
791        endpoint: Option<String>,
792    },
793
794    /// A declared gateway tree failed render validation (21-03): one or
795    /// more findings, each naming its file and, for YAML syntax and
796    /// schema defects, a line and column. Exit 2 — usage class: the
797    /// CALLER authored the tree and must fix it. Render is offline, so
798    /// there is no endpoint. Boxed to keep `CoreError` under clippy's
799    /// `result_large_err` threshold.
800    #[error("deploy tree invalid: {0}")]
801    DeployTreeInvalid(Box<DeployTreeInvalidDetails>),
802
803    /// A declared tree carries an Embedded or plaintext secret (21-03).
804    /// Exit 2 — usage class. The payload holds JSON paths and other
805    /// findings only: no field exists that could carry a secret value.
806    #[error("refusing to render an embedded or plaintext secret: {0}")]
807    EmbeddedSecret(Box<EmbeddedSecretDetails>),
808
809    /// A deploy host transport could not read (or later write) a node's
810    /// gateway data directory (22-01): a missing or unreadable directory, a
811    /// hostile live tree (symlink, special file, non-UTF-8 name), a size cap
812    /// breach or an ssh failure. Exit 4 — network class: the host could not
813    /// be reached or read. A failed read is NEVER an empty result, so a plan
814    /// cannot mistake an unreadable tree for deletions. `detail` names paths
815    /// and causes only, never file content.
816    #[error("transport failed ({operation}) on {node}: {detail}")]
817    TransportFailed {
818        /// What was being done, e.g. `read`.
819        operation: String,
820        /// The target node label, e.g. `nodes.master`.
821        node: String,
822        /// What went wrong; names paths and causes, never file content.
823        detail: String,
824    },
825
826    /// The on-box declared-names ledger exists but cannot be trusted (22-03,
827    /// D-07): unreadable, empty, over its size cap, not strict UTF-8 JSON, of
828    /// another schema version, naming another gateway or mode, unsorted or
829    /// duplicated, or listing a path plan must never delete. Exit 6 -
830    /// target_state class: the on-box state is wrong, not the caller's input.
831    /// Plan never falls back to an empty delete set. `problem` names fields
832    /// and listed paths only, never other file content.
833    #[error("declared-names ledger {location} is unusable: {problem}")]
834    DeployLedgerInvalid {
835        /// The ledger location relative to the node's data directory.
836        location: String,
837        /// What is wrong; names fields and listed paths, never file content.
838        problem: String,
839    },
840
841    /// The plan the caller reviewed is no longer the plan (23-02, DAPL-02):
842    /// `--plan-sha256` differs from the recomputed `plan_sha256`, or the
843    /// on-box pre-image changed under the scan lock. Exit 6 - target_state
844    /// class: the gateway moved since the review. Carries hashes only.
845    #[error("plan changed since review: expected plan_sha256 {expected}, now {actual} ({detail})")]
846    PlanStale {
847        /// The `plan_sha256` the caller reviewed.
848        expected: String,
849        /// The `plan_sha256` of the plan as it is now.
850        actual: String,
851        /// Where the mismatch was found, e.g. `review hash`; no file content.
852        detail: String,
853    },
854
855    /// A destructive or drifted plan was applied without its flag (23-02,
856    /// DAPL-02). Exit 2 - usage class: the caller must pass
857    /// `--allow-destructive` or `--allow-drift <reason>`. Carries unit keys
858    /// and counts only.
859    #[error("{}", destructive_change_refused_text(.gate, .keys, *.count))]
860    DestructiveChangeRefused {
861        /// Which gate refused: `destructive` or `drift`.
862        gate: String,
863        /// The first [`APPLY_LIST_SHOWN`] offending unit keys, byte-sorted.
864        keys: Vec<String>,
865        /// How many entries the gate refused in all.
866        count: usize,
867    },
868
869    /// A removal that cannot take effect (23-02, DAPL-01): the unit's `core`
870    /// copy shadows the mode collection, so deleting from the mode collection
871    /// changes nothing. No flag permits it. Exit 6.
872    #[error("{}", deploy_blocked_text(.keys))]
873    DeployBlocked {
874        /// The `shadows_removal` unit keys, byte-sorted.
875        keys: Vec<String>,
876    },
877
878    /// An apply would write, check or delete a unit whose live copy the
879    /// host's sftp server cannot read (quick-261029-ms3): a host path of 240
880    /// characters or more, so the live copy is unknown and must never be
881    /// taken as absent. Raised when an unreadable unit is declared or listed
882    /// in the declared-names ledger, on the master by the plan's gate, and on
883    /// the backup by the `backup_read` preflight (a unit the apply writes or
884    /// deletes that the backup cannot read, WR-02). No flag permits it. Exit
885    /// 6.
886    #[error("{}", deploy_unreadable_text(.node, .keys))]
887    DeployUnreadable {
888        /// The node whose host cannot read the units: `master` or `backup`.
889        node: String,
890        /// The unreadable unit keys that are declared or in the ledger,
891        /// byte-sorted.
892        keys: Vec<String>,
893    },
894
895    /// A deploy to a redundant pair (a target with `nodes.backup`) would
896    /// write, keep or restore a `tag-definition` or `tag-type-definition`
897    /// unit in the mode collection (debug deploy-tag-redundancy-sync).
898    /// Ignition 8.3.6 counts those resources of a mode collection in its
899    /// `config-resource` sync snapshot but leaves them out of every diff, and
900    /// syncs tags only from `core`, so the backup can never match the master
901    /// and stops syncing configuration. No flag permits it. Exit 6.
902    #[error("{}", deploy_pair_tags_refused_text(.mode, .keys))]
903    DeployPairTagsRefused {
904        /// The target's mode (the collection the units would sit in).
905        mode: String,
906        /// The tag unit keys, byte-sorted and unique.
907        keys: Vec<String>,
908    },
909
910    /// One or more apply preflight checks failed (23-02, DAPL-04). Exit 6.
911    /// Nothing was written to the gateway. The payload is BOXED so the large
912    /// list stays off every `Result<_, CoreError>`.
913    #[error("{}", deploy_preflight_failed_text(.failures))]
914    DeployPreflightFailed {
915        /// Every failed check, in the order the checks ran.
916        failures: Box<[PreflightFailure]>,
917    },
918
919    /// An interrupted deploy left its journal on the box (23-02, D-09). Exit
920    /// 6. The message, not the hint, carries the recovery command.
921    #[error(
922        "an interrupted deploy left the journal {location} for run {run_id}; \
923         recover it with: ign deploy rollback --run {run_id} --env {env}"
924    )]
925    DeployJournalPresent {
926        /// The interrupted run.
927        run_id: String,
928        /// The `--env` value the recovery command needs.
929        env: String,
930        /// The on-box journal location.
931        location: String,
932    },
933
934    /// Live files changed after the run wrote them, so a rollback would
935    /// overwrite someone's edit (23-02, DAPL-07). Exit 6. Carries paths and
936    /// counts only.
937    #[error("{}", deploy_live_changed_text(.run_id, .paths, *.count))]
938    DeployLiveChanged {
939        /// The run being rolled back.
940        run_id: String,
941        /// The first [`APPLY_LIST_SHOWN`] changed paths, byte-sorted.
942        paths: Vec<String>,
943        /// How many touched paths changed in all.
944        count: usize,
945    },
946
947    /// A run's record or snapshot cannot be trusted (23-02, D-04). Exit 6.
948    /// `problem` names fields and paths only, never file content.
949    #[error("deploy run {run_id} is unusable: {problem}")]
950    DeployRunInvalid {
951        /// The run id asked for.
952        run_id: String,
953        /// What is wrong.
954        problem: String,
955    },
956
957    /// A rollback could not restore the pre-apply files (23-02, D-12).
958    /// Exit 6. Boxed to keep `CoreError` under clippy's `result_large_err`.
959    #[error("{0}")]
960    RollbackFailed(Box<RollbackFailure>),
961
962    /// `deploy projects` refused a project the git module still manages
963    /// (25-07, DPRJ-04, D-11): the project's `.git` entry exists on the
964    /// gateway host. Exit 6 - target_state class: the gateway is not in a
965    /// state this deploy may touch. The tool never deletes a `.git`; an
966    /// administrator removes it per project. Carries the project name, the
967    /// location below the data directory and the kind (`directory`, `file`,
968    /// `symlink`, `other`) only.
969    #[error(
970        "project {project} is still managed by the git module: {location} is present ({kind}); an administrator removes it before this project is deployed"
971    )]
972    ProjectGitManaged {
973        /// The gateway project name.
974        project: String,
975        /// The `.git` path below the node's data directory.
976        location: String,
977        /// What the entry is.
978        kind: String,
979    },
980
981    /// `deploy projects` refused a project whose parent cannot be used
982    /// (25-10, DPRJ-02, D-05, K6): the parent named in the repo's
983    /// `project.json` is missing on the gateway, closes a cycle, or is not
984    /// inheritable. Exit 6 - target_state class: the gateway is not in a
985    /// state this deploy may build on. Carries names and the reason word
986    /// (`missing`, `cycle`, `not_inheritable`) only. The parent is only read.
987    #[error("project {project} cannot be deployed under parent {parent}: {reason}")]
988    ProjectParentInvalid {
989        /// The gateway project being deployed.
990        project: String,
991        /// The parent named in the repo's `project.json`.
992        parent: String,
993        /// `missing`, `cycle` or `not_inheritable`.
994        reason: String,
995    },
996
997    /// `deploy projects` refused an artifact that is not the one that was
998    /// reviewed (25-11, SC-1, D-01, V1): `--artifact-sha256` differs from the
999    /// canonical digest of the artifact the checkout builds. Exit 6 -
1000    /// target_state class: the checkout is not in the state the reviewer saw.
1001    /// The comparison runs after the local build and before any host or
1002    /// gateway contact, so nothing was uploaded. Carries the project name and
1003    /// the two hashes only.
1004    #[error(
1005        "artifact hash mismatch for project {project}: expected {expected}, the checkout builds {actual}; nothing was uploaded"
1006    )]
1007    ProjectHashMismatch {
1008        /// The gateway project name.
1009        project: String,
1010        /// The `--artifact-sha256` the caller reviewed.
1011        expected: String,
1012        /// The canonical digest of the artifact this checkout builds.
1013        actual: String,
1014    },
1015}
1016
1017/// Items listed inline by the Phase 23 refusal Displays; the rest collapse
1018/// into `and {m} more`.
1019pub const APPLY_LIST_SHOWN: usize = 10;
1020
1021/// One failed apply preflight check (23-02). `detail` carries no value from
1022/// the gateway, the host or the repo that could be a secret.
1023#[derive(Debug, Clone, PartialEq, Eq)]
1024pub struct PreflightFailure {
1025    /// The check id, e.g. `mode_master` or `run_dir_not_ignored`.
1026    pub check: String,
1027    /// What failed, value-free.
1028    pub detail: String,
1029}
1030
1031/// Payload of [`CoreError::RollbackFailed`] (23-02).
1032#[derive(Debug, Clone, PartialEq, Eq)]
1033pub struct RollbackFailure {
1034    /// The run being restored.
1035    pub run_id: String,
1036    /// The `--env` value the retry command needs.
1037    pub env: String,
1038    /// The local run directory holding the snapshot, when one was made.
1039    pub run_dir: Option<String>,
1040    /// The on-box journal path, left in place for a manual recovery.
1041    pub journal: String,
1042    /// Why the restore failed; names paths and causes, never file content.
1043    pub cause: String,
1044}
1045
1046impl RollbackFailure {
1047    /// The on-box directory holding the run's pre-images
1048    /// (`snapshots/<run>/`), next to the journal.
1049    pub fn snapshot_dir(&self) -> String {
1050        match self.journal.rsplit_once('/') {
1051            Some((dir, _)) => format!("{dir}/snapshots/{}/", self.run_id),
1052            None => format!("snapshots/{}/", self.run_id),
1053        }
1054    }
1055}
1056
1057impl std::fmt::Display for RollbackFailure {
1058    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1059        // The run directory's `snapshot/` is made only after a restore has
1060        // succeeded, so it is never offered here: the intact pre-images are
1061        // the on-box `snapshots/<run>/` directory and, when one was taken, the
1062        // `.gwbk`.
1063        write!(
1064            f,
1065            "rollback of run {run} failed ({cause}); the gateway files may be half restored. \
1066             Retry with: ign deploy rollback --run {run} --env {env} (the journal at {journal} \
1067             and the snapshot are still on the host). If the retry reports a lost or damaged \
1068             snapshot, restore the files from the .gwbk backup (when this run took one) or from \
1069             the on-box snapshot {snapshot}, then delete the journal by hand. \
1070             run directory: {run_dir}",
1071            run = self.run_id,
1072            cause = self.cause,
1073            env = self.env,
1074            journal = self.journal,
1075            snapshot = self.snapshot_dir(),
1076            run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1077        )
1078    }
1079}
1080
1081/// `a, b, c and {m} more`: `shown` capped at [`APPLY_LIST_SHOWN`], then the
1082/// remainder of `total`.
1083fn listed(shown: &[String], total: usize) -> String {
1084    let take = shown.len().min(APPLY_LIST_SHOWN);
1085    let mut out = shown[..take].join(", ");
1086    if total > take {
1087        out.push_str(&format!(" and {} more", total - take));
1088    }
1089    out
1090}
1091
1092fn destructive_change_refused_text(gate: &str, keys: &[String], count: usize) -> String {
1093    let (flag, verb) = match gate {
1094        "drift" => ("--allow-drift <reason>", "apply"),
1095        "downgrade" => ("--allow-downgrade", "deploy projects"),
1096        "first_deploy" => ("--allow-first-change <reason>", "deploy projects"),
1097        _ => ("--allow-destructive", "apply"),
1098    };
1099    format!(
1100        "{gate} change refused for {count} entr{}: {}; {verb} needs {flag}",
1101        if count == 1 { "y" } else { "ies" },
1102        listed(keys, count)
1103    )
1104}
1105
1106fn deploy_blocked_text(keys: &[String]) -> String {
1107    format!(
1108        "deploy blocked: {} unit(s) are removed from the declared tree but a core copy \
1109         shadows them, so deleting them from the mode collection cannot take effect: {}",
1110        keys.len(),
1111        listed(keys, keys.len())
1112    )
1113}
1114
1115fn deploy_unreadable_text(node: &str, keys: &[String]) -> String {
1116    format!(
1117        "deploy refused: {} unit(s) the deploy would write or delete have a live path the \
1118         {node} host's sftp server cannot read, so their live copy is unknown: {}; nothing was \
1119         written",
1120        keys.len(),
1121        listed(keys, keys.len())
1122    )
1123}
1124
1125fn deploy_pair_tags_refused_text(mode: &str, keys: &[String]) -> String {
1126    format!(
1127        "deploy refused: {} tag unit(s) would be written to, kept in or restored to the {mode} \
1128         collection of a redundant pair, which Ignition's redundancy sync cannot carry (the \
1129         backup stops syncing configuration): {}; nothing was written",
1130        keys.len(),
1131        listed(keys, keys.len())
1132    )
1133}
1134
1135fn deploy_preflight_failed_text(failures: &[PreflightFailure]) -> String {
1136    let mut out = format!("deploy preflight failed ({} check(s)):", failures.len());
1137    for failure in failures {
1138        out.push_str(&format!("\n  {}: {}", failure.check, failure.detail));
1139    }
1140    out
1141}
1142
1143fn deploy_live_changed_text(run_id: &str, paths: &[String], count: usize) -> String {
1144    format!(
1145        "live files changed since run {run_id} wrote them ({count}): {}; restoring the \
1146         snapshot would overwrite them",
1147        listed(paths, count)
1148    )
1149}
1150
1151/// Findings shown inline by the deploy error Displays; the rest collapse
1152/// into `and {m} more`.
1153pub const DEPLOY_FINDINGS_SHOWN: usize = 20;
1154
1155/// One defect in a declared deploy tree (21-03).
1156#[derive(Debug, Clone, PartialEq, Eq)]
1157pub struct DeployFinding {
1158    /// Collection-relative file the finding is about.
1159    pub file: String,
1160    /// 1-based line, when the defect has a source position.
1161    pub line: Option<usize>,
1162    /// 1-based column, when the defect has a source position.
1163    pub column: Option<usize>,
1164    /// What is wrong. Never contains a value read from the tree.
1165    pub message: String,
1166}
1167
1168impl std::fmt::Display for DeployFinding {
1169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1170        match (self.line, self.column) {
1171            (Some(line), Some(column)) => {
1172                write!(f, "{}:{line}:{column}: {}", self.file, self.message)
1173            }
1174            _ => write!(f, "{}: {}", self.file, self.message),
1175        }
1176    }
1177}
1178
1179/// Payload of [`CoreError::DeployTreeInvalid`], boxed to keep
1180/// `CoreError` under clippy's `result_large_err` threshold.
1181#[derive(Debug, Clone, PartialEq, Eq)]
1182pub struct DeployTreeInvalidDetails {
1183    /// Every finding, in discovery order.
1184    pub findings: Vec<DeployFinding>,
1185}
1186
1187impl std::fmt::Display for DeployTreeInvalidDetails {
1188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1189        if let [only] = self.findings.as_slice() {
1190            return write!(f, "{only}");
1191        }
1192        write!(f, "{} findings: ", self.findings.len())?;
1193        write_findings(f, &self.findings, DEPLOY_FINDINGS_SHOWN)
1194    }
1195}
1196
1197/// Write `findings` joined by `; `, at most `shown` of them, then
1198/// `; and {m} more` when truncated.
1199fn write_findings(
1200    f: &mut std::fmt::Formatter<'_>,
1201    findings: &[DeployFinding],
1202    shown: usize,
1203) -> std::fmt::Result {
1204    for (i, finding) in findings.iter().take(shown).enumerate() {
1205        if i > 0 {
1206            f.write_str("; ")?;
1207        }
1208        write!(f, "{finding}")?;
1209    }
1210    if findings.len() > shown {
1211        write!(f, "; and {} more", findings.len() - shown)?;
1212    }
1213    Ok(())
1214}
1215
1216/// Payload of [`CoreError::EmbeddedSecret`], boxed to keep `CoreError`
1217/// under clippy's `result_large_err` threshold. Carries locations only:
1218/// there is deliberately no field that can hold a secret value.
1219#[derive(Debug, Clone, PartialEq, Eq)]
1220pub struct EmbeddedSecretDetails {
1221    /// Each entry is `<collection-relative file>: <dotted json path>`.
1222    pub paths: Vec<String>,
1223    /// Non-secret tree findings discovered in the same render.
1224    pub other_findings: Vec<DeployFinding>,
1225}
1226
1227impl std::fmt::Display for EmbeddedSecretDetails {
1228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1229        write!(f, "embedded secret at {} path(s): ", self.paths.len())?;
1230        let shown_paths = self.paths.len().min(DEPLOY_FINDINGS_SHOWN);
1231        f.write_str(&self.paths[..shown_paths].join("; "))?;
1232        if self.paths.len() > shown_paths {
1233            write!(f, "; and {} more", self.paths.len() - shown_paths)?;
1234        }
1235        if !self.other_findings.is_empty() {
1236            write!(
1237                f,
1238                "; also {} other tree finding(s): ",
1239                self.other_findings.len()
1240            )?;
1241            // The combined list stays within DEPLOY_FINDINGS_SHOWN entries.
1242            let room = DEPLOY_FINDINGS_SHOWN.saturating_sub(shown_paths);
1243            write_findings(f, &self.other_findings, room)?;
1244        }
1245        Ok(())
1246    }
1247}
1248
1249/// Why a resource write's signature was refused
1250/// ([`CoreError::ResourceSignatureRejected`]).
1251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1252pub enum SignatureReason {
1253    /// The gateway refused a signature that no longer matches: the
1254    /// resource changed since it was read.
1255    Stale,
1256    /// No usable signature was supplied: either refused before any
1257    /// request because it was empty, or a gateway refusal that the live
1258    /// capture shows is distinguishable.
1259    Missing,
1260}
1261
1262impl std::fmt::Display for SignatureReason {
1263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1264        f.write_str(match self {
1265            Self::Stale => "stale",
1266            Self::Missing => "missing",
1267        })
1268    }
1269}
1270
1271/// Payload of [`CoreError::ModuleDigestMismatch`], boxed to keep
1272/// `CoreError` under clippy's `result_large_err` threshold.
1273#[derive(Debug, Clone, PartialEq, Eq)]
1274pub struct ModuleDigestMismatchDetails {
1275    /// The [`crate::module::ModuleSpec::id`] being fetched.
1276    pub module: String,
1277    /// The requested version string.
1278    pub version: String,
1279    /// The asset URL the mismatched bytes were downloaded from.
1280    pub url: String,
1281    /// The release's published sha256 digest.
1282    pub expected: String,
1283    /// The sha256 digest of the bytes actually downloaded.
1284    pub actual: String,
1285}
1286
1287/// Payload of [`CoreError::ModuleDigestChanged`], boxed to keep
1288/// `CoreError` under clippy's `result_large_err` threshold.
1289#[derive(Debug, Clone, PartialEq, Eq)]
1290pub struct ModuleDigestChangedDetails {
1291    /// The [`crate::module::ModuleSpec::id`] being fetched.
1292    pub module: String,
1293    /// The requested version string.
1294    pub version: String,
1295    /// The digest of the artifact already cached for this version.
1296    pub cached_digest: String,
1297    /// The digest the feed currently publishes for this version.
1298    pub upstream_digest: String,
1299    /// Path of the untouched, still-usable cached artifact.
1300    pub cached_path: String,
1301}
1302
1303impl std::fmt::Display for ModuleDigestMismatchDetails {
1304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1305        write!(
1306            f,
1307            "module {:?} version {:?} digest mismatch at {}: expected {}, got {}",
1308            self.module, self.version, self.url, self.expected, self.actual
1309        )
1310    }
1311}
1312
1313impl std::fmt::Display for ModuleDigestChangedDetails {
1314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1315        write!(
1316            f,
1317            "module {:?} version {:?} digest changed upstream: cached {}, \
1318             upstream now reports {} (cached artifact kept at {})",
1319            self.module, self.version, self.cached_digest, self.upstream_digest, self.cached_path
1320        )
1321    }
1322}
1323
1324impl CoreError {
1325    /// Stable machine slug — public contract, never respell.
1326    pub fn code(&self) -> &'static str {
1327        match self {
1328            Self::Internal(_) => "internal",
1329            Self::ConfirmationRequired { .. } => "confirmation_required",
1330            Self::ProfilingRiskNotAccepted { .. } => "profiling_risk_not_accepted",
1331            Self::InvalidImportFile { .. } => "invalid_import_file",
1332            Self::ImportDenied { .. } => "import_denied",
1333            Self::InvalidInput { .. } => "invalid_input",
1334            Self::ProfileNotFound { .. } => "profile_not_found",
1335            Self::NoActiveProfile => "no_active_profile",
1336            Self::SecretUnavailable { .. } => "secret_unavailable",
1337            Self::PasswordUnavailable { .. } => "password_unavailable",
1338            Self::ConfigInvalid { .. } => "config_invalid",
1339            Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
1340            Self::Network { .. } => "network_error",
1341            Self::Auth { .. } => "auth_rejected",
1342            Self::GatewayTooOld { .. } => "gateway_too_old",
1343            Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
1344            Self::GatewayRestarting { .. } => "gateway_restarting",
1345            Self::NotFound { .. } => "not_found",
1346            Self::ProjectExists { .. } => "project_exists",
1347            Self::ResourceBinary { .. } => "resource_binary",
1348            Self::TrialNotExpired { .. } => "trial_not_expired",
1349            Self::ProviderNotFound { .. } => "provider_not_found",
1350            Self::RoutesNotDeployed { .. } => "routes_not_deployed",
1351            Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
1352            Self::RouteVersionMismatch { .. } => "route_version_mismatch",
1353            Self::WebdevRouteError { .. } => "webdev_route_error",
1354            Self::TagCollision { .. } => "tag_collision",
1355            Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
1356            Self::SessionNotPrunable { .. } => "session_not_prunable",
1357            Self::Rig(_) => "rig_error",
1358            Self::EamNotController { .. } => "eam_not_controller",
1359            Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
1360            Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
1361            Self::EamTaskInFlight { .. } => "eam_task_in_flight",
1362            Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
1363            Self::LintToolAbsent => "lint_tool_absent",
1364            Self::NodeToolAbsent { .. } => "node_tool_absent",
1365            Self::GatewayClientError { .. } => "gateway_client_error",
1366            Self::BundleNotAvailable { .. } => "bundle_not_available",
1367            Self::ModuleFeedUnreachable { .. } => "module_feed_unreachable",
1368            Self::ModuleReleaseNotFound { .. } => "module_release_not_found",
1369            Self::ModuleDigestMismatch(..) => "module_digest_mismatch",
1370            Self::ModuleFeedUnusable { .. } => "module_feed_unusable",
1371            Self::ModuleDigestChanged(..) => "module_digest_changed",
1372            Self::ModuleNotRegistered { .. } => "module_not_registered",
1373            Self::ModuleUninstallDenied { .. } => "module_uninstall_denied",
1374            Self::ScanLockHeld { .. } => "scan_lock_held",
1375            Self::ResourceSignatureRejected { .. } => "resource_signature_rejected",
1376            Self::ResourceExists { .. } => "resource_exists",
1377            Self::ModeExists { .. } => "mode_exists",
1378            Self::DeployTreeInvalid(..) => "deploy_tree_invalid",
1379            Self::EmbeddedSecret(..) => "embedded_secret",
1380            Self::TransportFailed { .. } => "transport_failed",
1381            Self::DeployLedgerInvalid { .. } => "deploy_ledger_invalid",
1382            Self::PlanStale { .. } => "plan_stale",
1383            Self::DestructiveChangeRefused { .. } => "destructive_change_refused",
1384            Self::DeployBlocked { .. } => "deploy_blocked",
1385            Self::DeployUnreadable { .. } => "deploy_unreadable",
1386            Self::DeployPairTagsRefused { .. } => "deploy_pair_tags_refused",
1387            Self::DeployPreflightFailed { .. } => "deploy_preflight_failed",
1388            Self::DeployJournalPresent { .. } => "deploy_journal_present",
1389            Self::DeployLiveChanged { .. } => "deploy_live_changed",
1390            Self::DeployRunInvalid { .. } => "deploy_run_invalid",
1391            Self::RollbackFailed(..) => "rollback_failed",
1392            Self::ProjectGitManaged { .. } => "project_git_managed",
1393            Self::ProjectParentInvalid { .. } => "project_parent_invalid",
1394            Self::ProjectHashMismatch { .. } => "project_hash_mismatch",
1395        }
1396    }
1397
1398    /// The LOCKED exit-code mapping — the only place exit codes are decided.
1399    pub fn exit_code(&self) -> u8 {
1400        match self {
1401            Self::Internal(_) => 1,
1402            Self::ConfirmationRequired { .. }
1403            | Self::ProfilingRiskNotAccepted { .. }
1404            | Self::InvalidImportFile { .. }
1405            | Self::InvalidInput { .. }
1406            | Self::GatewayClientError { .. }
1407            | Self::DeployTreeInvalid(..)
1408            | Self::EmbeddedSecret(..)
1409            | Self::DestructiveChangeRefused { .. } => 2,
1410            Self::ProfileNotFound { .. }
1411            | Self::NoActiveProfile
1412            | Self::SecretUnavailable { .. }
1413            | Self::PasswordUnavailable { .. }
1414            | Self::ConfigInvalid { .. }
1415            | Self::PollIntervalTooSmall { .. }
1416            | Self::ModuleNotRegistered { .. } => 3,
1417            Self::Network { .. }
1418            | Self::ModuleFeedUnreachable { .. }
1419            | Self::TransportFailed { .. } => 4,
1420            Self::Auth { .. } => 5,
1421            Self::GatewayTooOld { .. }
1422            | Self::GatewayNotCommissioned { .. }
1423            | Self::GatewayRestarting { .. }
1424            | Self::NotFound { .. }
1425            | Self::ProjectExists { .. }
1426            | Self::ResourceBinary { .. }
1427            | Self::TrialNotExpired { .. }
1428            | Self::ProviderNotFound { .. }
1429            | Self::RoutesNotDeployed { .. }
1430            | Self::WebdevUnlicensed { .. }
1431            | Self::RouteVersionMismatch { .. }
1432            | Self::WebdevRouteError { .. }
1433            | Self::TagCollision { .. }
1434            | Self::AlarmJournalMissing { .. }
1435            | Self::SessionNotPrunable { .. }
1436            | Self::ImportDenied { .. }
1437            | Self::EamNotController { .. }
1438            | Self::ProviderRootUnsupported { .. }
1439            | Self::EamTaskTypeRefused { .. }
1440            | Self::EamTaskInFlight { .. }
1441            | Self::ScriptExecNotConfigured { .. }
1442            | Self::LintToolAbsent
1443            | Self::NodeToolAbsent { .. }
1444            | Self::BundleNotAvailable { .. }
1445            | Self::ModuleReleaseNotFound { .. }
1446            | Self::ModuleDigestMismatch(..)
1447            | Self::ModuleFeedUnusable { .. }
1448            | Self::ModuleDigestChanged(..)
1449            | Self::ModuleUninstallDenied { .. }
1450            | Self::ScanLockHeld { .. }
1451            | Self::ResourceSignatureRejected { .. }
1452            | Self::ResourceExists { .. }
1453            | Self::ModeExists { .. }
1454            | Self::DeployLedgerInvalid { .. }
1455            | Self::PlanStale { .. }
1456            | Self::DeployBlocked { .. }
1457            | Self::DeployUnreadable { .. }
1458            | Self::DeployPairTagsRefused { .. }
1459            | Self::DeployPreflightFailed { .. }
1460            | Self::DeployJournalPresent { .. }
1461            | Self::DeployLiveChanged { .. }
1462            | Self::DeployRunInvalid { .. }
1463            | Self::RollbackFailed(..)
1464            | Self::ProjectGitManaged { .. }
1465            | Self::ProjectParentInvalid { .. }
1466            | Self::ProjectHashMismatch { .. } => 6,
1467            Self::Rig(_) => 7,
1468        }
1469    }
1470
1471    /// Actionable next step (CORE-05). Every class carries one.
1472    pub fn hint(&self) -> Option<String> {
1473        match self {
1474            Self::Internal(_) => Some(
1475                "internal errors are bugs; re-run with -vv and report the \
1476                 diagnostics output"
1477                    .to_string(),
1478            ),
1479            Self::ConfirmationRequired { .. } => Some(
1480                "this operation is destructive; re-run with --yes or set \
1481                   IGNITION_YES=1"
1482                    .to_string(),
1483            ),
1484            Self::ProfilingRiskNotAccepted { .. } => Some(
1485                "JVM profiling ACTIVELY instruments the gateway: stack sampling \
1486                   adds CPU overhead proportional to thread count x frequency, \
1487                   and flight recordings buffer in JVM memory and write to \
1488                   gateway disk. On a PRODUCTION server this belongs inside an \
1489                   approved change window with a rollback plan — never as a \
1490                   casual probe. --yes deliberately does NOT satisfy this gate; \
1491                   re-run with --i-accept-the-risk after confirming that"
1492                    .to_string(),
1493            ),
1494            Self::InvalidImportFile { .. } => Some(
1495                "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
1496                   most 512 MB — pass a file produced by `ign project export` \
1497                   via --file (or `-` to pipe one on stdin)"
1498                    .to_string(),
1499            ),
1500            Self::ImportDenied { problem, .. } => Some(format!(
1501                "the gateway refused the import over a 200 answer — the problem \
1502                   text above is the gateway's own; `ign project export` of the \
1503                   current state is the honest baseline for hand-editing ({problem})"
1504            )),
1505            Self::InvalidInput { reason } => Some(
1506                if reason == TUI_TTY_REFUSAL_REASON {
1507                    // The ONE contextual InvalidInput hint (06-07): the
1508                    // TTY refusal's fix is terminal-related — the
1509                    // --file/stdin default is meaningless for a pipe.
1510                    "run `ign tui` in an interactive terminal (the cockpit \
1511                     needs a TTY on stdout — not a pipe or redirect)"
1512                } else if reason == TOP_TTY_REFUSAL_REASON {
1513                    // itop's sibling refusal: same terminal-contextual
1514                    // fix, naming the MONITOR's command (a hint naming
1515                    // `ign tui` would not fix an `ign top` run).
1516                    "run `ign top` in an interactive terminal (the monitor \
1517                     needs a TTY on stdout — not a pipe or redirect)"
1518                } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
1519                    // The loss-gate refusal (11-07): the message above already names
1520                    // every finding and ends with the actionable guidance — the hint
1521                    // restates it for envelope readers instead of the file-read
1522                    // default, which is meaningless here (the file WAS readable).
1523                    "the loss report above names what this import would drop or \
1524                     coerce — re-run with --yes to import anyway"
1525                } else if reason.starts_with("--plan-sha256 ")
1526                    || reason.starts_with("--allow-drift ")
1527                    || reason.starts_with("--artifact-sha256 ")
1528                    || reason.starts_with("--release ")
1529                    || reason.starts_with("--allow-first-change ")
1530                    || reason.starts_with("deploy projects needs --artifact-sha256")
1531                {
1532                    // The deploy apply and deploy projects gate flags (23-02,
1533                    // 25-11): the --file/stdin default is meaningless for a
1534                    // flag value.
1535                    "correct the flag value named above and rerun; no host or gateway \
1536                     was contacted"
1537                } else {
1538                    "fix the input source — a readable file path via --file, or `-` \
1539                     to pipe the content on stdin"
1540                }
1541                .to_string(),
1542            ),
1543            Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
1544                "no profiles configured yet; run `ign profile add` to create \
1545                     one"
1546                .to_string()
1547            } else {
1548                format!(
1549                    "known profiles: {}; run `ign profile add` to add another",
1550                    known.join(", ")
1551                )
1552            }),
1553            Self::NoActiveProfile => Some(
1554                "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
1555                 active with `ign profile use`"
1556                    .to_string(),
1557            ),
1558            Self::SecretUnavailable { profile } => Some(format!(
1559                "set IGNITION_TOKEN (or token_env in the profile), or store a \
1560                 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
1561            )),
1562            Self::PasswordUnavailable { .. } => Some(
1563                "ign adopt logs in with the gateway admin password — export \
1564                 IGNITION_PASSWORD (env-only, never a flag)"
1565                    .to_string(),
1566            ),
1567            Self::ConfigInvalid { .. } => Some(
1568                "verify the config file is valid TOML with [profiles.NAME] \
1569                 tables; `ign profile add` writes a known-good one"
1570                    .to_string(),
1571            ),
1572            Self::PollIntervalTooSmall { profile } => Some(format!(
1573                "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
1574                 or remove the key to use the default cadence"
1575            )),
1576            Self::Network {
1577                url,
1578                observation,
1579                ..
1580            } => Some(match observation {
1581                Some(observation) => format!(
1582                    "the gateway answered but no terminal state arrived before the deadline — \
1583                     the last observation was: {observation}; address that state, not the \
1584                     connection ({url})"
1585                ),
1586                None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
1587            }),
1588            Self::Auth { status, .. } => Some(match status {
1589                401 => {
1590                    // 401 = token not recognized — the #1 setup failure is
1591                    // a key-only header (verified: key-only → 401, full
1592                    // `name:key` → 200; Basic is dead on 8.3 /data).
1593                    "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"
1594                }
1595                403 => {
1596                    // 403 = recognized but under-permitted (verified
1597                    // semantics; see 02-RESEARCH Auth §4/§5).
1598                    "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"
1599                }
1600                _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
1601            }
1602            .to_string()),
1603            Self::GatewayTooOld { minimum, .. } => {
1604                Some(format!("upgrade the gateway to at least {minimum}"))
1605            }
1606            Self::GatewayNotCommissioned { .. } => Some(
1607                "open http://<host>:<port>/welcome in a browser and complete the \
1608                 commissioning wizard"
1609                    .to_string(),
1610            ),
1611            Self::GatewayRestarting { .. } => Some(
1612                "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
1613            ),
1614            Self::NotFound { .. } => Some(
1615                "check the id/path; a 404 JSON 'No route match' body can also mean \
1616                  a pre-8.3 gateway"
1617                    .to_string(),
1618            ),
1619            Self::ProjectExists { .. } => Some(
1620                "the default collision policy refuses to overwrite; re-run with \
1621                  --collision-policy overwrite to replace it — overwrite \
1622                  REPLACES the ENTIRE project (resources absent from the ZIP \
1623                  are deleted; merge is Designer-only)"
1624                    .to_string(),
1625            ),
1626            Self::ResourceBinary { .. } => Some(
1627                "resource content is binary — use `ign project export`/`import` \
1628                  for data.bin-class resources"
1629                    .to_string(),
1630            ),
1631            Self::TrialNotExpired { .. } => Some(
1632                "wait for the trial to expire (watch `ign rig trial status`), or \
1633                  run `ign rig reset --yes` for a completely fresh trial volume"
1634                    .to_string(),
1635            ),
1636            Self::RoutesNotDeployed { route, .. } => Some(
1637                // CONTENT-ADDRESSED off the route (the
1638                // TUI_TTY_REFUSAL_REASON / loss-gate prose-prefix
1639                // pattern, same file): the testing bundle is a
1640                // SEPARATE deploy with its own flag, so the generic
1641                // `ign webdev deploy` hint would send the caller to a
1642                // command that does not install it. No new variant,
1643                // no new slug — the exit tables are untouched.
1644                if route.starts_with(TESTING_ROUTE_PREFIX) {
1645                    "run `ign webdev deploy --with-testing` to install the gateway \
1646                      testing bundle into the project (or bootstrap it in one shot \
1647                      with `ign adopt --project NAME --testing`), then retry"
1648                } else {
1649                    "run `ign webdev deploy` to install the CLI's WebDev routes into \
1650                      the gateway, then retry"
1651                }
1652                .to_string(),
1653            ),
1654            Self::WebdevUnlicensed { .. } => Some(
1655                "license the gateway — the WebDev module answers 402 while \
1656                  unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
1657                  expired trial)"
1658                    .to_string(),
1659            ),
1660            Self::RouteVersionMismatch { deployed, expected, .. } => {
1661                // Direction decides the fix (roadmap criterion): an older
1662                // deployed route → redeploy from THIS binary; a NEWER
1663                // deployed route → this CLI is behind (the route bundle
1664                // travels with the binary). Same slug either way.
1665                let newer = semver::Version::parse(deployed)
1666                    .ok()
1667                    .zip(semver::Version::parse(expected).ok())
1668                    .is_some_and(|(deployed, expected)| deployed > expected);
1669                Some(if newer {
1670                    "the deployed routes are NEWER than this CLI — update ign \
1671                      (the route bundle travels with the binary)"
1672                        .to_string()
1673                } else {
1674                    "run `ign webdev deploy` to redeploy the route version \
1675                      this CLI expects"
1676                        .to_string()
1677                })
1678            }
1679            Self::ProviderNotFound { .. } => Some(
1680                "check the provider name; `ign tags provider list` shows the \
1681                  gateway's tag providers"
1682                    .to_string(),
1683            ),
1684            Self::TagCollision { .. } => Some(
1685                "re-run with --collision-policy overwrite to replace the \
1686                  existing tags (destructive: requires --yes)"
1687                    .to_string(),
1688            ),
1689            Self::AlarmJournalMissing { .. } => Some(
1690                "alarm history needs a journal profile — provision a database \
1691                  connection + alarm-journal profile on the gateway (and point \
1692                  the general-alarm-settings singleton at it), then retry; see \
1693                  the README 'Alarm history' section"
1694                    .to_string(),
1695            ),
1696            Self::SessionNotPrunable { .. } => Some(
1697                "close the Designer first — prune removes stale entries only".to_string(),
1698            ),
1699            Self::WebdevRouteError { code, .. } => Some(if code == "secret_required" || code == "secret_mismatch" {
1700                "the scriptExec route is secret-gated — deploy it with `ign \
1701                  webdev deploy --with-script-exec` (the secret is generated \
1702                  and stored in the profile config at 0600); a mismatch means \
1703                  the route was deployed with a different secret: redeploy or \
1704                  pass --rotate-secret"
1705                    .to_string()
1706            } else {
1707                "the deployed route refused the action — the code and message \
1708                  are the route's stable contract; `ign webdev status` \
1709                  diagnoses the deployment"
1710                    .to_string()
1711            }),
1712            Self::EamNotController { .. } => Some(
1713                "flip the gateway's EAM role: config-resource PUT on \
1714                   com.inductiveautomation.eam/module-settings with \
1715                   installMode \"Controller\" (array body carrying the current \
1716                   signature) — a manual gateway-role decision this CLI \
1717                   deliberately does not automate; see the README 'EAM tasks' \
1718                   section"
1719                    .to_string(),
1720            ),
1721            Self::ProviderRootUnsupported { .. } => Some(
1722                "target a subtree path like [provider]folder — provider-ROOT \
1723                   forms ([default] alone, or a bare provider name) need an \
1724                   RPC context WebDev threads don't carry (8.3.3); subtree \
1725                   paths are the supported form"
1726                    .to_string(),
1727            ),
1728            Self::EamTaskTypeRefused { .. } => Some(
1729                "restore/install/upgrade tasks dispatch fleet-wide (every \
1730                   agent target); run them from the Ignition EAM console — \
1731                   EXT-03 (v2) will scope them into the CLI"
1732                    .to_string(),
1733            ),
1734            Self::EamTaskInFlight { .. } => Some(
1735                "complete or delete the leftover '(forced)' run from the EAM \
1736                   console — no ign verb deletes runs; the slot frees once the \
1737                   run is resolved"
1738                    .to_string(),
1739            ),
1740            Self::ScriptExecNotConfigured { .. } => Some(
1741                "run `ign webdev deploy --with-script-exec` to deploy the route and \
1742                  generate + persist its secret (the deploy flag IS the opt-in — \
1743                  `ign script run` has no --yes by design)"
1744                    .to_string(),
1745            ),
1746            Self::LintToolAbsent => Some(
1747                "install the linter: `uv tool install ignition-lint-toolkit` \
1748                 (or `pip install ignition-lint-toolkit`) — \
1749                 github.com/TheThoughtagen/ignition-lint; then re-run with \
1750                 ignition-lint on PATH"
1751                    .to_string(),
1752            ),
1753            // The SAME string `ign e2e doctor`'s node row hands out —
1754            // a user who hits this refusal and then runs the doctor
1755            // must not be given two wordings of one instruction.
1756            Self::NodeToolAbsent { .. } => {
1757                Some(crate::actions::e2e::NODE_INSTALL_HINT.to_string())
1758            }
1759            Self::GatewayClientError { .. } => Some(
1760                "the gateway rejected this request — the body above is the \
1761                 gateway's own answer; fix the path/method/body, or use a \
1762                 curated `ign` command when one exists"
1763                    .to_string(),
1764            ),
1765            Self::BundleNotAvailable { .. } => Some(
1766                "generate a fresh bundle with `ign diagnostics bundle generate`, \
1767                 then wait again — the gateway reports no current bundle and \
1768                 polling cannot produce one"
1769                    .to_string(),
1770            ),
1771            Self::ModuleFeedUnreachable { .. } => Some(
1772                "check network reachability to the module release feed — an \
1773                 already-cached version works fully offline, so this only \
1774                 blocks a first-time fetch of a new version"
1775                    .to_string(),
1776            ),
1777            Self::ModuleReleaseNotFound {
1778                module, version, ..
1779            } => Some(format!(
1780                "check that {module} version {version} exists as a release upstream \
1781                 (the tag and asset name must match exactly) — this CLI never falls \
1782                 back to \"latest\""
1783            )),
1784            Self::ModuleDigestMismatch(d) => Some(format!(
1785                "the downloaded bytes for {} version {} do not match \
1786                 the feed's published digest — do not trust this artifact; nothing \
1787                 was cached, so re-running the fetch tries again from scratch",
1788                d.module,
1789                d.version
1790            )),
1791            Self::ModuleFeedUnusable { .. } => Some(
1792                "the module release feed answered but was not usable — if the \
1793                 detail names a rate limit, wait and retry; otherwise the feed's \
1794                 response itself needs investigation"
1795                    .to_string(),
1796            ),
1797            Self::ModuleDigestChanged(d) => Some(format!(
1798                "upstream re-released this version with different bytes — the cached \
1799                 artifact at {} is still there and usable offline; re-run with \
1800                 FetchPolicy::AcceptUpstreamChange to deliberately accept and verify the \
1801                 new bytes",
1802                d.cached_path
1803            )),
1804            Self::Rig(_) => Some(
1805                "check Docker is running and inspect the rig containers \
1806                 (docker ps)"
1807                    .to_string(),
1808            ),
1809            Self::ModuleNotRegistered { known, .. } => Some(if known.is_empty() {
1810                "no modules are registered in this build of ign".to_string()
1811            } else {
1812                format!(
1813                    "known module ids: {} — check the id passed to --with-module \
1814                     or declared under [rigs.NAME.modules.*]",
1815                    known.join(", ")
1816                )
1817            }),
1818            Self::ModuleUninstallDenied { .. } => Some(
1819                "the gateway refuses to uninstall a module whose .modl is still \
1820                 mounted — remove it from [rigs.NAME.modules], run `ign rig up` \
1821                 so the mount goes, then retry; a successful uninstall is NOT \
1822                 undone by re-declaring the module — recovery today means \
1823                 `ign rig reset --yes`, which destroys the rig's data volume"
1824                    .to_string(),
1825            ),
1826            Self::ScanLockHeld { .. } => Some(
1827                "the scan-lock is released when the holder's scan runs or its \
1828                 holdTimeout expires — wait for that, then retry"
1829                    .to_string(),
1830            ),
1831            Self::ResourceSignatureRejected { .. } => Some(
1832                "re-read the resource for its current signature and retry — \
1833                 signatures change on every write"
1834                    .to_string(),
1835            ),
1836            Self::ResourceExists { .. } => Some(
1837                "a resource of that name is already present — read it for its \
1838                 current signature and update it, or choose another name"
1839                    .to_string(),
1840            ),
1841            Self::ModeExists { .. } => Some(
1842                "that deployment mode is already defined — list the modes and \
1843                 skip the create"
1844                    .to_string(),
1845            ),
1846            Self::DeployTreeInvalid(..) => Some(
1847                "fix each file named above in the declared gateway tree; render \
1848                 refuses until every finding is resolved"
1849                    .to_string(),
1850            ),
1851            Self::EmbeddedSecret(..) => Some(
1852                "store the value in the gateway's secret provider and reference it \
1853                 as {\"type\":\"Referenced\",\"data\":{\"providerName\":\"…\",\
1854                 \"secretName\":\"…\"}}; never commit an Embedded or plaintext secret"
1855                    .to_string(),
1856            ),
1857            Self::TransportFailed { .. } => Some(
1858                "check the node's host access in targets/<env>.yaml: for `local`, that \
1859                 data_dir exists and is readable; for `ssh`, the pinned host key, the \
1860                 key and reachability. Nothing was read as absent, so no plan was made"
1861                    .to_string(),
1862            ),
1863            Self::DeployLedgerInvalid { .. } => Some(
1864                "the delete set is never assumed empty, so no plan was made; inspect or \
1865                 restore the ledger file named above (only an apply writes it) and rerun"
1866                    .to_string(),
1867            ),
1868            Self::PlanStale { detail, .. } if detail.starts_with("live project") => Some(
1869                "the live project changed after the plan was drawn; run the check again, \
1870                 review what it reports and rerun the deploy, nothing was imported"
1871                    .to_string(),
1872            ),
1873            Self::PlanStale { .. } => Some(
1874                "the plan changed after it was reviewed; review the new plan and rerun \
1875                 with its plan_sha256, nothing was written"
1876                    .to_string(),
1877            ),
1878            Self::DestructiveChangeRefused { gate, .. } => Some(match gate.as_str() {
1879                "drift" => "live files differ from both the base render and the declared tree; \
1880                     review the drift, then rerun with --allow-drift and a reason of at most \
1881                     500 characters"
1882                    .to_string(),
1883                "downgrade" => "the gateway last received a newer release of this project; \
1884                     deploy the newer release, or rerun with --allow-downgrade when the older \
1885                     release must replace it"
1886                    .to_string(),
1887                "first_deploy" => "this is the first deploy of the project by this tool and it \
1888                     would change what the gateway runs; release what the gateway runs first, \
1889                     or rerun with --allow-first-change and a reason"
1890                    .to_string(),
1891                _ => "review the destructive entries named above, then rerun with \
1892                     --allow-destructive and the plan_sha256 you reviewed"
1893                    .to_string(),
1894            }),
1895            Self::DeployBlocked { .. } => Some(
1896                "a core copy still shadows each unit named above, so no flag permits the \
1897                 removal; keep the unit in the declared tree, or remove the core copy on \
1898                 the gateway first"
1899                    .to_string(),
1900            ),
1901            Self::DeployUnreadable { .. } => Some(
1902                "the live path of each unit named above is too long for the host's sftp \
1903                 server; shorten it on the gateway (rename the folder), or, when the gateway \
1904                 owns the provider, list the provider in exclude_tag_providers and stop \
1905                 declaring it; nothing was written"
1906                    .to_string(),
1907            ),
1908            Self::DeployPairTagsRefused { .. } => Some(
1909                "tag units cannot be deployed to a redundant pair's mode collection; stop \
1910                 declaring the units named above for this target (remove them from the \
1911                 declared tree), so that an apply deletes the copy the ledger lists; remove \
1912                 by hand from the master's mode collection any file of theirs the ledger \
1913                 does not list; keep those tags in the gateway's core collection, which \
1914                 syncs to the backup; nothing was written"
1915                    .to_string(),
1916            ),
1917            Self::DeployPreflightFailed { .. } => Some(
1918                "fix each failed check named above and rerun; no gateway file was written"
1919                    .to_string(),
1920            ),
1921            Self::DeployJournalPresent { .. } => Some(
1922                "an earlier deploy was interrupted and its journal is still on the box; run \
1923                 the recovery command in the message, then rerun; plan and apply refuse \
1924                 until the journal is cleared"
1925                    .to_string(),
1926            ),
1927            Self::DeployLiveChanged { .. } => Some(
1928                "someone changed the files named above after the run wrote them; review \
1929                 those changes, then rerun the rollback with --discard-live-changes to \
1930                 restore the snapshot over them"
1931                    .to_string(),
1932            ),
1933            Self::DeployRunInvalid { .. } => Some(
1934                "the run directory or the on-box record is missing or damaged; restore the \
1935                 files from the .gwbk backup or the run-directory snapshot instead"
1936                    .to_string(),
1937            ),
1938            Self::RollbackFailed(..) => Some(
1939                "do not apply again until the files are restored: run the retry command in \
1940                 the message first, because the journal and the snapshot are usually intact; \
1941                 only if it reports a lost or damaged snapshot restore from the .gwbk backup \
1942                 or the on-box snapshot named there, and then delete the journal at the path \
1943                 in the message by hand"
1944                    .to_string(),
1945            ),
1946            Self::ProjectGitManaged { .. } => Some(
1947                "follow the README cutover runbook: remove this project's .git on the \
1948                 gateway (the deploy tool never deletes it), then run the check again"
1949                    .to_string(),
1950            ),
1951            Self::ProjectParentInvalid { reason, .. } => Some(
1952                match reason.as_str() {
1953                    "missing" => "deploy the parent project from its own repo first, then run \
1954                                  the check again",
1955                    "cycle" => "fix the parent in project.json so the inheritance has no cycle",
1956                    _ => "the parent is not inheritable; fix the parent in project.json or \
1957                          make the parent inheritable",
1958                }
1959                .to_string(),
1960            ),
1961            Self::ProjectHashMismatch { .. } => Some(
1962                "the checkout is not the reviewed one; rerun the check on this commit and \
1963                 pass its artifact_sha256"
1964                    .to_string(),
1965            ),
1966        }
1967    }
1968
1969    /// URL/path of the request involved, when one was — populated for the
1970    /// network, auth, and target-state classes (CORE-05).
1971    pub fn endpoint(&self) -> Option<String> {
1972        match self {
1973            Self::Network { url, .. } => Some(url.clone()),
1974            Self::Auth { endpoint, .. } => endpoint.clone(),
1975            Self::GatewayTooOld { endpoint, .. }
1976            | Self::GatewayNotCommissioned { endpoint }
1977            | Self::GatewayRestarting { endpoint }
1978            | Self::NotFound { endpoint }
1979            | Self::ProjectExists { endpoint, .. }
1980            | Self::ResourceBinary { endpoint, .. }
1981            | Self::TrialNotExpired { endpoint, .. }
1982            | Self::ProviderNotFound { endpoint, .. }
1983            | Self::RoutesNotDeployed { endpoint, .. }
1984            | Self::WebdevUnlicensed { endpoint }
1985            | Self::RouteVersionMismatch { endpoint, .. }
1986            | Self::WebdevRouteError { endpoint, .. }
1987            | Self::TagCollision { endpoint, .. }
1988            | Self::AlarmJournalMissing { endpoint }
1989            | Self::SessionNotPrunable { endpoint, .. }
1990            | Self::ImportDenied { endpoint, .. }
1991            | Self::EamNotController { endpoint }
1992            | Self::ProviderRootUnsupported { endpoint }
1993            | Self::EamTaskInFlight { endpoint, .. }
1994            | Self::ModuleUninstallDenied { endpoint, .. }
1995            | Self::ScanLockHeld { endpoint, .. }
1996            | Self::ResourceSignatureRejected { endpoint, .. }
1997            | Self::ResourceExists { endpoint, .. }
1998            | Self::ModeExists { endpoint, .. } => endpoint.clone(),
1999            Self::ModuleFeedUnreachable { url, .. }
2000            | Self::ModuleReleaseNotFound { url, .. }
2001            | Self::ModuleFeedUnusable { url, .. } => Some(url.clone()),
2002            // Boxed payload, so it cannot share the or-pattern above.
2003            Self::ModuleDigestMismatch(d) => Some(d.url.clone()),
2004            _ => None,
2005        }
2006    }
2007
2008    /// The deploy slugs (21-03), in the order the README row lists them.
2009    pub const DEPLOY_SLUGS: &'static [&'static str] = &[
2010        "deploy_tree_invalid",
2011        "embedded_secret",
2012        "transport_failed",
2013        "deploy_ledger_invalid",
2014        "plan_stale",
2015        "destructive_change_refused",
2016        "deploy_blocked",
2017        "deploy_preflight_failed",
2018        "deploy_journal_present",
2019        "deploy_live_changed",
2020        "deploy_run_invalid",
2021        "rollback_failed",
2022        "project_git_managed",
2023        "project_parent_invalid",
2024        "project_hash_mismatch",
2025        "deploy_unreadable",
2026        "deploy_pair_tags_refused",
2027    ];
2028
2029    /// A single positioned (or file-only, when `line`/`column` are
2030    /// `None`) tree finding as a [`Self::DeployTreeInvalid`].
2031    pub fn deploy_tree_invalid(
2032        file: impl Into<String>,
2033        line: Option<usize>,
2034        column: Option<usize>,
2035        message: impl Into<String>,
2036    ) -> Self {
2037        Self::deploy_findings(vec![DeployFinding {
2038            file: file.into(),
2039            line,
2040            column,
2041            message: message.into(),
2042        }])
2043    }
2044
2045    /// Several tree findings as one [`Self::DeployTreeInvalid`]. An empty
2046    /// vector is a programming error: debug builds assert, release builds
2047    /// still return one generic finding so the error is never empty.
2048    pub fn deploy_findings(findings: Vec<DeployFinding>) -> Self {
2049        debug_assert!(!findings.is_empty(), "deploy_findings needs a finding");
2050        let findings = if findings.is_empty() {
2051            vec![DeployFinding {
2052                file: String::new(),
2053                line: None,
2054                column: None,
2055                message: "the declared tree is invalid".to_string(),
2056            }]
2057        } else {
2058            findings
2059        };
2060        Self::DeployTreeInvalid(Box::new(DeployTreeInvalidDetails { findings }))
2061    }
2062
2063    /// An embedded or plaintext secret at each of `paths` (locations
2064    /// only, never values), plus any non-secret findings from the same
2065    /// render.
2066    pub fn embedded_secret(paths: Vec<String>, other_findings: Vec<DeployFinding>) -> Self {
2067        Self::EmbeddedSecret(Box::new(EmbeddedSecretDetails {
2068            paths,
2069            other_findings,
2070        }))
2071    }
2072
2073    /// The `ign tui` TTY refusal (06-07): InvalidInput carrying the
2074    /// ONE reason whose hint is terminal-contextual — see
2075    /// [`TUI_TTY_REFUSAL_REASON`]. Same slug, same exit 2 (frozen
2076    /// taxonomy; only the hint differs).
2077    pub fn tui_tty_refusal() -> Self {
2078        Self::InvalidInput {
2079            reason: TUI_TTY_REFUSAL_REASON.to_string(),
2080        }
2081    }
2082
2083    /// The `ign top` TTY refusal (itop): the tui refusal's sibling at
2084    /// one command over — same slug, same exit 2, the hint naming the
2085    /// monitor's command. See [`TOP_TTY_REFUSAL_REASON`].
2086    pub fn top_tty_refusal() -> Self {
2087        Self::InvalidInput {
2088            reason: TOP_TTY_REFUSAL_REASON.to_string(),
2089        }
2090    }
2091
2092    /// Build the LOCKED failure envelope for this error (field order is part
2093    /// of the golden contract: `ok`, `profile`, `error` then `code`,
2094    /// `message`, `endpoint`, `hint`).
2095    pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
2096        ErrorEnvelope {
2097            ok: false,
2098            profile,
2099            error: ErrorBody {
2100                code: self.code(),
2101                message: self.to_string(),
2102                endpoint: self.endpoint(),
2103                hint: self.hint(),
2104            },
2105        }
2106    }
2107}
2108
2109/// LOCKED failure envelope shape: exactly the top-level fields `ok`,
2110/// `profile`, `error` — changing the set is a breaking change for agents.
2111#[derive(Debug, Serialize)]
2112pub struct ErrorEnvelope<'a> {
2113    /// Always `false` in this envelope.
2114    pub ok: bool,
2115    /// Active profile echoed in every output (CORE-01); `None` until config
2116    /// resolution lands.
2117    pub profile: Option<&'a str>,
2118    /// The typed error body.
2119    pub error: ErrorBody,
2120}
2121
2122/// LOCKED error body: `code` (stable slug), `message` (human-readable),
2123/// `endpoint` (when a request was involved), `hint` (actionable next step).
2124#[derive(Debug, Serialize)]
2125pub struct ErrorBody {
2126    /// Stable slug from [`CoreError::code`] — never respelled.
2127    pub code: &'static str,
2128    /// Human-readable description.
2129    pub message: String,
2130    /// URL/path when a request was involved.
2131    pub endpoint: Option<String>,
2132    /// Actionable next step.
2133    pub hint: Option<String>,
2134}
2135
2136#[cfg(test)]
2137mod tests {
2138    use super::{
2139        CoreError, DeployFinding, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
2140        GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX,
2141        ModuleDigestChangedDetails, ModuleDigestMismatchDetails, PreflightFailure, RollbackFailure,
2142        SignatureReason, TESTING_ROUTE_PREFIX, truncate_api_body,
2143    };
2144
2145    /// Build a real `reqwest::Error` for the Network variant: a request to
2146    /// an unroutable loopback port fails at connect time (instant refusal —
2147    /// `reqwest::Error` has no public constructor).
2148    fn network_error() -> CoreError {
2149        // reqwest is built with `rustls-no-provider`, so a raw
2150        // `reqwest::get` panics unless the process-wide provider is
2151        // installed. In a full lib-test run some other test happens to
2152        // install it first; running the error tests ALONE (which the
2153        // exit-table verify command does) hit the panic. The install is
2154        // idempotent and documented for exactly this use.
2155        crate::client::install_crypto_provider();
2156        let rt = tokio::runtime::Builder::new_current_thread()
2157            .enable_all()
2158            .build()
2159            .expect("test runtime");
2160        let url = "http://127.0.0.1:1";
2161        let source = rt
2162            .block_on(reqwest::get(url))
2163            .expect_err("request to an unroutable port must fail");
2164        CoreError::Network {
2165            url: url.to_string(),
2166            source: Some(source),
2167            observation: None,
2168        }
2169    }
2170
2171    /// The [`CoreError::ModuleFeedUnreachable`] twin of [`network_error`]
2172    /// — a real `reqwest::Error` from an unroutable loopback port, for
2173    /// the offline-module-feed exit-4 case.
2174    fn module_feed_unreachable_error() -> CoreError {
2175        crate::client::install_crypto_provider();
2176        let rt = tokio::runtime::Builder::new_current_thread()
2177            .enable_all()
2178            .build()
2179            .expect("test runtime");
2180        let url = "http://127.0.0.1:1";
2181        let source = rt
2182            .block_on(reqwest::get(url))
2183            .expect_err("request to an unroutable port must fail");
2184        CoreError::ModuleFeedUnreachable {
2185            url: url.to_string(),
2186            source: Some(source),
2187        }
2188    }
2189
2190    /// Pitfall-5 guard: the FULL 1–7 taxonomy enumerated on day one so no
2191    /// later phase can silently renumber it or respell a slug. The slugs are
2192    /// asserted against literals — that IS the stability contract.
2193    #[test]
2194    fn exit_code_mapping_enumerated() {
2195        let cases: Vec<(CoreError, u8, &'static str)> = vec![
2196            (CoreError::Internal("boom".into()), 1, "internal"),
2197            (
2198                CoreError::ConfirmationRequired {
2199                    operation: "project download".into(),
2200                },
2201                2,
2202                "confirmation_required",
2203            ),
2204            (
2205                CoreError::ProfilingRiskNotAccepted {
2206                    operation: "jvm sample".into(),
2207                },
2208                2,
2209                "profiling_risk_not_accepted",
2210            ),
2211            (
2212                CoreError::InvalidImportFile {
2213                    reason: "missing ZIP magic".into(),
2214                },
2215                2,
2216                "invalid_import_file",
2217            ),
2218            (
2219                CoreError::InvalidInput {
2220                    reason: "cannot read put.json".into(),
2221                },
2222                2,
2223                "invalid_input",
2224            ),
2225            (
2226                CoreError::ProfileNotFound {
2227                    name: "nope".into(),
2228                    known: vec!["dev".into()],
2229                },
2230                3,
2231                "profile_not_found",
2232            ),
2233            (CoreError::NoActiveProfile, 3, "no_active_profile"),
2234            (
2235                CoreError::SecretUnavailable {
2236                    profile: "dev".into(),
2237                },
2238                3,
2239                "secret_unavailable",
2240            ),
2241            (
2242                CoreError::PasswordUnavailable {
2243                    profile: "dev".into(),
2244                },
2245                3,
2246                "password_unavailable",
2247            ),
2248            (
2249                CoreError::ConfigInvalid {
2250                    reason: "bad toml".into(),
2251                },
2252                3,
2253                "config_invalid",
2254            ),
2255            (
2256                CoreError::PollIntervalTooSmall {
2257                    profile: "dev".into(),
2258                },
2259                3,
2260                "poll_interval_too_small",
2261            ),
2262            (network_error(), 4, "network_error"),
2263            (
2264                CoreError::Auth {
2265                    status: 401,
2266                    endpoint: None,
2267                },
2268                5,
2269                "auth_rejected",
2270            ),
2271            (
2272                CoreError::GatewayTooOld {
2273                    found: "8.1.0".into(),
2274                    minimum: "8.3.1".into(),
2275                    endpoint: None,
2276                },
2277                6,
2278                "gateway_too_old",
2279            ),
2280            (
2281                CoreError::GatewayNotCommissioned {
2282                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2283                },
2284                6,
2285                "gateway_not_commissioned",
2286            ),
2287            (
2288                CoreError::GatewayRestarting {
2289                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2290                },
2291                6,
2292                "gateway_restarting",
2293            ),
2294            (
2295                CoreError::NotFound {
2296                    endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
2297                },
2298                6,
2299                "not_found",
2300            ),
2301            (
2302                CoreError::ProjectExists {
2303                    name: "PlantFloor".into(),
2304                    endpoint: None,
2305                },
2306                6,
2307                "project_exists",
2308            ),
2309            (
2310                CoreError::ResourceBinary {
2311                    path: "com.x/perspective/session-permissions".into(),
2312                    endpoint: None,
2313                },
2314                6,
2315                "resource_binary",
2316            ),
2317            (
2318                CoreError::TrialNotExpired {
2319                    remaining_s: 6590,
2320                    endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
2321                },
2322                6,
2323                "trial_not_expired",
2324            ),
2325            (
2326                CoreError::ProviderNotFound {
2327                    name: "nope".into(),
2328                    endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
2329                },
2330                6,
2331                "provider_not_found",
2332            ),
2333            (
2334                CoreError::RoutesNotDeployed {
2335                    project: "ign-cli".into(),
2336                    route: "tags".into(),
2337                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2338                },
2339                6,
2340                "routes_not_deployed",
2341            ),
2342            (
2343                CoreError::WebdevUnlicensed {
2344                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2345                },
2346                6,
2347                "webdev_unlicensed",
2348            ),
2349            (
2350                CoreError::RouteVersionMismatch {
2351                    route: "tags".into(),
2352                    deployed: "0.9.0".into(),
2353                    expected: "1.0.0".into(),
2354                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2355                },
2356                6,
2357                "route_version_mismatch",
2358            ),
2359            (
2360                CoreError::WebdevRouteError {
2361                    code: "route_error".into(),
2362                    message: "boom".into(),
2363                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2364                },
2365                6,
2366                "webdev_route_error",
2367            ),
2368            (
2369                CoreError::TagCollision {
2370                    provider: "p5import".into(),
2371                    names: vec!["T1".into(), "P5".into()],
2372                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2373                },
2374                6,
2375                "tag_collision",
2376            ),
2377            (
2378                CoreError::AlarmJournalMissing {
2379                    endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
2380                },
2381                6,
2382                "alarm_journal_missing",
2383            ),
2384            (
2385                CoreError::ImportDenied {
2386                    project: "PlantFloor".into(),
2387                    problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
2388                    endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
2389                },
2390                6,
2391                "import_denied",
2392            ),
2393            (
2394                CoreError::SessionNotPrunable {
2395                    id: "d-live-1".into(),
2396                    endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
2397                },
2398                6,
2399                "session_not_prunable",
2400            ),
2401            (
2402                CoreError::EamNotController {
2403                    endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
2404                },
2405                6,
2406                "eam_not_controller",
2407            ),
2408            (
2409                CoreError::ProviderRootUnsupported {
2410                    endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
2411                },
2412                6,
2413                "provider_root_unsupported",
2414            ),
2415            (
2416                CoreError::EamTaskTypeRefused {
2417                    task_type: "eam_remoteUpgrade".into(),
2418                },
2419                6,
2420                "eam_task_type_refused",
2421            ),
2422            (
2423                CoreError::EamTaskInFlight {
2424                    task: "cli-research-backup".into(),
2425                    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(),
2426                    endpoint: Some(
2427                        "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
2428                            .into(),
2429                    ),
2430                },
2431                6,
2432                "eam_task_in_flight",
2433            ),
2434            (
2435                CoreError::ScriptExecNotConfigured {
2436                    profile: "dev".into(),
2437                },
2438                6,
2439                "script_exec_not_configured",
2440            ),
2441            (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
2442            (
2443                CoreError::NodeToolAbsent {
2444                    tool: "node".into(),
2445                },
2446                6,
2447                "node_tool_absent",
2448            ),
2449            (
2450                CoreError::BundleNotAvailable {
2451                    state: "Invalid".into(),
2452                },
2453                6,
2454                "bundle_not_available",
2455            ),
2456            (
2457                CoreError::GatewayClientError {
2458                    status: 400,
2459                    endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
2460                    body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
2461                },
2462                2,
2463                "gateway_client_error",
2464            ),
2465            (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
2466            (
2467                module_feed_unreachable_error(),
2468                4,
2469                "module_feed_unreachable",
2470            ),
2471            (
2472                CoreError::ModuleReleaseNotFound {
2473                    module: "git".into(),
2474                    version: "99.99.99".into(),
2475                    url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v99.99.99".into(),
2476                    detail: "no release exists for tag \"v99.99.99\"".into(),
2477                },
2478                6,
2479                "module_release_not_found",
2480            ),
2481            (
2482                CoreError::ModuleDigestMismatch(Box::new(ModuleDigestMismatchDetails {
2483                    module: "git".into(),
2484                    version: "2.3.4".into(),
2485                    url: "https://release-assets.githubusercontent.com/Git-2.3.4-signed.modl".into(),
2486                    expected: "b".repeat(64),
2487                    actual: "c".repeat(64),
2488                })),
2489                6,
2490                "module_digest_mismatch",
2491            ),
2492            (
2493                CoreError::ModuleFeedUnusable {
2494                    url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v2.3.4".into(),
2495                    detail: "rate limit exceeded".into(),
2496                },
2497                6,
2498                "module_feed_unusable",
2499            ),
2500            (
2501                CoreError::ModuleDigestChanged(Box::new(ModuleDigestChangedDetails {
2502                    module: "git".into(),
2503                    version: "2.3.4".into(),
2504                    cached_digest: "a".repeat(64),
2505                    upstream_digest: "b".repeat(64),
2506                    cached_path: "/cache/modules/git/2.3.4-aaaa.modl".into(),
2507                })),
2508                6,
2509                "module_digest_changed",
2510            ),
2511            (
2512                CoreError::ModuleNotRegistered {
2513                    id: "bogus".into(),
2514                    known: vec!["git".into()],
2515                },
2516                3,
2517                "module_not_registered",
2518            ),
2519            (
2520                CoreError::ModuleUninstallDenied {
2521                    module_id: "git".into(),
2522                    gateway_module_id: "com.axone_io.ignition.git".into(),
2523                    failed: vec!["com.axone_io.ignition.git".into()],
2524                    endpoint: Some(
2525                        "http://gw:8088/data/api/v1/modules/uninstall".into(),
2526                    ),
2527                },
2528                6,
2529                "module_uninstall_denied",
2530            ),
2531            (
2532                CoreError::ScanLockHeld {
2533                    actor: Some("api-token:cap-a:172.17.0.1".into()),
2534                    kind: "config".into(),
2535                    endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2536                },
2537                6,
2538                "scan_lock_held",
2539            ),
2540            (
2541                CoreError::ResourceSignatureRejected {
2542                    name: "DB_A".into(),
2543                    reason: SignatureReason::Stale,
2544                    endpoint: Some(
2545                        "http://gw:8088/data/api/v1/resources/find/ignition/database-connection/DB_A"
2546                            .into(),
2547                    ),
2548                },
2549                6,
2550                "resource_signature_rejected",
2551            ),
2552            (
2553                CoreError::ResourceExists {
2554                    name: "DB_A".into(),
2555                    endpoint: Some("http://gw:8088/data/api/v1/resources/ignition/database-connection".into()),
2556                },
2557                6,
2558                "resource_exists",
2559            ),
2560            (
2561                CoreError::ModeExists {
2562                    name: "production".into(),
2563                    endpoint: Some("http://gw:8088/data/api/v1/mode".into()),
2564                },
2565                6,
2566                "mode_exists",
2567            ),
2568            (
2569                CoreError::deploy_tree_invalid("manifest.yaml", Some(2), Some(1), "unknown field"),
2570                2,
2571                "deploy_tree_invalid",
2572            ),
2573            (
2574                CoreError::embedded_secret(vec!["a.json: settings.password".into()], vec![]),
2575                2,
2576                "embedded_secret",
2577            ),
2578            (
2579                CoreError::TransportFailed {
2580                    operation: "read".into(),
2581                    node: "nodes.master".into(),
2582                    detail: "cannot read directory config/resources (NotFound)".into(),
2583                },
2584                4,
2585                "transport_failed",
2586            ),
2587            (
2588                CoreError::DeployLedgerInvalid {
2589                    location: "ign-deploy/gw/dev/ledger.json".into(),
2590                    problem: "files is not byte-sorted".into(),
2591                },
2592                6,
2593                "deploy_ledger_invalid",
2594            ),
2595            (
2596                CoreError::PlanStale {
2597                    expected: "a".repeat(64),
2598                    actual: "b".repeat(64),
2599                    detail: "review hash".into(),
2600                },
2601                6,
2602                "plan_stale",
2603            ),
2604            (
2605                CoreError::DestructiveChangeRefused {
2606                    gate: "destructive".into(),
2607                    keys: vec!["ignition/a/x".into()],
2608                    count: 1,
2609                },
2610                2,
2611                "destructive_change_refused",
2612            ),
2613            (
2614                CoreError::DeployBlocked {
2615                    keys: vec!["ignition/a/x".into()],
2616                },
2617                6,
2618                "deploy_blocked",
2619            ),
2620            (
2621                CoreError::DeployPreflightFailed {
2622                    failures: vec![PreflightFailure {
2623                        check: "mode_master".into(),
2624                        detail: "the gateway is not in the declared mode".into(),
2625                    }]
2626                    .into(),
2627                },
2628                6,
2629                "deploy_preflight_failed",
2630            ),
2631            (
2632                CoreError::DeployJournalPresent {
2633                    run_id: "r1".into(),
2634                    env: "prod".into(),
2635                    location: "ign-deploy/gw/m/journal.json".into(),
2636                },
2637                6,
2638                "deploy_journal_present",
2639            ),
2640            (
2641                CoreError::DeployLiveChanged {
2642                    run_id: "r1".into(),
2643                    paths: vec!["a/b.json".into()],
2644                    count: 1,
2645                },
2646                6,
2647                "deploy_live_changed",
2648            ),
2649            (
2650                CoreError::DeployRunInvalid {
2651                    run_id: "r1".into(),
2652                    problem: "record.json is missing".into(),
2653                },
2654                6,
2655                "deploy_run_invalid",
2656            ),
2657            (
2658                CoreError::RollbackFailed(Box::new(RollbackFailure {
2659                    run_id: "r1".into(),
2660                    env: "dev".into(),
2661                    run_dir: Some(".ign/deploy/r1".into()),
2662                    journal: "ign-deploy/gw/m/journal.json".into(),
2663                    cause: "write_failed".into(),
2664                })),
2665                6,
2666                "rollback_failed",
2667            ),
2668            (
2669                CoreError::ProjectGitManaged {
2670                    project: "CapApp".into(),
2671                    location: "projects/CapApp/.git".into(),
2672                    kind: "directory".into(),
2673                },
2674                6,
2675                "project_git_managed",
2676            ),
2677            (
2678                CoreError::ProjectParentInvalid {
2679                    project: "CapApp".into(),
2680                    parent: "WHK-Global".into(),
2681                    reason: "missing".into(),
2682                },
2683                6,
2684                "project_parent_invalid",
2685            ),
2686            (
2687                CoreError::ProjectHashMismatch {
2688                    project: "CapApp".into(),
2689                    expected: "0".repeat(64),
2690                    actual: "1".repeat(64),
2691                },
2692                6,
2693                "project_hash_mismatch",
2694            ),
2695            (
2696                CoreError::DeployUnreadable {
2697                    node: "master".into(),
2698                    keys: vec!["ignition/tag-definition/default/Deep".into()],
2699                },
2700                6,
2701                "deploy_unreadable",
2702            ),
2703            (
2704                CoreError::DeployPairTagsRefused {
2705                    mode: "production".into(),
2706                    keys: vec!["ignition/tag-definition/default/Deploy".into()],
2707                },
2708                6,
2709                "deploy_pair_tags_refused",
2710            ),
2711        ];
2712        for (err, code, slug) in cases {
2713            assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
2714            assert_eq!(err.code(), slug, "unstable slug for: {err}");
2715        }
2716    }
2717
2718    /// The (exit_code, slug) literal table the Three-Place rule syncs.
2719    /// DUPLICATED from [`exit_code_mapping_enumerated`]'s triple list by
2720    /// design: that test is the literal source of truth (enum ↔ literals);
2721    /// this flat table is what the README cross-check runs against, so a
2722    /// drift inside error.rs surfaces as a disagreement instead of silently
2723    /// re-shuffling both sides. Keep the two lists in lockstep — the
2724    /// enumerated test fails if the enum respells/renumbers, and a stale
2725    /// copy here fails the README check below until updated.
2726    const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
2727        (1, "internal"),
2728        (2, "confirmation_required"),
2729        (2, "profiling_risk_not_accepted"),
2730        (2, "invalid_import_file"),
2731        (2, "invalid_input"),
2732        (2, "gateway_client_error"),
2733        (2, "deploy_tree_invalid"),
2734        (2, "embedded_secret"),
2735        (2, "destructive_change_refused"),
2736        (3, "profile_not_found"),
2737        (3, "no_active_profile"),
2738        (3, "secret_unavailable"),
2739        (3, "password_unavailable"),
2740        (3, "config_invalid"),
2741        (3, "poll_interval_too_small"),
2742        (3, "module_not_registered"),
2743        (4, "network_error"),
2744        (5, "auth_rejected"),
2745        (6, "gateway_too_old"),
2746        (6, "gateway_not_commissioned"),
2747        (6, "gateway_restarting"),
2748        (6, "not_found"),
2749        (6, "project_exists"),
2750        (6, "resource_binary"),
2751        (6, "trial_not_expired"),
2752        (6, "provider_not_found"),
2753        (6, "routes_not_deployed"),
2754        (6, "webdev_unlicensed"),
2755        (6, "route_version_mismatch"),
2756        (6, "webdev_route_error"),
2757        (6, "tag_collision"),
2758        (6, "alarm_journal_missing"),
2759        (6, "import_denied"),
2760        (6, "session_not_prunable"),
2761        (6, "eam_not_controller"),
2762        (6, "eam_task_type_refused"),
2763        (6, "eam_task_in_flight"),
2764        (6, "script_exec_not_configured"),
2765        (6, "lint_tool_absent"),
2766        (6, "node_tool_absent"),
2767        (6, "provider_root_unsupported"),
2768        (6, "bundle_not_available"),
2769        (4, "module_feed_unreachable"),
2770        (4, "transport_failed"),
2771        (6, "module_release_not_found"),
2772        (6, "module_digest_mismatch"),
2773        (6, "module_feed_unusable"),
2774        (6, "module_digest_changed"),
2775        (6, "module_uninstall_denied"),
2776        (6, "scan_lock_held"),
2777        (6, "resource_signature_rejected"),
2778        (6, "resource_exists"),
2779        (6, "mode_exists"),
2780        (6, "deploy_ledger_invalid"),
2781        (6, "plan_stale"),
2782        (6, "deploy_blocked"),
2783        (6, "deploy_preflight_failed"),
2784        (6, "deploy_journal_present"),
2785        (6, "deploy_live_changed"),
2786        (6, "deploy_run_invalid"),
2787        (6, "rollback_failed"),
2788        (6, "project_git_managed"),
2789        (6, "project_parent_invalid"),
2790        (6, "project_hash_mismatch"),
2791        (6, "deploy_unreadable"),
2792        (6, "deploy_pair_tags_refused"),
2793        (7, "rig_error"),
2794    ];
2795
2796    /// The two deploy slugs (20-02) never carry a signature value or
2797    /// gateway problem text, and render their payloads sensibly.
2798    #[test]
2799    fn deploy_slugs_never_carry_signature_or_problem_text() {
2800        const SAMPLE_SIG: &str = "d80c81d167362252bf389893ea0f3a81479521ba0bb68ee3bfc46e94bc2540b7";
2801
2802        let held = CoreError::ScanLockHeld {
2803            actor: Some("api-token:cap-a:172.17.0.1".into()),
2804            kind: "config".into(),
2805            endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2806        };
2807        assert_eq!(held.code(), "scan_lock_held");
2808        assert_eq!(held.exit_code(), 6);
2809        let shown = held.to_string();
2810        assert!(shown.contains("api-token:cap-a:172.17.0.1"), "{shown}");
2811        assert!(shown.contains("config"), "{shown}");
2812        assert_eq!(
2813            held.endpoint().as_deref(),
2814            Some("http://gw:8088/data/api/v1/scan-lock/config")
2815        );
2816
2817        let anonymous = CoreError::ScanLockHeld {
2818            actor: None,
2819            kind: "config".into(),
2820            endpoint: None,
2821        };
2822        let shown = anonymous.to_string();
2823        assert!(shown.contains("another actor"), "{shown}");
2824        assert!(!shown.contains("None"), "{shown}");
2825
2826        for reason in [SignatureReason::Stale, SignatureReason::Missing] {
2827            let err = CoreError::ResourceSignatureRejected {
2828                name: "DB_A".into(),
2829                reason,
2830                endpoint: Some("http://gw:8088/data/api/v1/resources/x".into()),
2831            };
2832            assert_eq!(err.code(), "resource_signature_rejected");
2833            assert_eq!(err.exit_code(), 6);
2834            let shown = err.to_string();
2835            assert!(shown.contains("DB_A"), "{shown}");
2836            assert!(shown.contains(&reason.to_string()), "{shown}");
2837            assert!(!shown.contains(SAMPLE_SIG), "{shown}");
2838            let hint = err.hint().expect("hint required");
2839            assert!(!hint.contains(SAMPLE_SIG), "{hint}");
2840            let endpoint = err.endpoint().expect("endpoint");
2841            assert!(!endpoint.contains(SAMPLE_SIG), "{endpoint}");
2842        }
2843        assert_eq!(SignatureReason::Stale.to_string(), "stale");
2844        assert_eq!(SignatureReason::Missing.to_string(), "missing");
2845    }
2846
2847    /// Parse the README's exit-code table: rows `| <exit> | class | meaning |
2848    /// \`slug\`, ... |` where `<exit>` is 1–7 (row 0 is the success row, no
2849    /// slugs). Parsing is SCOPED to the `## Exit codes` section — the README
2850    /// contains unrelated tables whose rows coincidentally begin `| 5 |`,
2851    /// `| 3 |`, etc. Std-only string ops: split on `|`, trim, take the LAST
2852    /// non-empty cell as the slug column (the meaning column can carry
2853    /// backticked non-slugs like `--yes`), and keep the backtick-delimited
2854    /// tokens that start with an ASCII letter and contain no spaces.
2855    fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
2856        let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
2857        let mut rows = Vec::new();
2858        let table_lines = section
2859            .lines()
2860            .skip_while(|line| !line.trim_start().starts_with('|'));
2861        for line in table_lines {
2862            if !line.trim_start().starts_with('|') {
2863                break; // table ended
2864            }
2865            let cells: Vec<&str> = line.split('|').map(str::trim).collect();
2866            let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
2867                continue;
2868            };
2869            if !(1..=7).contains(&exit) {
2870                continue;
2871            }
2872            let slug_cell = cells
2873                .iter()
2874                .rev()
2875                .find(|cell| !cell.is_empty())
2876                .copied()
2877                .unwrap_or_default();
2878            let slugs: Vec<String> = slug_cell
2879                .split('`')
2880                .enumerate()
2881                .filter(|(idx, _)| idx % 2 == 1)
2882                .map(|(_, token)| token.trim().to_string())
2883                .filter(|token| {
2884                    token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
2885                })
2886                .collect();
2887            rows.push((exit, slugs));
2888        }
2889        rows
2890    }
2891
2892    /// CORE-11, the Three-Place slug rule made executable: the exit-code
2893    /// table exists in the enum ([`CoreError::exit_code`]), this file's
2894    /// literal triples, and the README — and the README side is now
2895    /// machine-checked. The README is parsed verbatim via `include_str!`
2896    /// and cross-checked against the literal table in BOTH directions:
2897    /// (a) every literal slug appears under its exit code (a README row
2898    /// that lost or misspelled a slug fails), (b) every README slug token
2899    /// exists in the literal table (a stale/deleted row fails). Exit 6
2900    /// carries 23 slugs, so the full cross-check is the value — not the
2901    /// happy-path smoke.
2902    #[test]
2903    fn readme_exit_table_agreement() {
2904        let readme = include_str!("../../../README.md");
2905        let rows = parse_readme_exit_table(readme);
2906        assert!(
2907            rows.len() >= 7,
2908            "README exit-code table not found — the parser must see all 7 \
2909             failure-class rows (found {})",
2910            rows.len()
2911        );
2912
2913        // Direction (a): every literal (exit, slug) is present in the
2914        // README row matching its exit code.
2915        for (exit, slug) in EXIT_SLUG_LITERALS {
2916            let row = rows
2917                .iter()
2918                .find(|(readme_exit, _)| readme_exit == exit)
2919                .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
2920            assert!(
2921                row.1.iter().any(|s| s == slug),
2922                "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
2923                row.1
2924            );
2925        }
2926
2927        // Direction (b): every README slug token exists in the literal
2928        // table under the same exit code — catches stale/deleted rows.
2929        for (exit, slugs) in &rows {
2930            for slug in slugs {
2931                assert!(
2932                    EXIT_SLUG_LITERALS
2933                        .iter()
2934                        .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
2935                    "README exit-{exit} row carries slug {slug:?} that no \
2936                     CoreError variant emits — stale table row or slug/exit \
2937                     drift between README and error.rs"
2938                );
2939            }
2940        }
2941    }
2942
2943    /// CORE-05: config, auth, and target-state classes carry actionable
2944    /// hints (and every other class does too).
2945    #[test]
2946    fn hints_are_actionable_for_config_auth_target_state() {
2947        let profile_not_found = CoreError::ProfileNotFound {
2948            name: "x".into(),
2949            known: vec!["dev".into(), "prod".into()],
2950        };
2951        let hint = profile_not_found.hint().expect("hint required");
2952        assert!(
2953            hint.contains("dev"),
2954            "hint must list known profiles: {hint}"
2955        );
2956        assert!(
2957            hint.contains("ign profile add"),
2958            "hint must name the fix: {hint}"
2959        );
2960
2961        let auth = CoreError::Auth {
2962            status: 403,
2963            endpoint: None,
2964        };
2965        let hint = auth.hint().expect("hint required");
2966        assert!(
2967            hint.contains("three parts"),
2968            "auth hint must name the three-part token setup: {hint}"
2969        );
2970
2971        // Status-aware auth hints (02-RESEARCH Auth §4): 401 = not
2972        // recognized (name:key format), 403 = under-permitted.
2973        let unauthorized = CoreError::Auth {
2974            status: 401,
2975            endpoint: None,
2976        };
2977        let hint = unauthorized.hint().expect("hint required");
2978        assert!(
2979            hint.contains("name:key"),
2980            "401 hint must name the full name:key token format: {hint}"
2981        );
2982        assert!(
2983            hint.contains("API token"),
2984            "401 hint must say Basic cannot work: {hint}"
2985        );
2986        let hint403 = auth.hint().expect("hint required");
2987        assert!(
2988            hint403.contains("secure connections"),
2989            "403 hint must name the secure-channel part: {hint403}"
2990        );
2991
2992        let too_old = CoreError::GatewayTooOld {
2993            found: "8.1.0".into(),
2994            minimum: "8.3.1".into(),
2995            endpoint: None,
2996        };
2997        let hint = too_old.hint().expect("hint required");
2998        assert!(
2999            hint.contains("8.3.1"),
3000            "target-state hint must name the minimum: {hint}"
3001        );
3002
3003        // The WebDev refusal matrix (05-03): every hint names the fix
3004        // — `ign webdev deploy` for absent/older routes, `update ign`
3005        // for newer ones (the roadmap's actionable-error criterion).
3006        let undeployed = CoreError::RoutesNotDeployed {
3007            project: "ign-cli".into(),
3008            route: "tags".into(),
3009            endpoint: None,
3010        };
3011        let hint = undeployed.hint().expect("hint required");
3012        assert!(
3013            hint.contains("ign webdev deploy"),
3014            "absent-routes hint must name the fix: {hint}"
3015        );
3016
3017        let older = CoreError::RouteVersionMismatch {
3018            route: "tags".into(),
3019            deployed: "0.9.0".into(),
3020            expected: "1.0.0".into(),
3021            endpoint: None,
3022        };
3023        let hint = older.hint().expect("hint required");
3024        assert!(
3025            hint.contains("ign webdev deploy") && !hint.contains("update ign"),
3026            "older-route hint says redeploy: {hint}"
3027        );
3028
3029        let newer = CoreError::RouteVersionMismatch {
3030            route: "tags".into(),
3031            deployed: "1.1.0".into(),
3032            expected: "1.0.0".into(),
3033            endpoint: None,
3034        };
3035        let hint = newer.hint().expect("hint required");
3036        assert!(
3037            hint.contains("update ign") && !hint.contains("ign webdev deploy"),
3038            "newer-route hint says update ign: {hint}"
3039        );
3040
3041        let secret_gate = CoreError::WebdevRouteError {
3042            code: "secret_required".into(),
3043            message: "missing x-ignition-cli-secret header".into(),
3044            endpoint: None,
3045        };
3046        let hint = secret_gate.hint().expect("hint required");
3047        assert!(
3048            hint.contains("--with-script-exec"),
3049            "secret-gate hint names the deploy flag: {hint}"
3050        );
3051
3052        // The alarm-journal refusal (05-06): the hint names the missing
3053        // provisioning chain AND the README section.
3054        let journal = CoreError::AlarmJournalMissing { endpoint: None };
3055        let hint = journal.hint().expect("hint required");
3056        assert!(
3057            hint.contains("journal profile") && hint.contains("database connection"),
3058            "journal hint names the chain: {hint}"
3059        );
3060        assert!(
3061            hint.contains("README"),
3062            "journal hint points at the README section: {hint}"
3063        );
3064
3065        // The live-designer prune refusal (06-07): the hint names the
3066        // action — close the Designer — and the stale-only semantics.
3067        let prunable = CoreError::SessionNotPrunable {
3068            id: "d-live-1".into(),
3069            endpoint: None,
3070        };
3071        let hint = prunable.hint().expect("hint required");
3072        assert!(
3073            hint.contains("close the Designer"),
3074            "prune hint must name the action: {hint}"
3075        );
3076        assert!(
3077            hint.contains("stale entries"),
3078            "prune hint must name stale-only semantics: {hint}"
3079        );
3080
3081        // The scan-lock refusal (20-02): the hint names the release
3082        // conditions and no ign command.
3083        let held = CoreError::ScanLockHeld {
3084            actor: None,
3085            kind: "config".into(),
3086            endpoint: None,
3087        };
3088        let hint = held.hint().expect("hint required");
3089        assert!(
3090            hint.contains("holdTimeout") && hint.contains("scan runs"),
3091            "scan-lock hint names the release conditions: {hint}"
3092        );
3093        assert!(
3094            !hint.contains("ign "),
3095            "scan-lock hint names no ign command: {hint}"
3096        );
3097
3098        // The signature refusal (20-02): re-read and retry.
3099        let rejected = CoreError::ResourceSignatureRejected {
3100            name: "DB_A".into(),
3101            reason: SignatureReason::Stale,
3102            endpoint: None,
3103        };
3104        let hint = rejected.hint().expect("hint required");
3105        assert!(
3106            hint.contains("re-read") && hint.contains("signatures change on every write"),
3107            "signature hint names the action: {hint}"
3108        );
3109
3110        // The duplicate-create refusals (20-REVIEW WR-01): each names the
3111        // next step and carries no gateway problem text.
3112        let exists = CoreError::ResourceExists {
3113            name: "DB_A".into(),
3114            endpoint: None,
3115        };
3116        let hint = exists.hint().expect("hint required");
3117        assert!(
3118            hint.contains("update it"),
3119            "resource_exists hint names the action: {hint}"
3120        );
3121        assert_eq!(exists.to_string(), "resource \"DB_A\" already exists");
3122        let mode_exists = CoreError::ModeExists {
3123            name: "production".into(),
3124            endpoint: None,
3125        };
3126        let hint = mode_exists.hint().expect("hint required");
3127        assert!(
3128            hint.contains("skip the create"),
3129            "mode_exists hint names the action: {hint}"
3130        );
3131
3132        // The TTY refusal's hint is contextual (06-07): the ONE
3133        // InvalidInput reason whose fix is terminal-related. Every
3134        // other reason keeps the --file/stdin resource-put hint (pinned
3135        // here so the special case can never leak or regress).
3136        let tty = CoreError::tui_tty_refusal();
3137        assert_eq!(tty.code(), "invalid_input", "slug unchanged");
3138        assert_eq!(tty.exit_code(), 2, "usage class unchanged");
3139        let hint = tty.hint().expect("hint required");
3140        assert!(
3141            hint.contains("interactive terminal"),
3142            "TTY hint must name the fix: {hint}"
3143        );
3144        assert!(
3145            !hint.contains("--file"),
3146            "TTY hint must not carry the resource-put hint: {hint}"
3147        );
3148
3149        // The itop sibling (ign top): the SAME usage class and slug,
3150        // but the refusal/hint name the MONITOR's command — a hint
3151        // naming `ign tui` would not fix an `ign top` run (the
3152        // RoutesNotDeployed rule at the TTY pair).
3153        let top = CoreError::top_tty_refusal();
3154        assert_eq!(top.code(), "invalid_input", "slug unchanged");
3155        assert_eq!(top.exit_code(), 2, "usage class unchanged");
3156        let top_hint = top.hint().expect("hint required");
3157        assert!(
3158            top_hint.contains("`ign top`"),
3159            "the top refusal's hint names ign top: {top_hint}"
3160        );
3161        assert!(
3162            !top_hint.contains("ign tui"),
3163            "the top refusal never names the cockpit command: {top_hint}"
3164        );
3165        assert!(
3166            top_hint.contains("interactive terminal"),
3167            "the top refusal keeps the terminal-contextual fix: {top_hint}"
3168        );
3169        let put = CoreError::InvalidInput {
3170            reason: "cannot read put.json".into(),
3171        };
3172        let hint = put.hint().expect("hint required");
3173        assert!(
3174            hint.contains("--file") && hint.contains("stdin"),
3175            "resource-put hint unchanged: {hint}"
3176        );
3177
3178        // The EAM controller state gate (07-02): the hint names the
3179        // manual flip recipe + the README section (role decisions
3180        // stay one config-PUT away from the CLI, never one flag).
3181        let controller = CoreError::EamNotController { endpoint: None };
3182        let hint = controller.hint().expect("hint required");
3183        assert!(
3184            hint.contains("installMode"),
3185            "controller hint names the flip: {hint}"
3186        );
3187        assert!(
3188            hint.contains("Controller"),
3189            "controller hint names the target role: {hint}"
3190        );
3191        assert!(
3192            hint.contains("README"),
3193            "controller hint points at the README section: {hint}"
3194        );
3195
3196        // The refused task-type ladder top (07-02 Task 3): the hint
3197        // names the EAM console + the v2 scope pointer.
3198        let refused = CoreError::EamTaskTypeRefused {
3199            task_type: "eam_restoreBackup".into(),
3200        };
3201        assert_eq!(refused.exit_code(), 6, "target state");
3202        let hint = refused.hint().expect("hint required");
3203        assert!(
3204            hint.contains("EAM console"),
3205            "refused hint names where to run it: {hint}"
3206        );
3207        assert!(
3208            hint.contains("fleet-wide") || hint.contains("EXT-03"),
3209            "refused hint names the fleet consequence / scope: {hint}"
3210        );
3211
3212        // The force-route 409 (07-06 gap 4): the hint names the
3213        // resolution — complete or delete the leftover '(forced)'
3214        // run via the EAM console (no ign verb deletes runs).
3215        let in_flight = CoreError::EamTaskInFlight {
3216            task: "cli-research-backup".into(),
3217            detail: "the previous '(forced)' run must be completed or deleted first".into(),
3218            endpoint: None,
3219        };
3220        assert_eq!(in_flight.exit_code(), 6, "target state");
3221        let hint = in_flight.hint().expect("hint required");
3222        assert!(
3223            hint.contains("EAM console"),
3224            "in-flight hint names where to resolve it: {hint}"
3225        );
3226        assert!(
3227            hint.contains("complete or delete"),
3228            "in-flight hint names the resolution: {hint}"
3229        );
3230
3231        // The scriptExec structural gate (07-03): the hint names the
3232        // deploy flag VERBATIM — the flag IS the opt-in (no --yes
3233        // exists on script run).
3234        let unconfigured = CoreError::ScriptExecNotConfigured {
3235            profile: "dev".into(),
3236        };
3237        assert_eq!(unconfigured.exit_code(), 6, "target state");
3238        let hint = unconfigured.hint().expect("hint required");
3239        assert!(
3240            hint.contains("ign webdev deploy --with-script-exec"),
3241            "the hint names the deploy flag verbatim: {hint}"
3242        );
3243
3244        // Totality: no class silently loses its hint later.
3245        let no_active = CoreError::NoActiveProfile;
3246        assert!(no_active.hint().is_some());
3247    }
3248
3249    /// The failure envelope's serialized field order and endpoint population
3250    /// are contract: `ok`, `profile`, `error` / `code`, `message`,
3251    /// `endpoint`, `hint` (string-level comparison because `serde_json::Value`
3252    /// maps are key-sorted and would hide ordering).
3253    #[test]
3254    fn error_envelope_locked_shape_and_endpoint() {
3255        let auth = CoreError::Auth {
3256            status: 401,
3257            endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
3258        };
3259        let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
3260        let json = serde_json::to_string(&envelope).expect("serialize envelope");
3261
3262        assert_eq!(
3263            json,
3264            concat!(
3265                r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
3266                r#""message":"gateway rejected credentials (HTTP 401)","#,
3267                r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
3268                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"}}"#
3269            )
3270        );
3271
3272        // Classes without a request involved carry no endpoint.
3273        let no_request = CoreError::NoActiveProfile;
3274        let body: &ErrorBody = &no_request.envelope(None).error;
3275        assert_eq!(body.endpoint, None);
3276    }
3277
3278    /// The api-call body cap (09-01): a body at or under
3279    /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] rides verbatim (no marker); a
3280    /// larger body is truncated to the cap on a UTF-8 char boundary with
3281    /// the exact ASCII marker appended — and the cap is enforced at
3282    /// CONSTRUCTION, so the variant always carries its final form.
3283    #[test]
3284    fn truncates_at_cap_with_marker() {
3285        // Under the cap: byte-identical passthrough.
3286        let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
3287        assert_eq!(truncate_api_body(short), short);
3288        assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3289
3290        // Exactly at the cap: still verbatim (the marker only joins an
3291        // OVER-cap body — the boundary is <=, not <).
3292        let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
3293        assert_eq!(truncate_api_body(&exact), exact);
3294
3295        // One byte over: truncated, marker appended, total size bounded.
3296        let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
3297        let truncated = truncate_api_body(&over);
3298        assert!(
3299            truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
3300            "truncated body must end with the explicit marker"
3301        );
3302        assert!(
3303            truncated.len()
3304                <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
3305            "truncated body must stay within cap + marker: {}",
3306            truncated.len()
3307        );
3308
3309        // A multi-byte character straddling the cap boundary does not
3310        // panic (the cut backs up to a char boundary) and still carries
3311        // the marker.
3312        let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); // 2 bytes each
3313        let truncated = truncate_api_body(&multibyte);
3314        assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3315        assert!(
3316            truncated
3317                .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
3318        );
3319    }
3320
3321    /// The loss-gate hint override (11-07 gap closure): a reason with the
3322    /// [`LOSS_GATE_REFUSAL_REASON_PREFIX`] sentinel carries the --yes
3323    /// hint, while every OTHER InvalidInput reason (generic file-read)
3324    /// and the TTY-refusal precedent keep their hints byte-identically.
3325    /// The loss-gate reason is built from the CONST via format! — a
3326    /// string literal of the sentinel text here would be a third
3327    /// production-literal hit and break the prefix-uniqueness gate the
3328    /// plan's verify pins.
3329    #[test]
3330    fn loss_gate_hint_override_and_neighbors_unchanged() {
3331        // Sentinel-prefixed reason (dynamic prose after the header):
3332        // the --yes hint, never the file-read default.
3333        let loss_gate = CoreError::InvalidInput {
3334            reason: format!(
3335                "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
3336                 with --yes to import anyway",
3337                LOSS_GATE_REFUSAL_REASON_PREFIX
3338            ),
3339        };
3340        assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
3341        assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
3342        let hint = loss_gate.hint().expect("hint required");
3343        assert!(
3344            hint.contains("--yes"),
3345            "loss-gate hint must name the --yes re-run: {hint}"
3346        );
3347        assert!(
3348            hint.contains("loss report above"),
3349            "loss-gate hint must point back at the report: {hint}"
3350        );
3351        assert!(
3352            !hint.contains("fix the input source"),
3353            "the file-read default must never leak onto the loss-gate path: {hint}"
3354        );
3355
3356        // Generic InvalidInput: the file-read default UNCHANGED (the
3357        // regression pin for every other raise site).
3358        let generic = CoreError::InvalidInput {
3359            reason: "x is not valid JSON: expected value at line 1".into(),
3360        };
3361        let hint = generic.hint().expect("hint required");
3362        assert!(
3363            hint.contains("--file") && hint.contains("stdin"),
3364            "resource-put hint unchanged: {hint}"
3365        );
3366        assert!(
3367            !hint.contains("--yes"),
3368            "the --yes hint must not leak onto generic reasons: {hint}"
3369        );
3370
3371        // The TTY-refusal precedent intact (06-07).
3372        let tty = CoreError::tui_tty_refusal();
3373        let hint = tty.hint().expect("hint required");
3374        assert!(
3375            hint.contains("interactive terminal"),
3376            "TTY hint unchanged: {hint}"
3377        );
3378        assert!(
3379            !hint.contains("--yes"),
3380            "the --yes hint must not leak onto the TTY path: {hint}"
3381        );
3382    }
3383
3384    /// The testing-scoped `routes_not_deployed` hint (QUICK-p0g): a
3385    /// `testing/`-prefixed route names the TESTING install commands,
3386    /// while every other route keeps the generic `ign webdev deploy`
3387    /// text BYTE-identically. Both branches are pinned so a future
3388    /// edit cannot silently merge them — sending a caller to
3389    /// `ign webdev deploy` for an absent testing bundle is a hint
3390    /// that does not fix the error.
3391    #[test]
3392    fn routes_not_deployed_hint_is_testing_scoped() {
3393        let testing = CoreError::RoutesNotDeployed {
3394            project: "Flux".into(),
3395            route: format!("{TESTING_ROUTE_PREFIX}run"),
3396            endpoint: None,
3397        };
3398        assert_eq!(testing.code(), "routes_not_deployed", "slug unchanged");
3399        assert_eq!(testing.exit_code(), 6, "target-state class unchanged");
3400        let hint = testing.hint().expect("hint required");
3401        assert!(
3402            hint.contains("ign webdev deploy --with-testing"),
3403            "testing hint must name the deploy flag: {hint}"
3404        );
3405        assert!(
3406            hint.contains("ign adopt") && hint.contains("--testing"),
3407            "testing hint must name the adopt path: {hint}"
3408        );
3409
3410        // Every non-testing route: the generic hint, unchanged.
3411        let generic = CoreError::RoutesNotDeployed {
3412            project: "ign-cli".into(),
3413            route: "tags".into(),
3414            endpoint: None,
3415        };
3416        let hint = generic.hint().expect("hint required");
3417        assert_eq!(
3418            hint,
3419            "run `ign webdev deploy` to install the CLI's WebDev routes into \
3420             the gateway, then retry",
3421            "the generic branch is byte-identical to its pre-QUICK-p0g text"
3422        );
3423    }
3424
3425    fn finding(file: &str, line: Option<usize>, column: Option<usize>, msg: &str) -> DeployFinding {
3426        DeployFinding {
3427            file: file.into(),
3428            line,
3429            column,
3430            message: msg.into(),
3431        }
3432    }
3433
3434    #[test]
3435    fn deploy_finding_display_names_file_line_column() {
3436        let positioned = finding("targets/prod.yaml", Some(3), Some(1), "unknown field `x`");
3437        assert_eq!(
3438            positioned.to_string(),
3439            "targets/prod.yaml:3:1: unknown field `x`"
3440        );
3441        let bare = finding("targets/prod.yaml", None, None, "missing field `gateway`");
3442        assert_eq!(
3443            bare.to_string(),
3444            "targets/prod.yaml: missing field `gateway`"
3445        );
3446        // A half-positioned finding never claims a position.
3447        let half = finding("m.yaml", Some(3), None, "m");
3448        assert_eq!(half.to_string(), "m.yaml: m");
3449    }
3450
3451    #[test]
3452    fn deploy_findings_cap_at_twenty() {
3453        let many: Vec<DeployFinding> = (0..25)
3454            .map(|i| finding("manifest.yaml", None, None, &format!("problem {i}")))
3455            .collect();
3456        let shown = CoreError::deploy_findings(many).to_string();
3457        assert!(shown.contains("25 findings: "), "{shown}");
3458        assert!(shown.contains("problem 19"), "{shown}");
3459        assert!(!shown.contains("problem 20"), "{shown}");
3460        assert!(shown.ends_with("; and 5 more"), "{shown}");
3461
3462        let single = CoreError::deploy_tree_invalid("m.yaml", Some(2), Some(1), "bad");
3463        assert_eq!(single.to_string(), "deploy tree invalid: m.yaml:2:1: bad");
3464    }
3465
3466    #[test]
3467    fn embedded_secret_display_lists_paths_only() {
3468        let err = CoreError::embedded_secret(
3469            vec![
3470                "ignition/database-connection/A/config.json: settings.password".into(),
3471                "ignition/database-connection/B/config.json: settings.password".into(),
3472            ],
3473            vec![finding("manifest.yaml", None, None, "duplicate type")],
3474        );
3475        let shown = err.to_string();
3476        assert!(shown.contains("2 path(s)"), "{shown}");
3477        assert!(
3478            shown.contains("A/config.json: settings.password"),
3479            "{shown}"
3480        );
3481        assert!(
3482            shown.contains("B/config.json: settings.password"),
3483            "{shown}"
3484        );
3485        assert!(shown.contains("also 1 other tree finding(s)"), "{shown}");
3486        assert!(shown.contains("manifest.yaml: duplicate type"), "{shown}");
3487
3488        // The combined list stays within the cap.
3489        let paths: Vec<String> = (0..25).map(|i| format!("f{i}.json: a.b")).collect();
3490        let shown =
3491            CoreError::embedded_secret(paths, vec![finding("m.yaml", None, None, "x")]).to_string();
3492        assert!(shown.contains("25 path(s)"), "{shown}");
3493        assert!(shown.contains("and 5 more"), "{shown}");
3494        assert!(shown.contains("f19.json: a.b"), "{shown}");
3495        assert!(!shown.contains("f20.json: a.b"), "{shown}");
3496    }
3497
3498    #[test]
3499    fn deploy_slugs_have_their_exit_codes() {
3500        let errs = [
3501            (
3502                CoreError::deploy_tree_invalid("m.yaml", None, None, "bad"),
3503                2,
3504            ),
3505            (
3506                CoreError::embedded_secret(vec!["a.json: p".into()], vec![]),
3507                2,
3508            ),
3509            (
3510                CoreError::TransportFailed {
3511                    operation: "read".into(),
3512                    node: "nodes.master".into(),
3513                    detail: "data_dir is missing".into(),
3514                },
3515                4,
3516            ),
3517            (
3518                CoreError::DeployLedgerInvalid {
3519                    location: "ign-deploy/gw/dev/ledger.json".into(),
3520                    problem: "the file is empty".into(),
3521                },
3522                6,
3523            ),
3524            (
3525                CoreError::PlanStale {
3526                    expected: "a".repeat(64),
3527                    actual: "b".repeat(64),
3528                    detail: "review hash".into(),
3529                },
3530                6,
3531            ),
3532            (
3533                CoreError::DestructiveChangeRefused {
3534                    gate: "destructive".into(),
3535                    keys: vec!["ignition/a/x".into()],
3536                    count: 1,
3537                },
3538                2,
3539            ),
3540            (
3541                CoreError::DeployBlocked {
3542                    keys: vec!["ignition/a/x".into()],
3543                },
3544                6,
3545            ),
3546            (
3547                CoreError::DeployPreflightFailed {
3548                    failures: vec![PreflightFailure {
3549                        check: "mode_master".into(),
3550                        detail: "the gateway is not in the declared mode".into(),
3551                    }]
3552                    .into(),
3553                },
3554                6,
3555            ),
3556            (
3557                CoreError::DeployJournalPresent {
3558                    run_id: "20261015T142233Z-a1b2c3".into(),
3559                    env: "prod".into(),
3560                    location: "ign-deploy/gw/m/journal.json".into(),
3561                },
3562                6,
3563            ),
3564            (
3565                CoreError::DeployLiveChanged {
3566                    run_id: "r1".into(),
3567                    paths: vec!["a/b.json".into()],
3568                    count: 1,
3569                },
3570                6,
3571            ),
3572            (
3573                CoreError::DeployRunInvalid {
3574                    run_id: "r1".into(),
3575                    problem: "record.json is missing".into(),
3576                },
3577                6,
3578            ),
3579            (
3580                CoreError::RollbackFailed(Box::new(RollbackFailure {
3581                    run_id: "r1".into(),
3582                    env: "dev".into(),
3583                    run_dir: Some(".ign/deploy/r1".into()),
3584                    journal: "ign-deploy/gw/m/journal.json".into(),
3585                    cause: "write_failed".into(),
3586                })),
3587                6,
3588            ),
3589            (
3590                CoreError::ProjectGitManaged {
3591                    project: "CapApp".into(),
3592                    location: "projects/CapApp/.git".into(),
3593                    kind: "directory".into(),
3594                },
3595                6,
3596            ),
3597            (
3598                CoreError::ProjectParentInvalid {
3599                    project: "CapApp".into(),
3600                    parent: "WHK-Global".into(),
3601                    reason: "cycle".into(),
3602                },
3603                6,
3604            ),
3605            (
3606                CoreError::ProjectHashMismatch {
3607                    project: "CapApp".into(),
3608                    expected: "0".repeat(64),
3609                    actual: "1".repeat(64),
3610                },
3611                6,
3612            ),
3613            (
3614                CoreError::DeployUnreadable {
3615                    node: "master".into(),
3616                    keys: vec!["ignition/tag-definition/default/Deep".into()],
3617                },
3618                6,
3619            ),
3620            (
3621                CoreError::DeployPairTagsRefused {
3622                    mode: "production".into(),
3623                    keys: vec!["ignition/tag-definition/default/Deploy".into()],
3624                },
3625                6,
3626            ),
3627        ];
3628        let codes: Vec<&str> = errs.iter().map(|(e, _)| e.code()).collect();
3629        assert_eq!(codes, CoreError::DEPLOY_SLUGS);
3630        for (err, exit) in &errs {
3631            assert_eq!(err.exit_code(), *exit, "{err}");
3632            assert_eq!(err.endpoint(), None, "deploy errors name no URL: {err}");
3633            let hint = err.hint().expect("hint required");
3634            assert!(
3635                !hint.contains("`ign ") && !hint.contains("ign "),
3636                "hint must not name an ign subcommand: {hint}"
3637            );
3638        }
3639    }
3640
3641    #[test]
3642    fn apply_gate_flag_refusals_do_not_get_the_file_hint() {
3643        for reason in [
3644            "--plan-sha256 must be 64 lowercase hex characters",
3645            "--allow-drift reason must not be empty",
3646            "--artifact-sha256 must be 64 lowercase hex characters",
3647            "--release <tag>: not a semver tag (for example v1.4.0)",
3648            "--allow-first-change <reason>: the reason is 1 to 500 characters",
3649            "deploy projects needs --artifact-sha256 when --json is given",
3650        ] {
3651            let hint = CoreError::InvalidInput {
3652                reason: reason.into(),
3653            }
3654            .hint()
3655            .expect("hint");
3656            assert!(!hint.contains("--file"), "{hint}");
3657            assert!(hint.contains("flag value"), "{hint}");
3658        }
3659    }
3660
3661    /// The exact ordered deploy slug list. Verdicts (`deploy_rolled_back`,
3662    /// `deploy_backup_unsynced`, `deploy_project_unverified`) are payload
3663    /// slugs on a success envelope and never `CoreError` codes. Later plans
3664    /// extend this list in README row order (25-09, 25-10, 25-11).
3665    #[test]
3666    fn deploy_slugs_are_pinned_and_never_a_verdict() {
3667        assert_eq!(
3668            CoreError::DEPLOY_SLUGS,
3669            &[
3670                "deploy_tree_invalid",
3671                "embedded_secret",
3672                "transport_failed",
3673                "deploy_ledger_invalid",
3674                "plan_stale",
3675                "destructive_change_refused",
3676                "deploy_blocked",
3677                "deploy_preflight_failed",
3678                "deploy_journal_present",
3679                "deploy_live_changed",
3680                "deploy_run_invalid",
3681                "rollback_failed",
3682                "project_git_managed",
3683                "project_parent_invalid",
3684                "project_hash_mismatch",
3685                "deploy_unreadable",
3686                "deploy_pair_tags_refused",
3687            ]
3688        );
3689        assert_eq!(CoreError::DEPLOY_SLUGS.len(), 17);
3690        for verdict in [
3691            "deploy_rolled_back",
3692            "deploy_backup_unsynced",
3693            "deploy_project_unverified",
3694        ] {
3695            assert!(!CoreError::DEPLOY_SLUGS.contains(&verdict), "{verdict}");
3696        }
3697    }
3698
3699    /// 25-07: the four-place rule for `project_git_managed` (variant, code,
3700    /// exit class 6, hint) and the message names what the operator needs.
3701    #[test]
3702    fn project_git_managed_four_places() {
3703        let err = CoreError::ProjectGitManaged {
3704            project: "CapApp".into(),
3705            location: "projects/CapApp/.git".into(),
3706            kind: "directory".into(),
3707        };
3708        assert_eq!(err.code(), "project_git_managed");
3709        assert_eq!(err.exit_code(), 6);
3710        let message = err.to_string();
3711        for needle in [
3712            "CapApp",
3713            "projects/CapApp/.git",
3714            "directory",
3715            "administrator",
3716        ] {
3717            assert!(message.contains(needle), "{needle}: {message}");
3718        }
3719        let hint = err.hint().expect("hint required");
3720        assert!(!hint.contains("ign "), "{hint}");
3721        assert!(hint.contains(".git"), "{hint}");
3722        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_git_managed"));
3723    }
3724
3725    /// 25-10: the four-place rule for `project_parent_invalid` (variant,
3726    /// code, exit class 6, hint per reason) and the message names the
3727    /// project, the parent and the reason.
3728    #[test]
3729    fn project_parent_invalid_four_places() {
3730        for (reason, needle) in [
3731            ("missing", "its own repo"),
3732            ("cycle", "no cycle"),
3733            ("not_inheritable", "inheritable"),
3734        ] {
3735            let err = CoreError::ProjectParentInvalid {
3736                project: "CapApp".into(),
3737                parent: "WHK-Global".into(),
3738                reason: reason.into(),
3739            };
3740            assert_eq!(err.code(), "project_parent_invalid");
3741            assert_eq!(err.exit_code(), 6);
3742            let message = err.to_string();
3743            for part in ["CapApp", "WHK-Global", reason] {
3744                assert!(message.contains(part), "{part}: {message}");
3745            }
3746            let hint = err.hint().expect("hint required");
3747            assert!(!hint.contains("ign "), "{hint}");
3748            assert!(hint.contains(needle), "{reason}: {hint}");
3749            assert_eq!(err.endpoint(), None);
3750        }
3751        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_parent_invalid"));
3752    }
3753
3754    /// 25-11: the four-place rule for `project_hash_mismatch` (variant, code,
3755    /// exit class 6, hint). The message names the project and both hashes and
3756    /// says nothing was uploaded; nothing else rides it (SC-1, D-01).
3757    #[test]
3758    fn project_hash_mismatch_four_places() {
3759        let err = CoreError::ProjectHashMismatch {
3760            project: "CapApp".into(),
3761            expected: "0".repeat(64),
3762            actual: "1".repeat(64),
3763        };
3764        assert_eq!(err.code(), "project_hash_mismatch");
3765        assert_eq!(err.exit_code(), 6);
3766        let message = err.to_string();
3767        for part in [
3768            "CapApp",
3769            "0000000000000000000000000000000000000000000000000000000000000000",
3770            "1111111111111111111111111111111111111111111111111111111111111111",
3771            "nothing was uploaded",
3772        ] {
3773            assert!(message.contains(part), "{part}: {message}");
3774        }
3775        let hint = err.hint().expect("hint required");
3776        assert!(!hint.contains("ign "), "{hint}");
3777        assert!(hint.contains("artifact_sha256"), "{hint}");
3778        assert_eq!(err.endpoint(), None);
3779        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_hash_mismatch"));
3780    }
3781
3782    /// quick-261029-ms3: the four-place rule for `deploy_unreadable`
3783    /// (variant, code, exit class 6, hint). The message names every unit,
3784    /// says the host's sftp server cannot read its live path and that nothing
3785    /// was written; the hint names the two fixes and no `ign` subcommand.
3786    #[test]
3787    fn deploy_unreadable_four_places() {
3788        let keys = vec![
3789            "ignition/database-translator/OLD".to_string(),
3790            "ignition/tag-definition/default/Deep".to_string(),
3791        ];
3792        let err = CoreError::DeployUnreadable {
3793            node: "backup".to_string(),
3794            keys: keys.clone(),
3795        };
3796        assert_eq!(err.code(), "deploy_unreadable");
3797        assert_eq!(err.exit_code(), 6);
3798        assert_eq!(err.endpoint(), None);
3799        let message = err.to_string();
3800        for part in keys.iter().map(String::as_str).chain([
3801            "backup host's sftp server cannot read",
3802            "nothing was written",
3803            "2 unit(s)",
3804        ]) {
3805            assert!(message.contains(part), "{part}: {message}");
3806        }
3807        let hint = err.hint().expect("hint required");
3808        assert!(!hint.contains("ign "), "{hint}");
3809        assert!(hint.contains("exclude_tag_providers"), "{hint}");
3810        assert!(hint.contains("shorten"), "{hint}");
3811        assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_unreadable"));
3812    }
3813
3814    /// debug deploy-tag-redundancy-sync: the four-place rule for
3815    /// `deploy_pair_tags_refused` (variant, code, exit class 6, hint). The
3816    /// message names the mode, every unit, the pair and that nothing was
3817    /// written; the hint names the fixes and no `ign` subcommand.
3818    #[test]
3819    fn deploy_pair_tags_refused_four_places() {
3820        let keys = vec![
3821            "ignition/tag-definition/default/Deploy".to_string(),
3822            "ignition/tag-type-definition/default/DeployTypes".to_string(),
3823        ];
3824        let err = CoreError::DeployPairTagsRefused {
3825            mode: "production".to_string(),
3826            keys: keys.clone(),
3827        };
3828        assert_eq!(err.code(), "deploy_pair_tags_refused");
3829        assert_eq!(err.exit_code(), 6);
3830        assert_eq!(err.endpoint(), None);
3831        let message = err.to_string();
3832        for part in keys.iter().map(String::as_str).chain([
3833            "2 tag unit(s)",
3834            "production collection of a redundant pair",
3835            "nothing was written",
3836        ]) {
3837            assert!(message.contains(part), "{part}: {message}");
3838        }
3839        let hint = err.hint().expect("hint required");
3840        assert!(!hint.contains("ign "), "{hint}");
3841        assert!(!hint.contains("exclude_tag_providers"), "{hint}");
3842        assert!(hint.contains("ledger does not list"), "{hint}");
3843        assert!(hint.contains("core collection"), "{hint}");
3844        assert!(hint.contains("nothing was written"), "{hint}");
3845        assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_pair_tags_refused"));
3846    }
3847
3848    /// 25-11 (K15): a stale live project is told apart from a stale plan
3849    /// hash, and neither hint names an `ign` subcommand.
3850    #[test]
3851    fn plan_stale_hint_names_the_live_project_case() {
3852        let live = CoreError::PlanStale {
3853            expected: "a".repeat(64),
3854            actual: "b".repeat(64),
3855            detail: "live project changed since the plan".into(),
3856        };
3857        let hint = live.hint().expect("hint");
3858        assert!(hint.contains("live project"), "{hint}");
3859        assert!(hint.contains("nothing was imported"), "{hint}");
3860        assert!(!hint.contains("ign "), "{hint}");
3861        let review = CoreError::PlanStale {
3862            expected: "a".repeat(64),
3863            actual: "b".repeat(64),
3864            detail: "review hash".into(),
3865        };
3866        assert!(
3867            review.hint().expect("hint").contains("plan_sha256"),
3868            "the plan-hash case keeps its hint"
3869        );
3870    }
3871
3872    #[test]
3873    fn journal_present_message_carries_the_recovery_command() {
3874        let err = CoreError::DeployJournalPresent {
3875            run_id: "20261015T142233Z-a1b2c3".into(),
3876            env: "prod".into(),
3877            location: "ign-deploy/gw/m/journal.json".into(),
3878        };
3879        let shown = err.to_string();
3880        assert!(
3881            shown.contains("ign deploy rollback --run 20261015T142233Z-a1b2c3 --env prod"),
3882            "{shown}"
3883        );
3884        assert!(shown.contains("ign-deploy/gw/m/journal.json"), "{shown}");
3885        let hint = err.hint().expect("hint");
3886        assert!(
3887            !hint.contains("ign deploy"),
3888            "D-09: the command lives in Display: {hint}"
3889        );
3890    }
3891
3892    #[test]
3893    fn rollback_failed_message_names_run_dir_journal_and_the_guidance() {
3894        let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
3895            run_id: "r-7".into(),
3896            env: "prod".into(),
3897            run_dir: Some(".ign/deploy/r-7".into()),
3898            journal: "ign-deploy/gw/m/journal.json".into(),
3899            cause: "write_failed".into(),
3900        }));
3901        let shown = err.to_string();
3902        for needle in [
3903            "r-7",
3904            ".ign/deploy/r-7",
3905            "ign-deploy/gw/m/journal.json",
3906            ".gwbk",
3907            "snapshot",
3908            "by hand",
3909            // The retry comes first, with the real env, and the intact
3910            // pre-images are the on-box directory (review WR-02).
3911            "ign deploy rollback --run r-7 --env prod",
3912            "ign-deploy/gw/m/snapshots/r-7/",
3913        ] {
3914            assert!(shown.contains(needle), "{needle} missing from: {shown}");
3915        }
3916        assert!(
3917            shown.find("Retry with").expect("retry") < shown.find("by hand").expect("by hand"),
3918            "the retry precedes the hand deletion: {shown}"
3919        );
3920        assert!(
3921            !shown.contains("run-directory snapshot"),
3922            "the run directory has no snapshot to offer: {shown}"
3923        );
3924        let hint = err.hint().expect("hint");
3925        assert!(
3926            hint.contains("retry") && !hint.contains("run-directory snapshot"),
3927            "{hint}"
3928        );
3929        let none = CoreError::RollbackFailed(Box::new(RollbackFailure {
3930            run_id: "r-7".into(),
3931            env: "prod".into(),
3932            run_dir: None,
3933            journal: "j".into(),
3934            cause: "c".into(),
3935        }));
3936        assert!(!none.to_string().contains("None"), "{none}");
3937    }
3938
3939    #[test]
3940    fn preflight_failed_lists_every_failure_in_order() {
3941        let err = CoreError::DeployPreflightFailed {
3942            failures: vec![
3943                PreflightFailure {
3944                    check: "mode_master".into(),
3945                    detail: "first".into(),
3946                },
3947                PreflightFailure {
3948                    check: "gwbk".into(),
3949                    detail: "second".into(),
3950                },
3951            ]
3952            .into(),
3953        };
3954        let shown = err.to_string();
3955        let first = shown.find("mode_master: first").expect("first");
3956        let second = shown.find("gwbk: second").expect("second");
3957        assert!(first < second, "{shown}");
3958        assert_eq!(shown.lines().count(), 3, "{shown}");
3959    }
3960
3961    #[test]
3962    fn apply_refusal_lists_cap_at_ten() {
3963        let keys: Vec<String> = (0..10).map(|i| format!("u{i}")).collect();
3964        let shown = CoreError::DestructiveChangeRefused {
3965            gate: "destructive".into(),
3966            keys: keys.clone(),
3967            count: 13,
3968        }
3969        .to_string();
3970        assert!(shown.contains("13 entries"), "{shown}");
3971        assert!(shown.contains("and 3 more"), "{shown}");
3972        assert!(shown.contains("--allow-destructive"), "{shown}");
3973        let drift = CoreError::DestructiveChangeRefused {
3974            gate: "drift".into(),
3975            keys: keys[..1].to_vec(),
3976            count: 1,
3977        }
3978        .to_string();
3979        assert!(drift.contains("--allow-drift"), "{drift}");
3980        assert!(drift.contains("1 entry"), "{drift}");
3981        let downgrade = CoreError::DestructiveChangeRefused {
3982            gate: "downgrade".into(),
3983            keys: vec!["CapApp".into()],
3984            count: 1,
3985        };
3986        let shown = downgrade.to_string();
3987        assert!(shown.contains("downgrade"), "{shown}");
3988        assert!(shown.contains("CapApp"), "{shown}");
3989        assert!(
3990            shown.contains("deploy projects needs --allow-downgrade"),
3991            "{shown}"
3992        );
3993        assert!(!shown.contains("apply needs"), "{shown}");
3994        let first = CoreError::DestructiveChangeRefused {
3995            gate: "first_deploy".into(),
3996            keys: vec!["CapApp".into()],
3997            count: 1,
3998        };
3999        let shown = first.to_string();
4000        assert!(shown.contains("first_deploy"), "{shown}");
4001        assert!(
4002            shown.contains("deploy projects needs --allow-first-change <reason>"),
4003            "{shown}"
4004        );
4005        // each gate has its own hint, and none names an `ign` command
4006        let hints: Vec<String> = [&downgrade, &first]
4007            .iter()
4008            .map(|e| e.hint().expect("hint"))
4009            .collect();
4010        assert!(hints[0].contains("--allow-downgrade"), "{}", hints[0]);
4011        assert!(hints[1].contains("--allow-first-change"), "{}", hints[1]);
4012        for hint in &hints {
4013            assert!(!hint.contains("ign "), "{hint}");
4014            assert!(!hint.contains("--allow-destructive"), "{hint}");
4015        }
4016
4017        let many: Vec<String> = (0..25).map(|i| format!("a/{i:02}")).collect();
4018        let blocked = CoreError::DeployBlocked { keys: many.clone() }.to_string();
4019        assert!(blocked.contains("and 15 more"), "{blocked}");
4020        assert!(!blocked.contains("a/10"), "{blocked}");
4021        let live = CoreError::DeployLiveChanged {
4022            run_id: "r".into(),
4023            paths: many[..10].to_vec(),
4024            count: 12,
4025        }
4026        .to_string();
4027        assert!(live.contains("and 2 more"), "{live}");
4028    }
4029}