Skip to main content

ignition_core/
error.rs

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