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