Skip to main content

microvms_core/control/
ops.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The wire shapes, transcribed member-for-member from the pinned service model.
3//!
4//! # The honest-fake rule
5//!
6//! Every field name here is copied from `service-2.json` for API version
7//! [`crate::constants::MODEL_API_VERSION`], and every deserializer test in this file
8//! parses **literal JSON in the model's spelling** rather than the output of this
9//! module's own serializer. That is not a style preference; it is the fix for a specific
10//! bug that shipped.
11//!
12//! `MicrovmImageBuildSummary` carries a member called `buildState`. It has no member
13//! called `state`. The Python client read `b.get("state")`, which returned `None` from
14//! every real response — so the stall probe that is the only thing separating a wedged
15//! image from a slow build was **dead against live AWS while passing its unit test**,
16//! because the test's fake returned `{"state": "PENDING"}`. The fake shared the client's
17//! own misreading, so the two agreed with each other and with nothing else. Found
18//! 2026-08-07 by diffing against the service model, after a ~15-hour wedge that this
19//! guard existed to catch and did not.
20//!
21//! A round-trip test through this module's `Serialize` would have exactly the same
22//! blind spot. So the tests below hold JSON.
23//!
24//! # What is `Option` and what is not
25//!
26//! The model's `required` list, not a guess about what the service usually sends. A
27//! member the model marks required is a bare field; everything else is `Option`. Two
28//! consequences worth stating:
29//!
30//! * `stateReason` is optional everywhere it appears, which is why TRAP-8's error says
31//!   "no stateReason" rather than printing an empty string — the absence is information.
32//! * `RunMicrovmRequest` requires only `imageIdentifier`. `executionRoleArn` is
33//!   optional *on the wire* even though every real launch needs one.
34//!
35//! # Unknown members are dropped, deliberately
36//!
37//! No `#[serde(deny_unknown_fields)]` and no catch-all map. A member AWS adds to a
38//! response must not fail a deserialization — that would turn an additive service change
39//! into a broken client — and this crate has no consumer for a field it does not know
40//! about. The drift gate (TRAP-12) is where a new member gets noticed.
41
42use serde::{Deserialize, Serialize};
43
44// ── requests ────────────────────────────────────────────────────────────────
45
46/// `CreateMicrovmImageRequest`.
47///
48/// `clientToken` is `String` rather than `Option<String>` because this client always
49/// sends one and it is always minted here (TRAP-1) — see [`crate::control::token`].
50#[derive(Clone, Debug, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct CreateMicrovmImageWire {
53    pub name: String,
54    pub base_image_arn: String,
55    /// `Version`, absent unless the caller pinned one.
56    ///
57    /// Omitted rather than sent empty when nobody pinned, because that is the difference
58    /// between "whatever the service defaults to" and a `ValidationException` on a blank
59    /// `Version` (min 1, pattern `[^\s]+`). The default has already moved once — the managed
60    /// base carries versions `"0"` and `"1"` (docs/PLATFORM.md, 'The managed base image has
61    /// two versions') — so an unpinned build is not reproducible, which is what this member
62    /// exists to fix.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub base_image_version: Option<String>,
65    pub build_role_arn: String,
66    pub code_artifact: CodeArtifact,
67    pub cpu_configurations: Vec<CpuConfiguration>,
68    pub resources: Vec<Resources>,
69    pub hooks: Hooks,
70    /// `CapabilityList`. Omitted entirely rather than sent empty when identity repair
71    /// was not asked for: the enum's only value is `ALL`, and an empty list is a
72    /// different request from an absent member.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub additional_os_capabilities: Option<Vec<String>>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub tags: Option<std::collections::BTreeMap<String, String>>,
77    /// The minted idempotency token.
78    ///
79    /// `pub(crate)` while every sibling field is `pub`, and that asymmetry is TRAP-1's last
80    /// mile. `CreateImageRequest` has no token field, but this type is the wire shape behind
81    /// it — and a `pub` field here would let a caller outside this module build the request
82    /// body directly with a token of their choosing, which is the wedge with one extra step.
83    /// Narrowing the visibility means the only way to populate it is
84    /// [`crate::control::token::create_token`], from inside the crate.
85    pub(crate) client_token: String,
86}
87
88/// `CodeArtifact`, a union whose only member this client uses is `uri`.
89#[derive(Clone, Debug, Deserialize, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct CodeArtifact {
92    pub uri: String,
93}
94
95/// `CpuConfiguration`. One required member, whose enum has one value (`ARM_64`).
96#[derive(Clone, Debug, Deserialize, Serialize)]
97#[serde(rename_all = "camelCase")]
98pub struct CpuConfiguration {
99    pub architecture: String,
100}
101
102/// `Resources` — note the plural. The model's `ResourcesList` member shape is
103/// `Resources`, not `Resource`; there is no shape called `Resource` at all.
104///
105/// `minimumMemoryInMiB` selects a size class, it does not size the VM (TRAP-10, and
106/// `crate::sizing`). The member name's `MiB` capitalisation is the model's.
107#[derive(Clone, Debug, Deserialize, Serialize)]
108pub struct Resources {
109    #[serde(rename = "minimumMemoryInMiB")]
110    pub minimum_memory_in_mib: u32,
111}
112
113/// `Hooks`.
114#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
115#[serde(rename_all = "camelCase")]
116pub struct Hooks {
117    /// `HooksPortInteger`, `min: 1`. Checked by [`crate::control::require_valid_port`] before the
118    /// block is built — a `hooks.port` of 0 is an image with no reachable hook endpoint.
119    pub port: u16,
120    pub microvm_hooks: MicrovmHooks,
121    pub microvm_image_hooks: MicrovmImageHooks,
122}
123
124/// `HookState`, the model's two values.
125///
126/// # A typed enum rather than a validated `String`, and why this one and not the response states
127///
128/// This is the same trade [`VersionStatus`] makes and the reasoning is the same shape. A
129/// response enum must stay a `String` — a third value AWS adds has to parse rather than fail,
130/// which is what [`MicrovmImageBuildSummaryWire::architecture`] documents. `HookState` on the
131/// request side is only ever **serialized**, so narrowing it costs nothing and buys the thing a
132/// two-value enum buys: `"ENABLE"`, `"Enabled"`, and `"ACTIVE"` become compile errors instead of
133/// a `ValidationException`.
134///
135/// It is worth more here than on any other member, because it appears **six times** on one
136/// request. Issue #24 measured what was there before: six `String` fields with `"ENABLED"`
137/// hardcoded as a `const &str` local to `hooks_block`, so no constant named either value and a
138/// typo in one of the six was a rejected `CreateMicrovmImage` — after the artifact upload.
139///
140/// # `Deserialize` too, unlike [`VersionStatus`]
141///
142/// Because a `MicrovmHooks` block is a **request** shape in this module and the tests parse
143/// model-spelled JSON into it (the honest-fake rule). Deriving `Deserialize` on a two-variant
144/// enum means an unknown state fails that parse, which is correct for a shape nothing reads off
145/// a real response — and `MicrovmImageVersionSummaryWire` deliberately does not read a hooks
146/// block at all, for measured reasons that type records.
147#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
148pub enum HookState {
149    /// The platform does not call this hook.
150    #[serde(rename = "DISABLED")]
151    Disabled,
152    /// The platform calls this hook and waits for it. What this client always sends.
153    #[serde(rename = "ENABLED")]
154    Enabled,
155}
156
157impl HookState {
158    /// The wire spelling, which is also what a diagnostic prints.
159    pub fn as_str(self) -> &'static str {
160        match self {
161            HookState::Disabled => "DISABLED",
162            HookState::Enabled => "ENABLED",
163        }
164    }
165}
166
167impl std::fmt::Display for HookState {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.write_str(self.as_str())
170    }
171}
172
173/// `MicrovmHooks` — the family that caps at 60 seconds.
174///
175/// The state members are [`HookState`], so neither value can be misspelled at a call site; the
176/// timeouts are built from [`crate::hooks::RunHookTimeout`] before they get here, which is where
177/// the ceiling is enforced.
178#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
179#[serde(rename_all = "camelCase")]
180pub struct MicrovmHooks {
181    pub run: HookState,
182    pub run_timeout_in_seconds: u32,
183    pub resume: HookState,
184    pub resume_timeout_in_seconds: u32,
185    pub suspend: HookState,
186    pub suspend_timeout_in_seconds: u32,
187    pub terminate: HookState,
188    pub terminate_timeout_in_seconds: u32,
189}
190
191/// `MicrovmImageHooks` — the build family, capping at 3600 seconds.
192#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
193#[serde(rename_all = "camelCase")]
194pub struct MicrovmImageHooks {
195    pub ready: HookState,
196    pub ready_timeout_in_seconds: u32,
197    pub validate: HookState,
198    pub validate_timeout_in_seconds: u32,
199}
200
201/// `RunMicrovmRequest`.
202///
203/// `runHookPayload` is the only per-VM secret channel the platform offers and is checked
204/// against the 4096-byte ceiling before this struct is built (TRAP-5).
205#[derive(Clone, Debug, Serialize)]
206#[serde(rename_all = "camelCase")]
207pub struct RunMicrovmWire {
208    pub image_identifier: String,
209    /// `Version`, absent unless the caller pinned one.
210    ///
211    /// Absent means "whatever the image's `latestActiveImageVersion` is", which is what this
212    /// client always sent. Pinning it is the launch half of the blue/green story: a canary
213    /// launches against exactly the version it means to test, and a rollback re-pins to the
214    /// previous one rather than hoping the image's notion of latest has moved back.
215    ///
216    /// A version set INACTIVE by `UpdateMicrovmImageVersion` refuses to launch when pinned
217    /// here — measured, see [`crate::control::ControlPlane::set_image_version_status`].
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub image_version: Option<String>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub execution_role_arn: Option<String>,
222    /// Derived ARNs, never caller strings (TRAP-4).
223    pub ingress_network_connectors: Vec<String>,
224    /// Absent rather than empty when egress was not asked for: omitting it is how you
225    /// get a VM with no outbound network.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub egress_network_connectors: Option<Vec<String>>,
228    pub idle_policy: IdlePolicy,
229    pub maximum_duration_in_seconds: u32,
230    pub run_hook_payload: String,
231    /// The minted idempotency token. `pub(crate)` for the reason
232    /// [`CreateMicrovmImageWire::client_token`] gives.
233    pub(crate) client_token: String,
234}
235
236/// `IdlePolicy`. All three members are required by the model, in **both** directions.
237///
238/// # `suspendedDurationSeconds` comes back, measured
239///
240/// This comment used to say the member "exists only in the request". That was wrong twice
241/// over. The model has one `IdlePolicy` shape, used by `RunMicrovmRequest`,
242/// `RunMicrovmResponse`, and `GetMicrovmResponse` alike, and it marks all three members
243/// required — so the model says the opposite. And the service agrees with the model:
244/// `GetMicrovm` on a RUNNING VM answers
245/// `"idlePolicy": {"maxIdleDurationSeconds": 1800, "suspendedDurationSeconds": 600,
246/// "autoResumeEnabled": false}` (measured 2026-08-15, us-east-1, one read-only
247/// `GetMicrovm`).
248///
249/// The claim mattered because STATE-12 was resting on it: if only the client could name the
250/// suspended window, the client's own record is the only authority on it. The readback
251/// means there is a second one, and [`crate::control::Microvm::idle_policy`] carries it —
252/// so a suspended window that disagrees with what was asked for is now observable rather
253/// than assumed.
254#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
255#[serde(rename_all = "camelCase")]
256pub struct IdlePolicy {
257    /// `min: 60`, checked by [`crate::control::require_idle_duration`] before the launch.
258    ///
259    /// The comment here used to say botocore enforced this one locally, so no guard was needed.
260    /// Botocore does; this client does not use it. See that function for the whole account.
261    pub max_idle_duration_seconds: u32,
262    pub suspended_duration_seconds: u32,
263    pub auto_resume_enabled: bool,
264}
265
266/// `UpdateMicrovmImageVersionRequest`'s **one** body member.
267///
268/// `imageIdentifier` and `imageVersion` are both URI parameters, so the body is a single
269/// key. That is the whole request, and it is the model's only non-destructive retire
270/// primitive: setting a version `INACTIVE` makes `RunMicrovm` refuse to launch it while
271/// existing VMs keep running and the version stays readable through
272/// `GetMicrovmImageVersion`. `DeleteMicrovmImageVersion` is the alternative and it is
273/// irreversible.
274///
275/// `status` is a [`VersionStatus`] rather than a `String`, unlike the `HookState` members
276/// above. The difference is that this enum is the *entire* request: a typo in it is a
277/// `ValidationException` on the only field there is, spent on a call that was going to
278/// change a version's availability. `MicrovmImageVersionStatus` has exactly two values and
279/// naming them in the type means a caller cannot spell either wrong.
280#[derive(Clone, Debug, Serialize)]
281#[serde(rename_all = "camelCase")]
282pub struct UpdateImageVersionWire {
283    pub status: VersionStatus,
284}
285
286/// `MicrovmImageVersionStatus`, the model's two values.
287///
288/// # Why this one is an enum where `HookState` is a bare `String`
289///
290/// A response is not a request (see [`MicrovmImageBuildSummaryWire::architecture`]): a value
291/// AWS adds to a *response* enum must parse rather than fail, which is why the state fields
292/// on every wire struct in this module are `String`. This type is only ever **serialized** —
293/// the readback carries `status` as a `String` for exactly that reason — so narrowing it
294/// costs nothing and buys the one thing a two-value enum can buy: `"INACTIVATE"`,
295/// `"Inactive"`, and `"DISABLED"` are all compile errors rather than a rejected call.
296///
297/// The `Display`/`as_str` spelling is the model's own casing, asserted against a literal in
298/// the tests below rather than derived from the variant name.
299#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
300pub enum VersionStatus {
301    /// `RunMicrovm` may launch this version.
302    #[serde(rename = "ACTIVE")]
303    Active,
304    /// `RunMicrovm` refuses to launch this version. Running VMs are untouched.
305    #[serde(rename = "INACTIVE")]
306    Inactive,
307}
308
309impl VersionStatus {
310    /// The wire spelling, which is also what a diagnostic prints.
311    pub fn as_str(self) -> &'static str {
312        match self {
313            VersionStatus::Active => "ACTIVE",
314            VersionStatus::Inactive => "INACTIVE",
315        }
316    }
317}
318
319impl std::fmt::Display for VersionStatus {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        f.write_str(self.as_str())
322    }
323}
324
325/// `CreateMicrovmAuthTokenRequest`'s body members. `microvmIdentifier` is a URI
326/// parameter and so is not in the body.
327#[derive(Clone, Debug, Serialize)]
328#[serde(rename_all = "camelCase")]
329pub struct CreateAuthTokenWire {
330    /// Maximum 60 minutes, the service ceiling. TRAP-9 refreshes below it.
331    pub expiration_in_minutes: u32,
332    pub allowed_ports: Vec<PortSpecification>,
333}
334
335/// `PortSpecification`, the model's tagged union: one port, a range, or all of them.
336///
337/// A union rather than the single-field struct this was, and the change is a defect fix
338/// rather than completeness for its own sake. `Session::connect_headers(port)` and
339/// `connect_subprotocols(port)` exist to reach *another* port on the same VM, and they were
340/// built over a token the control plane had minted with `allowedPorts: [{port: 9000}]` — so
341/// they returned a correct-looking port header behind a credential that did not authorize
342/// it. Measured 2026-08-15 on one VM with a listener on 8080, varying only the mint:
343///
344/// | `allowedPorts` | `GET :8080` through the endpoint |
345/// | --- | --- |
346/// | `[{port: 9000}]` | **403 `Access to port denied`** |
347/// | `[{port: 9000}, {port: 8080}]` | 200, the guest answered |
348/// | `[{allPorts: {}}]` | 200 |
349/// | `[{range: {startPort: 8000, endPort: 9100}}]` | 200 |
350///
351/// One variable, four outcomes, so the token's scope is the whole of it. On the WebSocket
352/// path the same rejection is close code 1006 with no reason, which is why this was
353/// invisible to a client-side test: the strings were right and the credential behind them
354/// was not.
355///
356/// `#[serde(untagged)]` because the wire form is the *member name* as the key —
357/// `{"port": 9000}`, `{"range": {..}}`, `{"allPorts": {}}` — not a discriminator field, and
358/// serde's default enum representation would emit `{"Port": 9000}`. Each variant names its
359/// own key through `rename`.
360#[derive(Clone, Copy, Debug, Serialize)]
361#[serde(untagged)]
362pub enum PortSpecification {
363    /// Exactly one port. What a session built for the agent port asks for.
364    One {
365        #[serde(rename = "port")]
366        port: u16,
367    },
368    /// An inclusive range. Both members are required by the model.
369    Range {
370        #[serde(rename = "range")]
371        range: PortRange,
372    },
373    /// Every port on the VM.
374    ///
375    /// Deliberately **not** what this client mints by default. A token good for every port
376    /// is one whose leak is worth more, and the ports a caller actually needs are knowable:
377    /// the agent port plus whatever it asked for. Present because the model has it and a
378    /// caller building a general proxy needs it, reached only through an explicit scope.
379    All {
380        #[serde(rename = "allPorts")]
381        all_ports: AllPorts,
382    },
383}
384
385impl PortSpecification {
386    /// One port.
387    pub fn port(port: u16) -> Self {
388        Self::One { port }
389    }
390
391    /// An inclusive range of ports.
392    pub fn range(start: u16, end: u16) -> Self {
393        Self::Range {
394            range: PortRange {
395                start_port: start,
396                end_port: end,
397            },
398        }
399    }
400
401    /// Every port. See [`PortSpecification::All`] for why this is not a default.
402    pub fn all() -> Self {
403        Self::All {
404            all_ports: AllPorts {},
405        }
406    }
407}
408
409/// `PortRange`. Inclusive at both ends, per the model's `min: 1, max: 65535` on each.
410#[derive(Clone, Copy, Debug, Serialize)]
411#[serde(rename_all = "camelCase")]
412pub struct PortRange {
413    pub start_port: u16,
414    pub end_port: u16,
415}
416
417/// `AllPorts`, which the model declares with no members.
418///
419/// An empty struct rather than a unit one, because the wire form is `{}` and a unit struct
420/// serialises to `null` — which the service rejects as a malformed union member.
421#[derive(Clone, Copy, Debug, Serialize)]
422pub struct AllPorts {}
423
424// ── responses ───────────────────────────────────────────────────────────────
425
426/// `CreateMicrovmImageResponse`, narrowed to the members this client reads.
427///
428/// The model marks eight members required; the four below are the ones anything
429/// downstream uses. Deserializing a subset is safe — serde ignores the rest — and
430/// listing all twenty would be twenty more chances to misspell one.
431#[derive(Clone, Debug, Deserialize)]
432#[serde(rename_all = "camelCase")]
433pub struct CreateMicrovmImageResponseWire {
434    pub image_arn: String,
435    pub name: String,
436    pub state: String,
437    pub image_version: String,
438}
439
440/// `GetMicrovmImageOutput`.
441///
442/// `tags` is here rather than left to a `ListTags` this client does not implement: the
443/// service already sends the map on every `GetMicrovmImage`, so reading it costs nothing
444/// and a separate operation would cost a call. `createdAt` is one of the model's four
445/// required members and was the only one missing.
446#[derive(Clone, Debug, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct GetMicrovmImageResponseWire {
449    pub image_arn: String,
450    pub name: String,
451    pub state: String,
452    pub latest_active_image_version: Option<String>,
453    pub latest_failed_image_version: Option<String>,
454    /// Required by the model. Epoch seconds, possibly fractional.
455    pub created_at: f64,
456    /// Optional: the model marks only `createdAt` required, so an image never updated
457    /// carries no `updatedAt` and the absence is not a parse failure.
458    pub updated_at: Option<f64>,
459    /// `Tags`, absent rather than empty when the image carries none — measured 2026-08-15
460    /// as `"tags": {}` on a real `GetMicrovmImage`, but `Option` because the model does not
461    /// require the member and an absent map must not fail the parse.
462    pub tags: Option<std::collections::BTreeMap<String, String>>,
463}
464
465/// `ListMicrovmImageVersionsOutput`.
466#[derive(Clone, Debug, Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct ListImageVersionsResponseWire {
469    pub items: Vec<MicrovmImageVersionSummaryWire>,
470    pub next_token: Option<String>,
471}
472
473/// `MicrovmImageVersionSummary`, which is **also** `GetMicrovmImageVersionOutput` and
474/// `UpdateMicrovmImageVersionResponse` — the three shapes are member-for-member identical
475/// in the model, so one type serves all of them.
476///
477/// One type rather than three for the reason [`MicrovmResponseWire`] gives: a second copy
478/// is a second place to keep twenty member spellings right, and the model itself declares
479/// them as one set. Checked member-by-member against `service-2.json` on 2026-08-16 —
480/// `GetMicrovmImageVersionOutput`, `UpdateMicrovmImageVersionResponse`, and
481/// `MicrovmImageVersionSummary` list the same twenty members with the same eight required.
482///
483/// Note `state` here **is** the member's name — unlike the build summary below, and that
484/// asymmetry is the whole trap.
485///
486/// # All eight required members, not the four this crate reads
487///
488/// The same argument [`MicrovmImageBuildSummaryWire`] records: the model marking a member
489/// required is the strongest promise it makes about a response, and a required member absent
490/// from the struct is one no drift check can notice going missing. `status` in particular was
491/// `Option<String>` while the model requires it — so a service that stopped sending the
492/// availability status would have read as `None`, which is exactly how a version blocked from
493/// launching would look identical to one nobody had ever set. Confirmed present on all 22
494/// versions in the conformance account (2026-08-16), so requiring it is a measurement rather
495/// than a reading of the model alone.
496///
497/// # `hooks` is deliberately **not** here, and the reason is a measurement
498///
499/// The model marks every member of `Hooks`, `MicrovmHooks`, and `MicrovmImageHooks` optional,
500/// while this module's request-side [`Hooks`] declares them all as bare fields — correct for
501/// a request, since this client always sends all six. A real response is not so generous:
502/// `GetMicrovmImageVersion` on an image built by another tool answered
503/// `"microvmHooks": {"run": "ENABLED", "runTimeoutInSeconds": 30}` and nothing else
504/// (measured 2026-08-16, us-east-1, `omnigent-host-vpc` version 3.0), so five of
505/// `MicrovmHooks`'s eight members were absent. Reusing [`Hooks`] here would fail that parse
506/// and turn a readable version into a client error. A response-shaped hooks type is the fix
507/// if a caller ever needs it; until then the absence is the honest option, and the test
508/// `a_versions_hooks_block_is_not_read_because_a_real_one_omits_members` pins the reason.
509#[derive(Clone, Debug, Deserialize)]
510#[serde(rename_all = "camelCase")]
511pub struct MicrovmImageVersionSummaryWire {
512    // ── the model's eight required members ────────────────────────────────────
513    pub base_image_arn: String,
514    pub build_role_arn: String,
515    pub code_artifact: CodeArtifact,
516    pub image_arn: String,
517    pub image_version: String,
518    pub state: String,
519    /// `MicrovmImageVersionStatus`: `ACTIVE` or `INACTIVE`.
520    ///
521    /// A `String` rather than a [`VersionStatus`], because this is a response and a third
522    /// value AWS adds must parse rather than fail — the same rule
523    /// [`MicrovmImageBuildSummaryWire::architecture`] follows. [`VersionStatus`] is for the
524    /// request, where narrowing turns a typo into a compile error.
525    pub status: String,
526    /// Epoch seconds, possibly fractional.
527    pub created_at: f64,
528
529    // ── the optional ones, which are the config readback ─────────────────────
530    /// The base version this version was pinned to, when one was.
531    ///
532    /// The readback for `CreateMicrovmImage.baseImageVersion`, and the reason pinning is
533    /// worth doing: an unpinned build still reports one here, and the value the service
534    /// echoes is spelled differently from anything the base's own listing offers
535    /// (`"1.0"` against the managed base's `"0"`/`"1"` — docs/PLATFORM.md). So this is the
536    /// record of what was built, not a value to feed back into a request.
537    pub base_image_version: Option<String>,
538    pub description: Option<String>,
539    /// `ResourcesList`, max 1. The only place a built image's size class is observable:
540    /// `GetMicrovm` carries no memory figure at all.
541    pub resources: Option<Vec<Resources>>,
542    pub cpu_configurations: Option<Vec<CpuConfiguration>>,
543    /// The image-level egress list. **Max 1** in the model, not the 10 the VM-level
544    /// `NetworkConnectorList` allows — see [`crate::constants::MAX_IMAGE_EGRESS_CONNECTORS`].
545    pub egress_network_connectors: Option<Vec<String>>,
546    pub additional_os_capabilities: Option<Vec<String>>,
547    pub environment_variables: Option<std::collections::BTreeMap<String, String>>,
548    pub updated_at: Option<f64>,
549    /// The version-level failure reason. Measured **null** on real failures while the
550    /// build-level one is populated — docs/PLATFORM.md, "A failed build's `stateReason`
551    /// lives on the build".
552    pub state_reason: Option<String>,
553    pub tags: Option<std::collections::BTreeMap<String, String>>,
554}
555
556impl MicrovmImageVersionSummaryWire {
557    /// Whether `RunMicrovm` will launch this version.
558    ///
559    /// A comparison against the model's spelling through [`VersionStatus`] rather than
560    /// against a literal here, so the two cannot drift: the request type and the readback
561    /// agree on what `"ACTIVE"` is spelled like or neither compiles.
562    pub fn is_active(&self) -> bool {
563        self.status == VersionStatus::Active.as_str()
564    }
565
566    /// The version, its state, its availability status, and the reason when the service gave
567    /// one — the line a retire or a rollback prints.
568    ///
569    /// An absent reason renders nothing rather than an empty parenthesis, for the reason
570    /// [`MicrovmImageBuildSummaryWire::describe`] gives.
571    pub fn describe(&self) -> String {
572        match self.state_reason.as_deref() {
573            Some(reason) => format!(
574                "{} {} / {} ({reason})",
575                self.image_version, self.state, self.status
576            ),
577            None => format!("{} {} / {}", self.image_version, self.state, self.status),
578        }
579    }
580}
581
582/// `ListMicrovmImageBuildsOutput`.
583#[derive(Clone, Debug, Deserialize)]
584#[serde(rename_all = "camelCase")]
585pub struct ListImageBuildsResponseWire {
586    pub items: Vec<MicrovmImageBuildSummaryWire>,
587    pub next_token: Option<String>,
588}
589
590/// `MicrovmImageBuildSummary`.
591///
592/// **`buildState`, not `state`.** The model has no `state` member on this shape. A
593/// `#[serde(rename)]` to `state` here, or a field named `state` relying on
594/// `rename_all`, reproduces the bug described in this module's docs: the stall probe
595/// reads `None` from every real response and TRAP-2 becomes unfalsifiable. The
596/// deserializer test for this shape uses literal model-spelled JSON for that reason,
597/// and asserts that `{"state": ...}` **fails** to deserialize.
598/// Every member the model marks required is a bare field here, plus the optional
599/// `stateReason`. All eight required ones rather than the four this crate reads today,
600/// because the model requiring a member is the strongest promise it makes about a
601/// response, and a required member absent from the struct is a member no drift check can
602/// notice going missing.
603#[derive(Clone, Debug, Deserialize)]
604#[serde(rename_all = "camelCase")]
605pub struct MicrovmImageBuildSummaryWire {
606    pub image_arn: String,
607    pub image_version: String,
608    pub build_id: String,
609    pub build_state: String,
610    /// `ARM_64`, the enum's only value — carried as `String` rather than narrowed, because
611    /// a response is not a request and a second value AWS adds must parse rather than fail.
612    pub architecture: String,
613    pub chipset: String,
614    pub chipset_generation: String,
615    /// A `Timestamp`, which rest-json sends as epoch seconds — possibly fractional, hence
616    /// `f64` rather than `i64`.
617    pub created_at: f64,
618    pub state_reason: Option<String>,
619}
620
621impl MicrovmImageBuildSummaryWire {
622    /// The build, as a stall diagnosis wants to read it: the state, the build id, and the
623    /// reason when the service gave one.
624    ///
625    /// The `buildId` is in here because it is the identifier `GetMicrovmImageBuild` takes,
626    /// so a reader handed a wedge verdict can go ask about a named build rather than about
627    /// "one of the builds". The reason is included because the model documents it as "the
628    /// reason for the build state, **if applicable**" — so it is populated on states other
629    /// than a failure, and a probe that dropped it would be discarding the service's own
630    /// account of why nothing was scheduled.
631    ///
632    /// An absent reason renders nothing at all rather than an empty parenthesis, for the
633    /// reason [`MicrovmResponseWire::state_reason`] gives: "the service said nothing" and
634    /// "the service said nothing useful" are different diagnoses.
635    pub fn describe(&self) -> String {
636        match self.state_reason.as_deref() {
637            Some(reason) => format!("{} {} ({reason})", self.build_id, self.build_state),
638            None => format!("{} {}", self.build_id, self.build_state),
639        }
640    }
641}
642
643/// `GetMicrovmImageBuildOutput`, which is `MicrovmImageBuildSummary` plus one member.
644///
645/// # Why this is not the same type as the summary
646///
647/// It differs by exactly one member — `snapshotBuild` — and that member is the whole reason
648/// to make the call: it is the **only** place any API reports a size for anything this
649/// platform builds. Nothing on `GetMicrovmImage`, `GetMicrovm`, or either listing carries a
650/// byte count, and the three figures here are the quantities the snapshot read, write, and
651/// storage line items bill on (`crate::cost`). A separate type rather than an `Option` field
652/// on the summary, because the summary is a *listing* shape that never carries one, and an
653/// `Option` there would read as "the service sometimes omits the sizes" rather than "the
654/// listing does not have this member".
655///
656/// Every other member is required and identical to the summary's, so the same eight are bare
657/// fields here.
658#[derive(Clone, Debug, Deserialize)]
659#[serde(rename_all = "camelCase")]
660pub struct GetImageBuildResponseWire {
661    pub image_arn: String,
662    pub image_version: String,
663    pub build_id: String,
664    pub build_state: String,
665    pub architecture: String,
666    pub chipset: String,
667    /// The Graviton generation this build targeted. One `CreateMicrovmImage` fans out into
668    /// one build per generation, so a version's builds differ only in this member — and a
669    /// partially failed image, one generation succeeding and the other not, is a state a
670    /// caller should expect (docs/PLATFORM.md).
671    pub chipset_generation: String,
672    pub created_at: f64,
673    pub state_reason: Option<String>,
674    /// The three snapshot sizes, when the service reports them.
675    ///
676    /// `Option` because the model does not mark it required, and because a **failed** build
677    /// answers a partial one: measured 2026-08-16 on a real `FAILED` build, `snapshotBuild`
678    /// carried `codeInstallSizeInBytes` alone with no memory and no disk snapshot — which is
679    /// exactly the shape of a build that installed the code and then never produced a
680    /// snapshot.
681    pub snapshot_build: Option<SnapshotBuild>,
682}
683
684impl GetImageBuildResponseWire {
685    /// The build as a diagnosis reads it: the generation, the state, the reason when there is
686    /// one, and the sizes when the service reported them.
687    ///
688    /// The generation is in the line because a version has one build per Graviton generation
689    /// and they can disagree — "the build failed" is ambiguous where "generation 4 failed" is
690    /// not. Sizes are appended only when present, for the reason the absent-reason rule
691    /// gives: a rendered `0 bytes` would claim a measurement the service did not make.
692    pub fn describe(&self) -> String {
693        let mut line = match self.state_reason.as_deref() {
694            Some(reason) => format!(
695                "{} {} (chipset generation {}) — {reason}",
696                self.build_id, self.build_state, self.chipset_generation
697            ),
698            None => format!(
699                "{} {} (chipset generation {})",
700                self.build_id, self.build_state, self.chipset_generation
701            ),
702        };
703        if let Some(sizes) = &self.snapshot_build
704            && let Some(rendered) = sizes.describe()
705        {
706            line.push_str(&format!(", {rendered}"));
707        }
708        line
709    }
710}
711
712/// `SnapshotBuild`: the three byte counts, each optional in the model.
713///
714/// `u64` from the model's `Long`, and every one `Option` — the model marks none required, and
715/// a failed build really does answer with a subset (see
716/// [`GetImageBuildResponseWire::snapshot_build`]). A defaulted `0` would be a size claim
717/// nobody made, which is the same distinction `stateReason`'s `None` carries.
718#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
719#[serde(rename_all = "camelCase")]
720pub struct SnapshotBuild {
721    pub memory_snapshot_size_in_bytes: Option<u64>,
722    pub code_install_size_in_bytes: Option<u64>,
723    pub disk_snapshot_size_in_bytes: Option<u64>,
724}
725
726impl SnapshotBuild {
727    /// The sizes the service reported, or `None` when it reported none of them.
728    ///
729    /// `None` rather than an empty string so a caller can tell "no sizes" from "sizes that
730    /// render blank", and so [`GetImageBuildResponseWire::describe`] does not append a
731    /// dangling comma.
732    pub fn describe(&self) -> Option<String> {
733        let parts: Vec<String> = [
734            ("memory", self.memory_snapshot_size_in_bytes),
735            ("code", self.code_install_size_in_bytes),
736            ("disk", self.disk_snapshot_size_in_bytes),
737        ]
738        .into_iter()
739        .filter_map(|(label, bytes)| bytes.map(|bytes| format!("{label} {bytes} bytes")))
740        .collect();
741        (!parts.is_empty()).then(|| parts.join(", "))
742    }
743}
744
745/// `ListManagedMicrovmImageVersionsOutput`.
746///
747/// The versions of an AWS-managed base image, which is what closes the reproducibility hole:
748/// every build this client made floated on whatever `baseImageVersion` the service defaulted
749/// to, and that default has already moved once (`al2023-1` carries `"0"` and `"1"`).
750#[derive(Clone, Debug, Deserialize)]
751#[serde(rename_all = "camelCase")]
752pub struct ListManagedVersionsResponseWire {
753    pub items: Vec<ManagedMicrovmImageVersionWire>,
754    pub next_token: Option<String>,
755}
756
757/// `ManagedMicrovmImageVersion`. Three required members and one optional.
758///
759/// Note how much thinner this is than [`MicrovmImageVersionSummaryWire`]: a managed base's
760/// version carries no state, no status, no config — just an ARN, a version string, and two
761/// timestamps. So there is nothing here to check before pinning one, which is why
762/// [`crate::control::ControlPlane::managed_base_versions`] hands back the strings and lets
763/// the caller choose.
764#[derive(Clone, Debug, Deserialize)]
765#[serde(rename_all = "camelCase")]
766pub struct ManagedMicrovmImageVersionWire {
767    pub image_arn: String,
768    /// A **bare integer** for the managed base (`"0"`, `"1"`), where a custom image's
769    /// versions are `"1.0"`. The two spellings are not comparable and code that parses one
770    /// will not parse the other (docs/PLATFORM.md).
771    pub image_version: String,
772    pub created_at: f64,
773    pub updated_at: Option<f64>,
774}
775
776/// `ListManagedMicrovmImagesOutput`.
777#[derive(Clone, Debug, Deserialize)]
778#[serde(rename_all = "camelCase")]
779pub struct ListManagedImagesResponseWire {
780    pub items: Vec<ManagedMicrovmImageSummaryWire>,
781    pub next_token: Option<String>,
782}
783
784/// `ManagedMicrovmImageSummary`: an ARN and two timestamps, and **that is all the model
785/// declares**.
786///
787/// # Why a discovered base cannot construct a [`crate::control::BaseImage`]
788///
789/// [`crate::control::BaseImage`] pairs three things that must agree: the `baseImageArn`, the
790/// Dockerfile `FROM` that goes with it, and whether the image declares a `WORKDIR`. This
791/// shape carries the first and says nothing about the other two — no registry reference, no
792/// architecture, no working directory. So `require_matching_from` would have no `FROM` to
793/// compare a caller's Dockerfile against, and `require_workdir` would have no answer to the
794/// question it exists to ask; both guards would have to be skipped for a discovered base,
795/// which is worse than not discovering one. The registry ref in particular is not derivable:
796/// `al2023-1` pairs with `public.ecr.aws/amazonlinux/amazonlinux:2023-minimal`, and nothing
797/// in the ARN says so.
798///
799/// So this is **informational only** — surfaced by `microvm doctor` so a caller can see
800/// whether AWS has published a base this client does not know about, and never fed into a
801/// create request. Measured 2026-08-16: the listing returns exactly one item, so a client
802/// hardcoding `al2023-1` is currently missing nothing.
803#[derive(Clone, Debug, Deserialize)]
804#[serde(rename_all = "camelCase")]
805pub struct ManagedMicrovmImageSummaryWire {
806    pub image_arn: String,
807    pub created_at: f64,
808    pub updated_at: Option<f64>,
809}
810
811/// `RunMicrovmResponse` and `GetMicrovmResponse` — identical in every member this client
812/// reads, so one type serves both.
813///
814/// One type rather than two because the two shapes agree on all seven required members
815/// and both carry `state`, `endpoint`, and `stateReason`; a second type would be a
816/// second place to keep the spelling right.
817#[derive(Clone, Debug, Deserialize)]
818#[serde(rename_all = "camelCase")]
819pub struct MicrovmResponseWire {
820    pub microvm_id: String,
821    pub state: String,
822    pub endpoint: String,
823    pub image_arn: String,
824    pub image_version: String,
825    /// Optional in the model, and the absence is information: TRAP-8's message says so
826    /// rather than printing an empty string.
827    pub state_reason: Option<String>,
828    pub idle_policy: Option<IdlePolicy>,
829}
830
831/// `CreateMicrovmAuthTokenResponse`.
832///
833/// `authToken` is a `TokenParts` **map**, not a string (TRAP-7). Reading it as a string
834/// is a deserialization failure rather than a wrong value, which is the good direction —
835/// but the map's `X-aws-proxy-auth` key is what the endpoint header takes, and a caller
836/// who sends the map's `Debug` rendering gets a rejection that reads like a bad token.
837#[derive(Clone, Debug, Deserialize)]
838#[serde(rename_all = "camelCase")]
839pub struct CreateAuthTokenResponseWire {
840    pub auth_token: std::collections::BTreeMap<String, String>,
841}
842
843/// `ListMicrovmsResponse`.
844#[derive(Clone, Debug, Deserialize)]
845#[serde(rename_all = "camelCase")]
846pub struct ListMicrovmsResponseWire {
847    pub items: Vec<MicrovmItemWire>,
848    pub next_token: Option<String>,
849}
850
851/// `MicrovmItem` — the list shape, which is narrower than `GetMicrovmResponse`: it
852/// carries no `endpoint` and no `stateReason`.
853#[derive(Clone, Debug, Deserialize)]
854#[serde(rename_all = "camelCase")]
855pub struct MicrovmItemWire {
856    pub microvm_id: String,
857    pub state: String,
858    pub image_arn: String,
859    pub image_version: String,
860}
861
862/// `DeleteMicrovmImageOutput`.
863///
864/// # Kept and read, rather than deleted as dead
865///
866/// This struct was parsed nowhere: `try_delete_image` discarded the reply, so the shape
867/// existed and no code could observe drift in it. The choice was to delete it or to read
868/// it, and reading it wins on one specific case — a 2xx whose `state` is a `*_FAILED`
869/// spelling. That is the service accepting the delete request and refusing the work, and
870/// without the readback it is indistinguishable from a successful delete: `delete_image`
871/// answers `true`, teardown reports clean, and an image keeps billing. This project has
872/// had exactly that failure once already, in `scripts/verify-clean.py`, which is why the
873/// account is now asked what leaked rather than trusted to have been cleaned.
874///
875/// **`DELETING` and `DELETED` are both success** and the call site treats them alike. The
876/// deletion is asynchronous, so `DELETING` is the ordinary answer; treating it as
877/// incomplete would make the retry loop re-issue a delete that is already in progress and
878/// then report failure on the `ConflictException` that comes back.
879#[derive(Clone, Debug, Deserialize)]
880#[serde(rename_all = "camelCase")]
881pub struct DeleteImageResponseWire {
882    pub image_identifier: String,
883    pub state: String,
884}
885
886/// `ListMicrovmImagesResponse`.
887///
888/// `nextToken` is how the account's listing paginates, and the name resolver **must**
889/// follow it: an image on page two of a paginated listing is still an image, and a
890/// resolver that read only the first page would report "no image named X" for a name
891/// that exists — the confident wrong answer, which is worse than a failure.
892#[derive(Clone, Debug, Deserialize)]
893#[serde(rename_all = "camelCase")]
894pub struct ListImagesResponseWire {
895    pub items: Vec<MicrovmImageSummaryWire>,
896    pub next_token: Option<String>,
897}
898
899/// `MicrovmImageSummary`, narrowed to the members name resolution reads.
900///
901/// The model marks `imageArn`, `name`, `state`, and `createdAt` required; the first
902/// three are the ones anything downstream uses.
903#[derive(Clone, Debug, Deserialize)]
904#[serde(rename_all = "camelCase")]
905pub struct MicrovmImageSummaryWire {
906    pub image_arn: String,
907    pub name: String,
908    pub state: String,
909}
910
911/// The modeled error shapes, which all carry a `message` and several add fields.
912///
913/// One type for all seven because every one has `message` as its only member this client
914/// reads, and the status code is what distinguishes them.
915///
916/// `message` is `Option` for a reason that is itself a finding: an unsupported region
917/// answers `AccessDeniedException` with the message field **null** (TRAP-6). A required
918/// `String` here would fail to deserialize and report a parse error instead of the
919/// denial, hiding the one detail that identifies the trap.
920#[derive(Clone, Debug, Default, Deserialize)]
921#[serde(rename_all = "camelCase")]
922pub struct ServiceErrorWire {
923    pub message: Option<String>,
924    /// `ThrottlingException`/`ServiceQuotaExceededException`.
925    pub quota_code: Option<String>,
926    /// `ThrottlingException`/`ServiceQuotaExceededException`.
927    pub service_code: Option<String>,
928    /// `ConflictException`/`ResourceNotFoundException`/`ServiceQuotaExceededException`.
929    pub resource_type: Option<String>,
930    /// As above.
931    pub resource_id: Option<String>,
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937
938    /// The build summary, from JSON spelled exactly as `MicrovmImageBuildSummary`
939    /// declares it — every required member present, in the model's casing.
940    ///
941    /// **This is the honest-fake test.** It parses text, not this module's own output, so
942    /// it cannot agree with a misreading the way the Python fake did.
943    #[test]
944    fn a_build_summary_deserialises_from_the_models_own_spelling() {
945        let json = r#"{
946            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
947            "imageVersion": "1",
948            "buildId": "build-abc",
949            "buildState": "PENDING",
950            "architecture": "ARM_64",
951            "chipset": "GRAVITON",
952            "chipsetGeneration": "1",
953            "createdAt": 1754524800
954        }"#;
955        let parsed: MicrovmImageBuildSummaryWire =
956            serde_json::from_str(json).expect("the model's own spelling parses");
957        assert_eq!(parsed.build_state, "PENDING");
958        assert_eq!(parsed.build_id, "build-abc");
959        assert_eq!(parsed.image_version, "1");
960        assert_eq!(parsed.state_reason, None);
961
962        // The five members the struct used to drop, all of them model-required. A required
963        // member absent from the struct is a member no drift check can notice going missing.
964        assert_eq!(
965            parsed.image_arn,
966            "arn:aws:lambda:us-east-1:123456789012:microvm-image:img"
967        );
968        assert_eq!(parsed.architecture, "ARM_64");
969        assert_eq!(parsed.chipset, "GRAVITON");
970        assert_eq!(parsed.chipset_generation, "1");
971        assert_eq!(parsed.created_at, 1_754_524_800.0);
972    }
973
974    /// A summary missing a member the model marks **required** fails to deserialize, which
975    /// is what makes the five added members load-bearing rather than decorative.
976    ///
977    /// One case per member, because a single omission would prove only that *some* member is
978    /// required. `stateReason` is checked in the other direction: it is the one optional
979    /// member, so omitting it must still parse.
980    #[test]
981    fn a_build_summary_missing_a_required_member_fails_to_deserialise() {
982        let members = [
983            (
984                "imageArn",
985                r#""arn:aws:lambda:us-east-1:1:microvm-image:i""#,
986            ),
987            ("imageVersion", r#""1""#),
988            ("buildId", r#""build-abc""#),
989            ("buildState", r#""PENDING""#),
990            ("architecture", r#""ARM_64""#),
991            ("chipset", r#""GRAVITON""#),
992            ("chipsetGeneration", r#""1""#),
993            ("createdAt", "1754524800"),
994        ];
995
996        for omitted in members.iter().map(|(name, _)| *name) {
997            let body: Vec<String> = members
998                .iter()
999                .filter(|(name, _)| *name != omitted)
1000                .map(|(name, value)| format!(r#""{name}": {value}"#))
1001                .collect();
1002            let json = format!("{{{}}}", body.join(", "));
1003            let error = serde_json::from_str::<MicrovmImageBuildSummaryWire>(&json)
1004                .expect_err(&format!("{omitted} is required by the model"));
1005            assert!(
1006                error.to_string().contains(omitted),
1007                "the error must name the member that is missing ({omitted}): {error}"
1008            );
1009        }
1010
1011        // The one optional member: omitting `stateReason` parses, and the absence is `None`
1012        // rather than an empty string.
1013        let all: Vec<String> = members
1014            .iter()
1015            .map(|(name, value)| format!(r#""{name}": {value}"#))
1016            .collect();
1017        let parsed: MicrovmImageBuildSummaryWire =
1018            serde_json::from_str(&format!("{{{}}}", all.join(", ")))
1019                .expect("stateReason is optional");
1020        assert_eq!(parsed.state_reason, None);
1021    }
1022
1023    /// `describe` names the build id, the state, and the reason when there is one — and
1024    /// renders **nothing** rather than an empty parenthesis when there is not.
1025    ///
1026    /// The build id is in there because it is what `GetMicrovmImageBuild` takes, so a reader
1027    /// handed a wedge verdict can ask about a named build rather than about "one of the
1028    /// builds".
1029    #[test]
1030    fn a_build_describes_itself_with_its_id_and_only_a_reason_it_has() {
1031        let with_reason: MicrovmImageBuildSummaryWire = serde_json::from_str(
1032            r#"{
1033                "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:i",
1034                "imageVersion": "1",
1035                "buildId": "build-abc",
1036                "buildState": "FAILED",
1037                "architecture": "ARM_64",
1038                "chipset": "GRAVITON",
1039                "chipsetGeneration": "1",
1040                "createdAt": 1754524800,
1041                "stateReason": "no space left on device"
1042            }"#,
1043        )
1044        .expect("parses");
1045        assert_eq!(
1046            with_reason.describe(),
1047            "build-abc FAILED (no space left on device)"
1048        );
1049
1050        let without: MicrovmImageBuildSummaryWire = serde_json::from_str(
1051            r#"{
1052                "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:i",
1053                "imageVersion": "1",
1054                "buildId": "build-abc",
1055                "buildState": "PENDING",
1056                "architecture": "ARM_64",
1057                "chipset": "GRAVITON",
1058                "chipsetGeneration": "1",
1059                "createdAt": 1754524800
1060            }"#,
1061        )
1062        .expect("parses");
1063        assert_eq!(without.describe(), "build-abc PENDING");
1064        assert!(
1065            !without.describe().contains("()"),
1066            "an absent reason renders nothing, not an empty parenthesis: {}",
1067            without.describe()
1068        );
1069    }
1070
1071    /// The bug, made a compile-and-test-time fact: a response carrying `state` instead of
1072    /// `buildState` **fails to deserialize** rather than yielding a summary whose state is
1073    /// silently absent.
1074    ///
1075    /// This is the falsification for the whole `buildState` discipline. Rename the field
1076    /// to `state` (or add a `#[serde(alias = "state")]` "for compatibility") and this
1077    /// test goes red — which is what the Python client had no equivalent of, and why its
1078    /// dead guard passed for a review round.
1079    #[test]
1080    fn a_summary_spelled_state_instead_of_build_state_fails_to_deserialise() {
1081        let json = r#"{
1082            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1083            "imageVersion": "1",
1084            "buildId": "build-abc",
1085            "state": "PENDING",
1086            "architecture": "ARM_64",
1087            "chipset": "GRAVITON",
1088            "chipsetGeneration": "1",
1089            "createdAt": 1754524800
1090        }"#;
1091        let parsed: Result<MicrovmImageBuildSummaryWire, _> = serde_json::from_str(json);
1092        let error = parsed.expect_err("MicrovmImageBuildSummary has no member called state");
1093        assert!(
1094            error.to_string().contains("buildState"),
1095            "the error must name the member that is actually missing: {error}"
1096        );
1097    }
1098
1099    /// The asymmetry that makes the trap a trap: the *version* summary really does have a
1100    /// member called `state`, so "these shapes use `state`" is true of one and false of
1101    /// its neighbour. Both spellings are asserted here, together, so a reader sees why
1102    /// the two cannot be handled by one habit.
1103    #[test]
1104    fn a_version_summary_does_use_state_which_is_why_the_build_one_confuses() {
1105        let json = r#"{
1106            "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1107            "buildRoleArn": "arn:aws:iam::123456789012:role/build",
1108            "codeArtifact": {"uri": "s3://bucket/img.zip"},
1109            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1110            "imageVersion": "1",
1111            "state": "SUCCESSFUL",
1112            "status": "ACTIVE",
1113            "createdAt": 1754524800
1114        }"#;
1115        let parsed: MicrovmImageVersionSummaryWire =
1116            serde_json::from_str(json).expect("the version summary's member is state");
1117        assert_eq!(parsed.state, "SUCCESSFUL");
1118        assert_eq!(parsed.status, "ACTIVE");
1119        assert!(parsed.is_active(), "ACTIVE is what RunMicrovm will launch");
1120    }
1121
1122    /// The version summary's `stateReason`, which is the **version-level** failure reason
1123    /// `GetMicrovmImage` structurally cannot provide — that shape has no such member.
1124    ///
1125    /// It was parsed and never read, so a build failure could only be reported with a log
1126    /// group and a guess while the service's own account of the cause sat in hand.
1127    #[test]
1128    fn a_version_summary_carries_the_state_reason_get_image_cannot() {
1129        let json = r#"{
1130            "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1131            "buildRoleArn": "arn:aws:iam::123456789012:role/build",
1132            "codeArtifact": {"uri": "s3://bucket/img.zip"},
1133            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1134            "imageVersion": "2",
1135            "state": "FAILED",
1136            "status": "INACTIVE",
1137            "createdAt": 1754524800,
1138            "stateReason": "one or more builds failed"
1139        }"#;
1140        let parsed: MicrovmImageVersionSummaryWire = serde_json::from_str(json).expect("parses");
1141        assert_eq!(parsed.state, "FAILED");
1142        assert_eq!(
1143            parsed.state_reason.as_deref(),
1144            Some("one or more builds failed")
1145        );
1146
1147        // `GetMicrovmImageOutput` has no `stateReason` member at all, which is the whole
1148        // reason the version-level one is worth reading. A response carrying one parses and
1149        // the member is simply dropped, so this is asserted by there being no field to read
1150        // rather than by a failure.
1151        let image: GetMicrovmImageResponseWire = serde_json::from_str(
1152            r#"{"imageArn": "a", "name": "n", "state": "CREATE_FAILED",
1153                "createdAt": 1754524800, "stateReason": "ignored"}"#,
1154        )
1155        .expect("an unknown member is dropped");
1156        assert_eq!(image.state, "CREATE_FAILED");
1157    }
1158
1159    /// `GetMicrovmImageOutput` carries `tags`, `createdAt`, and `updatedAt`.
1160    ///
1161    /// Reading `tags` here is a cheaper answer to "what are this image's tags" than
1162    /// implementing `ListTags`: the service already sends the map on every call. The literal
1163    /// below is the shape a real response has — measured 2026-08-15 against a live
1164    /// `GetMicrovmImage`, which answered `"tags": {}` and an `updatedAt` on an untagged image.
1165    #[test]
1166    fn a_get_image_response_carries_its_tags_and_timestamps() {
1167        let json = r#"{
1168            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1169            "name": "img",
1170            "state": "CREATED",
1171            "latestActiveImageVersion": "1.0",
1172            "createdAt": 1754524800,
1173            "updatedAt": 1754528400,
1174            "tags": {"owner": "conformance", "cost-centre": "agents"}
1175        }"#;
1176        let parsed: GetMicrovmImageResponseWire = serde_json::from_str(json).expect("parses");
1177        assert_eq!(parsed.created_at, 1_754_524_800.0);
1178        assert_eq!(parsed.updated_at, Some(1_754_528_400.0));
1179        let tags = parsed.tags.expect("the map was sent");
1180        assert_eq!(tags.get("owner").map(String::as_str), Some("conformance"));
1181        assert_eq!(tags.len(), 2);
1182
1183        // An untagged image answers with an empty map, which is different from an absent
1184        // one: `Some({})` says the service answered and the image has no tags.
1185        let empty: GetMicrovmImageResponseWire = serde_json::from_str(
1186            r#"{"imageArn": "a", "name": "n", "state": "CREATED",
1187                "createdAt": 1754524800, "updatedAt": 1754528400, "tags": {}}"#,
1188        )
1189        .expect("parses");
1190        assert_eq!(empty.tags, Some(std::collections::BTreeMap::new()));
1191
1192        // And an absent map is `None` rather than a parse failure, since the model does not
1193        // mark `tags` required.
1194        let absent: GetMicrovmImageResponseWire = serde_json::from_str(
1195            r#"{"imageArn": "a", "name": "n", "state": "CREATED", "createdAt": 1754524800}"#,
1196        )
1197        .expect("tags is optional");
1198        assert_eq!(absent.tags, None);
1199        assert_eq!(absent.updated_at, None);
1200    }
1201
1202    /// `createdAt` is required by the model, so a `GetMicrovmImage` response without it
1203    /// fails rather than yielding an image whose creation time is silently zero.
1204    #[test]
1205    fn a_get_image_response_without_created_at_fails_to_deserialise() {
1206        let error = serde_json::from_str::<GetMicrovmImageResponseWire>(
1207            r#"{"imageArn": "a", "name": "n", "state": "CREATED"}"#,
1208        )
1209        .expect_err("createdAt is one of the model's four required members");
1210        assert!(error.to_string().contains("createdAt"), "{error}");
1211    }
1212
1213    /// `IdlePolicy` round-trips through a **response** body, all three members present.
1214    ///
1215    /// The comment on the shape used to say `suspendedDurationSeconds` "exists only in the
1216    /// request". The JSON below is what a live `GetMicrovm` answered on 2026-08-15, so this
1217    /// test is the measurement rather than a restatement of the model.
1218    #[test]
1219    fn an_idle_policy_comes_back_on_a_response_with_all_three_members() {
1220        let json = r#"{
1221            "microvmId": "mvm-1",
1222            "state": "RUNNING",
1223            "endpoint": "https://e",
1224            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1225            "imageVersion": "1.0",
1226            "idlePolicy": {
1227                "maxIdleDurationSeconds": 1800,
1228                "suspendedDurationSeconds": 600,
1229                "autoResumeEnabled": false
1230            },
1231            "maximumDurationInSeconds": 10800
1232        }"#;
1233        let parsed: MicrovmResponseWire = serde_json::from_str(json).expect("parses");
1234        let policy = parsed.idle_policy.expect("the service sent one");
1235        assert_eq!(policy.max_idle_duration_seconds, 1800);
1236        assert_eq!(
1237            policy.suspended_duration_seconds, 600,
1238            "the member the comment claimed the response omits"
1239        );
1240        assert!(!policy.auto_resume_enabled);
1241    }
1242
1243    /// A `DeleteMicrovmImage` readback parses, and the failure spelling is distinguishable
1244    /// from the two success ones — which is the whole reason this shape is read rather than
1245    /// deleted as dead.
1246    #[test]
1247    fn a_delete_readback_distinguishes_failure_from_deleting_and_deleted() {
1248        for state in ["DELETING", "DELETED", "DELETE_FAILED"] {
1249            let json = format!(
1250                r#"{{"imageIdentifier": "arn:aws:lambda:us-east-1:1:microvm-image:img",
1251                     "state": "{state}"}}"#
1252            );
1253            let parsed: DeleteImageResponseWire = serde_json::from_str(&json).expect("parses");
1254            assert_eq!(parsed.state, state);
1255            assert_eq!(
1256                parsed.image_identifier,
1257                "arn:aws:lambda:us-east-1:1:microvm-image:img"
1258            );
1259        }
1260    }
1261
1262    /// `ListMicrovmImagesResponse` from the model's own spelling, `nextToken` included.
1263    ///
1264    /// The honest-fake rule again: this JSON is text transcribed from `service-2.json`,
1265    /// not a round trip through this module's serializer, so a misspelled member here
1266    /// cannot agree with a misspelled member in the struct.
1267    #[test]
1268    fn an_image_listing_deserialises_from_the_models_own_spelling() {
1269        let json = r#"{
1270            "items": [{
1271                "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1272                "name": "img",
1273                "state": "ACTIVE",
1274                "latestActiveImageVersion": "1",
1275                "createdAt": 1754524800
1276            }],
1277            "nextToken": "opaque-page-2-token"
1278        }"#;
1279        let parsed: ListImagesResponseWire =
1280            serde_json::from_str(json).expect("the model's own spelling parses");
1281        assert_eq!(parsed.items.len(), 1);
1282        assert_eq!(parsed.items[0].name, "img");
1283        assert_eq!(
1284            parsed.items[0].image_arn,
1285            "arn:aws:lambda:us-east-1:123456789012:microvm-image:img"
1286        );
1287        assert_eq!(parsed.items[0].state, "ACTIVE");
1288        assert_eq!(parsed.next_token.as_deref(), Some("opaque-page-2-token"));
1289
1290        // The final page: nextToken absent means None, which is what stops the loop.
1291        let last: ListImagesResponseWire =
1292            serde_json::from_str(r#"{"items": []}"#).expect("an absent nextToken parses");
1293        assert!(last.items.is_empty());
1294        assert_eq!(last.next_token, None);
1295    }
1296
1297    /// `RunMicrovmResponse` from the model's spelling, including the optional
1298    /// `stateReason` that TRAP-8 reports.
1299    #[test]
1300    fn a_run_response_deserialises_with_its_state_reason() {
1301        let json = r#"{
1302            "microvmId": "mvm-123",
1303            "state": "TERMINATED",
1304            "endpoint": "https://mvm-123.microvm.amazonaws.com",
1305            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1306            "imageVersion": "1",
1307            "maximumDurationInSeconds": 3600,
1308            "startedAt": 1754524800,
1309            "stateReason": "run hook returned 500"
1310        }"#;
1311        let parsed: MicrovmResponseWire = serde_json::from_str(json).expect("parses");
1312        assert_eq!(parsed.state, "TERMINATED");
1313        assert_eq!(
1314            parsed.state_reason.as_deref(),
1315            Some("run hook returned 500")
1316        );
1317        assert_eq!(parsed.microvm_id, "mvm-123");
1318    }
1319
1320    /// The same shape without `stateReason`, which the model permits. The field must be
1321    /// `None` rather than an empty string, because "the service said nothing" and "the
1322    /// service said nothing useful" are different diagnoses.
1323    #[test]
1324    fn an_absent_state_reason_is_none_rather_than_an_empty_string() {
1325        let json = r#"{
1326            "microvmId": "mvm-123",
1327            "state": "RUNNING",
1328            "endpoint": "https://mvm-123.microvm.amazonaws.com",
1329            "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1330            "imageVersion": "1",
1331            "maximumDurationInSeconds": 3600,
1332            "startedAt": 1754524800
1333        }"#;
1334        let parsed: MicrovmResponseWire = serde_json::from_str(json).expect("parses");
1335        assert_eq!(parsed.state_reason, None);
1336    }
1337
1338    /// `authToken` is a map (TRAP-7). The key is the header name, and reading the map's
1339    /// one interesting key is what the proxy needs.
1340    #[test]
1341    fn an_auth_token_response_is_a_header_map_not_a_string() {
1342        let json = r#"{"authToken": {"X-aws-proxy-auth": "opaque-token-value"}}"#;
1343        let parsed: CreateAuthTokenResponseWire = serde_json::from_str(json).expect("parses");
1344        assert_eq!(
1345            parsed
1346                .auth_token
1347                .get("X-aws-proxy-auth")
1348                .map(String::as_str),
1349            Some("opaque-token-value")
1350        );
1351    }
1352
1353    /// A string `authToken` fails rather than being coerced. The failure is the good
1354    /// direction: a client that accepted both would send a stringified map as a header
1355    /// value and get a rejection that reads like a bad token.
1356    #[test]
1357    fn a_string_auth_token_fails_to_deserialise() {
1358        let json = r#"{"authToken": "opaque-token-value"}"#;
1359        let parsed: Result<CreateAuthTokenResponseWire, _> = serde_json::from_str(json);
1360        assert!(parsed.is_err(), "TokenParts is a map shape");
1361    }
1362
1363    /// TRAP-6's null message survives deserialization. A required `String` here would
1364    /// fail to parse and report a parse error, which hides the one detail — the null —
1365    /// that tells an unsupported region apart from a real IAM denial.
1366    #[test]
1367    fn an_access_denied_with_a_null_message_still_deserialises() {
1368        let parsed: ServiceErrorWire =
1369            serde_json::from_str(r#"{"message": null}"#).expect("a null message must parse");
1370        assert_eq!(parsed.message, None);
1371
1372        let empty: ServiceErrorWire = serde_json::from_str("{}").expect("an absent message parses");
1373        assert_eq!(empty.message, None);
1374    }
1375
1376    /// An unknown member is dropped rather than failing, so a member AWS adds does not
1377    /// break the client. This is the deliberate absence of `deny_unknown_fields`.
1378    #[test]
1379    fn an_unknown_response_member_is_ignored() {
1380        let json = r#"{
1381            "microvmId": "mvm-123",
1382            "state": "RUNNING",
1383            "endpoint": "https://e",
1384            "imageArn": "arn",
1385            "imageVersion": "1",
1386            "somethingAwsAddedLater": {"nested": [1, 2, 3]}
1387        }"#;
1388        let parsed: MicrovmResponseWire =
1389            serde_json::from_str(json).expect("additive change is safe");
1390        assert_eq!(parsed.microvm_id, "mvm-123");
1391    }
1392
1393    /// The create-image request serialises to the model's member names. Asserted against
1394    /// literal keys rather than a round trip, for the same reason the response tests hold
1395    /// JSON: `minimumMemoryInMiB` in particular is a casing `rename_all = "camelCase"`
1396    /// gets wrong on its own (it produces `minimumMemoryInMib`).
1397    #[test]
1398    fn the_create_request_serialises_to_the_models_member_names() {
1399        let wire = CreateMicrovmImageWire {
1400            name: "img".to_string(),
1401            base_image_arn: "arn:base".to_string(),
1402            base_image_version: None,
1403            build_role_arn: "arn:role".to_string(),
1404            code_artifact: CodeArtifact {
1405                uri: "s3://bucket/img.zip".to_string(),
1406            },
1407            cpu_configurations: vec![CpuConfiguration {
1408                architecture: "ARM_64".to_string(),
1409            }],
1410            resources: vec![Resources {
1411                minimum_memory_in_mib: 2048,
1412            }],
1413            hooks: Hooks {
1414                port: 9000,
1415                microvm_hooks: MicrovmHooks {
1416                    run: HookState::Enabled,
1417                    run_timeout_in_seconds: 30,
1418                    resume: HookState::Enabled,
1419                    resume_timeout_in_seconds: 30,
1420                    suspend: HookState::Enabled,
1421                    suspend_timeout_in_seconds: 30,
1422                    terminate: HookState::Enabled,
1423                    terminate_timeout_in_seconds: 30,
1424                },
1425                microvm_image_hooks: MicrovmImageHooks {
1426                    ready: HookState::Enabled,
1427                    ready_timeout_in_seconds: 300,
1428                    validate: HookState::Enabled,
1429                    validate_timeout_in_seconds: 300,
1430                },
1431            },
1432            additional_os_capabilities: Some(vec!["ALL".to_string()]),
1433            tags: None,
1434            client_token: "create-img-0011223344556677".to_string(),
1435        };
1436
1437        let value = serde_json::to_value(&wire).expect("serialises");
1438        let object = value.as_object().expect("an object");
1439
1440        // The exact member names, from the model. `minimumMemoryInMiB` is the one
1441        // camelCase alone would misspell.
1442        assert_eq!(value["resources"][0]["minimumMemoryInMiB"], 2048);
1443        assert_eq!(value["baseImageArn"], "arn:base");
1444        assert_eq!(value["buildRoleArn"], "arn:role");
1445        assert_eq!(value["codeArtifact"]["uri"], "s3://bucket/img.zip");
1446        assert_eq!(value["cpuConfigurations"][0]["architecture"], "ARM_64");
1447        assert_eq!(
1448            value["hooks"]["microvmImageHooks"]["readyTimeoutInSeconds"],
1449            300
1450        );
1451        assert_eq!(value["hooks"]["microvmHooks"]["runTimeoutInSeconds"], 30);
1452        assert_eq!(value["additionalOsCapabilities"][0], "ALL");
1453        assert_eq!(value["clientToken"], "create-img-0011223344556677");
1454        assert!(!object.contains_key("tags"), "an absent tag map is omitted");
1455
1456        // **Issue #24's wrong-by-10x hazard is closed by absence on this shape.** The model gives
1457        // `CreateMicrovmImageRequest` an `egressNetworkConnectors` with `max: 1` — a tenth of the
1458        // VM-level list's — and this wire type has no such field, so the client cannot send one at
1459        // all. That is why `MAX_IMAGE_EGRESS_CONNECTORS` guards nothing: there is nothing to guard.
1460        //
1461        // Asserted here, on the serialized object, rather than left as a comment. The hazard is a
1462        // future edit that adds the member and reuses `MAX_NETWORK_CONNECTORS` — permissive by an
1463        // order of magnitude, in a request whose rejection lands after the artifact upload. This
1464        // line is what makes adding the member a visible diff against a test that says the bound is
1465        // 1: whoever adds it has to delete this assertion and will read why while doing it.
1466        assert!(
1467            !object.contains_key("egressNetworkConnectors"),
1468            "the client sends no image-level egress list. If you are adding one, the ceiling is \
1469             MAX_IMAGE_EGRESS_CONNECTORS (1), NOT MAX_NETWORK_CONNECTORS (10) — the two differ by \
1470             10x and the permissive direction is the one that reaches the wire."
1471        );
1472    }
1473
1474    /// `additionalOsCapabilities` is **absent** rather than `[]` when identity repair was
1475    /// not requested. An empty list is a different request, and the model's `CapabilityList`
1476    /// has no documented meaning for one.
1477    #[test]
1478    fn an_unrequested_capability_list_is_omitted_not_empty() {
1479        let mut wire = minimal_create_wire();
1480        wire.additional_os_capabilities = None;
1481        let value = serde_json::to_value(&wire).expect("serialises");
1482        assert!(
1483            !value
1484                .as_object()
1485                .expect("object")
1486                .contains_key("additionalOsCapabilities"),
1487            "omitted, not empty: {value}"
1488        );
1489    }
1490
1491    /// The run request's member names, including the two connector lists and the payload.
1492    #[test]
1493    fn the_run_request_serialises_to_the_models_member_names() {
1494        let wire = RunMicrovmWire {
1495            image_identifier: "arn:image".to_string(),
1496            image_version: None,
1497            execution_role_arn: Some("arn:aws:iam::123456789012:role/exec".to_string()),
1498            ingress_network_connectors: vec!["arn:connector:ALL_INGRESS".to_string()],
1499            egress_network_connectors: None,
1500            idle_policy: IdlePolicy {
1501                max_idle_duration_seconds: 600,
1502                suspended_duration_seconds: 600,
1503                auto_resume_enabled: false,
1504            },
1505            maximum_duration_in_seconds: 3600,
1506            run_hook_payload: r#"{"agent_token":"t"}"#.to_string(),
1507            client_token: "run-arn-0011223344556677".to_string(),
1508        };
1509
1510        let value = serde_json::to_value(&wire).expect("serialises");
1511        assert_eq!(value["imageIdentifier"], "arn:image");
1512        assert_eq!(
1513            value["ingressNetworkConnectors"][0],
1514            "arn:connector:ALL_INGRESS"
1515        );
1516        assert_eq!(value["idlePolicy"]["maxIdleDurationSeconds"], 600);
1517        assert_eq!(value["idlePolicy"]["suspendedDurationSeconds"], 600);
1518        assert_eq!(value["idlePolicy"]["autoResumeEnabled"], false);
1519        assert_eq!(value["maximumDurationInSeconds"], 3600);
1520        assert_eq!(value["runHookPayload"], r#"{"agent_token":"t"}"#);
1521        assert!(
1522            !value
1523                .as_object()
1524                .expect("object")
1525                .contains_key("egressNetworkConnectors"),
1526            "omitting egress is how you get no outbound network"
1527        );
1528    }
1529
1530    /// The auth-token request's body. `microvmIdentifier` is a **URI** parameter and must
1531    /// not appear in the body — a serialized copy of it there is a member the shape does
1532    /// not declare.
1533    #[test]
1534    fn the_auth_token_request_body_carries_no_uri_parameter() {
1535        let wire = CreateAuthTokenWire {
1536            expiration_in_minutes: 60,
1537            allowed_ports: vec![PortSpecification::port(9000)],
1538        };
1539        let value = serde_json::to_value(&wire).expect("serialises");
1540        assert_eq!(value["expirationInMinutes"], 60);
1541        assert_eq!(value["allowedPorts"][0]["port"], 9000);
1542        assert!(
1543            !value
1544                .as_object()
1545                .expect("object")
1546                .contains_key("microvmIdentifier"),
1547            "it is a uri-located member: {value}"
1548        );
1549    }
1550
1551    /// **Each union variant serialises as the member name the model declares, with nothing
1552    /// else alongside it.**
1553    ///
1554    /// The wire form of a Smithy union is one key, and serde's default enum representation is
1555    /// `{"One": {"port": 9000}}` — a shape the service rejects as an undeclared member. So
1556    /// `untagged` plus a `rename` per field is not a style choice, and this test is what says
1557    /// so: it asserts the *exact* key set, because a variant that serialised as two keys or as
1558    /// the wrong one would still be valid JSON and would fail only against real AWS.
1559    ///
1560    /// The three variants together are what the port-scope fix rests on. Measured 2026-08-15:
1561    /// a token minted with only `{"port": 9000}` answers 403 `Access to port denied` for a
1562    /// `GET :8080`, and all three of the forms below answer 200.
1563    ///
1564    /// **Guard proof.** Dropping `#[serde(untagged)]` makes every case fail with the variant
1565    /// name as the key (`{"One": ..}`). Renaming `all_ports`'s field to snake_case makes the
1566    /// `allPorts` case fail. Making `AllPorts` a unit struct serialises it as `null` and the
1567    /// object assertion fails. Each applied and observed.
1568    #[test]
1569    fn each_port_specification_variant_serialises_as_its_model_member() {
1570        let cases = [
1571            (
1572                PortSpecification::port(8080),
1573                "port",
1574                serde_json::json!(8080),
1575            ),
1576            (
1577                PortSpecification::range(8000, 9100),
1578                "range",
1579                serde_json::json!({ "startPort": 8000, "endPort": 9100 }),
1580            ),
1581            (PortSpecification::all(), "allPorts", serde_json::json!({})),
1582        ];
1583        for (spec, key, expected) in cases {
1584            let value = serde_json::to_value(spec).expect("serialises");
1585            let object = value.as_object().expect("a union member is an object");
1586            assert_eq!(
1587                object.keys().collect::<Vec<_>>(),
1588                vec![key],
1589                "a union serialises as exactly one member named by the model: {value}"
1590            );
1591            assert_eq!(object[key], expected, "{key} carries the model's shape");
1592        }
1593    }
1594
1595    // ── GetMicrovmImageBuild ─────────────────────────────────────────────────
1596
1597    /// `GetMicrovmImageBuildOutput` from the model's own spelling, `snapshotBuild` included.
1598    ///
1599    /// The literal is a **real response**, copied from a live `GetMicrovmImageBuild` on
1600    /// 2026-08-16 against `coding-agents-on-bedrock` version 1.0 — so the three byte counts
1601    /// and the `chipsetGeneration: "4"` are measurements rather than plausible numbers. That
1602    /// matters for `chipsetGeneration` in particular: it is a `NonBlankString` in the model,
1603    /// so it comes back as `"4"` and not `4`, and a struct typing it `u32` would fail to parse
1604    /// every real response.
1605    ///
1606    /// **Guard proof.** Type `chipset_generation` as `u32` and this fails with "invalid type:
1607    /// string". Drop `snapshot_build` and the size assertions do not compile — which is the
1608    /// point, since those sizes are the only reason this call exists.
1609    #[test]
1610    fn a_build_get_response_carries_the_snapshot_sizes_the_listing_has_no_member_for() {
1611        let json = r#"{
1612            "imageArn": "arn:aws:lambda:us-east-1:392583147479:microvm-image:coding-agents-on-bedrock",
1613            "imageVersion": "1.0",
1614            "buildId": "d39847fa-4c6b-43c6-ba69-e25e3b55197b",
1615            "buildState": "SUCCESSFUL",
1616            "architecture": "ARM_64",
1617            "chipset": "GRAVITON",
1618            "chipsetGeneration": "4",
1619            "createdAt": 1755150457.035,
1620            "snapshotBuild": {
1621                "memorySnapshotSizeInBytes": 582238208,
1622                "codeInstallSizeInBytes": 2355486720,
1623                "diskSnapshotSizeInBytes": 23760896
1624            }
1625        }"#;
1626        let parsed: GetImageBuildResponseWire =
1627            serde_json::from_str(json).expect("the model's own spelling parses");
1628        assert_eq!(parsed.build_state, "SUCCESSFUL");
1629        assert_eq!(parsed.build_id, "d39847fa-4c6b-43c6-ba69-e25e3b55197b");
1630        // A string, because the model types it NonBlankString. A `u32` field here would fail
1631        // on every real response.
1632        assert_eq!(parsed.chipset_generation, "4");
1633        assert_eq!(parsed.state_reason, None);
1634
1635        let sizes = parsed.snapshot_build.expect("a successful build has sizes");
1636        assert_eq!(sizes.memory_snapshot_size_in_bytes, Some(582_238_208));
1637        assert_eq!(sizes.code_install_size_in_bytes, Some(2_355_486_720));
1638        assert_eq!(sizes.disk_snapshot_size_in_bytes, Some(23_760_896));
1639        assert_eq!(
1640            sizes.describe().as_deref(),
1641            Some("memory 582238208 bytes, code 2355486720 bytes, disk 23760896 bytes")
1642        );
1643    }
1644
1645    /// **A failed build's `snapshotBuild` is partial, and the partial shape is the diagnosis.**
1646    ///
1647    /// Measured 2026-08-16 against a real `FAILED` build (`bonk-sandbox-v4`, ready hook timed
1648    /// out): `snapshotBuild` carried `codeInstallSizeInBytes` alone, with no memory and no disk
1649    /// snapshot. That is a build which installed 1.7 GB of code and then never produced a
1650    /// snapshot, which is a different failure from a Dockerfile that broke before installing
1651    /// anything — so the two absent members carry information and a defaulted `0` would erase
1652    /// it.
1653    ///
1654    /// **Guard proof.** Change the three `Option<u64>` fields to bare `u64` and this fails to
1655    /// deserialize on the two missing members. Make `describe` render absent sizes as `0
1656    /// bytes` and the assertion that the line names only `code` goes red.
1657    #[test]
1658    fn a_failed_builds_snapshot_sizes_are_partial_rather_than_zeroed() {
1659        let json = r#"{
1660            "imageArn": "arn:aws:lambda:us-east-1:392583147479:microvm-image:bonk-sandbox-v4",
1661            "imageVersion": "1.0",
1662            "buildId": "4a4c5e30-811f-47fa-9893-260ea6a37a8f",
1663            "buildState": "FAILED",
1664            "architecture": "ARM_64",
1665            "chipset": "GRAVITON",
1666            "chipsetGeneration": "4",
1667            "stateReason": "Ready hook invocation timed out after PT5M",
1668            "createdAt": 1751042593.476,
1669            "snapshotBuild": {"codeInstallSizeInBytes": 1724940288}
1670        }"#;
1671        let parsed: GetImageBuildResponseWire = serde_json::from_str(json).expect("parses");
1672        let sizes = parsed
1673            .snapshot_build
1674            .expect("the service sent a partial breakdown");
1675        assert_eq!(sizes.code_install_size_in_bytes, Some(1_724_940_288));
1676        assert_eq!(
1677            sizes.memory_snapshot_size_in_bytes, None,
1678            "no memory snapshot was produced, which is the finding — not a size of zero"
1679        );
1680        assert_eq!(sizes.disk_snapshot_size_in_bytes, None);
1681
1682        let described = parsed.describe();
1683        assert!(
1684            described.contains("Ready hook invocation timed out"),
1685            "{described}"
1686        );
1687        assert!(
1688            described.contains("chipset generation 4"),
1689            "one CreateMicrovmImage fans out per generation, so the line has to name which: \
1690             {described}"
1691        );
1692        assert!(described.contains("code 1724940288 bytes"), "{described}");
1693        assert!(
1694            !described.contains("memory"),
1695            "an absent size must not render at all: {described}"
1696        );
1697    }
1698
1699    /// A build the service reports with **no** `snapshotBuild` at all renders no sizes and no
1700    /// dangling comma.
1701    ///
1702    /// The model marks the member optional, and a `PENDING` build has nothing to report — so
1703    /// `None` has to render as absence rather than as an empty clause.
1704    #[test]
1705    fn a_build_with_no_snapshot_block_renders_no_size_clause() {
1706        let json = r#"{
1707            "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:img",
1708            "imageVersion": "1",
1709            "buildId": "build-abc",
1710            "buildState": "PENDING",
1711            "architecture": "ARM_64",
1712            "chipset": "GRAVITON",
1713            "chipsetGeneration": "3",
1714            "createdAt": 1754524800
1715        }"#;
1716        let parsed: GetImageBuildResponseWire = serde_json::from_str(json).expect("parses");
1717        assert_eq!(parsed.snapshot_build, None);
1718        assert_eq!(
1719            parsed.describe(),
1720            "build-abc PENDING (chipset generation 3)"
1721        );
1722        assert!(!parsed.describe().ends_with(", "), "{}", parsed.describe());
1723
1724        // And an *empty* snapshot block is `Some` with three `None`s, which is different from
1725        // an absent one: the service answered and reported nothing.
1726        let empty: GetImageBuildResponseWire = serde_json::from_str(
1727            r#"{"imageArn": "a", "imageVersion": "1", "buildId": "b", "buildState": "PENDING",
1728                "architecture": "ARM_64", "chipset": "GRAVITON", "chipsetGeneration": "3",
1729                "createdAt": 1754524800, "snapshotBuild": {}}"#,
1730        )
1731        .expect("an empty block parses");
1732        assert_eq!(empty.snapshot_build.expect("Some").describe(), None);
1733        assert!(!empty.describe().contains("bytes"), "{}", empty.describe());
1734    }
1735
1736    /// The build get response requires the same eight members the summary does.
1737    ///
1738    /// One case per member, for the reason the summary's own test gives: a single omission
1739    /// would prove only that *some* member is required.
1740    #[test]
1741    fn a_build_get_response_missing_a_required_member_fails_to_deserialise() {
1742        let members = [
1743            (
1744                "imageArn",
1745                r#""arn:aws:lambda:us-east-1:1:microvm-image:i""#,
1746            ),
1747            ("imageVersion", r#""1""#),
1748            ("buildId", r#""build-abc""#),
1749            ("buildState", r#""SUCCESSFUL""#),
1750            ("architecture", r#""ARM_64""#),
1751            ("chipset", r#""GRAVITON""#),
1752            ("chipsetGeneration", r#""4""#),
1753            ("createdAt", "1754524800"),
1754        ];
1755        for omitted in members.iter().map(|(name, _)| *name) {
1756            let body: Vec<String> = members
1757                .iter()
1758                .filter(|(name, _)| *name != omitted)
1759                .map(|(name, value)| format!(r#""{name}": {value}"#))
1760                .collect();
1761            let error = serde_json::from_str::<GetImageBuildResponseWire>(&format!(
1762                "{{{}}}",
1763                body.join(", ")
1764            ))
1765            .expect_err(&format!("{omitted} is required by the model"));
1766            assert!(error.to_string().contains(omitted), "{omitted}: {error}");
1767        }
1768    }
1769
1770    // ── GetMicrovmImageVersion / UpdateMicrovmImageVersion ───────────────────
1771
1772    /// **One struct for three shapes.** A real `GetMicrovmImageVersion` response parses as
1773    /// `MicrovmImageVersionSummaryWire`, config readback and all.
1774    ///
1775    /// The literal is a real response, copied from a live call on 2026-08-16 against
1776    /// `coding-agents-on-bedrock` version 1.0 — including the detail that
1777    /// `baseImageVersion` reads `"1.0"` while the managed base's own listing offers `"0"` and
1778    /// `"1"`. The two spellings are not comparable and this test records that they are not.
1779    ///
1780    /// `hooks` is present in the JSON and **not** read, deliberately: see the type's docs and
1781    /// the sibling test below.
1782    ///
1783    /// **Guard proof.** Make `status` an `Option<String>` and the `is_active` assertion still
1784    /// passes, which is why the *requiredness* test below exists instead. Remove `resources`
1785    /// and the size-class assertion does not compile.
1786    #[test]
1787    fn a_get_image_version_response_parses_as_the_summary_with_its_whole_config() {
1788        let json = r#"{
1789            "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1790            "baseImageVersion": "1.0",
1791            "buildRoleArn": "arn:aws:iam::392583147479:role/agentd-conformance-build-b2111c56",
1792            "codeArtifact": {"uri": "s3://agentd-conformance-392583147479-b2111c56/coding-agents-on-bedrock.zip"},
1793            "egressNetworkConnectors": [
1794                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1795            ],
1796            "cpuConfigurations": [{"architecture": "ARM_64"}],
1797            "resources": [{"minimumMemoryInMiB": 2048}],
1798            "hooks": {
1799                "port": 9000,
1800                "microvmHooks": {
1801                    "run": "ENABLED", "runTimeoutInSeconds": 30,
1802                    "resume": "ENABLED", "resumeTimeoutInSeconds": 30,
1803                    "suspend": "ENABLED", "suspendTimeoutInSeconds": 30,
1804                    "terminate": "ENABLED", "terminateTimeoutInSeconds": 30
1805                },
1806                "microvmImageHooks": {
1807                    "ready": "ENABLED", "readyTimeoutInSeconds": 30,
1808                    "validate": "ENABLED", "validateTimeoutInSeconds": 30
1809                }
1810            },
1811            "imageArn": "arn:aws:lambda:us-east-1:392583147479:microvm-image:coding-agents-on-bedrock",
1812            "imageVersion": "1.0",
1813            "state": "SUCCESSFUL",
1814            "status": "ACTIVE",
1815            "createdAt": 1755150457.035,
1816            "updatedAt": 1755150629.117
1817        }"#;
1818        let parsed: MicrovmImageVersionSummaryWire =
1819            serde_json::from_str(json).expect("a real GetMicrovmImageVersion response parses");
1820
1821        assert_eq!(parsed.image_version, "1.0");
1822        assert_eq!(parsed.state, "SUCCESSFUL");
1823        assert_eq!(parsed.status, "ACTIVE");
1824        assert!(parsed.is_active());
1825        assert_eq!(
1826            parsed.base_image_arn,
1827            "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
1828        );
1829        // The spelling that is not comparable with the base's own listing: `"1.0"` here,
1830        // against `"0"` and `"1"` from ListManagedMicrovmImageVersions.
1831        assert_eq!(parsed.base_image_version.as_deref(), Some("1.0"));
1832        assert!(
1833            parsed
1834                .code_artifact
1835                .uri
1836                .ends_with("coding-agents-on-bedrock.zip")
1837        );
1838
1839        // `resources` is the only place a built image's size class is observable — GetMicrovm
1840        // reports no memory figure at all.
1841        let resources = parsed
1842            .resources
1843            .as_ref()
1844            .expect("the readback carries the resources");
1845        assert_eq!(resources.len(), 1);
1846        assert_eq!(resources[0].minimum_memory_in_mib, 2048);
1847
1848        // The image-level egress list, whose ceiling is 1 and not the VM-level 10.
1849        let egress = parsed
1850            .egress_network_connectors
1851            .as_ref()
1852            .expect("one connector");
1853        assert_eq!(egress.len(), 1);
1854        assert!(egress.len() <= crate::constants::MAX_IMAGE_EGRESS_CONNECTORS);
1855        assert!(
1856            egress.len() < crate::constants::MAX_NETWORK_CONNECTORS,
1857            "the two ceilings are different numbers; see the constants test that holds the \
1858             inequality"
1859        );
1860
1861        assert_eq!(parsed.state_reason, None, "a successful version has none");
1862        assert_eq!(parsed.updated_at, Some(1_755_150_629.117));
1863        assert_eq!(parsed.describe(), "1.0 SUCCESSFUL / ACTIVE");
1864    }
1865
1866    /// The version summary requires all eight members the model marks required, `status`
1867    /// included.
1868    ///
1869    /// `status` is the one worth the test. It was `Option<String>` while the model requires it,
1870    /// so a service that stopped sending it would have read as `None` — and a version blocked
1871    /// from launching would have looked identical to one nobody had ever set. Confirmed present
1872    /// on all 22 versions in the conformance account (2026-08-16).
1873    ///
1874    /// **Guard proof.** Restore `pub status: Option<String>` and the `status` case here passes
1875    /// where it should fail; `is_active` then cannot be written at all without a default, which
1876    /// is the second half of why the field is bare.
1877    #[test]
1878    fn a_version_summary_missing_a_required_member_fails_to_deserialise() {
1879        let members = [
1880            (
1881                "baseImageArn",
1882                r#""arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1""#,
1883            ),
1884            ("buildRoleArn", r#""arn:aws:iam::1:role/build""#),
1885            ("codeArtifact", r#"{"uri": "s3://b/k"}"#),
1886            (
1887                "imageArn",
1888                r#""arn:aws:lambda:us-east-1:1:microvm-image:i""#,
1889            ),
1890            ("imageVersion", r#""1.0""#),
1891            ("state", r#""SUCCESSFUL""#),
1892            ("status", r#""ACTIVE""#),
1893            ("createdAt", "1754524800"),
1894        ];
1895        for omitted in members.iter().map(|(name, _)| *name) {
1896            let body: Vec<String> = members
1897                .iter()
1898                .filter(|(name, _)| *name != omitted)
1899                .map(|(name, value)| format!(r#""{name}": {value}"#))
1900                .collect();
1901            let error = serde_json::from_str::<MicrovmImageVersionSummaryWire>(&format!(
1902                "{{{}}}",
1903                body.join(", ")
1904            ))
1905            .expect_err(&format!("{omitted} is required by the model"));
1906            assert!(error.to_string().contains(omitted), "{omitted}: {error}");
1907        }
1908    }
1909
1910    /// **`hooks` is not read, and a real response is why.**
1911    ///
1912    /// The model marks every member of `MicrovmHooks` optional, and a real
1913    /// `GetMicrovmImageVersion` on an image built by another tool answered
1914    /// `"microvmHooks": {"run": "ENABLED", "runTimeoutInSeconds": 30}` and nothing else —
1915    /// measured 2026-08-16 against `omnigent-host-vpc` version 3.0, five of eight members
1916    /// absent. This module's request-side [`Hooks`] declares all of them as bare fields, which
1917    /// is correct for a request this client always fills, so reusing it on the response would
1918    /// fail that parse and turn a readable version into a client error.
1919    ///
1920    /// So this test is the record of a deliberate omission: the sparse block parses fine
1921    /// *because* nothing reads it, and reusing `Hooks` here is the change that breaks it.
1922    ///
1923    /// **Guard proof.** Add `pub hooks: Option<Hooks>` to the summary and this test fails with
1924    /// "missing field `resume`".
1925    #[test]
1926    fn a_versions_hooks_block_is_not_read_because_a_real_one_omits_members() {
1927        let json = r#"{
1928            "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1929            "baseImageVersion": "0.0",
1930            "buildRoleArn": "arn:aws:iam::392583147479:role/bonk-sandbox-microvm-build",
1931            "codeArtifact": {"uri": "s3://bucket/omnigent-host-overlay.zip"},
1932            "resources": [{"minimumMemoryInMiB": 2048}],
1933            "hooks": {
1934                "port": 9000,
1935                "microvmHooks": {"run": "ENABLED", "runTimeoutInSeconds": 30},
1936                "microvmImageHooks": {"ready": "ENABLED", "readyTimeoutInSeconds": 120}
1937            },
1938            "imageArn": "arn:aws:lambda:us-east-1:392583147479:microvm-image:omnigent-host-vpc",
1939            "imageVersion": "3.0",
1940            "state": "SUCCESSFUL",
1941            "status": "ACTIVE",
1942            "createdAt": 1752205430.83
1943        }"#;
1944        let parsed: MicrovmImageVersionSummaryWire = serde_json::from_str(json)
1945            .expect("a sparse hooks block parses precisely because nothing reads it");
1946        assert_eq!(parsed.image_version, "3.0");
1947
1948        // And the request-side type really would refuse that block, which is the fact this
1949        // test exists to hold: the two directions need two shapes.
1950        let refused = serde_json::from_str::<Hooks>(
1951            r#"{"port": 9000,
1952                "microvmHooks": {"run": "ENABLED", "runTimeoutInSeconds": 30},
1953                "microvmImageHooks": {"ready": "ENABLED", "readyTimeoutInSeconds": 120}}"#,
1954        )
1955        .expect_err("the request-side Hooks declares all six enabled flags as required");
1956        assert!(refused.to_string().contains("resume"), "{refused}");
1957    }
1958
1959    /// An `INACTIVE` version reads back as not launchable, and `describe` says so.
1960    ///
1961    /// The pair with the `ACTIVE` case above, so `is_active` is a comparison rather than a
1962    /// function that returns true.
1963    ///
1964    /// **Guard proof.** Invert `is_active` and this fails; compare against a bare `"Active"`
1965    /// literal instead of `VersionStatus::Active.as_str()` and it fails too.
1966    #[test]
1967    fn an_inactive_version_reads_back_as_not_launchable() {
1968        let json = r#"{
1969            "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1970            "buildRoleArn": "arn:aws:iam::1:role/build",
1971            "codeArtifact": {"uri": "s3://b/k"},
1972            "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:img",
1973            "imageVersion": "2.0",
1974            "state": "SUCCESSFUL",
1975            "status": "INACTIVE",
1976            "createdAt": 1754524800,
1977            "updatedAt": 1754528400
1978        }"#;
1979        let parsed: MicrovmImageVersionSummaryWire = serde_json::from_str(json).expect("parses");
1980        assert!(
1981            !parsed.is_active(),
1982            "INACTIVE is the version RunMicrovm refuses to launch"
1983        );
1984        assert_eq!(parsed.describe(), "2.0 SUCCESSFUL / INACTIVE");
1985        assert_eq!(
1986            parsed.state, "SUCCESSFUL",
1987            "the state and the status are different questions: a SUCCESSFUL build can be \
1988             retired, which is the whole point of the status member"
1989        );
1990    }
1991
1992    /// The version summary's `describe` carries a reason when there is one, and nothing when
1993    /// there is not.
1994    #[test]
1995    fn a_version_describes_itself_with_only_a_reason_it_has() {
1996        let json = r#"{
1997            "baseImageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
1998            "buildRoleArn": "arn:aws:iam::1:role/build",
1999            "codeArtifact": {"uri": "s3://b/k"},
2000            "imageArn": "arn:aws:lambda:us-east-1:1:microvm-image:img",
2001            "imageVersion": "2.0",
2002            "state": "FAILED",
2003            "status": "INACTIVE",
2004            "createdAt": 1754524800,
2005            "stateReason": "one or more builds failed"
2006        }"#;
2007        let parsed: MicrovmImageVersionSummaryWire = serde_json::from_str(json).expect("parses");
2008        assert_eq!(
2009            parsed.describe(),
2010            "2.0 FAILED / INACTIVE (one or more builds failed)"
2011        );
2012        assert!(!parsed.describe().contains("()"), "{}", parsed.describe());
2013    }
2014
2015    /// The update request serialises to **one** member, `status`, spelled as the model spells
2016    /// it — and both enum values are asserted.
2017    ///
2018    /// The key set is asserted exactly, because `imageIdentifier` and `imageVersion` are URI
2019    /// parameters and a serialized copy of either in the body is a member the shape does not
2020    /// declare.
2021    ///
2022    /// **Guard proof.** Drop the `#[serde(rename = "ACTIVE")]` attributes and the values
2023    /// serialise as `"Active"`/`"Inactive"`, which the service refuses as an enum violation —
2024    /// both assertions go red.
2025    #[test]
2026    fn the_update_version_request_is_one_member_spelled_as_the_model_spells_it() {
2027        for (status, expected) in [
2028            (VersionStatus::Active, "ACTIVE"),
2029            (VersionStatus::Inactive, "INACTIVE"),
2030        ] {
2031            let value =
2032                serde_json::to_value(UpdateImageVersionWire { status }).expect("serialises");
2033            let object = value.as_object().expect("an object");
2034            assert_eq!(
2035                object.keys().collect::<Vec<_>>(),
2036                vec!["status"],
2037                "imageIdentifier and imageVersion are uri-located: {value}"
2038            );
2039            assert_eq!(object["status"], expected);
2040            assert_eq!(status.as_str(), expected);
2041            assert_eq!(status.to_string(), expected);
2042        }
2043    }
2044
2045    /// The typed request spelling and the drift gate's published array are the same two
2046    /// values.
2047    ///
2048    /// The coupling worth pinning: `constants::IMAGE_VERSION_STATUSES` is what the gate holds
2049    /// against the model's shape, and [`VersionStatus`] is what a call site can construct. If
2050    /// they disagree the gate passes while the client sends a value the model does not declare.
2051    #[test]
2052    fn the_typed_statuses_are_exactly_the_published_ones() {
2053        let typed: Vec<&str> = [VersionStatus::Active, VersionStatus::Inactive]
2054            .iter()
2055            .map(|status| status.as_str())
2056            .collect();
2057        assert_eq!(typed, crate::constants::IMAGE_VERSION_STATUSES);
2058    }
2059
2060    // ── the managed listings ─────────────────────────────────────────────────
2061
2062    /// `ListManagedMicrovmImageVersionsOutput` from a **real** response.
2063    ///
2064    /// Copied from a live call on 2026-08-16: `al2023-1` answers two versions, `"1"` and
2065    /// `"0"`, newest first. The bare-integer spelling is the finding — a custom image's
2066    /// versions are `"1.0"` — and it is why a version string cannot be parsed by one rule.
2067    #[test]
2068    fn a_managed_version_listing_carries_bare_integer_versions_newest_first() {
2069        let json = r#"{
2070            "items": [
2071                {
2072                    "imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2073                    "imageVersion": "1",
2074                    "createdAt": 1753119464.231,
2075                    "updatedAt": 1753119644.78
2076                },
2077                {
2078                    "imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2079                    "imageVersion": "0",
2080                    "createdAt": 1750180266.531,
2081                    "updatedAt": 1750180537.679
2082                }
2083            ]
2084        }"#;
2085        let parsed: ListManagedVersionsResponseWire =
2086            serde_json::from_str(json).expect("the model's own spelling parses");
2087        assert_eq!(parsed.items.len(), 2);
2088        assert_eq!(parsed.items[0].image_version, "1");
2089        assert_eq!(parsed.items[1].image_version, "0");
2090        assert_eq!(parsed.next_token, None, "one page, so the loop stops");
2091        assert_eq!(
2092            parsed.items[0].image_arn,
2093            "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
2094        );
2095        assert_eq!(parsed.items[0].updated_at, Some(1_753_119_644.78));
2096
2097        // Bare integers, not the `major.minor` a custom image uses. A client that parsed one
2098        // format would not parse the other, and neither can be compared with the `"1.0"` that
2099        // GetMicrovmImageVersion echoes as `baseImageVersion`.
2100        for item in &parsed.items {
2101            assert!(
2102                !item.image_version.contains('.'),
2103                "a managed base's versions are bare integers: {}",
2104                item.image_version
2105            );
2106        }
2107    }
2108
2109    /// `ManagedMicrovmImageSummary` is an ARN and two timestamps, and **that is all**.
2110    ///
2111    /// Asserted as an absence, which is the reason this shape cannot construct a
2112    /// [`crate::control::BaseImage`]: no registry reference for `require_matching_from` to
2113    /// compare a Dockerfile against, and no working directory for `require_workdir` to check.
2114    /// The absence is checked by deserializing a response that *does* carry extra members and
2115    /// showing there is no field to read them into — the same technique
2116    /// `a_version_summary_carries_the_state_reason_get_image_cannot` uses.
2117    #[test]
2118    fn a_managed_image_summary_carries_no_registry_ref_so_it_cannot_build_a_base_image() {
2119        let json = r#"{
2120            "items": [{
2121                "imageArn": "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1",
2122                "createdAt": 1750180266.531,
2123                "updatedAt": 1753119644.78
2124            }]
2125        }"#;
2126        let parsed: ListManagedImagesResponseWire =
2127            serde_json::from_str(json).expect("the model's own spelling parses");
2128        assert_eq!(parsed.items.len(), 1, "measured 2026-08-16: exactly one");
2129        assert_eq!(
2130            parsed.items[0].image_arn,
2131            "arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1"
2132        );
2133
2134        // The whole struct, as JSON, is three keys. There is nothing here a BaseImage's
2135        // `docker_ref` or `working_dir` could come from, and the ARN does not imply either:
2136        // `al2023-1` pairs with public.ecr.aws/amazonlinux/amazonlinux:2023-minimal, and
2137        // nothing in the ARN says so.
2138        let base = crate::control::BaseImage::al2023();
2139        assert_eq!(
2140            base.docker_ref, "public.ecr.aws/amazonlinux/amazonlinux:2023-minimal",
2141            "the pairing a discovered base cannot supply"
2142        );
2143        assert!(
2144            !parsed.items[0].image_arn.contains(&base.docker_ref),
2145            "the ARN does not carry the registry ref, so discovery cannot derive it"
2146        );
2147
2148        // A required member missing still fails, so the three that *are* there are load-bearing.
2149        let error = serde_json::from_str::<ManagedMicrovmImageSummaryWire>(
2150            r#"{"createdAt": 1750180266.531}"#,
2151        )
2152        .expect_err("imageArn is required");
2153        assert!(error.to_string().contains("imageArn"), "{error}");
2154    }
2155
2156    // ── the two unsent request members ───────────────────────────────────────
2157
2158    /// `CreateMicrovmImage.baseImageVersion` reaches the wire when pinned and is **absent**
2159    /// when it is not.
2160    ///
2161    /// Absent rather than null or empty, because an absent member means "whatever the service
2162    /// defaults to" while a blank one is a `ValidationException` on the `Version` shape's
2163    /// `min: 1`. The two are different requests and only one of them is legal.
2164    ///
2165    /// **Guard proof.** Remove `skip_serializing_if` and the absence assertion fails with a
2166    /// `"baseImageVersion": null` on every unpinned build — which the service rejects.
2167    #[test]
2168    fn a_pinned_base_image_version_reaches_the_wire_and_an_unpinned_one_is_omitted() {
2169        let mut wire = minimal_create_wire();
2170        wire.base_image_version = Some("1".to_string());
2171        let value = serde_json::to_value(&wire).expect("serialises");
2172        assert_eq!(value["baseImageVersion"], "1");
2173
2174        let mut unpinned = minimal_create_wire();
2175        unpinned.base_image_version = None;
2176        let value = serde_json::to_value(&unpinned).expect("serialises");
2177        assert!(
2178            !value
2179                .as_object()
2180                .expect("object")
2181                .contains_key("baseImageVersion"),
2182            "omitted, not null: an absent member takes the service default, and a blank one \
2183             is a ValidationException: {value}"
2184        );
2185    }
2186
2187    /// `RunMicrovm.imageVersion` reaches the wire when pinned and is **absent** when it is not.
2188    ///
2189    /// The absence is what this client always sent, so the field existing must not change an
2190    /// unpinned launch by a byte — a `"imageVersion": null` on every launch would be a new
2191    /// member on a request that has worked for months.
2192    ///
2193    /// **Guard proof.** Remove `skip_serializing_if` and the absence assertion fails.
2194    #[test]
2195    fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_is_omitted() {
2196        let mut wire = minimal_run_wire();
2197        wire.image_version = Some("2.0".to_string());
2198        let value = serde_json::to_value(&wire).expect("serialises");
2199        assert_eq!(value["imageVersion"], "2.0");
2200        assert_eq!(
2201            value["imageIdentifier"], "arn:image",
2202            "the version does not replace the identifier; both are sent"
2203        );
2204
2205        let value = serde_json::to_value(minimal_run_wire()).expect("serialises");
2206        assert!(
2207            !value
2208                .as_object()
2209                .expect("object")
2210                .contains_key("imageVersion"),
2211            "an unpinned launch must emit byte-for-byte what this client always sent: {value}"
2212        );
2213    }
2214
2215    fn minimal_run_wire() -> RunMicrovmWire {
2216        RunMicrovmWire {
2217            image_identifier: "arn:image".to_string(),
2218            image_version: None,
2219            execution_role_arn: None,
2220            ingress_network_connectors: Vec::new(),
2221            egress_network_connectors: None,
2222            idle_policy: IdlePolicy {
2223                max_idle_duration_seconds: 600,
2224                suspended_duration_seconds: 600,
2225                auto_resume_enabled: false,
2226            },
2227            maximum_duration_in_seconds: 3_600,
2228            run_hook_payload: String::new(),
2229            client_token: "run-arn-0011223344556677".to_string(),
2230        }
2231    }
2232
2233    fn minimal_create_wire() -> CreateMicrovmImageWire {
2234        CreateMicrovmImageWire {
2235            name: "img".to_string(),
2236            base_image_arn: "arn:base".to_string(),
2237            base_image_version: None,
2238            build_role_arn: "arn:role".to_string(),
2239            code_artifact: CodeArtifact {
2240                uri: "s3://b/k".to_string(),
2241            },
2242            cpu_configurations: vec![CpuConfiguration {
2243                architecture: "ARM_64".to_string(),
2244            }],
2245            resources: vec![Resources {
2246                minimum_memory_in_mib: 2048,
2247            }],
2248            hooks: Hooks {
2249                port: 9000,
2250                microvm_hooks: MicrovmHooks {
2251                    run: HookState::Enabled,
2252                    run_timeout_in_seconds: 30,
2253                    resume: HookState::Enabled,
2254                    resume_timeout_in_seconds: 30,
2255                    suspend: HookState::Enabled,
2256                    suspend_timeout_in_seconds: 30,
2257                    terminate: HookState::Enabled,
2258                    terminate_timeout_in_seconds: 30,
2259                },
2260                microvm_image_hooks: MicrovmImageHooks {
2261                    ready: HookState::Enabled,
2262                    ready_timeout_in_seconds: 300,
2263                    validate: HookState::Enabled,
2264                    validate_timeout_in_seconds: 300,
2265                },
2266            },
2267            additional_os_capabilities: None,
2268            tags: None,
2269            client_token: "create-img-0011223344556677".to_string(),
2270        }
2271    }
2272}