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 mesh ServiceDescriptor. That descriptor is retired; --emit descriptor and --emit both now fail with a message saying so.

An older, more opinionated projection (consumes/produces/transportKind per topic) existed as a spike output; it is deferred pending the outbound-routing read-model that would make its transportKind field 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:

  1. The member's $ref target name, when it has one (e.g. "Dog" from #/components/schemas/Dog) — a $ref already 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 $ref name 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 $ref on the other), manufacturing a spurious UnionVariantRemoved/UnionVariantAdded pair (fixed as part of WP-J/#53; see work/archive/bug-fix-designs-round7-10-2026-08.md).
  2. 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 $ref name to prefer over it.
  3. 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.

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.