Skip to main content

ignition_core/client/
mod.rs

1//! The gateway HTTP seam: a coarse [`GatewayApi`] trait so actions never
2//! touch reqwest types, plus the production [`ReqwestGatewayApi`].
3//!
4//! LOCKED: the trait uses `async_trait` (research Open Question 2,
5//! resolved) — dyn-compatible today, ubiquitous. The trait stays COARSE —
6//! one method per capability, not per endpoint — so Phase 2 grows it
7//! without churn.
8//!
9//! Auth-header rule (verified against a live 8.3.6 gateway, 02-RESEARCH
10//! §Auth Model): a token credential sends `X-Ignition-API-Token`; a basic
11//! credential sends `Authorization: Basic <b64>`; NEVER both — enforced by
12//! a match in [`ReqwestGatewayApi::apply_auth`], the ONE place
13//! [`Secret::expose`] is called outside the secret module (the
14//! grep-auditable redaction boundary; CORE-02).
15//!
16//! Basic is loudly demoted there: valid Basic credentials → 401 on every
17//! 8.3 `/data` route (verified), so each use warns — never silently
18//! retried. Note gateway-info itself DOES require auth under 8.3 default
19//! security (header-less → 401, re-verified live 2026-08-21 — the 83-api
20//! collection's `auth: none` tag does not hold); a `None` credential
21//! proceeds header-less and classifies the answer.
22//!
23//! Redirects are never followed (`Policy::none()`): an uncommissioned
24//! gateway 302s EVERYTHING to `/welcome` and the default follow would
25//! render the wizard's HTML as a 200 (02-RESEARCH Pitfall 6). The 3xx is
26//! classified by [`classify`] instead.
27//!
28//! Every request runs the pipeline: build URL → apply auth (opt-in) →
29//! send (transport error → `Network`) → [`classify`] → parse the body.
30//! Nothing ever calls `.json()` on a response that skipped `classify()`.
31
32use std::path::Path;
33use std::time::Duration;
34
35pub mod adopt;
36pub mod apicall;
37pub mod backup;
38mod classify;
39pub mod connections;
40pub mod diagnostics;
41pub mod eam;
42pub mod gan;
43pub mod idp;
44pub mod license;
45pub mod logs;
46pub mod metrics;
47pub mod projects;
48pub mod query;
49pub mod redundancy;
50pub mod resources;
51pub mod restart;
52pub mod scripts_codec;
53pub mod sessions;
54pub mod status;
55pub mod tags;
56pub mod trial;
57pub mod version;
58pub mod webdev;
59pub mod workspace;
60
61use crate::client::connections::GatewayConnection;
62use crate::client::diagnostics::BundleStatusWire;
63use crate::client::eam::{
64    DeleteOutcome, EamHistoryItem, EamScheduledTask, EamTaskRecord, ModifyOutcome,
65};
66use crate::client::gan::GanStatusWire;
67use crate::client::license::LicenseStatusWire;
68use crate::client::logs::{LogDownload, LogEntry, LogQuery, LoggerInfo};
69use crate::client::metrics::{CurrentGauges, PerformanceCharts, ThreadCounts};
70use crate::client::projects::{
71    ExportMeta, ImportOutcome, ProjectCopy, ProjectCreate, ProjectModify, ProjectRecord,
72    ProjectRenameBody,
73};
74use crate::client::query::ListEnvelope;
75use crate::client::redundancy::RedundancyStatusWire;
76use crate::client::restart::SecurityProperties;
77use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
78use crate::client::status::{ModuleInfo, Overview, StatusPing};
79use crate::client::tags::{TagProviderCreate, TagProviderRecord};
80use crate::client::trial::{BannerSet, TrialWire};
81use crate::client::version::GatewayInfo;
82use crate::client::webdev::{RouteBody, RouteProbe};
83use crate::config::{Credential, Profile};
84use crate::error::CoreError;
85
86/// GET path of the gateway-info capability.
87const GATEWAY_INFO_PATH: &str = "/data/api/v1/gateway-info";
88
89/// One capability per method — coarse on purpose. Phase 2 adds status,
90/// modules, metrics, … as methods here; actions never see reqwest types.
91///
92/// (All impl bodies live in the ONE `impl GatewayApi for
93/// ReqwestGatewayApi` block below: Rust rejects a second impl block of
94/// the same trait for the same type, so the per-capability files own the
95/// models + verified path constants and this block owns the delegation.)
96#[async_trait::async_trait]
97pub trait GatewayApi: Send + Sync {
98    /// Fetch `/data/api/v1/gateway-info`.
99    async fn gateway_info(&self) -> Result<GatewayInfo, CoreError>;
100    /// Fetch `/data/api/v1/overview` (authed) — platform + runtime.
101    async fn overview(&self) -> Result<Overview, CoreError>;
102    /// Fetch `/StatusPing` **header-less** (auth=false) — the
103    /// unauthenticated readiness anchor: it must keep answering when
104    /// credentials are broken or absent and mid-restart (02-02).
105    async fn status_ping(&self) -> Result<StatusPing, CoreError>;
106    /// Fetch `/data/api/v1/modules/healthy` (`quarantined = false`) or
107    /// `/modules/quarantined` (`true`) with the standard list params.
108    async fn modules(
109        &self,
110        quarantined: bool,
111        query: &query::ListQuery,
112    ) -> Result<ListEnvelope<ModuleInfo>, CoreError>;
113    /// Fetch `/data/api/v1/systemPerformance/currentGauges` (authed) —
114    /// cpu in PERCENT (contrast [`Overview::cpu`], a 0–1 fraction).
115    async fn metrics_current(&self) -> Result<CurrentGauges, CoreError>;
116    /// Fetch `/data/api/v1/systemPerformance/charts` (authed) — historic
117    /// cpu/heap/non-heap datapoints (epoch-ms timestamps).
118    async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError>;
119    /// Fetch `/data/api/v1/systemPerformance/threads` (authed) — thread
120    /// execution counts (running/waiting/timedWaiting/blocked).
121    async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError>;
122    /// Fetch `/data/api/v1/designers` (authed) — active Designer
123    /// sessions (02-03, HLTH-08).
124    async fn designers(
125        &self,
126        query: &query::ListQuery,
127    ) -> Result<ListEnvelope<DesignerInfo>, CoreError>;
128    /// Fetch `/data/perspective/api/v1/sessions/` (authed) — the EXACT
129    /// trailing slash is the contract (Pitfall 8; module-scoped prefix).
130    async fn perspective_sessions(
131        &self,
132        query: &query::ListQuery,
133    ) -> Result<ListEnvelope<PerspectiveSession>, CoreError>;
134    /// Fetch `/data/vision/api/v1/clients` (authed) — active Vision
135    /// clients (designer shape + `tagCount`).
136    async fn vision_clients(
137        &self,
138        query: &query::ListQuery,
139    ) -> Result<ListEnvelope<VisionClient>, CoreError>;
140    /// DELETE `/data/perspective/api/v1/sessions?sessionId=<id>` (+ an
141    /// optional `message` shown to the session's user) — NO trailing
142    /// slash on the DELETE (spec). Audit-logged server-side.
143    async fn terminate_perspective_session(
144        &self,
145        id: &str,
146        message: Option<&str>,
147    ) -> Result<(), CoreError>;
148    /// DELETE `/data/vision/api/v1/client/{id}` — terminate a Vision
149    /// client. Audit-logged server-side.
150    async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError>;
151    /// DELETE `/data/api/v1/designer/{id}` — prune a Designer session.
152    /// Audit-logged server-side.
153    async fn prune_designer(&self, id: &str) -> Result<(), CoreError>;
154    /// Fetch `/data/api/v1/resources/list/ignition/database-connection`
155    /// (authed) — the web UI's Connections→Databases poll (HLTH-05).
156    /// `healthchecks` is raw passthrough (LOW-confidence populated
157    /// shape, research Open Question 1).
158    async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
159    /// Fetch `/data/api/v1/resources/list/ignition/opc-connection`
160    /// (authed) — the Connections→OPC poll (HLTH-06), same family.
161    async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
162    /// Fetch `/data/api/v1/logs` (authed) with [`LogQuery`] — the tail
163    /// primitive: `startTime` (epoch ms) is the cursor, no server push
164    /// exists (02-04, HLTH-03). The query ALWAYS carries an explicit
165    /// `limit` (Pitfall 9 — the server default is unlimited).
166    async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError>;
167    /// GET `/data/api/v1/logs/download` (authed, per-request 120 s
168    /// timeout) — a SQLite `.idb` archive, returned byte-for-byte with
169    /// the `Content-Disposition` filename and `Content-Type`. NEVER
170    /// zipped/extracted (Pitfall 7; Don't-Hand-Roll table).
171    async fn logs_download(&self) -> Result<LogDownload, CoreError>;
172    /// Fetch `/data/api/v1/logs/loggers` (authed) — the logger registry
173    /// (HLTH-04; ~1250 loggers on a fresh gateway).
174    async fn loggers(
175        &self,
176        query: &query::ListQuery,
177    ) -> Result<ListEnvelope<LoggerInfo>, CoreError>;
178    /// POST `/data/api/v1/logs/loggers/{loggerName}?level=X` (authed,
179    /// empty body, NO CSRF — verified: token mutations need none).
180    /// Logger names are Java identifiers `[A-Za-z0-9._]` — URL-safe,
181    /// embedded as-is. Audit-logged server-side.
182    async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError>;
183    /// POST `/data/api/v1/logs/levelreset` (authed, empty body) — reset
184    /// all custom logger levels to defaults. Audit-logged server-side.
185    async fn reset_logger_levels(&self) -> Result<(), CoreError>;
186    /// POST `/data/api/v1/restart-tasks/restart?confirm=true` (authed,
187    /// empty body, NO CSRF — token mutations need none) — the one big
188    /// red button. The gateway answers 200 with the literal body `true`
189    /// almost immediately; the ~40 s wait is poller-side (02-05's
190    /// `restart --wait` owns it). Audit-logged server-side.
191    async fn restart(&self) -> Result<(), CoreError>;
192    /// POST `/data/api/v1/scan/projects` (authed) — the harmless
193    /// project-rescan write probe (`ign doctor --check-write`; 2xx =
194    /// write permission, 403 = read-only token).
195    async fn scan_projects(&self) -> Result<(), CoreError>;
196    /// GET `/data/api/v1/resources/ignition/security-properties`
197    /// (authed) — the security config singleton; the doctor's
198    /// permissions deep-dive surfaces `readPermissions`/
199    /// `writePermissions` verbatim (passthrough shape).
200    async fn security_properties(&self) -> Result<SecurityProperties, CoreError>;
201    /// GET `/system/webdev/<route>` (authed) reporting the RAW HTTP
202    /// status — the doctor's route-presence probe (404 = absent;
203    /// 200/401/403 = exists). Deliberately NOT classified: presence
204    /// IS the answer; only transport failures are errors.
205    async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError>;
206    /// POST `/system/webdev/{project}/cli/{route}` (authed + any
207    /// caller headers — scriptExec's secret gate) with the action
208    /// JSON. classify() runs for transport/status errors, BUT the
209    /// 200 BODY is the route envelope `{ok, data|error}` — WebDev
210    /// IGNORES `status`, so denials ride HTTP 200: `ok:false` maps
211    /// `error.code` onto the taxonomy (05-03), `ok:true` returns
212    /// `data`. HTTP 200 alone is NEVER a success verdict.
213    async fn webdev_route_call(
214        &self,
215        project: &str,
216        route: &str,
217        body: &serde_json::Value,
218        extra_headers: &[(&str, &str)],
219    ) -> Result<serde_json::Value, CoreError>;
220    /// POST the route action with a PER-REQUEST timeout override —
221    /// the large-payload escape hatch (the `get_bytes`
222    /// `RequestBuilder::timeout` pattern, 02-04/09-05): the 30 s
223    /// client default would truncate the Phase-11 bulk exportTags
224    /// transfer (research Pitfall 6), so the bulk arms override with
225    /// [`crate::client::tags::TAGS_EXPORT_TIMEOUT`]. Default body =
226    /// the plain call — test doubles and any impl without an
227    /// override path ride the client default (the override only
228    /// lengthens the ceiling; request/response semantics are the
229    /// same classify + envelope parse).
230    async fn webdev_route_call_with_timeout(
231        &self,
232        project: &str,
233        route: &str,
234        body: &serde_json::Value,
235        extra_headers: &[(&str, &str)],
236        _timeout: Duration,
237    ) -> Result<serde_json::Value, CoreError> {
238        self.webdev_route_call(project, route, body, extra_headers)
239            .await
240    }
241    /// POST the route's `{"action":"version"}` handshake and
242    /// discriminate ([`webdev::RouteProbe`]): 200-body-ok →
243    /// `Present{route_version}`, 405 → `Absent` (the live-proven 8.3
244    /// marker — NOT 404), 402 → `Unlicensed`, 401/403 → `AuthGated`,
245    /// 200-body-denial → `Denied{code,message}`. Deliberately NOT
246    /// classified — the status code IS the answer (the
247    /// `webdev_route_status` precedent); only transport failures and
248    /// shapes the enum has no variant for (wizard redirects, 503
249    /// restarts, foreign 404s) are errors.
250    async fn webdev_route_probe(
251        &self,
252        project: &str,
253        route: &str,
254        extra_headers: &[(&str, &str)],
255    ) -> Result<RouteProbe, CoreError>;
256    /// GET `/data/api/v1/projects/list` (authed) — every RUNNABLE
257    /// project with inheritance info from the items themselves
258    /// (PROJ-01; standard list params, `limit=-1` UI convention).
259    async fn projects(
260        &self,
261        query: &query::ListQuery,
262    ) -> Result<ListEnvelope<ProjectRecord>, CoreError>;
263    /// GET `/data/api/v1/projects/find/{name}` (authed, name
264    /// percent-encoded per segment) — one project's full record; 404 →
265    /// `NotFound` via classify (this doubles as 03-02's collision
266    /// pre-check).
267    async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError>;
268    /// POST `/data/api/v1/projects` (authed, JSON body) — create. Ok
269    /// classification IS the success contract (create's response body
270    /// is unverified LOW — the restart `literal true` precedent;
271    /// callers that want data re-`find`). Audit-logged server-side.
272    async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError>;
273    /// POST `/data/api/v1/projects/copy` (authed, body exactly
274    /// `{"fromName":…,"toName":…}`) — an exact copy of all resources.
275    /// Audit-logged server-side.
276    async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError>;
277    /// POST `/data/api/v1/projects/rename/{name}` (authed, body
278    /// `{"name": "<new>"}`) — native rename, NOT copy+delete.
279    /// Audit-logged server-side.
280    async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError>;
281    /// PUT `/data/api/v1/projects/{name}` (authed, JSON body WITHOUT
282    /// `name`) — modify/reparent (`set --parent` IS the inheritance
283    /// move). Audit-logged server-side.
284    async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError>;
285    /// DELETE `/data/api/v1/projects/{name}?confirm=true` (authed,
286    /// empty body) — the server's own confirmation guard rides the
287    /// QUERY string (Pitfall 8: BOTH layers, always — the CLI's
288    /// `--yes` and the wire's `confirm=true`). Audit-logged
289    /// server-side.
290    async fn project_delete(&self, name: &str) -> Result<(), CoreError>;
291    /// GET `/data/api/v1/projects/export/{name}` (authed, per-request
292    /// [`projects::PROJECT_EXPORT_TIMEOUT`] = 120 s) — the project ZIP
293    /// STREAMED to `out` chunk-by-chunk via `bytes_stream` (Pitfall 2:
294    /// NO `Vec<u8>` accumulation anywhere), with the disposition
295    /// filename + byte count in the meta. Audit-relevant only as a
296    /// read (exports never mutate).
297    async fn project_export_to_file(&self, name: &str, out: &Path)
298    -> Result<ExportMeta, CoreError>;
299    /// POST `/data/api/v1/projects/import/{name}?overwrite=<bool>`
300    /// (authed, per-request [`projects::PROJECT_IMPORT_TIMEOUT`] =
301    /// 300 s) — the ZIP as the RAW body with `Content-Type:
302    /// application/zip` and a known `Content-Length` (a `Vec<u8>`
303    /// sidesteps the chunked-encoding question entirely — Pitfall 3's
304    /// timeout is handled by the override). Synchronous, no job IDs
305    /// (verified). Audit-logged server-side.
306    async fn project_import(
307        &self,
308        name: &str,
309        zip: Vec<u8>,
310        overwrite: bool,
311    ) -> Result<ImportOutcome, CoreError>;
312    /// GET `/data/api/v1/resources/list/ignition/tag-provider`
313    /// (authed) — the tag-provider resource list: full records
314    /// incl. `config`, `metrics.tagCount`, `healthchecks.status`
315    /// (05-04, TAGS-01 — the NATIVE provider seam; no deployed
316    /// route involved). Standard list params (limit=-1, the UI
317    /// convention).
318    async fn tag_provider_list(
319        &self,
320        query: &query::ListQuery,
321    ) -> Result<ListEnvelope<TagProviderRecord>, CoreError>;
322    /// GET `/data/api/v1/resources/find/ignition/tag-provider/{name}`
323    /// (authed, name percent-encoded per segment) — one provider's
324    /// full record incl. the `signature` the chained delete needs.
325    /// 404 → `NotFound` via classify.
326    async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError>;
327    /// POST `/data/api/v1/resources/ignition/tag-provider` (authed)
328    /// with a JSON **ARRAY** body of create records — the
329    /// live-proven create shape (05-RESEARCH provider table).
330    /// Audit-logged server-side.
331    async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError>;
332    /// DELETE `/data/api/v1/resources/ignition/tag-provider/{name}/{signature}`
333    /// (authed, both segments percent-encoded) — delete-by-signature;
334    /// the signature comes from find. Audit-logged server-side.
335    async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError>;
336    /// GET `/data/api/v1/trial` — the trial state, live-verified
337    /// UNAUTHENTICATED on 8.3.3 + 8.3.6 (both trial states): auth
338    /// headers ride ONLY when the client carries a credential (fresh
339    /// rigs have none — the version-command degradation precedent,
340    /// rig-family edition).
341    async fn trial_status_wire(&self) -> Result<TrialWire, CoreError>;
342    /// GET `/data/api/v1/overview/banners` — the trial cross-check
343    /// (severity/expireTime semantics, Pitfall 7). Same conditional
344    /// auth as [`Self::trial_status_wire`].
345    async fn banners(&self) -> Result<BannerSet, CoreError>;
346    /// POST `/data/api/v1/trial` (authed, empty body) — the trial
347    /// RESET, tier 0 of the ladder: a token credential plausibly
348    /// satisfies it without CSRF (token mutations need none — the
349    /// restart/set-logger precedent). The 2xx body IS the fresh
350    /// [`TrialWire`] (live-observed). NOTE (live-discovered state
351    /// gate): the gateway 403s resets on a NON-expired trial — the
352    /// action layer pre-checks expiry.
353    async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError>;
354    /// GET `/data/api/v1/backup?type={roaming|all}` (authed,
355    /// [`backup::BACKUP_TIMEOUT`] = 300 s, `Accept:
356    /// application/octet-stream`) — the portable gwbk STREAMED to
357    /// `out` chunk-by-chunk through the 03-02 `download_to_file`
358    /// pipeline (the ONE streaming body-consumption site — never a
359    /// `Vec<u8>`, Pitfall 2). Byte count + metadata ride out in
360    /// [`ExportMeta`] (04-04, RIG-04; 07-02 param-ized the type —
361    /// `Roaming` stays the caller default).
362    async fn backup_download(
363        &self,
364        out: &Path,
365        backup_type: backup::BackupType,
366    ) -> Result<ExportMeta, CoreError>;
367    /// POST `/data/api/v1/backup` (authed, [`backup::BACKUP_TIMEOUT`]
368    /// = 300 s) — the RESTORE: the gwbk bytes as a RAW
369    /// `application/octet-stream` body (NOT multipart — the postman
370    /// collection's exact shape) with the four scope params EXPLICIT
371    /// on the query string. Synchronous AND followed by a gateway
372    /// restart (Pitfall 6): the 2xx means the restore was ACCEPTED —
373    /// the actions layer owns the post-restore RUNNING wait. The
374    /// upload direction buffers by design (the import precedent).
375    async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError>;
376    /// GET `/data/eam/api/v1/eam-tasks/history` (authed) — task run
377    /// history, the standard `{items, metadata}` envelope. `limit`
378    /// defaults to [`eam::EAM_HISTORY_DEFAULT_LIMIT`] (200 — EAM
379    /// history grows unboundedly; an explicit limit ALWAYS rides the
380    /// wire, the logs discipline). A stock (non-controller) gateway
381    /// 403s → [`CoreError::EamNotController`] via classify
382    /// (path-scoped message classification — never a misleading
383    /// `auth_rejected`).
384    async fn eam_task_history(
385        &self,
386        limit: Option<u32>,
387        search: Option<&str>,
388    ) -> Result<ListEnvelope<EamHistoryItem>, CoreError>;
389    /// GET `/data/api/v1/resources/list/com.inductiveautomation.eam/
390    /// eam-tasks` (authed) — task DEFINITIONS through the standard
391    /// config-resource family (the tag-provider pattern; available
392    /// on stock gateways — no controller needed for definitions).
393    async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError>;
394    /// GET `/data/api/v1/resources/find/com.inductiveautomation.eam/
395    /// eam-tasks/{name}` (authed) — one definition's full record
396    /// incl. the `scheduledTaskState` healthcheck
397    /// (`currentState`/`nextScheduled`/`owner` under `details`) and
398    /// the mutation `signature`. 404 → `NotFound` via classify.
399    async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError>;
400    /// POST `/data/api/v1/resources/com.inductiveautomation.eam/
401    /// eam-tasks` (authed) with a JSON **ARRAY** body of one
402    /// definition record — the config-resource create shape (the
403    /// tag-provider precedent). Ok classification IS the success
404    /// contract (create's response body is unverified — the
405    /// project-create precedent; callers that want data re-find).
406    /// Audit-logged server-side.
407    async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError>;
408    /// POST `/data/eam/api/v1/eam-tasks/force/{owner}/{name}` (authed,
409    /// empty body) — dispatch a task NOW. Live-proven success shape:
410    /// **204** (any 2xx is done — the route-status style; execution
411    /// OUTCOMES surface later in history as data, never on this
412    /// response). Runtime seam: the controller gate classifies.
413    async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError>;
414    /// POST `/data/eam/api/v1/eam-tasks/suspend/{name}` (authed,
415    /// empty body) — suspend the task's scheduler trigger.
416    /// Live-proven success shape: **204**; the flag PERSISTS into
417    /// `config.profile.isSuspended` (10-LIVE-CAPTURES §1c/Decision 1).
418    /// Requires a live scheduler trigger — an OnDemand/untriggered
419    /// (or unknown-named, §7) task answers 500 Jetty HTML, which
420    /// classifies as `Internal` with the page's own message (no
421    /// honest 404 exists on this seam).
422    async fn eam_task_suspend(&self, name: &str) -> Result<(), CoreError>;
423    /// POST `/data/eam/api/v1/eam-tasks/resume/{name}` (authed,
424    /// empty body) — the inverse of [`Self::eam_task_suspend`]:
425    /// **204** success, `isSuspended` back to `false` (§1d). Unknown
426    /// names answer 500 HTML naming the task (§7).
427    async fn eam_task_resume(&self, name: &str) -> Result<(), CoreError>;
428    /// POST `/data/eam/api/v1/eam-tasks/cancel/{name}` (authed,
429    /// empty body) — cancel a PENDING execution. Always **204** on
430    /// the captured shapes: nothing-pending AND unknown-name are
431    /// silent successes (§7) — cancel is never a name-validation
432    /// tool (the actions layer owns find-before-write).
433    async fn eam_task_cancel(&self, name: &str) -> Result<(), CoreError>;
434    /// GET `/data/eam/api/v1/eam-tasks/scheduled/{running}` (authed)
435    /// — the pending-execution read; `{running}` is the LITERAL word
436    /// `true`/`false` (§2). Answers the standard
437    /// `{items, metadata}` envelope; this method unwraps it to the
438    /// items ([`EamScheduledTask`] — 13 capture-locked keys,
439    /// `taskState` String vocabulary). A stock gateway 403s →
440    /// [`CoreError::EamNotController`] via the same path-scoped arm
441    /// as every runtime seam call.
442    async fn eam_tasks_scheduled(&self, running: bool) -> Result<Vec<EamScheduledTask>, CoreError>;
443    /// PUT `/data/api/v1/resources/com.inductiveautomation.eam/
444    /// eam-tasks` (authed) with a single-element JSON **ARRAY**
445    /// carrying the FULL find record (settings included — omitting
446    /// `config.settings` ⇒ 422, the create trap) and the ORIGINAL
447    /// `signature`. The 200 body is the captured
448    /// [`ModifyOutcome`] `{success, changes[], problem}`
449    /// (§6a); `None` = a 2xx with an empty body (lenient). Rename via
450    /// PUT is NOT supported (§5: changed name + original signature ⇒
451    /// 404 — the actions layer must compose create-new + delete-old).
452    async fn eam_task_modify(
453        &self,
454        definition: &serde_json::Value,
455    ) -> Result<Option<ModifyOutcome>, CoreError>;
456    /// DELETE `/data/api/v1/resources/com.inductiveautomation.eam/
457    /// eam-tasks/{name}/{signature}` (authed, both segments
458    /// percent-encoded) with `?collection=core` ALWAYS (the
459    /// collection VALUE — `collection=eam-tasks` 404s, §3c) and
460    /// `confirm=true` only when the caller opts in: a lone-resource
461    /// delete SUCCEEDS without it (§3b) and the confirm-demand shape
462    /// is UNOBSERVED (§3d) — never hard-coded. The 200 body is the
463    /// captured [`DeleteOutcome`] (adds `references`). A signature
464    /// mismatch answers HTTP 500 + `problem` — see the
465    /// [`eam`] module docs for the recorded FINDING (not classified
466    /// here; slug decision belongs to 10-03/10-04).
467    async fn eam_task_delete(
468        &self,
469        name: &str,
470        signature: &str,
471        confirm: bool,
472    ) -> Result<DeleteOutcome, CoreError>;
473    /// The raw passthrough (09-03, EXT-01): send `call` with its
474    /// arbitrary method, caller headers, query pairs, and optional raw
475    /// body (ANY method — GET/DELETE bodies allowed, curl parity).
476    /// Auth rides [`ReqwestGatewayApi::apply_auth`] (the ONE
477    /// `Secret::expose` site — user auth-pattern headers are refused
478    /// by [`apicall::refuse_auth_headers`] at the CLI/action layer,
479    /// never stripped); classification rides the api-call pipeline
480    /// ([`ReqwestGatewayApi::send_and_classify_for_api`]), so an
481    /// unclassified gateway 4xx is `GatewayClientError` (exit 2,
482    /// verbatim capped body). The 2xx body returns VERBATIM
483    /// ([`apicall::ApiCallData`] — `RawValue` passthrough: no field
484    /// dropped, no value coerced, key order preserved); a non-JSON
485    /// 2xx body is the honest internal-class refusal. The usage
486    /// guards live in [`apicall`] and the action layer — BOTH run
487    /// them, so in-process callers cannot skip the checks.
488    async fn api_call(
489        &self,
490        call: &apicall::ApiCallRequest,
491    ) -> Result<apicall::ApiCallData, CoreError>;
492    /// GET `/data/api/v1/licenses` (authed) — the license inventory
493    /// (09-04). The wire model is PARTIAL-CURATED: the morning-check
494    /// skeleton typed, array elements + `details` passthrough (the
495    /// fresh-rig captures answered empty arrays — element shapes are
496    /// not capture-proven).
497    async fn license_status(&self) -> Result<LicenseStatusWire, CoreError>;
498    /// GET `/data/api/v1/redundancy` (authed) — the flat 11-field
499    /// redundancy status (09-04). Units are capture-locked at the
500    /// model (`uptime` ms-since-start wall-clock-proven; the
501    /// `lastSyncTimestamp` `-1` sentinel normalized via
502    /// [`redundancy::RedundancyStatusWire::last_sync_epoch_ms`]).
503    async fn redundancy_status(&self) -> Result<RedundancyStatusWire, CoreError>;
504    /// GET `/data/api/v1/overview/gan` (authed) — the 5-field GAN
505    /// summary (09-04); a non-GAN gateway's zero-connection body IS
506    /// the canonical capture.
507    async fn gan_status(&self) -> Result<GanStatusWire, CoreError>;
508    /// POST `/data/api/v1/diagnostics/bundle/generate` (authed, no
509    /// body) — start bundle generation (09-05). The 200 body IS the
510    /// fresh [`BundleStatusWire`] (live capture:
511    /// `{"state":"Generating"}`). Audit-logged server-side.
512    async fn bundle_generate(&self) -> Result<BundleStatusWire, CoreError>;
513    /// GET `/data/api/v1/diagnostics/bundle/status` (authed) — the
514    /// status poll: captured state vocabulary + `fileSize` (bytes,
515    /// absent while generating — 09-LIVE-CAPTURES §5).
516    async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError>;
517    /// GET `/data/api/v1/diagnostics/bundle/download` (authed,
518    /// per-request [`diagnostics::BUNDLE_DOWNLOAD_TIMEOUT`] = 300 s —
519    /// Pitfall 8: the 30 s client default would truncate MB-sized
520    /// bundles) — the ZIP STREAMED to `out` chunk-by-chunk through the
521    /// `download_to_file` pipeline (classify-first; NO `Vec<u8>`
522    /// anywhere). The `Content-Disposition` filename + `Content-Type`
523    /// ride the pipeline's [`ExportMeta`].
524    async fn bundle_download(&self, out: &Path) -> Result<ExportMeta, CoreError>;
525}
526
527/// Production [`GatewayApi`] over reqwest.
528pub struct ReqwestGatewayApi {
529    base: url::Url,
530    credential: Option<Credential>,
531    client: reqwest::Client,
532}
533
534impl ReqwestGatewayApi {
535    /// Build from a resolved profile (post env-overlay — the dispatch site
536    /// owns that precedence) and an optional credential (`None` = proceed
537    /// header-less; the gateway's answer is then classified — 401 under
538    /// 8.3 default security).
539    ///
540    /// Timeouts: 10s connect / 30s overall (per-class refinements land in
541    /// Phase 2). `ssl_verify = false` accepts invalid certs — dev-rig
542    /// only, per-profile, never global.
543    pub fn new(profile: &Profile, credential: Option<Credential>) -> Result<Self, CoreError> {
544        let client = build_client(profile.ssl_verify)?;
545        Ok(Self {
546            base: profile.url.clone(),
547            credential,
548            client,
549        })
550    }
551
552    /// Test constructor: base URL + credential, no profile needed.
553    pub fn for_tests(base_url: &str, credential: Option<Credential>) -> Self {
554        Self {
555            base: url::Url::parse(base_url).expect("test base URL parses"),
556            credential,
557            client: build_client(true).expect("test client builds"),
558        }
559    }
560
561    /// The full request URL for `path` (bases are normalized to a trailing
562    /// slash; an absolute path replaces from root).
563    fn url_for(&self, path: &str) -> url::Url {
564        self.base.join(path).expect("base joins an absolute path")
565    }
566
567    /// The auth-header rule in ONE place: token XOR basic XOR neither — a
568    /// match, not if/if-else chains. [`Secret::expose`] is called at
569    /// exactly this site (the redaction boundary MOVED here in 02-01, not
570    /// duplicated).
571    ///
572    /// Basic carries a loud demotion warning: it cannot authenticate 8.3
573    /// `/data` routes (verified: valid commissioned credentials → 401) —
574    /// warn once per call, never silently retry (02-RESEARCH Auth §2).
575    fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
576        let mut request = request;
577        match &self.credential {
578            Some(Credential::Token(token)) => {
579                request = request.header("X-Ignition-API-Token", token.expose());
580            }
581            Some(Credential::Basic(user, password)) => {
582                tracing::warn!(
583                    "Basic auth does not authenticate Ignition 8.3 /data routes \
584                     (verified: valid credentials → 401); use an API token"
585                );
586                request = request.basic_auth(user.expose(), Some(password.expose()));
587            }
588            None => {}
589        }
590        request
591    }
592
593    /// GET `path` (with pre-built query `pairs` when given) → classify →
594    /// deserialize into `T`. `auth = false` fetches header-less (the
595    /// `/StatusPing` readiness probe, 02-02 — it must work with broken
596    /// credentials). Callers build pairs via `to_query_pairs()` so the
597    /// param-name mapping stays in the capability files.
598    async fn get_json<T: serde::de::DeserializeOwned>(
599        &self,
600        path: &str,
601        pairs: Option<&[(String, String)]>,
602        auth: bool,
603    ) -> Result<T, CoreError> {
604        let url = self.url_for(path);
605        let mut request = self.client.get(url.clone());
606        if let Some(pairs) = pairs {
607            request = request.query(&pairs);
608        }
609        if auth {
610            request = self.apply_auth(request);
611        }
612        let response = self.send_and_classify(request, &url).await?;
613        response.json::<T>().await.map_err(|err| {
614            CoreError::Internal(format!(
615                "response from {url} did not match the expected shape: {err}"
616            ))
617        })
618    }
619
620    /// GET `path` → classify → read the response as BYTES plus the
621    /// `Content-Disposition` filename and `Content-Type` — the
622    /// archive-download pipeline (02-04). `timeout` overrides the 30 s
623    /// client default PER REQUEST (a large `.idb` archive must not be
624    /// truncated) — `RequestBuilder::timeout`, not a second client.
625    async fn get_bytes(&self, path: &str, timeout: Duration) -> Result<LogDownload, CoreError> {
626        let url = self.url_for(path);
627        let request = self.client.get(url.clone()).timeout(timeout);
628        let request = self.apply_auth(request);
629        let response = self.send_and_classify(request, &url).await?;
630        let filename = response
631            .headers()
632            .get(reqwest::header::CONTENT_DISPOSITION)
633            .and_then(|value| value.to_str().ok())
634            .and_then(logs::filename_from_content_disposition);
635        let content_type = response
636            .headers()
637            .get(reqwest::header::CONTENT_TYPE)
638            .and_then(|value| value.to_str().ok())
639            .map(str::to_string);
640        let bytes = response.bytes().await.map_err(|err| CoreError::Network {
641            url: url.to_string(),
642            source: Some(err),
643            observation: None,
644        })?;
645        Ok(LogDownload {
646            bytes: bytes.to_vec(),
647            filename,
648            content_type,
649        })
650    }
651
652    /// GET `path` → classify → STREAM the body to `out` chunk-by-chunk
653    /// — the file-download pipeline (03-02). The response body is
654    /// consumed HERE, at a pipeline site, classify-first like every
655    /// other: an error answer must classify (never stream), and on
656    /// success each `bytes_stream()` chunk goes straight through
657    /// `AsyncWriteExt::write_all` into a `tokio::fs::File` — NO
658    /// `Vec<u8>` accumulation anywhere (Pitfall 2: a multi-hundred-MB
659    /// export ZIP must not buffer in memory). The response metadata
660    /// (`Content-Disposition` filename, `Content-Type`) and the
661    /// chunk-counted byte total ride out in [`ExportMeta`]. Requires
662    /// the workspace `reqwest` `stream` + `tokio` `fs` features (the
663    /// research-flagged dep gap this plan closed).
664    ///
665    /// `accept` adds an OPTIONAL `Accept` header for the callers whose
666    /// server contract names one (04-04's gwbk download sends
667    /// `application/octet-stream`; the 03-02 export sends none) — a
668    /// minimal parameterization that keeps THIS the one streaming
669    /// site instead of forking a second copy of the chunk loop.
670    async fn download_to_file(
671        &self,
672        path: &str,
673        out: &Path,
674        timeout: Duration,
675        accept: Option<&str>,
676    ) -> Result<ExportMeta, CoreError> {
677        use futures_util::StreamExt;
678        use tokio::io::AsyncWriteExt;
679
680        let url = self.url_for(path);
681        let mut request = self.client.get(url.clone()).timeout(timeout);
682        if let Some(accept) = accept {
683            request = request.header(reqwest::header::ACCEPT, accept);
684        }
685        let request = self.apply_auth(request);
686        let response = self.send_and_classify(request, &url).await?;
687        let filename = response
688            .headers()
689            .get(reqwest::header::CONTENT_DISPOSITION)
690            .and_then(|value| value.to_str().ok())
691            .and_then(logs::filename_from_content_disposition);
692        let content_type = response
693            .headers()
694            .get(reqwest::header::CONTENT_TYPE)
695            .and_then(|value| value.to_str().ok())
696            .map(str::to_string);
697
698        let mut file = tokio::fs::File::create(out).await.map_err(|err| {
699            CoreError::Internal(format!("cannot create {}: {err}", out.display()))
700        })?;
701        let mut stream = response.bytes_stream();
702        let mut bytes: u64 = 0;
703        while let Some(chunk) = stream.next().await {
704            let chunk = chunk.map_err(|err| CoreError::Network {
705                url: url.to_string(),
706                source: Some(err),
707                observation: None,
708            })?;
709            file.write_all(&chunk).await.map_err(|err| {
710                CoreError::Internal(format!("cannot write {}: {err}", out.display()))
711            })?;
712            bytes += chunk.len() as u64;
713        }
714        file.flush()
715            .await
716            .map_err(|err| CoreError::Internal(format!("cannot flush {}: {err}", out.display())))?;
717        Ok(ExportMeta {
718            filename,
719            bytes,
720            content_type,
721        })
722    }
723
724    /// POST `path` with `pairs` as QUERY params and an empty body →
725    /// classify → hand back the response (callers read `true`/JSON as
726    /// their capability needs). Production callers since 02-04:
727    /// `set_logger_level`, `reset_logger_levels` (and 02-05's restart
728    /// with `confirm=true`). Token-auth POSTs need NO CSRF (verified
729    /// 02-RESEARCH §Auth Model).
730    async fn post_empty(
731        &self,
732        path: &str,
733        pairs: &[(&str, String)],
734        auth: bool,
735    ) -> Result<reqwest::Response, CoreError> {
736        let url = self.url_for(path);
737        let mut request = self.client.post(url.clone()).query(pairs);
738        if auth {
739            request = self.apply_auth(request);
740        }
741        self.send_and_classify(request, &url).await
742    }
743
744    /// DELETE `path` with `pairs` as QUERY params (empty body) →
745    /// classify → `Ok(())` on any classified success. Token-auth DELETEs
746    /// need NO CSRF (verified 02-RESEARCH §Auth Model: CSRF is only for
747    /// cookie/session auth); the classified bodies (`{terminated: N}`,
748    /// `{message: …}`) are advisory — Ok classification IS the success
749    /// contract.
750    async fn delete_with_query(
751        &self,
752        path: &str,
753        pairs: &[(&str, String)],
754    ) -> Result<(), CoreError> {
755        let url = self.url_for(path);
756        let mut request = self.client.delete(url.clone()).query(pairs);
757        request = self.apply_auth(request);
758        self.send_and_classify(request, &url).await.map(|_| ())
759    }
760
761    /// POST `path` with a JSON body → classify → hand back the response
762    /// (callers read the body as their capability needs; the project
763    /// mutations treat Ok classification AS the success contract —
764    /// those bodies are unverified LOW, the restart `literal true`
765    /// precedent). Token-auth POSTs need NO CSRF (verified
766    /// 02-RESEARCH §Auth Model). One of the two body-carrying pipeline
767    /// helpers (03-01); serde serializes struct fields in declaration order, so
768    /// recorded bodies are deterministic for the wiremock pins.
769    async fn post_json<T: serde::Serialize + ?Sized>(
770        &self,
771        path: &str,
772        body: &T,
773    ) -> Result<reqwest::Response, CoreError> {
774        let url = self.url_for(path);
775        let request = self.apply_auth(self.client.post(url.clone()).json(body));
776        self.send_and_classify(request, &url).await
777    }
778
779    /// POST the action JSON to a webdev route with caller headers +
780    /// auth applied, returning `(full URL, response)` — the shared
781    /// head of the two webdev seam methods (05-03). NO classify here:
782    /// `webdev_route_probe` reads the raw status (the code IS the
783    /// answer); `webdev_route_call` classifies downstream. Transport
784    /// failures map to `Network` like every pipeline.
785    async fn webdev_post_raw(
786        &self,
787        project: &str,
788        route: &str,
789        body: &serde_json::Value,
790        extra_headers: &[(&str, &str)],
791        timeout: Option<Duration>,
792    ) -> Result<(String, reqwest::Response), CoreError> {
793        let path = webdev::route_url(project, route);
794        let url = self.url_for(&path);
795        let mut request = self.client.post(url.clone()).json(body);
796        if let Some(t) = timeout {
797            // Per-request override WITHOUT a second client — the
798            // RequestBuilder::timeout pattern (02-04/09-05).
799            request = request.timeout(t);
800        }
801        for (name, value) in extra_headers {
802            request = request.header(*name, *value);
803        }
804        let request = self.apply_auth(request);
805        let response = request.send().await.map_err(|err| CoreError::Network {
806            url: url.to_string(),
807            source: Some(err),
808            observation: None,
809        })?;
810        Ok((url.to_string(), response))
811    }
812
813    /// PUT `path` with a JSON body → classify → `Ok(())` (modify/
814    /// reparent; resource puts in 03-03). Token-auth PUTs need NO
815    /// CSRF. The classify-first rule holds: nothing consumes a body
816    /// that skipped classify.
817    async fn put_json<T: serde::Serialize + ?Sized>(
818        &self,
819        path: &str,
820        body: &T,
821    ) -> Result<(), CoreError> {
822        let url = self.url_for(path);
823        let request = self.apply_auth(self.client.put(url.clone()).json(body));
824        self.send_and_classify(request, &url).await.map(|_| ())
825    }
826
827    /// Send + transport-error mapping + [`classify`] — the shared tail of
828    /// every pipeline helper. Transport failures (connect/timeout/TLS) →
829    /// `Network` (exit 4); everything the gateway ANSWERED goes through
830    /// the classifier. Curated traffic: `api_call = false` — the
831    /// catch-all cannot fire without the parameter (09-01 Pitfall 1).
832    async fn send_and_classify(
833        &self,
834        request: reqwest::RequestBuilder,
835        url: &url::Url,
836    ) -> Result<reqwest::Response, CoreError> {
837        let response = request.send().await.map_err(|err| CoreError::Network {
838            url: url.to_string(),
839            source: Some(err),
840            observation: None,
841        })?;
842        classify::classify(response, url.as_ref(), false).await
843    }
844
845    /// The api-call-scoped pipeline entry (09-01): identical
846    /// transport-error → `Network` mapping, then [`classify`] with
847    /// `api_call = true` so an unclassified gateway 4xx maps to
848    /// `GatewayClientError` (exit 2, verbatim capped body) instead of
849    /// `Internal`. Public because `ign api call`'s action layer (09-03)
850    /// is the production consumer and the contract tests
851    /// (tests/api_classify_contract.rs) pin the full exit partition
852    /// through it — nothing in the curated pipeline switches to it.
853    pub async fn send_and_classify_for_api(
854        &self,
855        request: reqwest::RequestBuilder,
856        url: &url::Url,
857    ) -> Result<reqwest::Response, CoreError> {
858        let response = request.send().await.map_err(|err| CoreError::Network {
859            url: url.to_string(),
860            source: Some(err),
861            observation: None,
862        })?;
863        classify::classify(response, url.as_ref(), true).await
864    }
865}
866
867/// Install the process-wide TLS crypto provider (ring). reqwest is built
868/// with `rustls-no-provider` (workspace Cargo.toml) so the provider is the
869/// app's responsibility — this is the single install point for the binary,
870/// and it is `pub` so the e2e tests' raw `reqwest::Client` constructions
871/// can install it too. Safe to call any number of times.
872pub fn install_crypto_provider() {
873    // ring is the process-wide TLS provider (workspace Cargo.toml: reqwest
874    // rides rustls-no-provider). install_default() errors on a second call —
875    // the Once makes every later construction a no-op instead.
876    static ONCE: std::sync::Once = std::sync::Once::new();
877    ONCE.call_once(|| {
878        let _ = rustls::crypto::ring::default_provider().install_default();
879    });
880}
881
882fn build_client(ssl_verify: bool) -> Result<reqwest::Client, CoreError> {
883    install_crypto_provider();
884    let mut builder = reqwest::Client::builder()
885        // Never follow redirects: an uncommissioned gateway 302s everything
886        // to /welcome and the follow would render the wizard HTML as a 200
887        // (02-RESEARCH Pitfall 6). classify() maps the 3xx instead.
888        .redirect(reqwest::redirect::Policy::none())
889        .connect_timeout(Duration::from_secs(10))
890        .timeout(Duration::from_secs(30));
891    if !ssl_verify {
892        builder = builder.danger_accept_invalid_certs(true);
893    }
894    builder
895        .build()
896        .map_err(|err| CoreError::Internal(format!("cannot build HTTP client: {err}")))
897}
898
899#[async_trait::async_trait]
900impl GatewayApi for ReqwestGatewayApi {
901    async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
902        let mut info: GatewayInfo = self.get_json(GATEWAY_INFO_PATH, None, true).await?;
903        info.endpoint = Some(self.url_for(GATEWAY_INFO_PATH).to_string());
904        Ok(info)
905    }
906
907    async fn overview(&self) -> Result<Overview, CoreError> {
908        self.get_json(status::OVERVIEW_PATH, None, true).await
909    }
910
911    async fn status_ping(&self) -> Result<StatusPing, CoreError> {
912        // auth = false — the whole point: the readiness anchor must not
913        // depend on credentials (pinned by the wiremock header-absence
914        // proof in tests/status_contract.rs).
915        self.get_json(status::STATUS_PING_PATH, None, false).await
916    }
917
918    async fn modules(
919        &self,
920        quarantined: bool,
921        query: &query::ListQuery,
922    ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
923        let path = if quarantined {
924            status::MODULES_QUARANTINED_PATH
925        } else {
926            status::MODULES_HEALTHY_PATH
927        };
928        self.get_json(path, Some(&query.to_query_pairs()), true)
929            .await
930    }
931
932    async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
933        self.get_json(metrics::CURRENT_GAUGES_PATH, None, true)
934            .await
935    }
936
937    async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
938        self.get_json(metrics::CHARTS_PATH, None, true).await
939    }
940
941    async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
942        self.get_json(metrics::THREADS_PATH, None, true).await
943    }
944
945    async fn designers(
946        &self,
947        query: &query::ListQuery,
948    ) -> Result<ListEnvelope<DesignerInfo>, CoreError> {
949        self.get_json(
950            sessions::DESIGNERS_PATH,
951            Some(&query.to_query_pairs()),
952            true,
953        )
954        .await
955    }
956
957    async fn perspective_sessions(
958        &self,
959        query: &query::ListQuery,
960    ) -> Result<ListEnvelope<PerspectiveSession>, CoreError> {
961        // The trailing slash is PART OF THE PATH (Pitfall 8) — url_for's
962        // join preserves it; the exact-path wiremock matcher in
963        // tests/sessions_contract.rs pins it.
964        self.get_json(
965            sessions::PERSPECTIVE_SESSIONS_LIST_PATH,
966            Some(&query.to_query_pairs()),
967            true,
968        )
969        .await
970    }
971
972    async fn vision_clients(
973        &self,
974        query: &query::ListQuery,
975    ) -> Result<ListEnvelope<VisionClient>, CoreError> {
976        self.get_json(
977            sessions::VISION_CLIENTS_PATH,
978            Some(&query.to_query_pairs()),
979            true,
980        )
981        .await
982    }
983
984    async fn terminate_perspective_session(
985        &self,
986        id: &str,
987        message: Option<&str>,
988    ) -> Result<(), CoreError> {
989        // sessionId is a QUERY param on the spec's DELETE route — never
990        // a body (recorded-request proof in tests/sessions_contract.rs).
991        let mut pairs = vec![("sessionId", id.to_string())];
992        if let Some(message) = message {
993            pairs.push(("message", message.to_string()));
994        }
995        self.delete_with_query(sessions::PERSPECTIVE_SESSIONS_TERMINATE_PATH, &pairs)
996            .await
997    }
998
999    async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError> {
1000        self.delete_with_query(&sessions::vision_client_terminate_path(id), &[])
1001            .await
1002    }
1003
1004    async fn prune_designer(&self, id: &str) -> Result<(), CoreError> {
1005        self.delete_with_query(&sessions::designer_prune_path(id), &[])
1006            .await
1007    }
1008
1009    async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
1010        // The UI polls the resource list with limit=-1 — same convention
1011        // as every other list capability.
1012        self.get_json(
1013            connections::DATABASE_CONNECTIONS_PATH,
1014            Some(&query::ListQuery::default().to_query_pairs()),
1015            true,
1016        )
1017        .await
1018    }
1019
1020    async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
1021        self.get_json(
1022            connections::OPC_CONNECTIONS_PATH,
1023            Some(&query::ListQuery::default().to_query_pairs()),
1024            true,
1025        )
1026        .await
1027    }
1028
1029    async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError> {
1030        // Explicit limit ALWAYS rides the wire (Pitfall 9) — enforced by
1031        // LogQuery::to_query_pairs, pinned by the contract test.
1032        self.get_json(logs::LOGS_PATH, Some(&filter.to_query_pairs()), true)
1033            .await
1034    }
1035
1036    async fn logs_download(&self) -> Result<LogDownload, CoreError> {
1037        // Per-request timeout override: the 30 s client default would
1038        // truncate large archives (per-class timeout WITHOUT a second
1039        // client — RequestBuilder::timeout, 02-RESEARCH §Architecture).
1040        self.get_bytes(logs::LOGS_DOWNLOAD_PATH, Duration::from_secs(120))
1041            .await
1042    }
1043
1044    async fn loggers(
1045        &self,
1046        query: &query::ListQuery,
1047    ) -> Result<ListEnvelope<LoggerInfo>, CoreError> {
1048        self.get_json(logs::LOGGERS_PATH, Some(&query.to_query_pairs()), true)
1049            .await
1050    }
1051
1052    async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError> {
1053        // `level` rides the QUERY string against an EMPTY body (verified
1054        // live: 200 + the level flips; recorded-request proof in
1055        // tests/logs_contract.rs).
1056        self.post_empty(
1057            &logs::logger_set_path(logger),
1058            &[("level", level.to_string())],
1059            true,
1060        )
1061        .await
1062        .map(|_| ())
1063    }
1064
1065    async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1066        self.post_empty(logs::LEVEL_RESET_PATH, &[], true)
1067            .await
1068            .map(|_| ())
1069    }
1070
1071    async fn restart(&self) -> Result<(), CoreError> {
1072        // `confirm=true` rides the QUERY string against an empty body
1073        // (the verified shape; recorded-request proof in
1074        // tests/restart_wait_contract.rs). Token-auth POSTs need no
1075        // CSRF (02-RESEARCH §Auth Model).
1076        let response = self
1077            .post_empty(
1078                restart::RESTART_PATH,
1079                &[("confirm", "true".to_string())],
1080                true,
1081            )
1082            .await?;
1083        // Success-shape drift guard: the verified body is the literal
1084        // `true`. Any other 2xx body still means the POST was accepted
1085        // — warn, don't fail (the wait half reports what happens next).
1086        let body = response.text().await.unwrap_or_default();
1087        if body.trim() != "true" {
1088            tracing::warn!(
1089                body = %body,
1090                "restart POST answered an unexpected 2xx body (expected the literal `true`)"
1091            );
1092        }
1093        Ok(())
1094    }
1095
1096    async fn scan_projects(&self) -> Result<(), CoreError> {
1097        self.post_empty(restart::SCAN_PROJECTS_PATH, &[], true)
1098            .await
1099            .map(|_| ())
1100    }
1101
1102    async fn security_properties(&self) -> Result<SecurityProperties, CoreError> {
1103        // The singleton READ (ADOPT-RESEARCH §2, live-corrected): the
1104        // record nests the permissions under `config` — deserialize
1105        // from there, tolerating a legacy flat record (config absent
1106        // → the record itself parses).
1107        let record: serde_json::Value = self
1108            .get_json(
1109                restart::SECURITY_PROPERTIES_PATH,
1110                Some(&[("defaultIfUndefined".to_string(), "true".to_string())]),
1111                true,
1112            )
1113            .await?;
1114        let config = record.get("config").cloned().unwrap_or(record);
1115        serde_json::from_value(config).map_err(|err| {
1116            CoreError::Internal(format!(
1117                "security-properties did not match the expected shape: {err}"
1118            ))
1119        })
1120    }
1121
1122    async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError> {
1123        // The raw-status probe: send, surface the status code, never
1124        // classify (404 vs 200/401/403 is the ANSWER, not an error).
1125        // Only transport failures (DNS/refused/timeout) error out.
1126        let path = restart::webdev_route_path(route);
1127        let url = self.url_for(&path);
1128        let request = self.apply_auth(self.client.get(url.clone()));
1129        let response = request.send().await.map_err(|err| CoreError::Network {
1130            url: url.to_string(),
1131            source: Some(err),
1132            observation: None,
1133        })?;
1134        Ok(response.status().as_u16())
1135    }
1136
1137    async fn webdev_route_call(
1138        &self,
1139        project: &str,
1140        route: &str,
1141        body: &serde_json::Value,
1142        extra_headers: &[(&str, &str)],
1143    ) -> Result<serde_json::Value, CoreError> {
1144        // classify() runs normally for transport/status errors; the
1145        // 200 BODY is then the route envelope — WebDev ignores
1146        // `status`, so denials ride HTTP 200 and the body verdict is
1147        // the ONLY success oracle (never the status line alone).
1148        let (url, response) = self
1149            .webdev_post_raw(project, route, body, extra_headers, None)
1150            .await?;
1151        let response = classify::classify(response, &url, false).await?;
1152        let text = response.text().await.unwrap_or_default();
1153        match webdev::parse_route_body(&text)? {
1154            RouteBody::Ok(data) => Ok(data),
1155            RouteBody::Denied {
1156                code,
1157                message,
1158                traceback,
1159            } => Err(webdev::denial_to_error(
1160                &code,
1161                &message,
1162                traceback.as_deref(),
1163                url,
1164            )),
1165        }
1166    }
1167
1168    async fn webdev_route_call_with_timeout(
1169        &self,
1170        project: &str,
1171        route: &str,
1172        body: &serde_json::Value,
1173        extra_headers: &[(&str, &str)],
1174        timeout: Duration,
1175    ) -> Result<serde_json::Value, CoreError> {
1176        // The SAME classify + envelope-parse tail as
1177        // `webdev_route_call` — the ONLY delta is the per-request
1178        // ceiling (behavior-identical request semantics).
1179        let (url, response) = self
1180            .webdev_post_raw(project, route, body, extra_headers, Some(timeout))
1181            .await?;
1182        let response = classify::classify(response, &url, false).await?;
1183        let text = response.text().await.unwrap_or_default();
1184        match webdev::parse_route_body(&text)? {
1185            RouteBody::Ok(data) => Ok(data),
1186            RouteBody::Denied {
1187                code,
1188                message,
1189                traceback,
1190            } => Err(webdev::denial_to_error(
1191                &code,
1192                &message,
1193                traceback.as_deref(),
1194                url,
1195            )),
1196        }
1197    }
1198
1199    async fn webdev_route_probe(
1200        &self,
1201        project: &str,
1202        route: &str,
1203        extra_headers: &[(&str, &str)],
1204    ) -> Result<RouteProbe, CoreError> {
1205        // NOT classified — the status code IS the answer (the
1206        // webdev_route_status precedent): 405/402/401 discriminate
1207        // presence/licensing/gating, and a 200 body carries the
1208        // version handshake or the structured denial.
1209        let (url, response) = self
1210            .webdev_post_raw(
1211                project,
1212                route,
1213                &serde_json::json!({"action": "version"}),
1214                extra_headers,
1215                None,
1216            )
1217            .await?;
1218        let status = response.status();
1219        if status.is_success() {
1220            let text = response.text().await.unwrap_or_default();
1221            return match webdev::parse_route_body(&text)? {
1222                RouteBody::Ok(data) => {
1223                    let route_version = data
1224                        .get("routeVersion")
1225                        .and_then(serde_json::Value::as_str)
1226                        .map(str::to_string)
1227                        .ok_or_else(|| {
1228                            CoreError::Internal(format!(
1229                                "webdev route version action from {url} answered no routeVersion"
1230                            ))
1231                        })?;
1232                    Ok(RouteProbe::Present { route_version })
1233                }
1234                RouteBody::Denied {
1235                    code,
1236                    message,
1237                    traceback,
1238                } => Ok(RouteProbe::Denied {
1239                    code,
1240                    message,
1241                    traceback,
1242                }),
1243            };
1244        }
1245        match status.as_u16() {
1246            401 | 403 => Ok(RouteProbe::AuthGated),
1247            402 => Ok(RouteProbe::Unlicensed),
1248            405 => Ok(RouteProbe::Absent),
1249            // Shapes the enum has no variant for (wizard redirects,
1250            // mid-restart 503s, foreign 404s) — reuse classify's
1251            // status mappings verbatim; every non-success response
1252            // classifies to Err, and the Ok arm is unreachable by
1253            // construction (all 2xx took the body branch above).
1254            _ => match classify::classify(response, &url, false).await {
1255                Err(err) => Err(err),
1256                Ok(_) => Err(CoreError::Internal(format!(
1257                    "unexpected HTTP {status} from webdev route probe at {url}"
1258                ))),
1259            },
1260        }
1261    }
1262
1263    async fn projects(
1264        &self,
1265        query: &query::ListQuery,
1266    ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1267        // Standard list params (limit=-1 = the UI's "everything").
1268        self.get_json(
1269            projects::PROJECTS_LIST_PATH,
1270            Some(&query.to_query_pairs()),
1271            true,
1272        )
1273        .await
1274    }
1275
1276    async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1277        // The {name} segment is percent-encoded (Pitfall 6) — the
1278        // spaced-name recorded-request proof in tests/projects_contract.rs.
1279        self.get_json(&projects::project_find_path(name), None, true)
1280            .await
1281    }
1282
1283    async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1284        // Ok classification IS the success contract; callers that want
1285        // data re-`find` (the actions layer's read-back).
1286        self.post_json(projects::PROJECTS_CREATE_PATH, body)
1287            .await
1288            .map(|_| ())
1289    }
1290
1291    async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError> {
1292        let body = ProjectCopy {
1293            from_name: from.to_string(),
1294            to_name: to.to_string(),
1295        };
1296        self.post_json(projects::PROJECTS_COPY_PATH, &body)
1297            .await
1298            .map(|_| ())
1299    }
1300
1301    async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError> {
1302        let body = ProjectRenameBody {
1303            name: new_name.to_string(),
1304        };
1305        self.post_json(&projects::project_rename_path(name), &body)
1306            .await
1307            .map(|_| ())
1308    }
1309
1310    async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1311        self.put_json(&projects::project_modify_path(name), body)
1312            .await
1313    }
1314
1315    async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1316        // BOTH guard layers (Pitfall 8): the CLI already refused
1317        // without --yes (exit 2, pre-resolution) AND the wire request
1318        // always carries the server's own `confirm=true` query param
1319        // (wiremock recorded-request proof).
1320        self.delete_with_query(
1321            &projects::project_delete_path(name),
1322            &[("confirm", "true".to_string())],
1323        )
1324        .await
1325    }
1326
1327    async fn project_export_to_file(
1328        &self,
1329        name: &str,
1330        out: &Path,
1331    ) -> Result<ExportMeta, CoreError> {
1332        // The 120 s per-request override rides the RequestBuilder (the
1333        // logs-download precedent); the streaming itself lives in
1334        // download_to_file (classify FIRST, then chunk loop). No
1335        // `Accept` header — the export contract never named one.
1336        self.download_to_file(
1337            &projects::project_export_path(name),
1338            out,
1339            projects::PROJECT_EXPORT_TIMEOUT,
1340            None,
1341        )
1342        .await
1343    }
1344
1345    async fn project_import(
1346        &self,
1347        name: &str,
1348        zip: Vec<u8>,
1349        overwrite: bool,
1350    ) -> Result<ImportOutcome, CoreError> {
1351        // `overwrite` rides the QUERY string; the ZIP is the RAW body
1352        // with Content-Type application/zip and a known Content-Length
1353        // (Vec<u8> — chunked encoding never enters the picture). The
1354        // 300 s per-request override owns Pitfall 3. Token-auth POSTs
1355        // need no CSRF (02-RESEARCH §Auth Model).
1356        let url = self.url_for(&projects::project_import_path(name));
1357        let request = self
1358            .client
1359            .post(url.clone())
1360            .timeout(projects::PROJECT_IMPORT_TIMEOUT)
1361            .query(&[("overwrite", if overwrite { "true" } else { "false" })])
1362            .header(reqwest::header::CONTENT_TYPE, "application/zip")
1363            .body(zip);
1364        let request = self.apply_auth(request);
1365        let response = self.send_and_classify(request, &url).await?;
1366        // Opaque-success: parse the body when it is a JSON OBJECT,
1367        // else the fallback object (the body is unverified MEDIUM —
1368        // restart's `literal true` is the same family and normalizes
1369        // the same way, so agents always see a stable object shape).
1370        let body = response.text().await.unwrap_or_default();
1371        let parsed = serde_json::from_str::<serde_json::Value>(body.trim())
1372            .ok()
1373            .filter(|value| value.is_object())
1374            .unwrap_or_else(|| serde_json::json!({"status": "success"}));
1375        // Denial honesty (05-07, UAT Gap 1): the gateway refuses
1376        // imports over HTTP 200 with {success:false, problem} —
1377        // live-witnessed while NOTHING landed. ONE seam here fixes
1378        // every import caller at once (resource put/delete, project
1379        // import, webdev deploy) — per-caller checks are forbidden;
1380        // this IS the contract (the WebDev 200-denial precedent
1381        // applied to the import family).
1382        if let Some(problem) = projects::import_denied(&parsed) {
1383            return Err(CoreError::ImportDenied {
1384                project: name.to_string(),
1385                problem,
1386                endpoint: Some(url.to_string()),
1387            });
1388        }
1389        Ok(ImportOutcome { response: parsed })
1390    }
1391
1392    async fn tag_provider_list(
1393        &self,
1394        query: &query::ListQuery,
1395    ) -> Result<ListEnvelope<TagProviderRecord>, CoreError> {
1396        // Standard list params (limit=-1 = the UI's "everything") —
1397        // the connections-family resource lists' exact shape.
1398        self.get_json(
1399            tags::TAG_PROVIDERS_LIST_PATH,
1400            Some(&query.to_query_pairs()),
1401            true,
1402        )
1403        .await
1404    }
1405
1406    async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError> {
1407        self.get_json(&tags::tag_provider_find_path(name), None, true)
1408            .await
1409    }
1410
1411    async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError> {
1412        // The ARRAY body is the wire contract (a bare object 400s);
1413        // serde serializes elements in declaration order so the
1414        // recorded body is deterministic. Ok classification IS the
1415        // success contract (the project-create precedent).
1416        self.post_json(tags::TAG_PROVIDERS_CREATE_PATH, body)
1417            .await
1418            .map(|_| ())
1419    }
1420
1421    async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError> {
1422        // The signature rides the PATH (from find) — the
1423        // live-proven delete-by-signature chain; both segments
1424        // percent-encoded through the ONE locked encoder.
1425        self.delete_with_query(&tags::tag_provider_delete_path(name, signature), &[])
1426            .await
1427    }
1428
1429    async fn trial_status_wire(&self) -> Result<TrialWire, CoreError> {
1430        // Conditional auth: the endpoints answer unauthenticated
1431        // (live-verified both rigs), so a header-less client degrades
1432        // cleanly — but a carried credential rides along harmlessly
1433        // (future-proofing if a gateway version starts gating them).
1434        let auth = self.credential.is_some();
1435        self.get_json(trial::TRIAL_PATH, None, auth).await
1436    }
1437
1438    async fn banners(&self) -> Result<BannerSet, CoreError> {
1439        let auth = self.credential.is_some();
1440        self.get_json(trial::BANNERS_PATH, None, auth).await
1441    }
1442
1443    async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError> {
1444        // Empty body, authed POST (the UI mutation's exact shape —
1445        // decompiled ia-gateway.js: {method:"POST",
1446        // url:"/data/api/v1/trial"}). Token-auth POSTs need no CSRF
1447        // (02-RESEARCH §Auth Model); on 403 the tier-1 session+CSRF
1448        // flow takes over (actions layer owns the ladder).
1449        let response = self.post_empty(trial::TRIAL_PATH, &[], true).await?;
1450        let body = response.text().await.unwrap_or_default();
1451        serde_json::from_str(&body).map_err(|err| {
1452            CoreError::Internal(format!(
1453                "trial reset response did not match the trial shape: {err}"
1454            ))
1455        })
1456    }
1457
1458    async fn backup_download(
1459        &self,
1460        out: &Path,
1461        backup_type: backup::BackupType,
1462    ) -> Result<ExportMeta, CoreError> {
1463        // Pure reuse: the type query rides the path builder, the
1464        // Accept header rides the helper's optional param, and the
1465        // 300 s class rides the RequestBuilder — the 03-02 chunk loop
1466        // stays THE one streaming body-consumption site (04-04).
1467        self.download_to_file(
1468            &backup::backup_download_path(backup_type),
1469            out,
1470            backup::BACKUP_TIMEOUT,
1471            Some(backup::BACKUP_ACCEPT),
1472        )
1473        .await
1474    }
1475
1476    async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError> {
1477        // The upload direction buffers BY DESIGN (the import
1478        // precedent: a known Content-Length raw body sidesteps the
1479        // chunked-encoding question entirely). Token-auth POSTs need
1480        // no CSRF (02-RESEARCH §Auth Model). Ok classification IS the
1481        // acceptance contract — the actions layer owns the
1482        // post-restore RUNNING wait (Pitfall 6: the gateway restarts
1483        // after answering).
1484        let body = tokio::fs::read(gwbk)
1485            .await
1486            .map_err(|err| CoreError::InvalidInput {
1487                reason: format!("cannot read {}: {err}", gwbk.display()),
1488            })?;
1489        let url = self.url_for(backup::BACKUP_PATH);
1490        let request = self
1491            .client
1492            .post(url.clone())
1493            .timeout(backup::BACKUP_TIMEOUT)
1494            .query(&backup::restore_query())
1495            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1496            .body(body);
1497        let request = self.apply_auth(request);
1498        self.send_and_classify(request, &url).await.map(|_| ())
1499    }
1500
1501    async fn eam_task_history(
1502        &self,
1503        limit: Option<u32>,
1504        search: Option<&str>,
1505    ) -> Result<ListEnvelope<EamHistoryItem>, CoreError> {
1506        let query = query::ListQuery {
1507            limit: limit
1508                .map(i64::from)
1509                .unwrap_or(eam::EAM_HISTORY_DEFAULT_LIMIT),
1510            search: search.map(str::to_string),
1511            ..query::ListQuery::default()
1512        };
1513        self.get_json(eam::EAM_HISTORY_PATH, Some(&query.to_query_pairs()), true)
1514            .await
1515    }
1516
1517    async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError> {
1518        // Standard list params (limit=-1 = the UI's "everything") —
1519        // definition counts are small; the connections-family
1520        // resource lists' exact shape.
1521        self.get_json(
1522            &eam::eam_tasks_list_path(),
1523            Some(&query::ListQuery::default().to_query_pairs()),
1524            true,
1525        )
1526        .await
1527    }
1528
1529    async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError> {
1530        self.get_json(&eam::eam_task_find_path(name), None, true)
1531            .await
1532    }
1533
1534    async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError> {
1535        // The ARRAY body is the wire contract (a bare object 400s —
1536        // the tag-provider create precedent); the caller's composed
1537        // definition rides as the single element. Ok classification
1538        // IS the success contract.
1539        self.post_json(&eam::eam_tasks_create_path(), &[definition])
1540            .await
1541            .map(|_| ())
1542    }
1543
1544    async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError> {
1545        // Empty body, authed POST — 204 is the live-proven success
1546        // shape; classify()'s 2xx pass-through IS the oracle (any
1547        // 2xx = dispatched; outcomes land in history as data).
1548        self.post_empty(&eam::eam_force_path(owner, name), &[], true)
1549            .await
1550            .map(|_| ())
1551    }
1552
1553    async fn eam_task_suspend(&self, name: &str) -> Result<(), CoreError> {
1554        // Empty body, authed POST — 204 is the captured success shape
1555        // (10-LIVE-CAPTURES §1b/§1c); the suspend/resume failure
1556        // modes are 500 HTML (classify → Internal with the page's
1557        // message — the module-doc finding; no 404 exists here).
1558        self.post_empty(&eam::eam_task_suspend_path(name), &[], true)
1559            .await
1560            .map(|_| ())
1561    }
1562
1563    async fn eam_task_resume(&self, name: &str) -> Result<(), CoreError> {
1564        // Same shape as suspend: 204 success (§1b/§1d — resume of a
1565        // never-suspended task is ALSO a 204; idempotent wire).
1566        self.post_empty(&eam::eam_task_resume_path(name), &[], true)
1567            .await
1568            .map(|_| ())
1569    }
1570
1571    async fn eam_task_cancel(&self, name: &str) -> Result<(), CoreError> {
1572        // 204 whether a pending execution existed, the task has
1573        // nothing pending, or the name is unknown (§7) — cancel is
1574        // wire-idempotent; name validation is the actions layer's
1575        // find-before-write job.
1576        self.post_empty(&eam::eam_task_cancel_path(name), &[], true)
1577            .await
1578            .map(|_| ())
1579    }
1580
1581    async fn eam_tasks_scheduled(&self, running: bool) -> Result<Vec<EamScheduledTask>, CoreError> {
1582        // The standard {items, metadata} envelope (§2 — byte-identical
1583        // shape across both rigs, incl. the empty-list quiet body);
1584        // this method unwraps the envelope to the items.
1585        let envelope: query::ListEnvelope<EamScheduledTask> = self
1586            .get_json(&eam::eam_tasks_scheduled_path(running), None, true)
1587            .await?;
1588        Ok(envelope.items)
1589    }
1590
1591    async fn eam_task_modify(
1592        &self,
1593        definition: &serde_json::Value,
1594    ) -> Result<Option<ModifyOutcome>, CoreError> {
1595        // The ARRAY body is the wire contract (single element — the
1596        // §6a capture; a bare object is not the shape the resource
1597        // PUT family speaks). The classify-first rule holds, then the
1598        // captured 200 body parses into ModifyOutcome (None = a 2xx
1599        // that carried no body — the lenient `Option` per the plan).
1600        let url = self.url_for(&eam::eam_tasks_modify_path());
1601        let request = self.apply_auth(self.client.put(url.clone()).json(&[definition]));
1602        let response = self.send_and_classify(request, &url).await?;
1603        let text = response.text().await.unwrap_or_default();
1604        if text.trim().is_empty() {
1605            return Ok(None);
1606        }
1607        serde_json::from_str(&text).map(Some).map_err(|err| {
1608            CoreError::Internal(format!(
1609                "response from {url} did not match the expected shape: {err}"
1610            ))
1611        })
1612    }
1613
1614    async fn eam_task_delete(
1615        &self,
1616        name: &str,
1617        signature: &str,
1618        confirm: bool,
1619    ) -> Result<DeleteOutcome, CoreError> {
1620        // `collection=core` ALWAYS (the captured success value — the
1621        // type token 404s, §3c); `confirm=true` rides ONLY on
1622        // explicit opt-in (a lone-resource delete succeeds without
1623        // it, §3b; the confirm-demand shape is unobserved, §3d —
1624        // never hard-coded). The 200 body is the captured
1625        // DeleteOutcome; classify-first as everywhere.
1626        let url = self.url_for(&eam::eam_task_delete_path(name, signature));
1627        let mut pairs: Vec<(&str, String)> = vec![("collection", "core".to_string())];
1628        if confirm {
1629            pairs.push(("confirm", "true".to_string()));
1630        }
1631        let request = self.apply_auth(self.client.delete(url.clone()).query(&pairs));
1632        let response = self.send_and_classify(request, &url).await?;
1633        let text = response.text().await.unwrap_or_default();
1634        serde_json::from_str(&text).map_err(|err| {
1635            CoreError::Internal(format!(
1636                "response from {url} did not match the expected shape: {err}"
1637            ))
1638        })
1639    }
1640
1641    async fn api_call(
1642        &self,
1643        call: &apicall::ApiCallRequest,
1644    ) -> Result<apicall::ApiCallData, CoreError> {
1645        let url = self.url_for(&call.path);
1646        // Any RFC verb (lowercase input normalized); an unparseable
1647        // method is a usage-class refusal BEFORE the wire — reqwest's
1648        // own `Method` parse is the validator (no hand-rolled list).
1649        let method =
1650            reqwest::Method::from_bytes(call.method.to_uppercase().as_bytes()).map_err(|_| {
1651                CoreError::InvalidInput {
1652                    reason: format!(
1653                        "{:?} is not a valid HTTP method — use an RFC verb \
1654                     (GET/POST/PUT/DELETE/PATCH/HEAD, …)",
1655                        call.method
1656                    ),
1657                }
1658            })?;
1659        let mut request = self.client.request(method, url.clone());
1660        for (name, value) in &call.headers {
1661            // reqwest's `.header()` PANICS on an invalid name/value —
1662            // these strings are user-supplied, so they are validated
1663            // HERE: a bad header is an exit-2 refusal, never a crash
1664            // (the webdev extra_headers loop never carried raw user
1665            // input; this one does).
1666            let name =
1667                reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()).map_err(|_| {
1668                    CoreError::InvalidInput {
1669                        reason: format!("{name:?} is not a valid HTTP header name"),
1670                    }
1671                })?;
1672            let value = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
1673                CoreError::InvalidInput {
1674                    reason: format!("{value:?} is not a valid HTTP header value"),
1675                }
1676            })?;
1677            request = request.header(name, value);
1678        }
1679        // The ONE query mechanism: reqwest's own serializer (the same
1680        // `.query(&pairs)` shape every other capability uses) — never
1681        // a `?` smuggled through the path (validate_path refuses it).
1682        if !call.query.is_empty() {
1683            request = request.query(&call.query);
1684        }
1685        if let Some(body) = &call.body {
1686            // Raw TEXT on ANY method — GET/DELETE body passthrough is
1687            // allowed (curl parity; the gateway's answer classifies).
1688            request = request.body(body.clone());
1689        }
1690        // THE auth site: profile credentials only (the redaction
1691        // boundary); the pipeline tail is the api-call-scoped
1692        // classifier so unclassified gateway 4xx ride
1693        // GatewayClientError instead of Internal.
1694        let request = self.apply_auth(request);
1695        let response = self.send_and_classify_for_api(request, &url).await?;
1696        let status = response.status().as_u16();
1697        let text = response.text().await.map_err(|err| CoreError::Network {
1698            url: url.to_string(),
1699            source: Some(err),
1700            observation: None,
1701        })?;
1702        // THE verbatim decision (research OQ1): `from_string` both
1703        // preserves the gateway's bytes (key order, unknown fields)
1704        // AND validates JSON in one call — no parse-re-serialize. A
1705        // non-JSON 2xx body is the documented internal-class honesty
1706        // refusal (binary endpoints ride the download pipelines).
1707        let data = serde_json::value::RawValue::from_string(text).map_err(|err| {
1708            CoreError::Internal(format!(
1709                "the gateway answered 2xx with a non-JSON body — api call returns \
1710                 JSON; use logs/backup downloads for binary endpoints ({err})"
1711            ))
1712        })?;
1713        Ok(apicall::ApiCallData { status, data })
1714    }
1715
1716    async fn license_status(&self) -> Result<LicenseStatusWire, CoreError> {
1717        // auth = true — a /data route under 8.3 default security (the
1718        // gateway-info rule); the capture session rode a token header.
1719        self.get_json(license::LICENSES_PATH, None, true).await
1720    }
1721
1722    async fn redundancy_status(&self) -> Result<RedundancyStatusWire, CoreError> {
1723        self.get_json(redundancy::REDUNDANCY_PATH, None, true).await
1724    }
1725
1726    async fn gan_status(&self) -> Result<GanStatusWire, CoreError> {
1727        self.get_json(gan::GAN_OVERVIEW_PATH, None, true).await
1728    }
1729
1730    async fn bundle_generate(&self) -> Result<BundleStatusWire, CoreError> {
1731        // Empty body, authed POST (the restart/set-logger precedent;
1732        // token mutations need no CSRF). The 200 body IS the status
1733        // wire per capture ({"state":"Generating"}) — classify-first
1734        // through the shared pipeline, then parse.
1735        let url = self.url_for(diagnostics::DIAGNOSTICS_GENERATE_PATH);
1736        let response = self
1737            .send_and_classify(self.apply_auth(self.client.post(url.clone())), &url)
1738            .await?;
1739        response.json::<BundleStatusWire>().await.map_err(|err| {
1740            CoreError::Internal(format!(
1741                "response from {url} did not match the expected shape: {err}"
1742            ))
1743        })
1744    }
1745
1746    async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError> {
1747        // auth = true — a /data route under 8.3 default security; the
1748        // capture session rode a token header.
1749        self.get_json(diagnostics::DIAGNOSTICS_STATUS_PATH, None, true)
1750            .await
1751    }
1752
1753    async fn bundle_download(&self, out: &Path) -> Result<ExportMeta, CoreError> {
1754        // The 300 s per-request override rides the RequestBuilder
1755        // inside download_to_file (Pitfall 8: the 30 s client default
1756        // would truncate MB-sized bundles); the chunk loop stays THE
1757        // one streaming body-consumption site. No Accept header — the
1758        // capture named none (Content-Type: application/zip is the
1759        // answer). Disposition filename + content type ride ExportMeta.
1760        self.download_to_file(
1761            diagnostics::DIAGNOSTICS_DOWNLOAD_PATH,
1762            out,
1763            diagnostics::BUNDLE_DOWNLOAD_TIMEOUT,
1764            None,
1765        )
1766        .await
1767    }
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772    use super::ReqwestGatewayApi;
1773
1774    /// Exercises `post_empty` end-to-end with the shape 02-04's
1775    /// set-logger-level route uses (query param + empty body): the
1776    /// verified restart shape is a 200 with literal body `true`,
1777    /// classified Ok — and the query param rides the request.
1778    #[tokio::test]
1779    async fn post_empty_sends_query_param_and_empty_body() {
1780        let server = wiremock::MockServer::start().await;
1781        let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
1782            .and(wiremock::matchers::path(
1783                "/data/api/v1/restart-tasks/restart",
1784            ))
1785            .and(wiremock::matchers::query_param("confirm", "true"))
1786            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("true"))
1787            .expect(1)
1788            .mount_as_scoped(&server)
1789            .await;
1790
1791        let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1792        let response = api
1793            .post_empty(
1794                "/data/api/v1/restart-tasks/restart",
1795                &[("confirm", "true".to_string())],
1796                true,
1797            )
1798            .await
1799            .expect("200 classifies Ok");
1800        assert_eq!(response.status(), reqwest::StatusCode::OK);
1801
1802        let requests = guard.received_requests().await;
1803        assert_eq!(requests.len(), 1);
1804        assert!(
1805            requests[0].body.is_empty(),
1806            "the POST carries NO body — params ride the query string"
1807        );
1808    }
1809}