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