Contract Artifacts
benzene-descriptor is a dotnet tool that emits a service's contract document —
{name}.spec.json — as a build artifact from a built, non-running, non-deployed service. It
constructs the app in-process (runs ConfigureServices/Configure, never the run/listen step) and
derives the document it already computes. No deploy, no socket, no network.
That means a service's contract can be produced, stored, diffed, and fed into code generation at build time, without needing the service to be running anywhere — the "static floor" the rest of the tooling in this section builds on.
The artifact
| Artifact | Shape | Use |
|---|---|---|
{name}.spec.json |
The contract document — the same JSON the benzene:spec topic serves, and the body a meshed service registers with a collector |
Codegen input (client SDKs), the compatibility gate baseline (contract testing), and a mesh artifact store's snapshot of the service |
It is read directly off the service's real, code-derived registration — handlers, HTTP routes, event
and send declarations, FluentValidation-enriched schemas, wired transports — not re-derived from
source. Its shape is pinned by the language-neutral spec (docs/specification/contract-document.md
in the Benzene repo), and since spec-0.4.0 a live
benzene:mesh:register call carries the same document (mesh.md §2), so this build artifact and the
registration are the same contract by construction.
Until spec-0.4.0 the tool also wrote
{name}.service.json, the meshServiceDescriptor. That descriptor is retired;--emit descriptorand--emit bothnow fail with a message saying so.An older, more opinionated projection (
consumes/produces/transportKindper topic) existed as a spike output; it is deferred pending the outbound-routing read-model that would make itstransportKindfield reliable rather than best-effort reflection. It is not part of the shipped tool.
Install
dotnet tool install -g benzene-descriptor
Or as a local tool (pinned per-repo, restored via dotnet tool restore):
dotnet new tool-manifest # once, if the repo has no .config/dotnet-tools.json yet
dotnet tool install benzene-descriptor
Run it directly
benzene-descriptor --assembly path/to/YourService.dll --service-version 1.0.0 --version-scheme semver
With no --output, the document is written next to the assembly, named after it
(YourService.spec.json). Options:
| Flag | Meaning |
|---|---|
--assembly <dll> |
Required. The built service assembly. |
--emit spec |
The only artifact, and the default. (descriptor/both were removed with the mesh descriptor.) |
--output <path> |
The exact output path. Omit for the file next to the assembly. |
--service <name> |
Overrides the document's service (default: the service's own IApplicationInfo name, else the assembly's simple name). |
--service-version <v> / --version-scheme <integer\|semver\|lexicographic> |
The document's serviceVersion and versionScheme; a version needs a scheme, and one that does not parse under it fails the build. |
--cloud <aws\|azure\|gcp\|self-hosted\|…> / --region <r> |
The document's placement (omitted unless --cloud is given; region only where the platform documents one). Outside both hashes. |
--host <neutral\|aws-lambda> |
Force a host adapter (auto-selected by default — see below). |
--startup <fullTypeName> |
Pick the BenzeneStartUp type explicitly, needed only when the assembly defines more than one candidate. |
Exit codes: 0 on success, 2 on a bad/unparseable argument, 1 on any failure during
construction (assembly not found, no BenzeneStartUp found, a Benzene version mismatch between the
tool and the service, or any exception the service's own registration throws) — always with a
one-line reason on stderr.
Host adapters
Everything except the inbound transport list is cloud-agnostic (comes from host-neutral
ConfigureServices). An AWS Lambda host adapter additionally runs the host-specific Configure to
populate the inbound transport list and validation-enriched schemas; other hosts fall back to the
neutral core. See src/Benzene.Descriptor/README.md for the full mechanism.
MSBuild opt-in
Import build/Benzene.Descriptor.targets (packed into the tool's package — copy it into the repo,
or explicitly <Import Project="..."> it from wherever the package restores to) and opt in per
project:
<PropertyGroup>
<BenzeneEmitDescriptor>true</BenzeneEmitDescriptor>
</PropertyGroup>
This runs benzene-descriptor after every Build, writing $(TargetDir)$(AssemblyName).spec.json
(with MSBuild's $(Version) as a semver serviceVersion). A failing emit fails the build — a
service that cannot produce its own contract should not report green. Useful overrides:
BenzeneDescriptorCommand (default benzene-descriptor; e.g. dotnet tool run benzene-descriptor
for a local-tool install), BenzeneDescriptorOutput, BenzeneDescriptorServiceName (overrides the
document's service), BenzeneDescriptorVersionScheme, BenzeneDescriptorStartup.
A NuGet tool package does not auto-import its .targets (that only happens for PackageReference
libraries) — either copy the file into the repo, or <Import> it explicitly from the restored
package path.
See it in action: examples/AwsMesh/Payments in this repo opts in exactly this way — its csproj
sets BenzeneEmitDescriptor and imports the targets file from source, and
examples/Directory.Build.props supplies the BenzeneDescriptorCommand override every example
under examples/ inherits (a dotnet run --project form, since this repo's CI builds
Benzene.Descriptor and the examples in separate jobs with no shared PATH). Copy that pattern for
a new service.
CI: upload the artifacts
- name: Emit the contract document
run: benzene-descriptor --assembly bin/Release/net10.0/YourService.dll --service-version ${{ github.run_number }} --version-scheme integer
- uses: actions/upload-artifact@v4
with:
name: your-service-contract
path: bin/Release/net10.0/YourService.spec.json
Feed the uploaded spec.json into the benzene CLI's diff command as a compatibility gate (see
contract testing), or into a mesh artifact store to seed/refresh it
without a live deploy.
What the compatibility comparer detects
SchemaCompatibilityComparer (OpenAPI-backed) and JsonSchemaComparer (JsonNode-backed, used where
a component must classify a change without an OpenAPI toolchain dependency — e.g. the mesh aggregator)
walk a spec.json pair field by field and classify every difference as SchemaChangeKind:
SchemaChangeKind |
Meaning |
|---|---|
TopicAdded / TopicRemoved |
A topic exists on only one side. |
PropertyAdded / RequiredPropertyAdded |
A property exists on only the current side (required or not). |
PropertyRemoved |
A property exists on only the baseline side. |
PropertyBecameRequired / PropertyBecameOptional |
A property's required membership changed. |
TypeChanged |
A schema's type/format changed — also used for an items null-asymmetry (below). |
UnionVariantAdded |
A oneOf/anyOf/allOf member exists on only the current side. |
UnionVariantRemoved |
A oneOf/anyOf/allOf member exists on only the baseline side. |
UnionVariantChanged |
A member matched between baseline and current, but the matched pair's schemas differ. The specific differences are reported immediately after this entry (their own kinds, from a recursive comparison of the matched pair). |
Matching a oneOf/anyOf/allOf member across baseline and current (so a reordered or
renumbered union doesn't manufacture spurious adds/removes) uses this priority, per member:
- The member's
$reftarget name, when it has one (e.g."Dog"from#/components/schemas/Dog) — a$refalready uniquely and stably identifies the target component, so it takes priority regardless of whether a discriminator mapping happens to cover it. Keying on mapping coverage instead of the$refname made an additive mapping edit — a mapping entry newly added for a variant that was already$ref'd and unchanged otherwise — look like the variant was replaced (matched by mapping on one side, by$refon the other), manufacturing a spuriousUnionVariantRemoved/UnionVariantAddedpair (fixed as part of WP-J/#53; seework/archive/bug-fix-designs-round7-10-2026-08.md). - Else the discriminator mapping value, when the owning schema declares a
discriminator— this only applies to an inline (non-$ref) member, since there is no$refname to prefer over it. - Else its position in the array.
allOf matches its $ref members by target name and its inline members by position among the inline
members (the two pools are matched independently).
An items schema present on only one side (while both sides agree on type: array) is treated as a
TypeChanged, not silently skipped — the array's element contract appeared or disappeared entirely.
Breaking-direction table
Classification is direction-aware — see contract testing for the full default table including the older kinds. The rows added for union-aware comparison:
| Change | Request schema (what the service accepts) | Response schema (what consumers receive) |
|---|---|---|
| Union variant removed | Breaking (callers still sending it are rejected) | Non-breaking |
| Union variant added | Non-breaking | Breaking (consumers meet an unknown variant) |
Items appears/disappears on one side |
Breaking (as type change) | Breaking (as type change) |
This table is reproduced verbatim from the design ruling
(work/bug-fix-designs-2026-08.md §"WP-9 — Schema compatibility: union-aware walkers") and applies
identically to oneOf/anyOf variants and to allOf members (both use the UnionVariantAdded/
UnionVariantRemoved kinds — there is no separate allOf-specific kind). UnionVariantChanged itself
carries no direction-specific rule; its compatibility falls to the rules table's default (Warning) —
the concrete impact of a changed variant is carried by the nested changes reported alongside it, which
are independently classified by their own kind.
The version-pinning caveat
benzene-descriptor loads the service assembly into a plugin AssemblyLoadContext that prefers
its own already-loaded Benzene.* assemblies over the service's copies — that is what keeps type
identity intact across the boundary (e.g. the service's StartUp stays assignable to the tool's
BenzeneStartUp). The consequence: pin the tool version to the service's Benzene version. The tool
detects a Benzene.Core version mismatch between what the service was compiled against and what the
tool carries, and fails loudly (printing both versions) rather than silently running the service's
registration against an API surface it wasn't built for.
Related
docs/cookbooks/contract-testing.md— usingspec.jsonas a compatibility gate baselinedocs/client-sdks.md— generating typed clients from a spec artifactsrc/Benzene.Descriptor/README.md— implementation detail: the ALC plugin mechanism, host adapters, and why this needs a built assembly rather than source analysis (work/deployment-descriptor-design.mdhas the full rationale)
A benzene CLI reference page (install, every command including a diff verb over these
artifacts) is planned but not yet part of this repo's docs tree.