RFC #12 (1/5): PsValue layer + Ps derive macro engine#20
Merged
irvingouj@Devolutions (irvingoujAtDevolution) merged 33 commits intoJun 23, 2026
Merged
Conversation
Introduce the ergonomic access layer the RFC prioritizes as the biggest win per line — additive, no breaking changes to the value model. L0 (known_types.rs): static primitive tag <-> .NET type const table and a single-source GUID-uppercase convention helper (now used by From<Uuid>). L1: - convert.rs: FromPsValue / ToPsValue traits with impls for the primitive Rust types, Uuid, byte vectors, Vec<T> (lists) and Option<T> (Nil). - builder.rs: ComplexObject::req/opt typed accessors (searching both adapted and extended bags, with precise missing/type-mismatch errors) and a fluent ComplexObjectBuilder that writes each property name once, hiding the PsProperty duplication and the two-bag representation. Fix the as_string_array stub that silently returned Some(vec![]); it now reads <LST>/<STK>/<QUE> containers and rejects non-string elements. Migrate ConnectRunspacePool to the new API as the first exemplar (~70 lines of hand-rolled trio -> ~25), preserving its roundtrip tests. Add value_layer_tests covering accessors, builder, conversions and the as_string_array fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…L3) The "serde for CLIXML" derive layer: a message becomes an annotated struct instead of a hand-written PsObjectWithType + From<T> for ComplexObject + TryFrom<ComplexObject> trio. - ironposh-macros: PsSerialize emits PsObjectWithType + From<T> for ComplexObject by building through the L1 ComplexObjectBuilder; PsDeserialize emits TryFrom<ComplexObject> via the req/opt accessors. Field attribute #[ps(name = "..")] sets the CLIXML property name; #[ps(adapted)] targets the adapted bag; Option<T> fields are omitted on serialize and tolerate absent/Nil on deserialize. Struct attribute #[ps(message_type = Variant)] binds the MessageType. - Serialization goes by reference (&self.field), so no per-message clone. - Add a blanket ToPsValue impl for &T to support borrow-based field emission. - Migrate ConnectRunspacePool and PublicKey to the derive; their existing roundtrip/shape tests are unchanged and still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…erive Convert two more property-bag messages to #[derive(PsSerialize, PsDeserialize)]. Existing roundtrip/parse tests for RunspacePoolInitData are unchanged and still pass. SessionCapability is intentionally left hand-written: it uses the Version primitive and a String-stored-as-bytes TimeZone, the kind of "gnarly type" the RFC keeps as a manual impl. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…tach Addresses the CONNECT_RUNSPACEPOOL min/max gap (issue #12, step 6). The reattach path previously always built the pool with the creator default of 1/1, so a reattach to a differently-sized pool would advertise wrong limits. Add Connector::new_connect_with_runspaces(config, shell_id, min, max) and an optional connect_runspaces override threaded into the reattach RunspacePoolCreator. Default behavior is unchanged (1/1) and documented inline with a reference to the issue; proper fix is server-side session enumeration, which we still do not implement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
Add the L4 typed protocol stream: PsrpMessage is the typed view of a wire PowerShellRemotingMessage, so parsing happens once at the boundary instead of every consumer re-running "match MessageType -> parse_ps_message -> try_from". PsrpMessage::parse covers the receive-path message types and preserves unmodeled/free-form payloads (debug/verbose/warning records, unknown types) verbatim rather than failing, so the variant set can grow incrementally. Large record variants are boxed to keep the enum compact. Unit-tested for SessionCapability, RunspacePoolInitData and PipelineOutput. Adopt it end-to-end in the WSMan Connect negotiation loop (expect_shell_connected): the reattach ConnectResponse handler now matches typed PsrpMessage variants instead of re-deriving and re-parsing per arm, with identical behavior (same tolerance for unexpected messages). Covered by the existing connect_mode_reaches_connected / reattach tests. Note: the pool's main receive loop is intentionally left on its current dispatch — its InformationRecord arm deliberately tolerates a strict parse failure with a lossy fallback, which the strict typed stream would turn into a hard error; that migration needs live-server validation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…step 4)
The RFC's one breaking change: replace ComplexObject's two
`BTreeMap<String, PsProperty>` fields (adapted_properties + extended_properties)
with a single ordered, name-keyed `Properties` map whose entries are tagged
`PropertyKind::{Adapted, Extended}`. `PsProperty` is deleted — it duplicated the
map key it was stored under; a `Property { kind, value }` holds only the value.
Why now: the L1 accessors/builder (req/opt, ComplexObject::standard()) already
hide the representation for callers, which is exactly the precondition the RFC
set for doing this swap.
Wire compatibility is preserved byte-for-byte: serialization still emits
adapted (<Props>) before extended (<MS>), each sorted by name, and the
exact-XML + roundtrip test suites pass unchanged. Property reads now search
both member sets (RFC L1 finding #4: clients don't care about the
adapted/extended distinction) — this corrects one error_record test whose
expected string had encoded a doubled-comma artifact from the old
extended-only lookup.
Scope: ComplexObject + Properties/Property/PropertyKind, the serialize/
deserialize codec, the L1 builder/accessors, all ~19 message types, host
params/returns, the pool's in-place SecureString walk, and the web JS-interop
layer (JsComplexObject keeps its two JS-facing maps, converted at the
boundary). Full workspace build, tests (40 groups), clippy and fmt are green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…bility messages Completes the L3 derive feature set from RFC #12: the derive now supports the `#[ps(with = "module")]` custom-converter attribute (module provides `to_ps_value(&T) -> PsValue` and `from_ps_value(&PsValue) -> Result<T, _>`), which the RFC listed for "gnarly" fields. Works for required and optional fields, extended and adapted bags. Also add an identity `FromPsValue for PsValue` (the dynamic escape hatch) so fields kept as raw PsValue can be derived. Migrate three more messages off hand-written impls: - RunspacePoolStateMessage / PipelineStateMessage: implement ToPsValue + FromPsValue for their I32-backed enums, then derive (enum field + raw Option<PsValue> escape-hatch field). - SessionCapability: derive with `#[ps(with = ..)]` converters for its `<Version>` fields and the byte-array TimeZone blob — exercises the new attribute end to end. Wire output unchanged; the exact-XML/roundtrip suites pass. 7 messages now use the derive; the remaining ones are the genuinely nested/dictionary/multi- location types the RFC explicitly keeps hand-written on the L1 API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…igrate Coordinates/Size
Enhance #[derive(PsSerialize, PsDeserialize)] so sub-objects compose:
- generate ToPsValue/FromPsValue for every derived type, so a field of a
derived type (de)serializes as a nested <Obj> automatically;
- message_type is now optional — the same derive serves top-level messages and
nested sub-objects;
- new struct attr #[ps(type_names("A","B"))] sets the <TN> chain;
- new field attrs #[ps(alias = "...")] (try multiple wire names) and
#[ps(default)] (missing -> Default) for tolerant/multi-spelling fields.
Migrate Coordinates/Size (host_default_data) to the derive as the first nested
proof — ~50 lines of hand-written conversions removed, wire bytes unchanged
(exact-XML/roundtrip suites pass). Add derive_features tests covering nesting,
type_names, alias, default and the ToPsValue/FromPsValue bridge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
A review against the RFC found three additions exercised only by their own tests, with no production consumer — speculative generality. Cut them: - L0 PRIMITIVE_TYPES table + dotnet_type_for_tag: nothing read it. A second tag<->type source of truth that could drift from the serializer's match arms — the opposite of the "kill tag-drift" rationale. Kept format_guid (the only used part). - derive #[ps(alias = ..)] and #[ps(default)]: no message uses them; the tolerant/multi-spelling cases live in client-core's separate FromParams system, not this derive. - derive #[ps(type_names(..))]: no migrated object needs a <TN> chain yet (Coordinates/Size deliberately omit it). Re-add it with the first typed sub-object that actually carries type names. Kept everything with a real consumer: L1 accessors/builder, #[ps(with)] (session_capability), and the nesting bridge — optional message_type + generated ToPsValue/FromPsValue (Coordinates/Size). Full workspace build, 40 test groups, clippy and fmt all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…rsions New PsEnum derive covers both CLIXML enum encodings, zero hand-written impls: - repr = "object": full enum <Obj> (<TN> chain + <ToString> variant name + <I32> discriminant) — e.g. ApartmentState, PSThreadOptions; - repr = "i32": bare <I32> primitive — e.g. PSInvocationState, RunspacePoolStateValue. Variants carry explicit discriminants; #[ps(rename)] overrides the ToString name. Generates ToPsValue/FromPsValue (+ From/TryFrom<ComplexObject> for the object repr) so the enum composes inside derived structs. Migrated ApartmentState, PSThreadOptions (object) and PSInvocationState, RunspacePoolStateValue (i32) to the derive, deleting their hand-written From/TryFrom/ToPsValue/FromPsValue. Wire bytes unchanged; psrp tests green. Part of: concrete types + macro-generated conversions, no dynamically assembled objects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…57644) Migrate the last two enums (PipelineResultTypes, RemoteStreamOptions) to #[derive(PsEnum)] object repr. PipelineResultTypes has an `Error` variant that collided with `TryFrom::Error`, so PsEnum no longer generates TryFrom at all — it emits an inherent `from_ps_object(ComplexObject) -> Result<Self, _>` plus ToPsValue/FromPsValue/From. With no `Error` associated item in scope, variant paths are unambiguous. Updated the two call sites (command/create_pipeline) from `Enum::try_from(obj)` to `Enum::from_ps_object(obj)`. All standalone CLIXML enums are now macro-driven (zero hand-written conversions). Wire bytes unchanged; psrp tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
… `also`
Prep for migrating the cross-crate host param/return types to the derive:
- Ps* derives now emit `ironposh_psrp::...` paths (via `extern crate self as
ironposh_psrp` in the psrp root) so they work from downstream crates too;
the Simple* XML derives are untouched.
- Re-add struct attr #[ps(type_names("A","B"))] (now has real consumers: the
typed .NET host objects that carry a <TN> chain).
- Add field attr #[ps(also = "AltName")]: emit a property under additional
names and accept any of them on parse — preserves the dual camelCase +
PascalCase emission of host objects byte-for-byte.
psrp builds and tests green; wire output unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…en FromParams/ToPs bodies Migrate the 8 host param/return types (Coordinates, Size, Rectangle, BufferCell, KeyInfo, ProgressRecord, FieldDescription, ChoiceDescription, PSCredential) to #[derive(PsSerialize, PsDeserialize)] using the now crate-agnostic macro. Dual camelCase+PascalCase emission via #[ps(also=..)], <TN> chains via #[ps(type_names(..))], SecureString credential blob and the nested ProgressRecordType enum via #[ps(with=..)], lenient host params via #[ps(default)]. The ~700 lines of hand-written PsValue construction/parsing in host/params.rs and host/returns.rs collapse to: - the derived conversions (zero manual PsValue logic), plus - thin positional adapters (arg(a,i) / Some(v.to_ps_value())) — argument plumbing, not CLIXML conversion. The only remaining hand-built object is the genuinely dynamic Prompt-result Hashtable (runtime field names). Byte-for-byte safety: added host::snapshot_test characterization tests that lock the exact CLIXML of the return types (incl. the credential SecureString and dual-casing) captured from the old impls, and assert the derived path reproduces them identically. Full workspace build, 40 test groups, clippy and fmt all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
New field attr nil_when_none: an Option<..> field is always emitted (as Nil when None) instead of being omitted — required by objects whose slot must be present. Migrate CommandParameter (N is Nil when positional, V is the dynamic escape-hatch value) to the derive. create_pipeline roundtrip tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…ssages Define RemoteHostMethodId (MS-PSRP §2.2.3.17, 56 variants) as a PsEnum object — the mi enum-object is now macro-derived (PsEnums<I32> is byte-identical to the old ExtendedPrimitive<I32>, ToString = variant name). Migrate RUNSPACEPOOL_HOST_CALL and PIPELINE_HOST_CALL to #[derive]: ci (i64), mi (RemoteHostMethodId), mp (Vec<PsValue>, the method-specific params stay the dynamic boundary). Replaces method_id:i32 + method_name:String + hand-built mi. client-core's define_host_methods! now matches on the typed phc.method (exhaustive over the enum) instead of an i32 literal; an unknown method id is rejected when the wire enum is parsed (test repurposed accordingly). The real-capture parse_real_pipeline_host_call test passes (WriteProgress + its ProgressRecord param now go through the derived path). Workspace green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
Migrate RUNSPACEPOOL_HOST_RESPONSE and PIPELINE_HOST_RESPONSE to #[derive]: ci (i64), mi (RemoteHostMethodId), mr/me (optional dynamic-value boundary). All four host call/response messages now build their mi enum-object by macro; no hand-written ComplexObject in any of them. Threaded the typed method end-to-end across crates: - HostCall::method() -> RemoteHostMethodId (generated by define_host_methods!); - RemoteHostMethodId::from_id(i32) for the transport result path; - client-core send_*_host_response and the CancelHostCall op now carry RemoteHostMethodId instead of (method_id: i32, method_name: String); - web/tokio response construction uses host_call.method(). Workspace builds, 40 test groups, clippy and fmt green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…ipeline New field attr to_string: also set the object's <ToString> from a String field. Migrate Command and PowerShellPipeline to #[derive]: - Command: cmd (#[ps(to_string)] -> the object's ToString, matching pwsh), Args = Vec<CommandParameter> (plain ArrayList; the old to_string on Args was a divergence from captured pwsh XML and is dropped), UseLocalScope (nil_when_none), 8 merge fields via a lenient merge_result_conv (#[ps(with)]) that tolerates flag combinations like Output|Error=3 -> None, matching the old behavior. - PowerShellPipeline: cmds = Vec<Command>, History via a with-converter (empty -> Nil). The exact-XML round-trip command tests pass unchanged, proving byte fidelity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
ProgressRecordType -> PsEnum object repr; ProgressRecord -> #[derive], keeping its TypedBuilder transforms (parent-id negative filter, percent clamp). The old negative-filter/-1-default on the parse side were dead defensive paths (the builder normalizes before serialize); the roundtrip tests pass unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…e ApplicationPrivateData Add reusable ToPsValue/FromPsValue for BTreeMap<String, PsValue> (a PSPrimitiveDictionary <DCT>). Migrate APPLICATION_PRIVATE_DATA to #[derive]: data: Option<BTreeMap<..>> with #[ps(nil_when_none)] (Nil when absent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…licationArguments/PSVersionTable New PsObject struct mode `#[ps(dictionary)]`: serialize fields as a <DCT> PSPrimitiveDictionary keyed by field name (instead of an <MS> property bag), with `#[ps(flatten)]` merging a BTreeMap field's entries into the same dict (and collecting unclaimed keys on parse). Migrate PSVersionTable and ApplicationArguments to the derive (dictionary mode + Version/version-array with-converters + flatten for additional_arguments). The hand-built dictionary ComplexObject construction is gone; round-trip tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…HostInfo/HostDefaultData
Eliminates the last hand-written ComplexObject in the host_info cluster.
HostDefaultData now derives an integer-keyed {T,V} value dictionary via
#[ps(value_dictionary, key, type_tag)]; HostInfo nests it one level under a
'data' member via #[ps(wrap)]. ValueWrapper and all manual TryFrom/From impls
are gone. Wire bytes verified unchanged by create_pipeline exact-XML test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
Both now derive their CLIXML conversions; the only bespoke bit is app_args_conv collapsing empty ApplicationArguments to Nil (a primitive, not a built object). Wire bytes verified by the create_pipeline exact-XML characterization test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…ject/to_string-with ErrorRecord + ErrorCategory now fully macro-derived. New reusable macro features: #[ps(flatten_prefix)] (prefix-flatten a nested Option<Struct>), #[ps(fallback_object)] (read a field from a nested sibling object when absent up top — for .NET records that nest payload in Exception), and #[ps(to_string)] now works for any Display field. Real-world CommandNotFound XML verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…hic message_data Adds the PsUnion derive (untagged polymorphic enum: primitive/type_match/ fallback dispatch) so InformationMessageData's 3-way union converts to/from PsValue by macro. HostInformationMessage + InformationRecord now fully derive. TimeGenerated (DateTime|String) and the index-keyed Tags String[] use small typed converters (no object literals). ZERO hand-built ComplexObject literals remain in the messages layer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
…equire dispatch) Review hardening: #[ps(value_dictionary)] fields now honor a #[ps(with)] converter instead of silently ignoring it; PsUnion now rejects (at compile time) any variant lacking exactly one dispatch mode, which previously would serialize but never deserialize. No behavior change for current consumers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pd7AHzyaL2o4eDWQGBGv6A
A PowerShell <Nil/> member is semantically equivalent to an absent one. For Option/#[ps(default)] fields, a present-but-Nil property must map to None/Default instead of being fed to from_ps_value (which rejects Nil). Fixes WriteProgress rejection when ProgressRecord.CurrentOperation arrives as <Nil/>.
…ed auth Over plain HTTP, SSPI message sealing is used (application/HTTP-SPNEGO-session- encrypted). Over HTTPS, TLS provides confidentiality so the body is sent plain (application/soap+xml) and authentication is connection-oriented (RFC 4559): the connection is authenticated once during the handshake (the first operation rides the SPNEGO challenge legs) and every subsequent operation reuses that authenticated connection. requires_sspi_sealing() is therefore true only for plain HTTP. Key points: - SSPI INTEGRITY (sign) is requested unconditionally; CONFIDENTIALITY (seal) only when sealing. INTEGRITY over HTTPS lets the server trust the connection and produces the NTLM MIC / SPNEGO mechListMIC. - EPA channel binding (tls-server-end-point): learned from the server cert after a 401 and applied on a restarted auth sequence. - AlreadyComplete is handled consistently across all four transports (tokio direct, gateway, sync, web). - Fail fast on terminal auth rejection (401 -> PwshCoreError::Auth), try_join! so a failed handshake short-circuits, and the non-interactive client exits non-zero on failure / clean on success (command_completed is authoritative). - Basic over plain HTTP is refused unless --http-insecure is given.
…lding Add the 10-case transport_auth_matrix asserting the WinRM rule against a real server: HTTP+SSPI seals; HTTPS+SSPI is unsealed (TLS); Basic refused over plain HTTP unless forced, allowed (unsealed) over HTTPS. Helpers: sealed() detects the multipart/encrypted envelope, connected() detects pipeline completion. Also makes the shared e2e scaffolding auth-aware: e2e_pwsh_config gains default_auth_method(); native_pty_matrix and pty_harness use it (the default DC refuses Basic over HTTP), and fall back to the standard e2e host/creds out of the box.
test (5/5): transport × auth × sealing e2e matrix
feat (4/5): spec-correct WinRM HTTP/HTTPS transport + connection-oriented auth
fix (3/5): treat present-but-Nil property as absent in PsDeserialize
RFC #12 (2/5): derive PSRP & host-call messages
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack 1 of 5 — base: `master`.
Foundation of the RFC #12 CLIXML serialization refactor: the typed PsValue
access layer (L0/L1) and the `Ps` derive macro engine (`PsSerialize`/`PsDeserialize`/
`PsEnum`, `#[ps(with=)]`, nestable derives, crate-agnostic codegen).
This is the first half of the RFC #12 commit series (13 commits); the message-level
migrations that consume this engine are in the next PR in the stack.
Subsequent PRs stack on top:
2. `stack/02-rfc12-message-derives` — derive PSRP & host-call messages
3. `stack/03-writeprogress-nil` — fix Nil-as-absent in PsDeserialize
4. `stack/04-winrm-transport-auth` — spec-correct HTTP/HTTPS transport
5. `stack/05-transport-auth-matrix` — e2e matrix tests