Skip to content

Changelog

All notable changes to vouchfx are documented in this file.

The format is based on Keep a Changelog, and the project adheres to Semantic Versioning. From v1.0.0 GA onwards, the v1 language schema, provider SDK surface and event-wire contract are frozen for the whole v1.x series and enforced by golden-file CI gates; within v1.x, evolution is additive only. Pre-GA (the Unreleased section and every alpha/rc entry below it) still narrows and corrects the contract in place — exactly the entries the Breaking/Changed headings below record.

The first public pre-releases shipped beginning 2026-07-08: see the version entries below. The Unreleased section remains the cumulative delivered-capability record that seeds the v1.0.0 GA release notes; the alpha pre-releases are published previews of it. Note: GitHub Releases for v1.0.0-alpha.3 and v1.0.0-alpha.4 were left in draft status at the time of publication (packages shipped to NuGet.org regardless) and were promoted to published pre-releases on 2026-07-14.

[Unreleased]

Added

  • ${env:NAME} placeholders in service environment variablesenvironment.services.<name>.env values may now reference engine-process environment variables via ${env:NAME} syntax, resolved at topology-build time before container startup. An unset variable fails the suite before any container starts, naming the variable — never an empty-string substitution. This check is invisible to vouchfx validate (which never builds a topology) and, on vouchfx run, reports as an Environment error (§12.1) — exiting the process with code 0 by default, like any other Environment error, unless the caller passes --fail-on-env-error; without it, a mistyped ${env:DB_PASSWORD} produces a green CI run that executed zero steps. Whether this case should exit non-zero unconditionally is left to exit-code semantics generally; the unconditional exit rule for unconfirmable security declarations, delivered later in this series, is deliberately scoped to a security: block the author wrote and does not extend to it. The resolved value is visible via standard docker inspect output (inherent to container environment storage, not a security defect); authors should treat service-visible environment variables as non-confidential and use ${secret:…} references for credentials requiring redaction.
  • Raw TCP endpoints on services via ports declarationenvironment.services.<name>.ports: [9093, ...] declares raw TCP endpoints without an implicit HTTP health probe or HTTP endpoint, enabling non-HTTP systems under test (e.g. a customer-supplied Kafka broker, a proprietary binary service) to be declared. Each port is exposed via Aspire's generic endpoint (scheme tcp).
  • Explicit service health-check configuration, with per-shape defaultsenvironment.services.<name>.healthCheck: overrides the health probe. Two forms: { type: tcp, port: N } (a TCP connect probe on the specified port — no HTTP request — followed by a bounded zero-byte read to discriminate a live backend from DCP's host-published proxy accepting a connection before it has even reached one) or { type: http, path: "/..." } (the explicit spelling of the default HTTP / health check). There is no way to disable health-checking outright — type is closed to tcp/http. Omitting healthCheck now depends on shape: an image:-only HTTP service, and the hybrid ports + httpPort shape, both keep the default HTTP probe on /; a ports-only service (no sibling httpPort) defaults to a tcp probe against the first declared port, rather than no health check at all. type values are case-sensitive.
  • IProjectContext.DeclaredServices (the services-generalisation surface) — new member on the provider-authoring SDK's validation-stage context, mapping each declared environment.services.<name> to the Aspire endpoint names its shape produces (mirroring the existing DeclaredDependencies). IProjectContext is provider-consumed, never provider-implemented (see its own remarks), so this is non-breaking for every Core/Community provider, which only ever reads it. It is inside the frozen v1 provider contract golden — SdkContractFreezeTests snapshots the whole Vouchfx.Sdk public surface, interface Vouchfx.Sdk.IProjectContext included — and that golden was regenerated deliberately for this change, not bypassed: the gate was engaged and its diff reviewed. Regenerating it is legitimate here because neither the new member nor its DeclaredServiceInfo value type has shipped in any of the fourteen published tags (measured: DeclaredServices appears in src/Sdk/Vouchfx.Sdk/ in none of them), so no consumer can have compiled against either shape. It IS source-breaking for any TEST DOUBLE — in this repository or an external one — that implements IProjectContext directly: a hand-rolled class X : IProjectContext with no DeclaredServices member fails to compile against this SDK version. This repository's own test suites needed 29 such stand-ins updated (measured directly, not estimated); two further implementations outside the test suites — TestProjectContext (Vouchfx.Sdk.Testing) and the engine's own RunProjectContext (Vouchfx.Engine.Runtime) — needed the identical change, 31 in total. TestProjectContext ships inside the Vouchfx.Sdk.Testing package with the member already present, so any external harness built against that package is unaffected, but a hand-rolled double anywhere else is not. DeclaredServices underpins the health-check and target-resolution entries below. Reshaped before this series' first release (still pre-release, so a value-type change costs nothing external): the map's value is now a DeclaredServiceInfo record (EndpointNames, with room for a future init-only addition) rather than a bare endpoint-name list — an external target the engine does not itself start (a later capability) would otherwise have had nowhere to signal that beyond the SAME empty-list shape [] already means for a project-form service's auto-discovered endpoints.
  • Service-targeted Kafka publish/expect stepsmq-publish.kafka and mq-expect.kafka now accept a target naming a declared service (previously kafka-dependency-only; a non-kafka dependency target is still rejected). The connection staging that makes such a step actually reach its broker is described in its own entry below.
  • Every Core provider schema fragment now documents itself — a root-level description was added to the 16 fragments that lacked one (http.rest, http.soap, all five db-assert.* providers, both cache-assert.* providers, mail-expect.smtp, mq-expect.kafka/rabbitmq, mq-publish.kafka, script.csharp, trace-expect.otlp, webhook-listen.http), plus http.soap's previously-undescribed expect.xpath[].path/.value fields — every provider and field in the generated docs/language-reference.md now states what it does. Author-facing text only: internal engine class names stay in $comment (a JSON Schema 2020-12 keyword the reference generator never reads), the same treatment already applied elsewhere in the schema and now extended to root-language-schema.json's $defs/dependency container and its type field, whose descriptions previously leaked SchemaComposer.BuildIfThenClauses/EnvironmentMapper class names into author-facing prose.
  • "default" declared on five fields where the engine already applies onemetrics-assert.prometheus.path (/metrics), db-assert.dynamodb.expect.exists and storage-assert.s3.expect.exists (true), http.soap.expect.status (200), http.soap.expect.fault (false). Editor/IDE tooling reading the schema can now surface these without consulting provider source.
  • EngineExport.BuildCatalogue surfaces a oneOf/anyOf-nested requirement honestly, via two new typed fields — never as prose folded into RequiredFields. script.csharp's catalogue entry previously reported RequiredFields: [] — a real lie: exactly one of code/file is required, but that constraint lives entirely inside a oneOf the field-extraction logic never read. StepCatalogueEntry gains two additive fields: ExactlyOneOfGroups (from a root oneOf, e.g. script.csharp's [["code","file"]], and — the same generic detection — mq-publish.azureservicebus's [["queue","topic"]]) and AtLeastOneOfGroups (from a root anyOf, e.g. mq-expect.azureservicebus's [["expectPayloadContains","expectProperties"]] — which also closes a related gap: a minimal document built from RequiredFields alone for that type previously under-specified a document the composed schema actually rejects). SuiteScaffolder (the vouchfx scaffold / MCP generator) consumes both fields directly, emitting each group's first member with its own scaffold value, in place of the two hardcoded per-provider special cases this replaces; a [Theory] scaffolds all 25 registered Core provider types and validates every result against the full composed schema, so a mismatch between the catalogue and what the scaffolder emits cannot recur unnoticed. A qualifying oneOf/anyOf branch must be EXACTLY {"required": ["name"]} — one field name, nothing else — enforced by the extraction code itself, not merely documented; a branch with any other content, or with more than one required name (mq-expect.azureservicebus's own queue XOR (topic + subscription) shape has a two-name branch), degrades to no group at all rather than a fabricated or mis-cardinality one. The two consumers of this shape see different compatibility classes from the same change: the JSON wire (the list --json/vouchfx-mcp contract) is additive only — two new properties an existing client can ignore; a client with an older DTO simply never sees them (on the wire each is always present and always an array, [] at minimum). The .NET API is not — StepCatalogueEntry is a positional record, so both its constructor and its compiler-generated Deconstruct change arity, a binary-breaking change for any direct consumer (source-breaking too, for a positional-pattern match written against the old 7-arg shape). This is covered by this release's feat! marker on Vouchfx.Engine.Compilation (IsPackable, a published engine assembly — not the frozen Vouchfx.Sdk contract, which is untouched), not silently absorbed as a minor bump.
  • Schema-validation messages for two more forbidden-property shapes now name WHY, not just WHATstorage-assert.s3's mutually-exclusive expect.size/expect.minSize (Property 'minSize' cannot be combined with 'size' — exactly one of the two may be set) and every exists: false-forbids-content-fields shape (storage-assert.s3's six content fields, db-assert.dynamodb's expect.item; e.g. Property 'item' is not valid when 'exists' is false), both derived from the live composed schema's own sibling if condition, never a hardcoded field list. Degrades to the previous generic Property 'x' is not valid here for any shape that doesn't exactly match one of these two patterns.
  • security block on services and kafka dependencies — a security object is now recognised and validated on every environment.services.<name> and on a kafka environment.dependencies.<name>; on every other dependency kind it is rejected, because no security profile is wired for those kinds in this release (see the freeze-critical shape entry below for why that narrowing had to land before 1.0). The schema: profile (tls/mtls, case-sensitive, required — an open string pattern rather than a closed enum; an unrecognised name is rejected at validation time against the engine's security-profile registry, not by the schema itself, mirroring how a step's own type is a pattern in the schema and a registry lookup elsewhere), endpoint (port number or endpoint name, required whenever security is present — deliberately explicit so a suite cannot silently resolve to plaintext), optional caCert (valid to omit: trust material may already live in declared truststore/keystore or platform trust store; nothing is synthesised when absent), clientCert/clientKey (required together, mtls only; forbidden under tls), and serverArtifacts (list of {source, target} pairs; no inline contents — binary keystore material cannot survive YAML text). At vouchfx validate and pre-topology vouchfx run time: every declared path-valued field (caCert, clientCert, clientKey, each serverArtifacts[].source) is resolved relative to the suite's directory, containment-checked (checked before existence — a path pointing to a real file outside the directory still fails containment), and must exist on the host. An undeclared optional field is absent, not missing — nothing is checked or synthesised. A declared but blank path value (empty or whitespace-only) is rejected outright: the schema's minLength: 1 catches a literal empty string, and EnvironmentSecurityValidator catches a whitespace-only value the schema's character count alone cannot, naming the offending field. (The field was originally named mode; renamed to profile before this series' first release — see the freeze-critical shape entry below.)
  • security.profile is a freeze-critical, registry-closed, per-kind-narrowed shape (not merely a field rename) — three changes made together, before this series' first release, because each becomes impossible to make cleanly once the v1.0 language freezes: (1) mode renamed to profile, total, not aliased — a mechanism axis (which technology-specific wiring applies), not a strength axis, the same role type: family.provider plays for steps; the optional dotted form (<vendor>.<name>, e.g. acme.custom) reserves the SHAPE for a future out-of-tree profile, at no cost to the pattern — but, per (3) below, no such profile validates anywhere yet: it would still need to become a registered profile, and on a dependency other than kafka no profile validates at all. (2) $defs/security closes with unevaluatedProperties: false replacing additionalProperties: false (never both — a sibling additionalProperties in the same schema object silently voids unevaluatedProperties), so a composed profile fragment's own field can validate the way a provider fragment's own step field already can; a zero-behaviour change for every field this feature declares today. (3) A security block is legal only where this release actually wires a client connection: on any declared service, and on a kafka dependency. Every other dependency kind rejects the block outright — not the profile value, the block. There is no profile an author can substitute: tls and mtls are refused identically on a postgres, redis, mongodb, … dependency, and so is a future out-of-tree <vendor>.<name> profile. The message says so directly (Dependency 'cache' (type 'redis') declares 'security', but no security profile is wired for dependency kind 'redis' in this release — only a 'kafka' dependency, or a declared service, can carry a 'security' block today), because the alternative an author needs is a different target kind, not a different value. Expressed as a single allow-list clause (security: false for every dependency kind except kafka), not an enumerated exclusion, so a future dependency kind carries no security block the instant it is added. There is no substitute declaration that restores transport security for those twelve kinds in this release, and the message deliberately offers none. Re-declaring the technology under environment.services does not work: every step family targeting those kinds resolves target against environment.dependencies alone (measured — 17 of the 25 Core providers reject a target that is not a declared dependency of their own kind, and those 17 cover all twelve excluded kinds), so the move trades a schema rejection for a step-validation rejection. The service form is a working path only for infrastructure already reached as a service — an HTTP system under test, whose steps resolve target exclusively against environment.services. Transport security for the remaining dependency kinds is a 1.1 capability.

This is deliberately narrower than the shape first drafted for this series, which pinned security.profile to the single value tls on those twelve kinds and so accepted profile: tls on, say, a postgres dependency. Nothing in this release stages a TLS client connection for such a dependency: the engine-side confirmation probe would confirm the endpoint speaks TLS while the step's own provider-emitted client connected in plaintext — exactly the false assurance the registry invariant below exists to close, arriving through the schema instead. Because both gates run at validation time, they decide which suites validate at all, so widening them later is safe and tightening them later is not; 1.0 therefore rejects the whole block, and server-side TLS for the remaining dependency kinds (with the corresponding widening) is a 1.1 capability. A suite that declared a security block on a non-kafka dependency against an earlier pre-release of this series must drop that block: unless the technology is genuinely an HTTP system under test (which belongs under environment.services regardless), moving the declaration there does not preserve the behaviour — it only relocates the failure, from schema validation to the step's own target reconciliation.

A new SecurityProfileRegistry, internal to the engine assembly and published in no package (mirroring the provider StepKindRegistry's own reflective, frozen-at-startup discovery), checks that every declared (profile, target-kind) pair resolves to a registered wiring at validation time — closing a false-assurance path the transport-only confirmation probe cannot: the probe can confirm an endpoint speaks TLS while a provider's own client connects unsecured, and a schema narrowing alone has no way to notice that drift. Both built-in wirings recognise exactly the two target kinds above, so the registry and the schema agree by construction rather than by discipline. No SDK extension interface for third-party profiles ships — the registry and the mtls/tls wirings are built and exercised through it, but nothing in that seam is public, and it stays that way until a second profile exists to prove the shape.

One limit of (2) worth knowing before relying on it: nothing interprets a field out of the bucket yet. YamlDocumentParser reads a fixed set of seven keys into SecuritySpec — six scalars plus the serverArtifacts sequence — and a field contributed by a composed profile fragment survives parsing into SecuritySpec.Extra (any key with a scalar name; a complex YAML key is not retained, and a bound key given a non-scalar value reaches neither a typed member nor the bucket), withheld from ToString() on the same grounds as ClientKeyPassword (since a parser applies no shape to what lands there, it can hold a literal passphrase and a ToString() cannot prove otherwise). That interpretation — what a declared but untyped field should mean — is what the second profile will define.

  • HTTPS and mutual-TLS client connections for the HTTP step family. http.rest, http.soap and metrics-assert.prometheus now configure their transport from the security block declared on the target they name: they present the declared client certificate (profile: mtls) and validate the server against the declared caCert. A target that declares no security block is untouched — no callback is installed and the platform's own trust store applies, exactly as before. Certificate paths are read at run time, never interpolated into the compiled script, so a secured suite still compiles once and its reproducibility envelope is unaffected. Certificates are loaded lazily (only for a target some step actually resolves), once per scenario, and shared by every step that resolves the same target.

Five author-visible consequences of declaring security on a service, none of which apply to a service that declares none:

  • The endpoint named by security.endpoint is exposed with an https scheme, and svc::<name> resolves to it in preference to any sibling plaintext endpoint the same service declares. What that resolution stages follows the protocol the suite's own steps speak to the target: an https://host:port URL for a service the HTTP family addresses, and a bare host:port bootstrap authority for one the Kafka families address (see the separate entry below) — the endpoint annotation carries the https scheme either way.
  • The implicit plaintext HTTP endpoint an image-form service would otherwise receive is suppressed, exactly as declaring ports suppresses it, unless httpPort is also declared alongside. Note that an httpPort equal to security.endpoint is not a surviving plaintext endpoint — it is the secured one, replaced rather than added to, so such a service ends up with no plaintext endpoint at all.
  • A secured service's default health check becomes a TCP probe rather than an HTTP one. A container health check cannot present a client certificate, so an HTTP probe against an mTLS listener holds a working topology unhealthy forever; declare an explicit healthCheck against a separate unsecured port for a stronger probe.
  • A project-form service cannot be secured in this release, and now says so at vouchfx validate time rather than once containers are starting. Its endpoints come from its own launch profile, so the engine has none of its own to give an https scheme.
  • A declared caCert is a pin, not additional trust. The declared anchor is consulted on every path: a peer certificate that chains only to the machine's own trust store is rejected even though the platform would have accepted it, and the peer must also present the serverAuth extended key usage (or none at all, which means unconstrained). The hostname is still checked — a declared CA says which issuer to trust, never which host. The hostname the engine checks is a loopback address, not the logical service name: a step reaches its target at the host-side endpoint the orchestrator allocates (https://localhost:<allocated port>), so the system under test's own server certificate must name localhost — and, for safety, 127.0.0.1 — in its subject alternative name. A certificate issued for the declared service name fails with a hostname mismatch, which is never forgiven and reports as an Environment error. Two-tier certificate authorities are supported in the server direction: declare the offline root as caCert and the server sends its issuing intermediate as usual; peer-supplied intermediates are used to build the path and never become trust anchors themselves. The client direction has no equivalent — clientCert is presented as a single leaf with no chain alongside it, so an intermediate-issued client certificate authenticates only if the system under test can already obtain that intermediate itself, and the client pair is additionally PEM-only — a PKCS#12/PFX bundle carrying both cannot be used, and both files must resolve inside the suite directory like every other declared path. An encrypted key was refused at this point in the series and is now declared with clientKeyPassword, delivered later in this series and recorded in its own entry below (§3.2.6b).

  • Kafka client transport security, on all four emitted client configurations. mq-publish.kafka and mq-expect.kafka now configure their producer and consumer from the security block declared on the target the step names — on the plain-payload path and the Avro/schema-registry path alike, publish and expect. profile: tls verifies the broker only; profile: mtls additionally presents the declared clientCert/clientKey. A declared caCert becomes the client's trust anchor; an omitted one leaves the client's own default trust resolution untouched, and the property is assigned only when a path was actually declared — the Kafka client configuration is a keyed property bag in which assigning an empty string adds ssl.ca.location with an empty value (a path the library then tries to open and fails on), whereas leaving it unassigned removes the key. A target that declares no security block emits byte-for-byte the plaintext client it always did. Certificate paths are read at step-execution time through the run's own security accessor, never interpolated into the compiled script, so a secured suite still compiles once and its reproducibility envelope is unaffected. Kafka needs provider-side code where the other broker and store kinds do not, because its client configuration derives no transport decision from the bootstrap string the way an amqps://, tls://, ,ssl=true or tls=true connection string does — there is no connection-string channel to carry it, so the decision has to be made in the emitted client configuration. The profile switch is exhaustive and fails closed: tls and mtls are mapped explicitly and any other profile throws, naming the target and the profile. That is deliberate rather than redundant with the engine's own registry check, because the profile discriminator is an open string: a later wired profile reached through a different security protocol (SASL/SCRAM, Kerberos, an OAuth bearer) would, under a which-fields-happen-to-be-set test, silently inherit mutual-TLS semantics nobody chose for it and connect with the wrong protocol. A profile added to the registry alone therefore turns the suite red rather than acquiring these semantics by accident; the two sets are pinned equal by a test.

  • A fail-closed confirmation probe runs before the suite does, and reports a named level rather than a boolean. Once the topology is health-gated, and before the seed — therefore before any step — the engine connects to every declared security endpoint itself, presenting the same material a step will, and refuses to run the suite if it cannot confirm the declaration. Deliberately not an Aspire health check: a container health check cannot present a client certificate, so it can only establish that something accepted a socket. Health-gate the container, then confirm the security. Each declared target produces one declared-versus-observed line in the run's output (under --parallel too), carrying one of two levels:

  • AuthenticatedRoundTrip — the engine completed an application-protocol round trip over the secured connection. Today that means a Kafka ApiVersions exchange, reached for any kafka dependency and for any target the suite's own mq-publish.kafka/mq-expect.kafka steps address — including one declared as a service, which is the shape a customer-supplied broker actually takes, so the strong level is inferred from the suite's own steps rather than from the declaration kind. Under profile: mtls it additionally means a second connection presenting no client certificate did not complete the same exchange, and that differential is what makes the claim "the broker required an identity" true rather than merely "the broker tolerated one". A successful round trip on its own could not carry that claim: a peer that never sends a certificate request refuses nothing, and Kafka's own ssl.client.auth defaults to none, so a listener that requests a certificate without requiring one would be indistinguishable from an enforcing one. Under profile: tls there is no client identity to accept and none is claimed — the confirmation's own detail line says which of the two it is. The level says nothing about authorisation: whether that identity may publish to or consume from a given topic is the broker's own per-request decision and still surfaces as an ordinary step-level environment error.

  • TransportConfirmed — the endpoint speaks TLS, its certificate satisfied the declared caCert (or the platform's own trust store where none is declared), and the declared client certificate was presented. It does not confirm that the peer accepted that certificate, and nothing in this release claims it does: the target's application protocol is not known from its declaration, and a completed TLS 1.3 handshake carries no such signal. Certificate acceptance is first established when a step actually runs.

The two levels exist so that a run which confirmed only the transport cannot read identically to one which confirmed an authenticated round trip. Note also that the probe and the step share the material but not the judge: the probe's peer verdict is .NET SslStream's, applied to the host-published address the topology staged, while a Kafka step's is its own client library's, applied to whatever the broker's advertised.listeners names. The risk direction is safe (the step is never less strict than the probe), but they are not the same judgement.

  • security.serverArtifacts now copies the declared files into the target's own container at topology-build time — the server-side keystore or certificate a broker's own entrypoint expects to find — on a service and on a kafka dependency alike. Previously the list was validated and then ignored. The bytes are streamed in through the container runtime's own API rather than bind-mounted, and that choice is the substance of the feature: a bind mount depends on the host filesystem and the container daemon sharing a view of one path, which under a remote daemon or Docker-in-Docker they do not, and the mount then presents an empty directory inside the container rather than failing — so an entrypoint that only tests for the keystore's existence comes up healthy with no secured listener and no error anywhere. Streaming carries no host/daemon co-location assumption, which is what keeps a secured suite running unchanged local, in CI, or against a remote fabric. Three author-visible rules that the schema alone does not carry, all now rejected before any container starts: target must name a file, not a directory (a trailing / is refused, naming the offending path and showing the intended shape); two artefacts declared on the same owner may not claim the same in-container path; and only a host file path is accepted — there is deliberately no inline contents: form, because binary keystore material cannot survive as YAML text. Each source is resolved against the suite's own directory, containment-checked and existence-checked on exactly the same terms as caCert/clientCert/clientKey.

  • A security-confirmation failure now breaks CI with neither --fail-on-env-error nor --fail-on-inconclusive — the single deliberate exception to "only Fail breaks CI by default" (§12.1), and the entry here most likely to change a pipeline's outcome: a pipeline that passes neither gating flag can now go red where it previously could not. It is one property, not a list of causes. A run vouches for a declared security: block only when it confirmed every target that block declares. Two shapes raise. Either the post-health-gate confirmation probe ran and measured the declaration not to hold — the declared endpoint refuses the connection, does not speak TLS, presents a certificate that does not chain to the declared caCert, or refuses the declared client certificate — in which case the run aborts before any step executes; or something refused the document before it could be confirmed, in which case nothing downstream of the refusal ever runs to validate, probe or confirm the declaration. A run can also end without that confirmation and still exit 0: a topology that came up and then failed its health gate leaves the declaration unprobed and is neither shape — that is #390, deliberately still open (see the Changed entry below). Read that as the run's only fault: a health-gate failure never clears a refusal the same run already recorded, so a secured suite whose scenario was refused on an authoring fault before the gate failed exits non-zero on that refusal (measured: a two-scenario secured suite with a missing method: and a topology that then fails to provision exits 3, where the same pair without the security block exits 0). What the refusal was about is not consulted, and that is the whole of the rule: a certificate or artefact path that escapes the suite directory or does not exist, a profile with no wiring for the target's kind, a schema error anywhere in the document — including one located at the declaration itself, and including security: mtls, the profile name written where the block belongs, which binds no block at all — a clientKeyPassword that is not one whole, well-formed ${secret:<source>/<path>} reference, a step-level ${secret:…} naming a source the engine cannot resolve, an unresolvable script.csharp file:, a ${conn:…} naming no dependency, a target addressed by two protocol families, and the multi-scenario directory-layout guard are all instances of the property, and none of them is a definition of it. The rule holds for causes not named here, so neither this entry nor docs/ci-integration.md maintains a list of them. The practical consequence, measured on two documents differing only in whether they declare security:: the secured one now exits 4 where the unsecured one exits 0. In a secured suite a schema typo anywhere reddens the run; the run prints a line saying why, so the exit code is never the only evidence. Each keeps the exit code its own verdict names: no new code is introduced, and what EnvironmentError means is unchanged, so a pipeline keying on the taxonomy reads the same outcome it always did. Every other cause of an environment error is untouched — it raises nothing of its own, so a run whose only fault is one of them still exits 0 by default: an unhealthy container, an image that cannot be pulled, a seed failure unrelated to security, an unset ${env:NAME}. On the probe arm the discriminator is the classified error kind on the exception, never the message text and never the verdict — that is what separates a failed probe from the unhealthy container reaching the same catch. A refusal classifies nothing: each door records only that it was the door that refused. Either way the signal is read off the runner's own result rather than re-derived, because the whole point of the carve-out is that the verdict is unchanged and so cannot distinguish the case. Both run paths give the same answer for any document either path can refuse. Scoped deliberately: the three suite-level guards noted later in this entry exist only on the shared-topology run path, so a document they refuse has no --parallel counterpart to agree with. That includes the pairing issue #399 records, where the default path judged a step-level ${secret:…} fault first, stopped short of the security preflight and lost the security refusal altogether — the printed diagnostic as well as the exit code — while --parallel refused at the preflight: a scenario carrying both faults now reports both and exits 4 on run and run --parallel 1 alike, measured on the built CLI as a subprocess with neither gating flag. The suite-level guards are properties of the one shared topology and have no counterpart under --parallel, where each scenario compiles, resolves its declared client material and seeds its server artefacts against one and the same directory: its own. The change is therefore scoped to suites that declare a security block at all — measured: security appears in the language schema of none of the fourteen published tags, so no already-published suite can be affected.

One related behavioural change, not limited to security: when every discovered scenario carries an early (pre-topology) verdict, the shared-topology run path now returns without building the topology at all, where it previously started, health-gated and then tore down containers first. That is what turns a missing clientCert file into a prompt exit 4 naming the file, instead of a two-minute wait ending in a health-gate timeout reported as exit 3 with the preflight message buried above it. A suite mixing one valid scenario with one that failed preflight is unaffected by construction — the condition is that every scenario has an early verdict — so the topology builds and the valid scenario runs exactly as before.

One more suite-level refusal, for secured suites only: a multi-scenario suite that declares security and whose scenarios live in different directories is refused before the shared topology is built, with a non-zero exit. The scenarios of a suite must share a byte-identical environment block but not a folder, so a relative path such as caCert: ./certs/ca.pem in two scenarios one directory apart names two different files — the pre-run probe would then present one scenario's copy while another scenario's steps present their own, and the probe's verdict would no longer be evidence about those steps. The engine refuses rather than silently picking a directory; suites declaring no security are unaffected, and so is --parallel, where the guard deliberately does not apply — each scenario there builds its own topology and its own pre-run probe against its own directory, so there is no shared probe for a second scenario's material to diverge from.

  • An encrypted client private key can now be declared, through security.clientKeyPassword. The client half of mtls previously accepted only a plaintext PEM key, so the form an enterprise PKI hands out by default had to be decrypted before a suite could use it — and the decrypted copy then sat in a working tree. A new clientKeyPassword field, optional under profile: mtls and forbidden under profile: tls, names the passphrase instead: clientKeyPassword: ${secret:env/CLIENT_KEY_PASS}. It takes a single, whole ${secret:<source>/<path>} reference and nothing else — a literal passphrase, a partly interpolated string, and a reference with text around it are all rejected at authoring time, because a literal here would be a plaintext passphrase committed beside the encrypted key it unlocks. So is a well-formed reference naming a source the engine cannot resolve. vouchfx validate reports all four, and on vouchfx run all four are refused before the topology is built — no container starts and the run exits 4 with neither --fail-on-inconclusive nor --fail-on-env-error, because a declaration the engine cannot honour is one it cannot confirm (see the security-confirmation carve-out entry above). The same fault written into a step's field is an ordinary authoring error and carries no unconditional exit. Optional rather than required under mtls, because whether the declared clientKey is encrypted is a property of that file and not of the document: mtls still requires exactly clientCert and clientKey, as it always did. The reference is resolved at first use of the certificate material — inside the certificate load, once the topology is up, which in practice means the pre-run confirmation probe — and never at compile time, so no passphrase is baked into the compiled script and the reproducibility envelope hashes the reference rather than the value; the resolved value reaches no event, no report and no rendered observation. Both consumers read it: the HTTP family's certificate load, and mq-publish.kafka/mq-expect.kafka, which set their client library's ssl.key.password from it — assigned only where a passphrase was actually declared, since in that keyed property bag assigning an empty string adds the key with an empty value where leaving it unassigned removes it. Under --watch it resolves against exactly the sources vouchfx run does: the topology probe resolves it when the topology is (re)built, and each save's run resolves it again through that run's own accessor, exactly as vouchfx run does. Only the ENCRYPTED PRIVATE KEY (PKCS#8) form can be opened this way; an openssl legacy key, marked Proc-Type: 4,ENCRYPTED, is named alongside a wrong passphrase as one of the two candidate causes, with the openssl pkcs8 -topk8 conversion it needs. Every contradiction around the field fails closed with a diagnostic rather than passing silently: a passphrase declared against a key that is not encrypted (a passphrase that decrypts nothing is how a key rotated back to plaintext goes unnoticed), a passphrase declared with no clientKey for it to unlock, a reference resolving to an empty value, and a wrong passphrase — which names clientKeyPassword as the likeliest cause, the other being an encryption form this runtime cannot read, while reporting neither the value nor its length. An encrypted key declaring no passphrase is now named as such too: the platform's own message for that case is byte-identical to the one for a malformed key, so such a key was previously reported to its author as a broken one. Still unsupported, unchanged: a PKCS#12/PFX bundle carrying certificate and key together, a passphrase for caCert (a trust anchor is a public certificate with no private key to unlock), and any declared file outside the suite directory.

  • A Kafka broker declared under environment.services is now reachable by its own steps, not merely accepted at validation. mq-publish.kafka/mq-expect.kafka have accepted a target naming a declared service since this feature began — the shape a customer-supplied broker takes, since it runs its own entrypoint and configuration rather than being provisioned by the engine — but such a step could never actually run: the engine staged a service's endpoint at svc::<name> and both providers read conn::<name>, so a suite that confirmed green at the pre-run probe then failed on its first step with kafka bootstrap not found. Two halves of one rule fix it, and the rule is that the engine stages the value in the form its own consumer uses, and a provider never rewrites it. The engine now determines that form from the protocol the suite's own steps speak against the target — the same inference that already chooses the confirmation level, reused so the two cannot disagree: a target the HTTP family addresses is staged as an https://host:port URL exactly as before, and a target the Kafka families address is staged as the bare host:port bootstrap authority those clients expect, carrying no scheme for the provider to strip. And each provider emits the Vars key matching the kind its target actually is, decided at compile time from the same declared-service map its own Validate reconciled the target against — never guessed, and never resolved by trying one key and falling back to another. A target addressed by both families is now rejected, naming the target: one endpoint stages one value and the two families consume different shapes of it, so picking a winner would hand the loser a value it must transform to use. vouchfx validate reports it for a scenario that addresses one target with both families. A multi-scenario suite that splits the two families across its scenarios is rejected at the pre-topology stage of a shared-topology vouchfx run instead, with the same diagnostic: those scenarios share one topology, so the staged form is decided from the union of the steps across every scenario the run will actually execute, and no single-file check can see that (under --parallel the union never forms — each scenario owns its topology, so its own steps alone decide its staged form, and the per-scenario rejection is the whole of what applies) — validate deliberately treats each file independently and never decides which files form a suite. On both of those paths the rejection arrives before any container starts; under --watch it arrived once the topology was already up, and those containers then stayed up, because at that time the watch loop's compile step built the scenario but ran no validation stage and tore a topology down only when a save changed the environment block — which a steps-level conflict does not (both halves of that are closed in the entries for #370 above). The rejection narrows nothing that ever worked — before this change the Kafka half of such a suite failed at run time every time — and the remedy is to declare the broker and the HTTP API as two entries under environment.services.

  • New public SDK type Vouchfx.Sdk.KafkaSecurityHelper — the compile-time constant source of the helper class the two Kafka providers splice into their CsxFragment.RequiredHelpers to configure transport security on the emitted client, the Kafka counterpart of the existing helper that does the same job for the HTTP family's message handler. It is a separate type rather than a member on that one because the two are spliced by disjoint provider sets and name disjoint types: this one references the Kafka client library, which resolves only because the Kafka providers contribute that assembly, so splicing it into an HTTP-only suite would not compile. Like every helper source it is byte-identical across both providers, so the assembler deduplicates it to one copy per suite. It is inside the frozen v1 provider contract golden — SdkContractFreezeTests snapshots the whole Vouchfx.Sdk public surface — and that golden was regenerated deliberately for this addition, not bypassed: the gate was engaged and its three-line diff reviewed. Regenerating it is legitimate here because the type has shipped in none of the fourteen published tags (measured: KafkaSecurityHelper.cs is absent from src/Sdk/Vouchfx.Sdk/ in every one), so no consumer can have compiled against it, and the change is purely additive — a new type alongside the existing surface, mutating no v1 interface and changing no existing member's shape. Nothing that has already shipped changes.

  • New public SDK type Vouchfx.Engine.Authoring.Model.SecurityArtifactPath, and a new ICompileContext.DeclaredServices member — the two other additive surface changes this release makes, recorded here for the same reason the type above is: an addition nobody wrote down is indistinguishable, to a reader of the changelog, from one nobody made. SecurityArtifactPath is the single spelling of the containment rule for a declared security artefact path, hoisted so the pre-topology validator, the client-certificate accessor and the container-file copy resolve one rule rather than three copies of it; it is public because those three live in assemblies that do not reference one another, and it is not covered by the frozen v1 provider golden (that golden snapshots Vouchfx.Sdk only). ICompileContext.DeclaredServices mirrors the identically named member on IProjectContext so a provider's Emit can decide which Vars key its target needs; it carries a default implementation returning the empty map, so every existing implementation of that interface — this repository alone holds some eighty test stand-ins, and provider authors are free to hold more — compiles and behaves exactly as before.

  • A service may pin the host port its container port publishes on, by writing a ports: entry as a "<host>:<container>" string — docker-compose's ordering — instead of a bare integer: ports: ["19093:9093"] publishes container port 9093 on host port 19093. A bare integer still declares a container port whose host port the orchestrator allocates per run. One narrowing comes with it, on the bare form too: a port with a leading zero is now refused rather than parsed, so ports: [0123] — which parsed as 123 — is an error. That is deliberate and it closes a real divergence: the document is read twice, and a leading zero is octal to one of those readers and decimal to the other, so [0123] meant 83 to the schema and 123 to the parser. It is also the rule security.endpoint already published. Any other bare integer behaves exactly as before. Why it exists, because it is not a convenience. A container-run server that has to announce its own address — a Kafka broker's advertised.listeners above all — reads that address from the configuration it is given at start-up. Where the engine provisions the server itself, as it does for a kafka dependency, its own integration knows the allocated host port and writes it in. A service is the shape this requirement is for: the engine starts someone else's image with someone else's configuration, so the only address available to write is one the author knew when they wrote the suite. With no way to pin the host side there was no such value that was true from outside the container: a step would bootstrap successfully over the staged secured endpoint, receive metadata directing it to the announced address, and find nothing listening there. A customer-supplied broker declared as a service was therefore reachable up to, and only up to, its last hop. It is now measured end to end — a producer and a consumer completing over mutual TLS from the engine host against exactly that shape. Pin only where something outside the engine must name the number; a bare integer remains the right default everywhere else, because pinning trades away the orchestrator's freedom to route around a busy port. Nothing else in the language moves with it: endpoint names, security.endpoint's selector and healthCheck.port all continue to name the container port. The host half must be 1024–65535 — a pinned privileged port would squat a real service's port on the machine for the length of a run, and sub-1024 binds succeed as root on CI while failing on a developer's machine; the container half keeps the full range, since it lives in the container's own namespace. The editor checks the shape of a pinned entry but not those ranges, so an out-of-range one is refused at validate/run rather than underlined as you type.

  • A pinned host port that is already in use now fails immediately, naming the port, instead of hanging for the health-gate budget. Measured before the change, on a suite pinning a port another process held: the run did not fail — it consumed its entire budget and ended after 3m15s reporting that the resource "failed to become healthy", which named the service and nothing else. Not the port, not the collision, not even that a port was involved. The engine now proves every pinned port is bindable before it starts any container, and refuses the run with an Environment error naming the port, the container port it publishes, the declaring service and the socket error — measured at 8ms for the check itself, against the 3m15s the same suite previously spent failing. The set of addresses it probes is chosen per platform, and that is load-bearing rather than tidy: on Linux and BSD a held wildcard bind conflicts with its own loopback sibling, so probing both there would refuse a port nothing holds, while on Windows the wildcard does not conflict — and probing only the wildcard there would pass a port a squatter already holds on loopback, which is the false pass the check exists to prevent. It names a cause only where the socket error establishes one: a port that is in use and a port that is reserved or privileged fail differently, and telling an author to go and stop a process that does not exist is worse than telling them nothing. Two entries pinning the same host port, in one service or across two, are refused by name rather than left to collide. It never falls back to a different port: the whole reason a port is pinned is that something outside the engine already names that number. The check is best-effort by nature — it proves the port was free an instant before the orchestrator binds it, not that nothing takes it in between.

  • New public member ServiceSpec.PinnedHostPorts on the packable Vouchfx.Engine.Authoring assembly: the host port each pinned container port publishes on, keyed by container port, and null when a service pins nothing. Recorded here because that is this file's own rule for an addition to a packable surface. It is an init-only property rather than a positional record parameter, so it is purely additive and no already-compiled caller's constructor or Deconstruct changes. ServiceSpec.Ports is unchanged and still lists container ports only — every existing reader of it wants that half.

  • A published security compatibility matrix answers "can vouchfx reach my secured X?" per integration, covering all 25 step families and all 13 dependency kinds, at vouchfx.io/security-matrix. It leads with the constraint that surprises people most, because it decides whether the rest of the page applies: where vouchfx starts the container it stages only the client side, so running a secured broker means supplying your own image and its server-side material — you configure the server, vouchfx configures the client. Each row is derived from the code rather than from intent, and the page says on what basis. Two answers there are worth knowing before you plan around them. A service declared by project: cannot declare security:, because its endpoints come from its own launch profile and the engine has none of its own to give an https scheme — which does not make such a service plaintext by definition, and the page names the state that leaves open: addressed over TLS with no engine-configured trust. And for the stores whose clients carry TLS in a connection string, "supply your own connection string" is not a workaround: connection strings are engine-built, and a variable name may not begin with conn:: precisely so an author cannot overwrite a staged one — the client-side mechanism exists, which is why widening is cheap, but the engine does not yet stage it.

  • endpoint: selector for project:-form services — a new optional field on environment.services.<name> that names which of a project's discovered launch-profile endpoints the engine stages as svc::<name>, matching by endpoint name under case-sensitive Ordinal comparison. Omit it and the engine's fixed preference rule applies unchanged (plaintext http, else https, else first declared). An unmatched endpoint: value is refused at topology-build time, naming every endpoint the project declared. endpoint: is project:-form only — declaring it on an image:-form service is rejected at schema time. Selecting an https endpoint changes which listener is addressed and nothing else: the engine configures no client trust material for it, and a handshake failure is an environment error that exits code 0 by default. A terminal advisory announces that absence whenever such a service is addressed over https; because it fires just as readily where no endpoint: was declared at all, it is recorded on its own under Changed below rather than here. New public member ServiceSpec.Endpoint comes with it on the packable Vouchfx.Engine.Authoring assembly, recorded here for the same reason ServiceSpec.PinnedHostPorts above is: it is an init-only property rather than a positional record parameter, so it is purely additive and no already-compiled caller's constructor or Deconstruct changes.

  • The deployment's three stated requirements are now proven in ONE suite against ONE topology, with the servers' own evidence that authentication happened. A single run brings up a customer-shaped Kafka broker and a TLS-terminating HTTP API side by side, both secured, both confirmed by the pre-run probe, and exercises: container configuration through env:; a REST call presenting a client certificate to a service whose own certificate chains to a private CA; and a Kafka consumer over mutual TLS — the customer's requirement is verbatim the consumer, and it needs the broker's advertised address to be reachable from the engine host, exactly as the producer beside it does. Host-port pinning is what makes that address writable at all: until a host port could be pinned there was no value the broker could advertise that was true outside its container. Every step green, flagless exit 0. The env: value is not merely declared, and it does not start in the suite: it starts in the engine host's own environment, which is the shape a regulated deployment actually uses — the CI system holds the value, and the suite only names it. From there it resolves through an ${env:...} reference at topology-build time, the API renders it from the container's environment, the REST step captures it, the publish step carries it forward and the consumer matches it against a constant. A passing run is unreachable unless the variable made that entire journey: an unset variable fails the run naming the variable, and a reference that resolved to nothing would render verbatim into the response and fail the consumer's match. Evidence, in assertions rather than console output, because a green verdict is also what a fixture with verification switched off produces: the run asserts the broker's own record naming the principal it authenticated, and the API's own access log recording the client's distinguished name and a successful verification. Neither can be produced by anything on the engine's side of the connection.

  • Authorisation enforcement is now a control of its own, and it is the one negative in this series that exits ZERO. A client certificate perfectly valid for the mutual-TLS handshake but granted nothing by the broker's own access-control rules produces a run whose pre-run probe legitimately passes — authentication succeeded, and the probe has neither the means nor the remit to check per-topic authorisation — and whose step then fails with the broker's refusal, classified as an ordinary environment error rather than the security-confirmation carve-out. So vouchfx run exits 0 with no gating flag and 3 with --fail-on-env-error, both measured on real processes. That inversion is the point: every other negative in this series aborts before any step and exits non-zero, and only this pair distinguishes "TLS is working" from "authorisation is actually enforced" — ruling out a broker that authenticates everyone and authorises everyone. The broker-side configuration is the deployment's own responsibility and the fixture's here; the engine neither provisions access-control rules nor inspects them, and what these rows measure is that a refusal surfaces as a legible step-level environment error rather than a pass, a crash, or a security failure.

  • Both false-assurance traps the confirmation probe exists to close are now proven against a live broker, not only against a stub. Docker-gated acceptance drills stand up a real confluentinc/cp-kafka:7.6.1 under environment.services and vary exactly one declared value per pair, each pair sharing one positive control. A plaintext listener open beside the secured one: with security.endpoint naming the broker's plaintext port while the same broker genuinely serves TLS on the other, the run aborts at the probe — not at any pre-topology check — with an EnvironmentError naming both the port and the broker, and executes no step. A keystore delivered to a path the broker's startup logic does not check: the artefact arrives exactly where it was declared, the host-side existence check cannot see the fault because the host file is genuinely there, the container reaches healthy with no secured listener at all — and the probe still aborts before any step. Both drills capture the broker's own record of which listeners it opened and where the keystore actually landed, so each negative is paired with evidence that its own trap was real. The control, run against the same fixture with the endpoint naming the secured port and the keystore at the checked path, confirms at the AuthenticatedRoundTrip level — the broker answered a Kafka ApiVersions exchange over the secured connection and refused the same exchange from a connection presenting no client certificate — and its step then executes.

Neither losing the declared client identity nor substituting someone else's lets that suite pass, and the two are caught at different stages — which is what makes them two controls rather than one. Deleted: the host-side existence check refuses the suite at validation time, naming the field and the declared file, and both vouchfx run and vouchfx validate exit 4 with no flags — validate's 4 being its own code for an invalid document, not the carve-out's doing. No container is created: the suite is a single scenario whose only verdict is a pre-topology one, so the run completes without a topology being built at all, and a container snapshot across the run corroborates it. Present but issued by an unrelated authority: the existence check has nothing to object to, the topology comes up, and the run aborts at the confirmation probe before any step, with a flagless vouchfx run exiting 3. The engine reports that the TLS handshake completed and the broker then declined to answer over it; the drill additionally reads the broker's own log, which records refusing the connection at the certificate layer and never names the foreign identity as one it authenticated — while the positive control's broker log names the declared client by principal. That pairing, not the engine's own message, is what distinguishes "the peer rejected this identity" from "the endpoint was not speaking TLS". The two codes differ because the verdicts do — a rejection before any topology exists is not an infrastructure fault — and each keeps the code its own verdict names.

The exit code is measured on a real process, because a process exit code is what the security-confirmation carve-out is about: each row also runs the built CLI as a subprocess with neither gating flag. Both security failures exit 3; the control exits 0. The control is what makes that a measurement of the carve-out rather than of the fixture — and it makes the point more sharply than a passing run would, because its scenario does not pass either: its step runs, its verdict is not Pass, and a flagless run still exits 0, because only a security-confirmation failure gates CI without a flag. Two non-passing runs, one exiting 3 and one exiting 0, separated by exactly the signal the carve-out keys on. Nothing about the engine changed for these drills; what changed is that the claim is now measured rather than composed.

  • A runnable mutual-TLS example, and the CI hook it needsexamples/security-mtls.e2e.yaml demonstrates both halves of the authenticated-infrastructure shape against one topology: a http.rest call presenting a client certificate to a TLS-terminating nginx service, and a mq-publish.kafka/mq-expect.kafka pair over a mutually-authenticated broker, with both services chaining to one private certificate authority. The API's own report of which client identity it verified is captured, asserted immediately by a script.csharp step, and carried onto the topic. The two legs deliberately do not carry the same grade of evidence, and the example says so at length rather than claiming otherwise: the broker leg is measured by the engine independently of the broker's cooperation (AuthenticatedRoundTrip — a completed round trip plus a refused anonymous one, which fails closed before any step), while the API leg is TransportConfirmed plus the service's own report about itself, which is trustworthy here only because the nginx configuration producing it ships beside the suite and can be read. Measured: an nginx with verification switched off and the expected response hardcoded passes with both strict flags set — so the example spells out that an application-layer claim about authentication is exactly as trustworthy as the thing making it. The certificates are produced by examples/security-mtls.setup.sh (openssl) or examples/security-mtls.setup.ps1 (.NET, via PowerShell 7), each using the tool its own platform guarantees; they are written to a git-ignored directory and never committed. A pre-run script is the only option available, not a workaround: every path under a security: block is resolved and existence-checked before the first container starts, so no step inside a suite is early enough to create the material. To let that script run in CI, the reusable GitHub Actions workflow gains an optional setup-script input and the GitLab template an equivalent VOUCHFX_SETUP_SCRIPT variable — a script run on the same runner, after checkout and before the suite, that must exist and exit 0. Both default to empty, so no existing caller changes behaviour. .github/workflows/vouchfx-run-examples.yml discovers examples/<name>.setup.sh by convention rather than naming this example, and ExamplesCompileTests honours the same convention — that gate also now resolves each example against its OWN directory rather than the test process's current directory, so relative paths a suite declares are checked where a real run would look for them.

  • env: on a managed dependencyenvironment.dependencies.<name>.env accepts an environment-variable map, so a managed resource whose image is configured that way can be configured at all (e.g. MSSQL_COLLATION on the SQL Server image Aspire pins). Values take the same shapes a service's env does, including bare numeric and boolean scalars retained as literal text; an explicit null (FOO: ~) is rejected. ${env:NAME} is supported on the same contract as a service's — resolved from the engine process's environment before the container starts, failing the suite by name if unset. ${conn:…} is refused, naming the reference: a dependency is a connection source, not a consumer, and barring it removes inter-dependency cycles outright. ${secret:…} is refused, on the same reasoning as the service rule — a container's environment is readable by anyone who can run docker inspect, so it is the wrong place for a secret whenever it would resolve. An entry naming a variable the engine itself sets for that dependency type (minio root credentials, elasticsearch discovery/heap, the azureservicebus emulator's SQL wiring) is refused when the topology is built, before any container starts, naming the variable, the dependency and the type. Like the other two refusals this check runs only on the run path — vouchfx validate never builds a topology and does not report it — and is reported as Inconclusive — and, since #369, never exiting 0, because the refusal starts no container and runs no step. The engine relies on those values to bring the dependency up in the shape every scenario shares, and on minio they are the credentials ${conn:…} advertises to every other scenario consuming that dependency; variables set by Aspire internally are not detected and will take the author's value instead.

  • transport-notice — the two transport advisories, as a record on the JSON Lines event stream. Both advisories a project:-form service can raise were terminal-only: the engine selecting a plaintext listener while an https one was also available, and a run addressing an https listener the engine configures no client trust material of its own for. A CI job reading the artefacts saw neither — which matters most for the trust advisory, because the handshake failure it warns about lands as an Environment error and exits 0 by default, so the artefacts showed a green run with no explanation in them. Each advisory now also emits a record wherever it is printed — records emitted equals advisories printed, at every site — carrying kind (plaintext-downgrade or no-engine-trust), service, selectedEndpoint, and — on the downgrade kind only, since nothing is rejected in the trust case — rejectedEndpoint. Both endpoint fields carry endpoint names, never a resolved URL or a host:port authority. A fifth field, replayed, is written as true only where --watch re-reports an advisory raised by an earlier topology build, and is otherwise absent from the wire rather than written as false; read an absent field as "not a replay". Emission moves no verdict and no exit code, and happens whether or not a renderer is attached. TransportNoticeEvent is inside the frozen v1 event-wire contract and covered by its golden gate; a run with no advisory emits no record at all, so such a stream is byte-identical to one written before the record existed. Three additions to the packable Vouchfx.Engine.Abstractions assembly come with it, recorded here because that is this file's own rule for an addition to a packable surface: the record TransportNoticeEvent itself; the constant EventTypes.TransportNotice, holding the wire type string; and the class TransportNoticeKinds, holding the two kind tokens as constants alongside an IsKnown predicate — a consumer branching on type or on kind should reference those rather than hard-code the literals, and IsKnown is what distinguishes a token this engine version emits from one a newer engine added.

--events and --events-stream only, and that scope is the whole of what this adds. The JUnit and HTML renderers take their default: arm on an event type they do not recognise — the §14 forward-compatibility guarantee every renderer carries — so a pipeline whose only artefacts are --junit and --html still gets a green report with nothing in it about the transport; rendering the record in those two is a separate change and is not part of this one. --watch is wired to no report artefact whatever (--events, --events-stream, --junit and --html alike), so replayed is a property of the record rather than of anything a watch run writes.

Two things a consumer needs to know before parsing it. First, kind is the second wire property of that name — step-started already carries a kind holding the step type, e.g. mq-expect.kafka — so branch on type before reading kind. Second, where one topology serves a whole suite, the record's envelope runId deliberately resolves to no scenario: the advisory belongs to the topology, which outlives every scenario in it, and attributing it to one named scenario would state something false about the others. service is the correlation key. A consumer counting distinct runId values to count scenarios over-counts on that path, by one per topology build that raised an advisory. - CSX helper Source bodies are pinned by hash in the SDK contract freeze gate. SdkContractFreezeTests gains a companion golden, vouchfx-sdk-helper-sources.v1.txt, recording a SHA-256 of every public const string Source in Vouchfx.Sdk — today SecretHelper, SubstituteHelper, SecurityHelper and KafkaSecurityHelper, discovered by shape rather than by name so a later one is pinned as soon as it follows the convention. The existing signature golden could never see this: it records field const System.String Source, which is byte-identical whatever text the constant holds. That text is nonetheless part of the frozen v1 surface, because a const inlines into every provider assembly at that assembly's compile time and CsxAssembler deduplicates helpers by exact source text — so within v1.x a body edit breaks any suite mixing a provider built against an older SDK with one built against the newer, and it now shows up as a deliberate golden change reviewed under the same VOUCHFX_REGEN_SDK_CONTRACT flag rather than passing unnoticed. - DCP diagnostics are now captured automatically whenever a topology fails to become ready (#420), and that capture root-caused #420. The fault presented as an intermittent, Windows-host-only, self-clearing failure inside Aspire's DCP orchestrator - every container port it tries to publish fails (Unable to allocate a network port for service '…', then Service '…' should have valid address at this point) while a plain docker run -p 0:80 publishes fine. Two attempts to capture the golden evidence — which port DCP tried and which OS error came back — failed for the same reason both times: the fault cleared before an operator could raise Logging__LogLevel__Aspire_Hosting_Dcp=Debug and re-run, and even mid-fault those Debug lines did not reach dotnet test's output. A diagnostic that has to be switched on after the fault appears cannot capture a fault that clears before it can be switched on, so the engine now keeps a bounded in-memory recorder armed on every topology start: 512 entries or 128 Ki characters, whichever binds first, oldest evicted, with the eviction count written into the capture so a truncated record says so. The cost of keeping it armed on every run is below measurement noise, which is the number rather than the adjective. Measured on a real Docker topology start (one container, through the production path), armed versus VOUCHFX_DCP_CAPTURE=0, five interleaved pairs on a warm host: median 6.012 s armed against 6.082 s disarmed — armed 70 ms faster on the median and 170 ms faster on the mean, with the disarmed run-to-run spread (496 ms) alone larger than the difference between them. So the formatting, ASCII-folding and locked enqueue of the DCP Debug traffic costs nothing detectable against a ~6 s startup. The interleaving is load-bearing and is reported because the naive form misleads: running all four armed measurements before all four disarmed ones produced an apparent 5.7 s armed penalty that was entirely a host warm-up trend (16.5 s down to 6.1 s across the sequence, continuing straight through the switch). The arming window spans the health gates, not just StartAsync — the fail: in #420's transcript is a console level token rather than proof of an exception leaving the start, so Aspire may be catching that throw internally and letting the fault surface later as a gate timeout; a window that closed when the start returned would buffer the golden evidence and then discard it on exactly the fault it exists to capture.

On failure the whole buffer — DCP Debug traffic included — is written to a per-user file (%LOCALAPPDATA%\vouchfx\dcp-capture-<utc-timestamp>.log; ~/.local/share/vouchfx/ on Linux and macOS, owner-only where the platform has file modes, newest twelve kept because the last occurrence was eight consecutive failures in one session and a smaller bound would delete the evidence that session produced). Where the engine classifies the failure itself — the topology start, a health gate, or service discovery — the resulting Environment error names the file as a platform token (%LOCALAPPDATA%\vouchfx\<name>, never the resolved path, which would carry the operator's account name into the world-downloadable report.html and results.xml artefacts) and quotes a bounded tail of the warning lines inline, so the evidence also survives in a CI log whose runner filesystem does not. The secured-endpoint probe and the seed are the two exceptions and the troubleshooting guide says so: each builds its own error where it raises it, before the capture is written, so those runs get the file without a pointer to it. Where no per-user directory exists the engine writes nothing and says so, rather than falling back to a shared temporary directory. VOUCHFX_DCP_CAPTURE_DIR redirects captures to an absolute directory of the caller's choosing - the companion switch for CI, where the per-user directory is discarded with the runner and a capture written there is destroyed unread; a relative value is refused rather than silently downgraded. A failure carrying #420's signature additionally gains a short note — on Windows, that it is a known host-level fault, measured not to be port exhaustion, and not transient, so a re-run will fail identically and the state-store directory named later in this entry is what to check; on every other platform, that the signature is recorded but has never been reproduced there, so it should be reported rather than re-run past.

On ready the recorder is dropped: buffer cleared, no file, no output, and every later log statement returns before it formats anything. Opt out with VOUCHFX_DCP_CAPTURE=0 (that exact value). Nothing about the verdict taxonomy or the §14 wire moves: the failure classifies as the same Provision Environment error it always did, and only the content of the existing detail string grows. The recorder's own log-filter rules are scoped to its provider, so the console keeps exactly the levels it had.

What a capture can contain, stated precisely because over-warning would defeat the feature. It is a verbatim record of Aspire's start-time logging, so it can hold Aspire's generated per-run dependency passwords (throwaway, destroyed with the container), the fixed local test credentials a suite declares, any host value the author routed in with ${env:NAME}, and absolute host paths. It cannot hold a resolved ${secret:…} value: EnvironmentMapper refuses that sigil outright, case-insensitively, in both services[].env and dependencies[].env, so no resolved secret reaches a container specification for this layer to log. Everything on that list is already visible to docker inspect on the same machine, which is why the file is owner-only, local, never uploaded, and worth attaching to #420 after a skim. The tail quoted inline in the Environment error passes through the same redaction chokepoint as every other environment-error detail, but it arrives there truncated and ASCII-folded, and that redaction matches values exactly — so it is best-effort redacted, which the penetration suite now pins in both directions. The recorder was armed for its own subject, and #420 is now root-caused because of it. On its first live encounter it captured what two earlier hand-instrumented attempts had missed, and the cause was established from that capture: DCP's controller host refuses its state-store directory for invalid ownership and exits code 1 about 130 ms in, so nothing allocates ports; Aspire then waits for an allocation that never arrives and gives up on a fixed 60-second timeout, twice, which is the constant ~2 minutes before the throw. Unable to allocate a network port is Aspire's downstream wording for that, logged under Aspire.Hosting.DistributedApplication — a category the DCP-prefixed capture rule would have missed, so the belt-and-braces rule earned its place on the first occurrence. The remedy is verified: move the offending ~/.dcp state store aside (an ELEVATED run uses state.elevated), and DCP recreates it — the previously-failing suite went from a 2-minute failure to green in 23 seconds on the same host. Moving it aside is not durable if you keep running elevated, also measured: the recreated state.elevated was given to BUILTIN\Administrators rather than to the running account and DCP refused it again immediately. Re-owning the directory (takeown) rather than deleting it is the remedy that follows from that mechanism, and the troubleshooting guide gives the exact command — but it is inferred, not executed: what was verified end to end is renaming the directory aside and running non-elevated. The fault is in DCP, a closed binary this repository does not own, so the engine does not fix it; the known-fault note now names that cause and remedy instead of the earlier advice to re-run, which was written when the fault looked transient and would have sent an operator round a loop that never converges. When the capture shows this specific refusal the remedy is surfaced inline in the Environment error itself. The troubleshooting guide carries the operator-facing recipe.

Changed

  • Every diagnostic string the CLI and the engine print is now ASCII (#379). Em dashes, ellipses, arrows and section signs are gone from the text of --help, of every validation, schema, orchestration and security diagnostic, and of the scenario-level causes that reach the JSON Lines event stream and the HTML and JUnit reports: - for a dash, ... for an ellipsis, -> for an arrow, section 12.1 for a section reference. 127 literals across 35 files. This is author-visible — the wording is identical and the punctuation is not. A pipeline grepping vouchfx output for a phrase that spans one of those characters must be updated; a pipeline matching on the surrounding words is unaffected.

The motivating trap is worth more than the tidiness. Measured differentially inside a single trx: the same security-confirmation line, from one source string, kept its em dash on the in-process row and lost it on the CLI-subprocess row. On Windows Console.OutputEncoding defaults to the active codepage rather than UTF-8, and an em dash with no representation there best-fit-maps to a hyphen — not to the usual ?, which is exactly what makes it invisible. Nothing broke, because the existing assertions on CLI output were deliberately ASCII-only. The next one would not have been: an assertion spanning an em dash fails on such a host with a diff between two strings that render identically in a terminal, and the cause is nowhere near the assertion. It is also user-visible beyond tests — a run piped to a CI log keeps the mangling in the artefact that is kept.

Setting Console.OutputEncoding was considered and not chosen: it trades one mangling for another on a terminal that cannot render UTF-8, and has to be reasoned about separately for redirected output. The strings did not need the characters.

The class is held closed by a source census (AsciiRuntimeOutputCensusTests), which parses src/Cli and src/Engine with Roslyn and fails on any non-ASCII character inside a string or character literal, reporting every offender at once. Roslyn rather than a regex because comments and XML documentation are trivia and never appear as tokens — so the line between prose, which keeps its typographic punctuation deliberately, and output, which may not, is drawn by the compiler. The boundary is structural: everything under src/Providers and src/Sdk is outside it and some of that is still runtime-reachable. The inventory of what remains — diff-renderer tables, schema description and $comment strings, and provider validator and observation literals, which travel the same channel as the engine strings this entry fixes — is held in issue #472 rather than listed here, because a list in a changelog cannot be kept true and a rule can.

Every truncated diagnostic in the engine got two characters longer, as a consequence rather than as a decision: the marker appended past a cap was the one-character ellipsis and is now .... No truncation POINT moved — OrchestrationErrorClassifier still cuts the underlying message at 256 characters, and SchemaErrorCollector and SecurityProfileRegistry still cut an offending value at 200 — so what an author loses is unchanged and only the marker is wider. The visible effect is on length bounds a consumer may have pinned: the longest environment-error detail is 259 characters rather than 257, and the truncated-value and truncated-enum-list markers in schema diagnostics read ... and ... and N more.

  • Breaking: httpPort on a project:-form service is now refused at validation. The field was accepted and silently ignored — it names a container port, not a listener — and never had any effect on a service form that auto-discovers its endpoints from its launch profile. It is a pre-GA narrowing of a field that does nothing, not a repair of a previously-working feature. An existing suite declaring it alongside project: now fails schema validation, and the refusal names its own remedy on the path an ordinary author takes: schema validation runs first on both vouchfx run and vouchfx validate, so the single error such a suite produces is the one that says a project's endpoints are discovered from its own launch profile, that httpPort names a container port rather than a listener, and that endpoint: is the field that chooses which discovered endpoint the service is addressed on. Nothing an author has breaks: no example, no documented snippet and no suite this repository accepts declares the combination — the only in-tree occurrences are the rejected-corpus fixture and the unit tests written to pin the refusal itself.

  • A project:-form service addressed over https now prints a transport advisory, in a suite whose author changes nothing. The advisory fires whenever the endpoint staged for such a service is an https listener and some step actually addresses it — including the https-only project that declares no endpoint: at all, where the engine's own fixed rule picks the sole listener the project offers. That suite ran silently before and now prints a line per affected service, naming the service and the selected endpoint and stating what the engine did not do: it contributes no trust anchor, pins no peer, presents no client identity and asserts nothing about the transport. A project:-form service cannot declare security:, so nothing else in the run says so, and the most plausible reading of an https address — "this is secured, and vouchfx checked" — is wrong in its second half. No verdict and no exit code moves with it. The advisory also reaches the JSON Lines event stream, as a transport-notice record naming the service and the selected endpoint, so a CI job reading --events or --events-stream can see it; the JUnit and HTML renderers ignore it (their default: arm — the forward-compatibility guarantee every §14 renderer carries), so neither of those two reports is any different. What the run does with the certificate is unchanged too — for a step that makes an HTTP request the platform's own trust store still verifies it, full chain, exactly as any other .NET HTTPS request would be verified, and a handshake failure on a host that does not already trust it is still an environment error that exits 0 without --fail-on-env-error. This is a disclosure of what such a run already did, not a new refusal. It is distinct from the downgrade notice recorded under #348 in Fixed below, which reports the opposite choice: that one fires only where no endpoint: was declared and the engine picked a plaintext listener over an available https one.

  • Breaking: the ${secret: sigil is now matched case-insensitively in env: values (#428). A lower-case ${secret:…} written into environment.services.<name>.env or environment.dependencies.<name>.env has always been refused — a dependency's or service's container environment is readable by anyone who can run docker inspect, so it is no place for a secret. But the comparison was Ordinal while the sibling ${env: sigil is deliberately IgnoreCase, so ${SECRET:vault/db/pw} matched nothing and reached the container as opaque literal text. No value ever leaked — nothing in the engine resolves that spelling either — but the author saw a green suite and believed a secret had been delivered, which is the failure this refusal exists to prevent. The match is now OrdinalIgnoreCase. Widening is safe here in a way it would not be on a secret-supporting field: env: accepts no secret reference in any case, well-formed or not, so a case-insensitive match can only ever turn a silent pass-through into a refusal. This reddens a previously-green suite that wrote the sigil in any casing other than lower-case. The diagnostic wording is deliberately unchanged — it is pinned byte-identical by an existing test and mirrored in the DSL spec, and "references a ${secret:...} value" names the fault correctly whatever case was typed. The remedy is unchanged too: use ${env:NAME} for a value the engine host can look up, a literal for one known at authoring time, or a field the engine actually redacts, such as security.clientKeyPassword.

  • Breaking: a suite that parsed but never executed a step now reddens the run it used to pass (#369). A schema-invalid suite, a secret-reference failure, a malformed dependency env: and the both-families protocol conflict all abort before any topology is built — no container starts and no step runs — and all exited 0 by default. They now exit 4. This is the same reasoning as #425 applied one step further: the distinction the code drew was did the YAML parse, while the distinction its own remarks argued for was did anything execute. Scoped to Inconclusive, deliberately. A topology that fails to start also executes nothing and reaches the same completion path, but carries EnvironmentError and keeps its own --fail-on-env-error gate — widening this to every verdict would silently close #390, which stays open precisely because it would redden every suite whose unrelated container was slow to come up. A scenario that DID run and could not conclude (timeout, partition outlasted grace, upstream capture unmet) still exits 0 by default, because it executed.
  • Breaking: a document the engine could not read now reddens the run it used to pass (#425). vouchfx run <dir> over a directory holding one malformed .e2e.yaml beside one that parses used to exit 0: the parse failure was folded in as Inconclusive, which maps to Success unless --fail-on-inconclusive is passed. It now exits 4, regardless of that flag. This is not a new policy so much as the existing one applied consistently — an entirely-unparseable set already exited 4 unconditionally (#278), on the reasoning that a CI pipeline keying on run's exit code must never see an unparseable suite reported as clean. That reasoning never depended on whether a sibling happened to parse; the file was unread either way. The rule now keys on the parse failure itself rather than on how many files parsed, and the two spellings collapse into one. A genuine execution-time Inconclusive is untouched — a scenario that DID run and could not conclude (timeout, partition outlasted grace, upstream capture unmet) stays opt-in gated and still exits 0 by default, which is the §12.1 distinction this rests on: a file that could not be read is a deterministic authoring fault, not an undetermined outcome. A Fail still outranks a parse failure and still exits 1. This also closes #425's security case without any security-specific exit policy: a malformed document declaring mtls now reddens because it was unreadable, not because of anything it declared — so no raw-YAML scan for a security: key was needed (the engine refuses one, in two places, as a second spelling of "does this document declare security") and the security-assurance derivation is untouched. The accepted cost, previously declined and now taken deliberately: an unsecured suite that merely contains an unreadable file also reddens.
  • Breaking: the unconfirmable-security: rule is now derived once — from what a run declared and what it confirmed — so it reaches every refusal that leaves a declared target unconfirmed, and stops reaching a refusal the run confirmed past. It used to be a boolean each refusal site set for itself, and those sites are mutually exclusive early returns, so which one a document happened to reach decided its exit code: three adjacent pre-topology refusals gave three different answers to the same question about the same class of document, and one of them argued in its own comment that a protocol conflict "is an authoring error, not a failure to confirm a security assertion" and declined to raise, while a schema rejection in the same method widened. Both readings were sound in isolation and they cannot both be the rule. The wide one wins, and the narrow rationale is overturned deliberately and on the record rather than quietly dropped: which door refused is not consulted, because nothing downstream of a refusal ever runs to confirm the declaration. What now reddens — measured on the built CLI as a subprocess with neither gating flag, under run and run --parallel 1 alike: a secured document whose sole fault is a step-level ${secret:…} naming a source the engine cannot resolve, an unresolvable script.csharp file:, a ${conn:…} naming no dependency, or a target addressed by two protocol families now exits 4 on each of those four shapes, where each exited 0 before; the matching unsecured control — differing in nothing but the security: block — exited 0 on all four at the time. It no longer does: all four are refusals before anything executes, so #369 now takes the unsecured arm to 4 as well, and the security notice rather than the exit code is what distinguishes the two. A secured suite whose scenarios declare different environment blocks exits 3 where it exited 0, and does so whichever scenario carries the declaration rather than only the first; that code is the guard's own EnvironmentError verdict rather than a fixed one, which is why it differs from the directory-layout guard's 4, itself unchanged. The five "previously exited 0" halves of those claims are read from the archived ScenarioRunner.cs at the branch point, not re-run: each of those returns either accumulated a flag nothing had set or bypassed it altogether. The line explaining a non-zero exit is now printed wherever the rule raises rather than only for a schema rejection — a failed probe excepted, which already reports its own measured security failure. What stops reddening, the opposite direction, and the reason the rule reads confirmations rather than refusals: a shared-topology suite whose one refused scenario sits beside siblings that bring the topology up, where the probe then confirms every declared target, no longer raises. The last of that direction's three evidence labels has since been upgraded, and the entry says so rather than being left at its weakest reading (issue #410). The record-level behaviour on this build was already measured; the previous behaviour is still read from the archived source at the branch point, where the refusal set a flag that survived to the run's own completion; and the end-to-end exit code — which this entry recorded as unmeasured on both trees, because the shape needs a probe that succeeds and therefore Docker — is now measured on this tree by a Docker-gated row that brings the topology up: KafkaSecurityConfirmationDrillDockerTests.AuthoringRefusalBesideAFullyConfirmedProbe_ExitsZeroWithNoNotice. That row asserts the probe confirmed BEFORE it reads the exit code, so a topology that never came up fails it rather than passing it quietly, and it asserts the security notice's ABSENCE beside the 0 — the notice prints at exactly one site in RunCommand, whose guard is the conjunction Unconfirmed && Refusal is not ProbeUnconfirmed, so its absence is that predicate answering false once the row's own probe-confirmed premise has ruled out the guard's other conjunct, which it asserts before reading the exit code. The previous-tree half stays read: nothing re-runs the archived source. Exit 0 is right on the taxonomy as well as on the measurement: the declared assertion was confirmed, so what remains is an authoring fault, which is Inconclusive, and only Fail breaks CI by default (§12.1). Confirming some of what was declared is still not confirming it — a suite declaring two secured targets, one confirmed and one not, raises, and that companion is measured end to end too, by PartiallyConfirmedDeclarationBesideAConfirmedProbe_ExitsNonZero over the same baseline suite with the refused sibling's environment AND its declaration moved, so the row above cannot pass by the carve-out simply having been switched off. A third row, RejectedDivergentSiblingBesideAConfirmedProbe_RaisesThroughTheFold, covers the one shape where the suite-wide walk sees only confirmed identities and the divergent-scenario fold is the only thing that can raise — the wiring ScenarioRunner could not observe without a container. That third row is shown to measure the wiring rather than the suite, by mutation: replacing the fold's term in the suite's answer with the identity value takes it from exit 4 to exit 0, and it fails, while the second row stays green — so the two rows genuinely separate the fold from the canonical union rather than both riding on whichever raises first. All three fixtures' premises (schema-rejected outside the declaration, still a scenario rather than an unbuilt document, diverging in environment and identity exactly as each row's name claims) are pinned Docker-free beside them, so a fixture that quietly stopped testing anything reddens on every machine rather than only where Docker is healthy. Issue #390 is untouched and deliberately so: a secured suite whose only fault is a topology that reached the health gate and failed it still exits 0 — the gate failure does not clear a refusal the same run already recorded, so a suite carrying one exits non-zero on that refusal.
  • Breaking: a broken copy of a working secured file now reddens the run it used to pass (#415). Stated as its own entry rather than folded into the fix below it, because it is the one shape in that fix where a default CI colour moves: a directory whose secured .e2e.yaml files declare the same targets — the arrangement the engine's own shared-environment requirement encourages, since every scenario of a suite must declare a byte-identical environment block — and one of which is refused for its contents (an unknown step type, a duplicate step id) while its siblings come up and their probe confirms those targets. That run exited 0 on the default run path with neither --fail-on-inconclusive nor --fail-on-env-error, and now exits non-zero on both run paths. That pair of exit codes carries the same evidence label as the entry above it, and for the same reason: this shape's defining premise is siblings whose probe succeeds, and a succeeding probe needs Docker. Measured on this tree: the record-tier fold — each unbuilt document contributing one whole assurance with its Confirmed set empty by construction, on both run paths, so no sibling's confirmation can satisfy its declaration — together with the pre-topology doors and both run paths agreeing on every shape reachable without a container. Read, not re-run: the previous exit 0, from the archived source at the branch point, where such a document's declaration was matched to a sibling's confirmation by target name and its refusal folded in as Inconclusive. Measured end to end too, and that label is an upgrade made the same day: the Docker-gated drill written for exactly this shape — KafkaSecurityConfirmationDrillDockerTests.SecuredUnbuiltSiblingBesideAConfirmedProbe_ExitsNonZeroOnBothRunPaths — now passes. Two topologies came up, one per run path, each probe reaching AuthenticatedRoundTrip on a different host port (the broker accepted the declared client identity and refused the same request on a connection presenting none); the sibling was refused for its duplicate step id; both arms exited non-zero and printed the security notice. The row asserts that probe confirmation BEFORE its exit code, so a topology that never came up fails it rather than passing it quietly. It had failed three times earlier the same day for a host reason unrelated to this change — Aspire/DCP could not allocate a container host port, and the drill's own unmodified positive control and an unrelated Kafka row failed identically. What is still not claimed: that the row will keep guarding this. CI's integration job is continue-on-error: true, so a future break here is a non-blocking orange nobody must read. Issue #410's separate confirmed-probe-narrowing rows have since been written and measured green on this host, beside this one and over the same fixture; what stays unclaimed is the CI half, for the reason just given. Exit non-zero is right on the taxonomy and on the record-tier measurement, not on an observed process exit. The old exit 0 was not a considered decision and is not being traded away for one: it fell out of the refused document folding into the suite verdict as Inconclusive, which is gated behind --fail-on-inconclusive, combined with its declaration being matched to a sibling's confirmation by target name. Nothing had established that the refused file's declaration was ever exercised. The fail-closed direction was chosen deliberately: its worst case is a suite containing a broken secured file going red — a file its author must fix regardless — against the alternative's green pipeline on an mtls assertion nothing ever ran. A pipeline that has been green on such a directory will go red on the next run, and the file it names is the one to fix. Unsecured suites are untouched: a file declaring no security: block moves nothing, on either run path.
  • Breaking: a document that spells one mapping key twice is now refused outright, closing the remainder of #417. YamlDotNet's own duplicate-key check compares scalar key nodes, and a node's identity includes its YAML tag — so !!str environment: beside a plain environment: loaded without a word, and the engine's two YAML front-ends then read two different documents. Measured over that shape on the previous build: the parser bound services: [tagged] (its key lookup scans forward and takes the FIRST occurrence) while the schema validator reported Unknown property 'totallyBogusKey' on service 'plain' — an error inside the SECOND occurrence, on a service the parser never saw, because that front-end is last-wins. The earlier half of #417 taught the parser's key lookup to compare by value rather than by tagged node, which moved the divergence from which tag wins to which duplicate wins rather than ending it. The parser now walks every mapping in the document before binding anything and refuses one holding two scalar keys with the same value, whatever tag each was written with — including keys it never looks up by name, such as a duplicated service name. This can redden a previously-green suite, since such a document used to parse (binding one of the two occurrences silently) and a document the engine cannot read now reddens the run per the #425 entry above. The exposure is narrow: only an explicitly tagged key reaches it, because the loader already refused the quoted spellings ("environment":, 'environment':) beside a plain one, and re-parsing every .yaml/.yml in this repository — 185 files, examples and fixtures included — refused none of them that parsed before.
  • Scalar and map-valued fields across every Core provider widened to match what each provider's own Bind already accepts at runtime — a non-breaking widening, not a narrowing. Every additionalProperties: {"type":"string"} map declared by a provider (headers, parameters, properties, labels, expect.row, expect.document, expect.item, expect.metadata, avro.record, match.headers, match.json, expectProperties, and their siblings — 24 fields across all 25 providers) now accepts ["string","integer","number","boolean"]; the named comparison-VALUE scalars a provider reads back as raw text regardless of the YAML value's own declared type (payload on all five mq-publish.* providers, key on mq-publish.kafka/match.key on mq-expect.kafka, expect.value on cache-assert.redis, expect.xpath[].value on http.soap (bound via the same GetScalar raw-text read as its sibling .path), payloadContains/expectPayloadContains/bodyContains/contentContains/subject-contains/body-contains) widen the same way; and the int/long-parsed integer fields — expect.status on http.rest and http.soap, expect.rowCount on the three SQL db-assert.* providers, expect.count on cache-assert.elasticsearch/mail-expect.smtp/db-assert.mongodb, expect.min-count on cache-assert.elasticsearch, and expect.length on cache-assert.redis — ten fields in total (expect.min-count is one of the ten, not an eleventh field beyond them) — now accept ["integer","string"], with any declared numeric bound (minimum) kept, guarded by a new pattern: "^[0-9]+$" alongside the type union.

The map/value-scalar widenings above genuinely already worked at runtime exactly as described: the affected Bind reads the field back via a raw YamlScalarNode cast, as opaque text, regardless of how the YAML value was written. The ten integer fields are not the same claim, and an earlier draft of this note overstated them identically — a quoted numeric string ("200") already worked, but a non-numeric string ("abc", or an unresolved {placeholder} token — none of these ten fields' Bind applies placeholder substitution before the numeric parse) did not "work": int.TryParse/long.TryParse failed silently and the assertion was never applied, a silent pass rather than an error, and the type widening alone — with no further guard — would have legalised writing exactly that at schema level too. The new pattern closes that gap: it still accepts every quoted numeric string the type union was widened for, and still rejects "abc"/"2xx"/an unresolved placeholder — identically to what the pre-widening, string-rejecting schema also rejected for that non-numeric text, so this is parity with the old schema's outcome on those inputs, not a regression, reached by a different mechanism ([pattern] instead of [type]).

Fields that name a declared or addressed resource rather than carry a comparison value (target, topic, queue, subject, stream, routingKey, table, bucket, collection, index, a Redis/S3/DynamoDB key, traceId/service/spanName, to) are deliberately left string-only — an identifier an author would never plausibly write as a bare YAML number. The four fixtures previously pinned by SchemaAcceptedCorpusTests as "the engine accepts this, the schema rejects it today" now validate; that pinning theory (and its now-empty discovery method) is retired and the fixtures moved into the plain accepted corpus. - Breaking: eleven previously-open nested blocks across nine Core providers now reject unknown keys. unevaluatedProperties: false on $defs/step (added in a prior release) does not recurse into nested objects, so a typo inside any of these blocks previously validated silently: expect on http.rest and http.soap (plus http.soap's own expect.xpath[] array items), match on mq-expect.kafka/rabbitmq/nats/redis, trace-expect.otlp, and webhook-listen.http, and avro on mq-publish.kafka and mq-expect.kafka. Each now closes with a plain additionalProperties: false — a REPLACEMENT of the eight blocks that previously declared their own additionalProperties: true (the remaining three — http.rest's expect, http.soap's expect, and http.soap's own expect.xpath[] array items — had no additionalProperties keyword at all before this change, a pure addition, not a replacement; eight replacements plus three additions is the eleven), never a false added alongside a retained true (the same same-object-cancellation trap the step-level closure's own regression guard documents applies one nesting level down). A document with an unrecognised key in any of these eleven positions that previously validated now fails at that exact location with an actionable [additionalProperties]-tagged message. - Breaking: constraints previously enforced only by a provider's own runtime Validate now also reject at schema/authoring time, across all 25 Core providers. Every required string field gains minLength: 1, mirroring the empty-string rejection exactly (an empty target: "" etc. now fails schema validation instead of a provider's own runtime check) — a whitespace-only value (e.g. target: " ") is a deliberate boundary, not an oversight: minLength counts characters, so it still passes schema, and is still caught at the provider's own Validate (IsNullOrWhiteSpace); the schema is looser than the provider here, never tighter — the same two-gate division of labour as everywhere else in this release, with exactly three deliberate exceptions: the genuinely NEW rejections named at the end of this entry, where the schema is tighter than a Validate that never covered those shapes at all. http.rest/http.soap/metrics-assert.prometheus's path gains an SSRF-guard pattern (rooted-relative only: no absolute URL, no protocol-relative //, no backslash). mq-publish.azureservicebus now requires exactly one of queue/topic. mq-expect.azureservicebus now requires queue XOR (topic + subscription together — enforced by both a oneOf and a dependentRequired pair, the latter for a more specific message when only topic is set) plus at least one of expectPayloadContains/expectProperties. cache-assert.redis now requires field when operation: hget, and requires the expect member matching each of the seven operations (value for get/hget, exists for exists/ttl, length for hlen/llen/scard). db-assert.postgres/sqlserver/mysql now require expect.rowCount and/or expect.row; db-assert.mongodb now requires expect.count and/or expect.document. db-assert.dynamodb's expect.exists: false now forbids expect.item. storage-assert.s3's expect.size and expect.minSize are now mutually exclusive, and expect.exists: false now forbids all six content expectations (size/minSize/sha256/contentContains/contentType/metadata). metrics-assert.prometheus.expect now requires at least one of value/min/max. mail-expect.smtp's expect.match, and — now that the block closes (see above) — mq-expect.kafka/rabbitmq/nats/redis and webhook-listen.http's match, each now require at least one declared criterion. mq-publish.kafka's avro.record now requires at least one field. script.csharp's code is now capped at 64 KiB, mirroring the provider's own existing runtime bound. Nearly every constraint above was already enforced by the corresponding provider's Validate method; for those, a document that previously failed at compile time with the provider's own message now fails earlier, at schema validation, with a schema-native message instead ([required]/[oneOf]/[dependentRequired]/[minProperties]/[maxLength]/[minLength]/[pattern]/[properties]). Three of the constraints above are genuinely NEW rejections, not a re-timed lift — found only by re-checking each Validate method line-by-line against its schema counterpart, since an earlier draft of this note claimed no new rejections existed at all: http.soap's expect.xpath[].path gains minLength: 1 where Validate never inspects expect.xpath at all — the array, and every field inside each of its entries, was previously unvalidated at authoring time full stop, only surfacing as a runtime XPath-engine failure against a possibly-empty expression string. db-assert.dynamodb and storage-assert.s3's exists: false-forbids-content checks are presence-based in the schema (the key itself may not appear, regardless of what it contains) but were count-based in Validate (is { Count: > 0 }) for one field on each provider — db-assert.dynamodb's expect.item and storage-assert.s3's expect.metadata alone (the other five S3 content fields were already presence-checked in Validate, is not null, so those five are exact parity, old check and new schema agreeing). Concretely: expect: {exists: false, item: {}} — an explicit, empty map — previously validated (Count is 0, not > 0, so Validate's condition was false) and now fails schema validation, since the key's mere presence now trips the false sub-schema regardless of its content being empty; storage-assert.s3's expect: {exists: false, metadata: {}} narrows identically.

  • Breaking: closed target resolution for HTTP and metrics steps, narrowed to services only. http.rest, http.soap, and metrics-assert.prometheus now reject a target naming anything other than a declared service at validation time (vouchfx validate, before any container starts). An unknown target is rejected naming the target and listing what is declared; a target naming a declared DEPENDENCY is rejected too — these three providers resolve target exclusively against declared services, so a dependency target would otherwise validate and then always fail at run time. Previously, an unknown or dependency target was accepted and failed only at runtime (an opaque "bootstrap not found" environment error). Host-resource-contributed names (e.g. a webhook-listen.http listener) count as valid declared targets — but a host resource whose name collides with a declared service, or with a dependency's own sidecar endpoint (a mailpit SMTP sidecar, a kafka schema-registry sidecar), is rejected outright, naming both surfaces; such a suite previously validated and ran, with the listener silently shadowing the real target, so a step could report a Pass having never contacted it. This rejects suites that previously validated: four pre-existing test fixtures needed an added environment.services block to keep passing, the branch's own evidence that this narrows rather than adds.
  • Per-dependency image overrideenvironment.dependencies[].image field allows explicit specification of a container image for individual managed dependencies, bypassing Aspire's provisioned defaults. An image: carrying no tag or digest must be paired with a version: field; if both image: (with tag) and version: are set, the combination is rejected as ambiguous. A tagless image: without version: is rejected to prevent floating on :latest. Any image: value is used exactly as written, with the provider's registry default cleared, and imageRegistry applied on top only if the image carries no registry hostname of its own.
  • capture now has a real schema shape — each entry is either a bare scalar JSONPath expression or a single-key mapping ({ jsonpath: "$.id" } / { xpath: "//id" }). A non-scalar/non-mapping value, both keys present, neither present, an unknown key, and a non-scalar expression value were already rejected before this change — by ParseCaptureEntry (YamlDocumentParser), with a located parse error — and remain rejected identically today; on the CLI path the parser still reports these first, since it runs before schema validation is reached for a document that fails to parse. The genuinely new value here is authoring-time: the schema now expresses the same grammar, so a .e2e.yaml-aware editor can flag these shapes and offer completion for the two recognised keys (jsonpath/xpath) without invoking the compiler at all. A capture variable name beginning with an engine-reserved bookkeeping prefix (svc::, conn::, __outcome::, __capture_status::, __attempts::) is likewise now expressed in the schema — previously only AstBuilder caught this, at compile time — and the identical guard now also applies to top-level variables: keys, which AstBuilder always rejected but the schema never mirrored.
  • metadata.schemaVersion is now a real rejection hook — constrained to the literal "v1" (the only language schema version that exists); the field stays optional, so omitting it remains valid, but a document declaring anything else (e.g. schemaVersion: v2) now fails schema validation instead of being silently accepted and ignored.
  • A scenario's completion message now reaches every written artefact, closing a visibility gap in aggregated results (#372). Before this, only the terminal output carried a step-level EarlyMessage from the engine — the JUnit and HTML renderers, which are exactly where a triager consults when a run fails, built their own text from the scenario id, verdict token and step counts. A scenario refused before any step ran was reported as Scenario 'a' INCONCLUSIVE (pass=0 fail=0 …) in JUnit and HTML, with nothing saying why; the cause lived only on the terminal. The frozen v1 event-wire contract gains one optional field, scenario-completed.message, carrying the engine's own account of which gate the scenario reached and what happened there — the same text the terminal already printed. It is omitted from the wire when there is no cause, rather than written as an empty string: the producer records null and the stream serialises with WhenWritingNull, so a stream with nothing to report is byte-identical to before and the golden gained exactly one line. Both renderers null-check the field. JUnit appends the cause to its existing count summary rather than substituting for it, because that summary is what a publisher UI groups and diffs on; the HTML report adds a new <p class="scenario-message"> and replaces nothing. A scenario that actually executed carries no scenario-level message — its cause is already in its step records, and stamping a second copy at scenario level would duplicate it. The field names a suite-level gate instead: a malformed environment, divergent environment blocks, a topology failure, a schema rejection, a refusal before any container started. Consumers reading only the event stream need not change — §14's forward-compatibility promise already requires renderers to tolerate an unknown field, and an absent one reads exactly as it did before.
  • Resolved absolute host paths are no longer exposed in diagnostics, closing path-disclosure gaps across security validation and seed operations (#357). Path-valued security fields (caCert, clientCert, clientKey, and serverArtifacts[].source) and seed paths are resolved against the suite directory before any containment or existence check. An EnvironmentSecurityValidator or SeedApplier fault previously reached the event stream (in scenario-completed.message), the JUnit message attribute, and the HTML report carrying the resolved absolute path on the host — a security-relevant information leak. Now every diagnostic names the declared path exactly as the author wrote it, plus the concept it resolves against rather than the directory itself — 'ca.pem' not found, relative to the suite directory, and '../ca.pem' resolves outside the suite directory. Neither the base directory nor the resolved form appears in any message. This supersedes REQ-004's acceptance criterion rather than reinterpreting it: that criterion required the resolved path to be exposed for troubleshooting, and it is now deliberately not. Naming the concept keeps a relative path diagnosable — an author can still see which field failed and what its path was measured against — without disclosing the host's layout. The distinction matters: a suite is portable (runs unchanged on different hosts), and every other trace (captured output, events, reports) already hides the host paths via substitution — a diagnostic is the wrong place to hold one.
  • A declared clientKeyPassword is now withheld from ToString() rendering, closing a latent secret-value leak (#408). On a schema-validated path SecuritySpec.ClientKeyPassword holds a ${secret:…} reference rather than a value, and §17 permits quoting a reference — but only once SecretReference.ValidateSecretBearingField has returned true, and that needs the run's secret-source list. A ToString() has no such list, and the parser is deliberately lenient enough to bind a literal passphrase, so SecretReference.TryParse alone is not the proof. The withholding is therefore unconditional rather than conditional on what the field appears to hold. However, ServiceSpec and DependencySpec each hold a SecuritySpec?, and the compiler-generated PrintMembers on those records still invoked SecuritySpec's own compiler-generated ToString(). This is not a live leak in this branch, because the only paths that reach a ToString() during an actual run (logging and inspection) never exercise those records. What kept it latent is that a SecuritySpec is bound before any resolution runs, so on a schema-validated path the field holds a reference at the moment anything could render it. That is a property of the current call graph, not a guarantee — which is why the guard does not rely on it. The fix is definitive: SecuritySpec.PrintMembers overrides the default and renders ClientKeyPassword = <redacted> when declared (the reference itself is not printed) and ClientKeyPassword = (empty, no value) when undeclared — every other member prints unchanged. The override is private on a sealed record — the same accessibility the compiler generates — so no public API of Vouchfx.Engine.Authoring moved and no positional parameter was added. Because a record's generated PrintMembers expands a member's own ToString(), guarding the root makes every holder safe for the passphrase — measured against an unguarded control holder built on the guarded assembly, the canary is absent from its rendering. ServiceSpec, DependencySpec and SecuredTarget each additionally carry a guard of their own, which withholds the whole security block rather than just the passphrase. Those per-holder guards came first, and relying on them alone is exactly why the defect survived two earlier rounds — #408 guarded SecuredTarget, a later round guarded the two Spec records, and each fix named a type, so the next holder was invisible to both the fix and its test. Guarding SecuritySpec itself closes the class at the root: every present and future holder is safe without enumerating holders. The completeness objection that had previously blocked a root guard — that an override must list every member, so a future field would be silently dropped — is discharged by a member-count census test, which fails the day a member is added rather than letting it vanish from the rendering. SecuritySpecDisclosureTests pins all of it, including two class-level gates that enumerate the assembly by reflection rather than naming types.
  • A topology-start failure now writes scenario completion events and configured artefacts instead of exiting empty (#407). When StartAsync on the Aspire topology throws (e.g. an image cannot be pulled, a port is already in use, a health check times out), the run previously returned without generating any events or reports — a CI job got a red exit code with no JUnit <testcase> to inspect and no HTML report to consult. Now the engine completes each scenario with an EnvironmentError verdict, stamps that onto the event stream, and writes the configured --junit, --html and --events artefacts before returning. The topology failure is stamped onto every scenario that had no cause of its own — a scenario already carrying one keeps it, since its own refusal is the more specific answer — matching the behaviour of the shared-environment guard and other suite-level refusals. A run over a directory of unsecured files that hit a topology failure now exits 0 by default (unchanged — an environment error without --fail-on-env-error) and writes those reports where it previously wrote none. A run over the same directory with one secured file also exits 0 (because the topology failure is EnvironmentError, not a security-unconfirmed state), writes the reports, and does not print a security line (the probe never ran). Measured: the RunSuiteAsync completion path is reachable without a built topology, and both run paths (sequential and parallel) hit the same code for the three configured report kinds.
  • The HTML report no longer carries raw control characters (#371). HtmlRenderer now drops the C0 controls below U+0020keeping TAB, LF and CR, which are legitimate layout in a diagnostic and render under white-space: pre-wrap — together with the two BMP non-characters U+FFFE and U+FFFF. The hazard was measured rather than theorised: an ESC[31m sequence written into an author-controlled metadata.name reached the written report as a raw 0x1b byte. Entity-escaping the five markup characters does not catch it, because a control byte is not markup — it passes through untouched and is re-interpreted downstream. A report cat-ed in a terminal replays the ANSI sequence, which can recolour or rewrite surrounding text and so misrepresent a verdict, and the raw bytes sit in an artefact CI archives and other tools read. This is a rendering change, not a new refusal: nothing that validated before is rejected now, and metadata.name reaches no provider's Emit stage where it might have been. The JUnit renderer has applied the identical predicate for far longer, as an XML 1.0 conformance requirement rather than a terminal-safety one — the two are deliberately independent implementations, and a test now asserts they cannot drift apart. The JSON event stream is unfiltered and carries the characters verbatim, which is valid JSON.
  • Prerelease tags are now created with the --prerelease flag (#388). .github/workflows/release.yml was not passing --prerelease when promoting a GitHub Release from draft to published for alpha/rc/beta versions, so pre-GA releases were published as stable releases on the GitHub Releases page and (on NuGet) as stable pins. The workflow now passes --prerelease to gh release edit for any tag matching the pattern v*.*.*-* (semantic version with a pre-release identifier). GA releases (v*.*.* only) remain published without the flag. This affects no engine or package behaviour, only the metadata of the GitHub Release and NuGet package — users already pinning pre-GA versions got them as intended; the flag makes the releases findable as pre-releases in UI dropdowns and filters.
  • A dependency's env: is key-order sensitive, and adding the field moves the environment hash of every suite that declares a dependency. Two consequences, both user-visible: two scenarios sharing an environment block whose dependency env: keys are written in a different order now abort the suite as an Environment error before either scenario reaches its own schema verdict; and a watch-mode save that merely reorders two keys tears the topology down and rebuilds it rather than reusing it. A service's env: has behaved this way since the field was introduced. Separately, every dependency's serialised form now carries the new field whether or not the author declared one, so the environment hash of any suite declaring at least one dependency differs from its previous value — inherent to adding the field, not a choice; suppressing it would move the hash of every suite in the other direction.
  • Breaking: a malformed dependency env: now fails discovery. It briefly opened a mixed-directory run exiting 0 on a suite that declared mTLS; #425 closed that in this same release. A malformed env: map is now a parse failure rather than an unrecognised key retained verbatim, which is correct — but a parse failure of this class records no recovered document, so the scenario's security: declaration is dropped from the security-assurance tally. In a run over a directory where at least one other scenario parsed, the verdict elevates only to Inconclusive and, where the surviving scenarios pass, the process exited 0 unless --fail-on-inconclusive was passed. Previously the document parsed and was refused at the schema door with its declaration recorded, exiting non-zero unconditionally. A single-file run is unaffected (exit 4). Tracked separately as an assurance-path defect and closed in this same release by #425, which makes any parse failure never-clean: the shape described above now exits 4, so a mixed-directory run over a suite declaring mTLS no longer reports success. This entry is kept rather than deleted because the disclosure was published, and a reader who acted on it should know it no longer applies.
  • Breaking: the DSL's vocabulary terms are now matched case-sensitively — dependency type, imagePullPolicy, verifyMode, and cache-assert.redis's operation. A suite that previously wrote type: Postgres, imagePullPolicy: always, or verifyMode: retry validated and ran; all now fail at suite-build time. Each term has exactly one canonical spelling, matching the JSON Schema enums that constrain it — previously the schema rejected a wrong-case value at authoring time while the engine accepted it at runtime, so the two gates disagreed about what was legal. Widening the enums to accept every case variant was considered and rejected: it makes editor completion noisy (Postgres/postgres/POSTGRES) and stops the schema being a clean statement of the accepted forms. Update any suite using a wrong-case spelling to the lower-case dependency kind (e.g. postgres, sqlserver, mongodb, kafka, …), the capitalised pull-policy value (Always, Missing, Never), the upper-case verify mode (IMMEDIATE, RETRY), or the lower-case redis operation (get, hget, llen, …). Every [enum] schema-validation error — not only these four terms — now names the offending value, lists the accepted values, and, when the value is a case-insensitive match for exactly one of them, states the correct spelling directly, e.g. Value 'Postgres' is not one of the accepted values for 'type': postgres, sqlserver, … — write 'postgres'. This closes a gap in how that promise first shipped: schema validation runs before EnvironmentMapper.Map() on every production path, so a hand-written "did you mean" message living only in the mapper was unreachable — an author hit the schema's generic [enum] Value should match one of the values specified by the enum first, always. The fix is now in the message an author actually sees.
  • imageRegistry now applies to both services and dependencies — the environment-level imageRegistry override previously affected only services; it now prefixes every un-qualified image reference in both sections. Already-qualified references (those carrying a registry hostname) are never rewritten and are pulled from their specified host as-is. When a fully-qualified image: is specified on a dependency, the engine clears any built-in registry default the provider might carry, preventing unintended double-prefixing.
  • imagePullPolicy is now enforced at topology start — previously the field was parsed but ignored at runtime. It now governs pull behaviour for all images (Always, Missing, Never) and can be set at the environment level (applies to all containers) or per-service to override it; there is no per-dependency form.
  • environment.seed is now closed to its one working kindseed.<dependency>.sql (an array of file paths) is the only recognised seed entry; a dependency mapping with an unrecognised key now fails schema validation. sql applies to postgres, sqlserver, and mysql dependencies alike.
  • environment.services[].env, httpPort, environment.seed.<dependency>.sql entries, and bare-scalar capture values widened to match runtime behaviourenv values now also accept a bare (unquoted) numeric or boolean YAML scalar, not only a quoted string; httpPort now also accepts a quoted string ("8080"), not only a bare integer; a sql file-path entry and a bare-scalar capture value (e.g. capture: { orderId: 42 }) now likewise accept a bare numeric or boolean YAML scalar, not only a string. All four already worked at runtime — YamlDocumentParser reads every one of these back as raw scalar text, regardless of how it was written — and were previously rejected only by the stricter schema. Not breaking for any previously-valid suite — this is a widening, not a narrowing.
  • environment.dependencies[].topics[].name no longer accepts an explicit nulltopics: [{ name: ~ }] previously validated even though it is never what an author means: with .e2e.yaml's pinned YAML parser, name: ~ is read back as the literal, one-character text ~, not as null, so EnvironmentMapper.ParseAsbTopics would declare a topic literally named ~ (a different, narrower defect than an absent name, which the parser genuinely does drop). Rejected at schema time instead of surfacing later as an unrelated-looking Service Bus environment error.
  • Breaking for a filtered job: a --tag/--owner selection now SEES a document that parsed and was then refused by AstBuilder. Selection matched on the built AST's metadata, which such a document does not have, so every metadata filter excluded it — silently, without even printing the file's own parse error — and selection runs before the split that hands those documents to the runner, so an excluded file contributed no security: declaration either. The metadata block was bound by the same parse that bound the environment block and was discarded beside it; both are recovered now, and the filter is answered from what the document actually says. The change is that property, and its security consequence is one instance of it rather than its definition. Three consequences follow, in the order a pipeline will meet them. A filter matching only unbuildable files now exits 4 through the all-parse-failure rule (#278), with no security: block anywhere in the picture — measured on the built CLI: a directory of one nightly-tagged unbuildable, unsecured file plus one untagged sibling exits 4 under run <dir> --tag nightly, where that command previously selected nothing and returned 0. In a mixed selection the file folds into the suite verdict as Inconclusive, so a job passing --fail-on-inconclusive reddens on it as it would on any other Inconclusive scenario, security or not. And it reddens the unconfirmable-security: rule when the file declares a block the run cannot confirm — measured, both run paths: a secured unbuildable file carrying the tag itself, beside a likewise-tagged sibling refused at a compile-time door, exits 4 with the security line under run <dir> --tag smoke, matching the bare run. Where the tag sits is part of the reproduction, and two drafts of this entry got it wrong in opposite directions: the change bites only when the unbuildable file itself carries the filtered tag or owner, and the sibling must carry it too. With the tag on the sibling alone the unbuildable file is still never selected, so this rule does not reach it and no security line prints; the run's code is then the selected sibling's own, which for a sibling refused before execution is 4 through the no-verdict rule (#369), not the 0 recorded when this entry was written. Measured with the tag on the unbuildable file alone, it exits 4 but prints no security line: that 4 comes from the all-parse-failure rule, because nothing parsed, and the security rule needs at least one document that did. A document whose recovered tags genuinely do not match is still excluded, which is the instruction the user gave. --watch is deliberately outside this: its selection still reads the built AST's metadata only, so a run <dir> --tag … --watch resolves to exactly the files it resolved to before — watch requires a single file and builds no unbuilt-document evidence at all, so the recovery has nothing to serve there and would only have turned a working watch into a usage error.
  • The forbidden-property and unknown-property schema diagnostics now bound the author-controlled name they echo. Property 'x' is not valid on service 'y', the project/image conflict, the httpPort-on-a-project-form refusal, the capture-entry messages and every [additionalProperties] Unknown property 'x' on <container> 'y' interpolate values that carry no length limit of their own — a service or dependency key, a dependency's declared type, a capture variable name, the rejected property itself. A key pasted at kilobyte scale turned a one-line rejection into a screenful and pushed the sentence naming the remedy off the terminal; the httpPort refusal is the sharpest case, since it is a breaking change whose remedy clause sits at the very end of its message. Each of those values now passes through the same 200-character display bound the enum rejection and the dependency-security refusal already applied, with a … (N chars total) tail and never a split surrogate pair. A normal-sized name is echoed exactly as before, so the only messages whose text moves are ones already too long to read. The bound is legibility and nothing more: the value is the author's own YAML key rendered back to the author, through the same sinks every other diagnostic in that class already reaches.

Fixed

  • Docker drill lane now sweeps for and kills orphaned CLI hosts left by the test harness (#378). The integration CI job and local dotnet test against requires=docker tests may leave lingering dotnet processes holding the repo's CLI output in memory, blocking the next build if the output directory is locked. The lane now sweeps at both ends of a run — a machine-wide inspection of dotnet/vouchfx processes, kills confined to processes holding this repository's CLI build output — with every finding recorded in a per-user log file (%LOCALAPPDATA%\vouchfx\drill-host-sweep.log; ~/.local/share/vouchfx/ on Linux and macOS). An opt-out is available via VOUCHFX_DRILL_SWEEP=0. Crash dumps are also captured on a test-host crash: mini dumps in CI, full dumps locally, preserving the state of the intermittent 1-in-4 crash for offline inspection. The crash itself remains unreproduced but is now diagnosable.
  • A resolved certificate path quoted back by a Kafka client no longer reaches an archived diagnostic (#375). #357 established that any diagnostic naming a security-material path names the DECLARED text the author wrote, never the absolute host path it resolves to — and every site the engine writes has complied since. librdkafka is not such a site. It takes ssl.ca.location, ssl.certificate.location and ssl.key.location as resolved absolute paths and nothing else, and when it cannot open one it builds its own message quoting the path it tried. That message arrives as a caught exception inside a Kafka provider's guarded region, becomes the step's observation, and is archived into the JSON Lines event stream, the --events artefact and the HTML report, where no later scrubber could reach it: the existing net is ResolvedSecretLedger, which replaces recorded secret VALUES, and a filesystem path is never one.

The engine now keeps a second, separate net. SecurityPathDisclosureLedger records (resolved path -> the author's declared text) at the one accessor chokepoint that holds both, and substitutes the declared form back at the three scrub chokepoints every archived channel already passes through — the step observation, the scenario-level cause, and the environment-error detail. A substitution, not a redaction, and that is why it is a separate class: blanking a path to [REDACTED] would satisfy the disclosure and leave the author holding a diagnostic they cannot act on. The ledger is run-scoped and shared by the topology probe and every scenario, exactly as the secret ledger is, so a path handed out while the topology was built is substitutable from text a step emits later. Both the raw path and its JSON-escaped form are matched — not a theoretical case on Windows, where every resolved path is full of backslashes and would otherwise survive into the on-disk artefact in a form any consumer can decode.

The same change closes the accessor's own exception texts, which folded the PLATFORM's message into a SecurityMaterialException verbatim. Measured on .NET 8 / Windows: X509Certificate2.CreateFromPemFile — the client-identity load — opens its files through System.IO, so a file that vanishes between the pre-topology existence check and the load throws FileNotFoundException carrying Could not find file 'C:\...\client-key.pem'. That was a real leak down the same probe path, reachable through a TOCTOU window. The trust-anchor load is different and was measured so, on Windows: there new X509Certificate2(string) goes through CryptoAPI, whose message is path-free for a missing file, a locked file and a directory alike (on Linux the same constructor goes through OpenSSL and was not measured); the substitution is applied at that catch too, because its filter admits the same IOException and UnauthorizedAccessException types, but no leak was measured there. No provider changed, and none needed to — the fix sits entirely between the engine and the wire.

  • --watch now refuses an authoring fault before it starts a container, instead of after (#370). The watch loop's compile step was YamlDocumentParser.Parse + AstBuilder.Build and nothing else, so both of the engine's authoring gates ran later, inside the re-run seam, against a topology that was already up. Three consequences, all measured, all gone: a schema-invalid suite started containers where plain vouchfx run rejected it before any Docker work; a both-families protocol conflict (REQ-023) on a secured broker suite reached the confirmation probe, which attempted a Kafka ApiVersions round trip against a target the engine had already decided was misconfigured and reported "the broker did not answer a Kafka ApiVersions request" — blaming the broker for an authoring fault detected one layer down; and SecuredEndpointProbe's unrecognised-security.profile refusal, documented unreachable by author input because the schema and the profile-wiring validator reject an unregistered profile first, was reachable on this path alone, so a typo such as profile: kerbros started containers, passed the health gate, and then told the author to register a profile in an internal engine dictionary.

Every gate now runs on every save, ahead of the reuse-vs-rebuild decision: schema validation, the provider-pipeline compile (which carries the security-artefact preflight, the profile-wiring check and the protocol-conflict guard) and the secret-reference walk. The verdict taxonomy does not move: each of those refusals already carried Inconclusive at its own door, and the same scenario-started / scenario-completed pair is emitted with the same text. What changes is when an author sees it, and what it costs them. On the first save of a session, nothing starts at all — where previously the refusal arrived only after the topology was up and health-gated. On a later save against a kept topology, the refusal no longer runs anything against it first: the kept topology was left alive behind such a refusal before this change and still is, so that is not what moved — what moved is that a refused save used to reach the run seam, which reset the dependency state, re-applied the seed and re-printed the replayed security/transport advisory block before reporting the fault. It now stops at the compile seam, so a refused save neither disturbs the topology's state nor prints the replayed advisories. That last part is user-visible: an author who saves a broken edit against a secured or advisory-carrying topology sees the diagnostic alone, where the same save used to print the replayed security: / transport: block above it. One class of save also changes its REPORT shape, not merely its timing: a secured suite naming a caCert that is not on disk used to reach the topology build, where the confirmation probe failed first — so the run emitted an environment-error line and no scenario verdict record at all. It is now refused at the artefact preflight, which produces the located authoring diagnostic and an Inconclusive scenario-started / scenario-completed pair. That is convergence with vouchfx run, which has always reported it that way, and it is the better diagnosis of the two — but a consumer parsing watch-mode output for that shape sees a different record.

  • A --watch save carrying faults in both pre-topology walks now reports both, in the same order run reports them (#412). The provider-pipeline compile and the secret-reference walk were merged into one door on vouchfx run and run --parallel, so the same document is diagnosed the same way on both — but the watch seam kept the retired ordering in a third spelling: the secret pass first, returning at its first fault, so a compile fault in the same document was never computed and never printed. All three paths now call one door, which runs both walks and joins their messages. The price is the one the two verdict-producing paths already pay: a document whose only fault is a step-secret fault builds a full in-memory CSX emit that is then discarded. It is bounded — no topology, no container, no Roslyn load context — and is accepted deliberately, because guarding the compile on "the secret walk found nothing" reinstates the ordering divergence silently.

  • --watch now rebuilds the topology when a save changes what the steps target, not only when the environment block changes (#370). The reuse key was a hash of the environment block alone, which cannot see two inputs the topology is genuinely built from, both derived from the steps. A save that added a step targeting a previously untargeted endpoint-less project:-form service left the hash unchanged, so the session went on reusing a topology built under the assumption that the service was an untargeted worker — and saw a UriFormatException for the rest of that session instead of the located refusal plain run gives. A save that made a service Kafka-speaking for the first time had the same shape and a different symptom: that service is staged as a bare host:port bootstrap authority rather than as a URL, and no guard fires because only one protocol family addresses it, so the kept topology went on handing the step a URL where its client expects an authority.

The reuse key is now a digest over every input the topology would be built from — the serialised environment, the seed base directory, the app-host assembly name, the startup timeout and the two protocol target sets. The common edit still re-uses: the digest moves only when the set of targeted resource names changes, never when a step's body, headers, assertions or URL path change. The extra cost is bounded and converges within a session — at worst one further rebuild per newly targeted service — and it buys a --watch session that refuses exactly what vouchfx run refuses. The enabling change is structural (#364): the kept-topology seam is an interface rather than a concrete Aspire-backed class, and the whole topology argument list is one value object with a single call site, so the watch path is now covered by Docker-free tests rather than by container drills.

  • A mq-publish.kafka step's declared timeout is an upper bound again; it was overrunning by exactly ten seconds (#367). A step declaring timeout: 20s concluded at 30027 ms and one declaring timeout: 10s at 20099 ms — two budgets, one constant overrun, which is the whole diagnosis and not the one the filing proposed. The cancellation token was never the problem. Confluent.Kafka.ProduceAsync(topic, message, ct) does observe the step token: measured against an unreachable broker on Confluent.Kafka 2.14.2 with a 5000 ms token, it threw OperationCanceledException at 5025 ms. What then ran was the emitted helper's finally, and it held an unconditional producer.Flush(TimeSpan.FromSeconds(10)). A message that cannot be delivered stays queued, so that flush spent its full 10001 ms — and it spends it before the cancellation propagates out of the step's statement block, while the engine's own per-step stopwatch is still running. Dispose() then cost 16 ms in that same pre-fix probe, for a total of 15045 ms on a 5000 ms budget. (Several Dispose() figures appear in this work and they are not interchangeable: 16 ms is the pre-fix probe against a refused bootstrap; a connect-refused peer measured 94-110 ms across the author's and the review's probes. The teardown is bounded but not token-bounded, so the restored upper bound is near-exact.) The provider's own source comment asserted the opposite ("no hard-coded transport timeout to lift here — the step token plus the assembler's late supersession are the bound"), was quoted into the issue as the recorded mechanism, and is corrected in place. The flush is now bounded by a CancellationTokenSource linked to the step token and capped at the same ten seconds, so a governed step is cut within one librdkafka poll slice — the client polls its queue in 100 ms slices, which is the worst case. A step that declares no timeout is left near-exact rather than untouched, and the difference is stated because "unchanged" would be wrong twice: ct is CancellationToken.None there so only CancelAfter can cut it, but the cap is now reached by polling in ~100 ms slices instead of one blocking wait, so it overshoots by up to one slice (measured in review: ~65 ms at a 2 s cap; the ~10.07 s vs 10.01 s at the shipped cap follows arithmetically), and it ends by throwing a swallowed OperationCanceledException where Flush(TimeSpan) previously returned normally with the outstanding count. Neither is observable in the step's outcome, which is why the cap is kept rather than removed. Measured end to end through the real emitted CSX and the assembler's IMMEDIATE wrapper — driven directly against a manually-run broker container rather than through the Aspire topology, because the requires=docker topology row could not be executed on the measuring host (#420); the broker advertises a listener address the host cannot reach, so bootstrap answers, the partition leader does not, and the produce genuinely sticks — a timeout: 10s step went from 20130 ms to 10178 ms, a 10130 ms overrun reduced to 178 ms; the observation is byte-identical throughout ({"reason":"step-timeout","timeoutMs":10000}), as is the verdict. The same shape is now pinned without any container at all, by a test that runs the emitted teardown against a refused connection: 12135 ms before, ~180 ms past the budget after, on a 2000 ms budget (inside the pin's stated grace; the restored bound is near-exact, as above). The verdict was already correct and deliberately did not move: a timeout is Inconclusive, never Fail (§12.1). A successful publish against a reachable broker is unaffected — measured Pass in 272 ms, because a delivered message leaves nothing outstanding and the flush returns at once. Deriving librdkafka's own message.timeout.ms from the step budget was considered and rejected, and a test pins its absence so it is not reintroduced: that timeout is not on the critical path once the token cuts the produce, and setting it to the budget would race the token — whichever won would decide the verdict, since a delivery-timeout expiry surfaces as a ProduceException and classifies as EnvironmentError. That trades a timing defect for a nondeterministic one. Only mq-publish.kafka changed, and the sibling sweep is not an all-clear. No other provider carries a fixed-duration blocking teardown, but that is a narrower statement than it sounds: mq-expect.kafka's consumer.Close() is the same defect class with a config-derived bound rather than a hard-coded one, and mq-publish.redis and mq-expect.redis do not observe the step token at all in the calls they make (StackExchange.Redis exposes no CancellationToken overloads, so the real bound is its own ~5 s client timeouts). Both are recorded in #468 and deliberately left unfixed here. The mq-expect.kafka comment that asserted the opposite — the same sentence this entry retracts for mq-publish.kafka — is corrected in place, since it sits on the likeliest next instance.
  • A security: block declared by a document that parses and is then refused for its contents is no longer discarded, closing the largest of the ways such a block could be declared and never exercised on a green pipeline (#411). It is not all of them, and the residual is named in the paragraphs below rather than left to be discovered: the three failure classes that bind nothing at all. (A second residual was recorded here — a declaration matched to a sibling's confirmation by target name, on the sequential path, #415 — and it has since been closed; see its own entry below.) Discovery bound the document — environment.services[].security and all — and then threw that bound document away when AstBuilder refused it, so the declaration never reached the runner's one canonical walk of the declared secured targets. Alone, such a file still exited 4, but through the all-parse-failure rule (#278) rather than through the security rule; put it beside one sibling that parses and that rescue does not apply, and the suite exited 0 with no security line while a file in it plainly asserted mtls. Discovery now retains that bound environment block and the runner folds it into the same walk that answers what the suite declared, recording the authoring refusal that pairs with it — both halves are required, because the unconfirmable-security: predicate raises only on a declaration paired with a refusal. On the fixture measured below that is proved by the --parallel 1 arm alone: remove the refusal and that arm falls back to 0, while the sequential arm still exits 4, because it holds one suite-wide assurance and the pair's unsecured sibling contributes an authoring refusal of its own. The refusal is recorded only when one of those unbuilt documents declares a security: block — or carries one the schema rejects, see the next paragraph, which is what keeps a suite-wide declaration from being paired with an unsecured file's refusal: an unsecured file with an unknown step type, beside a secured sibling whose topology cannot start (measured with a pinned host port already in use, which fails the same way a health gate does), exited 3 under run and 0 under run --parallel 1 while that stamp was unconditional — a divergence between the two run paths, and a silent override of the fence that keeps a topology failure from reddening a secured suite. The stamp is now conditional, which removes the divergence. The exit code this pair reports under the post-#369/#425 rules is NOT re-measured here: the original figure needed Docker (a pinned host port already in use) and the shape routes through the security rule rather than either no-verdict rule, so the earlier "0 on both paths" is withdrawn rather than replaced with a derived number. Measured on the built CLI as a subprocess with neither gating flag, under run and run --parallel 1 alike: a directory pairing a secured file carrying an unknown step type with an unsecured sibling carrying a step-secret fault now exits 4 — the run's own Inconclusive verdict code, not a fixed one — and prints the security line, where it exited 0 with no line before; the unsecured control, differing in nothing but the security: block, exits 4 as well since #369 — it prints no security line, which is now what separates the two. Nothing else about such a file changes: it is still never run, and still folds into the suite verdict as Inconclusive.

And a security: node the parser cannot bind at all is covered too, by the schema door rather than by that walk. The walk reads bound values, so the most likely typo of the lot — security: mtls, the profile name written where the block belongs — binds nothing and the walk reports nothing, as does a bare security: whose children are commented out. Measured on the built CLI before this half landed, with neither gating flag: either spelling beside one parsing sibling exited 0 with no security line on both run paths, while the same typo alone exited 4 through the all-parse-failure rule (#278) — so the exit code was non-monotone in the number of faults, and adding an unrelated broken file to a suite turned the pipeline green. Such a document is now put through the same schema validation and the same "is this error located at or inside a declared security block" test that every document which did become a scenario already passes through — the engine's existing spelling, shared rather than re-derived, so the two cannot answer differently. Both spellings now exit 4 with the security line on both run paths; security: {} (a mapping, so it binds) exited 4 before and still does; and the control — the same unbuildable file with no security: node, whose schema error sits at /steps/0/type — exits 4 too since #425, without the security line.

What remains open is stated rather than implied, and it is the other three ways discovery can fail. A file whose YAML is malformed, one the runner cannot read, and one over the 1 MiB document cap all bind nothing, so there is no declaration to recover: measured on the same pair with the secured file's YAML malformed instead, both run paths exited 0 with no security line when this entry was written. That is closed, in this same release, by #425: an unreadable file now reddens the run wherever it sits, so the pair exits 4 — pinned by Row09c_SecuredMalformedYamlBesideAParseableSibling_ExitsInconclusiveOnTheUnreadFile. It is closed from the parse side rather than the security side: no raw-YAML scan for a security: key was added, so the engine still has exactly one way of asking whether a document declares security, and the security line still does not print for a file that bound nothing. The cost this entry named as the reason not to close it — "failing closed would redden every unsecured suite that merely contains an unreadable file" — was taken deliberately, and is recorded as the accepted cost of #425. The residual is pinned by a test asserting today's behaviour, so closing it turns that test red.

When this first shipped the recoverable class was closed for the reported reproduction and not for every arrangement of it, and that remainder has since been closed too — the paragraph is kept rather than deleted, because the sentence a reader may have acted on was here. As shipped, the sequential (run) path held ONE declaration set and matched it against what the probe confirmed by target name alone, so a refused document declaring a name a sibling's probe went on to confirm was treated as confirmed — whatever profile, endpoint or client certificate it actually declared — and on that one shape the two run paths disagreed: on an identical suite with the topology up and the probe confirming that name, run exited 0 where run --parallel 1 exited non-zero. Both of those are now wrong descriptions of the engine. Issue #415 is closed; a declaration is matched only to a confirmation that tested that declaration, and this shape exits non-zero on both run paths. The reason given here for leaving it open — that comparing more than the name would put declared security values back into the record — did not survive contact: what the record compares is a one-way digest of the declaration, never the values, and it reaches no report, event or log. See the entry for #415 below, and the behaviour-change note beside it. - A declared security: block is vouched for only by a probe that tested that declaration — never by a sibling that happened to declare the same target name (#415). One property, and every consequence below follows from it: a run confirms a declaration, not a name. Previously the engine compared what a suite declared against what its probe confirmed by target name, so two documents in one directory both declaring api — one asserting profile: mtls on port 9093 with a client certificate, the other asserting profile: tls on 8443 with none — satisfied each other, and a suite asserting mutual TLS could report itself confirmed by a plain-TLS handshake. Each side of that comparison now carries the declaration's whole identity: its target name and kind, together with a one-way digest of everything the security block itself declares. Declaration and confirmation derive that identity from one function, so the two agree by construction rather than by two call sites being kept in step — and the fields that go into the digest are deliberately not enumerated here, because the enumeration is that function and a copy of it is a second spelling free to go stale the next time a field joins it. No declared security value enters the record: the digest is a value the engine compares and never renders, it reaches no report, no event and no log, and a declared clientKeyPassword's text enters it only where the whole value is one ${secret:…} reference — a literal passphrase contributes its presence and nothing more, so the one thing that must not be hashed is not hashed. The identity is scoped to one suite directory, since path-valued fields are hashed as the relative text the author wrote.

The practical shape is the one a working pipeline has: a broken secured file among siblings that come up and confirm the same target. That file used to be vouched for by its siblings' probe and the suite exited 0; it now exits non-zero, because nothing downstream of that document ran and nothing established that its environment block is the one the topology started from. And run and run --parallel N now give the same answer on it, where the flag used to decide: the sequential path folded such a document's declaration into one suite-wide set and kept only its refusal, while --parallel had always paired each document's declaration with its own (empty) confirmations. Both paths now fold one whole assurance per document, through one shared spelling, so neither can answer differently about the same file. Nothing else about such a document changes: it is still never run, and still folds into the suite verdict as Inconclusive. Issue #390 is untouched — a secured suite whose only fault is a topology that came up and failed its health gate still exits 0 — and so is the residual named in the entry above: a document whose YAML never parsed at all binds no declaration, so there is still nothing to recover from it.

  • A suite refused by the shared-environment divergence guard now writes the reports the run asked for, instead of exiting with none. The guard returned before the completion path, so --junit, --html and --events produced no file at all. That was survivable while the guard exited 0; once a secured divergent suite began exiting 3, a CI job got a red build beside an empty results directory and a JUnit publisher reporting "no test results" — the failure correctly reported on stdout and invisible in the artefact every pipeline actually parses. The guard is unmoved, so the diagnostic an author meets first is unchanged; it now stamps its suite-level EnvironmentError onto every scenario, which is what the two neighbouring suite-level guards already did. This applies to unsecured divergent suites too, which still exit 0 and now write those reports where they previously wrote none — so a pipeline gating on JUnit errors rather than on the exit code will see a red result where it used to see a missing file. Measured: errors="2", one <testcase> per scenario, one started/completed event pair per scenario. Two clauses of this entry were overtaken by #451 in this same release and are corrected here rather than rewritten away (the measurement above is unaffected and stands): the guard is no longer "unmoved" — it now runs below per-scenario schema validation — and it no longer stamps onto every scenario, because a scenario the schema already refused keeps its own located message instead. It still stamps its suite-level EnvironmentError onto every scenario that carries no verdict of its own, which is the property this entry was reporting, and a suite of individually-valid documents (the shape measured above) is stamped exactly as described.
  • A ${secret:…} reference written into a path-valued security field is now refused by name, instead of failing as a missing file with a mangled path (#387). caCert, clientCert, clientKey and every serverArtifacts[].source name a host file; a secret reference in one of them previously reached the filesystem check and reported file '${secret:env/CLIENT_KEY}' not found (resolved to '…\${secret:env\CLIENT_KEY}') — a path garbled by the reference's own / being read as a directory separator, blaming the filesystem for what is a category error in the declaration. The refusal now arrives ahead of the containment and existence checks, states that the field takes a path, and — on clientKey alone, where an author reaching for ${secret:} is almost certainly trying to avoid a plaintext private key at rest — points at clientKeyPassword as the supported way to achieve that. The reason it gives is scoping, not timing: these fields name a file for the engine to read and copy, while the secrets subsystem yields a value, which is not a file and has no path. (An earlier draft of the message blamed timing — that this material loads before any step runs, which is when references resolve — and then recommended clientKeyPassword, which resolves at that very same pre-step moment and works; a refusal standing on ground its own remedy also stands on teaches an author nothing.) The exit code is unchanged at 4.
  • Spurious composite-branch schema-validation noise — a fully valid step or service could still surface a misleading error, either because the document was invalid elsewhere in a way unrelated to it, or because JSON Schema's own oneOf/anyOf branch exploration leaked a non-matching branch's failure alongside a genuine one. Concretely, before this fix: a valid script.csharp step with a typo'd field reported only [required] Required properties ["file"] are not present (the unexercised code/file alternative) instead of naming the typo, and the advice was actively wrong (adding file: produced a different error); a valid image:-only service, anywhere in an otherwise-failing document, could report a spurious [required] Required properties ["project"] are not present; a valid httpPort or timeout value could report a spurious [type] mismatch from the branch it was never going to match, including advice to un-quote a httpPort this very release legalised quoting for. httpPort, timeout, and the mapping form of capture entries are now expressed as single merged schemas with no second branch left to leak from (identical accept/reject behaviour — minimum/maximum/pattern/required/properties/additionalProperties are all no-ops against a non-matching JSON type, so nothing was ever depending on the branch split). SchemaErrorCollector additionally drops a oneOf/anyOf branch's error whenever a SIBLING branch of the same composite application already satisfied it, for the two composites that cannot be merged this way (a service's "at least one of image/project", and a provider's own alternative-field oneOf, e.g. script.csharp's code/file — a frozen provider fragment, untouched). A composite that genuinely fails — e.g. a service with NEITHER image nor project set — still reports every failing branch, unchanged; every registered Core provider type is now covered by a regression test pinning exactly one error for a single typo.
  • [enum] schema-validation errors are now actionable — see the case-sensitivity entry above.
  • MySQL seed DDL/DML transactional caveat documented (docs/02 §3.2.5) — belated documentation catch-up for the MySQL "implicit commit" behaviour already shipped; no engine behaviour changed.
  • A dangling or empty image: on a dependency no longer throws at suite-build time. Previously, image: with no value (or an explicit image: "") threw ArgumentException: Image reference must not be null, empty, or whitespace; both now resolve identically to image: being absent altogether, matching the schema's existing description text.
  • A plain (unquoted) YAML-null version: or image:~, null, Null, or NULL — no longer silently becomes a literal, unpullable container tag or repository. Previously each of the four tokens reached Aspire verbatim as the container tag (e.g. version: ~ pulled ...:~; version: NULL pulled the four-character tag ...:NULL), producing no error until Docker tried to pull the garbage reference — this was also true of a plain-null image: when paired with a sibling version: (the token reached Aspire as a literal, wrong repository name). A LONE plain-null image: with no sibling version: behaved differently, and flips the other way — failure to success: previously it correctly failed loudly at suite-build time (the pre-existing rejection of a tagless image with nothing to pin its version, so it would otherwise float on :latest), and now instead succeeds, correctly treated as absent. All four tokens now resolve identically to the key being absent, for both fields. This is new behaviour only for the four explicit tokens: a dangling or "" version: was already correctly treated as absent before this change (unaffected). A quoted value (e.g. version: "~") is unaffected either way and is still used literally — only YAML's unquoted null forms are resolved. Whitespace-only values (e.g. image: " ") are also unaffected and continue to behave exactly as before this change: a loud rejection for image:, and the pre-existing literal-tag behaviour for version: (neither is part of this fix's contract).
  • #426: Dependency server artifacts now apply to the container, not the retained builder. ServerArtifactInjection.Apply was passed the retained dependency builder (the AddDatabase child for postgres, mysql, sqlserver, mongodb; the container itself for other kinds), so on the four database-backed types the covariant cast could never convert back to the container resource and threw ArgumentException during topology build. Two gates confine the security.serverArtifacts declaration to kafka-only targets (REQ-021's schema clause and SecurityProfileWiringValidator), so this defect was not reachable from a valid .e2e.yaml on run/validate; --watch, which at that time bypassed validation, could reproduce it (that gap is closed in the entry for #370 above). The fix is correctness for the shipped kafka path and defence for the 1.1 widening of that block to other dependency kinds. The parameter is now narrowed to IResourceBuilder<ContainerResource>, making the retarget a compile error rather than a runtime throw.
  • #348: Project-form services are now staged into svc::, with a requirement that they declare an applicationUrl. A project:-form service was never populated into serviceEndpoints, so svc::<name> was missing entirely, and every HTTP-family provider fell back to an empty string, throwing UriFormatException at step execution time — after the topology was up. The engine now reads the built ProjectResource's EndpointAnnotations and stages a primary endpoint: the listener the service named through endpoint:, or, where it named none, one selected by scheme — http, else https, else the first declared. A project-form service that a step targets must declare an applicationUrl in its launch profile or the suite is refused with a located diagnostic before the topology is built. A service that no step targets is unaffected and still starts — a .NET worker service with no HTTP endpoint is a legitimate shape. When a project declares both http and https endpoints, and the service names no endpoint of its own through endpoint:, the engine stages the plaintext one — and announces the choice in the terminal where a step also targets that service. The notice reports what steps addressing the service will use, so such a service, where no step addresses it, is staged plaintext in silence. The reason for the choice: a project-form service cannot declare security, so the engine holds no client trust material for its TLS listener, and preferring https would fail the handshake into an EnvironmentError that exits 0 unless the caller passes --fail-on-env-error (§12.1's base rule). The choice is also reported on the JSON Lines event stream, as a transport-notice record; the JUnit and HTML renderers ignore that record, so those two reports are unchanged.
  • #353: SecuritySpec now carries an Extra bucket for composed profile fragments, withheld from ToString(). Slice C's unevaluatedProperties: false on $defs/security opened the schema seam; this fix closes the model-layer seam. ParseSecurity reads a fixed set of seven keys — six scalars plus the serverArtifacts sequence; six is the primary constructor's arity, not the key count — into SecuritySpec, which gains an init-only YamlMappingNode? Extra field, populated by the existing BuildExtraNode. A field contributed by a composed profile fragment survives parsing and is withheld from ToString() for the same reason ClientKeyPassword is withheld: the parser applies no shape to what lands there, so it can hold a literal passphrase and ToString() cannot prove otherwise. Additionally, DependencySpec.Extra is withheld from ToString() on identical grounds. ComputeEnvironmentHash now returns a SHA-256 digest rather than the canonical JSON that carried clientKeyPassword in plaintext; all consumers are string-equality checks with no persistence, so digesting is behaviour-preserving. Extra is deliberately NOT consulted for the SecuredTargets digest — author-supplied untyped content cannot be part of a brute-forceable hash. One knock-on worth knowing: because such a key now survives into the serialised environment, two scenarios in one suite differing only by an unknown security key no longer produce identical environment blocks. For as long as the shared-environment divergence guard ran before per-scenario schema validation, that made such a suite abort naming the divergence rather than the key — closed in this same release by #451, which moved the guard below the schema pass, so the sibling runs and the offending scenario alone gets a located schema error naming the key, exactly as before this change. See that entry for the full behaviour. Nothing interprets a field out of the bucket yet; that is what the second profile will define.
  • #413: an unexpected engine or provider throw is now reported as Inconclusive (exit 4), where it was reported as a product Fail (exit 1). ProviderPipeline's per-step Bind call was unguarded and nothing above it caught, so a provider whose Bind threw escaped the entire run: no verdict, and none of the --junit / --html / --events artefacts the run asked for. The exit code was 1, not a crash code, and that is the sharp end of it: System.CommandLine's default exception handler is on for the bare configuration the CLI invokes through, so the framework caught the escape and returned TestFailure — telling CI that the suite had observed a product defect, when what actually happened was that the engine could not run. run --parallel N answered 0 for the same fault — measured as a Verdict.EnvironmentError (it caught the throw further out and classified it so); the 0 follows from ExitCodes.FromVerdict for an EnvironmentError with nothing executed (#390) rather than from a separate measurement of the process code. So one provider defect produced 1 on one run path and 0 on the other. Both now exit 4: run moves 1 → 4, --parallel moves 0 → 4. Such a step is refused before the topology is built, with a diagnostic naming the step and the provider and quoting the provider's own exception — unwrapped from the TargetInvocationException reflection wrapper, whose own message ("Exception has been thrown by the target of an invocation") identifies nothing — and the scenario takes Verdict.Inconclusive through the same door every other pre-topology compile fault takes, so the reports are written and the 4 arrives through the nothing-executed rule (#369). Inconclusive rather than EnvironmentError is the substance of the fix: EnvironmentError is reserved for infrastructure an author cannot fix by editing the suite, and on a run that started nothing it exits 0 (#390); what happened is that the engine could not reach a verdict, which is §12.1's Inconclusive. vouchfx run additionally grows a top-level backstop that maps any exception which would otherwise escape the run onto that same code; it cannot override a code the run already chose (every deliberate exit is a return, not a throw), and it synthesises no reports (the runners own the event buffer those are written from). Its reach is stated rather than implied: it covers escapes on the run path. An exception raised inside a --parallel slot never reaches it — the per-slot catch-all absorbs it first and classifies it EnvironmentError, which is right for a genuine infrastructure fault and wrong for an engine defect, and that frame cannot tell the two apart. That is tracked as #466 and is deliberately not changed here, because reclassifying every escape would move EnvironmentError semantics for real infra faults too. The provider-Bind route is fixed on both paths regardless, because it becomes a verdict before either catch sees it. Cancellation is filtered, not blanket-re-thrown, and the filter matters to anyone running vouchfx under a timeout: TaskCanceledException derives from OperationCanceledException and is what a timeout raises, so a blanket re-throw would have sent every timed-out run to that same framework exit 1 — a transport hiccup reported as a product defect. Only a cancellation on the process's own token (Ctrl-C / SIGTERM) is re-thrown and keeps the behaviour it has; a timeout, and a --shutdown-on-stdin-eof stop that ESCAPES the run, map to 4 — the same code ShutdownBackstop already force-exits with, so the graceful and forced halves of that feature agree for an escaping cancellation. An EOF stop absorbed lower down (a runner that observes the token and returns an ordinary result) still exits on whatever verdict that result carries, which is pre-existing and unchanged here. No suite whose providers behave changes behaviour: this is hardening for a community provider's defect and for the next unexpected route.
  • #451: an authoring typo inside one scenario's environment block is reported as an authoring fault again, not as an infrastructure one. The shared-environment divergence guard ran above per-scenario schema validation, so a key the schema rejects — and which #353 made survive into the AST and into the serialised environment — made two otherwise-identical scenarios diverge, and the suite aborted with a suite-level EnvironmentError reading "declares a different environment block than the first scenario": an infrastructure claim about a topology nobody could point at, with the offending key never named and the innocent sibling refused alongside it. §12.1 is explicit that reporting an authoring fault as an infrastructure fault is the one direction the taxonomy must not bend. Schema validation now runs first, for every scenario; the guard runs after it and compares only the documents that individually validate; provider-pipeline compilation runs after the guard, as before. The offending scenario gets its located schema error and Verdict.Inconclusive, and a runnable sibling runs. What did not move is the constraint the guard exists for: it still runs before the topology is built, so a genuinely divergent suite of individually-valid documents is still refused — same EnvironmentError, no container started — and it now stamps that verdict onto the scenarios rather than synthesising the list, so a document the schema already refused keeps its own located message. Two consequences worth stating. The guard's baseline — and the environment the one shared topology is built from — is the first scenario that passes schema validation, which is scenarios[0] in every suite with no rejected document ahead of it; a suite that previously built its topology from a schema-rejected first document no longer does. And the divergence diagnostic now names both scenarios (scenario 'b' declares a different environment block than scenario 'a') where it used to say "than the first scenario", which would point at the wrong file when a rejected document sits ahead of the baseline. The exit code for the shape that motivated this does not move (unchanged since rc.3: #353 moved it to 3 within this release and this moves it back): a typo inside a security: block is a schema error located at that block, so the engine records SecurityDeclarationRejected, which raises on its own and cannot be satisfied by any sibling's confirmation — the run still never exits 0. And one fail-open the reorder would otherwise have opened is closed with it, stated because it is the security-relevant half. Skipping schema-rejected scenarios in the guard means their environment block is no longer held against the one that starts, while the identity a declaration is matched by hashes the endpoint: selector's text and not its resolution — so a rejected scenario declaring a byte-identical security: block on a service with a different httpPort: would have shared the running sibling's identity, and that sibling's probe would have vouched for mutual TLS on a port nothing tested. A schema-rejected scenario whose serialised environment differs from the baseline's is therefore folded whole — its own declaration beside its own refusal, nothing confirmed — exactly as a document that never became a scenario already is, so it still exits non-zero. Where the environments are byte-identical (the ordinary typo) the scenario is covered by the suite's probe as its siblings are, which is what preserves this fix's improvement. One new refusal comes with the reindexing, and it is narrow: a MULTI-DIRECTORY suite whose first document the schema rejects, where the environment that starts therefore comes from a scenario in a different folder, that environment declares environment.seed, and the two documents' seed sections differ, is refused before the topology is built (Inconclusive) instead of seeding. The seed root is the first discovered scenario's directory while the environment is now the first schema-valid one's, so a relative seed path can name a different file in each — and the harmful case is the one that resolves: a same-named fixture in both folders would seed silently from the wrong copy and the run would go green against the wrong data. The fourth condition is what keeps this from being an over-refusal of its own: where the seed sections are identical, the baseline supplies character-for-character the paths the first document supplied, against the same root as before, so nothing has moved and the suite runs — a multi-directory suite (legal since #268) with a typo in one file is not refused for its neighbour's fault. A single-directory suite, a suite whose first document validates, and a suite that declares no seed are likewise all unaffected.

Removed

  • Breaking: the publish and documents environment.seed kinds — both were wired-but-deferred stubs: the engine read the referenced fixture, content-hashed it, and recorded the intent through an injectable sink (IBrokerWarmupSink / IDocumentSeedSink), but never performed an actual broker publish or document-store write. Neither kind was used anywhere in this repository. A suite still writing publish: or documents: under a seed dependency now fails schema validation loudly, rather than silently doing nothing the way the removed seams did. Re-adding either kind, once genuinely implemented, is purely additive.
  • Breaking (CLR surface): Vouchfx.Engine.Authoring's PublishSeed/DocumentSeed records are gone, and DependencySeed's constructor arity changed to a single Sql parameter — the direct consequence, at the library level, of the seed-kind removal above. Stated explicitly because it is a genuine break for any code consuming the parsed AST directly, not merely authoring .e2e.yaml, even though Vouchfx.Engine.Authoring ships with the package description "TESTING SURFACE, NOT the frozen v1 provider contract" and carries no stability contract before v1.0 GA — intentional pre-GA narrowing, not an oversight.

[1.0.0-rc.3] — 2026-07-30

Completion of the AI-facing tooling surface: the Generator (vouchfx scaffold) and Planner (vouchfx plan), both with public library APIs so MCP and other in-process hosts need not shell out. 1.0.0-rc.2 was prepared but never published (no tag, no package); rc.3 ships its contents in addition to the changes below. The language schema, provider SDK surface and event-wire contract are unchanged (additive only).

Added

  • v1-rc floating convenience tag - .github/workflows/move-floating-tag.yml now routes v1.0.0-rc.N releases to a v1-rc tag, alongside the existing v1-alpha (alpha/beta) and v1 (GA) tags. Each pre-GA line keeps its own tag deliberately: a consumer pinned to v1-alpha is never force-moved onto a release candidate, so switching lines stays an opt-in ref edit. v1-alpha is retired in place at v1.0.0-alpha.10 never deleted, simply no longer moved.
  • Manual dispatch for the floating-tag workflow - a workflow_dispatch trigger taking a release tag, for routing a tag introduced after its release was already published, and as the recovery path for a run GitHub superseded. It refuses any tag whose release is still a draft, preserving the same maintainer-confirmed-publish guarantee the release: published trigger provides.
  • vouchfx scaffold subcommand - emits a machine-drafted, catalogue-grounded, schema-valid .e2e.yaml skeleton from a structured JSON intent (--intent <file|->, optional --output <path>). Steps, services, and dependencies are validated against the live Core registration and known dependency kinds; unknown types, duplicate ids, empty steps, and unknown dependency kinds fail closed (exit 3). Provenance comment header (no timestamps); credential-shaped fields use ${secret:} references only. Docker-free.
  • Public library scaffolder (Vouchfx.Engine.Compilation.Scaffold.SuiteScaffolder) - Generate(StepKindRegistry, ScaffoldIntent, engineVersion?) is the shared implementation for CLI and future MCP hosts so they cannot drift. KnownDependencyKinds mirrors the topology mapper's supported dependency set.
  • vouchfx plan subcommand — a deterministic, read-only coverage-and-gap analysis that intersects the declared suite set, run history, and available step catalogue, emitting findings for coverage gaps (suite never run, step never exercised, dependency not asserted, vocabulary missing, service missing HTTP step), history-health signals (step stale, flaky, fragile, inconclusive-prone), and identity ambiguity. Every gap carries structured hints the scaffold tool consumes; the Planner never writes a suite file or calls a model. Human-readable summary on stdout by default; machine-readable JSON via --json; configurable thresholds for history-health classification; exit codes: 0 success (regardless of gaps), 2 usage error, 3 incomplete catalogue metadata, 5 gaps found (only with --fail-on-gap). Docker-free.
  • Public library Planner API (Vouchfx.Engine.Planning.PlanExport) — BuildPlan(PlanRequest, StepKindRegistry, engineVersion?) and SerializePlan(PlanReportDocument) expose the same analysis the CLI uses, so MCP and other in-process hosts need not shell out. The report is a frozen v1 wire shape (PlanReportDocument with schema version 1, inventory, and ten finding kinds); evolution within v1 is additive only.

Changed

  • README restructured as a landing page (626 → 364 lines) — the status section is a short callout rather than a single ~400-word sentence, a complete runnable .e2e.yaml example is shown rather than only described, and providers are presented as a family table. The full GitHub Actions and GitLab CI reference moved to a new docs/ci-integration.md page (recipes.md and getting-started.md previously linked back into README anchors for it, and now point at the new page).
  • Getting-started documentation - documents the Generator / scaffold workflow (structured intent, free-text host LLM boundary, validate/run path, provenance, secrets-as-refs, catalogue grounding).

[1.0.0-rc.2] — 2026-07-28

(Never published: no v1.0.0-rc.2 tag or NuGet package exists; these changes shipped in rc.3.)

Schema and catalogue export for AI and tooling consumers. The language schema, provider SDK surface, and event-wire contract are unchanged (additive catalogue fields only).

Added

  • vouchfx schema subcommand — emits the composed v1 JSON Schema (root language grammar merged with every registered provider fragment) to stdout by default, or to a file via --output <path>. Exit codes: 0 success, 2 usage error (e.g. missing parent directory for --output), 3 incomplete-metadata / composition failure. Docker-free.
  • Public library export API (Vouchfx.Engine.Compilation.Schema.EngineExport) — ComposeSchemaJson and BuildCatalogue expose the same schema and shape-level catalogue the CLI uses, so MCP and other in-process hosts need not shell out. Incomplete provider metadata fails closed with CatalogueExportException naming the step type.
  • Rich step catalogue on list --json (additive) — each step type entry now includes requiredFields, optionalFields, captureSupported, and familyIntent in addition to type / family / provider. Wire shape frozen by golden-file CI gates; evolution within v1 is additive only.
  • VS Code extension live schema source — prefers vouchfx list --json (bar-B gate) and vouchfx schema from the configured CLI, with a version-checked bundled fallback.

Changed

  • Getting-started documentation — documents vouchfx schema, the enriched catalogue, fail-closed export behaviour, and the EngineExport library entry points for MCP / VS Code / third-party consumers.

[1.0.0-rc.1] — 2026-07-24

A release-candidate consolidating developer-tooling, run-lifecycle, and validation improvements. The language schema, provider SDK surface and event-wire contract are unchanged (all frozen-contract-safe: per-step event-stream liveness and scenario-rooted file resolution are runtime enhancements with no wire-format impact; validation improvements and exit-code corrections align the verdict taxonomy).

Changed

  • Scenario file resolution roots per scenario (#268) — relative script.csharp file: references now resolve against each scenario's own directory in both run and validate, rather than the first discovered scenario's directory. A scenario in a subdirectory now correctly finds helper scripts beside it. Sequential unfiltered run topology seeding remains rooted at the first scenario's directory; parallel runs seed from each scenario's own directory.
  • --events-stream now emits step and step-attempt events in real time (#262) — previously, events were flushed at scenario completion (scenario-level granularity). Step and step-attempt events now appear in the stream immediately as they complete during a run, enabling live per-step progress tracking. For RETRY steps, each polling attempt is observable as it happens. In parallel runs, step lines from concurrently-running scenarios interleave by arrival order but remain disambiguated by (runId, stepId) pairs; the authoritative, declaration-ordered --events archive is unchanged.

Fixed

  • Unknown step type now reported as validation error with line context (#265) — vouchfx validate and the pre-compilation validation pass now report an unknown type field (e.g. type: db-assert.oracle) as a validation error with a line number and an authoring-friendly message ("unknown step type '…' — not a registered provider (expected <family>.<provider>, e.g. 'db-assert.postgres')."), rather than accepting it vacuously and surfacing the error later at provider binding without context. The composed JSON Schema is unchanged; validation is a post-schema cross-check against the registered provider keys.
  • Script body and document size limits (#266) — the engine now enforces a 64 KiB maximum per script.csharp step body and 1 MiB maximum per .e2e.yaml document as resource-limit bounds before compilation. These are sanity limits an author might reasonably hit, not a defence against deliberate compiler crashes (stack overflow exceptions, parse-phase interpolation hangs) which remain uncatchable and not fully preventable in-process. Out-of-process isolation (as the vouchfx MCP server does) remains the correct answer for untrusted input. Terminal diagnostic output is now scrubbed of control characters and ANSI escape sequences at every known diagnostic output path, so a crafted error string or captured value cannot inject terminal escapes through those paths into a human-viewed report or terminal output (--json output was already safe). Fuller in-process→out-of-process isolation of the engine's compile step is planned as a future enhancement.
  • Unrecognised option or flag now exits 2 (UsageError) instead of 1 (#269) — any vouchfx subcommand now exits with code 2 when given an unrecognised or unknown option or flag, making it possible for CI to distinguish a CLI-misuse from a genuine test failure. Exit code 1 remains reserved for the Fail verdict (one or more test scenarios failed); exit codes 0, 3, and 4 (--help, --version, and conditional verdicts) are unchanged.
  • run where every discovered scenario fails to parse now exits 4 (Inconclusive) instead of 0 (#278) — when every scenario fails to parse (malformed YAML, unknown step types across the board — whether a single file or a directory), the run is now classified as Inconclusive and exits 4 unconditionally, independent of the --fail-on-inconclusive flag, matching the behaviour of vouchfx validate and the verdict taxonomy.

[1.0.0-alpha.10] — 2026-07-21

A developer-tooling and run-lifecycle release: incremental JSON Lines event streaming to a tailable file, Docker-free compile-level validate and list subcommands, opt-in stdin-driven graceful shutdown for programmatic hosts, a wider Ctrl+C/SIGTERM teardown budget preventing container leaks, and cleaner schema-validation errors at full provider scale. The language schema, provider SDK surface and event-wire contract are unchanged (all frozen-contract-safe: new --json outputs and the new --events-stream file are additive contracts; the schema-noise fix leaves the composed schema byte-unchanged).

Added

  • vouchfx run --events-stream <file> flag (#258) — writes the schema-versioned JSON Lines event stream incrementally to a tailable file, independent of the buffered --events archive. The engine holds the write handle and grants shared read access; a tailing reader must open the file with shared read/write access (on Windows, FileShare.ReadWrite; on Unix, the file is readable immediately). UTF-8 without BOM. Enables live tailing by downstream consumers such as the vouchfx MCP server and CI progress tracking. Events are flushed at scenario completion (scenario-level granularity); in parallel mode the stream reflects completion order, not declaration order. Best-effort on unwritable paths: prints a diagnostic and does not affect the run's verdict or exit code.
  • vouchfx validate subcommand (#260) — compile-level validation without Docker: JSON-Schema validation → parse/AST → provider pipeline (bind/validate/emit) → full Roslyn compile. Discovers .e2e.yaml files from a file or directory path (recursive). Exit codes: 0 all valid, 2 usage error, 4 one or more invalid. --json flag produces a versioned machine document (schemaVersion, engineVersion, per-scenario diagnostics by stage).
  • vouchfx list subcommand (#260) — list the sealed Core step-type catalogue (twenty-five dotted family.provider types). Exit codes: 0 success. --json flag produces a versioned machine document (schemaVersion, engineVersion, sorted stepTypes array).
  • vouchfx run --shutdown-on-stdin-eof flag — an opt-in graceful-shutdown option for programmatic usage (for example, the vouchfx MCP server). When enabled, the engine monitors its standard input and gracefully initiates shutdown (as if Ctrl+C was pressed) when the input stream closes, allowing full container and topology teardown to complete before the process exits. If graceful shutdown does not complete within the teardown budget (approximately 30 seconds), the engine force-exits itself (exit code 4, Inconclusive, unconditionally), guaranteeing termination without the caller needing to send a separate kill signal. Default off; normal interactive and CI runs are unaffected. Requires the caller to hold stdin open; combining with stdin already closed (< /dev/null) causes immediate cancellation.

Changed

  • Documentation site rebuilt on Material for MkDocs — the GitHub Pages site migrated from a custom static builder to Material for MkDocs with identical visual design, all legacy .html URLs redirecting to their new homes, and a new blog platform seeded with launch and alpha.9 posts. Publication boundary (confidential content detection, snippet allowlist, unresolved-fact detection) is now enforced by a hard CI gate (scripts/check_site.py) that runs before every Pages deployment. Development workflow unchanged: local mkdocs build --strict and mkdocs serve, fact tokens {{fact:...}} still work identically (now applied by MkDocs hooks instead of the old build script), offline authoring supported with VOUCHFX_SITE_FACTS=offline. The legacy scripts/build_site.py remains in-tree as the DOCS-list source of truth for the redirect table but no longer runs in the engine's CI; satellite repositories' wrappers and SHA pins are unaffected.
  • Onboarding and local-development documentation now packaged-CLI-first — the getting-started guide leads with the published NuGet global tool (dotnet tool install --global vouchfx --prerelease), building from source repositioned as a contributor's path; stale pre-feature claims about secret values in --events corrected (verbatim occurrences of resolved secrets are redacted before terminal output, --events stream, and reports; transformed values remain the author's responsibility); troubleshooting guidance realigned accordingly; CI reference surfaces (the reusable GitHub Actions workflow and GitLab template documentation) now frame the build-from-source install as the deliberate pinned-ref design rather than claiming the tool remains unpackaged.
  • Project sites migrated to custom domains — the engine site and its three satellite sites now serve from vouchfx.io, samples.vouchfx.io, providers.vouchfx.io and telemetry.vouchfx.io respectively, each carrying canonical URLs, a robots.txt, and a sitemap.xml; the engine site additionally serves an llms.txt index for AI/search-engine crawlers and JSON-LD structured data on its landing page. The publication gate (scripts/check_site.py) now blocks any deploy whose built output still references the retired GitHub Pages default domain, closing off the split link equity and crawl confusion of publishing under two hosts at once.

Fixed

  • Improved Ctrl+C and SIGTERM teardown budget — the engine now allocates approximately 30 seconds for clean container and topology teardown after receiving a SIGINT (Ctrl+C) or SIGTERM signal, preventing orphaned containers and Aspire session networks when a run is interrupted. Previously the process could be force-killed mid-teardown, leaving infrastructure behind.
  • Schema validation error collection filters out discriminator-branch mismatches (#259) — when an .e2e.yaml scenario contains an invalid step, the composed 25-provider JSON Schema validation previously reported every non-matching provider's if/then discriminator branch as a separate spurious error — up to 24 "Expected \"\"" entries per invalid step, obscuring the genuine error. Error collection (now shared between the composed-schema and root-schema validators) filters these discriminator-branch mismatches out; an invalid step reports only its genuine errors (e.g. exactly one missing-required-properties error instead of 25 entries). The composed schema is byte-unchanged. User-visible effect: readable validation output at full provider scale, in the CLI run path and everywhere validation errors surface.

[1.0.0-alpha.9] — 2026-07-18

A single-fix correctness release: the step timeout field now does what the language reference has always said it does, for every verify mode. No language-schema shape, SDK or event-wire contract changes.

Fixed

  • Step timeout is now enforced for IMMEDIATE steps (#232) — the DSL has always documented timeout as an upper bound on the step, but the engine wired it only as the RETRY polling window. Every step's compiled body now runs inside a per-step cancellation scope: providers observe the step's token cooperatively (their client calls are cut when the budget elapses), and a body that ignores the token but completes past the budget has its outcome superseded. Either way the step resolves as Inconclusive (step-timeout), never Fail, mirroring the RETRY window semantics (§12.1). A declared timeout becomes the step's governing bound — it replaces the provider's built-in transport timeout (the previous hard-coded 30-second HTTP / 5-second AWS conventions), so timeout: 90s now genuinely means ninety seconds; with no timeout declared, behaviour is unchanged (provider transport conventions remain the de facto bound). RETRY semantics are preserved: the window bounds the poll, per-attempt transport conventions still bound each attempt, and an in-flight attempt is now also cut at the window's edge where the client supports cancellation. Language schema, SDK and event-wire contracts are untouched.

[1.0.0-alpha.8] — 2026-07-18

A customer-journey hardening release: every advertised example now passes when actually run, the packaged tool accepts a single .e2e.yaml file as the discovery root, and automatic state reset covers five more stores. No language, SDK or event-wire contract changes.

Added

  • Examples run gate in CI — a new vouchfx examples workflow discovers every flat examples/*.e2e.yaml suite dynamically and CLI-runs each on its own runner with strict gating (--fail-on-env-error --fail-on-inconclusive), so example run-rot is caught on every push to main that touches the examples or the engine. The compile-only examples test proves the YAML compiles; this gate proves the suites actually pass — and exercises the single-file discovery-root form across every provider family as a side effect.
  • Automatic state reset between sequential scenarios — SQL Server, MySQL, MongoDB, Redis and Elasticsearch dependencies now join PostgreSQL with automatic state reset between sequential scenarios sharing one topology. Data is cleared whilst structure (tables, indexes, mappings) is preserved. A failed reset surfaces as an environment error naming the dependency — never as a test failure. Brokers and DynamoDB/MinIO are not reset; add explicit cleanup steps for those. Language, SDK and event-wire contracts remain frozen.

Changed

  • GitHub Actions dependency upgrades via Dependabot across the CI workflows (setup-dotnet 6, setup-node 7, github-script 9, codeql-action 4.37.1), with the codeql-action init/analyze pair landed together to avoid the split-bump version-mismatch failure. The satellite repositories (providers, samples, telemetry backend) now carry the same weekly github-actions Dependabot configuration as the engine, so workflow SHA pins no longer rot silently anywhere in the fleet.

Fixed

  • All fifteen per-provider examples now pass when run — a full customer-journey audit found eight of the fifteen flat examples/*.e2e.yaml suites failing at run time despite the compile gate staying green: three declared placeholder SUT images that can never start (orders-api:latest, ghcr.io/example/orders-api:latest), and five depended on the traefik/whoami placeholder behaving like a real order service (expecting 201/202 responses, published events, sent email or a pre-declared queue). Each now follows the honest-simulate pattern the passing examples established: the placeholder SUT is exercised with expect: status: 200, and a clearly-marked script.csharp step simulates the SUT's own write over the staged connection string (Redis session shapes, Elasticsearch document, MongoDB document, MySQL row, RabbitMQ queue declaration, SMTP welcome email, Service Bus topic publish) so every assertion is genuinely observable. The docs/recipes.md "complete runnable example" links are now true as written.
  • vouchfx run <file>.e2e.yaml now works — the advertised single-file form (including vouchfx run <file> --watch, which is single-file only) previously exited 2 with "Discovery root … does not exist" because discovery accepted only directories. A root naming a single *.e2e.yaml file now resolves to exactly that scenario; an existing file without the .e2e.yaml suffix is a precise usage error (exit 2) rather than a silent false green. The CI reference workflow now gates both root forms on every relevant push, and the reusable workflow's scenario-path input accepts a file (with a new optional artifact-name input so one workflow run can invoke it more than once).
  • The CLI no longer prints the release build machine's path at startup — the packaged tool logged Application host directory is: /home/runner/work/… on every run (the apphostprojectpath assembly metadata baked at build time). Aspire's Aspire.Hosting.DistributedApplication lifecycle banners are now filtered below Warning, matching the existing health-check filter; the path was never used — scenario-relative paths resolve against the suite's own directory. DCP diagnostics and all warnings/errors still surface.

Security

  • The CI workflow token now defaults to least privilege (permissions: contents: read at workflow level); the coverage-badge job retains its job-scoped contents: write.

[1.0.0-alpha.7] — 2026-07-13

A hardening and housekeeping release: no engine, DSL or contract changes.

Security

  • Transitive security dependencies are now pinned centrally via CentralPackageTransitivePinningEnabled (MessagePack 2.5.301, SharpCompress 1.0.0), so vulnerable transitive versions cannot resolve silently.
  • The VS Code extension's undici development dependency was bumped to 7.28.0 (Dependabot).

Changed

  • The GitHub Pages site generator was extracted into the shared vouchfx-site-tools package, now consumed by all four ecosystem repositories instead of four diverging copies.
  • pages.yml actions are SHA-pinned and the ecosystem notify dispatch curl was repaired ahead of cross-repo docs fan-out activation.
  • Documentation truth-up after the pilot-programme discontinuation, with migration-guide cross-links, and a new knowledge-base article on DCP orchestrator portability backed by regression tests over the self-heal glue.

[1.0.0-alpha.6] — 2026-07-12

The packaged-tool portability release: the NuGet-installed tool now works on machines other than the release build runner.

Fixed

  • The NuGet-installed vouchfx tool failed its first run on every machine other than the CI runner that packed it (all earlier pre-releases). The engine now self-heals the Aspire DCP path at topology start — when the build-time baked path does not exist, it re-resolves the platform- and version-exact aspire.hosting.orchestration.<rid> package from the executing machine's NuGet cache, honours a user-set ASPIRE_DCP_PATH, and otherwise fails with an actionable environment error. A cross-machine smoke test now gates every release publish. See the knowledge-base article docs/kb/dcp-orchestrator-portability.md for the full write-up.

[1.0.0-alpha.5] — 2026-07-11

The alpha series continues with minor feature additions and dependency updates.

Added

  • script.csharp steps now accept an optional file field: a path to an external .csx file, resolved relative to the .e2e.yaml file's directory, read once at compile time and spliced verbatim into the generated code. Provides an alternative to inline code for larger scripts. code and file are mutually exclusive.

Changed

  • Dependency version upgrades via Dependabot (cosign-installer, actions/checkout).

[1.0.0-alpha.4] — 2026-07-10

The .NET identifier space is rebranded to Vouchfx.* ahead of v1.0 GA, replacing the generic Platform.* naming used in earlier alpha releases.

Changed

  • The .NET identifier space is rebranded pre-GA: package IDs, assembly names, and namespaces move from Platform.* to Vouchfx.* across the engine and all Core providers. The .e2e.yaml language and JSON wire contracts remain unchanged (frozen at v1). Provider SDK packages published as Vouchfx.Sdk, Vouchfx.Sdk.Testing, Vouchfx.Engine.Abstractions, Vouchfx.Engine.Authoring, and Vouchfx.Engine.Compilation.

[1.0.0-alpha.3] — 2026-07-09

Governance structure is simplified to two tiers (Core / Community) and the Provider SDK closure is published.

Changed

  • Provider governance simplified from three tiers (Core / Verified / Community) to two (Core / Community). The former Verified tier endorsement is replaced by the Vouched badge — a maintainer-awarded registry metadata entry awarded after conformance review on the community provider hub.

Added

  • The release pipeline now packs and publishes the five-package Provider SDK closure (published under the Platform.* IDs at the time — Platform.Sdk, Platform.Sdk.Testing and the Platform.Engine.* closure — renamed to Vouchfx.* in alpha.4) alongside the CLI, enabling provider authors to consume the published NuGet packages.

Fixed

  • The mailpit SMTP docker test repaired: correct CRLF line endings in the SMTP conversation, assertions on the server's response codes, and clearer CI diagnostics on failure.

[1.0.0-alpha.2] — 2026-07-08

The same engine as alpha.1, with release-quality fixes found by cutting alpha.1 for real:

Added

  • The NuGet package carries a package README and a current description, so the nuget.org page describes the framework properly.

Fixed

  • The release pipeline's publish job works on real tag pushes — its first-ever execution surfaced a missing repository context (and a masked failure in release creation) that the smoke-test runs could not reach.
  • The Docker integration suite repaired against current dependency images: RabbitMQ 4.x forbids transient non-exclusive queue declarations (test queues are now durable, matching the documented author guidance); Elasticsearch 8.17 rejects request bodies on _refresh; and bad-image startup failures classify as ImagePull rather than HealthGate even under Aspire's generic health-gate wrapper message, using structural container-creation evidence — keeping the four-verdict taxonomy trustworthy.

[1.0.0-alpha.1] — 2026-07-08

The first public release. Everything recorded under Unreleased below, published as a pre-release for pilot validation ahead of v1.0 GA: the vouchfx dotnet global tool on NuGet.org (published via Trusted Publishing — no long-lived keys), per-OS self-contained archives, MSI/deb/pkg installers and the VSCode extension attached to the GitHub release, all cosign-signed with SLSA provenance attestations and CycloneDX SBOMs.

dotnet tool install --global vouchfx --prerelease

The Provider SDK (Platform.Sdk) is not part of the alpha package set; it ships to NuGet.org with v1.0 GA. (Superseded: the SDK closure shipped early, at v1.0.0-alpha.3, and was renamed to Vouchfx.* in alpha.4 — see those entries.)

[Unreleased]

Added

Engine and compiler

  • Compile-once execution model: .e2e.yaml → AST → CSX → one Roslyn compilation into a collectible AssemblyLoadContext, invoked N times and unloaded to baseline. A memory-leak regression test over the full transitive closure of every Core provider is a permanent CI gate.
  • Validation of every scenario against a single composed JSON Schema before any container starts.
  • Cross-step state threading via capture (JSONPath and XPath) and {placeholder} substitution.
  • Engine-owned asynchronous verification: verifyMode: RETRY with bounded exponential backoff (Polly v8) and Inconclusive-on-timeout semantics.
  • Secrets as references (${secret:env/…}, ${secret:vault/…}), resolved at step-execution time, redacted at the source; defence-in-depth scrubbing of secret values from step observations; the redaction path is penetration-tested.
  • Declarative environment seeding (SQL, fixtures, warm-up) applied after the topology is healthy.
  • Automatic per-scenario database reset for PostgreSQL dependencies between sequential scenarios (Respawn).

Orchestration

  • Headless .NET Aspire AppHost + Testcontainers topology orchestration with health-gated startup and clean, race-free teardown.
  • services (system under test, any container or csproj) and dependencies (managed resources) with ${conn:<dependency>} connection references resolved in the consumer's network context.
  • Two additional managed-dependency types: dynamodb (amazon/dynamodb-local, health-gated on its documented liveness signal — a 400 response on /, not 200) and minio (minio/minio, health-gated on its documented /minio/health/cluster readiness path), both plain containers whose connection string is synthesised post-startup (ServiceURL=…;AccessKey=…;SecretKey=…), mirroring the azureservicebus pattern.

Providers

  • Twenty-five Core providers across eleven step families: http.rest, http.soap; db-assert.postgres, db-assert.mysql, db-assert.sqlserver, db-assert.mongodb, db-assert.dynamodb; mq-publish.kafka, mq-publish.rabbitmq, mq-publish.nats, mq-publish.azureservicebus, mq-publish.redis; mq-expect.kafka, mq-expect.rabbitmq, mq-expect.nats, mq-expect.azureservicebus, mq-expect.redis; cache-assert.redis, cache-assert.elasticsearch; mail-expect.smtp; webhook-listen.http; metrics-assert.prometheus; storage-assert.s3; trace-expect.otlp; script.csharp. Kafka steps support Avro with Confluent Schema Registry. mq-publish.redis/mq-expect.redis use Redis Streams (XADD/XRANGE). metrics-assert.prometheus is the first member of the metrics-assert family: it scrapes a Prometheus text-exposition endpoint (typically the SUT's own /metrics) and asserts on one metric's numeric value, optionally scoped by a label subset, with capture: support for the matched value. db-assert.dynamodb asserts against a DynamoDB item via GetItem (a real dynamodb-local container per suite). storage-assert.s3 is the first member of the new storage-assert family: it HEADs (and, only when a body digest/substring is declared, bounded-GETs) an object in an S3-compatible store (a real MinIO container per suite) and asserts on existence, size, content type, metadata, SHA-256 digest, or a body substring, with capture: support for etag/versionId/size. http.soap is the second http-family provider: a raw-envelope SOAP 1.1 client with fault detection (fault-expectation checked ahead of status), XPath assertions and captures over the response envelope, and the same hardened-XML-reader / SSRF-guarded-path discipline http.rest established. trace-expect.otlp is the first member of the new trace-expect family and the platform's flagship distributed assertion: an engine-hosted OTLP/HTTP JSON receiver (mirroring webhook-listen.http's host-resource model) captures the spans a real, unmodified OpenTelemetry SDK exports for the transaction under test. A trace id is REQUIRED (accepting either a bare id or a full W3C traceparent, with automatic extraction) — ties the assertion to the specific transaction under test and is what makes the no-forged-match security posture an enforced guarantee rather than an authoring convention; service name, span name, and attributes are optional refinements layered on top of it, never a substitute for it — proving the causal chain a single-service assertion cannot. The receiver's ring buffer surfaces an evicted count on a Fail so a saturated-buffer flood is distinguishable from a genuinely absent export.
  • The Provider SDK (Vouchfx.Sdk): the frozen v1 contract (IStepProvider, IStepBinder<T>, IStepValidator<T>, IStepCompiler<T>, IResourceContributor<T>), optional extension interfaces (IStepDiffRenderer, IHostResourceContributor), a conformance test harness, worked example providers, and an SDK dry-run validation path.
  • Provider-catalogue expansion: the DSL specification now names the planned community catalogue (§5.7, Table 5.1). trace-expect has graduated out of its reserved-family state now that trace-expect.otlp ships as its Core provider; realtime-expect remains the sole reserved family with its intent fixed ahead of its first provider.
  • The community provider hub (vouchfx-providers) ships the first Community-tier provider — rpc.json-rpc, hosted in the hub under community/ and listed in the provider registry — a complete JSON-RPC 2.0 protocol implementation over HTTP with substitution, capture, negative testing and the four-verdict mapping, plus a Docker-free conformance test harness pattern (21 tests, no infrastructure dependencies); it doubles as the reference implementation for the hub's provider-implementation guide, with its own conformance CI lane.
  • script.csharp accepts a file field as an alternative to inline code: a path (resolved relative to the .e2e.yaml file's own directory) to an external .csx file, read once at compile time and spliced verbatim — identical trust boundary and lack of placeholder/secret substitution as code. code and file are mutually exclusive; a missing file is a clean Inconclusive validation failure, not a runtime crash.

Verdicts and reporting

  • Four-outcome verdict taxonomy — Pass, Fail, Environment error, Inconclusive — kept distinct across taxonomy, reporting and exit codes; only Fail breaks CI by default.
  • One schema-versioned JSON Lines event stream feeding every renderer: the terminal renderer (with --no-decorations plain-text mode), a self-contained WCAG 2.1 AA HTML report (--html), JUnit XML (--junit), and the raw stream itself (--events).
  • Per-attempt recording of RETRY polling, rendered as a polling timeline; captured-variable provenance rendering.

CLI and CI

  • The vouchfx CLI (dotnet global tool): scenario discovery and selection by tag, owner, path glob, or git change-set; parallel runs with topology-per-scenario isolation (--parallel); watch mode (--watch); taxonomy-aware exit codes with --fail-on-env-error / --fail-on-inconclusive opt-in gates.
  • A reusable GitHub Actions workflow and an include-able GitLab CI template (static-validated), both publishing JUnit and HTML artefacts with identical gating semantics.
  • v1-alpha/v1 floating convenience tags for consumers of the reusable workflow/template, maintained by .github/workflows/move-floating-tag.yml (force-moved to each published release's commit) — a zero-SHA-hunting quick start alongside the still-recommended SHA-pinned production tier. README documents the Dependabot github-actions (GitHub) / Renovate (GitLab) automation that keeps a SHA pin current without manual lookups.
  • Environment-configured telemetry for CI: setting VOUCHFX_TELEMETRY_INSTALL_ID alongside the endpoint and token variables emits runs under one stable, repository-chosen install identifier, so ephemeral CI runners no longer mint a fresh install id per job (see docs/telemetry.md).

Editor

  • A VSCode extension: schema-driven YAML autocomplete/validation bound to *.e2e.yaml (byte-for-byte schema-sync CI gate), C# syntax highlighting inside script.csharp blocks, and Test Explorer integration with per-step verdicts and failing-line decoration.

Telemetry (opt-in)

  • Anonymous, aggregate, allowlist-only usage telemetry — off by default, controlled by vouchfx telemetry enable|disable|status, --no-telemetry, and VOUCHFX_NO_TELEMETRY; local JSON Lines outbox with optional backend drain. Privacy allowlist enforced by permanent CI gates.

Distribution and supply chain

  • A release pipeline producing the CLI nupkg, per-RID self-contained archives, MSI/deb/pkg installers, a CycloneDX SBOM and the VSCode extension — each artefact keyless-cosign-signed and SLSA-provenance-attested, with NuGet.org publication via Trusted Publishing (OIDC; no long-lived keys).
  • The release pipeline now packs and publishes the five-package Provider SDK closure (Vouchfx.Sdk, Vouchfx.Sdk.Testing, Vouchfx.Engine.Abstractions, Vouchfx.Engine.Authoring, Vouchfx.Engine.Compilation) alongside the CLI. Symbol packages (snupkg) are carried through attestation and signing; every action in the release workflow is SHA-pinned with dependabot keeping the pins current. Bare local packs self-identify as 1.0.0-0.local.

Changed

  • The MCP companion's documentation site is livevouchfx-mcp now serves from vouchfx-mcp.vouchfx.io, the fifth fleet site, covering installation and registration, the six-tool and two-resource reference, an overview and troubleshooting. The engine surfaces that described it as having no documentation site (README, docs/ecosystem.md) are corrected, and docs/getting-started.md and the landing footer now link it too; the drift sentinel crawls it alongside the other four, and the docs-deploy fan-out notifies it. The Vouchfx.Mcp dotnet tool itself remains unpublished on NuGet.org.
  • The .NET identifier space is rebranded pre-GA: package IDs, assembly names, and namespaces move from the generic Platform.* (engine and Core providers) to Vouchfx.*; the hub's community providers adopt Vouchfx.Community.* (hub repository change). The .e2e.yaml language and JSON wire contracts remain unchanged (frozen at v1); schema goldens and provider/event contracts are regenerated as pure renames; the Platform.* SDK packages (published at v1.0.0-alpha.3 under Platform.Sdk, Platform.Sdk.Testing and the Platform.Engine.* IDs) are to be unlisted and deprecated with migration pointers (NuGet alternate-package set to the Vouchfx.* successor) now that v1.0.0-alpha.4 has published the new IDs.
  • Provider governance simplified from three tiers (Core / Verified / Community) to two (Core / Community). The former Verified tier endorsement is replaced by the Vouched badge — a maintainer-awarded registry metadata entry (vouched: true + vouchedVersion = exact reviewed version) awarded after conformance review; one hygiene-gated contribution flow on the hub; no engine code or contract change.

Fixed

  • The NuGet-installed vouchfx tool now works on machines other than the release build runner. Packages up to and including 1.0.0-alpha.5 located Aspire's DCP orchestrator only through the absolute path the Aspire.AppHost.Sdk baked into assembly metadata at pack time (/home/runner/.nuget/packages/…linux-x64… for NuGet.org packages), so every cross-machine install failed its first vouchfx run with an infrastructure error. The engine now self-heals at topology start: when the baked path does not exist, it re-resolves the platform- and version-exact aspire.hosting.orchestration.<rid> package from the executing machine's NuGet cache (NUGET_PACKAGES, else ~/.nuget/packages) via the DcpPublisher:CliPath configuration override, and otherwise fails with an actionable environment error naming the missing package and remedy. A cross-machine smoke test in the release pipeline now gates publishing.