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. Historical alpha/rc entries record the pre-GA contract corrections under their Breaking/Changed headings.

The first public pre-releases shipped beginning 2026-07-08. Stable v1.0.0 and v1.0.1 were published on 2026-10-13; v1.0.2 and v1.0.3 were published on 2026-10-15.

Unreleased records changes after the latest published release; versioned sections collect changes assigned to a release and may be prepared before publication. Public availability begins when the corresponding GitHub Release is published.

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

  • Optional top-level listeners: policies enable TLS and mutual TLS on engine-owned webhook and OTLP/HTTP listeners. HTTPS URLs are staged for configured listeners; a declared but unconfirmed inbound identity makes the default run exit nonzero. Accepted mutual-TLS captures can assert the validated client certificate's SHA-256 fingerprint. Plaintext listeners and outbound security behavior remain unchanged.

[1.0.7] — 2026-10-17

Added

  • Archive successful security probes as additive v1 security-confirmation events, with declared/observed facts and explicit replay status. HTML and JUnit consume the same records. Existing confirmation levels, verdicts and authentication/authorisation limits are unchanged.

Fixed

  • Enforce provider MinEngineVersion using strict SemVer against explicit Runtime capability metadata in CLI and public runtime hosts, before provider schema admission. Frozen public SDK signatures and helper bodies remain unchanged; reflective discovery is still trusted code.
  • Include referenced field and property initialisers in the ASCII provider-output census, including helper chains, while excluding unrelated generated source.
  • Validate documentation snippet inputs before rendering and during the final site check, matching MkDocs path semantics without reading unadmitted include bodies.

  • Consolidate the unconditional exit checks in the exit-code reference and correct environment-variable guidance: an unset service or dependency ${env:NAME} is an authoring refusal that prevents exit 0 regardless of either gating flag, while an explicitly set empty value is preserved. The v1.0.0-rc.6 entry below records historical behavior; use the current exit-code reference for the active policy.

[1.0.6] — 2026-10-17

Fixed

  • Return exit code 4 when --shutdown-on-stdin-eof cancels an active run that would otherwise finish with an INCONCLUSIVE result and exit 0. Preserve the result, ordinary Inconclusive policy and public SDK API. The official NuGet-installed CLI was subsequently checked during startup and after readiness: both cases returned 4 and settled with no case-owned Docker containers or networks.

[1.0.5] — 2026-10-16

Fixed

  • Update centrally managed MessagePack to 2.5.303 and System.Security.Cryptography.Pkcs to 10.0.13. Consolidate the exit-code reference and clarify that an unset service ${env:NAME} is an authoring refusal that cannot be suppressed by either gating flag.

[1.0.4] — 2026-10-16

Added

  • Each scenario's public secret ledger now scopes Count and Scrub to that scenario while forwarding registrations to a hidden run/watch reporting aggregate. Explicitly shared-ledger construction retains shared identity. Runtime and CLI diagnostics apply redaction, declared-path scrubbing and terminal sanitisation; raw event observations retain content scrubbing without terminal sanitisation. Scripts remain trusted C# and the ledger is not a sandbox against deliberate exfiltration.
  • The SDK adds KafkaSecurityV2Helper.Source for Core's existing V2 payload while preserving the public v1 helper, its fingerprint and serialized Core fragments. Existing providers embedding the legacy literal remain compatible; a provider adopting V2 updates its helper and emitted call, then rebuilds.

Fixed

  • Historical erratum for the #369 entry: the malformed dependency env: example in that entry belongs to the parse-failure path, not the parsed-document no-execution path. ScenarioDiscovery.ParseFile rejects it before producing a recovered document; its mixed-directory exit is governed by #425's parse-failure rule. #369 applies to documents that parse but are refused before any scenario executes. The previously recorded measurements are retained; this clarification makes no new runtime measurement claim.
  • Plain unquoted null service images normalize as missing values; direct binds refuse unless a service supplies an image or project. This changes no schema.
  • For HTTP responses, telemetry retries only status 408, 429 and 500–599. Other non-2xx statuses cause permanent refusal with a fixed payload-free stderr message; later batches continue, and a broken stderr writer does not change the disposition.
  • Ten active Core provider validation and local size-limit diagnostic messages now use ASCII hyphens in place of em dashes. Authored and observed Unicode data is unchanged; generated runtime strings and frozen SDK helper sources are unchanged.
  • Provider diff tables escape authored line breaks, tabs and other row-breaking control characters before measuring or displaying cells, preserving ordinary Unicode text and existing table layouts.
  • Watch mode refuses --html, --junit, --events/--json, and --events-stream before discovery, and leaves report files untouched.

[1.0.3] — 2026-10-15

Fixed

  • Start package publication only after a GitHub Release is published, avoiding duplicate publishers when a version tag and Release are created together; manual preflight remains non-publishing. Set the CycloneDX root component to the release version and reject stale root identities or unresolved dependency references before uploading the SBOM. The historical v1.0.2 SBOM root remains vouchfx@0.0.0; its tag, Release and assets are preserved, and v1.0.3 carries the corrected SBOM identity. This patch contains no product C# or provider SDK changes.

[1.0.2] — 2026-10-15

Added

  • Run user-supplied Docker Compose environments through the extensible environment-driver contract, combining predefined containers, provider-independent HTTP/script steps and reusable providers.
  • Isolated GitHub-hosted Windows/Linux HTTP TLS acceptance with owned AIA download controls and captured TLS 1.2 client-chain evidence. Normal CI and release jobs require all four transport facts and the final certificate guard without skips; the v1.0.2 public artifact build and publication were gated on these checks.
  • Explicit reviewed publication inputs in site-publication-inputs.json. Documentation builds refuse unregistered pages, assets and templates before rendering; the deployment gate checks the manifest again. Reserved private paths cannot be registered, including case aliases. Content edits to approved files still require human review.

Fixed

  • Numeric queue/topic/index authoring parity (#342). Step identifiers now accept bare numbers, retained as their original text; boolean-looking identifiers still require quotes. Elasticsearch field expectations accept numeric and boolean scalar text, while null remains refused. Elasticsearch index schema and typed validation now reject slash/backslash, exact ./.. names and trailing whitespace; comma-separated indices and wildcard syntax remain supported. This corrects malformed path-segment acceptance in the v1 schema; percent-escape handling and runtime transport behaviour are unchanged.
  • Improve diagnostics, native TLS execution, packaged DCP distribution and owned environment cleanup. Stabilize Windows process lifetime tests with a small .NET apphost that publishes its real PID.
  • Extend the Windows process-owned certificate-store final guard to CLI and Orchestration test hosts. Ordinary CLI collections retain parallel execution; a syntax census checks guard adoption. Older process residue, abandoned private keys and unreadable stores remain outside this guard’s proof.
  • Engine 1.0.2 source accepts leaf-first client-certificate PEM bundles with public CA candidates for offline client contexts. The scenario accessor owns the loaded certificates. Four earlier SDK helper bodies remain unchanged; external providers retaining SecurityHelper keep its existing single-leaf/platform-chain limit. The release-blocking isolated Core http.rest TLS 1.2 acceptance passed on Windows and Linux; this does not expand external-provider compatibility.

Changed

  • Core HTTP TLS handlers use the additive HttpTlsHelper and SocketsHttpHandler; declared-security options suppress certificate downloads before the handshake. Engine-owned TLS probes apply the policy to identity-bearing and anonymous connections while retaining existing revocation mode, hostname, server-authentication EKU and declared-CA semantics.
  • Pin the hidden Azure Service Bus SQL sidecar, Vault and whoami Docker fixtures by reviewed digests. SQL remains Linux amd64 only; authored emulator and S3 override precedence is retained. A read-only weekly audit verifies release-tag identity and platform coverage without updating pins.
  • Core provider diff tables use ASCII separators; literal pipes in cells are displayed as \u007c. Unicode authored and observed values are retained. Public schema descriptions use ASCII punctuation; validation grammar, frozen SDK helpers and event fields are unchanged.

[1.0.1] — 2026-10-13

Fixed

  • Preserve the original Compose startup or endpoint-discovery error classification after successful cleanup while retaining ordinary readiness error handling.
  • Bound author-controlled names in schema diagnostics so long labels cannot bury the refusal and its remedy.
  • Release publication skips packages already present on NuGet and repairs symbol publication independently. Stable installation instructions describe the published packages and bundled DCP tools.

[1.0.0] — 2026-10-13

Added

  • A shared-app Docker Compose orders sample. The external Compose environment guide links a five-assertion .NET transaction covering REST, PostgreSQL, Kafka, an outbound webhook and a final refetch. The sample uses its own pinned Compose images, explicit native healthchecks and distinct internal/host Kafka listeners; the existing inline sample remains available.

  • Explicit TCP service bindings for owned Docker Compose environments. A logical service can declare type: tcp and readiness: {type: tcp} for host-port reachability, or {type: healthcheck} for an explicitly configured native healthy check plus reachability. Kafka steps receive the existing bare svc::<name> bootstrap value. Missing/disabled healthchecks, ambiguous ports and replicas are refused; startup cancellation and cleanup retain exact project ownership. HTTP/PostgreSQL forms, provider interfaces, shared helpers and event fields remain unchanged. The configuration guide describes listener advertisement and IPv4 limits. These bindings ship in the stable CLI.

  • Managed Kafka PEM TLS and mutual TLS — the default pinned broker provisions the selected tcp, internal or numeric container listener using Aspire's allocated host address for advertisement, steps and confirmation. Engine-owned listener roles relocate collisions; Schema Registry and container connection references retain the other plaintext listener. Equivalent environment-property spellings cannot bypass listener ownership or PEM material checks, including selected-listener material settings that Kafka applies before global fallback. Conflicting aliases of one material location are refused before acquisition; global and selected-listener locations may differ if both are declared server artefacts. The runnable recipe covers certificates, bounded readiness and asynchronous consumption. Typed acquisition failures of the declared secured target's own readiness, discovery or server-material access now prevent a default green exit; unrelated or unattributed infrastructure retains opt-in gating.

  • Owned Docker Compose environments with existing HTTP/PostgreSQL assertions. environment.source: {driver: compose, config: ./environment.compose.yaml} selects an independently authored environment through a format-neutral registered-driver plan/ready-lease boundary. The adapter owns its configuration grammar, uses native merging and random IPv4 port discovery, proves endpoint readiness and creates a fresh project per scenario/watch run. Cleanup verifies project ownership independently of edited manifests; vouchfx environment cleanup --journal recovers interrupted projects without original configuration or credentials. Separate schema export/editor support and an HTTP mutation/database example accompany the change. Unsupported shared resources, seed, reuse, delegated Compose providers and external-driver TLS/mTLS are refused. EnvironmentSpec.Source is additive; provider interfaces, CSX helpers and event fields remain unchanged. This environment adapter ships in the stable CLI.

  • Installed CLI packages carry matching Aspire DCP tools. Global tools bundle complete native trees for win-x64, glibc linux-x64, osx-x64 and osx-arm64; archives/installers carry their selected RID. Inline topology startup prefers its host-relative bundle over build metadata/cache, preserves ASPIRE_DCP_PATH, and rejects incomplete bundles. Release checks enforce native payload integrity, executable permissions, a conservative 250,000,000-byte NuGet ceiling and a source-restore-free installed-tool smoke. The stable CLI contains these tools; older 1.0.0-rc.6 installations remain cache-dependent.

  • A new s3 managed-dependency kind, backed by RustFS (#581). type: s3 provisions an S3-compatible object store from RustFS (Apache-2.0) and names the protocol rather than a product, so a suite that relies only on the S3 API, with no RustFS-specific image:, version: or env:, does not change when the engine's default backend does. The default image is pinned by digest, rustfs/rustfs@sha256:8cc98017… on Docker Hub (RustFS 1.0.0), because Aspire's image annotation cannot hold a tag and a digest together; version: replaces the digest with your tag (trading the content pin for a mutable tag; image: rustfs/rustfs@sha256:<digest> keeps a pin), image: replaces the whole reference, and an env-level imageRegistry applies. A mirror must serve the multi-arch index under that digest. The engine sets RUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY, port 9000 and data directory /data, and gates on /health/ready; an author env: naming either variable is refused, and the refusal wording now reads "…and on 'minio' and 's3' they are the credentials in the connection string the engine hands the steps of every scenario that targets it (for those two kinds, a service's ${conn:} carries only host and port)…". ${conn:<dependency>} resolves host and port, the connection string has the same ServiceURL=…;AccessKey=…;SecretKey=… shape as minio's, and the store is not reset between sequential scenarios, like minio. minio is unchanged: it keeps the MinIO contract on its frozen bitnamilegacy/minio pin. storage-assert.s3 now accepts an s3 or a minio target (any other kind is refused with "is not an s3 or minio dependency…"), examples/storage-assert-s3.e2e.yaml now declares type: s3, the scaffolder prefers s3 for new suites, and storage-assert.s3's Resources() now reports the family "s3" for either target kind where it reported "minio" (nothing in the engine reads that value). An image: override swaps only the image, not the contract, so it must honour the kind's own: a MinIO build for minio, a RustFS build for s3.

  • The terminal, HTML and JUnit reports now say how many event-stream lines they could not read, and telemetry counts them too (#588). Each renderer used to skip an unreadable line silently. Each now counts the lines its per-line guard skips — a malformed line, a line the stream reader refuses (for example "runId": null), or a line with a value that renderer reads and cannot decode (for example a lone-surrogate escape) — and, only when the count is above zero, says so: a trailing terminal line and a paragraph after the HTML run summary, both warning that the output may be incomplete because a line can fail after part of it was shown, and a vouchfx.skippedEventLines property as the first child of the JUnit <testsuite>. The text carries only the number, never line content. Each renderer counts only the lines it could not read, and the renderers read different fields, so the counts for one stream can differ. The HTML renderer now reads every field it shows while it reads each line, and no longer swallows an unreadable value: it used to drop without trace a step whose id could not be read, show a stand-in such as (unknown) for an unreadable provenance or reproducibility-envelope value, and, for some unreadable property names in those entries, throw part-way through writing the page, which FileReportWriter reported as "Report write failed for 'html'" and which left a truncated file. Such a line is now counted like any other. Telemetry gains skippedEventLines, the number of lines the telemetry builder could not read, counting a line whose typed record it refuses once, and its schemaVersion is now 2: a backend that predates tomas-rampas/vouchfx-telemetry-backend#30 parses version 1 strictly and refuses a whole batch at the first unknown field, while it accepts later versions leniently, so such a backend accepts these events and ignores the new count until it is updated. The first-run notice and the telemetry enable confirmation now list the new count.

  • 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 any of those 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 every excluded kind), 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 every kind other than kafka 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.

  • A published security compatibility matrix answers "can vouchfx reach my secured X?" per integration, covering all 25 step types and every dependency kind, 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.

  • env: on a managed dependency — environment.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, s3 access and secret keys, 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, when that refused scenario is the run's only outcome, nothing has executed and the run exits 4 with or without --fail-on-inconclusive (beside other scenarios the ordinary exit-code rules apply). The engine relies on those values to bring the dependency up in the shape every scenario shares, and on minio and s3 they are the credentials in the connection string the engine hands the steps of every scenario that targets it (for those two kinds, a service's ${conn:<dependency>} carries only host and port); variables set by Aspire internally are not detected and will take the author's value instead.

  • Response-body assertions for http.rest (#558). expect gains two optional members beside status: json maps a JSONPath (RFC 9535, used verbatim) to an expected value or to { exists: true } / { exists: false }, and bodyContains names a substring of the decoded body. The vocabulary is borrowed rather than invented — json has mq-expect's match.json key and shape and compares against the same canonical text capture already writes, exists is the word cache-assert.redis, db-assert.dynamodb and storage-assert.s3 use, and bodyContains is webhook-listen.http's. Equality needs exactly one node, so "$.items[*].status": SHIPPED fails as multipleNodes rather than passing because the first item shipped, and a JSON null present in the body compares as the text null (written quoted; an unquoted YAML null is still refused as a forgotten value). Both members are evaluated only once the status check has held, and before any capture runs. An assertion that does not hold is Fail — a body that is not JSON included — and its observation gains a body member, {"failed":k,"of":n,"first":{…}}, naming the path, the reason and the expected template — the path and template each capped at 256 characters with a trailing ellipsis — with at most the JSON kind or node count of what was found: no text from the response reaches it, because a service may echo a secret back. A json entry the library itself cannot evaluate against an otherwise-valid body is the same Fail, reason unevaluable — measured on the pinned JsonPath.Net: a filter comparing a number beyond decimal's range, or (defensively, since vouchfx validate already refuses this at the key) a malformed expression; any other fault raised while evaluating it — cancellation, an out-of-memory condition, or an unexpected library defect — is never folded into that Fail and instead reaches the step's own environment/cancellation handling (§12.1), because that is not a claim about the response body. Expected values may carry {placeholder} and ${secret:…} tokens, resolved when the step executes and before the request is sent; a placeholder naming an unset variable makes the step Inconclusive (placeholderUnmet) with nothing sent, instead of comparing against an empty string. A bodyContains value that resolves to the empty string once every placeholder and secret is substituted gets the identical classification (bodyContainsResolvedEmpty), and nothing is sent, rather than a vacuous Pass: every body "contains" "". Under verifyMode: RETRY every attempt re-sends the request and re-evaluates the body. vouchfx validate refuses a key that is not a JSONPath, an empty bodyContains, and any body assertion on HEAD or alongside status: 204/304. http.rest now implements the existing optional IStepDiffRenderer, so a body failure renders an assertion │ expected │ actual row; every observation it emitted before this change keeps its exact shape and still renders no diff. The composed schema golden (with the VS Code extension's copy) and docs/language-reference.md move additively, for http.rest only; the SDK surface, its helper-source hashes and the event-wire contract do not move.

Changed

  • Pre-GA environment-name grammar correction (#607). Service and managed-dependency env maps now require an ASCII letter or underscore first, then ASCII letters, digits, underscores or dots, across the schema, parser and eager mapper. Malformed names cannot bypass per-kind engine-owned variables by appending =...; keys are never split, trimmed or renamed. Dotted names and case remain intact. Malformed-name diagnostics use the owning map without echoing names or values; typed mapper secret/reference refusals retain value-first priority. Compose adapter configuration uses the same dotted grammar while retaining its separate COMPOSE_/DOCKER_ restrictions. This deliberately narrows invalid authored inputs before GA; the composed and editor schemas are regenerated.

  • An environment-level imageRegistry applies to the minio dependency again (#580). #533 exempted the built-in pin from mirroring because it had moved to quay.io/minio/minio, which a Docker Hub mirror cannot serve. The pin is now the unqualified bitnamilegacy/minio:2025.7.23-debian-12-r5 on Docker Hub, so imageRegistry: artifactory.mycompany.com/docker-mirror redirects it to artifactory.mycompany.com/docker-mirror/bitnamilegacy/minio:2025.7.23-debian-12-r5, like every other Docker Hub dependency. A suite that relied on the exemption to reach quay.io directly now goes through the mirror, which must be able to serve that repository. A per-dependency image: override still names any registry and repository explicitly.

  • A minio dependency now pulls from quay.io/minio/minio, and an env-level imageRegistry no longer applies to it (#533). The default image for the minio dependency kind moved registry, at the same tag (RELEASE.2025-09-07T16-13-09Z): Docker Hub no longer serves the minio/minio repository at all, so the previous bare reference was unpullable for anyone without a cached copy — measured, an unauthenticated pull returns pull access denied for minio/minio, repository does not exist or may require 'docker login', and Docker Hub's own API answers {"message":"object not found"} for the tag and the repository, while quay.io/minio/minio publishes the same release tag publicly, as MinIO's own build target rather than a third-party mirror (upstream's Makefile line 9 is REPO ?= quay.io/minio). Precisely what that establishes: the same tag string from the same publisher — byte-equivalence with the image Docker Hub used to serve is not checkable, since Hub now serves nothing to compare against. A suite that names no image of its own and sets no env-level imageRegistry needs no change and simply starts working again. The behaviour change is for authors who redirect images with an env-level imageRegistry: that override has always been documented never to rewrite an already-qualified reference, and quay.io/minio/minio now is one — so the redirect that previously produced <mirror>/minio/minio for this dependency no longer fires at all, and the dependency is pulled from quay.io regardless of the mirror. This is deliberate rather than an oversight: applying the mirror on top would yield <mirror>/quay.io/minio/minio, a path that does not exist. Mirroring and air-gapped users redirect this dependency with the per-dependency image: override, which is unaffected and takes an arbitrary registry (dependencies.<name>.image: my-nexus.corp/mirror/minio:RELEASE.2025-09-07T16-13-09Z), with imagePullPolicy unchanged either way. Carry the tag in that value, as shown: an image: with no tag and no sibling version: is refused at validation, because it would otherwise float on :latest. No other dependency kind is affected: of the thirteen, azureservicebus is the only other whose default image literal already embedded a registry (mcr.microsoft.com/…, for the emulator and its SQL Server 2022 sidecar alike) and it already skipped the redirect; the remaining eleven are unqualified and still take it. Superseded by #580: the default is back on Docker Hub and imageRegistry applies to it again.

Fixed

  • Pin scaffolded services, generated catalogue examples and runnable getting-started/reference fixtures to an explicit image version so they pass the deterministic-image guard.

  • Inline image validation agrees between validate and run. Both services and dependency overrides use the same topology-free reference, digest-algorithm and pin checks. Service images now require an explicit tag or SHA-256 digest instead of silently selecting :latest; dependency version precedence, empty overrides, registry redirection and external-driver configuration remain unchanged.

  • Schema validation reads bare integers as decimal. Leading-zero httpPort values such as 0123 and 08080 now validate as 123 and 8080, matching execution. Out-of-range values such as 0177777 produce a located range diagnostic. Quoted values and existing endpoint-selector spelling rules retain their behaviour.

  • Reject raw C0/DEL/C1 control characters in the authored path of http.rest, http.soap, and metrics-assert.prometheus during provider and composed-schema validation, while preserving percent-encoded text and existing path diagnostics (#344).

  • Security declaration diagnostics cover inert TLS passphrases and unsupported references. Pre-topology validation refuses a TLS-only passphrase without a client identity and explains secret-reference misuse in security endpoint and container-target fields. Diagnostics withhold passphrase and reference text; secret resolution, transport policy and frozen SDK helpers are unchanged.

  • Pre-topology faults and authoring refusals retain consistent diagnostics. Unexpected environment binding and pipeline escapes are Inconclusive engine faults with archived phase/type-only causes and nonzero exits; genuine acquisition failures keep their existing infrastructure policy. validate --json emits one diagnostic per independent secret-reference fault, with safe field identity and passphrase withholding. Shared-environment divergence is an Inconclusive authoring refusal in live and archived events, HTML and JUnit, including secured suites.

  • Fix YAML validation of Unicode string values and HTTP request body keys, including U+2028/U+2029 separators, while preserving scalar types, aliases, and the JSON conversion size limit.

  • Seed SQL fixture paths now share the lexical suite-relative containment policy used by security artefacts; rooted and base-escaping entries are refused before filesystem or driver access. Seed report details identify driver error types/codes without echoing raw SQL errors. Kept-topology reseeding resets supported SQL Server/MySQL schema objects as well as Postgres, allowing ordinary non-idempotent table fixtures to run again; MySQL database charset/collation are retained. See docs/seed-fixtures.md for reset scope and the symlink/DDL limitations.

  • Unicode across redirected CLI streams. Redirected stdin, stdout and stderr use UTF-8, without an output BOM, before command dispatch. Scaffold labels such as Café — naïve §1 round-trip through file or stdin intents and stdout or file output on non-UTF-8 Windows consoles. Interactive handles, shared console pages, JSON escaping, output bounds and shutdown budgets remain unchanged. MCP decoding must ship with the matching engine pin; published pre-releases may predate this source change.

  • S3 absence assertions confirm the bucket before accepting object404. A failed bucket HEAD is an environment error for either existence expectation; an existing object needs no extra bucket request. Confirmation shares the existing client, cancellation token and retry budget. Mismatch observations omit resolved expected values and returned metadata/content-type strings while retaining byte counts, calculated digests and supported captures. Historical diff observations remain readable.

  • Kafka retained-log assertions no longer depend on consumer-group rebalance timing. Each bounded attempt discovers and manually assigns every topic partition from the beginning, so retained matches in any partition can be read within the existing drain window. Plain and Avro matching retain their existing observations, capture behaviour and retry budgets.

  • Caller cancellation reaches generated steps and wins before final-result promotion. Isolated and reused delegates supply the caller token to IMMEDIATE bodies and RETRY attempts/backoff. A provider returning normally after cancellation cannot forge a completion or timeout verdict. Invocation services preserve the caller map's lookup semantics and live updates without modifying it; caller cancellation remains distinct from a step-budget timeout.

  • Telemetry discards permanently refused HTTP 400 batches atomically and continues with later batches. Valid neighbours in a refused batch are intentionally discarded; retryable failures retain their raw bytes and idempotency keys.

  • Run telemetry now requires a successful current raw archive write, including writer flush and disposal. Older readable archives cannot produce events after discovery or report-write failure; newly written refusal archives remain valid.

  • Both telemetry consent notices and their public copies disclose every field in the closed telemetry allowlist, including the random install identifier and UTC timestamp.

  • Git change selection recovers from confirmed executable-load failures on earlier PATH entries. The real repository-root lookup tries ordered absolute candidates within fixed limits: 220 complete entries, 32,768 UTF-16 code units and 32 distinct launch attempts. Windows still accepts only git.exe; POSIX still checks execute permission. Typed invalid-image or missing-interpreter errors can advance to the next candidate before execution. The first binary that starts is pinned for diff and status; child errors, timeouts, failed captures and cancellation never trigger fallback. Exhaustion is a fixed path-free exit-2 refusal. Native Windows and nonroot glibc Linux container controls cover the repaired cases; native Linux host, macOS and musl acceptance remain separate.

  • The documentation publication guard now handles marked DATA syntax consistently. Standard marked bodies preserve surrounding-text adjacency; conditional markers contribute boundaries while their condition text remains scanned. Unsupported headers and unfinished DATA tag syntax are refused safely. Marker names cannot hide adjacent split identifiers, and streaming input preserves wrapper classification. XML character data, actual HTML source exemptions, independent fields, single entity decoding and shared parsing limits remain unchanged.

  • The documentation publication guard now scans JSON declaration and processing-instruction bodies. Declaration, bogus-comment and processing-instruction openers form text boundaries; their bodies remain DATA and receive bounded inline projection, including unfinished input and marked-section nesting. Malformed DATA closing tags are refused safely. Nested attributes preserve single entity decoding; actual HTML source exemptions, XML character data, independent JSON fields and shared parsing limits remain unchanged.

  • The documentation publication guard now retains marked-section character data. XML CDATA remains literal and contiguous with surrounding XML text, including inside script/style elements. Published JSON marked-section bodies receive bounded inline projection even at end of input; actual HTML source comments and script/style bodies keep their existing exemptions. Shared work limits, independent JSON fields and schema annotation handling remain unchanged.

  • Complete precompile secret-reference validation, substitution provenance and reference hashing for all 18 Core providers that resolve secret templates (#563). Structured REST bodies include the exact bound JSON and its keys. Unknown or malformed references refuse before environment acquisition and cannot exit 0, including beside passing scenarios; the refusal yields 4 unless another policy already selected a nonzero code (for example, an assertion failure yields 1). Provider-owned literal and placeholder-only fields keep their existing modes, including PostgreSQL's historical scan. An independent Bind/Emit census detects new resolving inputs and providers without changing frozen SDK interfaces or helper source.

  • Telemetry's startupMs and timeToFirstTestMs read 0 on almost every run, because both were anchored on the earliest event in the archive rather than on the run's start (#568). That event was normally the first scenario-started line itself; a transport-notice emitted before it became the anchor instead. The CLI now captures the run-start instant once via DateTimeOffset.UtcNow at the very beginning of ExecuteRunPipelineAsync (before suite discovery) and threads it through to TelemetryEventBuilder.Build, which measures startupMs = (first scenario-started ts) − (run start), and timeToFirstTestMs = (first step-completed ts) − (run start); both are clamped at zero. A scenario-started or step-completed line whose typed record is refused (for example "scenarioId": null) or which carries no ts now feeds neither timing, matching the Planner's own EventHistoryReader rule for these lines and closing the divergence recorded on #568. Because the --events archive is reconstructed after each scenario completes, each scenario's step lines in it share one timestamp (the instant that scenario's script returned), so timeToFirstTestMs spans the whole first scenario's steps, not its first step alone. Values reported by earlier builds are not comparable with these.

  • Three dependency kinds with no built-in Aspire helper (mailpit, dynamodb, minio) and the kafka schema-registry sidecar registered their default container image with no registry qualification at all, so an unqualified short name could resolve through Podman's registries.conf search list instead of Docker Hub (#582, found in review of #583). Every Aspire-provided kind already names its registry in ContainerImageAnnotation.Registry through its own AddXxx helper — docker.io for postgres, mysql, mongodb, redis, rabbitmq, nats, elasticsearch and the kafka broker, mcr.microsoft.com for sqlserver — and azureservicebus embeds mcr.microsoft.com/ in both of its image strings; these four are registered via the lower-level AddContainer(name, image, tag), which sets no registry at all. Each of the four now sets the annotation's registry to docker.io, so the reference handed to the container runtime is the fully qualified docker.io/<repository>:<tag>, the same image Docker already resolved. The engine's override rules do not change: the image strings themselves stay unqualified, so an env-level imageRegistry still applies exactly as before — ApplyImageOverrides/ApplySidecarRegistryAndPullPolicy decide whether to re-prefix from the image STRING (HasExplicitRegistryComponent), never from the Registry field this fix sets, so the mirror overwrites the new default the same way it already overwrote each Aspire-provided kind's own built-in default — and a per-dependency image: override still clears the default unconditionally, as it always has. On a Podman host that routed short names to an internal registry through its search list, set imageRegistry, which redirects every unqualified default, not only these four. The CI pre-pull list and the DSL docs' registry-override composition rule are unchanged.

  • The frozen v1 event-wire golden reflected only public instance properties, so a [JsonInclude] field or a non-public [JsonInclude] member could join a frozen record's wire shape without moving the golden or failing the gate (#578). EventContractFreezeTests renders each snapshotted record via Type.GetProperties(Public | Instance), but System.Text.Json also maps a public field carrying [JsonInclude], and a non-public property or field carrying [JsonInclude], as wire members — both invisible to that scan, although since #573 EventStreamJson.FromLine<T>'s required-member guard already reads a required one at run time. Found in review of #575. A new census test asserts, per frozen record, that the STJ-mapped member set (EventStreamJson.Options.GetTypeInfo(type).Properties, the same contract FromLine<T> itself reflects over) is identical — by wire name, CLR type, required, and extension-data — to the golden's rendered public-property set, naming any member present in only one side. A negative test proves the census can see what the golden structurally cannot: a private record with a [JsonInclude] field and a non-public [JsonInclude] property, neither reachable via GetProperties(Public | Instance), both surfaced by the new comparison; the same record shows that [JsonIgnore] on a rendered property, which drops it from the wire and which neither gate could see before, and a [JsonRequired] without the required modifier, which changes what System.Text.Json enforces on read, now fail the gate too. Golden/event-stream-wire-contract.v1.txt is unchanged byte-for-byte — the gap is closed by a second, independent assertion, not by widening what the golden itself renders.

  • #578's census closed the "member present or not" gap but not HOW a mapped member is represented on the wire, so a property- or type-level [JsonConverter], [JsonNumberHandling], [JsonObjectCreationHandling], type-level unmapped-member handling or polymorphism, an explicit [JsonPropertyOrder], or a conditional [JsonIgnore(Condition = …)] on a frozen record could still change the wire with both EventContractFreezeTests gates green (#586, found in review of #578). [JsonRequired] needed no new check: #578's required comparison already caught it. Each member-level attribute now renders onto the golden line when present and is compared by the census against System.Text.Json's own JsonPropertyInfo; the type-level equivalents render onto the record's golden header line and are compared against JsonTypeInfo. Every attribute is read without inheritance, as System.Text.Json reads it. The golden names the converter type a [JsonConverter] DECLARES, never the converter System.Text.Json creates from it, so swapping one JsonConverterFactory (such as JsonStringEnumConverter) for another that creates the same internal converter type but writes different bytes is visible. The census compares only whether a custom converter is present. That suffices because System.Text.Json always instantiates the declared type when the attribute names one (measured: an attribute subclass's CreateConverter override is then ignored), and the census still reports the one shape with no declared type, an attribute subclass that supplies its converter only from CreateConverter. Polymorphism settings are pinned: the [JsonDerivedType] list (polymorphic even with no [JsonPolymorphic], as System.Text.Json treats it), the discriminator property name, the discriminator's kind (an int writes bare, a string quoted), unknown-derived-type handling, and whether unrecognised discriminators are ignored. A completeness test requires every derived type a frozen record declares to be a frozen record itself, because pinning the list does not freeze a derived type's own members. A named pin asserts that the only extension-data member across the frozen records is EventEnvelope.Extra. [JsonPropertyOrder] is pinned only as an explicit per-member attribute, not as wire member order in general: the golden's own property list is sorted alphabetically by CLR name, so a reordered declaration, or a member moved into a base record, changes nothing this gate can see; a byte-level golden that would see it is tracked separately (#600). A named-assertion test pins every EventStreamJson.Options setting that affects what it writes or parses: naming policies, ignore conditions, number and unknown-type handling, unmapped-member handling, reader settings, the converter list, and construction-time read-only-ness. It also asserts that the type-info resolver is System.Text.Json's own shared default, JsonSerializerOptions.Default.TypeInfoResolver, which System.Text.Json builds read-only, and not a private DefaultJsonTypeInfoResolver that EventStreamJson.CreateOptions could install, whose Modifiers stay mutable until its first use even after the options are made read-only; a CreateOptions edit that installs any other resolver, even an empty new DefaultJsonTypeInfoResolver(), fails the pin. Two tests pin the System.Text.Json version the gate's measurements were taken on: one the major version of the assembly the test process loads (the in-box 8), the other the major version of the central System.Text.Json PackageVersion in Directory.Packages.props, which the CLI ships (10). Either failing a test means re-measuring the notes in EventContractFreezeTests. Golden/event-stream-wire-contract.v1.txt is unchanged byte-for-byte: none of the frozen records carries any of these attributes today.

  • VerdictJsonConverter's unknown-token diagnostic quoted the token it was rejecting — the one vouchfx-authored event-stream exception message that still quoted input read from the line (#576). §17's redaction-at-source rule means every other event-stream guard message is fixed text plus a wire or type name; this one built Unknown Verdict token "{token}". Accepted tokens… from the string the wire actually carried, so a hostile or malformed verdict field travelled straight into the thrown message. No in-tree consumer prints this text today — every §14 renderer and reader catches JsonException per line and moves on without inspecting Message — so nothing was disclosed in this repository's own output. The message is now fixed text naming only the four accepted tokens (PASS, FAIL, ENV_ERROR, INCONCLUSIVE); a new test deserialises a distinctive marker as the verdict token and asserts it never appears in the thrown message. System.Text.Json's own parse diagnostics still carry the JSON path (any property name or dictionary key read from the line, such as an unknown member or a correlationIds key, at full length) and the offending bytes, and for a polymorphic T the message quotes an unrecognised $type value, so a consumer must not treat a caught exception's Message as redacted.

  • EventStreamJson.FromLine<T> let System.Text.Json's NotSupportedException escape every consumer's per-line tolerance filter for an abstract or interface T (#579). §14's renderers and readers tolerate one malformed line at a time via catch (Exception ex) when (ex is JsonException or InvalidOperationException); System.Text.Json throws NotSupportedException instead — a type neither filter names — for an abstract [JsonPolymorphic] T with no $type discriminator on the line, for an interface T, and for a polymorphic derived type carrying its own type-level converter. Left uncaught, such a line would abort the caller's read loop instead of being skipped like any other malformed line. FromLine<T> now wraps its one Deserialize<T> call and rethrows as JsonException with fixed text naming only typeof(T).Name, with the original NotSupportedException attached as InnerException. That inner exception is System.Text.Json's and is not redacted: its Path: suffix can name a property or dictionary key read from the line, and a custom converter's own message is preserved, so do not log ToString() or the inner exception; only this wrapper's own Message is fixed text (see #576). For a T System.Text.Json can never bind, a non-polymorphic interface or abstract type, every object line now throws this JsonException, so a per-line skip filter silently drops the whole stream where it used to abort. Unreachable in-tree today — every typed FromLine<T> call under src/ passes a concrete, non-polymorphic record — so this matters only for a consumer of the published Vouchfx.Engine.Abstractions package that calls FromLine<T> against its own abstract or interface type.

  • EventStreamJson.FromLine/FromLine<T> still let two classes of hostile line escape every consumer's per-line tolerance filter after #579 (#584). §14's renderers and readers tolerate one malformed line at a time via catch (Exception ex) when (ex is JsonException or InvalidOperationException); #579 closed that gap for System.Text.Json's own NotSupportedException, but left two more open. A CONSUMER-DEFINED T's own init accessor, [JsonConstructor], or a member [JsonConverter]'s Read can each throw while System.Text.Json binds it, and — measured on STJ 8 and 10.0.8 with an InvalidDataException and an ArgumentException — System.Text.Json passes such a throw through unwrapped, aborting the caller's read loop for a line that should have been skipped like any other malformed one. And a line string containing an unpaired UTF-16 surrogate makes Deserialize throw ArgumentException ("Cannot transcode invalid UTF-16 string to UTF-8 JSON text.") from either overload. Both overloads now wrap their one Deserialize call in a single broadened catch — ex is not (JsonException or InvalidOperationException or OutOfMemoryException or OperationCanceledException or TypeInitializationException), folding #579's narrower NotSupportedException-only filter into it — and rethrow as JsonException with fixed text naming only the bound type, the original attached as InnerException. OutOfMemoryException, OperationCanceledException and TypeInitializationException stay unwrapped deliberately: resource exhaustion, a cancellation a consumer's converter observes (FromLine takes no token) and a failing static constructor of T, of a member's type or of a derived type a $type discriminator selects are never a malformed line. A converter's own static constructor is not covered: System.Text.Json creates an attribute-declared converter through reflection, so that failure arrives as a TargetInvocationException and is wrapped. As before, the inner exception is System.Text.Json's or the consumer type's own and is not redacted — a Path: suffix or a converter's own message can carry a value read from the line — and only the wrapper's own Message is fixed text: a JsonException System.Text.Json raises itself carries the same Path: in its own message (#576), so a consumer must not treat any caught exception's text as redacted. A null line still throws ArgumentNullException. Neither class is reachable in-tree, so this matters only for a consumer of the published Vouchfx.Engine.Abstractions package: every typed FromLine<T> call under src/ passes an Abstractions record whose members are auto-properties, and the one converter EventStreamJson.Options registers, VerdictJsonConverter, raises nothing outside that filter; the file readers (File.ReadLines, File.ReadAllLinesAsync) decode an ill-formed byte to U+FFFD, never to a lone surrogate; and the renderers are handed lines ToLine produced, whose writer replaces a lone surrogate with U+FFFD and emits it as the escape \uFFFD rather than as a raw surrogate.

  • The minio dependency could not start without a cached copy of its image, and the Docker/Aspire CI lane failed at its pre-pull step on every run, because MinIO withdrew its container images (#580). quay.io/minio/minio no longer serves any tag and its API requires authentication, and docker.io/minio/minio denies every tag, old releases included, so the kind's pin quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z was unpullable. The kind now pins bitnamilegacy/minio:2025.7.23-debian-12-r5, a Bitnami build of MinIO 2025-07-23 on Docker Hub. The kind's contract is unchanged except for the data directory: MINIO_ROOT_USER and MINIO_ROOT_PASSWORD, port 9000 and a 200 on /minio/health/cluster were measured against this tag, but the image runs as uid 1001 under a root-owned /, so server /data cannot create its directory on any start path (measured: 7 of 8 S3 docker-lane scenarios failed with "Unable to initialize backend: file access denied"); the container now serves /tmp/minio-data, a directory under the world-writable /tmp that each measured build's user can create at runtime (a health check and a bucket round-trip on this image, on Chainguard's non-root build and on the withdrawn upstream image), and the lane passes 8 of 8. Two consequences for authors: version: now selects a tag in the bitnamilegacy/minio repository, whose tags look like 2025.7.23-debian-12-r5, so a RELEASE.… value no longer resolves (it had already stopped resolving when upstream withdrew); and the /tmp/minio-data argument also applies under a per-dependency image: override, where it keeps every MinIO build measured above starting, root or non-root. This build is older than the previous pin (2025-07-23 against 2025-09-07) and, being frozen, receives no security fixes; a suite that needs a specific digest can pin one per dependency with image: bitnamilegacy/minio@sha256:…. That repository is a frozen archive ("Legacy Bitnami images (no longer updated)", last push 2025-08-19), so this is a stopgap; #581 tracks a maintained S3-compatible default. The CI lane's pre-pull now classifies quay.io's anonymous unauthorized: access to the requested resource is not authorized (and the containerd image store's 401 unauthorized wording of it) as an absent image, where it was unknown; requested access to the resource is denied is added defensively, since Docker Hub's docker pull wording for a withdrawn repository was already classified.

  • 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 current refusal rules is NOT re-measured here: the original figure needed Docker (a pinned host port already in use). Derived from the current exit policy, the unbuilt document also carries independent authoring-refusal evidence; this is no longer solely a security-rule question. The earlier "0 on both paths" remains withdrawn, and no replacement Docker measurement is claimed here. 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.

  • JUnit time was always 0.000, the HTML report never showed a scenario duration, and the archive's scenario-started line was stamped after the scenario had finished (#566). JunitXmlRenderer and HtmlRenderer read a scenario durationMs from the scenario-completed event, a field the frozen v1 event-wire contract never carried (only step-completed has one), so every <testsuites>, <testsuite> and <testcase> printed time="0.000" and the HTML scenario heading's (N ms) suffix never rendered, in every report since the renderers landed. Both renderers now derive the scenario duration from the scenario-started to scenario-completed ts gap, rounded to the millisecond and clamped at zero; a wire durationMs, should a future engine emit one, takes precedence and is clamped the same way, and the JUnit suite total saturates rather than wrapping. That derivation only became truthful with a Runtime change: ScenarioRunner stamped the archive scenario-started line at an instant taken after the script had already run (the live --events-stream copy from #262 was the only truthfully stamped one), so the pair measured report assembly, about 30 ms for a scenario whose one step took 1.5 s. The three archive scenario-started sites in the execution core now reuse the one instant captured before execution, so the rendered duration is the scenario's wall-clock time including script compilation, the engine's in-scenario staging (variables, secret scope, security configuration) and its post-step bookkeeping, but not topology startup or host listeners, and, barring a wall-clock adjustment mid-scenario, is never below the sum of its steps; a refusal that executed nothing still stamps both events from one instant and renders 0.000, including the schema and parse refusals in the owning-topology path, which read the clock twice until now. A stream with no scenario-started, or an unset timestamp, still prints 0.000. The suite time is the sum of the scenario times; topology startup and teardown fall outside every scenario, and under --parallel overlapping scenarios are each counted in full, so it is not the run's elapsed time and can be shorter or longer than it. vouchfx plan's event-history span likewise now starts at the first scenario's real start, unless a transport notice was emitted before it. The terminal renderer's total=N ms suffix had the same cause and is fixed by #569 below. No event-contract or golden change.

  • The terminal renderer never printed its total=N ms scenario suffix (#569). TerminalRenderer read a scenario durationMs from the scenario-completed event, a field the frozen v1 event-wire contract never carried, the same phantom read #566 backed with a timestamp-derived fallback in JUnit and HTML, so the suffix its own header documented had never rendered. It now derives the total from the scenario-started to scenario-completed ts gap exactly as the other two renderers do on every stream the engine writes, rounded to the millisecond and clamped at zero, with a wire durationMs, should a future engine emit one, taking precedence and clamped the same way; RendererParityTests now pins all three renderers to one value from one stream. The terminal's and the HTML report's step (N ms) and attempt tMs figures are clamped at zero as well, so no renderer can print a negative duration from a hostile stream. No event-contract or golden change.

  • A stream line with "runId": null aborted the whole HTML report (#571). HtmlRenderer indexed its per-run scenario map by the null key, and the resulting ArgumentNullException escaped the per-line tolerance catch, which handles only JsonException and InvalidOperationException, so one hand-written or corrupted line lost the entire document while JUnit and the terminal rendered a scenario from it; the same line crashed vouchfx plan in RunCorrelator, which keys a dictionary by the run id downstream of the history reader. EventStreamJson.FromLine now refuses an envelope whose required runId or type deserialised to null, with the InvalidOperationException every in-tree consumer already treats as a malformed line: the three renderers, the Planner's history reader and the telemetry builder all skip it and process the rest, and the Planner counts it among its skipped lines. required enforces presence, not non-null, and the reader does not opt into STJ's nullable-annotation enforcement, which is why the check is explicit. RendererParityTests pins that all three renderers ignore such a line. No event-contract or golden change.

  • vouchfx plan still aborted on a scenario-started line with "scenarioId": null and a stale file (#573). The typed payload records declare their own required members, which required presence-checks but does not null-check, so such a line passed EventStreamJson.FromLine<T> and reached RunCorrelator, which keys a dictionary by the scenario id; the ArgumentNullException escaped the plan command, and a scenario-completed with "counts": null would likewise have thrown inside the telemetry builder. FromLine<T> now refuses a record whose required or [JsonRequired] reference-type member deserialised to null, using a per-type list of those members taken from System.Text.Json's own contract for the type that bound the line, with the same InvalidOperationException #571 gave the untyped envelope: the Planner's history reader and the telemetry builder already treat it as a malformed line, so the Planner counts it among its skipped lines, and the telemetry builder no longer throws and neither counts the scenario nor tallies its verdicts. The builder now counts a scenario only once its completion line parsed, so a refused completion line no longer counts as a scenario; the guard also drops a step-started line with a null step id from the family and provider tallies. No event-contract or golden change.

[1.0.0-rc.6] — 2026-09-23

Added

  • ${env:NAME} placeholders in service environment variables — environment.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 Inconclusive (§12.1), not an Environment error — the taxonomy reserves that verdict for a genuine infrastructure fault, not an authoring mistake the engine catches before any container starts. It exits the process with code 0 by default, unless the caller passes --fail-on-inconclusive; --fail-on-env-error does not gate it. Without --fail-on-inconclusive, a mistyped ${env:DB_PASSWORD} produces a green CI run that executed zero steps. 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.

  • list --json step-type entries gain tier, supportedVerifyModes, docsUrl and example (#556) — four members appended to every stepTypes[] entry after atLeastOneOfGroups; the catalogue's schemaVersion stays 1 and no existing member changes name, type, order or meaning. tier is "core" or "community" according to whether the provider's assembly is in the Core set the caller supplies (the CLI supplies exactly the providers it ships, so every list --json entry is "core"), and null when a library caller supplies no set: never a guess from a name. supportedVerifyModes is ["IMMEDIATE","RETRY"] on every entry, the tokens the step-started event already uses, because the engine compiles RETRY for every step type and no provider can opt out. docsUrl links the type's section of the published language reference (e.g. https://vouchfx.io/language-reference/#httprest), using the heading id the site's renderer assigns (a test checks every Core link against the committed reference and mkdocs.yml); it is null for a non-Core entry, which the reference does not document. example is a complete, minimal suite exercising one step of the type: vouchfx scaffold's own output over one service named app or one dependency named after its kind (the step alone would name a target it does not declare), with the provenance comments left out so it carries no version; every Core example passes vouchfx validate, the 25 examples merge into one suite that also does, and a non-Core entry gets null — as does a Core entry whose step declares a target the canonical minimal environment cannot supply (the scaffolder withholds a known-invalid example rather than publish one; no shipped Core type triggers this). There is deliberately no vouched member: the Vouched badge endorses a specific version of a Community provider and is recorded in the provider hub's registry, which the engine does not read, so a consumer derives it from tier plus the hub. For in-process hosts, EngineExport.BuildCatalogue(registry, engineVersion, coreProviderAssemblies) is a new overload; the existing signature is unchanged and leaves tier, docsUrl and example null. Unlike the previous additive round, the four members are init properties rather than positional parameters, so StepCatalogueEntry's constructor and Deconstruct keep their arity: source- and binary-compatible for existing .NET consumers. The human vouchfx list table is unchanged.

  • 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} is not in this list: it is Inconclusive, not an environment error — see the Fixed entry below.) 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.

  • 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.

  • 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.

  • 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

  • A structured http.rest body: that expands past 50,000 JSON nodes no longer compiles (#346). A suite whose body: uses YAML anchors to expand beyond that used to compile and send the request; it now fails to compile and the scenario is reported Inconclusive, with a message naming the step and the limit. That is a user-visible narrowing, which is why it is recorded here rather than under Fixed. A second bound was added in the same change — 64 nesting levels, whose message names the step, the limit and the offending line and column — but it narrows nothing an author can reach, and the distinction matters because the first version of this change did not draw it. The depth bound is a backstop, not the first line of defence: schema validation converts the document with a YamlDotNet serialiser whose MaximumRecursion default is 50, so a body deep enough to breach that ceiling was already refused with a clean Failed to parse YAML: ... diagnostic before this change — measured on YamlDotNet 16.3.0, and note that the ceiling is on the whole document graph, of which body is only a subtree — and 64 is reachable only by a caller that binds a step without validating the document first. It is kept because it costs one comparison per node and because the upstream figure is a library default this provider does not control; the ordering between the two cannot invert, since the upstream limit is a fixed constant rather than a function of stack size. The node budget is reachable from a real suite, and buys a refusal that names the step instead of this provider materialising a JsonNode tree for a body a YAML anchor expanded multiplicatively. It does not prevent an out-of-memory condition: the same validation-time conversion expands every alias with no ceiling of its own, and an alias chain multiplies that expansion per level — issue #505 tracks that gap. The budget also counts nodes, not bytes: a large scalar aliased 10,000 times is ~11,000 nodes, well inside the limit, and still serialises to gigabytes.

  • 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.

  • 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 changes the earlier 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+0020 — keeping 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.

  • 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 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.

  • --events consumers: a reproducibility-envelope fixture row with no contentHash key means "the engine could not hash this file", NOT "the file was absent". This is a change to how an archived stream must be interpreted, so it is recorded here rather than only under Fixed. A fixture whose hash could not be computed serialises as {"reference":"fixtures/orders.sql"} — the contentHash key is omitted entirely (WhenWritingNull), never written as an explicit null. The broader meaning has been in effect since the fix released as part of #466, which widened the envelope builder's catch from FileNotFoundException alone to the whole IO family; before that, only a genuinely absent file produced a hashless row, and afterwards a locked file, a permission denial, a path the filesystem rejects, and a file deleted between the existence check and the read all do. envSchemaVersion does not distinguish the two eras — it did not move for #466 and does not move now — so a consumer holding two archived streams cannot tell from the stream which semantics produced which, and must assume the broader meaning for any envelope of unknown vintage. The envelope deliberately records no cause; if you need to distinguish them, treat a hashless row as "unknown input" rather than as evidence of absence.

  • 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.

  • The vouchfx tool package and the per-RID self-contained archives no longer contain XML documentation files (#490). Nothing about the CLI's behaviour changes; what changes is what a published artefact contains, which is why it is recorded here rather than left in a build file. The repo-wide doc-comment gate sets GenerateDocumentationFile=true for every project, and PackAsTool packs the publish output, so every reachable assembly's .xml was travelling inside tools/net8.0/any — measured on packs of this tree: 35 documentation files, 3,002,636 uncompressed bytes, down to none (tools/net8.0/any/DotnetToolSettings.xml, 190 bytes, is the only .xml left there and the tool manifest requires it); the compressed package goes from 73,439,218 to 72,830,026 bytes. Because PublishDocumentationFiles=false acts at publish time, the same removal applies to every dotnet publish of this project, including the four self-contained per-RID archives the release workflow builds — measured on a win-x64 self-contained publish of this tree, the identical 35 files and 3,002,636 bytes, down to none. GenerateDocumentationFile is untouched, so the gate still parses every doc comment in the project and still fails the build on a malformed or unresolvable one. Scoped to the CLI project alone: Vouchfx.Sdk and Vouchfx.Sdk.Testing are library packages whose XML is part of the package contract, and they keep theirs. The reason is that nothing consumes the tool's copy — the package is an executable, not a library, and no one compiles against the DLLs inside a dotnet tool.

  • A minio dependency now pulls from quay.io/minio/minio, and an env-level imageRegistry no longer applies to it (#533). The default image for the minio dependency kind moved registry, at the same tag (RELEASE.2025-09-07T16-13-09Z): Docker Hub no longer serves the minio/minio repository at all, so the previous bare reference was unpullable for anyone without a cached copy — measured, an unauthenticated pull returns pull access denied for minio/minio, repository does not exist or may require 'docker login', and Docker Hub's own API answers {"message":"object not found"} for the tag and the repository, while quay.io/minio/minio publishes the same release tag publicly, as MinIO's own build target rather than a third-party mirror (upstream's Makefile line 9 is REPO ?= quay.io/minio). Precisely what that establishes: the same tag string from the same publisher — byte-equivalence with the image Docker Hub used to serve is not checkable, since Hub now serves nothing to compare against. A suite that names no image of its own and sets no env-level imageRegistry needs no change and simply starts working again. The behaviour change is for authors who redirect images with an env-level imageRegistry: that override has always been documented never to rewrite an already-qualified reference, and quay.io/minio/minio now is one — so the redirect that previously produced <mirror>/minio/minio for this dependency no longer fires at all, and the dependency is pulled from quay.io regardless of the mirror. This is deliberate rather than an oversight: applying the mirror on top would yield <mirror>/quay.io/minio/minio, a path that does not exist. Mirroring and air-gapped users redirect this dependency with the per-dependency image: override, which is unaffected and takes an arbitrary registry (dependencies.<name>.image: my-nexus.corp/mirror/minio:RELEASE.2025-09-07T16-13-09Z), with imagePullPolicy unchanged either way. Carry the tag in that value, as shown: an image: with no tag and no sibling version: is refused at validation, because it would otherwise float on :latest. No other dependency kind is affected: of the thirteen, azureservicebus is the only other whose default image literal already embedded a registry (mcr.microsoft.com/…, for the emulator and its SQL Server 2022 sidecar alike) and it already skipped the redirect; the remaining eleven are unqualified and still take it.

Fixed

  • An unset ${env:NAME} reference is Inconclusive, not an Environment error — the docs and this changelog said otherwise (#437). docs/02_YAML_DSL_Specification_and_VSCode_Extension_Design.md and this changelog's own ${env:NAME} entry above both told authors that vouchfx run reports an unset variable as an Environment error (§12.1), gated by --fail-on-env-error. Measured against the built CLI: EnvironmentMapper.Map's eager pre-Configure pass throws ArgumentException, and ScenarioRunner's catch around SuiteTopology.StartAsync records Verdict.Inconclusive instead — the taxonomy reserves EnvironmentError for a genuine infrastructure fault, not an authoring mistake the engine catches before any container starts. --fail-on-env-error therefore does not gate it; --fail-on-inconclusive does. The default behaviour is unchanged — a flagless run still exits 0 — so this corrects the documentation, not the engine: both surfaces above are corrected, and ScenarioRunnerTests.RunAsync_EnvConfigReferencesUnsetEnvironmentVariable_ReturnsInconclusive_NoTopology now pins the verdict alongside its ${conn:typo} twin, so it cannot silently drift back.

  • A run that exits non-zero on #480's provider-or-engine-fault rule now says so on stdout, beside the sibling notice the security-confirmation rule already prints (#515). RunCommand has printed SecurityUnconfirmableNotice for a while, so a run exiting non-zero on the security rule always says on stdout that the security rule is why. #480's rule printed nothing equivalent, and it needed it more than the parse-failure and nothing-executed rules beside it: those two fire on a run that is visibly broken (nothing executed, or a file could not be read), while #480's rule can fire on a run whose terminal shows a passing scenario, with the refused sibling's own diagnostic one line among many — the author saw a green-looking run and a non-zero exit, with nothing connecting the two. RunCommand.ProviderOrEngineFaultNotice is the new sibling constant, printed by the new RunCommand.ShouldPrintProviderOrEngineFaultNotice predicate exactly when #480's rule is what took the exit code off Success: never when a Fail verdict, --fail-on-env-error/--fail-on-inconclusive, the parse-failure rule or the nothing-executed rule already chose the non-zero code, and never for a run the security-confirmation notice already explains — the last exclusion holds by construction, because the security-confirmation carve-out is decided inside ExitCodes.FromVerdict before #480's rule is ever consulted, so whenever it is the reason the code is not Success, the same probe this predicate uses reproduces that non-Success code and stays silent, with no separate check to drift from the security notice's own condition. The notice deliberately names no step: the diagnostic printed above it already names the offending step and its provider type where one exists, and the suite-level CSX-assembly-failure arm has none to give (CsxAssemblyException records no fragment), so one wording serves both arms truthfully. Follows SecurityUnconfirmableNotice's own recorded limitation: stdout only, absent from --junit and --events, so a machine consumer reading only artefacts still sees a bare non-zero exit for this rule too.

  • A provider exception whose Message getter throws no longer escapes the diff-renderer containment #485 built (#518). ScenarioRunner.ReportDiffRendererFault composed its once-per-(kind, member, exception type) diagnostic from ex.Message while already inside the catch BuildDiffLookup uses to contain a throwing IStepDiffRenderer — but the compose step itself read that property unguarded, so a provider exception type free to override Message and throw from its own getter escaped past the catch, past TerminalRenderer's per-envelope filter (narrowed to JsonException/InvalidOperationException, no backstop for an arbitrary second throw), and took the whole render down after every verdict was already fixed — turning #485's contained, reported, cosmetic degradation back into the unhandled exception it closed. ex.GetType().FullName is read unconditionally now (Object.GetType() is non-virtual and cannot be overridden to throw); only the ex.Message read is guarded, degrading to a fixed placeholder naming the exception's type rather than the provider's unreadable text. Same class as the CLI last-resort-diagnostic fix recorded elsewhere in this section, whose own note named this surface as the one still open.

  • The azureservicebus dependency no longer leaves a vouchfx-asb-<guid> temp directory behind for every Map()+Configure() call that never starts a container (#438). Config.json was written to a fresh temp directory eagerly, inside EnvironmentMapper's Build() lambda — which runs for every caller that invokes the returned Configure delegate, whether or not DistributedApplication.StartAsync ever follows. That includes every non-Docker EnvironmentMapperTests case that inspects builder.Resources without Docker: measured, +3 directories leaked per Vouchfx.Engine.Orchestration.Tests run, none of them ever removed. The existing comment's reasoning — "the OS reclaims temp files on reboot; the normal teardown path removes the container before the engine exits, so the file is only retained on abnormal DCP exits" — understated the problem: it was true only for a started topology, and said nothing about the in-memory path, which is the common case in this project's own test suite. The directory and its Config.json write are now deferred to an Aspire OnBeforeResourceStarted hook on the emulator resource, which fires only when DCP is genuinely about to start that container — i.e. only during a real StartAsync — so a bare Configure(builder) call never touches the filesystem at all; there is nothing to clean up on that path because nothing was written. The directory it creates is staged into a TempDirectoryLedger MappedTopology.AsbTempDirectoriesCreated exposes, and HeadlessTopology.DisposeAsync — the single teardown chokepoint (§4.5) — closes it and removes every entry the closing snapshot names after StopAsync returns, swallowing only the IOException/UnauthorizedAccessException a still-held mount could realistically produce. The one path DisposeAsync cannot see is a StartAsync that fails after this hook has fired (DCP started the emulator, then failed on something else): no topology is ever returned, so StartAsync's own failure catch closes the ledger after disposing the app, in a finally so that a dispose that throws cannot skip it. EnvironmentMapper's Configure registers the ledger in the builder's services, and HeadlessTopology takes it from there on both paths, so a caller that composes EnvironmentMapper.Map with the public HeadlessTopology.StartAsync directly gets the same cleanup as SuiteTopology, with nothing internal to call. A MappedTopology therefore serves one start: a second start from the same one finds its ledger closed, and that start's emulator fails to start rather than leaking a directory. Once either cleanup path has run, the ledger is closed: a start hook that still fires afterwards — because a cancelled StartAsync can return while DCP is still creating containers in the background, and that background work can still fire the hook — finds the ledger refusing to stage its directory and throws, which Aspire logs as that one resource failing to start rather than as an engine crash. The ledger makes "take the snapshot" and "refuse anything staged later" one operation under one lock, so no directory can be created after cleanup has decided what to delete. Both paths share one helper, HeadlessTopology.DeleteEngineOwnedTempDirectories, and it deletes an entry only when it is a direct child of the system temp directory named with the engine's vouchfx- prefix, whatever else the ledger holds. No observable change for a real vouchfx run: the emulator still receives Config.json before it needs it. Pinned by non-Docker tests in AzureServiceBusTempDirectoryLeakTests.cs. One asserts Configure(builder) alone never creates the file, reading the exact bind-mount source path back off the resource's own ContainerMountAnnotation rather than globbing vouchfx-asb-* (parallel-safe on a shared machine). Others drive HeadlessTopology.DisposeAsync's cleanup directly, via a new internal ForTestingDisposal seam wrapping a Build()-built but never-StartAsync-ed DistributedApplication, and the shared helper on its own. A census pins the failure path, because that hook only runs against a real container: the catch around app.StartAsync inside HeadlessTopology.StartAsync resolves the ledger before disposing the app, cleans it after in a finally around that dispose, and rethrows. Another row wraps a built app without handing it a ledger, and shows DisposeAsync still removes the directory, through the ledger it took from the app's services. Further rows pin the ledger's own close/stage race directly: staging after either cleanup path has already closed the ledger refuses and creates nothing; a populate callback that itself throws still leaves its directory recorded, so cleanup still removes it; 32 concurrent stages racing one close leave no directory behind under any interleaving, deterministically rather than merely usually; and one row drives the real hook end-to-end without Docker or DCP, by publishing BeforeResourceStartedEvent directly against the builder's own eventing.

  • mq-publish.redis and mq-expect.redis now wire the step token through to their emitted helpers, closing the redis half of #468/#367 (#492). Both emitted helpers took no CancellationToken parameter, the call sites passed no __stepCt_<safeId> argument, and neither catch ladder carried the defensive OperationCanceledException filter cache-assert.redis already had — the three Redis providers were inconsistent with each other for no reason tied to the underlying library. They now match CacheAssertRedis_Helpers.Execute verbatim: PublishAsync/ExpectAsync take a trailing ct, the emit block passes __stepCt_{{safeId}}, and a catch (System.OperationCanceledException) when (ct.IsCancellationRequested) sits before the generic catch (System.Exception), rethrowing past this provider's own error handling so the assembler's WrapForImmediate wrapper classifies a step-token cut as Inconclusive(step-timeout) instead of the generic branch misclassifying it. This changes no behaviour today. Reflected against the pinned StackExchange.Redis 2.13.1: ConnectionMultiplexer.ConnectAsync has three overloads (string, string+Action<ConfigurationOptions>, ConfigurationOptions), none taking a CancellationToken, and IDatabaseAsync.StreamAddAsync/StreamRangeAsync likewise carry only a CommandFlags parameter — the redis client genuinely has nowhere to observe the token, so the filter is defensive, exactly as it already was on cache-assert.redis. What the wiring gives instead is a place for a future token overload to land, and three providers whose cancellation shape reads identically. The latency contract is worth stating precisely, and is now documented on both step types: a declared timeout: is still honoured as a verdict — CsxAssembler's late-enforcement supersession resolves a step that overruns it to Inconclusive, preserving the superseded verdict — but the step's actual completion is bounded only by the client's own defaults, measured against the same pinned assembly: ConnectTimeout 5000 ms, SyncTimeout 5000 ms, AsyncTimeout 5000 ms (inherits SyncTimeout). A step declaring timeout: 2s can therefore take up to ~5 s to conclude. Deriving ConfigurationOptions timeouts from the step budget to close that gap was considered and rejected, for the identical reason #367 rejected it for mq-publish.kafka: a client-side timeout expiry surfaces as RedisTimeoutException → EnvironmentError, the wrong verdict for what §12.1 says should be Inconclusive. Pinned by new non-docker emit tests on both providers (the helper's CancellationToken ct) parameter and the defensive filter text; the call site's __stepCt_<safeId> argument) and by two pre-existing "missing ${secret:...}" emit tests that were quietly compiling their CSX through a manual usings+helpers+StatementBlock join rather than CsxAssembler.Assemble — invisible until the call site started referencing __stepCt_<safeId>, a local only the assembler declares.

  • --changed-since no longer hands the git child vouchfx's whole environment (#500). Every git the selector runs inherited the engine's environment in full — including whatever ${secret:env/NAME} had resolved, since env is one of the two MVP secret sources (§17). Git executes repository-influenced code in several documented ways (core.fsmonitor, .git/hooks/*, credential.helper in its !shell form), so a secret loaded for the suite was readable by a helper the repository under test chose. The child now receives an explicit block rather than an inherited one: PATH, HOME/USERPROFILE/HOMEDRIVE/HOMEPATH, XDG_CONFIG_HOME, ProgramData, SystemRoot, windir, LD_LIBRARY_PATH, TMP/TEMP/TMPDIR, plus every variable already set whose name begins GIT_. Two operator-visible consequences. LANG/LC_* are not forwarded, so git's own messages arrive in English regardless of the operator's locale — deliberate, because that text is relayed into a diagnostic this release also path-substitutes, and deterministic output is worth more there than localised output. And a git configured entirely through a variable outside that list no longer sees it; GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM and the rest of the GIT_* family are unaffected. The residual is stated rather than implied: a secret stored in a GIT_-named variable still reaches the child, and GIT_SSH_COMMAND/GIT_EXTERNAL_DIFF remain code-execution vectors — narrow, because only the operator can set them (measured: nothing in the engine writes suite-derived values into its own environment). Confinement also removes inheritance, not access: repository-chosen code runs at vouchfx's own uid and can read the parent's environment directly, so against code that already executes this is a speed bump, not a boundary.

  • Six --changed-since diagnostics stopped naming absolute host paths, and a seventh stopped quoting the BCL's (#498). The standing invariant is that a diagnostic names the author's declared text, never a resolved host path. The no-repository-root message named the working directory verbatim. Two relays carried text the engine did not write: git's own stderr, which routinely spells absolute paths, and the BCL's IOException when a pipe read faults. Inside the process runner, four more messages carried one apiece, and only one of the four spliced the BCL's launch-failure sentence — the launch catch interpolated ex.Message, which .NET composes from SR.ErrorStartingProcess as "An error occurred trying to start process '{0}' with working directory '{1}'", carrying the working directory into an engine-authored diagnostic. The null-handle, capture and timeout throws did something simpler, and two earlier versions of this entry miscounted them: they interpolated fileName whole, which #499 had just made absolute by putting a PATH search in front of that seam. All three now name the leaf, and they were fixed although two of them reaches printed output today — RunGit discards the timeout message and cannot take the capture one's fallback arm. That is the count's seventh site and the one exception to its headline: the launch throw still names fileName, deliberately, because RunGit discards that message too and a test uses it as the control proving the mapped one stays clean. Relayed git output is now path-substituted rather than dropped or truncated — dropping loses the bad-ref diagnosis that is the common case, and truncation addresses length when the defect is disclosure. The substitution is quote-aware, because git quotes paths with ' and a naive tokeniser left Smith/src/repo standing for the commonest Windows shapes (C:\Users\<First Last>, C:\Program Files\Git). It also splits on =, so the KEY=/path form a hook or filter driver prints does not survive whole — git's stderr carries helper output, not only git's own. It shares its per-token predicate with the test-suite's own disclosure gate, now asserted structurally rather than by convention. What it still does not catch is recorded at the site rather than left to be discovered; the named residue is a path directly after a : prefix (error:/home/…), which no separator rule can close without breaking Windows drive letters and scheme:// forms. The enumeration that recorded those residues was itself scoped to ASCII, and the class is not — so the set also gained the seven quoting characters real tooling emits: ‘’ (glibc gettext's style in any UTF-8 locale, the direct successor to the backtick-apostrophe form above), “”„ and «». Measured before the addition, cannot open ‘/etc/gitconfig’ came back verbatim and the shared disclosure gate accepted it: both halves of the rule missed the same shape, which is what a scope written into a heading but not into the corpus buys you. There are two costs, not one, and an earlier version of this entry named only the first. The familiar one is that the tail of a path whose own name carries the character survives — a price already paid at a higher rate, since ASCII ' has always been a separator and turns /home/john/don't/x into <path>'t/x, a far likelier directory name than any curly quote. The second was not stated anywhere until review found it: a separator sitting immediately after the root splits the rooted token into a head of / — one character, below the two-character floor, so skipped — and a tail that is no longer rooted, so /‘etc/passwd now survives whole where it used to be substituted. That is a reversal, which contradicts the monotonicity this change was argued to have, and it is recorded rather than argued away: it is a pre-existing class, measured across fifteen incumbent separators that all leak the same shape today, and every shape a real emitter produces improves, because such an emitter wraps the path on both sides (‘/etc/gitconfig’ is substituted). It now carries a test row per character rather than a sentence, since an admission in prose is exactly what let it go unstated. was deliberately left out, on two grounds rather than one: it is a space rather than a quoting character, so it occurs in real filenames the way the other nineteen residues do, and it would also land in the whitespace subset the whole-path check splits on, where the parity assertion polices only one direction and a mismatch would therefore be silent. One interlock probably makes the remaining exposure narrower than it reads, and the two halves of that claim are not equally solid. What is pinned is that the environment confinement above forwards no LANG or LC_*: the confinement's own test feeds LANG in and omits it from the expected output, on every lane. What is inferred from there is that git therefore runs in the C locale and gettext therefore falls back to ASCII quoting — and that last link does not hold everywhere, because Git for Windows can take its language from the OS user default even with those variables absent. So the confinement removes the environment as a lever on the locale; it does not promise ASCII quoting on every host. What is left is a helper that hard-codes its quoting and so never consults the locale.

  • Two --changed-since diagnostics now say what actually happened. A graceful stop reported "shut down on stdin EOF", but the watcher invokes the same callback for an unreadable stdin as for a genuine EOF — so a broken pipe was reported as a requested shutdown, and the notice now covers both at the same exit code. And a git that was found on PATH but could not be started reused the not-found wording, asking "Is git installed and on PATH?" about an executable the engine had already located; launch failures now say it was found and the operating system refused to start it.

  • A PATH entry holding a git the caller cannot execute no longer shadows a working one (#509). Executability was decided by reading the file's mode bits — any of user, group or other — which is not the test the kernel makes. A root-owned 0700 git in an early PATH entry was therefore accepted, the search stopped there, and the launch failed EACCES on a host where a perfectly runnable git sat in a later entry: a refusal shadowing a working git, not a launch failure. The check now asks the C library (access(X_OK)), keeping the mode-bit read as the fallback where that interop is unavailable. On a runtime with no resolvable libc — musl/Alpine is the concrete case — the check degrades silently to the previous behaviour.

  • A graceful --shutdown-on-stdin-eof during selection is no longer reported as an engine defect (#502). Closing stdin while a --changed-since git call was in flight printed "the engine failed unexpectedly and could not reach a verdict … This is an engine or provider defect … please report it". The exit code was already correct — 4, the documented stdin-EOF code — so only the message was wrong, which is why a test asserting the integer alone passed against the defect. The run now reports that it was shut down because stdin reached EOF or could not be read — the watcher invokes the same path for both and cannot tell them apart, so claiming EOF alone would misreport a broken pipe as a requested stop. A genuine timeout keeps the engine-defect wording, which is the distinction the previous filter could not draw.

  • --changed-since no longer contends for the git index lock. The selector's git status was taking .git/index.lock for an opportunistic index refresh it does not need, which is lock contention against whatever else the operator is running in that working tree. All three selection calls now pass --no-optional-locks. (An earlier draft of this note claimed the reverse — that a concurrent git made vouchfx fail with exit 128. Measured, it does not: git takes that lock without LOCK_DIE_ON_ERROR and silently skips the refresh when it is held. The flag is right; the direction was not.)

  • An exception whose Message getter throws no longer costs the run its taxonomy exit code. The CLI's last-resort diagnostic composed its text outside the guard meant to contain a failing write, so a provider- or script-authored exception type that throws from Message escaped the backstop and returned the framework's exit 1 with the taxonomy code already decided. Composition now happens inside the guard. Same class as #518, closed for the diff-renderer surface by the entry above.

  • A provider defect beside a passing scenario no longer produces a green build (#480). A scenario refused at one of the engine's provider- or engine-surface guards — a provider's Bind, Validate, Resources, HostResources, Emit or CompileReferenceAssemblies threw, or the assembler refused the CSX fragments a provider emitted — now takes the run non-zero whatever its siblings did. This is a behaviour change for CI consumers: a run that exits 0 today will exit 4. A pipeline currently green over a suite containing a defective provider will turn red, which is the point, but nobody should have to discover it by surprise. The delta is slightly wider than "a defective provider", deliberately. The rule fires on the PROVENANCE of the escape rather than on the blame the diagnostic assigns, so it covers every arm of that diagnostic, including each one that declines to blame the provider — a reflective-dispatch failure before the provider's body ran, a filesystem condition that may be the host's (an antivirus lock racing script.csharp's own existence check is the in-tree example), and an out-of-memory or a cancellation surfacing through a provider frame. "Every arm" rather than a count, deliberately: the marking is applied at the call sites rather than inside the diagnostic, so a new arm inherits it and no enumeration here can fall out of step with the code. In a mixed run those now exit 4 too, where they previously exited 0, and no flag turns that back into a 0. (--fail-on-env-error can still change WHICH non-zero code such a run carries: where the aggregate is EnvironmentError the flag takes it to 3 before this rule is consulted, since the rule is conditioned on the code so far being Success. The parse-failure rule behaves identically.) That follows from the repair rather than being incidental to it: the nothing-executed rule already exits 4 for every one of those arms when the defective document is alone in a directory, so splitting them would have made the exit code depend on the exception type as well as on the sibling — the very coupling being removed. The hole was in the SCOPE of the existing nothing-executed rule rather than in the guards themselves, which #413 and #466 installed and which work: that rule is conditioned on nothing having executed, so it stopped protecting the defective scenario the moment a sibling ran. Measured on both run paths, with the same defect in both arrangements: a document whose provider throws at bind time exits 4 alone in a directory and exited 0 beside one script.csharp scenario that runs and passes — the exit code was deciding on the sibling rather than on the defect. It now exits 4 in both. The signal is the provenance of the failure, carried from the guard that raised it to the exit-code decision, and never the verdict: both shapes are Inconclusive and both executed something, so keying on the verdict would have reddened a genuine execution-time Inconclusive (a timeout, a partition outlasting its grace, an unmet upstream capture) and a genuine infrastructure fault, which §12.1 gates behind --fail-on-inconclusive and --fail-on-env-error on purpose. Both gates are unchanged, and so is #390. Stated as "never exits 0" rather than "exits 4", like the two rules beside it: the guard is conditioned on the code so far being Success, so it never overrides a code another rule already chose — a Failing sibling still takes the run to 1, and a gated environment error to 3. Two shapes are deliberately left open rather than quietly swept in: an ordinary authoring refusal beside a scenario that ran (a schema error, an unresolvable ${secret:…}, a malformed dependency env:, a protocol conflict) still exits 0, being the same sibling-dependence one level wider, and narrowing it would redden mixed suites that are green today (#514); and an exception escaping to --parallel's per-slot catch-all is outside the rule, because that frame holds an exception type and nothing else and cannot tell an engine defect from a container falling over (#486).

  • --changed-since now launches git by an absolute path resolved off PATH only, so a git.exe dropped beside the tool no longer wins by being beside the tool, and one committed into the repository under test cannot win at all (#499). The two clauses are deliberately different strengths, and the caveat below says why. GitChangeSet handed the bare name git to the runner, and on Windows that is not a PATH lookup: .NET starts a process with lpApplicationName = null (everything in the command line, per Process.Windows.cs), so the operating system applies its own search order for an unqualified name — an order whose first entry is the calling executable's own directory and whose second is the calling process's current directory, both ahead of PATH. Both were measured to win, on Windows 11 build 26200.9168 / net8.0, from a console app calling Process.Start with UseShellExecute = false and a bare FileName, against a planted git.exe that prints a marker; two positive controls run in the same harness, so a "not found" is never mistaken for a harness that cannot see an impostor — placing the plant directory on PATH runs the marker, and removing the impostor again runs the real git. With an impostor git.exe placed beside the calling executable the child that ran was the impostor (exit 0, marker on stdout); with it removed, the same call printed git version 2.54.0.windows.1. For a tool installed with dotnet tool install, that directory is ~/.dotnet/tools, which the user can write to — one file there takes over every git call on this path. git is now resolved to a rooted path, which CreateProcess and execve both take literally, and there is no search left to lose. The seam receiving it enforces that rather than documenting it: SystemProcessRunner.Run refuses a fileName that is not fully qualified, with an ArgumentException deliberately outside the three failure types GitChangeSet maps to exit 2 — a broken caller is not the user's mistake. The requirement first shipped as a doc-comment, which is the same shape as the defect it describes; the original defect was a caller handing over the bare name git, and a doc-comment cannot catch that. What remains open, and it includes ~/.dotnet/tools itself: an attacker-writable directory sitting earlier in PATH than git's own still wins, because the resolution takes the first PATH match. A global tool's install directory is on PATH by construction — it is how the shell finds vouchfx at all, and docs/getting-started.md tells the user to put it there — so for the supported install shape the drop described above is narrowed from an unconditional win to a PATH-order-dependent one, not refused. That is what the headline's first clause claims and no more. The change narrows the search from "whatever Windows searches" to "PATH, in order, and nothing else"; it does not vet PATH. A git.exe committed into the repository under test also won, and the fix closes that too — the previous version of this entry denied it on a mis-measured probe. With the impostor present only in the calling process's current directory and no git on PATH at all, the impostor ran (exit 0); with the real git added to PATH, the impostor still ran, so this term beats PATH. cd untrusted-repo && vouchfx run . --changed-since main gives the CLI exactly that current directory — the process inherits the shell's, and nothing in the CLI ever calls Directory.SetCurrentDirectory — and the drop needs no write access to the user's profile, which makes it at least as reachable as the ~/.dotnet/tools one above. The earlier non-reproduction was an artefact of the measuring environment. NoDefaultCurrentDirectoryInExePath suppresses the current-directory term, and it is not a Windows default — measured on this host, Machine='' and User='' while Process='1' in the environment this repository's tooling runs under, so every probe inheriting that environment measured a host with the term already switched off. (Which link in the chain sets it was not established; Git Bash was ruled out — launched from a parent without the variable, it does not add it.) Clearing it in the calling process flips the result. The child's environment block is not consulted at all: measured, setting the variable in psi.Environment while the caller's block lacked it did not re-suppress the term, so a probe or a fix applied there changes nothing. One half of the old correction stands: the original filing named the wrong mechanism — ProcessStartInfo.WorkingDirectory sets the working directory of the child and takes no part in resolving the command line's module name — but its substance was right; the term that wins is the calling process's own current directory, which the CLI inherits from the shell rather than sets. The rooted FileName removes the search entirely — every term of it, including the application load directory, the calling process's current directory, and the system and Windows directories. This is a different hazard from the two guards already on this path, and neither addressed it: the leading-dash refusal plus --end-of-options defends git's own option parsing, and ArgumentList defends against shell quoting; which binary is resolved was covered by neither, and all three now stand together. The search reads PATH and nothing else, in order, and skips any entry that is not fully qualified rather than resolving it — an empty element means "the current directory" on some platforms and a relative one resolves against it, so honouring either would put the ambient-directory term back into an answer this resolution exists to make absolute. (The test is Path.IsPathFullyQualified, not Path.IsPathRooted, because the latter accepts the Windows drive-relative form C:dir.) There is exactly one candidate per entry, and on Windows it is git.exe — not PATHEXT, because a search that replaces the operating system's must not resolve anything the operating system would not. Two measurements stand behind that, on a Windows 11 / net8.0 host. The OS search appends .exe and nothing else: with a PATH directory holding only git.cmd, the bare-name launch threw Win32Exception … The system cannot find the file specified. And a batch candidate would be a command-execution sink: Process.Start against a rooted git.cmd with UseShellExecute = false does launch — through cmd.exe, whose parser then re-reads the arguments ArgumentList had quoted for CreateProcess, so ArgumentList = ["diff", "\"&echo INJECTED&\""] made the child print INJECTED, and --changed-since reaches ArgumentList verbatim. A host whose only git is a .cmd/.bat shim therefore gets a refusal — the same ChangeSetException as a host with no git at all — rather than a candidate set wider than the one this search replaces. POSIX takes the bare name plus an execute-bit check. A PATH entry is taken verbatim — neither unquoted nor trimmed of surrounding whitespace, because both are cmd.exe behaviours rather than CreateProcess ones and either would resolve an entry the search being replaced does not. Measured on the same host with a bare-name Process.Start against a directory holding one real executable: the entry spelt plainly launched, while spelt with a leading space, a trailing space, a leading tab, or wrapped in literal quotes it was not found in all four cases. This search now returns those same five answers. Trimming was a live defect rather than a hypothetical one — it resolved all three whitespace spellings, and a leading-space entry written first, the shape a PATH=%PATH%; C:\tools hand-edit leaves behind, then shadowed a real git in a later entry. Resolution happens once per change-set, not once per git call: three invocations (rev-parse, diff, status) share one answer, so the diff and the status cannot be computed by two different binaries. A git that is not on PATH is refused rather than degraded to the bare name — falling back is precisely the hole being closed — and the refusal keeps the existing outcome exactly: a ChangeSetException carrying the same question (Is git installed and on PATH?), which the CLI maps to exit 2, unchanged. Whether selection-infrastructure failure deserves an exit code of its own is open, and is now filed as #521; a binary-resolution fix does not answer it in passing. (This sentence used to name #480 and #466-B as holding the question. Neither asks it: #466 closed on the parallel runner's per-slot catch-all classifying an engine throw as an EnvironmentError, and #480 on a provider or engine defect refusing a scenario beside a passing sibling — both about where a fault was raised inside a run, not about a selection step that never got as far as one. The pointer was therefore retired rather than repointed at an issue that does not ask this.) One consequence handled with it: the runner quotes the file name it was handed, which is now an absolute path on the host, so the launch-failure mapping no longer reports a reason at all — it emitted only Could not run git for <operation>. Is git installed and on PATH? and chained the original exception for a debugger. That wording has since been split, and this sentence is left in the past tense rather than rewritten because it is what #499 shipped: a git that is not on PATH still asks that question, while one that was found and would not start now gets its own, which says so and asks whether the first git on PATH is a valid executable this user may run. Both remain path-free. Both candidate reasons leak: measured, Process.Start against a rooted, absent executable throws a Win32Exception whose own message composes in both the executable path and the working directory (An error occurred trying to start process '<path>' with working directory '<dir>'.), and SystemProcessRunner quotes the file name again in the wrapper it raises — so the inner message is the source of the disclosure, not a path-free half of it. Host paths do not go into --events, the JUnit XML or the HTML report (#375/#473/#488), and the actionable half of the message was already in the question it asks. Pinned by a test that drives a real Process.Start failure and asserts the raw exception does name the resolved path before asserting the mapped one does not, so the assertion cannot degrade into a vacuous pass (#488's pattern). The resolution is injected as an optional collaborator alongside the process runner, declared in the constructor's own remarks as existing for the tests: without it every unit row in that class, including the dozen that only exercise parsing against a canned runner which launches nothing, would silently acquire a dependency on the host having git installed.

  • The --changed-since git shell-out no longer leaks its child, and is now bounded by a per-call time budget (#481). src/Cli/Vouchfx.Cli/Selection/SystemProcessRunner.cs is the single production IProcessRunner, used on the --changed-since selection path via GitChangeSet. It carried three defects: the Process was never disposed on any path (a handle leak per call), the launch sat outside any try that owned cleanup (so a throw from ReadToEnd() or WaitForExit() abandoned a live child), and there was no time bound at all. The fix wraps the launch in a using statement, guards child cleanup with a private KillTreeQuietly in a finally, injects a per-call time budget (defaulting to 2 minutes), and bounds the stream reads, not just the exit wait — this last is the substance of the change. Issue #392 measured the hang shape: a child that exits promptly while leaving a grandchild holding the inherited pipe handles never delivers EOF, because EOF arrives only when every writer closes the pipe, and the reads are the first blocking calls, so bounding only WaitForExit fixes nothing on that shape. Stdin is redirected and immediately closed so a child cannot consume the CLI's stdin. Three exception types now surface runner failures, two of them new: ProcessTimeoutException (the run exceeded its per-call time budget, which defaults to 2 minutes), ProcessCaptureException (output-stream reading failed mid-capture), and ProcessLaunchException (unchanged, the executable was not found). GitChangeSet.RunGit maps all three to ChangeSetException so the CLI still exits 2 (usage error) rather than crashing — an unmapped runner exception would have escaped as an unhandled crash, which for the timeout case would have converted a hang into a stack trace. The timeout does not guarantee the tree is dead: in the #392 shape the direct child is already exited by the time the budget expires, so the tree-kill reaches an empty tree and the orphaned grandchild holding the pipes keeps running — the code's own messages state this precisely. The runner's conversion of a pipe-read fault into ProcessCaptureException is covered by inspection rather than by a test row, because provoking a genuine mid-read fault needs a handle the tests do not own: the pipe belongs to the Process the runner creates and never hands out, and closing it from outside would need a production seam that exists only for a test. GitChangeSetTests.GitOutputCaptureFails_SurfacesChangeSetException_NotCrash pins the mapping from that exception to a usage error; the runner's own conversion is checked by code review. The production ceiling defaults to 2 minutes — an estimate, not measured from any repository. It is a ceiling, not a latency target: the only decision it makes is how long a genuinely wedged child is allowed to hold the CLI before the run fails and a tree-kill is issued. Should a real repository reach it, the fix is to raise this constant rather than remove the bound. Tests inject a short budget through the internal constructor instead of inheriting this one, so the unit suite does not pay the production ceiling's latency. The budget is not the only way out, and that gap was found by peer review rather than by the filing. --changed-since selection now receives the run's cancellation token, threaded through SelectScenarios and GitChangeSet to the runner, where it is raced alongside the budget. Before that, Ctrl+C on a wedged git signalled a token nothing observed: System.CommandLine waited out its process-termination budget and force-killed the CLI, so the new finally never ran and the child was orphaned exactly as it had been — on the path where an operator is most likely to intervene. Cancellation surfaces as cancellation and is deliberately NOT mapped to ChangeSetException; RunGit's three catches are narrow by type for that reason, so the existing cancellation exit-code behaviour is untouched. The census gate ChildProcessKillCallSiteCensusTests gained a third root, src/, which had never been censused — that absence is why #481 survived undetected in production code while the test lanes stayed guarded. ProcessKillGuardParityTests pins the product-code catch filter identical to Vouchfx.TestSupport's by parsing both sources with Roslyn, enforcing that a second copy of the catch filter (whose divergence is how such filters drift) stays synchronised as the product code evolves.

  • The HTML report no longer states a cause for a fixture it could not hash (#484). A reproducibility-envelope fixture row with no content hash rendered the token (absent) — a claim about the file. That was true only while the envelope builder caught FileNotFoundException alone; #466 widened that catch to the IO family, so five causes now record the same hashless row and (absent) was false for three of them — a locked file, a permission denial, and a path the filesystem rejects. (It stayed true for the other two: a file that was never there, and one deleted between the existence check and the read, which is genuinely absent by the time the read fails.) The row now reads (no hash recorded), which states what the envelope knows and nothing more. The envelope's shape is deliberately unchanged, and that decision is cheap to reverse: recording which cause occurred would need a third field on FixtureDigest, which the §14 event-wire freeze permits as an additive change within v1.x — so envelopes archived before such a field lands lack it whichever way the call goes, and adding it later costs about what adding it now would. What justifies waiting is that reaching a hashless row requires a file to stop being readable mid-run: the engine has already read every byte of every file it hashes (the seed applier's read at topology start and on every kept-topology re-seed, the script provider's at compile time). Reopen it on evidence that a consumer needs the distinction — see the Changed entry above for what consumers must assume today. Four stale prose sites that defined a hashless row as meaning "file was absent" were corrected with it, plus three more that credited the seed applier with computing a fixture hash it has never computed.

  • A script.csharp step whose file: cannot be read no longer discloses the host path it resolved to (#488). Emit handed the resolved absolute path to File.ReadAllText, whose own message quotes it back (Access to the path '<resolved>' is denied., or the sharing-violation equivalent); an Emit throw becomes a compile refusal that reaches --events, the JUnit XML and the HTML report. The reachable disclosure was a file: resolving outside the suite directory — which nothing refuses, since file carries no path constraint in the schema and validation applies no containment check. For an ordinary in-suite file: the pipeline's existing suite-directory substitution already reduced the leak to the suite directory\x.csx; that was incidental cover from another assembly, not a guarantee, and it never applied to a path that escaped. The read is now guarded and re-raised naming the declared path the author wrote — the same treatment #473 applied to the seed-fixture helper's own throw, and the only guard structurally available to a provider, which references neither the engine assembly holding SecurityPathDisclosureLedger nor any recording of the declared/resolved pair. Deliberately carries no inner exception: the pipeline's fault describer walks and appends the inner chain, which would reinstate the very message being suppressed. Pinned by a test that first asserts the raw BCL failure does name the resolved path, so the assertion cannot degrade into a vacuous pass. The companion claim that these exceptions escaped the reproducibility-envelope build was itself false and is corrected: the envelope's caller has caught them since #466.

  • mq-expect.kafka's emitted teardown no longer makes an uncancellable native call after the poll ends (#468). The finally was try { consumer.Close(); } catch { } then consumer.Dispose();; it is now a swallowed consumer.Dispose(); alone, on both the plain and the Avro consume path. The filing's own prescription — mirror #367's CTS bound — is unimplementable, and that is the finding rather than a shortcut around it. Measured over the pinned Confluent.Kafka 2.14.2 assembly: Close has exactly one overload, Close() -> Void, with no CancellationToken and no TimeSpan, and the package binds the blocking rd_kafka_consumer_close, not rd_kafka_consumer_close_queue. Bounding it in the engine is forbidden rather than merely unattractive — any Task.Run/Wait(timeout) shape abandons a frame inside the collectible submission assembly whose unload RunIsolatedAsync performs in the same finally, deferring that unload unobserved, and it would race the teardown on one rd_kafka_t. Removing Close() is not a new code path: from the IL it is ConsumerClose(); Dispose(true); …, and ConsumerClose() throws on any non-zero ErrorCode, so whenever the broker was uncooperative — the case #468 is about — the old bare catch already swallowed that throw and the handle was already released by the outer Dispose(), i.e. by exactly the mechanism that is now the only one. The full forensics, the rejected alternatives and the cost accounting live in one place, the provider's s_helpers remarks, rather than being restated across the emitted script. No timing figure is claimed, and the nearest ones do not transfer. The #367 entry below measured 16 ms and 94-110 ms, but those are the producer handle — SafeKafkaHandle.ReleaseHandle takes plain rd_kafka_destroy for a producer and destroy_flags only for a consumer — and both probes ran against brokers the client never reached, so no fetcher or coordinator thread existed to join. When #468 was filed, the consumer used Subscribe, so teardown of a consumer that had joined a group was unmeasured; that fault-injection shape is absent from this repo. That group-membership analysis describes the historical subscription path, not the current provider. Since 08f75bb (2026-10-11), both emitted consume paths discover retained partitions and call Assign; they do not subscribe or join a group, so a joined-consumer harness is not required to exercise the current provider. The current assigned-consumer Dispose() cost remains unmeasured. Historically, Unsubscribe() was adjudicated, not overlooked — the pinned package's own Consumer<K,V>.Dispose remarks name it alongside Close(), and it carries none of Close()'s disqualifiers — but rd_kafka_unsubscribe returns once the request is enqueued, so following it immediately with destroy_flags most likely tears the handle down before the LeaveGroup is transmitted. Inferred, not probed; this was an alternative for the historical subscribed path. Historically, the subscribed group could retain a live-but-silent member until broker eviction — mechanism documented by the pinned package, only the 45 s number a librdkafka default — before becoming Empty, where Close() would have put it sooner; that was a delay, with no persistent broker-side state. The peak-concurrency estimate for that historical window was ~7-8 groups at the RETRY delay cap and ~10-11 over the first 45 s counting the ramp, rising if an author shortens pollInterval and multiplying by --parallel slots on a shared broker (arithmetic from the runner's constants, not measured). The old subscribed path used a throwaway single-member group per attempt with auto-commit off, so Close() committed no offsets either. This supersedes the mq-expect.kafka half of the “recorded in #468 and deliberately left unfixed here” note in the #367 entry below, which remains correct for that change; the redis half is now #492. Two adjacent corrections ship with it. The teardown Dispose() is swallowed, matching the sibling's house rule, because the vars[outcomeKey] write sits after the finally and an escaping throw would discard the step's real verdict and skip registry.Dispose(). And budgetGoverned, silently ignored on the plain path, is now discarded explicitly on both: CsxAssembler sets it true in exactly one mode — IMMEDIATE with a declared timeout — and this config has no client-level transport timeout to lift, so a step declaring timeout: 30s under default IMMEDIATE still stops polling at the hard ~1 s per-attempt deadline and writes Fail, because waiting for a message to arrive is verifyMode: RETRY's job — under review as #493, which records that call as a #232 contract question rather than a settled default. Pinned by a non-docker regression test over the emitted CSX for both paths, driven red by re-injecting the old call.

  • 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.

  • The same net now covers security.serverArtifacts[].source and every resolved environment.seed SQL path (#473). #375 recorded exactly three fields — caCert/clientCert/clientKey — and the sibling sites that resolve an author-declared path held both halves of the pair and discarded the declared one. Two of them mattered. ServerArtifactInjection hands the resolved path to Aspire's WithContainerFiles, which streams the bytes through the Docker daemon: a file that becomes unreadable, or a stage the daemon rejects, between validation and container start throws text the engine did not write, and that text becomes the environment error's detail in the event stream, --events, JUnit and the HTML report. SeedApplier already named the declared file in each of its own diagnostics, but two of them splice a caught exception's message after it, and the BCL quotes the absolute path it was handed (Access to the path '...' is denied). Both now record the pair where they resolve it, so the existing three scrub chokepoints substitute the author's own text back.

Two housekeeping consequences. The ledger type moved from Vouchfx.Engine.Runtime to Vouchfx.Engine.Orchestration — every new recording site lives there and Runtime references Orchestration, not the reverse; it stops there rather than descending into Vouchfx.Engine.Abstractions, which providers and the SDK reference and which has no business with the engine's host paths. And SeedFixtures' fixture-not-found throw, which named a resolved absolute path outright, now names the declared one: no caller observes that message today, but "unreachable" was a fact about today's caller rather than a property of the method.

  • 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 each chokepoint that hands the resolved form to code the engine does not write, 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 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 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.

  • #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 (the Kafka-only 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.

  • #505: YAML alias expansion is now bounded at 16 Mi characters during schema validation. SchemaResources.ConvertYamlToJsonDocument runs during both vouchfx validate and vouchfx run and is upstream of every provider's Bind, so no provider-side bound could help. YamlDotNet's serialiser refuses at 50 levels of nesting, but an alias-heavy document is wide, not deep — measured on the pinned YamlDotNet 16.3.0 with anchored sequences and ten aliases per level: 3 levels expanded to 8,052 characters, 4 to 80,280, 5 to 802,508, 6 to 8,024,736 and 7 to 80,246,964 characters in 13.8 seconds at a 367 MB working set. Ten times the output per level added. The existing depth limit could not catch this shape. The fix budgets the re-serialisation step's output at 16,777,216 characters using a counting TextWriter that reserves before appending, so the builder refuses early rather than after the full string is materialised. How it surfaces: as an ordinary authoring diagnostic — Failed to parse YAML: …, naming anchors and aliases as the usual cause and suggesting the document be split. No new error channel, no exit-code change; both call sites already wrapped the conversion in a catch producing exactly that diagnostic. The budget derivation, which is load-bearing for later review: the largest real suite in the repository (examples/security-mtls.e2e.yaml, 23,528 characters) converts to 8,293 characters of JSON — every measured suite converts to fewer characters than it occupies. A legitimately-dense document at the CLI's own 1 MiB single-document cap expands by 1.37×, to 1,433,932 characters; the budget is ~11.7× that and ~2,000× the largest real suite. Explicitly not a security boundary: a suite author is trusted input (script.csharp grants arbitrary C#). This is mistake-catching and robustness — an author who generates a suite with anchors or pastes generated YAML reaches it without meaning to and previously got no usable diagnostic. The parser's own YamlStream.Load path is deliberately unbounded, and the reason is not that it reads only fixed paths — RequireUniqueMappingKeys walks the whole document graph. It walks it iteratively, de-duplicating by REFERENCE, so a node shared by an anchor and every alias site is expanded exactly once; the section parsers then read fixed paths and retain step mappings by reference. Neither shape re-materialises an alias. Measured: eleven alias levels — a nominal 10¹¹-node expansion — load and parse in under 1 millisecond.

  • #485: a provider diff renderer that throws no longer destroys a run's report artefacts. IStepDiffRenderer — an optional, provider-implemented SDK interface — was invoked unguarded at render time from a closure bridging the reporting layer to the SDK. TerminalRenderer's per-envelope catch filtered only JsonException and InvalidOperationException, so any other type — NullReferenceException, FormatException, KeyNotFoundException — escaped and destroyed every report artefact for the whole run, after the run had completed and the verdict was already known. The terminal render always runs first on the same buffer at every file-report call site, so the HTML artefact was collateral of that single terminal throw, not a second independent route. Not a verdict bug: the escape reached vouchfx run's top-level backstop (#413) and yielded exit 4 (Inconclusive), so no verdict was ever falsified. The fix: both CanRender and RenderDiff are now guarded separately at the closure — the single bridge, so every consumer is covered at once — and each degrades to "no diff rendered for this step" rather than escaping. The two are separate because they name different stages: CanRender throwing means the renderer could not even decide whether it recognises the shape, RenderDiff that it accepted the payload and then broke on it. Either can be payload-specific — a lone surrogate in one observation makes a correct provider throw from CanRender, which is why the diagnostic does not blame the provider unconditionally for an InvalidOperationException. Diagnostic: one line per (kind, member, exception type) per run, naming the step kind, the provider's type, which member threw, and the exception type and message — emitted at most once per deduplicated triple so a renderer that throws on every failed step cannot flood a CI log. The text is sanitised for terminal display at the write site, since the exception message is provider-authored. Unchanged: the verdict, the exit code and whether any artefact is written. Only the affected diff line is absent.

  • #493: an mq-expect step's single-drain semantics are now stated where an author reads them, and its drain window is visible in the report. mq-expect.kafka's emitted poll stops at a hard ~1 second deadline whatever timeout a step declares, so under the default verifyMode: IMMEDIATE an author writing timeout: 30s got a Fail at about a second and nothing said the declared budget had not widened the wait. The poll bound is unchanged and the flag is still deliberately discarded on both the plain and Avro paths: a single-shot drain is a property of the whole mq-expect family rather than a Kafka quirk — measured, mq-expect.rabbitmq drains at most 200 BasicGet fetches and stops the moment none is ready, with no time bound at all; Azure Service Bus peeks; NATS fetches with a one-second expiry. Making Kafka alone honour the step budget would mean timeout: 30s waits thirty seconds on Kafka and returns instantly on RabbitMQ, which is a worse contract than the one being fixed. Nor was the documented contract violated: timeout is documented as an upper bound whose declared value replaces a provider's built-in transport timeout, and a loop guard is not a transport timeout — the contract was under-documented, not broken. Three things change. The mq-expect.kafka and mq-expect.rabbitmq schema descriptions now state the single-drain semantics and name verifyMode: RETRY as the way to wait for a message, which the other three mq-expect providers already did; each is worded from its own provider's drain, so RabbitMQ's does not claim a one-second window it does not have. The common timeout description gains a clause making the converse explicit — it is an upper bound and never a lower one, so it does not extend a provider's per-attempt drain window. And every mq-expect.kafka Pass/Fail observation now carries drainWindowMs beside matched and scanned, derived from the same constant the loop deadline reads so the two cannot drift — pinned by a source-level test that also asserts the constant still agrees with the published description, and proven to bite by mutation — which puts the figure in the --events stream, where a Fail at about a second under timeout: 30s becomes self-explanatory. --events is the only surface that carries it: under verifyMode: RETRY the terminal and HTML polling timelines add the key but not the number — they summarise an observation by property NAME only, since §17 keeps potentially sensitive values out of them — and under the default IMMEDIATE there is no attempt timeline and mq-expect.kafka supplies no IStepDiffRenderer, so neither renderer shows the observation at all. drainWindowMs is Kafka's alone; RabbitMQ has no window to report. Compatibility: it is an additive key inside the observation string payload rather than a new event-record property, so the frozen v1 event-wire contract is untouched and a renderer that ignores unknown fields is unaffected; the composed-schema golden and the VS Code extension's shipped copy were regenerated deliberately and the diff is description-only, so nothing that validated before stops validating.

  • #553: two redaction layers presented as defence in depth did not cover what they appeared to — a RabbitMQ password whose decoded form differs from its escaped form, and a Redis password containing ;. mq-publish.rabbitmq/mq-expect.rabbitmq's RedactAmqpUri compared its userinfo layer against Uri.UserInfo, which is percent-ESCAPED; RabbitMQ.Client 7.2.1 stores and may echo the DECODED password, so a password that percent-encodes to something else (e.g. p@ss → p%40ss) evaded that layer, and the literal-URI and regex-fallback layers do not cover it either — inferred unreachable on the pinned client as currently used, since it does not echo the decoded form on the paths the existing tests exercise, so this was a latent gap rather than a live leak. The userinfo layer now additionally redacts Uri.UnescapeDataString of the extracted password, alongside the existing escaped-form replacement. Separately, the RedactCredentials helper emitted by cache-assert.redis, mq-publish.redis and mq-expect.redis bounded its password=/user= value match on [^,;]+, treating ; as a second delimiter — but StackExchange.Redis's ConfigurationOptions parser delimits options on , only, so a password containing a literal ; was redacted only up to the first one. The value-class is now bounded on , alone in all three providers, matching the parser's own delimiter (the two Redis MQ providers' copies were found by review of the first fix). Both gaps gain a dedicated regression test beside the existing redaction tests for each provider.

[1.0.0-rc.5] — 2026-08-24

Added

  • 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).

  • 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 runnable mutual-TLS example, and the CI hook it needs — examples/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 dependency — environment.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, exiting 0 unless the caller passes --fail-on-inconclusive. 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.

Changed

  • 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.

Fixed

  • 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 — and exits 0 on both paths now. 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, still exits 0. 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 — still exits 0.

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 still exit 0 with no security line. Closing that would take a raw-YAML scan for a security: key — a second way of asking "does this document declare security" that can disagree with the parsed one, which the engine forbids itself — and failing closed instead would redden every unsecured suite that merely contains an unreadable file. 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 ${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.

[1.0.0-rc.4] — 2026-08-10

Added

  • Raw TCP endpoints on services via ports declaration — environment.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 defaults — environment.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 steps — mq-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 one — metrics-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 WHAT — storage-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: the composed-fragment seam is proven at the schema layer only. YamlDocumentParser reads six fixed keys into SecuritySpec, which carries no Extra bucket (unlike DependencySpec), so a field contributed by a composed profile fragment validates and is then dropped before any consumer can read it. Closing that is purely additive (an init-only property on an unfrozen Authoring record) and waits for the second profile that would define what the bucket should carry.

  • 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.

  • 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: can never be secured, because its endpoints come from its own launch profile and the engine has no endpoint of its own to give an https scheme. 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.

  • 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.

Changed

  • 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 override — environment.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.

  • 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 kind — seed.<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 behaviour — env 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 null — topics: [{ 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.

Fixed

  • 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).

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 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.)

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 live — vouchfx-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.