The closed client-SDK seam (and the optional contract package)

Normative rules: ADR-039 P19 · CoC §8.3 · AGENTS.md §2.7A · DC-40 Enforcement data: README.md(manifest, register, generated report) Program: docs-archive/implplan/SPRINT_20260730_001_Program_microservice_build_independence.md (MBI-3)

This page is the developer-facing pattern. The rules above say what is forbidden; this says what to build, and what the two ways of shipping it cost you.


1. Two ways to cross the boundary, and how to choose

One microservice = one closed, independently buildable source graph. A consumer may never compile another service’s implementation. There are exactly two sanctioned ways for a producer to give consumers its wire vocabulary:

Source seam (the default)Package seam (opt in)
How the consumer gets itProjectReference to the producer’s closed contract projectPackageReference to an exact locked version from a local/offline feed
Version skewimpossible — one tree, one versionreal, and that is the point
Costnone beyond keeping the closure cleanyou own versioning, release order, a deprecation window, and mirror promotion
Verified bythe boundary gate on every runthe boundary gate plus the pack-time and artifact-level checks in §5

Choose the source seam unless you can name which of these applies:

“The producer lives in another top-level folder” is not a reason (owner clarification, 2026-07-30). Neither is “a package feels cleaner”. An unnecessary package converts a compile-time guarantee into a runtime one and adds a lockstep mechanism the sprint explicitly warns about.


2. What may live in a closed contract/client project

Allowed: wire DTOs, serializers and canonicalization of those DTOs, typed client calls, the ports (interfaces) both sides state the exchange in, exception contracts, protocol constants, and client-side plumbing such as auth headers, retry/resilience policy, DI registration and caching.

Forbidden — this is the whole rule: the project’s complete transitive ProjectReference closure may contain only the same producer’s client-SDK projects and domain-neutral-shared projects. No host, persistence, migrations, background work, startup, or domain implementation, from either side, at any depth.

Two live examples, both worth reading before you write one:

A name proves nothing. .Client, .Contracts, .Abstractions and .Sdk are hints; the classification is the verified closed graph in ownership-manifest.json. StellaOps.Symbols.Client was a project literally named “.Client” that compiled the symbol server’s ISymbolRepository / ISymbolBlobStore / ISymbolResolver ports until MBI-3 split the wire shape into StellaOps.Symbols.Contracts.

Extracting a contract out of a contaminated project

The pattern both existing extractions used, and the one to copy:

  1. Find what consumers actually reference. Usually one namespace of records.
  2. git mv those files into a new sibling project — byte-identically, and keep the original namespace. Both Signer.Contracts and Symbols.Contracts kept the pre-split namespace, so every consumer’s using still compiles and the extraction is a pure move with no source edit anywhere. Set RootNamespace/AssemblyName explicitly instead of renaming code.
  3. Point the producer’s own implementation project at the new contract, then repoint each consumer.
  4. Classify the new project in ownership-manifest.json, delete the register pins the split resolves, regenerate the report, and run the conformance suite.
  5. Put a comment in the new .csproj saying what may not be added to it. Both existing ones do.

3. Compatibility, and the version window

The contract is a published interface the moment anyone can be on a different version of it.

Additive only within a major. Adding an optional member, a new record, a new enum value at the end, or a new method on a producer-implemented interface is a minor bump. Removing or renaming a member, changing a type or its nullability, reordering enum values, tightening a required, or adding a member to an interface consumers implement is a major bump. Serialization is the real contract: a change is additive only if a payload written by v still deserializes under v-1 and vice versa. Prove that with a round-trip test against a frozen fixture, not by inspection.

The window is n/n-1 — the producer supports the current contract version and the one before it. This mirrors DC-39, which sets exactly that window for artifact format_version and plugin bundle contract versions, for exactly the reason that applies here: in an air-gapped estate the two sides upgrade at different cadences by construction. Concretely:

Deprecation window. Mark the member [Obsolete] with the replacement and the version it goes away in, announce it in the producer’s dossier, and keep it for at least one minor release and one full n/n-1 window after the replacement ships. Nothing may be removed in the same release that introduces its replacement. For air-gapped consumers the clock starts when the version reaches the mirror, not when it is merged.

Release order — producer first, always:

  1. Producer merges the additive change and bumps the contract version.
  2. Producer packs and promotes (§4/§6). v-1 stays in the mirror.
  3. Consumers move to v one at a time, each with its own lock update.
  4. Only after every supported consumer is on v may the producer plan v-1’s removal.

Reversing 1 and 3 — a consumer pinning a version that is not in the mirror yet — is the failure mode the exact lock turns into a restore error rather than a mystery.


4. The tooling

Both scripts live beside the boundary generator and are deterministic and offline.

# 1. pack the SDK and its whole closure into a staged feed + provenance manifest
pwsh tools/scripts/build-boundary/pack-contract-sdk.ps1 `
     -Project src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Agent.Contracts/StellaOps.ReleaseOrchestrator.Agent.Contracts.csproj `
     -PackageVersion 0.1.0-p19.1

# 2. prove a consumer can use it with no producer source, offline, locked, and tamper-evident
pwsh tools/scripts/build-boundary/verify-contract-package-consumer.ps1

Output (default tmp/build/contract-packages/, git-ignored):


5. What is actually verified (and why each check exists)

At pack time — the source graph. The pack script refuses unless the target is classified cross-service-client-sdk and every closure member is domain-neutral-shared or the same producer’s client SDK. It also fails closed on an unclassified project and on a conditional ProjectReference — a package must have one unambiguous graph, not a flag-dependent one.

At pack time — the produced artifacts. It then walks the emitted .nuspec dependency graph and requires every StellaOps.* dependency to be one of the packages just produced. The csproj graph is what the boundary gate sees; the nuspec graph is what a consumer restores, and only checking the first would let the two disagree.

Continuously — the boundary gate. Every csproj under a cross-service-client-sdk rule is purity-checked on every generator and conformance run, whether or not any deployable key references it. That is not a detail: the package path deliberately removes the ProjectReference, so a packaged SDK leaves every key’s closure. Before MBI-3 the check was reachability-driven and would have gone blind on the seam at exactly the point its graph became the thing other services restore.

Reproducibility. dotnet pack output is not byte-reproducible on its own — measured: the .nupkg zip carries wall-clock entry timestamps, a random GUID psmdcp filename, and random OPC relationship ids (the assemblies inside already are deterministic via Deterministic + ContinuousIntegrationBuild). Each package is therefore normalized — entries sorted and stamped 1980-01-01, psmdcp path fixed, relationship ids fixed — before its sha256 is recorded, the same discipline tools/scripts/build/offline-kit/assemble.sh applies to its tar. Re-packing an unchanged tree at the same commit then reproduces every digest.

The commit is an input, not incidental: RepositoryCommit is embedded in the assembly, so the same source at a different HEAD legitimately hashes differently. The property provenance needs — and the one that holds — is “this commit always yields these bytes”.

Consumer-side. verify-contract-package-consumer.ps1 generates a consumer with zero ProjectReference and empty Directory.Build.props/.targets, so the MSBuild upward walk stops at its own folder and it inherits nothing from the repo (the idiom devops/compose/notify-echo-receiver already uses). It then checks, in order: feed digests match the manifest → offline restore writes packages.lock.json with a contentHash--locked-mode restore from an empty package cache, build, and run a real contract round-trip → a single flipped byte in the contract .nupkg is rejected with NU1403 Package content hash validation failed → restoring the byte makes it green again.

That tamper check is what makes the recorded sha256 mean anything. And running the consumer, rather than only restoring it, is deliberate: a successful restore is not evidence that the type is usable.

Trap. <clear /> inside <packageSources> does not isolate a consumer that lives under the repo. NuGet merges config up the directory tree, and the root NuGet.config’s packageSourceMapping still routes StellaOps.* to a source key you just cleared — restore then fails NU1100: … PackageSourceMapping is enabled, the following source(s) were not considered. Clear packageSourceMapping, disabledPackageSources and fallbackPackageFolders explicitly.


6. Air-gap promotion

An air-gapped consumer restores from a folder, not a feed URL, so promotion means getting the .nupkg files and their manifest into the bundle the site already receives.

Operator side. Add the mirror folders to the consumer’s NuGet.config — clearing every inheritable section, as in §5 — and pin exact versions:

<configuration>
  <packageSources>
    <clear />
    <add key="stellaops-contracts" value="/opt/stellaops/offline/nuget/contract-packages" />
    <add key="stellaops-thirdparty" value="/opt/stellaops/offline/nuget/thirdparty" />
  </packageSources>
  <disabledPackageSources><clear /></disabledPackageSources>
  <fallbackPackageFolders><clear /></fallbackPackageFolders>
  <packageSourceMapping>
    <clear />
    <packageSource key="stellaops-contracts"><package pattern="StellaOps.*" /></packageSource>
    <packageSource key="stellaops-thirdparty"><package pattern="*" /></packageSource>
  </packageSourceMapping>
</configuration>

Consumers set RestorePackagesWithLockFile, commit packages.lock.json, and restore --locked-mode. Third-party packages must be mirrored too; verify-contract-package-consumer.ps1 harvests them into offline-feed-thirdparty/ and that is the shape to reproduce — your own contract packages plus a vendored third-party mirror. Both v and v-1 stay in the folder for the whole compatibility window (§3).

Bundle side. The offline kit already assembles a deterministic archive with a per-file sha256 manifest (stellaops.offline-kit.baseline, SOURCE_DATE_EPOCH=0, sorted, ids zeroed) from an explicit source table, and its add_source helper is skip-if-missing — so a staged feed is one line:

# tools/scripts/build/offline-kit/assemble.sh, in the add_source table
add_source "devops/offline/contract-packages" "nuget/contract-packages"

Ship contract-packages.manifest.json alongside the packages: it carries the sourceCommit and per-package sha256, which is what lets a receiving site verify what it got and tie it back to a commit. The kit’s own manifest then covers the same bytes a second time at the bundle level.


6a. The in-repo mirror — how a monorepo consumer restores

§6 is the operator’s problem. This is ours: once StellaOps.Signals takes a PackageReference, a fresh clone and CI must still restore it. Established by the MBI-5 pilot, 2026-08-10.

Layout — flat, and this is load-bearing.

devops/offline/nuget/contract-packages/
  StellaOps.Scanner.Contracts.0.1.0-p19.2.nupkg     <- every .nupkg at the ROOT
  manifests/
    scanner-contracts.manifest.json                 <- one provenance manifest per SDK

A NuGet folder source does not recurse into arbitrary subdirectories (probed directly, not read off documentation). A .nupkg filed one level down is invisible and restore fails NU1101 Unable to find package while the file is plainly on disk. So the per-SDK feed/ shape pack-contract-sdk.ps1 stages to is a staging layout, never the committed one: copy the .nupkg up to the root and park the manifest in manifests/, where NuGet ignores it.

The bytes are committed on purpose. The alternatives are an outward-facing push to the Gitea stellaops registry (credentials, network, and a release step before every consumer build) or a mandatory pack step before anyone can build — which breaks main on a fresh clone. Committed bytes also make the offline posture strictly better: with the folder source present, no StellaOps.* package needs the network at all. Precedent for committed binaries under devops/ is the plugin drop folder.

Wiring. The source goes in the repo-root NuGet.config, mapped for StellaOps.* alongside the remote stellaops key. Directory.Build.props pins RestoreConfigFile to that root file, so edits to src/nuget.config do not affect restore — keep both in sync, but know which one is live. Central Package Management is on, so the version lives once in src/Directory.Packages.props as an exact bracket, Version="[0.1.0-p19.2]", and consumers write <PackageReference Include="StellaOps.Scanner.Contracts" /> with no version.

Pick a version BELOW the producer project’s own Version(1.0.0 in this repo — the SDK default; verify with dotnet msbuild <sdk>.csproj -getProperty:Version). A graph that reaches the SDK as a direct package and transitively as source then fails NU1605 package downgrade instead of silently unifying on the package. That failure is a feature: mixed graphs are exactly where a drifted package would do invisible damage, and a build error is the cheapest place to find out. Consumers that legitimately compile producer source — E2E harnesses and test projects, which CoC §2.7A permits to span services — keep their ProjectReference and win by nearest-wins.

A version is immutable. dotnet pack stamps the commit into the .nuspec, so identical source at a later HEAD produces different bytes; and the NuGet global cache keys on id+version and never re-checks content, so swapping bytes in place leaves ~/.nuget/packages serving the old package indefinitely. Both measured during the pilot. New content ⇒ new version, without exception. Unlike §6, the in-repo mirror does not carry v-1: its previous version is git history, which any consumer can check out — the air-gapped operator has no such thing, which is why §6 does.

What guards it. ContractPackageMirrorConformanceTests in the architecture pack, four checks, each red-proofed: the committed bytes still hash to the manifest digest; the SDK’s current source still hashes to the manifest’s sourceInputsSha256 (the staleness check — edit the contract, forget to re-pack, and every packaged consumer silently keeps the old wire shape); every consumed StellaOps.* package is exact-pinned and present; and no .nupkg hides below the root. The boundary report cannot help here — it walks ProjectReferences, and the package seam’s whole purpose is that the ProjectReference is gone.


Not yet wired, deliberately. That line is not in assemble.sh today. tools/scripts/deploy/** is outside SPRINT_20260730_001’s declared working directories, and the kit is a live weekly CI job with a byte-determinism harness that fails on drift — adding a source to it is its owner’s change, not this program’s. The extension point is one line and skip-if-missing, so it is a no-op until a feed is staged there. Whoever takes it must also add the staging path to .gitignore in the same change (AGENTS.md §2.9) and decide whether the packages are built during kit assembly or promoted from a prior build by digest.