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-confirmationevents, 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
MinEngineVersionusing 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-eofcancels an active run that would otherwise finish with anINCONCLUSIVEresult 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
CountandScrubto 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.Sourcefor 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.ParseFilerejects 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-streambefore 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
SecurityHelperkeep its existing single-leaf/platform-chain limit. The release-blocking isolated Corehttp.restTLS 1.2 acceptance passed on Windows and Linux; this does not expand external-provider compatibility.
Changed¶
- Core HTTP TLS handlers use the additive
HttpTlsHelperandSocketsHttpHandler; 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: tcpandreadiness: {type: tcp}for host-port reachability, or{type: healthcheck}for an explicitly configured native healthy check plus reachability. Kafka steps receive the existing baresvc::<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,internalor 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 --journalrecovers 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.Sourceis 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, glibclinux-x64,osx-x64andosx-arm64; archives/installers carry their selected RID. Inline topology startup prefers its host-relative bundle over build metadata/cache, preservesASPIRE_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; older1.0.0-rc.6installations remain cache-dependent. -
A new
s3managed-dependency kind, backed by RustFS (#581).type: s3provisions 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-specificimage:,version:orenv:, 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-levelimageRegistryapplies. A mirror must serve the multi-arch index under that digest. The engine setsRUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY, port 9000 and data directory/data, and gates on/health/ready; an authorenv: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>}resolveshostandport, the connection string has the sameServiceURL=…;AccessKey=…;SecretKey=…shape asminio's, and the store is not reset between sequential scenarios, likeminio.miniois unchanged: it keeps the MinIO contract on its frozenbitnamilegacy/miniopin.storage-assert.s3now accepts ans3or aminiotarget (any other kind is refused with "is not an s3 or minio dependency…"),examples/storage-assert-s3.e2e.yamlnow declarestype: s3, the scaffolder preferss3for new suites, andstorage-assert.s3'sResources()now reports the family"s3"for either target kind where it reported"minio"(nothing in the engine reads that value). Animage:override swaps only the image, not the contract, so it must honour the kind's own: a MinIO build forminio, a RustFS build fors3. -
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 avouchfx.skippedEventLinesproperty 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, whichFileReportWriterreported as "Report write failed for 'html'" and which left a truncated file. Such a line is now counted like any other. Telemetry gainsskippedEventLines, the number of lines the telemetry builder could not read, counting a line whose typed record it refuses once, and itsschemaVersionis 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 thetelemetry enableconfirmation now list the new count. -
security.profileis 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)moderenamed toprofile, total, not aliased — a mechanism axis (which technology-specific wiring applies), not a strength axis, the same roletype: family.providerplays 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/securitycloses withunevaluatedProperties: falsereplacingadditionalProperties: false(never both — a siblingadditionalPropertiesin the same schema object silently voidsunevaluatedProperties), 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) Asecurityblock is legal only where this release actually wires a client connection: on any declared service, and on akafkadependency. Every other dependency kind rejects the block outright — not the profile value, the block. There is no profile an author can substitute:tlsandmtlsare refused identically on apostgres,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: falsefor every dependency kind except kafka), not an enumerated exclusion, so a future dependency kind carries nosecurityblock 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 underenvironment.servicesdoes not work: every step family targeting those kinds resolvestargetagainstenvironment.dependenciesalone (measured — 17 of the 25 Core providers reject atargetthat 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 resolvetargetexclusively againstenvironment.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 declaresecurity:, because its endpoints come from its own launch profile and the engine has none of its own to give anhttpsscheme — 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 withconn::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>.envaccepts an environment-variable map, so a managed resource whose image is configured that way can be configured at all (e.g.MSSQL_COLLATIONon the SQL Server image Aspire pins). Values take the same shapes a service'senvdoes, 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 rundocker 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 (minioroot credentials,s3access and secret keys,elasticsearchdiscovery/heap, theazureservicebusemulator'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 therunpath —vouchfx validatenever 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 onminioands3they 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).expectgains two optional members besidestatus:jsonmaps a JSONPath (RFC 9535, used verbatim) to an expected value or to{ exists: true }/{ exists: false }, andbodyContainsnames a substring of the decoded body. The vocabulary is borrowed rather than invented —jsonhasmq-expect'smatch.jsonkey and shape and compares against the same canonical textcapturealready writes,existsis the wordcache-assert.redis,db-assert.dynamodbandstorage-assert.s3use, andbodyContainsiswebhook-listen.http's. Equality needs exactly one node, so"$.items[*].status": SHIPPEDfails asmultipleNodesrather than passing because the first item shipped, and a JSON null present in the body compares as the textnull(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 abodymember,{"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. Ajsonentry the library itself cannot evaluate against an otherwise-valid body is the same Fail, reasonunevaluable— measured on the pinned JsonPath.Net: a filter comparing a number beyond decimal's range, or (defensively, sincevouchfx validatealready 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. AbodyContainsvalue 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""". UnderverifyMode: RETRYevery attempt re-sends the request and re-evaluates the body.vouchfx validaterefuses a key that is not a JSONPath, an emptybodyContains, and any body assertion onHEADor alongsidestatus: 204/304.http.restnow implements the existing optionalIStepDiffRenderer, so a body failure renders anassertion │ expected │ actualrow; 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) anddocs/language-reference.mdmove additively, forhttp.restonly; 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
envmaps 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 separateCOMPOSE_/DOCKER_restrictions. This deliberately narrows invalid authored inputs before GA; the composed and editor schemas are regenerated. -
An environment-level
imageRegistryapplies to theminiodependency again (#580). #533 exempted the built-in pin from mirroring because it had moved toquay.io/minio/minio, which a Docker Hub mirror cannot serve. The pin is now the unqualifiedbitnamilegacy/minio:2025.7.23-debian-12-r5on Docker Hub, soimageRegistry: artifactory.mycompany.com/docker-mirrorredirects it toartifactory.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-dependencyimage:override still names any registry and repository explicitly. -
A
miniodependency now pulls fromquay.io/minio/minio, and an env-levelimageRegistryno longer applies to it (#533). The default image for theminiodependency kind moved registry, at the same tag (RELEASE.2025-09-07T16-13-09Z): Docker Hub no longer serves theminio/miniorepository at all, so the previous bare reference was unpullable for anyone without a cached copy — measured, an unauthenticated pull returnspull 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, whilequay.io/minio/miniopublishes the same release tag publicly, as MinIO's own build target rather than a third-party mirror (upstream'sMakefileline 9 isREPO ?= 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-levelimageRegistryneeds no change and simply starts working again. The behaviour change is for authors who redirect images with an env-levelimageRegistry: that override has always been documented never to rewrite an already-qualified reference, andquay.io/minio/minionow is one — so the redirect that previously produced<mirror>/minio/miniofor 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-dependencyimage:override, which is unaffected and takes an arbitrary registry (dependencies.<name>.image: my-nexus.corp/mirror/minio:RELEASE.2025-09-07T16-13-09Z), withimagePullPolicyunchanged either way. Carry the tag in that value, as shown: animage:with no tag and no siblingversion:is refused at validation, because it would otherwise float on:latest. No other dependency kind is affected: of the thirteen,azureservicebusis 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 andimageRegistryapplies 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
httpPortvalues such as0123and08080now validate as 123 and 8080, matching execution. Out-of-range values such as0177777produce 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
pathofhttp.rest,http.soap, andmetrics-assert.prometheusduring 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 --jsonemits 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.mdfor 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 §1round-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
PATHentries. 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 onlygit.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
startupMsandtimeToFirstTestMsread 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 firstscenario-startedline itself; atransport-noticeemitted before it became the anchor instead. The CLI now captures the run-start instant once viaDateTimeOffset.UtcNowat the very beginning ofExecuteRunPipelineAsync(before suite discovery) and threads it through toTelemetryEventBuilder.Build, which measuresstartupMs= (first scenario-startedts) − (run start), andtimeToFirstTestMs= (first step-completedts) − (run start); both are clamped at zero. Ascenario-startedorstep-completedline whose typed record is refused (for example"scenarioId": null) or which carries notsnow feeds neither timing, matching the Planner's ownEventHistoryReaderrule for these lines and closing the divergence recorded on #568. Because the--eventsarchive is reconstructed after each scenario completes, each scenario's step lines in it share one timestamp (the instant that scenario's script returned), sotimeToFirstTestMsspans 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 thekafkaschema-registry sidecar registered their default container image with no registry qualification at all, so an unqualified short name could resolve through Podman'sregistries.confsearch list instead of Docker Hub (#582, found in review of #583). Every Aspire-provided kind already names its registry inContainerImageAnnotation.Registrythrough its ownAddXxxhelper —docker.ioforpostgres,mysql,mongodb,redis,rabbitmq,nats,elasticsearchand thekafkabroker,mcr.microsoft.comforsqlserver— andazureservicebusembedsmcr.microsoft.com/in both of its image strings; these four are registered via the lower-levelAddContainer(name, image, tag), which sets no registry at all. Each of the four now sets the annotation's registry todocker.io, so the reference handed to the container runtime is the fully qualifieddocker.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-levelimageRegistrystill applies exactly as before —ApplyImageOverrides/ApplySidecarRegistryAndPullPolicydecide whether to re-prefix from the image STRING (HasExplicitRegistryComponent), never from theRegistryfield 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-dependencyimage: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, setimageRegistry, 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).EventContractFreezeTestsrenders each snapshotted record viaType.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 #573EventStreamJson.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 contractFromLine<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 viaGetProperties(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 therequiredmodifier, which changes what System.Text.Json enforces on read, now fail the gate too.Golden/event-stream-wire-contract.v1.txtis 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 bothEventContractFreezeTestsgates 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 ownJsonPropertyInfo; the type-level equivalents render onto the record's golden header line and are compared againstJsonTypeInfo. 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 oneJsonConverterFactory(such asJsonStringEnumConverter) 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'sCreateConverteroverride is then ignored), and the census still reports the one shape with no declared type, an attribute subclass that supplies its converter only fromCreateConverter. 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 isEventEnvelope.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 everyEventStreamJson.Optionssetting 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 privateDefaultJsonTypeInfoResolverthatEventStreamJson.CreateOptionscould install, whoseModifiersstay mutable until its first use even after the options are made read-only; aCreateOptionsedit that installs any other resolver, even an emptynew 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 centralSystem.Text.JsonPackageVersioninDirectory.Packages.props, which the CLI ships (10). Either failing a test means re-measuring the notes inEventContractFreezeTests.Golden/event-stream-wire-contract.v1.txtis 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 builtUnknown Verdict token "{token}". Accepted tokens…from the string the wire actually carried, so a hostile or malformedverdictfield travelled straight into the thrown message. No in-tree consumer prints this text today — every §14 renderer and reader catchesJsonExceptionper line and moves on without inspectingMessage— 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 acorrelationIdskey, at full length) and the offending bytes, and for a polymorphicTthe message quotes an unrecognised$typevalue, so a consumer must not treat a caught exception'sMessageas redacted. -
EventStreamJson.FromLine<T>let System.Text.Json'sNotSupportedExceptionescape every consumer's per-line tolerance filter for an abstract or interfaceT(#579). §14's renderers and readers tolerate one malformed line at a time viacatch (Exception ex) when (ex is JsonException or InvalidOperationException); System.Text.Json throwsNotSupportedExceptioninstead — a type neither filter names — for an abstract[JsonPolymorphic]Twith no$typediscriminator on the line, for an interfaceT, 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 oneDeserialize<T>call and rethrows asJsonExceptionwith fixed text naming onlytypeof(T).Name, with the originalNotSupportedExceptionattached asInnerException. That inner exception is System.Text.Json's and is not redacted: itsPath:suffix can name a property or dictionary key read from the line, and a custom converter's own message is preserved, so do not logToString()or the inner exception; only this wrapper's ownMessageis fixed text (see #576). For aTSystem.Text.Json can never bind, a non-polymorphic interface or abstract type, every object line now throws thisJsonException, so a per-line skip filter silently drops the whole stream where it used to abort. Unreachable in-tree today — every typedFromLine<T>call undersrc/passes a concrete, non-polymorphic record — so this matters only for a consumer of the publishedVouchfx.Engine.Abstractionspackage that callsFromLine<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 viacatch (Exception ex) when (ex is JsonException or InvalidOperationException); #579 closed that gap for System.Text.Json's ownNotSupportedException, but left two more open. A CONSUMER-DEFINEDT's owninitaccessor,[JsonConstructor], or a member[JsonConverter]'sReadcan each throw while System.Text.Json binds it, and — measured on STJ 8 and 10.0.8 with anInvalidDataExceptionand anArgumentException— 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 makesDeserializethrowArgumentException("Cannot transcode invalid UTF-16 string to UTF-8 JSON text.") from either overload. Both overloads now wrap their oneDeserializecall in a single broadened catch —ex is not (JsonException or InvalidOperationException or OutOfMemoryException or OperationCanceledException or TypeInitializationException), folding #579's narrowerNotSupportedException-only filter into it — and rethrow asJsonExceptionwith fixed text naming only the bound type, the original attached asInnerException.OutOfMemoryException,OperationCanceledExceptionandTypeInitializationExceptionstay unwrapped deliberately: resource exhaustion, a cancellation a consumer's converter observes (FromLinetakes no token) and a failing static constructor ofT, of a member's type or of a derived type a$typediscriminator 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 aTargetInvocationExceptionand is wrapped. As before, the inner exception is System.Text.Json's or the consumer type's own and is not redacted — aPath:suffix or a converter's own message can carry a value read from the line — and only the wrapper's ownMessageis fixed text: aJsonExceptionSystem.Text.Json raises itself carries the samePath:in its own message (#576), so a consumer must not treat any caught exception's text as redacted. Anullline still throwsArgumentNullException. Neither class is reachable in-tree, so this matters only for a consumer of the publishedVouchfx.Engine.Abstractionspackage: every typedFromLine<T>call undersrc/passes an Abstractions record whose members are auto-properties, and the one converterEventStreamJson.Optionsregisters,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 linesToLineproduced, whose writer replaces a lone surrogate with U+FFFD and emits it as the escape\uFFFDrather than as a raw surrogate. -
The
miniodependency 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/miniono longer serves any tag and its API requires authentication, anddocker.io/minio/miniodenies every tag, old releases included, so the kind's pinquay.io/minio/minio:RELEASE.2025-09-07T16-13-09Zwas unpullable. The kind now pinsbitnamilegacy/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_USERandMINIO_ROOT_PASSWORD, port 9000 and a 200 on/minio/health/clusterwere measured against this tag, but the image runs as uid 1001 under a root-owned/, soserver /datacannot 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/tmpthat 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 thebitnamilegacy/miniorepository, whose tags look like2025.7.23-debian-12-r5, so aRELEASE.…value no longer resolves (it had already stopped resolving when upstream withdrew); and the/tmp/minio-dataargument also applies under a per-dependencyimage: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 withimage: 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 anonymousunauthorized: access to the requested resource is not authorized(and the containerd image store's401 unauthorizedwording of it) as an absent image, where it wasunknown;requested access to the resource is deniedis added defensively, since Docker Hub'sdocker pullwording 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[].securityand all — and then threw that bound document away whenAstBuilderrefused 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 assertedmtls. Discovery now retains that boundenvironmentblock 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 1arm 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 asecurity: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 underrunand 0 underrun --parallel 1while 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, underrunandrun --parallel 1alike: 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 thesecurity: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
timewas always0.000, the HTML report never showed a scenario duration, and the archive'sscenario-startedline was stamped after the scenario had finished (#566).JunitXmlRendererandHtmlRendererread a scenariodurationMsfrom thescenario-completedevent, a field the frozen v1 event-wire contract never carried (onlystep-completedhas one), so every<testsuites>,<testsuite>and<testcase>printedtime="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 thescenario-startedtoscenario-completedtsgap, rounded to the millisecond and clamped at zero; a wiredurationMs, 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:ScenarioRunnerstamped the archivescenario-startedline at an instant taken after the script had already run (the live--events-streamcopy 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 archivescenario-startedsites 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 renders0.000, including the schema and parse refusals in the owning-topology path, which read the clock twice until now. A stream with noscenario-started, or an unset timestamp, still prints0.000. The suitetimeis the sum of the scenario times; topology startup and teardown fall outside every scenario, and under--paralleloverlapping 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'stotal=N mssuffix had the same cause and is fixed by #569 below. No event-contract or golden change. -
The terminal renderer never printed its
total=N msscenario suffix (#569).TerminalRendererread a scenariodurationMsfrom thescenario-completedevent, 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 thescenario-startedtoscenario-completedtsgap exactly as the other two renderers do on every stream the engine writes, rounded to the millisecond and clamped at zero, with a wiredurationMs, should a future engine emit one, taking precedence and clamped the same way;RendererParityTestsnow pins all three renderers to one value from one stream. The terminal's and the HTML report's step(N ms)and attempttMsfigures 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": nullaborted the whole HTML report (#571).HtmlRendererindexed its per-run scenario map by the null key, and the resultingArgumentNullExceptionescaped the per-line tolerance catch, which handles onlyJsonExceptionandInvalidOperationException, so one hand-written or corrupted line lost the entire document while JUnit and the terminal rendered a scenario from it; the same line crashedvouchfx planinRunCorrelator, which keys a dictionary by the run id downstream of the history reader.EventStreamJson.FromLinenow refuses an envelope whose requiredrunIdortypedeserialised to null, with theInvalidOperationExceptionevery 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.requiredenforces presence, not non-null, and the reader does not opt into STJ's nullable-annotation enforcement, which is why the check is explicit.RendererParityTestspins that all three renderers ignore such a line. No event-contract or golden change. -
vouchfx planstill aborted on ascenario-startedline with"scenarioId": nulland a stalefile(#573). The typed payload records declare their ownrequiredmembers, whichrequiredpresence-checks but does not null-check, so such a line passedEventStreamJson.FromLine<T>and reachedRunCorrelator, which keys a dictionary by the scenario id; theArgumentNullExceptionescaped the plan command, and ascenario-completedwith"counts": nullwould likewise have thrown inside the telemetry builder.FromLine<T>now refuses a record whoserequiredor[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 sameInvalidOperationException#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>.envvalues 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 tovouchfx validate(which never builds a topology) and, onvouchfx 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-errordoes 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 asecurity:block the author wrote and does not extend to it. The resolved value is visible via standarddocker inspectoutput (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 --jsonstep-type entries gaintier,supportedVerifyModes,docsUrlandexample(#556) — four members appended to everystepTypes[]entry afteratLeastOneOfGroups; the catalogue'sschemaVersionstays1and no existing member changes name, type, order or meaning.tieris"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 everylist --jsonentry is"core"), andnullwhen a library caller supplies no set: never a guess from a name.supportedVerifyModesis["IMMEDIATE","RETRY"]on every entry, the tokens thestep-startedevent already uses, because the engine compilesRETRYfor every step type and no provider can opt out.docsUrllinks 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 andmkdocs.yml); it isnullfor a non-Core entry, which the reference does not document.exampleis a complete, minimal suite exercising one step of the type:vouchfx scaffold's own output over one service namedappor one dependency named after its kind (the step alone would name atargetit does not declare), with the provenance comments left out so it carries no version; every Core example passesvouchfx validate, the 25 examples merge into one suite that also does, and a non-Core entry getsnull— as does a Core entry whose step declares atargetthe 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 novouchedmember: 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 fromtierplus the hub. For in-process hosts,EngineExport.BuildCatalogue(registry, engineVersion, coreProviderAssemblies)is a new overload; the existing signature is unchanged and leavestier,docsUrlandexamplenull. Unlike the previous additive round, the four members are init properties rather than positional parameters, soStepCatalogueEntry's constructor andDeconstructkeep their arity: source- and binary-compatible for existing .NET consumers. The humanvouchfx listtable is unchanged. -
A security-confirmation failure now breaks CI with neither
--fail-on-env-errornor--fail-on-inconclusive— the single deliberate exception to "onlyFailbreaks 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 declaredsecurity: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 declaredcaCert, 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 missingmethod:and a topology that then fails to provision exits 3, where the same pair without thesecurityblock 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, aprofilewith no wiring for the target's kind, a schema error anywhere in the document — including one located at the declaration itself, and includingsecurity: mtls, the profile name written where the block belongs, which binds no block at all — aclientKeyPasswordthat is not one whole, well-formed${secret:<source>/<path>}reference, a step-level${secret:…}naming a source the engine cannot resolve, an unresolvablescript.csharpfile:, 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 nordocs/ci-integration.mdmaintains a list of them. The practical consequence, measured on two documents differing only in whether they declaresecurity:: 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 whatEnvironmentErrormeans 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-topologyrunpath, so a document they refuse has no--parallelcounterpart 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--parallelrefused at the preflight: a scenario carrying both faults now reports both and exits 4 onrunandrun --parallel 1alike, 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 asecurityblock at all — measured:securityappears 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.servicesis now reachable by its own steps, not merely accepted at validation.mq-publish.kafka/mq-expect.kafkahave accepted atargetnaming 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 atsvc::<name>and both providers readconn::<name>, so a suite that confirmed green at the pre-run probe then failed on its first step withkafka 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 anhttps://host:portURL exactly as before, and a target the Kafka families address is staged as the barehost:portbootstrap authority those clients expect, carrying no scheme for the provider to strip. And each provider emits theVarskey matching the kind its target actually is, decided at compile time from the same declared-service map its ownValidatereconciled 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 validatereports 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-topologyvouchfx runinstead, 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--parallelthe 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) —validatedeliberately 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--watchit 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 theenvironmentblock — 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 underenvironment.services. -
endpoint:selector forproject:-form services — a new optional field onenvironment.services.<name>that names which of a project's discovered launch-profile endpoints the engine stages assvc::<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 unmatchedendpoint:value is refused at topology-build time, naming every endpoint the project declared.endpoint:isproject:-form only — declaring it on animage:-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 noendpoint:was declared at all, it is recorded on its own under Changed below rather than here. New public memberServiceSpec.Endpointcomes with it on the packableVouchfx.Engine.Authoringassembly, recorded here for the same reasonServiceSpec.PinnedHostPortsabove 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 orDeconstructchanges. -
transport-notice— the two transport advisories, as a record on the JSON Lines event stream. Both advisories aproject:-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 — carryingkind(plaintext-downgradeorno-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 ahost:portauthority. A fifth field,replayed, is written astrueonly where--watchre-reports an advisory raised by an earlier topology build, and is otherwise absent from the wire rather than written asfalse; 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.TransportNoticeEventis 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 packableVouchfx.Engine.Abstractionsassembly come with it, recorded here because that is this file's own rule for an addition to a packable surface: the recordTransportNoticeEventitself; the constantEventTypes.TransportNotice, holding the wire type string; and the classTransportNoticeKinds, holding the twokindtokens as constants alongside anIsKnownpredicate — a consumer branching ontypeor onkindshould reference those rather than hard-code the literals, andIsKnownis 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
Sourcebodies are pinned by hash in the SDK contract freeze gate.SdkContractFreezeTestsgains a companion golden,vouchfx-sdk-helper-sources.v1.txt, recording a SHA-256 of everypublic const string SourceinVouchfx.Sdk— todaySecretHelper,SubstituteHelper,SecurityHelperandKafkaSecurityHelper, 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 recordsfield const System.String Source, which is byte-identical whatever text the constant holds. That text is nonetheless part of the frozen v1 surface, because aconstinlines into every provider assembly at that assembly's compile time andCsxAssemblerdeduplicates 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 sameVOUCHFX_REGEN_SDK_CONTRACTflag 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 '…', thenService '…' should have valid address at this point) while a plaindocker run -p 0:80publishes 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 raiseLogging__LogLevel__Aspire_Hosting_Dcp=Debugand re-run, and even mid-fault those Debug lines did not reachdotnet 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 versusVOUCHFX_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 DCPDebugtraffic 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 justStartAsync— thefail: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.restbody:that expands past 50,000 JSON nodes no longer compiles (#346). A suite whosebody: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 whoseMaximumRecursiondefault is 50, so a body deep enough to breach that ceiling was already refused with a cleanFailed 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 whichbodyis 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 aJsonNodetree 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.1for 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:
httpPorton aproject:-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 alongsideproject:now fails schema validation, and the refusal names its own remedy on the path an ordinary author takes: schema validation runs first on bothvouchfx runandvouchfx 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, thathttpPortnames a container port rather than a listener, and thatendpoint: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 noendpoint: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. Aproject:-form service cannot declaresecurity:, 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 atransport-noticerecord naming the service and the selected endpoint, so a CI job reading--eventsor--events-streamcan see it; the JUnit and HTML renderers ignore it (theirdefault: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 inFixedbelow, which reports the opposite choice: that one fires only where noendpoint:was declared and the engine picked a plaintext listener over an available https one. -
Breaking: the
${secret:sigil is now matched case-insensitively inenv:values (#428). A lower-case${secret:…}written intoenvironment.services.<name>.envorenvironment.dependencies.<name>.envhas always been refused — a dependency's or service's container environment is readable by anyone who can rundocker inspect, so it is no place for a secret. But the comparison wasOrdinalwhile the sibling${env:sigil is deliberatelyIgnoreCase, 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 nowOrdinalIgnoreCase. 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 assecurity.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 toInconclusive, deliberately. A topology that fails to start also executes nothing and reaches the same completion path, but carriesEnvironmentErrorand keeps its own--fail-on-env-errorgate — 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.yamlbeside one that parses used to exit 0: the parse failure was folded in asInconclusive, which maps to Success unless--fail-on-inconclusiveis 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 onrun'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-timeInconclusiveis 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. AFailstill outranks a parse failure and still exits 1. This also closes #425's security case without any security-specific exit policy: a malformed document declaringmtlsnow reddens because it was unreadable, not because of anything it declared — so no raw-YAML scan for asecurity: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, underrunandrun --parallel 1alike: a secured document whose sole fault is a step-level${secret:…}naming a source the engine cannot resolve, an unresolvablescript.csharpfile:, 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 thesecurity: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 differentenvironmentblocks 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 archivedScenarioRunner.csat 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 inRunCommand, whose guard is the conjunctionUnconfirmed && 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 onlyFailbreaks 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, byPartiallyConfirmedDeclarationBesideAConfirmedProbe_ExitsNonZeroover 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 wiringScenarioRunnercould 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.yamlfiles declare the same targets — the arrangement the engine's own shared-environmentrequirement encourages, since every scenario of a suite must declare a byte-identicalenvironmentblock — 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 defaultrunpath with neither--fail-on-inconclusivenor--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 itsConfirmedset 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 reachingAuthenticatedRoundTripon 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'sintegrationjob iscontinue-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 anmtlsassertion 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 nosecurity: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 plainenvironment: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 boundservices: [tagged](its key lookup scans forward and takes the FIRST occurrence) while the schema validator reportedUnknown 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/.ymlin 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
EarlyMessagefrom 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 asScenario '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 recordsnulland the stream serialises withWhenWritingNull, 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, andserverArtifacts[].source) and seed paths are resolved against the suite directory before any containment or existence check. AnEnvironmentSecurityValidatororSeedApplierfault previously reached the event stream (inscenario-completed.message), the JUnitmessageattribute, 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
clientKeyPasswordis now withheld fromToString()rendering, closing a latent secret-value leak (#408). On a schema-validated pathSecuritySpec.ClientKeyPasswordholds a${secret:…}reference rather than a value, and §17 permits quoting a reference — but only onceSecretReference.ValidateSecretBearingFieldhas returned true, and that needs the run's secret-source list. AToString()has no such list, and the parser is deliberately lenient enough to bind a literal passphrase, soSecretReference.TryParsealone is not the proof. The withholding is therefore unconditional rather than conditional on what the field appears to hold. However,ServiceSpecandDependencySpeceach hold aSecuritySpec?, and the compiler-generatedPrintMemberson those records still invokedSecuritySpec's own compiler-generatedToString(). This is not a live leak in this branch, because the only paths that reach aToString()during an actual run (logging and inspection) never exercise those records. What kept it latent is that aSecuritySpecis 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.PrintMembersoverrides the default and rendersClientKeyPassword = <redacted>when declared (the reference itself is not printed) andClientKeyPassword =(empty, no value) when undeclared — every other member prints unchanged. The override isprivateon asealed record— the same accessibility the compiler generates — so no public API ofVouchfx.Engine.Authoringmoved and no positional parameter was added. Because a record's generatedPrintMembersexpands a member's ownToString(), 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,DependencySpecandSecuredTargeteach additionally carry a guard of their own, which withholds the wholesecurityblock 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 guardedSecuredTarget, a later round guarded the twoSpecrecords, and each fix named a type, so the next holder was invisible to both the fix and its test. GuardingSecuritySpecitself 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.SecuritySpecDisclosureTestspins 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
StartAsyncon 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 anEnvironmentErrorverdict, stamps that onto the event stream, and writes the configured--junit,--htmland--eventsartefacts 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).
HtmlRenderernow drops the C0 controls belowU+0020— keeping TAB, LF and CR, which are legitimate layout in a diagnostic and render underwhite-space: pre-wrap— together with the two BMP non-charactersU+FFFEandU+FFFF. The hazard was measured rather than theorised: anESC[31msequence written into an author-controlledmetadata.namereached the written report as a raw0x1bbyte. 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 reportcat-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, andmetadata.namereaches no provider'sEmitstage 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
--prereleaseflag (#388)..github/workflows/release.ymlwas not passing--prereleasewhen 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--prereleasetogh release editfor any tag matching the patternv*.*.*-*(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 malformedenv: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'ssecurity: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-inconclusivewas 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/--ownerselection now SEES a document that parsed and was then refused byAstBuilder. Selection matched on the built AST'smetadata, 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 nosecurity:declaration either. Themetadatablock was bound by the same parse that bound theenvironmentblock 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 nosecurity:block anywhere in the picture — measured on the built CLI: a directory of onenightly-tagged unbuildable, unsecured file plus one untagged sibling exits 4 underrun <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-inconclusivereddens 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 underrun <dir> --tag smoke, matching the barerun. 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.--watchis deliberately outside this: its selection still reads the built AST's metadata only, so arun <dir> --tag … --watchresolves 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. -
--eventsconsumers: a reproducibility-envelope fixture row with nocontentHashkey 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"}— thecontentHashkey is omitted entirely (WhenWritingNull), never written as an explicitnull. The broader meaning has been in effect since the fix released as part of #466, which widened the envelope builder's catch fromFileNotFoundExceptionalone 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.envSchemaVersiondoes 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', theproject/imageconflict, thehttpPort-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 declaredtype, 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; thehttpPortrefusal 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 theenumrejection and the dependency-securityrefusal 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
vouchfxtool 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 setsGenerateDocumentationFile=truefor every project, andPackAsToolpacks the publish output, so every reachable assembly's.xmlwas travelling insidetools/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.xmlleft there and the tool manifest requires it); the compressed package goes from 73,439,218 to 72,830,026 bytes. BecausePublishDocumentationFiles=falseacts at publish time, the same removal applies to everydotnet publishof this project, including the four self-contained per-RID archives the release workflow builds — measured on awin-x64self-contained publish of this tree, the identical 35 files and 3,002,636 bytes, down to none.GenerateDocumentationFileis 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.SdkandVouchfx.Sdk.Testingare 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 adotnet tool. -
A
miniodependency now pulls fromquay.io/minio/minio, and an env-levelimageRegistryno longer applies to it (#533). The default image for theminiodependency kind moved registry, at the same tag (RELEASE.2025-09-07T16-13-09Z): Docker Hub no longer serves theminio/miniorepository at all, so the previous bare reference was unpullable for anyone without a cached copy — measured, an unauthenticated pull returnspull 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, whilequay.io/minio/miniopublishes the same release tag publicly, as MinIO's own build target rather than a third-party mirror (upstream'sMakefileline 9 isREPO ?= 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-levelimageRegistryneeds no change and simply starts working again. The behaviour change is for authors who redirect images with an env-levelimageRegistry: that override has always been documented never to rewrite an already-qualified reference, andquay.io/minio/minionow is one — so the redirect that previously produced<mirror>/minio/miniofor 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-dependencyimage:override, which is unaffected and takes an arbitrary registry (dependencies.<name>.image: my-nexus.corp/mirror/minio:RELEASE.2025-09-07T16-13-09Z), withimagePullPolicyunchanged either way. Carry the tag in that value, as shown: animage:with no tag and no siblingversion:is refused at validation, because it would otherwise float on:latest. No other dependency kind is affected: of the thirteen,azureservicebusis 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.mdand this changelog's own${env:NAME}entry above both told authors thatvouchfx runreports 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-Configurepass throwsArgumentException, andScenarioRunner's catch aroundSuiteTopology.StartAsyncrecordsVerdict.Inconclusiveinstead — the taxonomy reservesEnvironmentErrorfor a genuine infrastructure fault, not an authoring mistake the engine catches before any container starts.--fail-on-env-errortherefore does not gate it;--fail-on-inconclusivedoes. The default behaviour is unchanged — a flagless run still exits 0 — so this corrects the documentation, not the engine: both surfaces above are corrected, andScenarioRunnerTests.RunAsync_EnvConfigReferencesUnsetEnvironmentVariable_ReturnsInconclusive_NoTopologynow 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).
RunCommandhas printedSecurityUnconfirmableNoticefor 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.ProviderOrEngineFaultNoticeis the new sibling constant, printed by the newRunCommand.ShouldPrintProviderOrEngineFaultNoticepredicate exactly when #480's rule is what took the exit code offSuccess: never when aFailverdict,--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 insideExitCodes.FromVerdictbefore #480's rule is ever consulted, so whenever it is the reason the code is notSuccess, 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 (CsxAssemblyExceptionrecords no fragment), so one wording serves both arms truthfully. FollowsSecurityUnconfirmableNotice's own recorded limitation: stdout only, absent from--junitand--events, so a machine consumer reading only artefacts still sees a bare non-zero exit for this rule too. -
A provider exception whose
Messagegetter throws no longer escapes the diff-renderer containment #485 built (#518).ScenarioRunner.ReportDiffRendererFaultcomposed its once-per-(kind, member, exception type)diagnostic fromex.Messagewhile already inside thecatchBuildDiffLookupuses to contain a throwingIStepDiffRenderer— but the compose step itself read that property unguarded, so a provider exception type free to overrideMessageand throw from its own getter escaped past the catch, pastTerminalRenderer's per-envelope filter (narrowed toJsonException/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().FullNameis read unconditionally now (Object.GetType()is non-virtual and cannot be overridden to throw); only theex.Messageread 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
azureservicebusdependency no longer leaves avouchfx-asb-<guid>temp directory behind for everyMap()+Configure()call that never starts a container (#438).Config.jsonwas written to a fresh temp directory eagerly, insideEnvironmentMapper'sBuild()lambda — which runs for every caller that invokes the returnedConfiguredelegate, whether or notDistributedApplication.StartAsyncever follows. That includes every non-DockerEnvironmentMapperTestscase that inspectsbuilder.Resourceswithout Docker: measured, +3 directories leaked perVouchfx.Engine.Orchestration.Testsrun, 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 itsConfig.jsonwrite are now deferred to an AspireOnBeforeResourceStartedhook on the emulator resource, which fires only when DCP is genuinely about to start that container — i.e. only during a realStartAsync— so a bareConfigure(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 aTempDirectoryLedgerMappedTopology.AsbTempDirectoriesCreatedexposes, andHeadlessTopology.DisposeAsync— the single teardown chokepoint (§4.5) — closes it and removes every entry the closing snapshot names afterStopAsyncreturns, swallowing only theIOException/UnauthorizedAccessExceptiona still-held mount could realistically produce. The one pathDisposeAsynccannot see is aStartAsyncthat fails after this hook has fired (DCP started the emulator, then failed on something else): no topology is ever returned, soStartAsync's own failure catch closes the ledger after disposing the app, in afinallyso that a dispose that throws cannot skip it.EnvironmentMapper'sConfigureregisters the ledger in the builder's services, andHeadlessTopologytakes it from there on both paths, so a caller that composesEnvironmentMapper.Mapwith the publicHeadlessTopology.StartAsyncdirectly gets the same cleanup asSuiteTopology, with nothing internal to call. AMappedTopologytherefore 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 cancelledStartAsynccan 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'svouchfx-prefix, whatever else the ledger holds. No observable change for a realvouchfx run: the emulator still receivesConfig.jsonbefore it needs it. Pinned by non-Docker tests inAzureServiceBusTempDirectoryLeakTests.cs. One assertsConfigure(builder)alone never creates the file, reading the exact bind-mount source path back off the resource's ownContainerMountAnnotationrather than globbingvouchfx-asb-*(parallel-safe on a shared machine). Others driveHeadlessTopology.DisposeAsync's cleanup directly, via a new internalForTestingDisposalseam wrapping aBuild()-built but never-StartAsync-edDistributedApplication, and the shared helper on its own. A census pins the failure path, because that hook only runs against a real container: thecatcharoundapp.StartAsyncinsideHeadlessTopology.StartAsyncresolves the ledger before disposing the app, cleans it after in afinallyaround that dispose, and rethrows. Another row wraps a built app without handing it a ledger, and showsDisposeAsyncstill 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; apopulatecallback 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 publishingBeforeResourceStartedEventdirectly against the builder's own eventing. -
mq-publish.redisandmq-expect.redisnow wire the step token through to their emitted helpers, closing the redis half of #468/#367 (#492). Both emitted helpers took noCancellationTokenparameter, the call sites passed no__stepCt_<safeId>argument, and neither catch ladder carried the defensiveOperationCanceledExceptionfiltercache-assert.redisalready had — the three Redis providers were inconsistent with each other for no reason tied to the underlying library. They now matchCacheAssertRedis_Helpers.Executeverbatim:PublishAsync/ExpectAsynctake a trailingct, the emit block passes__stepCt_{{safeId}}, and acatch (System.OperationCanceledException) when (ct.IsCancellationRequested)sits before the genericcatch (System.Exception), rethrowing past this provider's own error handling so the assembler'sWrapForImmediatewrapper classifies a step-token cut asInconclusive(step-timeout)instead of the generic branch misclassifying it. This changes no behaviour today. Reflected against the pinned StackExchange.Redis 2.13.1:ConnectionMultiplexer.ConnectAsynchas three overloads (string,string+Action<ConfigurationOptions>,ConfigurationOptions), none taking aCancellationToken, andIDatabaseAsync.StreamAddAsync/StreamRangeAsynclikewise carry only aCommandFlagsparameter — the redis client genuinely has nowhere to observe the token, so the filter is defensive, exactly as it already was oncache-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 declaredtimeout:is still honoured as a verdict —CsxAssembler's late-enforcement supersession resolves a step that overruns it toInconclusive, 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:ConnectTimeout5000 ms,SyncTimeout5000 ms,AsyncTimeout5000 ms (inheritsSyncTimeout). A step declaringtimeout: 2scan therefore take up to ~5 s to conclude. DerivingConfigurationOptionstimeouts from the step budget to close that gap was considered and rejected, for the identical reason #367 rejected it formq-publish.kafka: a client-side timeout expiry surfaces asRedisTimeoutException→EnvironmentError, the wrong verdict for what §12.1 says should beInconclusive. Pinned by new non-docker emit tests on both providers (the helper'sCancellationToken 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 manualusings+helpers+StatementBlockjoin rather thanCsxAssembler.Assemble— invisible until the call site started referencing__stepCt_<safeId>, a local only the assembler declares. -
--changed-sinceno longer hands the git child vouchfx's whole environment (#500). Everygitthe selector runs inherited the engine's environment in full — including whatever${secret:env/NAME}had resolved, sinceenvis one of the two MVP secret sources (§17). Git executes repository-influenced code in several documented ways (core.fsmonitor,.git/hooks/*,credential.helperin its!shellform), 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 beginsGIT_. 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_SYSTEMand the rest of theGIT_*family are unaffected. The residual is stated rather than implied: a secret stored in aGIT_-named variable still reaches the child, andGIT_SSH_COMMAND/GIT_EXTERNAL_DIFFremain 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-sincediagnostics 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'sIOExceptionwhen 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 interpolatedex.Message, which .NET composes fromSR.ErrorStartingProcessas "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 interpolatedfileNamewhole, which #499 had just made absolute by putting aPATHsearch in front of that seam. All three now name the leaf, and they were fixed although two of them reaches printed output today —RunGitdiscards 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 namesfileName, deliberately, becauseRunGitdiscards 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 leftSmith/src/repostanding for the commonest Windows shapes (C:\Users\<First Last>,C:\Program Files\Git). It also splits on=, so theKEY=/pathform 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 andscheme://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/xinto<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/passwdnow 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 noLANGorLC_*: the confinement's own test feedsLANGin 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-sincediagnostics 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 onPATHbut could not be started reused the not-found wording, asking "Is git installed and onPATH?" about an executable the engine had already located; launch failures now say it was found and the operating system refused to start it. -
A
PATHentry 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-owned0700git in an earlyPATHentry was therefore accepted, the search stopped there, and the launch failedEACCESon 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 resolvablelibc— musl/Alpine is the concrete case — the check degrades silently to the previous behaviour. -
A graceful
--shutdown-on-stdin-eofduring selection is no longer reported as an engine defect (#502). Closing stdin while a--changed-sincegit 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-sinceno longer contends for the git index lock. The selector'sgit statuswas taking.git/index.lockfor 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 withoutLOCK_DIE_ON_ERRORand silently skips the refresh when it is held. The flag is right; the direction was not.) -
An exception whose
Messagegetter 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 fromMessageescaped 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,EmitorCompileReferenceAssembliesthrew, 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 racingscript.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-errorcan still change WHICH non-zero code such a run carries: where the aggregate isEnvironmentErrorthe 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 onescript.csharpscenario 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-inconclusiveand--fail-on-env-erroron 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 dependencyenv:, 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-sincenow launches git by an absolute path resolved offPATHonly, so agit.exedropped 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.GitChangeSethanded the bare namegitto the runner, and on Windows that is not aPATHlookup: .NET starts a process withlpApplicationName = null(everything in the command line, perProcess.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 ofPATH. Both were measured to win, on Windows 11 build 26200.9168 / net8.0, from a console app callingProcess.StartwithUseShellExecute = falseand a bareFileName, against a plantedgit.exethat 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 onPATHruns the marker, and removing the impostor again runs the real git. With an impostorgit.exeplaced beside the calling executable the child that ran was the impostor (exit 0, marker on stdout); with it removed, the same call printedgit version 2.54.0.windows.1. For a tool installed withdotnet tool install, that directory is~/.dotnet/tools, which the user can write to — one file there takes over every git call on this path.gitis now resolved to a rooted path, whichCreateProcessandexecveboth take literally, and there is no search left to lose. The seam receiving it enforces that rather than documenting it:SystemProcessRunner.Runrefuses afileNamethat is not fully qualified, with anArgumentExceptiondeliberately outside the three failure typesGitChangeSetmaps 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 namegit, and a doc-comment cannot catch that. What remains open, and it includes~/.dotnet/toolsitself: an attacker-writable directory sitting earlier inPATHthan git's own still wins, because the resolution takes the firstPATHmatch. A global tool's install directory is onPATHby construction — it is how the shell findsvouchfxat all, anddocs/getting-started.mdtells the user to put it there — so for the supported install shape the drop described above is narrowed from an unconditional win to aPATH-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 vetPATH. Agit.execommitted 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 onPATHat all, the impostor ran (exit 0); with the real git added toPATH, the impostor still ran, so this term beatsPATH.cd untrusted-repo && vouchfx run . --changed-since maingives the CLI exactly that current directory — the process inherits the shell's, and nothing in the CLI ever callsDirectory.SetCurrentDirectory— and the drop needs no write access to the user's profile, which makes it at least as reachable as the~/.dotnet/toolsone above. The earlier non-reproduction was an artefact of the measuring environment.NoDefaultCurrentDirectoryInExePathsuppresses the current-directory term, and it is not a Windows default — measured on this host,Machine=''andUser=''whileProcess='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 inpsi.Environmentwhile 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.WorkingDirectorysets 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 rootedFileNameremoves 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-optionsdefends git's own option parsing, andArgumentListdefends against shell quoting; which binary is resolved was covered by neither, and all three now stand together. The search readsPATHand 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 isPath.IsPathFullyQualified, notPath.IsPathRooted, because the latter accepts the Windows drive-relative formC:dir.) There is exactly one candidate per entry, and on Windows it isgit.exe— notPATHEXT, 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.exeand nothing else: with aPATHdirectory holding onlygit.cmd, the bare-name launch threwWin32Exception … The system cannot find the file specified. And a batch candidate would be a command-execution sink:Process.Startagainst a rootedgit.cmdwithUseShellExecute = falsedoes launch — throughcmd.exe, whose parser then re-reads the argumentsArgumentListhad quoted forCreateProcess, soArgumentList = ["diff", "\"&echo INJECTED&\""]made the child printINJECTED, and--changed-sincereachesArgumentListverbatim. A host whose only git is a.cmd/.batshim therefore gets a refusal — the sameChangeSetExceptionas 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. APATHentry is taken verbatim — neither unquoted nor trimmed of surrounding whitespace, because both arecmd.exebehaviours rather thanCreateProcessones and either would resolve an entry the search being replaced does not. Measured on the same host with a bare-nameProcess.Startagainst 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 aPATH=%PATH%; C:\toolshand-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 onPATHis refused rather than degraded to the bare name — falling back is precisely the hole being closed — and the refusal keeps the existing outcome exactly: aChangeSetExceptioncarrying 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 anEnvironmentError, 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 onlyCould 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 onPATHstill 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 onPATHis a valid executable this user may run. Both remain path-free. Both candidate reasons leak: measured,Process.Startagainst a rooted, absent executable throws aWin32Exceptionwhose own message composes in both the executable path and the working directory (An error occurred trying to start process '<path>' with working directory '<dir>'.), andSystemProcessRunnerquotes 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 realProcess.Startfailure 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-sincegit shell-out no longer leaks its child, and is now bounded by a per-call time budget (#481).src/Cli/Vouchfx.Cli/Selection/SystemProcessRunner.csis the single productionIProcessRunner, used on the--changed-sinceselection path viaGitChangeSet. It carried three defects: theProcesswas never disposed on any path (a handle leak per call), the launch sat outside anytrythat owned cleanup (so a throw fromReadToEnd()orWaitForExit()abandoned a live child), and there was no time bound at all. The fix wraps the launch in ausingstatement, guards child cleanup with a privateKillTreeQuietlyin afinally, 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 onlyWaitForExitfixes 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), andProcessLaunchException(unchanged, the executable was not found).GitChangeSet.RunGitmaps all three toChangeSetExceptionso 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 intoProcessCaptureExceptionis 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 theProcessthe 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_NotCrashpins 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-sinceselection now receives the run's cancellation token, threaded throughSelectScenariosandGitChangeSetto 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 newfinallynever 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 toChangeSetException;RunGit's three catches are narrow by type for that reason, so the existing cancellation exit-code behaviour is untouched. The census gateChildProcessKillCallSiteCensusTestsgained 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.ProcessKillGuardParityTestspins the product-code catch filter identical toVouchfx.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 caughtFileNotFoundExceptionalone; #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 onFixtureDigest, 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.csharpstep whosefile:cannot be read no longer discloses the host path it resolved to (#488).Emithanded the resolved absolute path toFile.ReadAllText, whose own message quotes it back (Access to the path '<resolved>' is denied., or the sharing-violation equivalent); anEmitthrow becomes a compile refusal that reaches--events, the JUnit XML and the HTML report. The reachable disclosure was afile:resolving outside the suite directory — which nothing refuses, sincefilecarries no path constraint in the schema and validation applies no containment check. For an ordinary in-suitefile:the pipeline's existing suite-directory substitution already reduced the leak tothe 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 holdingSecurityPathDisclosureLedgernor 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). Thefinallywastry { consumer.Close(); } catch { }thenconsumer.Dispose();; it is now a swallowedconsumer.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:Closehas exactly one overload,Close() -> Void, with noCancellationTokenand noTimeSpan, and the package binds the blockingrd_kafka_consumer_close, notrd_kafka_consumer_close_queue. Bounding it in the engine is forbidden rather than merely unattractive — anyTask.Run/Wait(timeout)shape abandons a frame inside the collectible submission assembly whose unloadRunIsolatedAsyncperforms in the samefinally, deferring that unload unobserved, and it would race the teardown on onerd_kafka_t. RemovingClose()is not a new code path: from the IL it isConsumerClose(); Dispose(true); …, andConsumerClose()throws on any non-zeroErrorCode, so whenever the broker was uncooperative — the case #468 is about — the old barecatchalready swallowed that throw and the handle was already released by the outerDispose(), 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'ss_helpersremarks, 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.ReleaseHandletakes plainrd_kafka_destroyfor a producer anddestroy_flagsonly 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 usedSubscribe, 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. Since08f75bb(2026-10-11), both emitted consume paths discover retained partitions and callAssign; they do not subscribe or join a group, so a joined-consumer harness is not required to exercise the current provider. The current assigned-consumerDispose()cost remains unmeasured. Historically,Unsubscribe()was adjudicated, not overlooked — the pinned package's ownConsumer<K,V>.Disposeremarks name it alongsideClose(), and it carries none ofClose()'s disqualifiers — butrd_kafka_unsubscribereturns once the request is enqueued, so following it immediately withdestroy_flagsmost 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 becomingEmpty, whereClose()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 shortenspollIntervaland multiplying by--parallelslots 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, soClose()committed no offsets either. This supersedes themq-expect.kafkahalf 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 teardownDispose()is swallowed, matching the sibling's house rule, because thevars[outcomeKey]write sits after thefinallyand an escaping throw would discard the step's real verdict and skipregistry.Dispose(). AndbudgetGoverned, silently ignored on the plain path, is now discarded explicitly on both:CsxAssemblersets ittruein exactly one mode — IMMEDIATE with a declaredtimeout— and this config has no client-level transport timeout to lift, so a step declaringtimeout: 30sunder default IMMEDIATE still stops polling at the hard ~1 s per-attempt deadline and writesFail, because waiting for a message to arrive isverifyMode: 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
integrationCI job and localdotnet testagainstrequires=dockertests may leave lingeringdotnetprocesses 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 ofdotnet/vouchfxprocesses, 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 viaVOUCHFX_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[].sourceand every resolvedenvironment.seedSQL 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.ServerArtifactInjectionhands the resolved path to Aspire'sWithContainerFiles, 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'sdetailin the event stream,--events, JUnit and the HTML report.SeedApplieralready 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).
#357established 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 takesssl.ca.location,ssl.certificate.locationandssl.key.locationas 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--eventsartefact and the HTML report, where no later scrubber could reach it: the existing net isResolvedSecretLedger, 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.
--watchnow refuses an authoring fault before it starts a container, instead of after (#370). The watch loop's compile step wasYamlDocumentParser.Parse+AstBuilder.Buildand 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 plainvouchfx runrejected it before any Docker work; a both-families protocol conflict on a secured broker suite reached the confirmation probe, which attempted a KafkaApiVersionsround 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; andSecuredEndpointProbe's unrecognised-security.profilerefusal, 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 asprofile: kerbrosstarted 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
--watchsave carrying faults in both pre-topology walks now reports both, in the same orderrunreports them (#412). The provider-pipeline compile and the secret-reference walk were merged into one door onvouchfx runandrun --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. -
--watchnow rebuilds the topology when a save changes what the steps target, not only when theenvironmentblock changes (#370). The reuse key was a hash of theenvironmentblock alone, which cannot see two inputs the topology is genuinely built from, both derived from thesteps. A save that added a step targeting a previously untargeted endpoint-lessproject:-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 aUriFormatExceptionfor the rest of that session instead of the located refusal plainrungives. 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 barehost:portbootstrap 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.kafkastep's declaredtimeoutis an upper bound again; it was overrunning by exactly ten seconds (#367). A step declaringtimeout: 20sconcluded at 30027 ms and one declaringtimeout: 10sat 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 threwOperationCanceledExceptionat 5025 ms. What then ran was the emitted helper'sfinally, and it held an unconditionalproducer.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. (SeveralDispose()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 aCancellationTokenSourcelinked 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:ctisCancellationToken.Nonethere so onlyCancelAftercan 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 swallowedOperationCanceledExceptionwhereFlush(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'sIMMEDIATEwrapper — driven directly against a manually-run broker container rather than through the Aspire topology, because therequires=dockertopology 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 — atimeout: 10sstep 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 isInconclusive, neverFail(§12.1). A successful publish against a reachable broker is unaffected — measuredPassin 272 ms, because a delivered message leaves nothing outstanding and the flush returns at once. Deriving librdkafka's ownmessage.timeout.msfrom 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 aProduceExceptionand classifies asEnvironmentError. That trades a timing defect for a nondeterministic one. Onlymq-publish.kafkachanged, 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'sconsumer.Close()is the same defect class with a config-derived bound rather than a hard-coded one, andmq-publish.redisandmq-expect.redisdo not observe the step token at all in the calls they make (StackExchange.Redis exposes noCancellationTokenoverloads, so the real bound is its own ~5 s client timeouts). Both are recorded in #468 and deliberately left unfixed here. Themq-expect.kafkacomment that asserted the opposite — the same sentence this entry retracts formq-publish.kafka— is corrected in place, since it sits on the likeliest next instance. -
A suite refused by the shared-
environmentdivergence guard now writes the reports the run asked for, instead of exiting with none. The guard returned before the completion path, so--junit,--htmland--eventsproduced 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-levelEnvironmentErroronto 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 JUniterrorsrather 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-levelEnvironmentErroronto 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.Applywas passed the retained dependency builder (theAddDatabasechild 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 threwArgumentExceptionduring topology build. Two gates confine thesecurity.serverArtifactsdeclaration tokafka-only targets (the Kafka-only schema clause andSecurityProfileWiringValidator), so this defect was not reachable from a valid.e2e.yamlonrun/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 toIResourceBuilder<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 anapplicationUrl. Aproject:-form service was never populated intoserviceEndpoints, sosvc::<name>was missing entirely, and every HTTP-family provider fell back to an empty string, throwingUriFormatExceptionat step execution time — after the topology was up. The engine now reads the builtProjectResource'sEndpointAnnotationsand stages a primary endpoint: the listener the service named throughendpoint:, 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 anapplicationUrlin 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 throughendpoint:, 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 declaresecurity, so the engine holds no client trust material for its TLS listener, and preferring https would fail the handshake into anEnvironmentErrorthat 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 atransport-noticerecord; the JUnit and HTML renderers ignore that record, so those two reports are unchanged. -
#353:
SecuritySpecnow carries anExtrabucket for composed profile fragments, withheld fromToString(). Slice C'sunevaluatedProperties: falseon$defs/securityopened the schema seam; this fix closes the model-layer seam.ParseSecurityreads a fixed set of seven keys — six scalars plus theserverArtifactssequence; six is the primary constructor's arity, not the key count — intoSecuritySpec, which gains an init-onlyYamlMappingNode? Extrafield, populated by the existingBuildExtraNode. A field contributed by a composed profile fragment survives parsing and is withheld fromToString()for the same reasonClientKeyPasswordis withheld: the parser applies no shape to what lands there, so it can hold a literal passphrase andToString()cannot prove otherwise. Additionally,DependencySpec.Extrais withheld fromToString()on identical grounds.ComputeEnvironmentHashnow returns a SHA-256 digest rather than the canonical JSON that carriedclientKeyPasswordin plaintext; all consumers are string-equality checks with no persistence, so digesting is behaviour-preserving.Extrais deliberately NOT consulted for theSecuredTargetsdigest — 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 unknownsecuritykey 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 productFail(exit 1).ProviderPipeline's per-stepBindcall was unguarded and nothing above it caught, so a provider whoseBindthrew escaped the entire run: no verdict, and none of the--junit/--html/--eventsartefacts 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 Nanswered 0 for the same fault — measured as aVerdict.EnvironmentError(it caught the throw further out and classified it so); the 0 follows fromExitCodes.FromVerdictfor anEnvironmentErrorwith 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:runmoves 1 → 4,--parallelmoves 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 theTargetInvocationExceptionreflection wrapper, whose own message ("Exception has been thrown by the target of an invocation") identifies nothing — and the scenario takesVerdict.Inconclusivethrough 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).Inconclusiverather thanEnvironmentErroris 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 runadditionally 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 therunpath. An exception raised inside a--parallelslot never reaches it — the per-slot catch-all absorbs it first and classifies itEnvironmentError, 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 moveEnvironmentErrorsemantics for real infra faults too. The provider-Bindroute 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 runningvouchfxunder a timeout:TaskCanceledExceptionderives fromOperationCanceledExceptionand 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-eofstop that ESCAPES the run, map to 4 — the same codeShutdownBackstopalready 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
environmentblock is reported as an authoring fault again, not as an infrastructure one. The shared-environmentdivergence 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-levelEnvironmentErrorreading "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 andVerdict.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 — sameEnvironmentError, 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 isscenarios[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 asecurity:block is a schema error located at that block, so the engine recordsSecurityDeclarationRejected, 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 theirenvironmentblock is no longer held against the one that starts, while the identity a declaration is matched by hashes theendpoint:selector's text and not its resolution — so a rejected scenario declaring a byte-identicalsecurity:block on a service with a differenthttpPort: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 declaresenvironment.seed, and the two documents'seedsections 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 theseedsections 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.ConvertYamlToJsonDocumentruns during bothvouchfx validateandvouchfx runand is upstream of every provider'sBind, 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 countingTextWriterthat 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.csharpgrants 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 ownYamlStream.Loadpath is deliberately unbounded, and the reason is not that it reads only fixed paths —RequireUniqueMappingKeyswalks 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 onlyJsonExceptionandInvalidOperationException, 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 reachedvouchfx run's top-level backstop (#413) and yielded exit 4 (Inconclusive), so no verdict was ever falsified. The fix: bothCanRenderandRenderDiffare 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:CanRenderthrowing means the renderer could not even decide whether it recognises the shape,RenderDiffthat 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 fromCanRender, which is why the diagnostic does not blame the provider unconditionally for anInvalidOperationException. 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-expectstep'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 whatevertimeouta step declares, so under the defaultverifyMode: IMMEDIATEan author writingtimeout: 30sgot 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 wholemq-expectfamily rather than a Kafka quirk — measured,mq-expect.rabbitmqdrains at most 200BasicGetfetches 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 meantimeout: 30swaits 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:timeoutis 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. Themq-expect.kafkaandmq-expect.rabbitmqschema descriptions now state the single-drain semantics and nameverifyMode: RETRYas the way to wait for a message, which the other threemq-expectproviders 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 commontimeoutdescription 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 everymq-expect.kafkaPass/Fail observation now carriesdrainWindowMsbesidematchedandscanned, 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--eventsstream, where a Fail at about a second undertimeout: 30sbecomes self-explanatory.--eventsis the only surface that carries it: underverifyMode: RETRYthe 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 defaultIMMEDIATEthere is no attempt timeline andmq-expect.kafkasupplies noIStepDiffRenderer, so neither renderer shows the observation at all.drainWindowMsis Kafka's alone; RabbitMQ has no window to report. Compatibility: it is an additive key inside theobservationstring 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'sRedactAmqpUricompared its userinfo layer againstUri.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 redactsUri.UnescapeDataStringof the extracted password, alongside the existing escaped-form replacement. Separately, theRedactCredentialshelper emitted bycache-assert.redis,mq-publish.redisandmq-expect.redisbounded itspassword=/user=value match on[^,;]+, treating;as a second delimiter — but StackExchange.Redis'sConfigurationOptionsparser 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.soapandmetrics-assert.prometheusnow configure their transport from thesecurityblock declared on the target they name: they present the declared client certificate (profile: mtls) and validate the server against the declaredcaCert. A target that declares nosecurityblock 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.endpointis exposed with anhttpsscheme, andsvc::<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: anhttps://host:portURL for a service the HTTP family addresses, and a barehost:portbootstrap authority for one the Kafka families address (see the separate entry below) — the endpoint annotation carries thehttpsscheme either way. - The implicit plaintext HTTP endpoint an
image-form service would otherwise receive is suppressed, exactly as declaringportssuppresses it, unlesshttpPortis also declared alongside. Note that anhttpPortequal tosecurity.endpointis 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
healthCheckagainst a separate unsecured port for a stronger probe. - A
project-form service cannot be secured in this release, and now says so atvouchfx validatetime rather than once containers are starting. Its endpoints come from its own launch profile, so the engine has none of its own to give anhttpsscheme. -
A declared
caCertis 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 theserverAuthextended 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 namelocalhost— 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 ascaCertand 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 —clientCertis 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 withclientKeyPassword, 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 ofmtlspreviously 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 newclientKeyPasswordfield, optional underprofile: mtlsand forbidden underprofile: 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 validatereports all four, and onvouchfx runall four are refused before the topology is built — no container starts and the run exits 4 with neither--fail-on-inconclusivenor--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 undermtls, because whether the declaredclientKeyis encrypted is a property of that file and not of the document:mtlsstill requires exactlyclientCertandclientKey, 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, andmq-publish.kafka/mq-expect.kafka, which set their client library'sssl.key.passwordfrom 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--watchit resolves against exactly the sourcesvouchfx rundoes: 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 asvouchfx rundoes. Only theENCRYPTED PRIVATE KEY(PKCS#8) form can be opened this way; an openssl legacy key, markedProc-Type: 4,ENCRYPTED, is named alongside a wrong passphrase as one of the two candidate causes, with theopenssl pkcs8 -topk8conversion 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 noclientKeyfor it to unlock, a reference resolving to an empty value, and a wrong passphrase — which namesclientKeyPasswordas 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 forcaCert(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.yamldemonstrates both halves of the authenticated-infrastructure shape against one topology: ahttp.restcall presenting a client certificate to a TLS-terminating nginx service, and amq-publish.kafka/mq-expect.kafkapair 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 ascript.csharpstep, 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 isTransportConfirmedplus 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 byexamples/security-mtls.setup.sh(openssl) orexamples/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 asecurity: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 optionalsetup-scriptinput and the GitLab template an equivalentVOUCHFX_SETUP_SCRIPTvariable — 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.ymldiscoversexamples/<name>.setup.shby convention rather than naming this example, andExamplesCompileTestshonours 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>.envaccepts an environment-variable map, so a managed resource whose image is configured that way can be configured at all (e.g.MSSQL_COLLATIONon the SQL Server image Aspire pins). Values take the same shapes a service'senvdoes, 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 rundocker 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 (minioroot credentials,elasticsearchdiscovery/heap, theazureservicebusemulator'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 therunpath —vouchfx validatenever 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 onminiothey 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 anenvironmentblock whose dependencyenv: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'senv: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[].securityand all — and then threw that bound document away whenAstBuilderrefused 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 assertedmtls. Discovery now retains that boundenvironmentblock 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 1arm 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 asecurity: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 underrunand 0 underrun --parallel 1while 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, underrunandrun --parallel 1alike: 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 thesecurity: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 declaringapi— one assertingprofile: mtlson port 9093 with a client certificate, the other assertingprofile: tlson 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 thesecurityblock 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 declaredclientKeyPassword'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-valuedsecurityfield is now refused by name, instead of failing as a missing file with a mangled path (#387).caCert,clientCert,clientKeyand everyserverArtifacts[].sourcename a host file; a secret reference in one of them previously reached the filesystem check and reportedfile '${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 — onclientKeyalone, where an author reaching for${secret:}is almost certainly trying to avoid a plaintext private key at rest — points atclientKeyPasswordas 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 recommendedclientKeyPassword, 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
portsdeclaration —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 (schemetcp). -
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 —typeis closed totcp/http. OmittinghealthChecknow depends on shape: animage:-only HTTP service, and the hybridports+httpPortshape, both keep the default HTTP probe on/; aports-only service (no siblinghttpPort) defaults to atcpprobe against the first declared port, rather than no health check at all.typevalues are case-sensitive. -
IProjectContext.DeclaredServices(the services-generalisation surface) — new member on the provider-authoring SDK's validation-stage context, mapping each declaredenvironment.services.<name>to the Aspire endpoint names its shape produces (mirroring the existingDeclaredDependencies).IProjectContextis 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 —SdkContractFreezeTestssnapshots the wholeVouchfx.Sdkpublic surface,interface Vouchfx.Sdk.IProjectContextincluded — 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 itsDeclaredServiceInfovalue type has shipped in any of the fourteen published tags (measured:DeclaredServicesappears insrc/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 implementsIProjectContextdirectly: a hand-rolledclass X : IProjectContextwith noDeclaredServicesmember 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 ownRunProjectContext(Vouchfx.Engine.Runtime) — needed the identical change, 31 in total.TestProjectContextships inside theVouchfx.Sdk.Testingpackage with the member already present, so any external harness built against that package is unaffected, but a hand-rolled double anywhere else is not.DeclaredServicesunderpins 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 aDeclaredServiceInforecord (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.kafkaandmq-expect.kafkanow accept atargetnaming 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
descriptionwas added to the 16 fragments that lacked one (http.rest,http.soap, all fivedb-assert.*providers, bothcache-assert.*providers,mail-expect.smtp,mq-expect.kafka/rabbitmq,mq-publish.kafka,script.csharp,trace-expect.otlp,webhook-listen.http), plushttp.soap's previously-undescribedexpect.xpath[].path/.valuefields — every provider and field in the generateddocs/language-reference.mdnow 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 toroot-language-schema.json's$defs/dependencycontainer and itstypefield, whose descriptions previously leakedSchemaComposer.BuildIfThenClauses/EnvironmentMapperclass 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.existsandstorage-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.BuildCataloguesurfaces aoneOf/anyOf-nested requirement honestly, via two new typed fields — never as prose folded intoRequiredFields.script.csharp's catalogue entry previously reportedRequiredFields: []— a real lie: exactly one ofcode/fileis required, but that constraint lives entirely inside aoneOfthe field-extraction logic never read.StepCatalogueEntrygains two additive fields:ExactlyOneOfGroups(from a rootoneOf, e.g.script.csharp's[["code","file"]], and — the same generic detection —mq-publish.azureservicebus's[["queue","topic"]]) andAtLeastOneOfGroups(from a rootanyOf, e.g.mq-expect.azureservicebus's[["expectPayloadContains","expectProperties"]]— which also closes a related gap: a minimal document built fromRequiredFieldsalone for that type previously under-specified a document the composed schema actually rejects).SuiteScaffolder(thevouchfx 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 qualifyingoneOf/anyOfbranch 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 ownqueueXOR (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 (thelist --json/vouchfx-mcpcontract) 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 —StepCatalogueEntryis a positional record, so both its constructor and its compiler-generatedDeconstructchange 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'sfeat!marker onVouchfx.Engine.Compilation(IsPackable, a published engine assembly — not the frozenVouchfx.Sdkcontract, 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-exclusiveexpect.size/expect.minSize(Property 'minSize' cannot be combined with 'size' — exactly one of the two may be set) and everyexists: false-forbids-content-fields shape (storage-assert.s3's six content fields,db-assert.dynamodb'sexpect.item; e.g.Property 'item' is not valid when 'exists' is false), both derived from the live composed schema's own siblingifcondition, never a hardcoded field list. Degrades to the previous genericProperty 'x' is not valid herefor any shape that doesn't exactly match one of these two patterns. -
securityblock on services and kafka dependencies — asecurityobject is now recognised and validated on everyenvironment.services.<name>and on akafkaenvironment.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 closedenum; 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 owntypeis a pattern in the schema and a registry lookup elsewhere),endpoint(port number or endpoint name, required wheneversecurityis present — deliberately explicit so a suite cannot silently resolve to plaintext), optionalcaCert(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,mtlsonly; forbidden undertls), andserverArtifacts(list of{source, target}pairs; no inlinecontents— binary keystore material cannot survive YAML text). Atvouchfx validateand pre-topologyvouchfx runtime: every declared path-valued field (caCert,clientCert,clientKey, eachserverArtifacts[].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'sminLength: 1catches a literal empty string, andEnvironmentSecurityValidatorcatches a whitespace-only value the schema's character count alone cannot, naming the offending field. (The field was originally namedmode; renamed toprofilebefore this series' first release — see the freeze-critical shape entry below.) -
security.profileis 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)moderenamed toprofile, total, not aliased — a mechanism axis (which technology-specific wiring applies), not a strength axis, the same roletype: family.providerplays 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/securitycloses withunevaluatedProperties: falsereplacingadditionalProperties: false(never both — a siblingadditionalPropertiesin the same schema object silently voidsunevaluatedProperties), 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) Asecurityblock is legal only where this release actually wires a client connection: on any declared service, and on akafkadependency. Every other dependency kind rejects the block outright — not the profile value, the block. There is no profile an author can substitute:tlsandmtlsare refused identically on apostgres,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: falsefor every dependency kind except kafka), not an enumerated exclusion, so a future dependency kind carries nosecurityblock 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 underenvironment.servicesdoes not work: every step family targeting those kinds resolvestargetagainstenvironment.dependenciesalone (measured — 17 of the 25 Core providers reject atargetthat 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 resolvetargetexclusively againstenvironment.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.kafkaandmq-expect.kafkanow configure their producer and consumer from thesecurityblock declared on the target the step names — on the plain-payload path and the Avro/schema-registry path alike, publish and expect.profile: tlsverifies the broker only;profile: mtlsadditionally presents the declaredclientCert/clientKey. A declaredcaCertbecomes 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 addsssl.ca.locationwith 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 nosecurityblock 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 anamqps://,tls://,,ssl=trueortls=trueconnection 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:tlsandmtlsare 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
securityendpoint 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--paralleltoo), carrying one of two levels: -
AuthenticatedRoundTrip— the engine completed an application-protocol round trip over the secured connection. Today that means a KafkaApiVersionsexchange, reached for anykafkadependency and for any target the suite's ownmq-publish.kafka/mq-expect.kafkasteps 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. Underprofile: mtlsit 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 ownssl.client.authdefaults tonone, so a listener that requests a certificate without requiring one would be indistinguishable from an enforcing one. Underprofile: tlsthere 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 declaredcaCert(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.serverArtifactsnow 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 akafkadependency 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:targetmust 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 inlinecontents:form, because binary keystore material cannot survive as YAML text. Eachsourceis resolved against the suite's own directory, containment-checked and existence-checked on exactly the same terms ascaCert/clientCert/clientKey. -
New public SDK type
Vouchfx.Sdk.KafkaSecurityHelper— the compile-time constant source of the helper class the two Kafka providers splice into theirCsxFragment.RequiredHelpersto 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 —SdkContractFreezeTestssnapshots the wholeVouchfx.Sdkpublic 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.csis absent fromsrc/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 newICompileContext.DeclaredServicesmember — 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.SecurityArtifactPathis the single spelling of the containment rule for a declaredsecurityartefact 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 snapshotsVouchfx.Sdkonly).ICompileContext.DeclaredServicesmirrors the identically named member onIProjectContextso a provider'sEmitcan decide whichVarskey 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, soports: [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 rulesecurity.endpointalready 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'sadvertised.listenersabove all — reads that address from the configuration it is given at start-up. Where the engine provisions the server itself, as it does for akafkadependency, 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 andhealthCheck.portall 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 atvalidate/runrather 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.PinnedHostPortson the packableVouchfx.Engine.Authoringassembly: the host port each pinned container port publishes on, keyed by container port, andnullwhen 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 orDeconstructchanges.ServiceSpec.Portsis 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 anhttpsscheme. 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 withconn::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. Theenv: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 runexits 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.1underenvironment.servicesand vary exactly one declared value per pair, each pair sharing one positive control. A plaintext listener open beside the secured one: withsecurity.endpointnaming 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 anEnvironmentErrornaming 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 theAuthenticatedRoundTriplevel — the broker answered a KafkaApiVersionsexchange 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
Bindalready accepts at runtime — a non-breaking widening, not a narrowing. EveryadditionalProperties: {"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 (payloadon all fivemq-publish.*providers,keyonmq-publish.kafka/match.keyonmq-expect.kafka,expect.valueoncache-assert.redis,expect.xpath[].valueonhttp.soap(bound via the sameGetScalarraw-text read as its sibling.path),payloadContains/expectPayloadContains/bodyContains/contentContains/subject-contains/body-contains) widen the same way; and theint/long-parsed integer fields —expect.statusonhttp.restandhttp.soap,expect.rowCounton the three SQLdb-assert.*providers,expect.countoncache-assert.elasticsearch/mail-expect.smtp/db-assert.mongodb,expect.min-countoncache-assert.elasticsearch, andexpect.lengthoncache-assert.redis— ten fields in total (expect.min-countis one of the ten, not an eleventh field beyond them) — now accept["integer","string"], with any declared numeric bound (minimum) kept, guarded by a newpattern: "^[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: falseon$defs/step(added in a prior release) does not recurse into nested objects, so a typo inside any of these blocks previously validated silently:expectonhttp.restandhttp.soap(plushttp.soap's ownexpect.xpath[]array items),matchonmq-expect.kafka/rabbitmq/nats/redis,trace-expect.otlp, andwebhook-listen.http, andavroonmq-publish.kafkaandmq-expect.kafka. Each now closes with a plainadditionalProperties: false— a REPLACEMENT of the eight blocks that previously declared their ownadditionalProperties: true(the remaining three —http.rest'sexpect,http.soap'sexpect, andhttp.soap's ownexpect.xpath[]array items — had noadditionalPropertieskeyword at all before this change, a pure addition, not a replacement; eight replacements plus three additions is the eleven), never afalseadded alongside a retainedtrue(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
Validatenow also reject at schema/authoring time, across all 25 Core providers. Every required string field gainsminLength: 1, mirroring the empty-string rejection exactly (an emptytarget: ""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:minLengthcounts characters, so it still passes schema, and is still caught at the provider's ownValidate(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 aValidatethat never covered those shapes at all.http.rest/http.soap/metrics-assert.prometheus'spathgains an SSRF-guard pattern (rooted-relative only: no absolute URL, no protocol-relative//, no backslash).mq-publish.azureservicebusnow requires exactly one ofqueue/topic.mq-expect.azureservicebusnow requiresqueueXOR (topic+subscriptiontogether — enforced by both aoneOfand adependentRequiredpair, the latter for a more specific message when onlytopicis set) plus at least one ofexpectPayloadContains/expectProperties.cache-assert.redisnow requiresfieldwhenoperation: hget, and requires theexpectmember matching each of the seven operations (valuefor get/hget,existsfor exists/ttl,lengthfor hlen/llen/scard).db-assert.postgres/sqlserver/mysqlnow requireexpect.rowCountand/orexpect.row;db-assert.mongodbnow requiresexpect.countand/orexpect.document.db-assert.dynamodb'sexpect.exists: falsenow forbidsexpect.item.storage-assert.s3'sexpect.sizeandexpect.minSizeare now mutually exclusive, andexpect.exists: falsenow forbids all six content expectations (size/minSize/sha256/contentContains/contentType/metadata).metrics-assert.prometheus.expectnow requires at least one ofvalue/min/max.mail-expect.smtp'sexpect.match, and — now that the block closes (see above) —mq-expect.kafka/rabbitmq/nats/redisandwebhook-listen.http'smatch, each now require at least one declared criterion.mq-publish.kafka'savro.recordnow requires at least one field.script.csharp'scodeis now capped at 64 KiB, mirroring the provider's own existing runtime bound. Nearly every constraint above was already enforced by the corresponding provider'sValidatemethod; 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 eachValidatemethod line-by-line against its schema counterpart, since an earlier draft of this note claimed no new rejections existed at all:http.soap'sexpect.xpath[].pathgainsminLength: 1whereValidatenever inspectsexpect.xpathat 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.dynamodbandstorage-assert.s3'sexists: 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 inValidate(is { Count: > 0 }) for one field on each provider —db-assert.dynamodb'sexpect.itemandstorage-assert.s3'sexpect.metadataalone (the other five S3 content fields were already presence-checked inValidate,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 (Countis0, not> 0, soValidate's condition was false) and now fails schema validation, since the key's mere presence now trips thefalsesub-schema regardless of its content being empty;storage-assert.s3'sexpect: {exists: false, metadata: {}}narrows identically. -
Breaking: closed target resolution for HTTP and metrics steps, narrowed to services only.
http.rest,http.soap, andmetrics-assert.prometheusnow reject atargetnaming 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 resolvetargetexclusively 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. awebhook-listen.httplistener) count as valid declared targets — but a host resource whose name collides with a declared service, or with a dependency's own sidecar endpoint (amailpitSMTP sidecar, akafkaschema-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 addedenvironment.servicesblock to keep passing, the branch's own evidence that this narrows rather than adds. -
Per-dependency image override —
environment.dependencies[].imagefield allows explicit specification of a container image for individual managed dependencies, bypassing Aspire's provisioned defaults. Animage:carrying no tag or digest must be paired with aversion:field; if bothimage:(with tag) andversion:are set, the combination is rejected as ambiguous. A taglessimage:withoutversion:is rejected to prevent floating on:latest. Anyimage:value is used exactly as written, with the provider's registry default cleared, andimageRegistryapplied on top only if the image carries no registry hostname of its own. -
capturenow 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 — byParseCaptureEntry(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 onlyAstBuildercaught this, at compile time — and the identical guard now also applies to top-levelvariables:keys, whichAstBuilderalways rejected but the schema never mirrored. -
metadata.schemaVersionis 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, andcache-assert.redis'soperation. A suite that previously wrotetype: Postgres,imagePullPolicy: always, orverifyMode: retryvalidated 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 beforeEnvironmentMapper.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 enumfirst, always. The fix is now in the message an author actually sees. -
imageRegistrynow applies to both services and dependencies — the environment-levelimageRegistryoverride previously affected onlyservices; 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-qualifiedimage:is specified on a dependency, the engine clears any built-in registry default the provider might carry, preventing unintended double-prefixing. -
imagePullPolicyis 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.seedis 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.sqlapplies to postgres, sqlserver, and mysql dependencies alike. -
environment.services[].env,httpPort,environment.seed.<dependency>.sqlentries, and bare-scalarcapturevalues widened to match runtime behaviour —envvalues now also accept a bare (unquoted) numeric or boolean YAML scalar, not only a quoted string;httpPortnow also accepts a quoted string ("8080"), not only a bare integer; asqlfile-path entry and a bare-scalarcapturevalue (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 —YamlDocumentParserreads 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[].nameno longer accepts an explicitnull—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, soEnvironmentMapper.ParseAsbTopicswould declare a topic literally named~(a different, narrower defect than an absentname, 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/anyOfbranch exploration leaked a non-matching branch's failure alongside a genuine one. Concretely, before this fix: a validscript.csharpstep with a typo'd field reported only[required] Required properties ["file"] are not present(the unexercisedcode/filealternative) instead of naming the typo, and the advice was actively wrong (addingfile:produced a different error); a validimage:-only service, anywhere in an otherwise-failing document, could report a spurious[required] Required properties ["project"] are not present; a validhttpPortortimeoutvalue could report a spurious[type]mismatch from the branch it was never going to match, including advice to un-quote ahttpPortthis very release legalised quoting for.httpPort,timeout, and the mapping form ofcaptureentries are now expressed as single merged schemas with no second branch left to leak from (identical accept/reject behaviour —minimum/maximum/pattern/required/properties/additionalPropertiesare all no-ops against a non-matching JSON type, so nothing was ever depending on the branch split).SchemaErrorCollectoradditionally drops aoneOf/anyOfbranch'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 ofimage/project", and a provider's own alternative-fieldoneOf, e.g.script.csharp'scode/file— a frozen provider fragment, untouched). A composite that genuinely fails — e.g. a service with NEITHERimagenorprojectset — 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 explicitimage: "") threwArgumentException: Image reference must not be null, empty, or whitespace; both now resolve identically toimage:being absent altogether, matching the schema's existing description text. -
A plain (unquoted) YAML-null
version:orimage:—~,null,Null, orNULL— 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: NULLpulled the four-character tag...:NULL), producing no error until Docker tried to pull the garbage reference — this was also true of a plain-nullimage:when paired with a siblingversion:(the token reached Aspire as a literal, wrong repository name). A LONE plain-nullimage:with no siblingversion: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 forimage:, and the pre-existing literal-tag behaviour forversion:(neither is part of this fix's contract).
Removed¶
-
Breaking: the
publishanddocumentsenvironment.seedkinds — 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 writingpublish:ordocuments: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'sPublishSeed/DocumentSeedrecords are gone, andDependencySeed's constructor arity changed to a singleSqlparameter — 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 thoughVouchfx.Engine.Authoringships 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-rcfloating convenience tag -.github/workflows/move-floating-tag.ymlnow routesv1.0.0-rc.Nreleases to av1-rctag, alongside the existingv1-alpha(alpha/beta) andv1(GA) tags. Each pre-GA line keeps its own tag deliberately: a consumer pinned tov1-alphais never force-moved onto a release candidate, so switching lines stays an opt-in ref edit.v1-alphais retired in place atv1.0.0-alpha.10never deleted, simply no longer moved.- Manual dispatch for the floating-tag workflow - a
workflow_dispatchtrigger 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 therelease: publishedtrigger provides. vouchfx scaffoldsubcommand - emits a machine-drafted, catalogue-grounded, schema-valid.e2e.yamlskeleton 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.KnownDependencyKindsmirrors the topology mapper's supported dependency set. vouchfx plansubcommand — 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 thescaffoldtool 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?)andSerializePlan(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 (PlanReportDocumentwith 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.yamlexample 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 newdocs/ci-integration.mdpage (recipes.mdandgetting-started.mdpreviously 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 schemasubcommand — 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) —ComposeSchemaJsonandBuildCatalogueexpose 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 withCatalogueExportExceptionnaming the step type. - Rich step catalogue on
list --json(additive) — each step type entry now includesrequiredFields,optionalFields,captureSupported, andfamilyIntentin addition totype/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) andvouchfx schemafrom the configured CLI, with a version-checked bundled fallback.
Changed¶
- Getting-started documentation — documents
vouchfx schema, the enriched catalogue, fail-closed export behaviour, and theEngineExportlibrary 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 bothrunandvalidate, rather than the first discovered scenario's directory. A scenario in a subdirectory now correctly finds helper scripts beside it. Sequential unfilteredruntopology seeding remains rooted at the first scenario's directory; parallel runs seed from each scenario's own directory. --events-streamnow 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--eventsarchive is unchanged.
Fixed¶
- Unknown step type now reported as validation error with line context (#265) —
vouchfx validateand the pre-compilation validation pass now report an unknowntypefield (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.csharpstep body and 1 MiB maximum per.e2e.yamldocument 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 (--jsonoutput 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. runwhere 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-inconclusiveflag, matching the behaviour ofvouchfx validateand 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--eventsarchive. 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 validatesubcommand (#260) — compile-level validation without Docker: JSON-Schema validation → parse/AST → provider pipeline (bind/validate/emit) → full Roslyn compile. Discovers.e2e.yamlfiles from a file or directory path (recursive). Exit codes: 0 all valid, 2 usage error, 4 one or more invalid.--jsonflag produces a versioned machine document (schemaVersion, engineVersion, per-scenario diagnostics by stage).vouchfx listsubcommand (#260) — list the sealed Core step-type catalogue (twenty-five dottedfamily.providertypes). Exit codes: 0 success.--jsonflag produces a versioned machine document (schemaVersion, engineVersion, sorted stepTypes array).vouchfx run --shutdown-on-stdin-eofflag — 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
.htmlURLs 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: localmkdocs build --strictandmkdocs serve, fact tokens{{fact:...}}still work identically (now applied by MkDocs hooks instead of the old build script), offline authoring supported withVOUCHFX_SITE_FACTS=offline. The legacyscripts/build_site.pyremains 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--eventscorrected (verbatim occurrences of resolved secrets are redacted before terminal output,--eventsstream, 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.ioandtelemetry.vouchfx.iorespectively, each carrying canonical URLs, arobots.txt, and asitemap.xml; the engine site additionally serves anllms.txtindex 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.yamlscenario contains an invalid step, the composed 25-provider JSON Schema validation previously reported every non-matching provider'sif/thendiscriminator 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
timeoutis now enforced for IMMEDIATE steps (#232) — the DSL has always documentedtimeoutas 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), sotimeout: 90snow 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 examplesworkflow discovers every flatexamples/*.e2e.yamlsuite 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 tomainthat 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-actionsDependabot 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.yamlsuites 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 thetraefik/whoamiplaceholder behaving like a real order service (expecting201/202responses, 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 withexpect: status: 200, and a clearly-markedscript.csharpstep 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. Thedocs/recipes.md"complete runnable example" links are now true as written. vouchfx run <file>.e2e.yamlnow works — the advertised single-file form (includingvouchfx 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.yamlfile now resolves to exactly that scenario; an existing file without the.e2e.yamlsuffix 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'sscenario-pathinput accepts a file (with a new optionalartifact-nameinput 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 (theapphostprojectpathassembly metadata baked at build time). Aspire'sAspire.Hosting.DistributedApplicationlifecycle 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: readat workflow level); the coverage-badge job retains its job-scopedcontents: 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
undicidevelopment dependency was bumped to 7.28.0 (Dependabot).
Changed¶
- The GitHub Pages site generator was extracted into the shared
vouchfx-site-toolspackage, now consumed by all four ecosystem repositories instead of four diverging copies. pages.ymlactions are SHA-pinned and the ecosystem notify dispatchcurlwas 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
vouchfxtool 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-exactaspire.hosting.orchestration.<rid>package from the executing machine's NuGet cache, honours a user-setASPIRE_DCP_PATH, and otherwise fails with an actionable environment error. A cross-machine smoke test now gates every release publish. See the knowledge-base articledocs/kb/dcp-orchestrator-portability.mdfor 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.csharpsteps now accept an optionalfilefield: a path to an external.csxfile, resolved relative to the.e2e.yamlfile's directory, read once at compile time and spliced verbatim into the generated code. Provides an alternative to inlinecodefor larger scripts.codeandfileare 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.*toVouchfx.*across the engine and all Core providers. The.e2e.yamllanguage and JSON wire contracts remain unchanged (frozen at v1). Provider SDK packages published asVouchfx.Sdk,Vouchfx.Sdk.Testing,Vouchfx.Engine.Abstractions,Vouchfx.Engine.Authoring, andVouchfx.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.Testingand thePlatform.Engine.*closure — renamed toVouchfx.*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 asImagePullrather thanHealthGateeven 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.
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 collectibleAssemblyLoadContext, 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: RETRYwith 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) anddependencies(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 — a400response on/, not200) andminio(minio/minio, health-gated on its documented/minio/health/clusterreadiness path), both plain containers whose connection string is synthesised post-startup (ServiceURL=…;AccessKey=…;SecretKey=…), mirroring theazureservicebuspattern.
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.redisuse Redis Streams (XADD/XRANGE).metrics-assert.prometheusis the first member of themetrics-assertfamily: 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, withcapture:support for the matched value.db-assert.dynamodbasserts against a DynamoDB item viaGetItem(a realdynamodb-localcontainer per suite).storage-assert.s3is the first member of the newstorage-assertfamily: 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, withcapture:support foretag/versionId/size.http.soapis the secondhttp-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 disciplinehttp.restestablished.trace-expect.otlpis the first member of the newtrace-expectfamily and the platform's flagship distributed assertion: an engine-hosted OTLP/HTTP JSON receiver (mirroringwebhook-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 W3Ctraceparent, 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 anevictedcount 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-expecthas graduated out of its reserved-family state now thattrace-expect.otlpships as its Core provider;realtime-expectremains 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 undercommunity/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.csharpaccepts afilefield as an alternative to inlinecode: a path (resolved relative to the.e2e.yamlfile's own directory) to an external.csxfile, read once at compile time and spliced verbatim — identical trust boundary and lack of placeholder/secret substitution ascode.codeandfileare mutually exclusive; a missingfileis 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
Failbreaks CI by default. - One schema-versioned JSON Lines event stream feeding every renderer: the terminal renderer (with
--no-decorationsplain-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
vouchfxCLI (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-inconclusiveopt-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/v1floating 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 Dependabotgithub-actions(GitHub) / Renovate (GitLab) automation that keeps a SHA pin current without manual lookups.- Environment-configured telemetry for CI: setting
VOUCHFX_TELEMETRY_INSTALL_IDalongside 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 (seedocs/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 insidescript.csharpblocks, 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, andVOUCHFX_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 as1.0.0-0.local.
Changed¶
- The MCP companion's documentation site is live —
vouchfx-mcpnow serves fromvouchfx-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, anddocs/getting-started.mdand the landing footer now link it too; the drift sentinel crawls it alongside the other four, and the docs-deploy fan-out notifies it. TheVouchfx.Mcpdotnet 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) toVouchfx.*; the hub's community providers adoptVouchfx.Community.*(hub repository change). The.e2e.yamllanguage and JSON wire contracts remain unchanged (frozen at v1); schema goldens and provider/event contracts are regenerated as pure renames; thePlatform.*SDK packages (published at v1.0.0-alpha.3 underPlatform.Sdk,Platform.Sdk.Testingand thePlatform.Engine.*IDs) are to be unlisted and deprecated with migration pointers (NuGet alternate-package set to theVouchfx.*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
vouchfxtool 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 theAspire.AppHost.Sdkbaked into assembly metadata at pack time (/home/runner/.nuget/packages/…linux-x64…for NuGet.org packages), so every cross-machine install failed its firstvouchfx runwith 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-exactaspire.hosting.orchestration.<rid>package from the executing machine's NuGet cache (NUGET_PACKAGES, else~/.nuget/packages) via theDcpPublisher:CliPathconfiguration 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.