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)
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    /// One or more apply preflight checks failed (23-02, DAPL-04). Exit 6.
896    /// Nothing was written to the gateway. The payload is BOXED so the large
897    /// list stays off every `Result<_, CoreError>`.
898    #[error("{}", deploy_preflight_failed_text(.failures))]
899    DeployPreflightFailed {
900        /// Every failed check, in the order the checks ran.
901        failures: Box<[PreflightFailure]>,
902    },
903
904    /// An interrupted deploy left its journal on the box (23-02, D-09). Exit
905    /// 6. The message, not the hint, carries the recovery command.
906    #[error(
907        "an interrupted deploy left the journal {location} for run {run_id}; \
908         recover it with: ign deploy rollback --run {run_id} --env {env}"
909    )]
910    DeployJournalPresent {
911        /// The interrupted run.
912        run_id: String,
913        /// The `--env` value the recovery command needs.
914        env: String,
915        /// The on-box journal location.
916        location: String,
917    },
918
919    /// Live files changed after the run wrote them, so a rollback would
920    /// overwrite someone's edit (23-02, DAPL-07). Exit 6. Carries paths and
921    /// counts only.
922    #[error("{}", deploy_live_changed_text(.run_id, .paths, *.count))]
923    DeployLiveChanged {
924        /// The run being rolled back.
925        run_id: String,
926        /// The first [`APPLY_LIST_SHOWN`] changed paths, byte-sorted.
927        paths: Vec<String>,
928        /// How many touched paths changed in all.
929        count: usize,
930    },
931
932    /// A run's record or snapshot cannot be trusted (23-02, D-04). Exit 6.
933    /// `problem` names fields and paths only, never file content.
934    #[error("deploy run {run_id} is unusable: {problem}")]
935    DeployRunInvalid {
936        /// The run id asked for.
937        run_id: String,
938        /// What is wrong.
939        problem: String,
940    },
941
942    /// A rollback could not restore the pre-apply files (23-02, D-12).
943    /// Exit 6. Boxed to keep `CoreError` under clippy's `result_large_err`.
944    #[error("{0}")]
945    RollbackFailed(Box<RollbackFailure>),
946
947    /// `deploy projects` refused a project the git module still manages
948    /// (25-07, DPRJ-04, D-11): the project's `.git` entry exists on the
949    /// gateway host. Exit 6 - target_state class: the gateway is not in a
950    /// state this deploy may touch. The tool never deletes a `.git`; an
951    /// administrator removes it per project. Carries the project name, the
952    /// location below the data directory and the kind (`directory`, `file`,
953    /// `symlink`, `other`) only.
954    #[error(
955        "project {project} is still managed by the git module: {location} is present ({kind}); an administrator removes it before this project is deployed"
956    )]
957    ProjectGitManaged {
958        /// The gateway project name.
959        project: String,
960        /// The `.git` path below the node's data directory.
961        location: String,
962        /// What the entry is.
963        kind: String,
964    },
965
966    /// `deploy projects` refused a project whose parent cannot be used
967    /// (25-10, DPRJ-02, D-05, K6): the parent named in the repo's
968    /// `project.json` is missing on the gateway, closes a cycle, or is not
969    /// inheritable. Exit 6 - target_state class: the gateway is not in a
970    /// state this deploy may build on. Carries names and the reason word
971    /// (`missing`, `cycle`, `not_inheritable`) only. The parent is only read.
972    #[error("project {project} cannot be deployed under parent {parent}: {reason}")]
973    ProjectParentInvalid {
974        /// The gateway project being deployed.
975        project: String,
976        /// The parent named in the repo's `project.json`.
977        parent: String,
978        /// `missing`, `cycle` or `not_inheritable`.
979        reason: String,
980    },
981
982    /// `deploy projects` refused an artifact that is not the one that was
983    /// reviewed (25-11, SC-1, D-01, V1): `--artifact-sha256` differs from the
984    /// canonical digest of the artifact the checkout builds. Exit 6 -
985    /// target_state class: the checkout is not in the state the reviewer saw.
986    /// The comparison runs after the local build and before any host or
987    /// gateway contact, so nothing was uploaded. Carries the project name and
988    /// the two hashes only.
989    #[error(
990        "artifact hash mismatch for project {project}: expected {expected}, the checkout builds {actual}; nothing was uploaded"
991    )]
992    ProjectHashMismatch {
993        /// The gateway project name.
994        project: String,
995        /// The `--artifact-sha256` the caller reviewed.
996        expected: String,
997        /// The canonical digest of the artifact this checkout builds.
998        actual: String,
999    },
1000}
1001
1002/// Items listed inline by the Phase 23 refusal Displays; the rest collapse
1003/// into `and {m} more`.
1004pub const APPLY_LIST_SHOWN: usize = 10;
1005
1006/// One failed apply preflight check (23-02). `detail` carries no value from
1007/// the gateway, the host or the repo that could be a secret.
1008#[derive(Debug, Clone, PartialEq, Eq)]
1009pub struct PreflightFailure {
1010    /// The check id, e.g. `mode_master` or `run_dir_not_ignored`.
1011    pub check: String,
1012    /// What failed, value-free.
1013    pub detail: String,
1014}
1015
1016/// Payload of [`CoreError::RollbackFailed`] (23-02).
1017#[derive(Debug, Clone, PartialEq, Eq)]
1018pub struct RollbackFailure {
1019    /// The run being restored.
1020    pub run_id: String,
1021    /// The `--env` value the retry command needs.
1022    pub env: String,
1023    /// The local run directory holding the snapshot, when one was made.
1024    pub run_dir: Option<String>,
1025    /// The on-box journal path, left in place for a manual recovery.
1026    pub journal: String,
1027    /// Why the restore failed; names paths and causes, never file content.
1028    pub cause: String,
1029}
1030
1031impl RollbackFailure {
1032    /// The on-box directory holding the run's pre-images
1033    /// (`snapshots/<run>/`), next to the journal.
1034    pub fn snapshot_dir(&self) -> String {
1035        match self.journal.rsplit_once('/') {
1036            Some((dir, _)) => format!("{dir}/snapshots/{}/", self.run_id),
1037            None => format!("snapshots/{}/", self.run_id),
1038        }
1039    }
1040}
1041
1042impl std::fmt::Display for RollbackFailure {
1043    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1044        // The run directory's `snapshot/` is made only after a restore has
1045        // succeeded, so it is never offered here: the intact pre-images are
1046        // the on-box `snapshots/<run>/` directory and, when one was taken, the
1047        // `.gwbk`.
1048        write!(
1049            f,
1050            "rollback of run {run} failed ({cause}); the gateway files may be half restored. \
1051             Retry with: ign deploy rollback --run {run} --env {env} (the journal at {journal} \
1052             and the snapshot are still on the host). If the retry reports a lost or damaged \
1053             snapshot, restore the files from the .gwbk backup (when this run took one) or from \
1054             the on-box snapshot {snapshot}, then delete the journal by hand. \
1055             run directory: {run_dir}",
1056            run = self.run_id,
1057            cause = self.cause,
1058            env = self.env,
1059            journal = self.journal,
1060            snapshot = self.snapshot_dir(),
1061            run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1062        )
1063    }
1064}
1065
1066/// `a, b, c and {m} more`: `shown` capped at [`APPLY_LIST_SHOWN`], then the
1067/// remainder of `total`.
1068fn listed(shown: &[String], total: usize) -> String {
1069    let take = shown.len().min(APPLY_LIST_SHOWN);
1070    let mut out = shown[..take].join(", ");
1071    if total > take {
1072        out.push_str(&format!(" and {} more", total - take));
1073    }
1074    out
1075}
1076
1077fn destructive_change_refused_text(gate: &str, keys: &[String], count: usize) -> String {
1078    let (flag, verb) = match gate {
1079        "drift" => ("--allow-drift <reason>", "apply"),
1080        "downgrade" => ("--allow-downgrade", "deploy projects"),
1081        "first_deploy" => ("--allow-first-change <reason>", "deploy projects"),
1082        _ => ("--allow-destructive", "apply"),
1083    };
1084    format!(
1085        "{gate} change refused for {count} entr{}: {}; {verb} needs {flag}",
1086        if count == 1 { "y" } else { "ies" },
1087        listed(keys, count)
1088    )
1089}
1090
1091fn deploy_blocked_text(keys: &[String]) -> String {
1092    format!(
1093        "deploy blocked: {} unit(s) are removed from the declared tree but a core copy \
1094         shadows them, so deleting them from the mode collection cannot take effect: {}",
1095        keys.len(),
1096        listed(keys, keys.len())
1097    )
1098}
1099
1100fn deploy_unreadable_text(node: &str, keys: &[String]) -> String {
1101    format!(
1102        "deploy refused: {} unit(s) the deploy would write or delete have a live path the \
1103         {node} host's sftp server cannot read, so their live copy is unknown: {}; nothing was \
1104         written",
1105        keys.len(),
1106        listed(keys, keys.len())
1107    )
1108}
1109
1110fn deploy_preflight_failed_text(failures: &[PreflightFailure]) -> String {
1111    let mut out = format!("deploy preflight failed ({} check(s)):", failures.len());
1112    for failure in failures {
1113        out.push_str(&format!("\n  {}: {}", failure.check, failure.detail));
1114    }
1115    out
1116}
1117
1118fn deploy_live_changed_text(run_id: &str, paths: &[String], count: usize) -> String {
1119    format!(
1120        "live files changed since run {run_id} wrote them ({count}): {}; restoring the \
1121         snapshot would overwrite them",
1122        listed(paths, count)
1123    )
1124}
1125
1126/// Findings shown inline by the deploy error Displays; the rest collapse
1127/// into `and {m} more`.
1128pub const DEPLOY_FINDINGS_SHOWN: usize = 20;
1129
1130/// One defect in a declared deploy tree (21-03).
1131#[derive(Debug, Clone, PartialEq, Eq)]
1132pub struct DeployFinding {
1133    /// Collection-relative file the finding is about.
1134    pub file: String,
1135    /// 1-based line, when the defect has a source position.
1136    pub line: Option<usize>,
1137    /// 1-based column, when the defect has a source position.
1138    pub column: Option<usize>,
1139    /// What is wrong. Never contains a value read from the tree.
1140    pub message: String,
1141}
1142
1143impl std::fmt::Display for DeployFinding {
1144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1145        match (self.line, self.column) {
1146            (Some(line), Some(column)) => {
1147                write!(f, "{}:{line}:{column}: {}", self.file, self.message)
1148            }
1149            _ => write!(f, "{}: {}", self.file, self.message),
1150        }
1151    }
1152}
1153
1154/// Payload of [`CoreError::DeployTreeInvalid`], boxed to keep
1155/// `CoreError` under clippy's `result_large_err` threshold.
1156#[derive(Debug, Clone, PartialEq, Eq)]
1157pub struct DeployTreeInvalidDetails {
1158    /// Every finding, in discovery order.
1159    pub findings: Vec<DeployFinding>,
1160}
1161
1162impl std::fmt::Display for DeployTreeInvalidDetails {
1163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1164        if let [only] = self.findings.as_slice() {
1165            return write!(f, "{only}");
1166        }
1167        write!(f, "{} findings: ", self.findings.len())?;
1168        write_findings(f, &self.findings, DEPLOY_FINDINGS_SHOWN)
1169    }
1170}
1171
1172/// Write `findings` joined by `; `, at most `shown` of them, then
1173/// `; and {m} more` when truncated.
1174fn write_findings(
1175    f: &mut std::fmt::Formatter<'_>,
1176    findings: &[DeployFinding],
1177    shown: usize,
1178) -> std::fmt::Result {
1179    for (i, finding) in findings.iter().take(shown).enumerate() {
1180        if i > 0 {
1181            f.write_str("; ")?;
1182        }
1183        write!(f, "{finding}")?;
1184    }
1185    if findings.len() > shown {
1186        write!(f, "; and {} more", findings.len() - shown)?;
1187    }
1188    Ok(())
1189}
1190
1191/// Payload of [`CoreError::EmbeddedSecret`], boxed to keep `CoreError`
1192/// under clippy's `result_large_err` threshold. Carries locations only:
1193/// there is deliberately no field that can hold a secret value.
1194#[derive(Debug, Clone, PartialEq, Eq)]
1195pub struct EmbeddedSecretDetails {
1196    /// Each entry is `<collection-relative file>: <dotted json path>`.
1197    pub paths: Vec<String>,
1198    /// Non-secret tree findings discovered in the same render.
1199    pub other_findings: Vec<DeployFinding>,
1200}
1201
1202impl std::fmt::Display for EmbeddedSecretDetails {
1203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1204        write!(f, "embedded secret at {} path(s): ", self.paths.len())?;
1205        let shown_paths = self.paths.len().min(DEPLOY_FINDINGS_SHOWN);
1206        f.write_str(&self.paths[..shown_paths].join("; "))?;
1207        if self.paths.len() > shown_paths {
1208            write!(f, "; and {} more", self.paths.len() - shown_paths)?;
1209        }
1210        if !self.other_findings.is_empty() {
1211            write!(
1212                f,
1213                "; also {} other tree finding(s): ",
1214                self.other_findings.len()
1215            )?;
1216            // The combined list stays within DEPLOY_FINDINGS_SHOWN entries.
1217            let room = DEPLOY_FINDINGS_SHOWN.saturating_sub(shown_paths);
1218            write_findings(f, &self.other_findings, room)?;
1219        }
1220        Ok(())
1221    }
1222}
1223
1224/// Why a resource write's signature was refused
1225/// ([`CoreError::ResourceSignatureRejected`]).
1226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1227pub enum SignatureReason {
1228    /// The gateway refused a signature that no longer matches: the
1229    /// resource changed since it was read.
1230    Stale,
1231    /// No usable signature was supplied: either refused before any
1232    /// request because it was empty, or a gateway refusal that the live
1233    /// capture shows is distinguishable.
1234    Missing,
1235}
1236
1237impl std::fmt::Display for SignatureReason {
1238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1239        f.write_str(match self {
1240            Self::Stale => "stale",
1241            Self::Missing => "missing",
1242        })
1243    }
1244}
1245
1246/// Payload of [`CoreError::ModuleDigestMismatch`], boxed to keep
1247/// `CoreError` under clippy's `result_large_err` threshold.
1248#[derive(Debug, Clone, PartialEq, Eq)]
1249pub struct ModuleDigestMismatchDetails {
1250    /// The [`crate::module::ModuleSpec::id`] being fetched.
1251    pub module: String,
1252    /// The requested version string.
1253    pub version: String,
1254    /// The asset URL the mismatched bytes were downloaded from.
1255    pub url: String,
1256    /// The release's published sha256 digest.
1257    pub expected: String,
1258    /// The sha256 digest of the bytes actually downloaded.
1259    pub actual: String,
1260}
1261
1262/// Payload of [`CoreError::ModuleDigestChanged`], boxed to keep
1263/// `CoreError` under clippy's `result_large_err` threshold.
1264#[derive(Debug, Clone, PartialEq, Eq)]
1265pub struct ModuleDigestChangedDetails {
1266    /// The [`crate::module::ModuleSpec::id`] being fetched.
1267    pub module: String,
1268    /// The requested version string.
1269    pub version: String,
1270    /// The digest of the artifact already cached for this version.
1271    pub cached_digest: String,
1272    /// The digest the feed currently publishes for this version.
1273    pub upstream_digest: String,
1274    /// Path of the untouched, still-usable cached artifact.
1275    pub cached_path: String,
1276}
1277
1278impl std::fmt::Display for ModuleDigestMismatchDetails {
1279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1280        write!(
1281            f,
1282            "module {:?} version {:?} digest mismatch at {}: expected {}, got {}",
1283            self.module, self.version, self.url, self.expected, self.actual
1284        )
1285    }
1286}
1287
1288impl std::fmt::Display for ModuleDigestChangedDetails {
1289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1290        write!(
1291            f,
1292            "module {:?} version {:?} digest changed upstream: cached {}, \
1293             upstream now reports {} (cached artifact kept at {})",
1294            self.module, self.version, self.cached_digest, self.upstream_digest, self.cached_path
1295        )
1296    }
1297}
1298
1299impl CoreError {
1300    /// Stable machine slug — public contract, never respell.
1301    pub fn code(&self) -> &'static str {
1302        match self {
1303            Self::Internal(_) => "internal",
1304            Self::ConfirmationRequired { .. } => "confirmation_required",
1305            Self::ProfilingRiskNotAccepted { .. } => "profiling_risk_not_accepted",
1306            Self::InvalidImportFile { .. } => "invalid_import_file",
1307            Self::ImportDenied { .. } => "import_denied",
1308            Self::InvalidInput { .. } => "invalid_input",
1309            Self::ProfileNotFound { .. } => "profile_not_found",
1310            Self::NoActiveProfile => "no_active_profile",
1311            Self::SecretUnavailable { .. } => "secret_unavailable",
1312            Self::PasswordUnavailable { .. } => "password_unavailable",
1313            Self::ConfigInvalid { .. } => "config_invalid",
1314            Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
1315            Self::Network { .. } => "network_error",
1316            Self::Auth { .. } => "auth_rejected",
1317            Self::GatewayTooOld { .. } => "gateway_too_old",
1318            Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
1319            Self::GatewayRestarting { .. } => "gateway_restarting",
1320            Self::NotFound { .. } => "not_found",
1321            Self::ProjectExists { .. } => "project_exists",
1322            Self::ResourceBinary { .. } => "resource_binary",
1323            Self::TrialNotExpired { .. } => "trial_not_expired",
1324            Self::ProviderNotFound { .. } => "provider_not_found",
1325            Self::RoutesNotDeployed { .. } => "routes_not_deployed",
1326            Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
1327            Self::RouteVersionMismatch { .. } => "route_version_mismatch",
1328            Self::WebdevRouteError { .. } => "webdev_route_error",
1329            Self::TagCollision { .. } => "tag_collision",
1330            Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
1331            Self::SessionNotPrunable { .. } => "session_not_prunable",
1332            Self::Rig(_) => "rig_error",
1333            Self::EamNotController { .. } => "eam_not_controller",
1334            Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
1335            Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
1336            Self::EamTaskInFlight { .. } => "eam_task_in_flight",
1337            Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
1338            Self::LintToolAbsent => "lint_tool_absent",
1339            Self::NodeToolAbsent { .. } => "node_tool_absent",
1340            Self::GatewayClientError { .. } => "gateway_client_error",
1341            Self::BundleNotAvailable { .. } => "bundle_not_available",
1342            Self::ModuleFeedUnreachable { .. } => "module_feed_unreachable",
1343            Self::ModuleReleaseNotFound { .. } => "module_release_not_found",
1344            Self::ModuleDigestMismatch(..) => "module_digest_mismatch",
1345            Self::ModuleFeedUnusable { .. } => "module_feed_unusable",
1346            Self::ModuleDigestChanged(..) => "module_digest_changed",
1347            Self::ModuleNotRegistered { .. } => "module_not_registered",
1348            Self::ModuleUninstallDenied { .. } => "module_uninstall_denied",
1349            Self::ScanLockHeld { .. } => "scan_lock_held",
1350            Self::ResourceSignatureRejected { .. } => "resource_signature_rejected",
1351            Self::ResourceExists { .. } => "resource_exists",
1352            Self::ModeExists { .. } => "mode_exists",
1353            Self::DeployTreeInvalid(..) => "deploy_tree_invalid",
1354            Self::EmbeddedSecret(..) => "embedded_secret",
1355            Self::TransportFailed { .. } => "transport_failed",
1356            Self::DeployLedgerInvalid { .. } => "deploy_ledger_invalid",
1357            Self::PlanStale { .. } => "plan_stale",
1358            Self::DestructiveChangeRefused { .. } => "destructive_change_refused",
1359            Self::DeployBlocked { .. } => "deploy_blocked",
1360            Self::DeployUnreadable { .. } => "deploy_unreadable",
1361            Self::DeployPreflightFailed { .. } => "deploy_preflight_failed",
1362            Self::DeployJournalPresent { .. } => "deploy_journal_present",
1363            Self::DeployLiveChanged { .. } => "deploy_live_changed",
1364            Self::DeployRunInvalid { .. } => "deploy_run_invalid",
1365            Self::RollbackFailed(..) => "rollback_failed",
1366            Self::ProjectGitManaged { .. } => "project_git_managed",
1367            Self::ProjectParentInvalid { .. } => "project_parent_invalid",
1368            Self::ProjectHashMismatch { .. } => "project_hash_mismatch",
1369        }
1370    }
1371
1372    /// The LOCKED exit-code mapping — the only place exit codes are decided.
1373    pub fn exit_code(&self) -> u8 {
1374        match self {
1375            Self::Internal(_) => 1,
1376            Self::ConfirmationRequired { .. }
1377            | Self::ProfilingRiskNotAccepted { .. }
1378            | Self::InvalidImportFile { .. }
1379            | Self::InvalidInput { .. }
1380            | Self::GatewayClientError { .. }
1381            | Self::DeployTreeInvalid(..)
1382            | Self::EmbeddedSecret(..)
1383            | Self::DestructiveChangeRefused { .. } => 2,
1384            Self::ProfileNotFound { .. }
1385            | Self::NoActiveProfile
1386            | Self::SecretUnavailable { .. }
1387            | Self::PasswordUnavailable { .. }
1388            | Self::ConfigInvalid { .. }
1389            | Self::PollIntervalTooSmall { .. }
1390            | Self::ModuleNotRegistered { .. } => 3,
1391            Self::Network { .. }
1392            | Self::ModuleFeedUnreachable { .. }
1393            | Self::TransportFailed { .. } => 4,
1394            Self::Auth { .. } => 5,
1395            Self::GatewayTooOld { .. }
1396            | Self::GatewayNotCommissioned { .. }
1397            | Self::GatewayRestarting { .. }
1398            | Self::NotFound { .. }
1399            | Self::ProjectExists { .. }
1400            | Self::ResourceBinary { .. }
1401            | Self::TrialNotExpired { .. }
1402            | Self::ProviderNotFound { .. }
1403            | Self::RoutesNotDeployed { .. }
1404            | Self::WebdevUnlicensed { .. }
1405            | Self::RouteVersionMismatch { .. }
1406            | Self::WebdevRouteError { .. }
1407            | Self::TagCollision { .. }
1408            | Self::AlarmJournalMissing { .. }
1409            | Self::SessionNotPrunable { .. }
1410            | Self::ImportDenied { .. }
1411            | Self::EamNotController { .. }
1412            | Self::ProviderRootUnsupported { .. }
1413            | Self::EamTaskTypeRefused { .. }
1414            | Self::EamTaskInFlight { .. }
1415            | Self::ScriptExecNotConfigured { .. }
1416            | Self::LintToolAbsent
1417            | Self::NodeToolAbsent { .. }
1418            | Self::BundleNotAvailable { .. }
1419            | Self::ModuleReleaseNotFound { .. }
1420            | Self::ModuleDigestMismatch(..)
1421            | Self::ModuleFeedUnusable { .. }
1422            | Self::ModuleDigestChanged(..)
1423            | Self::ModuleUninstallDenied { .. }
1424            | Self::ScanLockHeld { .. }
1425            | Self::ResourceSignatureRejected { .. }
1426            | Self::ResourceExists { .. }
1427            | Self::ModeExists { .. }
1428            | Self::DeployLedgerInvalid { .. }
1429            | Self::PlanStale { .. }
1430            | Self::DeployBlocked { .. }
1431            | Self::DeployUnreadable { .. }
1432            | Self::DeployPreflightFailed { .. }
1433            | Self::DeployJournalPresent { .. }
1434            | Self::DeployLiveChanged { .. }
1435            | Self::DeployRunInvalid { .. }
1436            | Self::RollbackFailed(..)
1437            | Self::ProjectGitManaged { .. }
1438            | Self::ProjectParentInvalid { .. }
1439            | Self::ProjectHashMismatch { .. } => 6,
1440            Self::Rig(_) => 7,
1441        }
1442    }
1443
1444    /// Actionable next step (CORE-05). Every class carries one.
1445    pub fn hint(&self) -> Option<String> {
1446        match self {
1447            Self::Internal(_) => Some(
1448                "internal errors are bugs; re-run with -vv and report the \
1449                 diagnostics output"
1450                    .to_string(),
1451            ),
1452            Self::ConfirmationRequired { .. } => Some(
1453                "this operation is destructive; re-run with --yes or set \
1454                   IGNITION_YES=1"
1455                    .to_string(),
1456            ),
1457            Self::ProfilingRiskNotAccepted { .. } => Some(
1458                "JVM profiling ACTIVELY instruments the gateway: stack sampling \
1459                   adds CPU overhead proportional to thread count x frequency, \
1460                   and flight recordings buffer in JVM memory and write to \
1461                   gateway disk. On a PRODUCTION server this belongs inside an \
1462                   approved change window with a rollback plan — never as a \
1463                   casual probe. --yes deliberately does NOT satisfy this gate; \
1464                   re-run with --i-accept-the-risk after confirming that"
1465                    .to_string(),
1466            ),
1467            Self::InvalidImportFile { .. } => Some(
1468                "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
1469                   most 512 MB — pass a file produced by `ign project export` \
1470                   via --file (or `-` to pipe one on stdin)"
1471                    .to_string(),
1472            ),
1473            Self::ImportDenied { problem, .. } => Some(format!(
1474                "the gateway refused the import over a 200 answer — the problem \
1475                   text above is the gateway's own; `ign project export` of the \
1476                   current state is the honest baseline for hand-editing ({problem})"
1477            )),
1478            Self::InvalidInput { reason } => Some(
1479                if reason == TUI_TTY_REFUSAL_REASON {
1480                    // The ONE contextual InvalidInput hint (06-07): the
1481                    // TTY refusal's fix is terminal-related — the
1482                    // --file/stdin default is meaningless for a pipe.
1483                    "run `ign tui` in an interactive terminal (the cockpit \
1484                     needs a TTY on stdout — not a pipe or redirect)"
1485                } else if reason == TOP_TTY_REFUSAL_REASON {
1486                    // itop's sibling refusal: same terminal-contextual
1487                    // fix, naming the MONITOR's command (a hint naming
1488                    // `ign tui` would not fix an `ign top` run).
1489                    "run `ign top` in an interactive terminal (the monitor \
1490                     needs a TTY on stdout — not a pipe or redirect)"
1491                } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
1492                    // The loss-gate refusal (11-07): the message above already names
1493                    // every finding and ends with the actionable guidance — the hint
1494                    // restates it for envelope readers instead of the file-read
1495                    // default, which is meaningless here (the file WAS readable).
1496                    "the loss report above names what this import would drop or \
1497                     coerce — re-run with --yes to import anyway"
1498                } else if reason.starts_with("--plan-sha256 ")
1499                    || reason.starts_with("--allow-drift ")
1500                    || reason.starts_with("--artifact-sha256 ")
1501                    || reason.starts_with("--release ")
1502                    || reason.starts_with("--allow-first-change ")
1503                    || reason.starts_with("deploy projects needs --artifact-sha256")
1504                {
1505                    // The deploy apply and deploy projects gate flags (23-02,
1506                    // 25-11): the --file/stdin default is meaningless for a
1507                    // flag value.
1508                    "correct the flag value named above and rerun; no host or gateway \
1509                     was contacted"
1510                } else {
1511                    "fix the input source — a readable file path via --file, or `-` \
1512                     to pipe the content on stdin"
1513                }
1514                .to_string(),
1515            ),
1516            Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
1517                "no profiles configured yet; run `ign profile add` to create \
1518                     one"
1519                .to_string()
1520            } else {
1521                format!(
1522                    "known profiles: {}; run `ign profile add` to add another",
1523                    known.join(", ")
1524                )
1525            }),
1526            Self::NoActiveProfile => Some(
1527                "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
1528                 active with `ign profile use`"
1529                    .to_string(),
1530            ),
1531            Self::SecretUnavailable { profile } => Some(format!(
1532                "set IGNITION_TOKEN (or token_env in the profile), or store a \
1533                 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
1534            )),
1535            Self::PasswordUnavailable { .. } => Some(
1536                "ign adopt logs in with the gateway admin password — export \
1537                 IGNITION_PASSWORD (env-only, never a flag)"
1538                    .to_string(),
1539            ),
1540            Self::ConfigInvalid { .. } => Some(
1541                "verify the config file is valid TOML with [profiles.NAME] \
1542                 tables; `ign profile add` writes a known-good one"
1543                    .to_string(),
1544            ),
1545            Self::PollIntervalTooSmall { profile } => Some(format!(
1546                "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
1547                 or remove the key to use the default cadence"
1548            )),
1549            Self::Network {
1550                url,
1551                observation,
1552                ..
1553            } => Some(match observation {
1554                Some(observation) => format!(
1555                    "the gateway answered but no terminal state arrived before the deadline — \
1556                     the last observation was: {observation}; address that state, not the \
1557                     connection ({url})"
1558                ),
1559                None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
1560            }),
1561            Self::Auth { status, .. } => Some(match status {
1562                401 => {
1563                    // 401 = token not recognized — the #1 setup failure is
1564                    // a key-only header (verified: key-only → 401, full
1565                    // `name:key` → 200; Basic is dead on 8.3 /data).
1566                    "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"
1567                }
1568                403 => {
1569                    // 403 = recognized but under-permitted (verified
1570                    // semantics; see 02-RESEARCH Auth §4/§5).
1571                    "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"
1572                }
1573                _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
1574            }
1575            .to_string()),
1576            Self::GatewayTooOld { minimum, .. } => {
1577                Some(format!("upgrade the gateway to at least {minimum}"))
1578            }
1579            Self::GatewayNotCommissioned { .. } => Some(
1580                "open http://<host>:<port>/welcome in a browser and complete the \
1581                 commissioning wizard"
1582                    .to_string(),
1583            ),
1584            Self::GatewayRestarting { .. } => Some(
1585                "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
1586            ),
1587            Self::NotFound { .. } => Some(
1588                "check the id/path; a 404 JSON 'No route match' body can also mean \
1589                  a pre-8.3 gateway"
1590                    .to_string(),
1591            ),
1592            Self::ProjectExists { .. } => Some(
1593                "the default collision policy refuses to overwrite; re-run with \
1594                  --collision-policy overwrite to replace it — overwrite \
1595                  REPLACES the ENTIRE project (resources absent from the ZIP \
1596                  are deleted; merge is Designer-only)"
1597                    .to_string(),
1598            ),
1599            Self::ResourceBinary { .. } => Some(
1600                "resource content is binary — use `ign project export`/`import` \
1601                  for data.bin-class resources"
1602                    .to_string(),
1603            ),
1604            Self::TrialNotExpired { .. } => Some(
1605                "wait for the trial to expire (watch `ign rig trial status`), or \
1606                  run `ign rig reset --yes` for a completely fresh trial volume"
1607                    .to_string(),
1608            ),
1609            Self::RoutesNotDeployed { route, .. } => Some(
1610                // CONTENT-ADDRESSED off the route (the
1611                // TUI_TTY_REFUSAL_REASON / loss-gate prose-prefix
1612                // pattern, same file): the testing bundle is a
1613                // SEPARATE deploy with its own flag, so the generic
1614                // `ign webdev deploy` hint would send the caller to a
1615                // command that does not install it. No new variant,
1616                // no new slug — the exit tables are untouched.
1617                if route.starts_with(TESTING_ROUTE_PREFIX) {
1618                    "run `ign webdev deploy --with-testing` to install the gateway \
1619                      testing bundle into the project (or bootstrap it in one shot \
1620                      with `ign adopt --project NAME --testing`), then retry"
1621                } else {
1622                    "run `ign webdev deploy` to install the CLI's WebDev routes into \
1623                      the gateway, then retry"
1624                }
1625                .to_string(),
1626            ),
1627            Self::WebdevUnlicensed { .. } => Some(
1628                "license the gateway — the WebDev module answers 402 while \
1629                  unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
1630                  expired trial)"
1631                    .to_string(),
1632            ),
1633            Self::RouteVersionMismatch { deployed, expected, .. } => {
1634                // Direction decides the fix (roadmap criterion): an older
1635                // deployed route → redeploy from THIS binary; a NEWER
1636                // deployed route → this CLI is behind (the route bundle
1637                // travels with the binary). Same slug either way.
1638                let newer = semver::Version::parse(deployed)
1639                    .ok()
1640                    .zip(semver::Version::parse(expected).ok())
1641                    .is_some_and(|(deployed, expected)| deployed > expected);
1642                Some(if newer {
1643                    "the deployed routes are NEWER than this CLI — update ign \
1644                      (the route bundle travels with the binary)"
1645                        .to_string()
1646                } else {
1647                    "run `ign webdev deploy` to redeploy the route version \
1648                      this CLI expects"
1649                        .to_string()
1650                })
1651            }
1652            Self::ProviderNotFound { .. } => Some(
1653                "check the provider name; `ign tags provider list` shows the \
1654                  gateway's tag providers"
1655                    .to_string(),
1656            ),
1657            Self::TagCollision { .. } => Some(
1658                "re-run with --collision-policy overwrite to replace the \
1659                  existing tags (destructive: requires --yes)"
1660                    .to_string(),
1661            ),
1662            Self::AlarmJournalMissing { .. } => Some(
1663                "alarm history needs a journal profile — provision a database \
1664                  connection + alarm-journal profile on the gateway (and point \
1665                  the general-alarm-settings singleton at it), then retry; see \
1666                  the README 'Alarm history' section"
1667                    .to_string(),
1668            ),
1669            Self::SessionNotPrunable { .. } => Some(
1670                "close the Designer first — prune removes stale entries only".to_string(),
1671            ),
1672            Self::WebdevRouteError { code, .. } => Some(if code == "secret_required" || code == "secret_mismatch" {
1673                "the scriptExec route is secret-gated — deploy it with `ign \
1674                  webdev deploy --with-script-exec` (the secret is generated \
1675                  and stored in the profile config at 0600); a mismatch means \
1676                  the route was deployed with a different secret: redeploy or \
1677                  pass --rotate-secret"
1678                    .to_string()
1679            } else {
1680                "the deployed route refused the action — the code and message \
1681                  are the route's stable contract; `ign webdev status` \
1682                  diagnoses the deployment"
1683                    .to_string()
1684            }),
1685            Self::EamNotController { .. } => Some(
1686                "flip the gateway's EAM role: config-resource PUT on \
1687                   com.inductiveautomation.eam/module-settings with \
1688                   installMode \"Controller\" (array body carrying the current \
1689                   signature) — a manual gateway-role decision this CLI \
1690                   deliberately does not automate; see the README 'EAM tasks' \
1691                   section"
1692                    .to_string(),
1693            ),
1694            Self::ProviderRootUnsupported { .. } => Some(
1695                "target a subtree path like [provider]folder — provider-ROOT \
1696                   forms ([default] alone, or a bare provider name) need an \
1697                   RPC context WebDev threads don't carry (8.3.3); subtree \
1698                   paths are the supported form"
1699                    .to_string(),
1700            ),
1701            Self::EamTaskTypeRefused { .. } => Some(
1702                "restore/install/upgrade tasks dispatch fleet-wide (every \
1703                   agent target); run them from the Ignition EAM console — \
1704                   EXT-03 (v2) will scope them into the CLI"
1705                    .to_string(),
1706            ),
1707            Self::EamTaskInFlight { .. } => Some(
1708                "complete or delete the leftover '(forced)' run from the EAM \
1709                   console — no ign verb deletes runs; the slot frees once the \
1710                   run is resolved"
1711                    .to_string(),
1712            ),
1713            Self::ScriptExecNotConfigured { .. } => Some(
1714                "run `ign webdev deploy --with-script-exec` to deploy the route and \
1715                  generate + persist its secret (the deploy flag IS the opt-in — \
1716                  `ign script run` has no --yes by design)"
1717                    .to_string(),
1718            ),
1719            Self::LintToolAbsent => Some(
1720                "install the linter: `uv tool install ignition-lint-toolkit` \
1721                 (or `pip install ignition-lint-toolkit`) — \
1722                 github.com/TheThoughtagen/ignition-lint; then re-run with \
1723                 ignition-lint on PATH"
1724                    .to_string(),
1725            ),
1726            // The SAME string `ign e2e doctor`'s node row hands out —
1727            // a user who hits this refusal and then runs the doctor
1728            // must not be given two wordings of one instruction.
1729            Self::NodeToolAbsent { .. } => {
1730                Some(crate::actions::e2e::NODE_INSTALL_HINT.to_string())
1731            }
1732            Self::GatewayClientError { .. } => Some(
1733                "the gateway rejected this request — the body above is the \
1734                 gateway's own answer; fix the path/method/body, or use a \
1735                 curated `ign` command when one exists"
1736                    .to_string(),
1737            ),
1738            Self::BundleNotAvailable { .. } => Some(
1739                "generate a fresh bundle with `ign diagnostics bundle generate`, \
1740                 then wait again — the gateway reports no current bundle and \
1741                 polling cannot produce one"
1742                    .to_string(),
1743            ),
1744            Self::ModuleFeedUnreachable { .. } => Some(
1745                "check network reachability to the module release feed — an \
1746                 already-cached version works fully offline, so this only \
1747                 blocks a first-time fetch of a new version"
1748                    .to_string(),
1749            ),
1750            Self::ModuleReleaseNotFound {
1751                module, version, ..
1752            } => Some(format!(
1753                "check that {module} version {version} exists as a release upstream \
1754                 (the tag and asset name must match exactly) — this CLI never falls \
1755                 back to \"latest\""
1756            )),
1757            Self::ModuleDigestMismatch(d) => Some(format!(
1758                "the downloaded bytes for {} version {} do not match \
1759                 the feed's published digest — do not trust this artifact; nothing \
1760                 was cached, so re-running the fetch tries again from scratch",
1761                d.module,
1762                d.version
1763            )),
1764            Self::ModuleFeedUnusable { .. } => Some(
1765                "the module release feed answered but was not usable — if the \
1766                 detail names a rate limit, wait and retry; otherwise the feed's \
1767                 response itself needs investigation"
1768                    .to_string(),
1769            ),
1770            Self::ModuleDigestChanged(d) => Some(format!(
1771                "upstream re-released this version with different bytes — the cached \
1772                 artifact at {} is still there and usable offline; re-run with \
1773                 FetchPolicy::AcceptUpstreamChange to deliberately accept and verify the \
1774                 new bytes",
1775                d.cached_path
1776            )),
1777            Self::Rig(_) => Some(
1778                "check Docker is running and inspect the rig containers \
1779                 (docker ps)"
1780                    .to_string(),
1781            ),
1782            Self::ModuleNotRegistered { known, .. } => Some(if known.is_empty() {
1783                "no modules are registered in this build of ign".to_string()
1784            } else {
1785                format!(
1786                    "known module ids: {} — check the id passed to --with-module \
1787                     or declared under [rigs.NAME.modules.*]",
1788                    known.join(", ")
1789                )
1790            }),
1791            Self::ModuleUninstallDenied { .. } => Some(
1792                "the gateway refuses to uninstall a module whose .modl is still \
1793                 mounted — remove it from [rigs.NAME.modules], run `ign rig up` \
1794                 so the mount goes, then retry; a successful uninstall is NOT \
1795                 undone by re-declaring the module — recovery today means \
1796                 `ign rig reset --yes`, which destroys the rig's data volume"
1797                    .to_string(),
1798            ),
1799            Self::ScanLockHeld { .. } => Some(
1800                "the scan-lock is released when the holder's scan runs or its \
1801                 holdTimeout expires — wait for that, then retry"
1802                    .to_string(),
1803            ),
1804            Self::ResourceSignatureRejected { .. } => Some(
1805                "re-read the resource for its current signature and retry — \
1806                 signatures change on every write"
1807                    .to_string(),
1808            ),
1809            Self::ResourceExists { .. } => Some(
1810                "a resource of that name is already present — read it for its \
1811                 current signature and update it, or choose another name"
1812                    .to_string(),
1813            ),
1814            Self::ModeExists { .. } => Some(
1815                "that deployment mode is already defined — list the modes and \
1816                 skip the create"
1817                    .to_string(),
1818            ),
1819            Self::DeployTreeInvalid(..) => Some(
1820                "fix each file named above in the declared gateway tree; render \
1821                 refuses until every finding is resolved"
1822                    .to_string(),
1823            ),
1824            Self::EmbeddedSecret(..) => Some(
1825                "store the value in the gateway's secret provider and reference it \
1826                 as {\"type\":\"Referenced\",\"data\":{\"providerName\":\"…\",\
1827                 \"secretName\":\"…\"}}; never commit an Embedded or plaintext secret"
1828                    .to_string(),
1829            ),
1830            Self::TransportFailed { .. } => Some(
1831                "check the node's host access in targets/<env>.yaml: for `local`, that \
1832                 data_dir exists and is readable; for `ssh`, the pinned host key, the \
1833                 key and reachability. Nothing was read as absent, so no plan was made"
1834                    .to_string(),
1835            ),
1836            Self::DeployLedgerInvalid { .. } => Some(
1837                "the delete set is never assumed empty, so no plan was made; inspect or \
1838                 restore the ledger file named above (only an apply writes it) and rerun"
1839                    .to_string(),
1840            ),
1841            Self::PlanStale { detail, .. } if detail.starts_with("live project") => Some(
1842                "the live project changed after the plan was drawn; run the check again, \
1843                 review what it reports and rerun the deploy, nothing was imported"
1844                    .to_string(),
1845            ),
1846            Self::PlanStale { .. } => Some(
1847                "the plan changed after it was reviewed; review the new plan and rerun \
1848                 with its plan_sha256, nothing was written"
1849                    .to_string(),
1850            ),
1851            Self::DestructiveChangeRefused { gate, .. } => Some(match gate.as_str() {
1852                "drift" => "live files differ from both the base render and the declared tree; \
1853                     review the drift, then rerun with --allow-drift and a reason of at most \
1854                     500 characters"
1855                    .to_string(),
1856                "downgrade" => "the gateway last received a newer release of this project; \
1857                     deploy the newer release, or rerun with --allow-downgrade when the older \
1858                     release must replace it"
1859                    .to_string(),
1860                "first_deploy" => "this is the first deploy of the project by this tool and it \
1861                     would change what the gateway runs; release what the gateway runs first, \
1862                     or rerun with --allow-first-change and a reason"
1863                    .to_string(),
1864                _ => "review the destructive entries named above, then rerun with \
1865                     --allow-destructive and the plan_sha256 you reviewed"
1866                    .to_string(),
1867            }),
1868            Self::DeployBlocked { .. } => Some(
1869                "a core copy still shadows each unit named above, so no flag permits the \
1870                 removal; keep the unit in the declared tree, or remove the core copy on \
1871                 the gateway first"
1872                    .to_string(),
1873            ),
1874            Self::DeployUnreadable { .. } => Some(
1875                "the live path of each unit named above is too long for the host's sftp \
1876                 server; shorten it on the gateway (rename the folder), or, when the gateway \
1877                 owns the provider, list the provider in exclude_tag_providers and stop \
1878                 declaring it; nothing was written"
1879                    .to_string(),
1880            ),
1881            Self::DeployPreflightFailed { .. } => Some(
1882                "fix each failed check named above and rerun; no gateway file was written"
1883                    .to_string(),
1884            ),
1885            Self::DeployJournalPresent { .. } => Some(
1886                "an earlier deploy was interrupted and its journal is still on the box; run \
1887                 the recovery command in the message, then rerun; plan and apply refuse \
1888                 until the journal is cleared"
1889                    .to_string(),
1890            ),
1891            Self::DeployLiveChanged { .. } => Some(
1892                "someone changed the files named above after the run wrote them; review \
1893                 those changes, then rerun the rollback with --discard-live-changes to \
1894                 restore the snapshot over them"
1895                    .to_string(),
1896            ),
1897            Self::DeployRunInvalid { .. } => Some(
1898                "the run directory or the on-box record is missing or damaged; restore the \
1899                 files from the .gwbk backup or the run-directory snapshot instead"
1900                    .to_string(),
1901            ),
1902            Self::RollbackFailed(..) => Some(
1903                "do not apply again until the files are restored: run the retry command in \
1904                 the message first, because the journal and the snapshot are usually intact; \
1905                 only if it reports a lost or damaged snapshot restore from the .gwbk backup \
1906                 or the on-box snapshot named there, and then delete the journal at the path \
1907                 in the message by hand"
1908                    .to_string(),
1909            ),
1910            Self::ProjectGitManaged { .. } => Some(
1911                "follow the README cutover runbook: remove this project's .git on the \
1912                 gateway (the deploy tool never deletes it), then run the check again"
1913                    .to_string(),
1914            ),
1915            Self::ProjectParentInvalid { reason, .. } => Some(
1916                match reason.as_str() {
1917                    "missing" => "deploy the parent project from its own repo first, then run \
1918                                  the check again",
1919                    "cycle" => "fix the parent in project.json so the inheritance has no cycle",
1920                    _ => "the parent is not inheritable; fix the parent in project.json or \
1921                          make the parent inheritable",
1922                }
1923                .to_string(),
1924            ),
1925            Self::ProjectHashMismatch { .. } => Some(
1926                "the checkout is not the reviewed one; rerun the check on this commit and \
1927                 pass its artifact_sha256"
1928                    .to_string(),
1929            ),
1930        }
1931    }
1932
1933    /// URL/path of the request involved, when one was — populated for the
1934    /// network, auth, and target-state classes (CORE-05).
1935    pub fn endpoint(&self) -> Option<String> {
1936        match self {
1937            Self::Network { url, .. } => Some(url.clone()),
1938            Self::Auth { endpoint, .. } => endpoint.clone(),
1939            Self::GatewayTooOld { endpoint, .. }
1940            | Self::GatewayNotCommissioned { endpoint }
1941            | Self::GatewayRestarting { endpoint }
1942            | Self::NotFound { endpoint }
1943            | Self::ProjectExists { endpoint, .. }
1944            | Self::ResourceBinary { endpoint, .. }
1945            | Self::TrialNotExpired { endpoint, .. }
1946            | Self::ProviderNotFound { endpoint, .. }
1947            | Self::RoutesNotDeployed { endpoint, .. }
1948            | Self::WebdevUnlicensed { endpoint }
1949            | Self::RouteVersionMismatch { endpoint, .. }
1950            | Self::WebdevRouteError { endpoint, .. }
1951            | Self::TagCollision { endpoint, .. }
1952            | Self::AlarmJournalMissing { endpoint }
1953            | Self::SessionNotPrunable { endpoint, .. }
1954            | Self::ImportDenied { endpoint, .. }
1955            | Self::EamNotController { endpoint }
1956            | Self::ProviderRootUnsupported { endpoint }
1957            | Self::EamTaskInFlight { endpoint, .. }
1958            | Self::ModuleUninstallDenied { endpoint, .. }
1959            | Self::ScanLockHeld { endpoint, .. }
1960            | Self::ResourceSignatureRejected { endpoint, .. }
1961            | Self::ResourceExists { endpoint, .. }
1962            | Self::ModeExists { endpoint, .. } => endpoint.clone(),
1963            Self::ModuleFeedUnreachable { url, .. }
1964            | Self::ModuleReleaseNotFound { url, .. }
1965            | Self::ModuleFeedUnusable { url, .. } => Some(url.clone()),
1966            // Boxed payload, so it cannot share the or-pattern above.
1967            Self::ModuleDigestMismatch(d) => Some(d.url.clone()),
1968            _ => None,
1969        }
1970    }
1971
1972    /// The deploy slugs (21-03), in the order the README row lists them.
1973    pub const DEPLOY_SLUGS: &'static [&'static str] = &[
1974        "deploy_tree_invalid",
1975        "embedded_secret",
1976        "transport_failed",
1977        "deploy_ledger_invalid",
1978        "plan_stale",
1979        "destructive_change_refused",
1980        "deploy_blocked",
1981        "deploy_preflight_failed",
1982        "deploy_journal_present",
1983        "deploy_live_changed",
1984        "deploy_run_invalid",
1985        "rollback_failed",
1986        "project_git_managed",
1987        "project_parent_invalid",
1988        "project_hash_mismatch",
1989        "deploy_unreadable",
1990    ];
1991
1992    /// A single positioned (or file-only, when `line`/`column` are
1993    /// `None`) tree finding as a [`Self::DeployTreeInvalid`].
1994    pub fn deploy_tree_invalid(
1995        file: impl Into<String>,
1996        line: Option<usize>,
1997        column: Option<usize>,
1998        message: impl Into<String>,
1999    ) -> Self {
2000        Self::deploy_findings(vec![DeployFinding {
2001            file: file.into(),
2002            line,
2003            column,
2004            message: message.into(),
2005        }])
2006    }
2007
2008    /// Several tree findings as one [`Self::DeployTreeInvalid`]. An empty
2009    /// vector is a programming error: debug builds assert, release builds
2010    /// still return one generic finding so the error is never empty.
2011    pub fn deploy_findings(findings: Vec<DeployFinding>) -> Self {
2012        debug_assert!(!findings.is_empty(), "deploy_findings needs a finding");
2013        let findings = if findings.is_empty() {
2014            vec![DeployFinding {
2015                file: String::new(),
2016                line: None,
2017                column: None,
2018                message: "the declared tree is invalid".to_string(),
2019            }]
2020        } else {
2021            findings
2022        };
2023        Self::DeployTreeInvalid(Box::new(DeployTreeInvalidDetails { findings }))
2024    }
2025
2026    /// An embedded or plaintext secret at each of `paths` (locations
2027    /// only, never values), plus any non-secret findings from the same
2028    /// render.
2029    pub fn embedded_secret(paths: Vec<String>, other_findings: Vec<DeployFinding>) -> Self {
2030        Self::EmbeddedSecret(Box::new(EmbeddedSecretDetails {
2031            paths,
2032            other_findings,
2033        }))
2034    }
2035
2036    /// The `ign tui` TTY refusal (06-07): InvalidInput carrying the
2037    /// ONE reason whose hint is terminal-contextual — see
2038    /// [`TUI_TTY_REFUSAL_REASON`]. Same slug, same exit 2 (frozen
2039    /// taxonomy; only the hint differs).
2040    pub fn tui_tty_refusal() -> Self {
2041        Self::InvalidInput {
2042            reason: TUI_TTY_REFUSAL_REASON.to_string(),
2043        }
2044    }
2045
2046    /// The `ign top` TTY refusal (itop): the tui refusal's sibling at
2047    /// one command over — same slug, same exit 2, the hint naming the
2048    /// monitor's command. See [`TOP_TTY_REFUSAL_REASON`].
2049    pub fn top_tty_refusal() -> Self {
2050        Self::InvalidInput {
2051            reason: TOP_TTY_REFUSAL_REASON.to_string(),
2052        }
2053    }
2054
2055    /// Build the LOCKED failure envelope for this error (field order is part
2056    /// of the golden contract: `ok`, `profile`, `error` then `code`,
2057    /// `message`, `endpoint`, `hint`).
2058    pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
2059        ErrorEnvelope {
2060            ok: false,
2061            profile,
2062            error: ErrorBody {
2063                code: self.code(),
2064                message: self.to_string(),
2065                endpoint: self.endpoint(),
2066                hint: self.hint(),
2067            },
2068        }
2069    }
2070}
2071
2072/// LOCKED failure envelope shape: exactly the top-level fields `ok`,
2073/// `profile`, `error` — changing the set is a breaking change for agents.
2074#[derive(Debug, Serialize)]
2075pub struct ErrorEnvelope<'a> {
2076    /// Always `false` in this envelope.
2077    pub ok: bool,
2078    /// Active profile echoed in every output (CORE-01); `None` until config
2079    /// resolution lands.
2080    pub profile: Option<&'a str>,
2081    /// The typed error body.
2082    pub error: ErrorBody,
2083}
2084
2085/// LOCKED error body: `code` (stable slug), `message` (human-readable),
2086/// `endpoint` (when a request was involved), `hint` (actionable next step).
2087#[derive(Debug, Serialize)]
2088pub struct ErrorBody {
2089    /// Stable slug from [`CoreError::code`] — never respelled.
2090    pub code: &'static str,
2091    /// Human-readable description.
2092    pub message: String,
2093    /// URL/path when a request was involved.
2094    pub endpoint: Option<String>,
2095    /// Actionable next step.
2096    pub hint: Option<String>,
2097}
2098
2099#[cfg(test)]
2100mod tests {
2101    use super::{
2102        CoreError, DeployFinding, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
2103        GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX,
2104        ModuleDigestChangedDetails, ModuleDigestMismatchDetails, PreflightFailure, RollbackFailure,
2105        SignatureReason, TESTING_ROUTE_PREFIX, truncate_api_body,
2106    };
2107
2108    /// Build a real `reqwest::Error` for the Network variant: a request to
2109    /// an unroutable loopback port fails at connect time (instant refusal —
2110    /// `reqwest::Error` has no public constructor).
2111    fn network_error() -> CoreError {
2112        // reqwest is built with `rustls-no-provider`, so a raw
2113        // `reqwest::get` panics unless the process-wide provider is
2114        // installed. In a full lib-test run some other test happens to
2115        // install it first; running the error tests ALONE (which the
2116        // exit-table verify command does) hit the panic. The install is
2117        // idempotent and documented for exactly this use.
2118        crate::client::install_crypto_provider();
2119        let rt = tokio::runtime::Builder::new_current_thread()
2120            .enable_all()
2121            .build()
2122            .expect("test runtime");
2123        let url = "http://127.0.0.1:1";
2124        let source = rt
2125            .block_on(reqwest::get(url))
2126            .expect_err("request to an unroutable port must fail");
2127        CoreError::Network {
2128            url: url.to_string(),
2129            source: Some(source),
2130            observation: None,
2131        }
2132    }
2133
2134    /// The [`CoreError::ModuleFeedUnreachable`] twin of [`network_error`]
2135    /// — a real `reqwest::Error` from an unroutable loopback port, for
2136    /// the offline-module-feed exit-4 case.
2137    fn module_feed_unreachable_error() -> CoreError {
2138        crate::client::install_crypto_provider();
2139        let rt = tokio::runtime::Builder::new_current_thread()
2140            .enable_all()
2141            .build()
2142            .expect("test runtime");
2143        let url = "http://127.0.0.1:1";
2144        let source = rt
2145            .block_on(reqwest::get(url))
2146            .expect_err("request to an unroutable port must fail");
2147        CoreError::ModuleFeedUnreachable {
2148            url: url.to_string(),
2149            source: Some(source),
2150        }
2151    }
2152
2153    /// Pitfall-5 guard: the FULL 1–7 taxonomy enumerated on day one so no
2154    /// later phase can silently renumber it or respell a slug. The slugs are
2155    /// asserted against literals — that IS the stability contract.
2156    #[test]
2157    fn exit_code_mapping_enumerated() {
2158        let cases: Vec<(CoreError, u8, &'static str)> = vec![
2159            (CoreError::Internal("boom".into()), 1, "internal"),
2160            (
2161                CoreError::ConfirmationRequired {
2162                    operation: "project download".into(),
2163                },
2164                2,
2165                "confirmation_required",
2166            ),
2167            (
2168                CoreError::ProfilingRiskNotAccepted {
2169                    operation: "jvm sample".into(),
2170                },
2171                2,
2172                "profiling_risk_not_accepted",
2173            ),
2174            (
2175                CoreError::InvalidImportFile {
2176                    reason: "missing ZIP magic".into(),
2177                },
2178                2,
2179                "invalid_import_file",
2180            ),
2181            (
2182                CoreError::InvalidInput {
2183                    reason: "cannot read put.json".into(),
2184                },
2185                2,
2186                "invalid_input",
2187            ),
2188            (
2189                CoreError::ProfileNotFound {
2190                    name: "nope".into(),
2191                    known: vec!["dev".into()],
2192                },
2193                3,
2194                "profile_not_found",
2195            ),
2196            (CoreError::NoActiveProfile, 3, "no_active_profile"),
2197            (
2198                CoreError::SecretUnavailable {
2199                    profile: "dev".into(),
2200                },
2201                3,
2202                "secret_unavailable",
2203            ),
2204            (
2205                CoreError::PasswordUnavailable {
2206                    profile: "dev".into(),
2207                },
2208                3,
2209                "password_unavailable",
2210            ),
2211            (
2212                CoreError::ConfigInvalid {
2213                    reason: "bad toml".into(),
2214                },
2215                3,
2216                "config_invalid",
2217            ),
2218            (
2219                CoreError::PollIntervalTooSmall {
2220                    profile: "dev".into(),
2221                },
2222                3,
2223                "poll_interval_too_small",
2224            ),
2225            (network_error(), 4, "network_error"),
2226            (
2227                CoreError::Auth {
2228                    status: 401,
2229                    endpoint: None,
2230                },
2231                5,
2232                "auth_rejected",
2233            ),
2234            (
2235                CoreError::GatewayTooOld {
2236                    found: "8.1.0".into(),
2237                    minimum: "8.3.1".into(),
2238                    endpoint: None,
2239                },
2240                6,
2241                "gateway_too_old",
2242            ),
2243            (
2244                CoreError::GatewayNotCommissioned {
2245                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2246                },
2247                6,
2248                "gateway_not_commissioned",
2249            ),
2250            (
2251                CoreError::GatewayRestarting {
2252                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2253                },
2254                6,
2255                "gateway_restarting",
2256            ),
2257            (
2258                CoreError::NotFound {
2259                    endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
2260                },
2261                6,
2262                "not_found",
2263            ),
2264            (
2265                CoreError::ProjectExists {
2266                    name: "PlantFloor".into(),
2267                    endpoint: None,
2268                },
2269                6,
2270                "project_exists",
2271            ),
2272            (
2273                CoreError::ResourceBinary {
2274                    path: "com.x/perspective/session-permissions".into(),
2275                    endpoint: None,
2276                },
2277                6,
2278                "resource_binary",
2279            ),
2280            (
2281                CoreError::TrialNotExpired {
2282                    remaining_s: 6590,
2283                    endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
2284                },
2285                6,
2286                "trial_not_expired",
2287            ),
2288            (
2289                CoreError::ProviderNotFound {
2290                    name: "nope".into(),
2291                    endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
2292                },
2293                6,
2294                "provider_not_found",
2295            ),
2296            (
2297                CoreError::RoutesNotDeployed {
2298                    project: "ign-cli".into(),
2299                    route: "tags".into(),
2300                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2301                },
2302                6,
2303                "routes_not_deployed",
2304            ),
2305            (
2306                CoreError::WebdevUnlicensed {
2307                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2308                },
2309                6,
2310                "webdev_unlicensed",
2311            ),
2312            (
2313                CoreError::RouteVersionMismatch {
2314                    route: "tags".into(),
2315                    deployed: "0.9.0".into(),
2316                    expected: "1.0.0".into(),
2317                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2318                },
2319                6,
2320                "route_version_mismatch",
2321            ),
2322            (
2323                CoreError::WebdevRouteError {
2324                    code: "route_error".into(),
2325                    message: "boom".into(),
2326                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2327                },
2328                6,
2329                "webdev_route_error",
2330            ),
2331            (
2332                CoreError::TagCollision {
2333                    provider: "p5import".into(),
2334                    names: vec!["T1".into(), "P5".into()],
2335                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2336                },
2337                6,
2338                "tag_collision",
2339            ),
2340            (
2341                CoreError::AlarmJournalMissing {
2342                    endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
2343                },
2344                6,
2345                "alarm_journal_missing",
2346            ),
2347            (
2348                CoreError::ImportDenied {
2349                    project: "PlantFloor".into(),
2350                    problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
2351                    endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
2352                },
2353                6,
2354                "import_denied",
2355            ),
2356            (
2357                CoreError::SessionNotPrunable {
2358                    id: "d-live-1".into(),
2359                    endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
2360                },
2361                6,
2362                "session_not_prunable",
2363            ),
2364            (
2365                CoreError::EamNotController {
2366                    endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
2367                },
2368                6,
2369                "eam_not_controller",
2370            ),
2371            (
2372                CoreError::ProviderRootUnsupported {
2373                    endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
2374                },
2375                6,
2376                "provider_root_unsupported",
2377            ),
2378            (
2379                CoreError::EamTaskTypeRefused {
2380                    task_type: "eam_remoteUpgrade".into(),
2381                },
2382                6,
2383                "eam_task_type_refused",
2384            ),
2385            (
2386                CoreError::EamTaskInFlight {
2387                    task: "cli-research-backup".into(),
2388                    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(),
2389                    endpoint: Some(
2390                        "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
2391                            .into(),
2392                    ),
2393                },
2394                6,
2395                "eam_task_in_flight",
2396            ),
2397            (
2398                CoreError::ScriptExecNotConfigured {
2399                    profile: "dev".into(),
2400                },
2401                6,
2402                "script_exec_not_configured",
2403            ),
2404            (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
2405            (
2406                CoreError::NodeToolAbsent {
2407                    tool: "node".into(),
2408                },
2409                6,
2410                "node_tool_absent",
2411            ),
2412            (
2413                CoreError::BundleNotAvailable {
2414                    state: "Invalid".into(),
2415                },
2416                6,
2417                "bundle_not_available",
2418            ),
2419            (
2420                CoreError::GatewayClientError {
2421                    status: 400,
2422                    endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
2423                    body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
2424                },
2425                2,
2426                "gateway_client_error",
2427            ),
2428            (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
2429            (
2430                module_feed_unreachable_error(),
2431                4,
2432                "module_feed_unreachable",
2433            ),
2434            (
2435                CoreError::ModuleReleaseNotFound {
2436                    module: "git".into(),
2437                    version: "99.99.99".into(),
2438                    url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v99.99.99".into(),
2439                    detail: "no release exists for tag \"v99.99.99\"".into(),
2440                },
2441                6,
2442                "module_release_not_found",
2443            ),
2444            (
2445                CoreError::ModuleDigestMismatch(Box::new(ModuleDigestMismatchDetails {
2446                    module: "git".into(),
2447                    version: "2.3.4".into(),
2448                    url: "https://release-assets.githubusercontent.com/Git-2.3.4-signed.modl".into(),
2449                    expected: "b".repeat(64),
2450                    actual: "c".repeat(64),
2451                })),
2452                6,
2453                "module_digest_mismatch",
2454            ),
2455            (
2456                CoreError::ModuleFeedUnusable {
2457                    url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v2.3.4".into(),
2458                    detail: "rate limit exceeded".into(),
2459                },
2460                6,
2461                "module_feed_unusable",
2462            ),
2463            (
2464                CoreError::ModuleDigestChanged(Box::new(ModuleDigestChangedDetails {
2465                    module: "git".into(),
2466                    version: "2.3.4".into(),
2467                    cached_digest: "a".repeat(64),
2468                    upstream_digest: "b".repeat(64),
2469                    cached_path: "/cache/modules/git/2.3.4-aaaa.modl".into(),
2470                })),
2471                6,
2472                "module_digest_changed",
2473            ),
2474            (
2475                CoreError::ModuleNotRegistered {
2476                    id: "bogus".into(),
2477                    known: vec!["git".into()],
2478                },
2479                3,
2480                "module_not_registered",
2481            ),
2482            (
2483                CoreError::ModuleUninstallDenied {
2484                    module_id: "git".into(),
2485                    gateway_module_id: "com.axone_io.ignition.git".into(),
2486                    failed: vec!["com.axone_io.ignition.git".into()],
2487                    endpoint: Some(
2488                        "http://gw:8088/data/api/v1/modules/uninstall".into(),
2489                    ),
2490                },
2491                6,
2492                "module_uninstall_denied",
2493            ),
2494            (
2495                CoreError::ScanLockHeld {
2496                    actor: Some("api-token:cap-a:172.17.0.1".into()),
2497                    kind: "config".into(),
2498                    endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2499                },
2500                6,
2501                "scan_lock_held",
2502            ),
2503            (
2504                CoreError::ResourceSignatureRejected {
2505                    name: "DB_A".into(),
2506                    reason: SignatureReason::Stale,
2507                    endpoint: Some(
2508                        "http://gw:8088/data/api/v1/resources/find/ignition/database-connection/DB_A"
2509                            .into(),
2510                    ),
2511                },
2512                6,
2513                "resource_signature_rejected",
2514            ),
2515            (
2516                CoreError::ResourceExists {
2517                    name: "DB_A".into(),
2518                    endpoint: Some("http://gw:8088/data/api/v1/resources/ignition/database-connection".into()),
2519                },
2520                6,
2521                "resource_exists",
2522            ),
2523            (
2524                CoreError::ModeExists {
2525                    name: "production".into(),
2526                    endpoint: Some("http://gw:8088/data/api/v1/mode".into()),
2527                },
2528                6,
2529                "mode_exists",
2530            ),
2531            (
2532                CoreError::deploy_tree_invalid("manifest.yaml", Some(2), Some(1), "unknown field"),
2533                2,
2534                "deploy_tree_invalid",
2535            ),
2536            (
2537                CoreError::embedded_secret(vec!["a.json: settings.password".into()], vec![]),
2538                2,
2539                "embedded_secret",
2540            ),
2541            (
2542                CoreError::TransportFailed {
2543                    operation: "read".into(),
2544                    node: "nodes.master".into(),
2545                    detail: "cannot read directory config/resources (NotFound)".into(),
2546                },
2547                4,
2548                "transport_failed",
2549            ),
2550            (
2551                CoreError::DeployLedgerInvalid {
2552                    location: "ign-deploy/gw/dev/ledger.json".into(),
2553                    problem: "files is not byte-sorted".into(),
2554                },
2555                6,
2556                "deploy_ledger_invalid",
2557            ),
2558            (
2559                CoreError::PlanStale {
2560                    expected: "a".repeat(64),
2561                    actual: "b".repeat(64),
2562                    detail: "review hash".into(),
2563                },
2564                6,
2565                "plan_stale",
2566            ),
2567            (
2568                CoreError::DestructiveChangeRefused {
2569                    gate: "destructive".into(),
2570                    keys: vec!["ignition/a/x".into()],
2571                    count: 1,
2572                },
2573                2,
2574                "destructive_change_refused",
2575            ),
2576            (
2577                CoreError::DeployBlocked {
2578                    keys: vec!["ignition/a/x".into()],
2579                },
2580                6,
2581                "deploy_blocked",
2582            ),
2583            (
2584                CoreError::DeployPreflightFailed {
2585                    failures: vec![PreflightFailure {
2586                        check: "mode_master".into(),
2587                        detail: "the gateway is not in the declared mode".into(),
2588                    }]
2589                    .into(),
2590                },
2591                6,
2592                "deploy_preflight_failed",
2593            ),
2594            (
2595                CoreError::DeployJournalPresent {
2596                    run_id: "r1".into(),
2597                    env: "prod".into(),
2598                    location: "ign-deploy/gw/m/journal.json".into(),
2599                },
2600                6,
2601                "deploy_journal_present",
2602            ),
2603            (
2604                CoreError::DeployLiveChanged {
2605                    run_id: "r1".into(),
2606                    paths: vec!["a/b.json".into()],
2607                    count: 1,
2608                },
2609                6,
2610                "deploy_live_changed",
2611            ),
2612            (
2613                CoreError::DeployRunInvalid {
2614                    run_id: "r1".into(),
2615                    problem: "record.json is missing".into(),
2616                },
2617                6,
2618                "deploy_run_invalid",
2619            ),
2620            (
2621                CoreError::RollbackFailed(Box::new(RollbackFailure {
2622                    run_id: "r1".into(),
2623                    env: "dev".into(),
2624                    run_dir: Some(".ign/deploy/r1".into()),
2625                    journal: "ign-deploy/gw/m/journal.json".into(),
2626                    cause: "write_failed".into(),
2627                })),
2628                6,
2629                "rollback_failed",
2630            ),
2631            (
2632                CoreError::ProjectGitManaged {
2633                    project: "CapApp".into(),
2634                    location: "projects/CapApp/.git".into(),
2635                    kind: "directory".into(),
2636                },
2637                6,
2638                "project_git_managed",
2639            ),
2640            (
2641                CoreError::ProjectParentInvalid {
2642                    project: "CapApp".into(),
2643                    parent: "WHK-Global".into(),
2644                    reason: "missing".into(),
2645                },
2646                6,
2647                "project_parent_invalid",
2648            ),
2649            (
2650                CoreError::ProjectHashMismatch {
2651                    project: "CapApp".into(),
2652                    expected: "0".repeat(64),
2653                    actual: "1".repeat(64),
2654                },
2655                6,
2656                "project_hash_mismatch",
2657            ),
2658            (
2659                CoreError::DeployUnreadable {
2660                    node: "master".into(),
2661                    keys: vec!["ignition/tag-definition/default/Deep".into()],
2662                },
2663                6,
2664                "deploy_unreadable",
2665            ),
2666        ];
2667        for (err, code, slug) in cases {
2668            assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
2669            assert_eq!(err.code(), slug, "unstable slug for: {err}");
2670        }
2671    }
2672
2673    /// The (exit_code, slug) literal table the Three-Place rule syncs.
2674    /// DUPLICATED from [`exit_code_mapping_enumerated`]'s triple list by
2675    /// design: that test is the literal source of truth (enum ↔ literals);
2676    /// this flat table is what the README cross-check runs against, so a
2677    /// drift inside error.rs surfaces as a disagreement instead of silently
2678    /// re-shuffling both sides. Keep the two lists in lockstep — the
2679    /// enumerated test fails if the enum respells/renumbers, and a stale
2680    /// copy here fails the README check below until updated.
2681    const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
2682        (1, "internal"),
2683        (2, "confirmation_required"),
2684        (2, "profiling_risk_not_accepted"),
2685        (2, "invalid_import_file"),
2686        (2, "invalid_input"),
2687        (2, "gateway_client_error"),
2688        (2, "deploy_tree_invalid"),
2689        (2, "embedded_secret"),
2690        (2, "destructive_change_refused"),
2691        (3, "profile_not_found"),
2692        (3, "no_active_profile"),
2693        (3, "secret_unavailable"),
2694        (3, "password_unavailable"),
2695        (3, "config_invalid"),
2696        (3, "poll_interval_too_small"),
2697        (3, "module_not_registered"),
2698        (4, "network_error"),
2699        (5, "auth_rejected"),
2700        (6, "gateway_too_old"),
2701        (6, "gateway_not_commissioned"),
2702        (6, "gateway_restarting"),
2703        (6, "not_found"),
2704        (6, "project_exists"),
2705        (6, "resource_binary"),
2706        (6, "trial_not_expired"),
2707        (6, "provider_not_found"),
2708        (6, "routes_not_deployed"),
2709        (6, "webdev_unlicensed"),
2710        (6, "route_version_mismatch"),
2711        (6, "webdev_route_error"),
2712        (6, "tag_collision"),
2713        (6, "alarm_journal_missing"),
2714        (6, "import_denied"),
2715        (6, "session_not_prunable"),
2716        (6, "eam_not_controller"),
2717        (6, "eam_task_type_refused"),
2718        (6, "eam_task_in_flight"),
2719        (6, "script_exec_not_configured"),
2720        (6, "lint_tool_absent"),
2721        (6, "node_tool_absent"),
2722        (6, "provider_root_unsupported"),
2723        (6, "bundle_not_available"),
2724        (4, "module_feed_unreachable"),
2725        (4, "transport_failed"),
2726        (6, "module_release_not_found"),
2727        (6, "module_digest_mismatch"),
2728        (6, "module_feed_unusable"),
2729        (6, "module_digest_changed"),
2730        (6, "module_uninstall_denied"),
2731        (6, "scan_lock_held"),
2732        (6, "resource_signature_rejected"),
2733        (6, "resource_exists"),
2734        (6, "mode_exists"),
2735        (6, "deploy_ledger_invalid"),
2736        (6, "plan_stale"),
2737        (6, "deploy_blocked"),
2738        (6, "deploy_preflight_failed"),
2739        (6, "deploy_journal_present"),
2740        (6, "deploy_live_changed"),
2741        (6, "deploy_run_invalid"),
2742        (6, "rollback_failed"),
2743        (6, "project_git_managed"),
2744        (6, "project_parent_invalid"),
2745        (6, "project_hash_mismatch"),
2746        (6, "deploy_unreadable"),
2747        (7, "rig_error"),
2748    ];
2749
2750    /// The two deploy slugs (20-02) never carry a signature value or
2751    /// gateway problem text, and render their payloads sensibly.
2752    #[test]
2753    fn deploy_slugs_never_carry_signature_or_problem_text() {
2754        const SAMPLE_SIG: &str = "d80c81d167362252bf389893ea0f3a81479521ba0bb68ee3bfc46e94bc2540b7";
2755
2756        let held = CoreError::ScanLockHeld {
2757            actor: Some("api-token:cap-a:172.17.0.1".into()),
2758            kind: "config".into(),
2759            endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2760        };
2761        assert_eq!(held.code(), "scan_lock_held");
2762        assert_eq!(held.exit_code(), 6);
2763        let shown = held.to_string();
2764        assert!(shown.contains("api-token:cap-a:172.17.0.1"), "{shown}");
2765        assert!(shown.contains("config"), "{shown}");
2766        assert_eq!(
2767            held.endpoint().as_deref(),
2768            Some("http://gw:8088/data/api/v1/scan-lock/config")
2769        );
2770
2771        let anonymous = CoreError::ScanLockHeld {
2772            actor: None,
2773            kind: "config".into(),
2774            endpoint: None,
2775        };
2776        let shown = anonymous.to_string();
2777        assert!(shown.contains("another actor"), "{shown}");
2778        assert!(!shown.contains("None"), "{shown}");
2779
2780        for reason in [SignatureReason::Stale, SignatureReason::Missing] {
2781            let err = CoreError::ResourceSignatureRejected {
2782                name: "DB_A".into(),
2783                reason,
2784                endpoint: Some("http://gw:8088/data/api/v1/resources/x".into()),
2785            };
2786            assert_eq!(err.code(), "resource_signature_rejected");
2787            assert_eq!(err.exit_code(), 6);
2788            let shown = err.to_string();
2789            assert!(shown.contains("DB_A"), "{shown}");
2790            assert!(shown.contains(&reason.to_string()), "{shown}");
2791            assert!(!shown.contains(SAMPLE_SIG), "{shown}");
2792            let hint = err.hint().expect("hint required");
2793            assert!(!hint.contains(SAMPLE_SIG), "{hint}");
2794            let endpoint = err.endpoint().expect("endpoint");
2795            assert!(!endpoint.contains(SAMPLE_SIG), "{endpoint}");
2796        }
2797        assert_eq!(SignatureReason::Stale.to_string(), "stale");
2798        assert_eq!(SignatureReason::Missing.to_string(), "missing");
2799    }
2800
2801    /// Parse the README's exit-code table: rows `| <exit> | class | meaning |
2802    /// \`slug\`, ... |` where `<exit>` is 1–7 (row 0 is the success row, no
2803    /// slugs). Parsing is SCOPED to the `## Exit codes` section — the README
2804    /// contains unrelated tables whose rows coincidentally begin `| 5 |`,
2805    /// `| 3 |`, etc. Std-only string ops: split on `|`, trim, take the LAST
2806    /// non-empty cell as the slug column (the meaning column can carry
2807    /// backticked non-slugs like `--yes`), and keep the backtick-delimited
2808    /// tokens that start with an ASCII letter and contain no spaces.
2809    fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
2810        let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
2811        let mut rows = Vec::new();
2812        let table_lines = section
2813            .lines()
2814            .skip_while(|line| !line.trim_start().starts_with('|'));
2815        for line in table_lines {
2816            if !line.trim_start().starts_with('|') {
2817                break; // table ended
2818            }
2819            let cells: Vec<&str> = line.split('|').map(str::trim).collect();
2820            let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
2821                continue;
2822            };
2823            if !(1..=7).contains(&exit) {
2824                continue;
2825            }
2826            let slug_cell = cells
2827                .iter()
2828                .rev()
2829                .find(|cell| !cell.is_empty())
2830                .copied()
2831                .unwrap_or_default();
2832            let slugs: Vec<String> = slug_cell
2833                .split('`')
2834                .enumerate()
2835                .filter(|(idx, _)| idx % 2 == 1)
2836                .map(|(_, token)| token.trim().to_string())
2837                .filter(|token| {
2838                    token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
2839                })
2840                .collect();
2841            rows.push((exit, slugs));
2842        }
2843        rows
2844    }
2845
2846    /// CORE-11, the Three-Place slug rule made executable: the exit-code
2847    /// table exists in the enum ([`CoreError::exit_code`]), this file's
2848    /// literal triples, and the README — and the README side is now
2849    /// machine-checked. The README is parsed verbatim via `include_str!`
2850    /// and cross-checked against the literal table in BOTH directions:
2851    /// (a) every literal slug appears under its exit code (a README row
2852    /// that lost or misspelled a slug fails), (b) every README slug token
2853    /// exists in the literal table (a stale/deleted row fails). Exit 6
2854    /// carries 23 slugs, so the full cross-check is the value — not the
2855    /// happy-path smoke.
2856    #[test]
2857    fn readme_exit_table_agreement() {
2858        let readme = include_str!("../../../README.md");
2859        let rows = parse_readme_exit_table(readme);
2860        assert!(
2861            rows.len() >= 7,
2862            "README exit-code table not found — the parser must see all 7 \
2863             failure-class rows (found {})",
2864            rows.len()
2865        );
2866
2867        // Direction (a): every literal (exit, slug) is present in the
2868        // README row matching its exit code.
2869        for (exit, slug) in EXIT_SLUG_LITERALS {
2870            let row = rows
2871                .iter()
2872                .find(|(readme_exit, _)| readme_exit == exit)
2873                .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
2874            assert!(
2875                row.1.iter().any(|s| s == slug),
2876                "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
2877                row.1
2878            );
2879        }
2880
2881        // Direction (b): every README slug token exists in the literal
2882        // table under the same exit code — catches stale/deleted rows.
2883        for (exit, slugs) in &rows {
2884            for slug in slugs {
2885                assert!(
2886                    EXIT_SLUG_LITERALS
2887                        .iter()
2888                        .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
2889                    "README exit-{exit} row carries slug {slug:?} that no \
2890                     CoreError variant emits — stale table row or slug/exit \
2891                     drift between README and error.rs"
2892                );
2893            }
2894        }
2895    }
2896
2897    /// CORE-05: config, auth, and target-state classes carry actionable
2898    /// hints (and every other class does too).
2899    #[test]
2900    fn hints_are_actionable_for_config_auth_target_state() {
2901        let profile_not_found = CoreError::ProfileNotFound {
2902            name: "x".into(),
2903            known: vec!["dev".into(), "prod".into()],
2904        };
2905        let hint = profile_not_found.hint().expect("hint required");
2906        assert!(
2907            hint.contains("dev"),
2908            "hint must list known profiles: {hint}"
2909        );
2910        assert!(
2911            hint.contains("ign profile add"),
2912            "hint must name the fix: {hint}"
2913        );
2914
2915        let auth = CoreError::Auth {
2916            status: 403,
2917            endpoint: None,
2918        };
2919        let hint = auth.hint().expect("hint required");
2920        assert!(
2921            hint.contains("three parts"),
2922            "auth hint must name the three-part token setup: {hint}"
2923        );
2924
2925        // Status-aware auth hints (02-RESEARCH Auth §4): 401 = not
2926        // recognized (name:key format), 403 = under-permitted.
2927        let unauthorized = CoreError::Auth {
2928            status: 401,
2929            endpoint: None,
2930        };
2931        let hint = unauthorized.hint().expect("hint required");
2932        assert!(
2933            hint.contains("name:key"),
2934            "401 hint must name the full name:key token format: {hint}"
2935        );
2936        assert!(
2937            hint.contains("API token"),
2938            "401 hint must say Basic cannot work: {hint}"
2939        );
2940        let hint403 = auth.hint().expect("hint required");
2941        assert!(
2942            hint403.contains("secure connections"),
2943            "403 hint must name the secure-channel part: {hint403}"
2944        );
2945
2946        let too_old = CoreError::GatewayTooOld {
2947            found: "8.1.0".into(),
2948            minimum: "8.3.1".into(),
2949            endpoint: None,
2950        };
2951        let hint = too_old.hint().expect("hint required");
2952        assert!(
2953            hint.contains("8.3.1"),
2954            "target-state hint must name the minimum: {hint}"
2955        );
2956
2957        // The WebDev refusal matrix (05-03): every hint names the fix
2958        // — `ign webdev deploy` for absent/older routes, `update ign`
2959        // for newer ones (the roadmap's actionable-error criterion).
2960        let undeployed = CoreError::RoutesNotDeployed {
2961            project: "ign-cli".into(),
2962            route: "tags".into(),
2963            endpoint: None,
2964        };
2965        let hint = undeployed.hint().expect("hint required");
2966        assert!(
2967            hint.contains("ign webdev deploy"),
2968            "absent-routes hint must name the fix: {hint}"
2969        );
2970
2971        let older = CoreError::RouteVersionMismatch {
2972            route: "tags".into(),
2973            deployed: "0.9.0".into(),
2974            expected: "1.0.0".into(),
2975            endpoint: None,
2976        };
2977        let hint = older.hint().expect("hint required");
2978        assert!(
2979            hint.contains("ign webdev deploy") && !hint.contains("update ign"),
2980            "older-route hint says redeploy: {hint}"
2981        );
2982
2983        let newer = CoreError::RouteVersionMismatch {
2984            route: "tags".into(),
2985            deployed: "1.1.0".into(),
2986            expected: "1.0.0".into(),
2987            endpoint: None,
2988        };
2989        let hint = newer.hint().expect("hint required");
2990        assert!(
2991            hint.contains("update ign") && !hint.contains("ign webdev deploy"),
2992            "newer-route hint says update ign: {hint}"
2993        );
2994
2995        let secret_gate = CoreError::WebdevRouteError {
2996            code: "secret_required".into(),
2997            message: "missing x-ignition-cli-secret header".into(),
2998            endpoint: None,
2999        };
3000        let hint = secret_gate.hint().expect("hint required");
3001        assert!(
3002            hint.contains("--with-script-exec"),
3003            "secret-gate hint names the deploy flag: {hint}"
3004        );
3005
3006        // The alarm-journal refusal (05-06): the hint names the missing
3007        // provisioning chain AND the README section.
3008        let journal = CoreError::AlarmJournalMissing { endpoint: None };
3009        let hint = journal.hint().expect("hint required");
3010        assert!(
3011            hint.contains("journal profile") && hint.contains("database connection"),
3012            "journal hint names the chain: {hint}"
3013        );
3014        assert!(
3015            hint.contains("README"),
3016            "journal hint points at the README section: {hint}"
3017        );
3018
3019        // The live-designer prune refusal (06-07): the hint names the
3020        // action — close the Designer — and the stale-only semantics.
3021        let prunable = CoreError::SessionNotPrunable {
3022            id: "d-live-1".into(),
3023            endpoint: None,
3024        };
3025        let hint = prunable.hint().expect("hint required");
3026        assert!(
3027            hint.contains("close the Designer"),
3028            "prune hint must name the action: {hint}"
3029        );
3030        assert!(
3031            hint.contains("stale entries"),
3032            "prune hint must name stale-only semantics: {hint}"
3033        );
3034
3035        // The scan-lock refusal (20-02): the hint names the release
3036        // conditions and no ign command.
3037        let held = CoreError::ScanLockHeld {
3038            actor: None,
3039            kind: "config".into(),
3040            endpoint: None,
3041        };
3042        let hint = held.hint().expect("hint required");
3043        assert!(
3044            hint.contains("holdTimeout") && hint.contains("scan runs"),
3045            "scan-lock hint names the release conditions: {hint}"
3046        );
3047        assert!(
3048            !hint.contains("ign "),
3049            "scan-lock hint names no ign command: {hint}"
3050        );
3051
3052        // The signature refusal (20-02): re-read and retry.
3053        let rejected = CoreError::ResourceSignatureRejected {
3054            name: "DB_A".into(),
3055            reason: SignatureReason::Stale,
3056            endpoint: None,
3057        };
3058        let hint = rejected.hint().expect("hint required");
3059        assert!(
3060            hint.contains("re-read") && hint.contains("signatures change on every write"),
3061            "signature hint names the action: {hint}"
3062        );
3063
3064        // The duplicate-create refusals (20-REVIEW WR-01): each names the
3065        // next step and carries no gateway problem text.
3066        let exists = CoreError::ResourceExists {
3067            name: "DB_A".into(),
3068            endpoint: None,
3069        };
3070        let hint = exists.hint().expect("hint required");
3071        assert!(
3072            hint.contains("update it"),
3073            "resource_exists hint names the action: {hint}"
3074        );
3075        assert_eq!(exists.to_string(), "resource \"DB_A\" already exists");
3076        let mode_exists = CoreError::ModeExists {
3077            name: "production".into(),
3078            endpoint: None,
3079        };
3080        let hint = mode_exists.hint().expect("hint required");
3081        assert!(
3082            hint.contains("skip the create"),
3083            "mode_exists hint names the action: {hint}"
3084        );
3085
3086        // The TTY refusal's hint is contextual (06-07): the ONE
3087        // InvalidInput reason whose fix is terminal-related. Every
3088        // other reason keeps the --file/stdin resource-put hint (pinned
3089        // here so the special case can never leak or regress).
3090        let tty = CoreError::tui_tty_refusal();
3091        assert_eq!(tty.code(), "invalid_input", "slug unchanged");
3092        assert_eq!(tty.exit_code(), 2, "usage class unchanged");
3093        let hint = tty.hint().expect("hint required");
3094        assert!(
3095            hint.contains("interactive terminal"),
3096            "TTY hint must name the fix: {hint}"
3097        );
3098        assert!(
3099            !hint.contains("--file"),
3100            "TTY hint must not carry the resource-put hint: {hint}"
3101        );
3102
3103        // The itop sibling (ign top): the SAME usage class and slug,
3104        // but the refusal/hint name the MONITOR's command — a hint
3105        // naming `ign tui` would not fix an `ign top` run (the
3106        // RoutesNotDeployed rule at the TTY pair).
3107        let top = CoreError::top_tty_refusal();
3108        assert_eq!(top.code(), "invalid_input", "slug unchanged");
3109        assert_eq!(top.exit_code(), 2, "usage class unchanged");
3110        let top_hint = top.hint().expect("hint required");
3111        assert!(
3112            top_hint.contains("`ign top`"),
3113            "the top refusal's hint names ign top: {top_hint}"
3114        );
3115        assert!(
3116            !top_hint.contains("ign tui"),
3117            "the top refusal never names the cockpit command: {top_hint}"
3118        );
3119        assert!(
3120            top_hint.contains("interactive terminal"),
3121            "the top refusal keeps the terminal-contextual fix: {top_hint}"
3122        );
3123        let put = CoreError::InvalidInput {
3124            reason: "cannot read put.json".into(),
3125        };
3126        let hint = put.hint().expect("hint required");
3127        assert!(
3128            hint.contains("--file") && hint.contains("stdin"),
3129            "resource-put hint unchanged: {hint}"
3130        );
3131
3132        // The EAM controller state gate (07-02): the hint names the
3133        // manual flip recipe + the README section (role decisions
3134        // stay one config-PUT away from the CLI, never one flag).
3135        let controller = CoreError::EamNotController { endpoint: None };
3136        let hint = controller.hint().expect("hint required");
3137        assert!(
3138            hint.contains("installMode"),
3139            "controller hint names the flip: {hint}"
3140        );
3141        assert!(
3142            hint.contains("Controller"),
3143            "controller hint names the target role: {hint}"
3144        );
3145        assert!(
3146            hint.contains("README"),
3147            "controller hint points at the README section: {hint}"
3148        );
3149
3150        // The refused task-type ladder top (07-02 Task 3): the hint
3151        // names the EAM console + the v2 scope pointer.
3152        let refused = CoreError::EamTaskTypeRefused {
3153            task_type: "eam_restoreBackup".into(),
3154        };
3155        assert_eq!(refused.exit_code(), 6, "target state");
3156        let hint = refused.hint().expect("hint required");
3157        assert!(
3158            hint.contains("EAM console"),
3159            "refused hint names where to run it: {hint}"
3160        );
3161        assert!(
3162            hint.contains("fleet-wide") || hint.contains("EXT-03"),
3163            "refused hint names the fleet consequence / scope: {hint}"
3164        );
3165
3166        // The force-route 409 (07-06 gap 4): the hint names the
3167        // resolution — complete or delete the leftover '(forced)'
3168        // run via the EAM console (no ign verb deletes runs).
3169        let in_flight = CoreError::EamTaskInFlight {
3170            task: "cli-research-backup".into(),
3171            detail: "the previous '(forced)' run must be completed or deleted first".into(),
3172            endpoint: None,
3173        };
3174        assert_eq!(in_flight.exit_code(), 6, "target state");
3175        let hint = in_flight.hint().expect("hint required");
3176        assert!(
3177            hint.contains("EAM console"),
3178            "in-flight hint names where to resolve it: {hint}"
3179        );
3180        assert!(
3181            hint.contains("complete or delete"),
3182            "in-flight hint names the resolution: {hint}"
3183        );
3184
3185        // The scriptExec structural gate (07-03): the hint names the
3186        // deploy flag VERBATIM — the flag IS the opt-in (no --yes
3187        // exists on script run).
3188        let unconfigured = CoreError::ScriptExecNotConfigured {
3189            profile: "dev".into(),
3190        };
3191        assert_eq!(unconfigured.exit_code(), 6, "target state");
3192        let hint = unconfigured.hint().expect("hint required");
3193        assert!(
3194            hint.contains("ign webdev deploy --with-script-exec"),
3195            "the hint names the deploy flag verbatim: {hint}"
3196        );
3197
3198        // Totality: no class silently loses its hint later.
3199        let no_active = CoreError::NoActiveProfile;
3200        assert!(no_active.hint().is_some());
3201    }
3202
3203    /// The failure envelope's serialized field order and endpoint population
3204    /// are contract: `ok`, `profile`, `error` / `code`, `message`,
3205    /// `endpoint`, `hint` (string-level comparison because `serde_json::Value`
3206    /// maps are key-sorted and would hide ordering).
3207    #[test]
3208    fn error_envelope_locked_shape_and_endpoint() {
3209        let auth = CoreError::Auth {
3210            status: 401,
3211            endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
3212        };
3213        let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
3214        let json = serde_json::to_string(&envelope).expect("serialize envelope");
3215
3216        assert_eq!(
3217            json,
3218            concat!(
3219                r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
3220                r#""message":"gateway rejected credentials (HTTP 401)","#,
3221                r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
3222                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"}}"#
3223            )
3224        );
3225
3226        // Classes without a request involved carry no endpoint.
3227        let no_request = CoreError::NoActiveProfile;
3228        let body: &ErrorBody = &no_request.envelope(None).error;
3229        assert_eq!(body.endpoint, None);
3230    }
3231
3232    /// The api-call body cap (09-01): a body at or under
3233    /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] rides verbatim (no marker); a
3234    /// larger body is truncated to the cap on a UTF-8 char boundary with
3235    /// the exact ASCII marker appended — and the cap is enforced at
3236    /// CONSTRUCTION, so the variant always carries its final form.
3237    #[test]
3238    fn truncates_at_cap_with_marker() {
3239        // Under the cap: byte-identical passthrough.
3240        let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
3241        assert_eq!(truncate_api_body(short), short);
3242        assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3243
3244        // Exactly at the cap: still verbatim (the marker only joins an
3245        // OVER-cap body — the boundary is <=, not <).
3246        let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
3247        assert_eq!(truncate_api_body(&exact), exact);
3248
3249        // One byte over: truncated, marker appended, total size bounded.
3250        let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
3251        let truncated = truncate_api_body(&over);
3252        assert!(
3253            truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
3254            "truncated body must end with the explicit marker"
3255        );
3256        assert!(
3257            truncated.len()
3258                <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
3259            "truncated body must stay within cap + marker: {}",
3260            truncated.len()
3261        );
3262
3263        // A multi-byte character straddling the cap boundary does not
3264        // panic (the cut backs up to a char boundary) and still carries
3265        // the marker.
3266        let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); // 2 bytes each
3267        let truncated = truncate_api_body(&multibyte);
3268        assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3269        assert!(
3270            truncated
3271                .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
3272        );
3273    }
3274
3275    /// The loss-gate hint override (11-07 gap closure): a reason with the
3276    /// [`LOSS_GATE_REFUSAL_REASON_PREFIX`] sentinel carries the --yes
3277    /// hint, while every OTHER InvalidInput reason (generic file-read)
3278    /// and the TTY-refusal precedent keep their hints byte-identically.
3279    /// The loss-gate reason is built from the CONST via format! — a
3280    /// string literal of the sentinel text here would be a third
3281    /// production-literal hit and break the prefix-uniqueness gate the
3282    /// plan's verify pins.
3283    #[test]
3284    fn loss_gate_hint_override_and_neighbors_unchanged() {
3285        // Sentinel-prefixed reason (dynamic prose after the header):
3286        // the --yes hint, never the file-read default.
3287        let loss_gate = CoreError::InvalidInput {
3288            reason: format!(
3289                "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
3290                 with --yes to import anyway",
3291                LOSS_GATE_REFUSAL_REASON_PREFIX
3292            ),
3293        };
3294        assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
3295        assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
3296        let hint = loss_gate.hint().expect("hint required");
3297        assert!(
3298            hint.contains("--yes"),
3299            "loss-gate hint must name the --yes re-run: {hint}"
3300        );
3301        assert!(
3302            hint.contains("loss report above"),
3303            "loss-gate hint must point back at the report: {hint}"
3304        );
3305        assert!(
3306            !hint.contains("fix the input source"),
3307            "the file-read default must never leak onto the loss-gate path: {hint}"
3308        );
3309
3310        // Generic InvalidInput: the file-read default UNCHANGED (the
3311        // regression pin for every other raise site).
3312        let generic = CoreError::InvalidInput {
3313            reason: "x is not valid JSON: expected value at line 1".into(),
3314        };
3315        let hint = generic.hint().expect("hint required");
3316        assert!(
3317            hint.contains("--file") && hint.contains("stdin"),
3318            "resource-put hint unchanged: {hint}"
3319        );
3320        assert!(
3321            !hint.contains("--yes"),
3322            "the --yes hint must not leak onto generic reasons: {hint}"
3323        );
3324
3325        // The TTY-refusal precedent intact (06-07).
3326        let tty = CoreError::tui_tty_refusal();
3327        let hint = tty.hint().expect("hint required");
3328        assert!(
3329            hint.contains("interactive terminal"),
3330            "TTY hint unchanged: {hint}"
3331        );
3332        assert!(
3333            !hint.contains("--yes"),
3334            "the --yes hint must not leak onto the TTY path: {hint}"
3335        );
3336    }
3337
3338    /// The testing-scoped `routes_not_deployed` hint (QUICK-p0g): a
3339    /// `testing/`-prefixed route names the TESTING install commands,
3340    /// while every other route keeps the generic `ign webdev deploy`
3341    /// text BYTE-identically. Both branches are pinned so a future
3342    /// edit cannot silently merge them — sending a caller to
3343    /// `ign webdev deploy` for an absent testing bundle is a hint
3344    /// that does not fix the error.
3345    #[test]
3346    fn routes_not_deployed_hint_is_testing_scoped() {
3347        let testing = CoreError::RoutesNotDeployed {
3348            project: "Flux".into(),
3349            route: format!("{TESTING_ROUTE_PREFIX}run"),
3350            endpoint: None,
3351        };
3352        assert_eq!(testing.code(), "routes_not_deployed", "slug unchanged");
3353        assert_eq!(testing.exit_code(), 6, "target-state class unchanged");
3354        let hint = testing.hint().expect("hint required");
3355        assert!(
3356            hint.contains("ign webdev deploy --with-testing"),
3357            "testing hint must name the deploy flag: {hint}"
3358        );
3359        assert!(
3360            hint.contains("ign adopt") && hint.contains("--testing"),
3361            "testing hint must name the adopt path: {hint}"
3362        );
3363
3364        // Every non-testing route: the generic hint, unchanged.
3365        let generic = CoreError::RoutesNotDeployed {
3366            project: "ign-cli".into(),
3367            route: "tags".into(),
3368            endpoint: None,
3369        };
3370        let hint = generic.hint().expect("hint required");
3371        assert_eq!(
3372            hint,
3373            "run `ign webdev deploy` to install the CLI's WebDev routes into \
3374             the gateway, then retry",
3375            "the generic branch is byte-identical to its pre-QUICK-p0g text"
3376        );
3377    }
3378
3379    fn finding(file: &str, line: Option<usize>, column: Option<usize>, msg: &str) -> DeployFinding {
3380        DeployFinding {
3381            file: file.into(),
3382            line,
3383            column,
3384            message: msg.into(),
3385        }
3386    }
3387
3388    #[test]
3389    fn deploy_finding_display_names_file_line_column() {
3390        let positioned = finding("targets/prod.yaml", Some(3), Some(1), "unknown field `x`");
3391        assert_eq!(
3392            positioned.to_string(),
3393            "targets/prod.yaml:3:1: unknown field `x`"
3394        );
3395        let bare = finding("targets/prod.yaml", None, None, "missing field `gateway`");
3396        assert_eq!(
3397            bare.to_string(),
3398            "targets/prod.yaml: missing field `gateway`"
3399        );
3400        // A half-positioned finding never claims a position.
3401        let half = finding("m.yaml", Some(3), None, "m");
3402        assert_eq!(half.to_string(), "m.yaml: m");
3403    }
3404
3405    #[test]
3406    fn deploy_findings_cap_at_twenty() {
3407        let many: Vec<DeployFinding> = (0..25)
3408            .map(|i| finding("manifest.yaml", None, None, &format!("problem {i}")))
3409            .collect();
3410        let shown = CoreError::deploy_findings(many).to_string();
3411        assert!(shown.contains("25 findings: "), "{shown}");
3412        assert!(shown.contains("problem 19"), "{shown}");
3413        assert!(!shown.contains("problem 20"), "{shown}");
3414        assert!(shown.ends_with("; and 5 more"), "{shown}");
3415
3416        let single = CoreError::deploy_tree_invalid("m.yaml", Some(2), Some(1), "bad");
3417        assert_eq!(single.to_string(), "deploy tree invalid: m.yaml:2:1: bad");
3418    }
3419
3420    #[test]
3421    fn embedded_secret_display_lists_paths_only() {
3422        let err = CoreError::embedded_secret(
3423            vec![
3424                "ignition/database-connection/A/config.json: settings.password".into(),
3425                "ignition/database-connection/B/config.json: settings.password".into(),
3426            ],
3427            vec![finding("manifest.yaml", None, None, "duplicate type")],
3428        );
3429        let shown = err.to_string();
3430        assert!(shown.contains("2 path(s)"), "{shown}");
3431        assert!(
3432            shown.contains("A/config.json: settings.password"),
3433            "{shown}"
3434        );
3435        assert!(
3436            shown.contains("B/config.json: settings.password"),
3437            "{shown}"
3438        );
3439        assert!(shown.contains("also 1 other tree finding(s)"), "{shown}");
3440        assert!(shown.contains("manifest.yaml: duplicate type"), "{shown}");
3441
3442        // The combined list stays within the cap.
3443        let paths: Vec<String> = (0..25).map(|i| format!("f{i}.json: a.b")).collect();
3444        let shown =
3445            CoreError::embedded_secret(paths, vec![finding("m.yaml", None, None, "x")]).to_string();
3446        assert!(shown.contains("25 path(s)"), "{shown}");
3447        assert!(shown.contains("and 5 more"), "{shown}");
3448        assert!(shown.contains("f19.json: a.b"), "{shown}");
3449        assert!(!shown.contains("f20.json: a.b"), "{shown}");
3450    }
3451
3452    #[test]
3453    fn deploy_slugs_have_their_exit_codes() {
3454        let errs = [
3455            (
3456                CoreError::deploy_tree_invalid("m.yaml", None, None, "bad"),
3457                2,
3458            ),
3459            (
3460                CoreError::embedded_secret(vec!["a.json: p".into()], vec![]),
3461                2,
3462            ),
3463            (
3464                CoreError::TransportFailed {
3465                    operation: "read".into(),
3466                    node: "nodes.master".into(),
3467                    detail: "data_dir is missing".into(),
3468                },
3469                4,
3470            ),
3471            (
3472                CoreError::DeployLedgerInvalid {
3473                    location: "ign-deploy/gw/dev/ledger.json".into(),
3474                    problem: "the file is empty".into(),
3475                },
3476                6,
3477            ),
3478            (
3479                CoreError::PlanStale {
3480                    expected: "a".repeat(64),
3481                    actual: "b".repeat(64),
3482                    detail: "review hash".into(),
3483                },
3484                6,
3485            ),
3486            (
3487                CoreError::DestructiveChangeRefused {
3488                    gate: "destructive".into(),
3489                    keys: vec!["ignition/a/x".into()],
3490                    count: 1,
3491                },
3492                2,
3493            ),
3494            (
3495                CoreError::DeployBlocked {
3496                    keys: vec!["ignition/a/x".into()],
3497                },
3498                6,
3499            ),
3500            (
3501                CoreError::DeployPreflightFailed {
3502                    failures: vec![PreflightFailure {
3503                        check: "mode_master".into(),
3504                        detail: "the gateway is not in the declared mode".into(),
3505                    }]
3506                    .into(),
3507                },
3508                6,
3509            ),
3510            (
3511                CoreError::DeployJournalPresent {
3512                    run_id: "20261015T142233Z-a1b2c3".into(),
3513                    env: "prod".into(),
3514                    location: "ign-deploy/gw/m/journal.json".into(),
3515                },
3516                6,
3517            ),
3518            (
3519                CoreError::DeployLiveChanged {
3520                    run_id: "r1".into(),
3521                    paths: vec!["a/b.json".into()],
3522                    count: 1,
3523                },
3524                6,
3525            ),
3526            (
3527                CoreError::DeployRunInvalid {
3528                    run_id: "r1".into(),
3529                    problem: "record.json is missing".into(),
3530                },
3531                6,
3532            ),
3533            (
3534                CoreError::RollbackFailed(Box::new(RollbackFailure {
3535                    run_id: "r1".into(),
3536                    env: "dev".into(),
3537                    run_dir: Some(".ign/deploy/r1".into()),
3538                    journal: "ign-deploy/gw/m/journal.json".into(),
3539                    cause: "write_failed".into(),
3540                })),
3541                6,
3542            ),
3543            (
3544                CoreError::ProjectGitManaged {
3545                    project: "CapApp".into(),
3546                    location: "projects/CapApp/.git".into(),
3547                    kind: "directory".into(),
3548                },
3549                6,
3550            ),
3551            (
3552                CoreError::ProjectParentInvalid {
3553                    project: "CapApp".into(),
3554                    parent: "WHK-Global".into(),
3555                    reason: "cycle".into(),
3556                },
3557                6,
3558            ),
3559            (
3560                CoreError::ProjectHashMismatch {
3561                    project: "CapApp".into(),
3562                    expected: "0".repeat(64),
3563                    actual: "1".repeat(64),
3564                },
3565                6,
3566            ),
3567            (
3568                CoreError::DeployUnreadable {
3569                    node: "master".into(),
3570                    keys: vec!["ignition/tag-definition/default/Deep".into()],
3571                },
3572                6,
3573            ),
3574        ];
3575        let codes: Vec<&str> = errs.iter().map(|(e, _)| e.code()).collect();
3576        assert_eq!(codes, CoreError::DEPLOY_SLUGS);
3577        for (err, exit) in &errs {
3578            assert_eq!(err.exit_code(), *exit, "{err}");
3579            assert_eq!(err.endpoint(), None, "deploy errors name no URL: {err}");
3580            let hint = err.hint().expect("hint required");
3581            assert!(
3582                !hint.contains("`ign ") && !hint.contains("ign "),
3583                "hint must not name an ign subcommand: {hint}"
3584            );
3585        }
3586    }
3587
3588    #[test]
3589    fn apply_gate_flag_refusals_do_not_get_the_file_hint() {
3590        for reason in [
3591            "--plan-sha256 must be 64 lowercase hex characters",
3592            "--allow-drift reason must not be empty",
3593            "--artifact-sha256 must be 64 lowercase hex characters",
3594            "--release <tag>: not a semver tag (for example v1.4.0)",
3595            "--allow-first-change <reason>: the reason is 1 to 500 characters",
3596            "deploy projects needs --artifact-sha256 when --json is given",
3597        ] {
3598            let hint = CoreError::InvalidInput {
3599                reason: reason.into(),
3600            }
3601            .hint()
3602            .expect("hint");
3603            assert!(!hint.contains("--file"), "{hint}");
3604            assert!(hint.contains("flag value"), "{hint}");
3605        }
3606    }
3607
3608    /// The exact ordered deploy slug list. Verdicts (`deploy_rolled_back`,
3609    /// `deploy_backup_unsynced`, `deploy_project_unverified`) are payload
3610    /// slugs on a success envelope and never `CoreError` codes. Later plans
3611    /// extend this list in README row order (25-09, 25-10, 25-11).
3612    #[test]
3613    fn deploy_slugs_are_pinned_and_never_a_verdict() {
3614        assert_eq!(
3615            CoreError::DEPLOY_SLUGS,
3616            &[
3617                "deploy_tree_invalid",
3618                "embedded_secret",
3619                "transport_failed",
3620                "deploy_ledger_invalid",
3621                "plan_stale",
3622                "destructive_change_refused",
3623                "deploy_blocked",
3624                "deploy_preflight_failed",
3625                "deploy_journal_present",
3626                "deploy_live_changed",
3627                "deploy_run_invalid",
3628                "rollback_failed",
3629                "project_git_managed",
3630                "project_parent_invalid",
3631                "project_hash_mismatch",
3632                "deploy_unreadable",
3633            ]
3634        );
3635        assert_eq!(CoreError::DEPLOY_SLUGS.len(), 16);
3636        for verdict in [
3637            "deploy_rolled_back",
3638            "deploy_backup_unsynced",
3639            "deploy_project_unverified",
3640        ] {
3641            assert!(!CoreError::DEPLOY_SLUGS.contains(&verdict), "{verdict}");
3642        }
3643    }
3644
3645    /// 25-07: the four-place rule for `project_git_managed` (variant, code,
3646    /// exit class 6, hint) and the message names what the operator needs.
3647    #[test]
3648    fn project_git_managed_four_places() {
3649        let err = CoreError::ProjectGitManaged {
3650            project: "CapApp".into(),
3651            location: "projects/CapApp/.git".into(),
3652            kind: "directory".into(),
3653        };
3654        assert_eq!(err.code(), "project_git_managed");
3655        assert_eq!(err.exit_code(), 6);
3656        let message = err.to_string();
3657        for needle in [
3658            "CapApp",
3659            "projects/CapApp/.git",
3660            "directory",
3661            "administrator",
3662        ] {
3663            assert!(message.contains(needle), "{needle}: {message}");
3664        }
3665        let hint = err.hint().expect("hint required");
3666        assert!(!hint.contains("ign "), "{hint}");
3667        assert!(hint.contains(".git"), "{hint}");
3668        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_git_managed"));
3669    }
3670
3671    /// 25-10: the four-place rule for `project_parent_invalid` (variant,
3672    /// code, exit class 6, hint per reason) and the message names the
3673    /// project, the parent and the reason.
3674    #[test]
3675    fn project_parent_invalid_four_places() {
3676        for (reason, needle) in [
3677            ("missing", "its own repo"),
3678            ("cycle", "no cycle"),
3679            ("not_inheritable", "inheritable"),
3680        ] {
3681            let err = CoreError::ProjectParentInvalid {
3682                project: "CapApp".into(),
3683                parent: "WHK-Global".into(),
3684                reason: reason.into(),
3685            };
3686            assert_eq!(err.code(), "project_parent_invalid");
3687            assert_eq!(err.exit_code(), 6);
3688            let message = err.to_string();
3689            for part in ["CapApp", "WHK-Global", reason] {
3690                assert!(message.contains(part), "{part}: {message}");
3691            }
3692            let hint = err.hint().expect("hint required");
3693            assert!(!hint.contains("ign "), "{hint}");
3694            assert!(hint.contains(needle), "{reason}: {hint}");
3695            assert_eq!(err.endpoint(), None);
3696        }
3697        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_parent_invalid"));
3698    }
3699
3700    /// 25-11: the four-place rule for `project_hash_mismatch` (variant, code,
3701    /// exit class 6, hint). The message names the project and both hashes and
3702    /// says nothing was uploaded; nothing else rides it (SC-1, D-01).
3703    #[test]
3704    fn project_hash_mismatch_four_places() {
3705        let err = CoreError::ProjectHashMismatch {
3706            project: "CapApp".into(),
3707            expected: "0".repeat(64),
3708            actual: "1".repeat(64),
3709        };
3710        assert_eq!(err.code(), "project_hash_mismatch");
3711        assert_eq!(err.exit_code(), 6);
3712        let message = err.to_string();
3713        for part in [
3714            "CapApp",
3715            "0000000000000000000000000000000000000000000000000000000000000000",
3716            "1111111111111111111111111111111111111111111111111111111111111111",
3717            "nothing was uploaded",
3718        ] {
3719            assert!(message.contains(part), "{part}: {message}");
3720        }
3721        let hint = err.hint().expect("hint required");
3722        assert!(!hint.contains("ign "), "{hint}");
3723        assert!(hint.contains("artifact_sha256"), "{hint}");
3724        assert_eq!(err.endpoint(), None);
3725        assert!(CoreError::DEPLOY_SLUGS.contains(&"project_hash_mismatch"));
3726    }
3727
3728    /// quick-261029-ms3: the four-place rule for `deploy_unreadable`
3729    /// (variant, code, exit class 6, hint). The message names every unit,
3730    /// says the host's sftp server cannot read its live path and that nothing
3731    /// was written; the hint names the two fixes and no `ign` subcommand.
3732    #[test]
3733    fn deploy_unreadable_four_places() {
3734        let keys = vec![
3735            "ignition/database-translator/OLD".to_string(),
3736            "ignition/tag-definition/default/Deep".to_string(),
3737        ];
3738        let err = CoreError::DeployUnreadable {
3739            node: "backup".to_string(),
3740            keys: keys.clone(),
3741        };
3742        assert_eq!(err.code(), "deploy_unreadable");
3743        assert_eq!(err.exit_code(), 6);
3744        assert_eq!(err.endpoint(), None);
3745        let message = err.to_string();
3746        for part in keys.iter().map(String::as_str).chain([
3747            "backup host's sftp server cannot read",
3748            "nothing was written",
3749            "2 unit(s)",
3750        ]) {
3751            assert!(message.contains(part), "{part}: {message}");
3752        }
3753        let hint = err.hint().expect("hint required");
3754        assert!(!hint.contains("ign "), "{hint}");
3755        assert!(hint.contains("exclude_tag_providers"), "{hint}");
3756        assert!(hint.contains("shorten"), "{hint}");
3757        assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_unreadable"));
3758    }
3759
3760    /// 25-11 (K15): a stale live project is told apart from a stale plan
3761    /// hash, and neither hint names an `ign` subcommand.
3762    #[test]
3763    fn plan_stale_hint_names_the_live_project_case() {
3764        let live = CoreError::PlanStale {
3765            expected: "a".repeat(64),
3766            actual: "b".repeat(64),
3767            detail: "live project changed since the plan".into(),
3768        };
3769        let hint = live.hint().expect("hint");
3770        assert!(hint.contains("live project"), "{hint}");
3771        assert!(hint.contains("nothing was imported"), "{hint}");
3772        assert!(!hint.contains("ign "), "{hint}");
3773        let review = CoreError::PlanStale {
3774            expected: "a".repeat(64),
3775            actual: "b".repeat(64),
3776            detail: "review hash".into(),
3777        };
3778        assert!(
3779            review.hint().expect("hint").contains("plan_sha256"),
3780            "the plan-hash case keeps its hint"
3781        );
3782    }
3783
3784    #[test]
3785    fn journal_present_message_carries_the_recovery_command() {
3786        let err = CoreError::DeployJournalPresent {
3787            run_id: "20261015T142233Z-a1b2c3".into(),
3788            env: "prod".into(),
3789            location: "ign-deploy/gw/m/journal.json".into(),
3790        };
3791        let shown = err.to_string();
3792        assert!(
3793            shown.contains("ign deploy rollback --run 20261015T142233Z-a1b2c3 --env prod"),
3794            "{shown}"
3795        );
3796        assert!(shown.contains("ign-deploy/gw/m/journal.json"), "{shown}");
3797        let hint = err.hint().expect("hint");
3798        assert!(
3799            !hint.contains("ign deploy"),
3800            "D-09: the command lives in Display: {hint}"
3801        );
3802    }
3803
3804    #[test]
3805    fn rollback_failed_message_names_run_dir_journal_and_the_guidance() {
3806        let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
3807            run_id: "r-7".into(),
3808            env: "prod".into(),
3809            run_dir: Some(".ign/deploy/r-7".into()),
3810            journal: "ign-deploy/gw/m/journal.json".into(),
3811            cause: "write_failed".into(),
3812        }));
3813        let shown = err.to_string();
3814        for needle in [
3815            "r-7",
3816            ".ign/deploy/r-7",
3817            "ign-deploy/gw/m/journal.json",
3818            ".gwbk",
3819            "snapshot",
3820            "by hand",
3821            // The retry comes first, with the real env, and the intact
3822            // pre-images are the on-box directory (review WR-02).
3823            "ign deploy rollback --run r-7 --env prod",
3824            "ign-deploy/gw/m/snapshots/r-7/",
3825        ] {
3826            assert!(shown.contains(needle), "{needle} missing from: {shown}");
3827        }
3828        assert!(
3829            shown.find("Retry with").expect("retry") < shown.find("by hand").expect("by hand"),
3830            "the retry precedes the hand deletion: {shown}"
3831        );
3832        assert!(
3833            !shown.contains("run-directory snapshot"),
3834            "the run directory has no snapshot to offer: {shown}"
3835        );
3836        let hint = err.hint().expect("hint");
3837        assert!(
3838            hint.contains("retry") && !hint.contains("run-directory snapshot"),
3839            "{hint}"
3840        );
3841        let none = CoreError::RollbackFailed(Box::new(RollbackFailure {
3842            run_id: "r-7".into(),
3843            env: "prod".into(),
3844            run_dir: None,
3845            journal: "j".into(),
3846            cause: "c".into(),
3847        }));
3848        assert!(!none.to_string().contains("None"), "{none}");
3849    }
3850
3851    #[test]
3852    fn preflight_failed_lists_every_failure_in_order() {
3853        let err = CoreError::DeployPreflightFailed {
3854            failures: vec![
3855                PreflightFailure {
3856                    check: "mode_master".into(),
3857                    detail: "first".into(),
3858                },
3859                PreflightFailure {
3860                    check: "gwbk".into(),
3861                    detail: "second".into(),
3862                },
3863            ]
3864            .into(),
3865        };
3866        let shown = err.to_string();
3867        let first = shown.find("mode_master: first").expect("first");
3868        let second = shown.find("gwbk: second").expect("second");
3869        assert!(first < second, "{shown}");
3870        assert_eq!(shown.lines().count(), 3, "{shown}");
3871    }
3872
3873    #[test]
3874    fn apply_refusal_lists_cap_at_ten() {
3875        let keys: Vec<String> = (0..10).map(|i| format!("u{i}")).collect();
3876        let shown = CoreError::DestructiveChangeRefused {
3877            gate: "destructive".into(),
3878            keys: keys.clone(),
3879            count: 13,
3880        }
3881        .to_string();
3882        assert!(shown.contains("13 entries"), "{shown}");
3883        assert!(shown.contains("and 3 more"), "{shown}");
3884        assert!(shown.contains("--allow-destructive"), "{shown}");
3885        let drift = CoreError::DestructiveChangeRefused {
3886            gate: "drift".into(),
3887            keys: keys[..1].to_vec(),
3888            count: 1,
3889        }
3890        .to_string();
3891        assert!(drift.contains("--allow-drift"), "{drift}");
3892        assert!(drift.contains("1 entry"), "{drift}");
3893        let downgrade = CoreError::DestructiveChangeRefused {
3894            gate: "downgrade".into(),
3895            keys: vec!["CapApp".into()],
3896            count: 1,
3897        };
3898        let shown = downgrade.to_string();
3899        assert!(shown.contains("downgrade"), "{shown}");
3900        assert!(shown.contains("CapApp"), "{shown}");
3901        assert!(
3902            shown.contains("deploy projects needs --allow-downgrade"),
3903            "{shown}"
3904        );
3905        assert!(!shown.contains("apply needs"), "{shown}");
3906        let first = CoreError::DestructiveChangeRefused {
3907            gate: "first_deploy".into(),
3908            keys: vec!["CapApp".into()],
3909            count: 1,
3910        };
3911        let shown = first.to_string();
3912        assert!(shown.contains("first_deploy"), "{shown}");
3913        assert!(
3914            shown.contains("deploy projects needs --allow-first-change <reason>"),
3915            "{shown}"
3916        );
3917        // each gate has its own hint, and none names an `ign` command
3918        let hints: Vec<String> = [&downgrade, &first]
3919            .iter()
3920            .map(|e| e.hint().expect("hint"))
3921            .collect();
3922        assert!(hints[0].contains("--allow-downgrade"), "{}", hints[0]);
3923        assert!(hints[1].contains("--allow-first-change"), "{}", hints[1]);
3924        for hint in &hints {
3925            assert!(!hint.contains("ign "), "{hint}");
3926            assert!(!hint.contains("--allow-destructive"), "{hint}");
3927        }
3928
3929        let many: Vec<String> = (0..25).map(|i| format!("a/{i:02}")).collect();
3930        let blocked = CoreError::DeployBlocked { keys: many.clone() }.to_string();
3931        assert!(blocked.contains("and 15 more"), "{blocked}");
3932        assert!(!blocked.contains("a/10"), "{blocked}");
3933        let live = CoreError::DeployLiveChanged {
3934            run_id: "r".into(),
3935            paths: many[..10].to_vec(),
3936            count: 12,
3937        }
3938        .to_string();
3939        assert!(live.contains("and 2 more"), "{live}");
3940    }
3941}