# Engineering Playbook > The reusable Purview engineering conventions — repository shape, architecture as tests, verification tiers, and agent enablement. The conventions the Purview projects are built on, written down once with the failure each one prevents, the mechanism that makes it true, and what a smaller repository does instead. It covers how a repository is laid out and named, how the architecture is held by tests that read the compiled assemblies, which checks run in which verification tier, how value objects, schemas and results cross a boundary, how tests are written and where their data comes from, how a repository is made legible to a coding agent, and how its documentation is published. It is documentation-first and ships no library: it holds the conventions, ready-to-copy templates, and a small layered reference implementation whose dependency-direction rules fail the build, so the rules are demonstrated rather than asserted. - Repository: https://github.com/purview-dev/engineering-playbook - Project page: https://purview.dev/projects/engineering-playbook/ - Documentation: https://purview.dev/docs/engineering-playbook/ - Full machine-readable content: https://purview.dev/projects/engineering-playbook/llms-full.txt # Engineering Playbook This is the playbook for building and maintaining a Purview repository. It records the conventions the projects under `purview-dev` are held to: how a repository is laid out, how its architecture is enforced by tests rather than by review, how a change is verified in tiers, how data crosses a boundary, how tests are written and fed, and how a repository is made legible to a coding agent. The conventions were previously discoverable only by reading the repositories and inferring the pattern from the repetition. They are stated here once, with the evidence and the trade-offs attached, and scaled so that a repository adopting them can take the part that fits. ## How to read a page Every content page follows the same shape: | Section | Answers | | -- | -- | | **The rule** | What is expected. | | **Why** | The failure the rule prevents. | | **How it is enforced** | The mechanism that makes it true — or an explicit note that it is a review obligation. | | **At your scale** | What to do when the repository is smaller than the one the rule came from. | Rules a build can enforce are labelled as such. Rules only a reviewer can enforce are labelled as review obligations. A convention that claims to be enforced when it is not is worse than no convention at all, so the two are never blurred. This page and [`index.md`](./) are navigation rather than rules, and [Engineering Principles](engineering-principles/) is an index that links to the page stating each rule in full. ## The playbook | Page | Covers | | -- | -- | | [Getting Started](getting-started/) | The smallest repository that satisfies every rule, in the order you would build it. | | [Engineering Principles](engineering-principles/) | The non-negotiable rules, and where the authoritative statement of each one lives. | | [Repository Shape](repository-shape/) | Where a file goes, what it is called, and how a project's name becomes its namespace. | | [Project Responsibilities](project-responsibilities/) | What each project is for, how it is named, and what it may depend on. | | [Architecture as Tests](architecture-as-tests/) | How the build refuses a dependency that points the wrong way. | | [Verification Model](verification-model/) | Which checks run, in which tier, for which change. | | [Contracts and Data Exchange](contracts-and-data-exchange/) | How value objects, schemas and results cross a boundary. | | [Testing and Test Data](testing-and-test-data/) | How tests are written, and where their data comes from. | | [Agent Enablement](agent-enablement/) | How a repository is made legible to a coding agent. | | [Agent Skills Distribution](agent-skills-distribution/) | How a skill is published once and reaches both a package's consumers and an agent's own directories. | | [Agent Guidance Skills](agent-guidance-skills/) | The shared architecture, telemetry and persistence guidance, and the context an agent must establish first. | | [Documentation Convention](documentation-convention/) | How the wiki is written, structured and published. | | [Worked Examples](worked-examples/) | The reference implementation in this repository, walked end to end. | | [Adopting This at Your Scale](adopting-this-at-your-scale/) | Which parts apply to a small, medium or large repository. | | [Contributing](contributing/) | How to change the playbook itself. | | [Release Flow](release-flow/) | How a change becomes a version, and what a release means here. | ## The reference implementation `src/` in this repository is a deliberately small layered repository that is built, linted and tested by the same pipeline every Purview repository uses. It exists so the rules in this playbook are not just prose: the dependency-direction rules fail the build when they are broken, and the test-data conventions are shown in the form they are meant to be copied. [Worked Examples](worked-examples/) walks it end to end. ## Provenance Every rule here is drawn from a public Purview repository — chiefly [build-sdk](https://github.com/purview-dev/build-sdk) (the MSBuild SDK that supplies the defaults), [containers](https://github.com/purview-dev/containers) (a multi-package library with two backends), [value-objects](https://github.com/purview-dev/value-objects), [results](https://github.com/purview-dev/results) and [event-sourcing](https://github.com/purview-dev/event-sourcing) (data exchange at a boundary), and [purview-dev](https://github.com/purview-dev/purview-dev) (documentation as a published surface). Each page names the repositories it was derived from, so a claim can always be checked against running code. --- # Getting Started ## The rule Build the smallest repository that satisfies every rule in the playbook, in the order you would actually create the files. Each step says what it buys you, so you can stop when you have taken what you need. The finished result is a repository whose defaults come from one MSBuild SDK, whose test projects are wired by their filenames, whose architecture is asserted by a test, and whose documentation is a wiki the build already knows how to find. ## Why A convention adopted by copying somebody else's finished repository is a convention you have not read. Building the repository in the order the rules depend on each other means every file has a reason by the time you write it, and the build tells you immediately when a step is wrong. The order is not arbitrary: the SDK import must come before anything that derives from it, the package versions must exist before the first project restores, and the solution must exist before the pipeline can be pointed at it. ## The steps ### Step 1 — Pin the SDK in `global.json` There are **two** versions in play here, and the file pins both: ```json { "sdk": { "version": "10.0.401", "rollForward": "disable", "allowPrerelease": false }, "msbuild-sdks": { "Purview.BuildSdk": "1.0.6" }, "test": { "runner": "Microsoft.Testing.Platform" } } ``` `sdk.version` pins the **.NET SDK** — the `dotnet` that runs the build. `rollForward: "disable"` means exactly that version and nothing else, so a machine with several SDKs installed cannot pick a different one. `msbuild-sdks` pins **`Purview.BuildSdk`**, the MSBuild SDK that supplies the conventions: it is what makes `` resolvable, because MSBuild restores it as a NuGet package and reads its `.props`/`.targets`. `test.runner` selects the Microsoft Testing Platform, which is the runner TUnit expects. The SDK writes this file for you when it is missing, but what it writes is only the `msbuild-sdks` and `test` blocks — **it does not write an `sdk` block at all**, so there is no `sdk.version` and no `rollForward`. Until you add those two lines by hand, a machine with several .NET SDKs installed resolves latest-wins, and the build that passes locally is not necessarily the build CI runs. See [build-sdk: `global.json` bootstrapping](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/Repository-Bootstrap.md#globaljson-bootstrapping). ### Step 2 — Declare the versions the SDK injects, in `Directory.Packages.props` Central Package Management is on by default, so every `PackageReference` the SDK adds has to have a matching `PackageVersion` or restore fails with `NU1010`. The versions below are the ones this repository builds with today — check them against the SDK's current release before you commit, as [`templates/README.md`](https://github.com/purview-dev/engineering-playbook/blob/main/templates/README.md) says. ```xml true 10.10.0 5.0.4 1.75.0 ``` `Purview.Telemetry.SourceGenerator`, `Microsoft.Extensions.Telemetry.Abstractions` and `Microsoft.SourceLink.GitHub` are injected by the SDK itself — telemetry and SourceLink are on by default. The rest are the test stack: TUnit is the framework, TUnit.Mocks the substitute library, Bogus the test-data generator. See [Testing and Test Data](../testing-and-test-data/). ### Versions are properties, packages are items The shape above is the convention, and it is worth following from the first file rather than retrofitting later: **a version shared by a family of packages is declared once as a property, and every `PackageVersion` in that family references it.** The failure it prevents is the partial bump. `TUnit`, `TUnit.Core` and `TUnit.Mocks` ship together and must move together; bump two of the three by hand and restore still succeeds, because NuGet has no idea they are related. What you get is a mismatch that surfaces at run time, in a test run, as something that looks like a product bug. With one `$(TUnitVersion)` there is no half-done upgrade to miss in review — the family moves, or it does not. The same applies to an EF Core provider against EF Core, an Aspire client against its hosting package, and every `Purview.*` product whose packages are released as a set. Two rules keep it from becoming indirection for its own sake: - **Declare a property when the version governs a family**, or when the packages belong to a product that releases them together — even where you reference only one of them today, because the property is where the second one will look. A package with no family, like `Bogus` or `Microsoft.SourceLink.GitHub`, keeps its literal version inline. - **Group with `Label`.** The file then reads as a short list of decisions — .NET, Purview, testing, everything else — rather than one long alphabetical block in which a stale version is invisible. This is a convention, not something the build checks: nothing fails if you inline every version. It is on the reviewer, and it is cheap to keep because the file is read far more often than it is written. ### Step 3 — Import the SDK, and name the namespace prefix `src/Directory.Build.props`: ```xml Acme ``` `src/Directory.Build.targets`: ```xml ``` `NamespacePrefix` must be set **before** the import, because the SDK derives `RootNamespace` from it during `.props` evaluation. From this point every project beneath `src/` gets the target framework, language version, nullable context, analyzers, code-style enforcement and assembly identity without declaring any of them. See [Repository Shape](../repository-shape/). > **Where the file goes.** MSBuild walks up from each project to the nearest `Directory.Build.props`, so > the import can sit at the repository root or at `src/`. This repository and the multi-project Purview > repositories put it at `src/`, which keeps the root for repository-level configuration only. The SDK's > own repository-root discovery still resolves correctly from there, because it asks `git`. ### Step 4 — Add the solution `src/Acme.slnx`: ```xml ``` The `Solution Items` folder is the list of files a reader of the solution needs and that a build would otherwise not surface. `.slnx` is the XML solution format; `.gitattributes` pins it to LF. ### Step 5 — Create a project with a two-line project file `src/src/Domain/Domain.csproj`: ```xml ``` That is the whole file. The folder name `Domain` becomes the project name, and `$(NamespacePrefix).Domain` becomes both `RootNamespace` and `AssemblyName`. Add a type: ```csharp namespace Acme.Domain; public sealed record CustomerId { CustomerId(string value) => Value = value; public string Value { get; } public static CustomerId Create(string value) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("A customer id cannot be empty.", nameof(value)) : new CustomerId(value); public static CustomerId Hydrate(string value) => new(value); public override string ToString() => Value; } ``` `Create` validates, `Hydrate` trusts. That split is the subject of [Contracts and Data Exchange](../contracts-and-data-exchange/). > **One trap, immediately.** The SDK strips a list of layer suffixes (`Core`, `Infrastructure`, > `Persistence`, `Abstractions`, …) from `RootNamespace`, so a project called `Infrastructure` declares > its types in `Acme`, not `Acme.Infrastructure`. If you want the suffix kept, remove it from the list > **after** the import: > > ```xml > > > > ``` ### Step 6 — Add a test project with an even shorter project file `src/tests/Domain.UnitTests/Domain.UnitTests.csproj`: ```xml ``` The filename does the wiring. The SDK sees `UnitTests`, so it adds the TUnit runner, coverage, the `[Category: Unit]` assembly attribute, and a `ProjectReference` to the project the name targets — resolved by probing `../Domain/`, `../../Domain/`, `../src/Domain/` and `../../src/Domain/`, and skipped silently when none of them exists. A test class then lives in the namespace of the type it tests: ```csharp namespace Acme.Domain; public sealed class CustomerIdTests { [Test] public async Task Create_GivenAnEmptyValue_Throws() { // Arrange // Act Action act = () => CustomerId.Create(""); // Assert await Assert.That(act).Throws(); } } ``` ### Step 7 — Add the shared testing project `src/tests/SharedTestingInfra/SharedTestingInfra.csproj`: ```xml ``` The name is one of a recognised set, so the SDK treats it as a shared testing project: it gets the test packages but not the runner, stays a library, and is referenced automatically by the test projects beside it. This is where `TestBuilder` lives — see [Testing and Test Data](../testing-and-test-data/). ### Step 8 — Point the shared pipeline at the solution `.config/purview-build.json`: ```json { "$schema": "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json", "Build": { "Solution": "src/Acme.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", "TestFilter": "/*/*/*/*[Category=Unit]", "RunPack": false, "ValidatePack": false }, "Release": { "Mode": "None" } } ``` Paths are anchored to the **repository root**, not to the configuration file, so moving the file into `.config/` does not change what it points at. `RunPack` and `ValidatePack` are off because this repository ships no package. The shared workflow defaults both to `true`, so leaving them unset would give a green local build and a failing pipeline. See [Verification Model](../verification-model/). `TestPatterns` is left at its default, `*Tests.csproj`, on purpose. Discovery and tiering are two mechanisms, and only one of them should carry the tier: `TestPatterns` chooses which project files are discovered, `TestFilter` chooses which discovered tests run. Leave discovery wide and the day you add `Domain.IntegrationTests` it is kept out of the pull-request tier by its category alone, with no second edit. Narrow `TestPatterns` and you have to remember both. See [Verification Model](../verification-model/). ### Step 9 — Add the caller workflow `.github/workflows/pr.yml`: ```yaml name: PR on: pull_request: branches: [main] types: [opened, synchronize, reopened, ready_for_review] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main with: run-pack: false validate-pack: false test-filter: "/*/*/*/*[Category=Unit]" secrets: inherit ``` The two pack switches repeat `Build:RunPack` and `Build:ValidatePack` from the configuration, so the workflow and the JSON agree about what a green build means. The workflow is a caller; the pipeline itself lives in one shared repository. See [Verification Model](../verification-model/) and [Release Flow](../release-flow/). ### Step 10 — Add the wiki `mkdocs.yml`: ```yaml site_name: Acme site_description: Developer documentation for Acme repo_url: https://github.com/acme/acme edit_uri: edit/main/docs/wiki/ docs_dir: docs/wiki theme: name: material plugins: [search, techdocs-core] markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] ``` Then `docs/wiki/index.md`, `docs/wiki/Home.md` and `docs/wiki/_Sidebar.md`, and the pages behind them. `_Sidebar.md` is the navigation list, so a new page that is not in it is a page nobody finds. See [Documentation Convention](../documentation-convention/). ### Step 11 — Make the repository legible to an agent Add `AGENTS.md` at the root: purpose, layout table, conventions, completion checklist, local commands. Then install the SDK's own skills with `npx skills add purview-dev/build-sdk`, so an agent gets that guidance without you writing it. Installation is explicit - no build writes into an agent directory. See [Agent Enablement](../agent-enablement/). ### Step 12 — Add the local tools and the commands that run them Step 13 runs `just`, and `just lint-check` runs CSharpier. Neither exists yet, so create both now. `.config/dotnet-tools.json` is the tool manifest, so CSharpier is restored at a pinned version rather than installed globally per machine: ```json { "version": 1, "isRoot": true, "tools": { "csharpier": { "version": "1.3.0", "commands": ["csharpier"], "rollForward": true }, "purview.build": { "version": "0.5.3", "commands": ["purview-build"], "rollForward": false } } } ``` `Purview.Build` belongs here too, for the same reason CSharpier does: the manifest pins the version, and `dotnet tool restore` is the one command that installs it. A repository should never hand-roll `dotnet tool install --tool-path` for it. Run `dotnet tool restore` once after adding it. `Justfile` at the repository root is the list of supported commands. The point of it is that every command a contributor needs is discoverable from `just --list`, so start with the four the next step uses: ```text set quiet solution_file := "./src/Acme.slnx" build_configuration := "Debug" pipeline_tool := "dotnet purview-build" # Builds the solution build *args: dotnet build "{{ solution_file }}" --configuration "{{ build_configuration }}" {{ args }} # Runs the tests test filter="/*/*/*/*/" *args: dotnet test --solution "{{ solution_file }}" --configuration "{{ build_configuration }}" \ --treenode-filter={{ filter }} {{ args }} # Checks formatting with CSharpier lint-check: dotnet csharpier check . # Fixes formatting with CSharpier lint-fix: dotnet csharpier format . # Runs the PR pipeline (restore, build, lint, tests) through the shared tool pipeline-pr *args: dotnet tool restore {{ pipeline_tool }} {{ args }} ``` There is deliberately no `Justfile` in `templates/`: the recipes are the list of things *your* repository actually does, and a copied list of someone else's is noise. The five above are the floor, not the shape. ### Step 13 — Prove it ```text just build # dotnet build (Debug) just test # dotnet test just lint-check # CSharpier check just pipeline-pr # restore, build, lint, test through the shared Purview.Build tool ``` Then break a rule on purpose — add a `ProjectReference` from the domain to the layer above it, or rename a project file so it no longer matches its folder — and confirm the build refuses. A convention you have not watched fail is a convention you are guessing about. ### Step 14 — Or copy the templates Most of the files in steps 1–12 have a copy-paste version in [`templates/`](https://github.com/purview-dev/engineering-playbook/tree/main/templates), with `Acme` standing in for your namespace prefix and a comment explaining the one thing about each file that is easy to get wrong. The [templates README](https://github.com/purview-dev/engineering-playbook/blob/main/templates/README.md) maps each template to its destination path and lists which files each profile needs. Four things in the steps above are deliberately **not** templated, because a copy of them would be wrong on arrival: the solution file (its paths depend on your project list), the two `.csproj` files (they are empty by design), and the `Justfile` (see step 12). `templates/README.md` says which, and why. ## How it is enforced Step 13 proved it by breaking a rule on purpose. The table below is the same statement for the whole repository: which concern lives where, and what refuses a change that breaks it. | Concern | Where it lives | Enforced by | | -- | -- | -- | | Framework, language version, nullable, analyzers | `global.json` + the SDK import | The build | | Package versions, and a family moving as one | `Directory.Packages.props` | The build (`NU1010`) for presence; review for the property style | | Assembly and namespace identity | `NamespacePrefix` + the folder name | The build | | Code style | Bootstrapped `.editorconfig` | The build, with the style policy validated | | Test wiring | The `.csproj` filename | The build | | Test data | `TestBuilder` in `SharedTestingInfra` | Review | | Architecture | Dependency-direction tests + `.config/architecture-policy.json` | The build | | Verification tiers | `TestFilter` in `purview-build.json` | The pipeline | | Documentation | `docs/wiki` + `_Sidebar.md` | The build (`DocumentationTests`) for navigation and fences; review for the rest | ## At your scale This page is the floor, not the whole playbook. [Adopting This at Your Scale](../adopting-this-at-your-scale/) decides which parts to take for a small, medium or large repository, and [Worked Examples](../worked-examples/) walks the reference implementation that results from taking all of them. ## See also - [Engineering Principles](../engineering-principles/) - [Repository Shape](../repository-shape/) - [Adopting This at Your Scale](../adopting-this-at-your-scale/) - [Worked Examples](../worked-examples/) --- # Engineering Principles These are the ten rules the Purview repositories are held to. This page is an index: each rule is stated once, in full, on the page named beside it. That page carries the failure the rule prevents, the mechanism that makes it true, and what a smaller repository does instead — so a rule is never written twice and the two copies cannot disagree. Where a rule has no dedicated page, the line here is its full statement. The distinction that runs through all ten: **a convention a build can check is checked by the build; a convention only a reviewer can check is labelled a review obligation and never described as enforced.** A rule that claims enforcement it does not have is worse than no rule, because people stop looking. | # | Principle | In one line | Detail | | -- | -- | -- | -- | | 1 | Convention over configuration | A repository declares as little as possible; one MSBuild SDK supplies the defaults, so a project file is short. | [Repository Shape](../repository-shape/) | | 2 | Identity is derived, never repeated | A name appears once, in the folder and file name; `RootNamespace` and `AssemblyName` are computed from it. | [Repository Shape](../repository-shape/) | | 3 | Architecture is a test, not a diagram | The dependencies a layer may have are asserted by tests, so a wrong `ProjectReference` fails the build. | [Architecture as Tests](../architecture-as-tests/), [Project Responsibilities](../project-responsibilities/) | | 4 | The build is the only authority | A convention a build can check is checked by the build; the rest is labelled a review obligation, never described as enforced. | [Documentation Convention](../documentation-convention/) | | 5 | Warnings are not acceptable output | A clean build has no warnings: fix one, suppress it where it occurs with a justification, or turn the rule off deliberately. | [Testing and Test Data](../testing-and-test-data/) | | 6 | Every claim about a consumer is proved by a consumer | A package that documents how it must be used ships something that builds that documented consumer and asserts the documented outcome. | [Contracts and Data Exchange](../contracts-and-data-exchange/) | | 7 | Documentation is a build artefact of the repository | The wiki lives in the repository it describes, and the repository owns its `mkdocs.yml`, navigation and `edit_uri`. | [Documentation Convention](../documentation-convention/) | | 8 | Everything a test needs is built by one builder | Domain content in a test comes from `TestBuilder`; a test overrides only the value it is proving. | [Testing and Test Data](../testing-and-test-data/) | | 9 | An agent is a reader with no memory | A repository states its purpose, layout and conventions in `AGENTS.md`; nothing important is discoverable only from the code. | [Agent Enablement](../agent-enablement/) | | 10 | A change is verified in proportion to its risk | Checks are tiered; a pull request runs the fast tier, and the slow tier is run deliberately. | [Verification Model](../verification-model/) | Enforcement is stated per rule on the linked page. Three are enforced by the build in a way worth knowing at a glance: identity (rule 2) by `PurviewProjectFileNameMismatch` and the SDK's derivation, architecture (rule 3) by the architecture tests, and the verification tiers (rule 10) by the pipeline's `TestFilter`. The rest are enforced partly by the build and partly by review, and each page says which. ## At your scale Every rule has a smaller version, and taking one before the problem it prevents exists is pure cost. [Adopting This at Your Scale](../adopting-this-at-your-scale/) decides which rules apply to a small, medium or large repository, and in what order to adopt them. ## See also - [Repository Shape](../repository-shape/) - [Architecture as Tests](../architecture-as-tests/) - [Verification Model](../verification-model/) - [Testing and Test Data](../testing-and-test-data/) - [Adopting This at Your Scale](../adopting-this-at-your-scale/) --- # Repository Shape ## The rule A repository has one layout, and every file has exactly one obvious home. The shape is the same whether the repository ships one package or twelve, because a contributor (or an agent) should be able to guess where something lives without being told. ```text Repository/ ├── .config/ dotnet-tools.json, lefthook.yml, purview-build.json, architecture-policy.json ├── .github/ │ ├── workflows/ pr.yml, release.yml, any repository-specific workflow │ └── copilot-instructions.md defers to AGENTS.md ├── skills/ skills this repository publishes (see Agent Enablement) ├── .agents/skills/ skills installed from elsewhere by the skills CLI; gitignored ├── .purview/ SDK state; gitignored ├── docs/ │ └── wiki/ the wiki; docs_dir in mkdocs.yml ├── src/ │ ├── .slnx canonical solution │ ├── Directory.Build.props NamespacePrefix + the SDK import │ ├── Directory.Build.targets the SDK targets import │ ├── src//.csproj │ └── tests//.csproj ├── samples/ runnable, consumer-shaped examples (optional) ├── templates/ copy-paste starters for other repositories (optional) ├── .editorconfig bootstrapped by the SDK; committed ├── .gitattributes ├── .gitignore ├── AGENTS.md the repository's instructions to agents ├── commitlint.config.mts Conventional Commits rules ├── Directory.Packages.props Central Package Management versions ├── global.json SDK pin + test runner ├── Justfile supported local commands ├── LICENSE.md ├── mkdocs.yml wiki site configuration ├── nuget.config ├── package.json authoritative version └── README.md ``` `artifacts/`, `.tools/` and `TestResults/` are generated and gitignored; they are in the layout above only so that seeing them is not a surprise. Not every file above is created by [Getting Started](../getting-started/), and the difference is worth knowing before you follow it expecting a finished repository: | File | Where it comes from | | -- | -- | | `.editorconfig` | Written by the SDK on the first build | | `.gitignore`, `.gitattributes` | The SDK's own `templates/` folder, so they stay in step with it | | `.config/architecture-policy.json` | Optional; adopted when the repository wants its architecture policy enforced — see [Project Responsibilities](../project-responsibilities/) | | `commitlint.config.mts`, `nuget.config`, `LICENSE.md`, `package.json` | Repository-level choices; not covered by Getting Started | | Everything else | Getting Started steps 1–12, or [`templates/`](https://github.com/purview-dev/engineering-playbook/tree/main/templates) | ## Why The layout is not aesthetics. Three specific things depend on it: - **Test discovery.** The pipeline finds tests under `src/tests` and filters them by the category the project name implies. A test project placed anywhere else is not run. - **Root discovery.** The SDK locates the repository root by asking `git`, then by probing upward for a `.git` marker, then by falling back to the `Directory.Build.props` directory. The first two only work if the layout is the one above. - **The SDK import.** MSBuild walks up from each project to the nearest `Directory.Build.props`. If a stray `Directory.Build.props` appears deeper in the tree, projects beneath it silently stop inheriting the SDK. ## Naming: one name, derived everywhere else A project's name is its `.csproj` filename and its parent directory name, and those two must match — a build-time check (`PurviewProjectFileNameMismatch`) fails when they do not. From that one name: | `.csproj` filename | `AssemblyName` / `PackageId` | `RootNamespace` | Detected as | | -- | -- | -- | -- | | `Domain.csproj` | `Acme.Domain` | `Acme.Domain` | source project | | `Domain.UnitTests.csproj` | `Acme.Domain.UnitTests` | `Acme.Domain` | `IsTestProject`, `TestingType=Unit` | | `Domain.IntegrationTests.csproj` | `Acme.Domain.IntegrationTests` | `Acme.Domain` | `IsTestProject`, `TestingType=Integration` | | `SharedTestingInfra.csproj` | `Acme.SharedTestingInfra` | `Acme` | `IsSharedTestingProject` | Use the **short** name. `Domain.csproj`, not `Acme.Domain.csproj` — the prefix is added for you, and repeating it produces `Acme.Acme.Domain`. A test project's namespace is the namespace of the type it tests, not a `.Tests` namespace. That is why `ConventionIdTests` sits in `Acme.Domain` beside `ConventionId`: the test reads as part of the thing it describes, and no `using` is needed to reach the type under test. ## The suffix-stripping trap The SDK removes a set of **layer suffixes** from `RootNamespace` — `Core`, `Infrastructure` and `Abstractions` are the ones you will meet first, alongside the `Shared*` and `SharedTesting*` project names. The consequence is the thing to remember: a project called `Infrastructure` has `AssemblyName` `Acme.Infrastructure` but declares its types in namespace `Acme`, and a project called `Core` declares its types in `Acme` too. That is deliberate: such a project *is* the root layer, and `Acme.Core.ContainerBuilder` would be noise. [containers](https://github.com/purview-dev/containers) is the reference — its `Core` project holds `namespace Purview.Containers;`. This page does not reproduce the list. The SDK owns it, a copy here is a second source of truth, and the two will drift the moment a suffix is added: see [build-sdk: Namespace stripping](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/Assembly-Name-Generation.md#namespace-stripping). One carve-out is worth knowing because it is the one people assume the other way round: Roslyn component suffixes — `SourceGenerators`, `Analyzers`, `CodeFixes` and their siblings — are **not** stripped. A generator, its analyzers and its code fixes are separate assemblies that each need their own namespace. The trap is that the strip is invisible in the project file, so the mismatch surfaces as a warning from the IDE's folder-structure rule rather than as a build error. If you want a layer to keep its suffix, remove it from the list **after** the import, because the item has to exist before it can be removed: ```xml ``` Removing a suffix the SDK never declared is a no-op, so this line is safe to add and does not have to be kept in step with the SDK's list. ## Test projects: the filename is the wiring A test project file is usually empty, because the name carries everything: ```xml ``` The detected suffix becomes the project's baseline test category, which is what the pipeline filters on — so the tier a test belongs to is decided by where it lives, not by an attribute someone remembered to add. **Detection is a pattern, not a list of blessed names.** The SDK matches the project name against `^.*\.([\w|_]\w*)Tests?$` and takes the captured word as `TestingType`. `Domain.GoldenTests` is therefore detected exactly as readily as `Domain.UnitTests`; it gets the runner, coverage and `[Category=Golden]`. There is no set of recognised suffixes to fall foul of. > **The silent failure is the missing target, not an unusual suffix.** The pattern requires the dot, so a > project named plainly `UnitTests` matches nothing. It is not detected as a test project at all — no > TUnit, no coverage, no `InternalsVisibleTo`, no `[Category]` — and the build says nothing, because a > project that is not a test project is simply a library. Name it `.UnitTests`. The SDK does ship an explicit list of roughly fifty test types, but it governs **`InternalsVisibleTo` generation**, not detection: a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name on that list. A suffix outside the list still builds, still runs and still gets its category — it just cannot reach the target project's `internal` members. See [build-sdk: InternalsVisibleTo](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/InternalsVisibleTo.md). Three consequences worth knowing: - **The target project is inferred.** `Domain.UnitTests` gets a `ProjectReference` to `../Domain/`, `../../Domain/`, `../src/Domain/` or `../../src/Domain/` — whichever exists, and nothing at all if none does. That is why `Architecture.UnitTests`, which has no `Architecture` project, builds: it declares its references explicitly instead. - **`InternalsVisibleTo` is generated.** Every non-test project grants access to `{AssemblyName}.{TestType}Tests` for each known test type, plus every shared testing project. A test can reach `internal` members of the project it targets without the project declaring anything. - **A shared testing project stays a library.** Names like `SharedTestingInfra`, `SharedTestingFramework`, `SharedTestingUtilities` get the test packages but not the runner, so the assembly is referenced rather than executed. ## `src/src` and `src/tests` For a repository with more than a handful of projects, source and tests are separated: ```text src/ ├── .slnx ├── Directory.Build.props ├── Directory.Build.targets ├── src/ production projects └── tests/ test and shared-testing projects ``` The doubling looks odd for a week and then stops mattering, because `src/src/...` in a path is unambiguous and `src/tests/...` matches the `TestRoot` the pipeline expects. Smaller repositories put projects side by side instead, which works identically — the SDK detects a test project by name, not by folder. See [build-sdk: Project Naming Conventions](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/Project-Naming-Conventions.md). ## Where the SDK import lives Either at the repository root or in `src/`. This playbook and the multi-project Purview repositories put `Directory.Build.props` and `Directory.Build.targets` in `src/`, which keeps the root for repository-level configuration only. The SDK's root discovery is unaffected, because it asks `git` rather than counting directories. ## How it is enforced | Rule | Enforced by | | -- | -- | | `.csproj` filename matches its parent directory | The build (`PurviewProjectFileNameMismatch`) | | `AssemblyName` and `RootNamespace` follow the project name | The build (SDK derivation) | | A test project is discovered and categorised | The build (`TestingType` detection) and the pipeline's filter | | The solution is the one the pipeline builds | Review, plus `Build:Solution` in `purview-build.json` | | Everything else on this page | Review | ## At your scale - **One project.** `src/src/` plus one `src/tests/.UnitTests`. Skip `samples/` and `templates/`. - **A library with a few modules.** Add `src/src/` per module, one test project per module, and one shared testing project. Add `samples/` once the README's getting-started code is long enough that it has stopped compiling. - **Several packages.** Add an `Sdk/` folder per packable project for package-only assets (`README.md`, `buildTransitive/**`), and `templates/` once another repository has started copying your files by hand. ## See also - [Getting Started](../getting-started/) - [Architecture as Tests](../architecture-as-tests/) - [Documentation Convention](../documentation-convention/) - [Agent Enablement](../agent-enablement/) --- # Project Responsibilities ## The rule A project name states a **responsibility**, optionally qualified by an implementation: ```text [.] ``` `Persistence`, `Persistence.SqlServer`, `Providers.GitHub`, `Adapters.Email` are all well-formed. The responsibility decides the rules; the qualifier is a naming choice and never changes them. **This is a catalogue of responsibilities, not a list of projects you must create.** A small application that combines Core, Application and Persistence in one assembly has not broken the rule — it has one project carrying three responsibilities, which is usually correct. The catalogue tells you what each responsibility *is* so that you can tell when splitting one out would buy something. The authoritative definition is [`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), described by the JSON Schema the SDK publishes at [`architecture-policy.schema.json`](https://github.com/purview-dev/build-sdk/blob/main/architecture-policy.schema.json). The tables on this page are checked against it — see [How it is enforced](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki#how-it-is-enforced). ## Why Two failures, equally common and opposite. **The catch-all project.** `Infrastructure`, `Services`, `Helpers`, `Common`. These name a *category*, so nothing can be excluded from them. Every technical class is plausibly infrastructure, every coordinating class is plausibly a service, and both grow until no one can say what the project is for. A responsibility name can be violated; a category name cannot, which is exactly why it is useless. **Architecture bought in advance.** Four projects, a repository interface and a mapping layer around one table and one caller. Nothing in it is wrong in isolation; all of it is cost paid at every future change against a failure that never arrives. A catalogue addresses both: it gives each project a name that can be wrong, and it makes "do I need this project?" a question with an answer rather than a matter of taste. ## The catalogue `A → B` means **A depends on B at compile time**. It is not a runtime call, a DI registration, an HTTP request or an Aspire orchestration edge. | Responsibility | Category | Also known as | May depend on | | -- | -- | -- | -- | | `Core` | domain | `Domain` | Shared | | `Abstractions` | domain | — | Core, Shared | | `Application` | application | `UseCases` | Core, Abstractions, Contracts, Shared | | `Contracts` | application | — | Core, Shared | | `Persistence` | infrastructure | — | Core, Abstractions, Application, Shared | | `Providers` | infrastructure | `Integrations` | Core, Abstractions, Application, Shared | | `Adapters` | infrastructure | — | Core, Abstractions, Application, Shared | | `Composition` | composition | — | Core, Abstractions, Application, Contracts, Persistence, Providers, Adapters, Shared | | `API` | host | — | Core, Abstractions, Application, Contracts, Composition, ServiceDefaults, Shared, Persistence, Providers, Adapters | | `Worker` | host | — | Core, Abstractions, Application, Contracts, Composition, ServiceDefaults, Shared, Persistence, Providers, Adapters | | `Migrator` | host | — | Core, Abstractions, Persistence, Composition, ServiceDefaults, Shared | | `AppHost` | host | — | API, Worker, Migrator, WebApp, Shared | | `ServiceDefaults` | host | — | Shared | | `WebApp` | host | — | Contracts, ServiceDefaults, Shared | | `Shared` | shared | — | *(nothing)* | ### When each one earns its own project | Responsibility | Split it out when | Leave it combined when | | -- | -- | -- | | `Core` | A domain model exists at all | The app is a thin utility with no domain rules | | `Abstractions` | A contract has two implementations, or a consumer must not see the implementation | One implementation, no cross-boundary consumer — keep the interface beside it | | `Application` | Workflows are reused by more than one host | It would only forward CRUD without adding behaviour | | `Contracts` | A client must compile against the contract without taking the server | The only consumer is the API host | | `Persistence` | Persistence exists — it is what Core must not reference | Single-project utility writing to a file | | `Providers` | Two platforms with different SDKs or lifecycles | One platform, or small clients sharing a lifecycle | | `Adapters` | A capability has more than one technology behind it | One capability, one technology | | `Composition` | More than one host composes the same implementations | One host — compose in the host | | `Migrator` | Schema changes are applied by a deliberate deployment step | Development-only, migrate on startup | | `ServiceDefaults` | More than one host shares operational defaults | One host | | `Shared` | A domain-independent primitive is used by two or more responsibilities | Almost always — it is the easiest project to create and the hardest to keep coherent | ### Diagram A — the responsibility catalogue Grouped by category. **No project here is mandatory.** ```text flowchart TB subgraph domain["Domain"] Core["Core
(aka Domain)"] Abstractions["Abstractions"] end subgraph application["Application"] Application["Application
(aka UseCases)"] Contracts["Contracts"] end subgraph infrastructure["Infrastructure - a category, not a project"] Persistence["Persistence"] Providers["Providers
(aka Integrations)"] Adapters["Adapters"] end subgraph composition["Composition"] Composition["Composition"] end subgraph hosts["Hosts"] API["API"] Worker["Worker"] Migrator["Migrator"] AppHost["AppHost"] ServiceDefaults["ServiceDefaults"] WebApp["WebApp"] end subgraph shared["Shared"] Shared["Shared"] end ``` ### Diagram B — compile-time dependency direction Every arrow means *depends on*. Note that nothing points into Core except by permission, and nothing points at Composition at all. ```text flowchart RL Abstractions --> Core Application --> Core Application --> Abstractions Application --> Contracts Contracts --> Core Persistence --> Core Persistence --> Abstractions Persistence --> Application Providers --> Abstractions Adapters --> Abstractions Composition --> Application Composition --> Persistence Composition --> Providers Composition --> Adapters API --> Composition API --> Contracts Worker --> Composition Migrator --> Persistence WebApp --> Contracts AppHost -.->|"orchestrates, not a reference"| API AppHost -.->|"orchestrates, not a reference"| Worker ``` ## Naming ### Diagram H — generic or qualified? ```text flowchart TD A["New implementation of a responsibility"] --> B{"More than one
implementation?"} B -- no --> C["Use the generic name
Persistence, Providers, Adapters"] B -- yes --> D{"Do they differ in
dependencies, lifecycle
or deployment?"} D -- no --> E["One project, several implementations inside
still the generic name"] D -- yes --> F["Qualify each one
Persistence.SqlServer, Persistence.PostgreSql"] F --> G{"Is there genuinely
shared code?"} G -- yes --> H["Add the unqualified project too
Persistence + Persistence.SqlServer"] G -- no --> I["Qualified projects only
no empty parent"] ``` Three rules fall out of that tree, and all three are departures from what people usually assume: - **Do not qualify a single implementation.** `Persistence` is correct even though it happens to use SQL Server. Rename it when a second provider arrives, not in anticipation. - **Do not create an empty parent.** `Persistence.SqlServer` and `Persistence.PostgreSql` with no shared code need no `Persistence` project. - **Multiple implementations do not require multiple assemblies.** Two small adapters with the same dependencies can live in one project. ### Diagram C — single implementation ```text flowchart RL Persistence["Persistence
(SQL Server inside - not in the name)"] --> Core Providers["Providers
(one platform)"] --> Abstractions Adapters["Adapters"] --> Abstractions Abstractions --> Core Composition --> Persistence Composition --> Providers Composition --> Adapters API --> Composition ``` ### Diagram D — multiple implementations The unqualified `Persistence` appears only because there is shared mapping code. `Providers` has no unqualified project, because `GitHub` and `AzureDevOps` share nothing. ```text flowchart RL PersistenceShared["Persistence
shared mappings"] --> Core PersistenceSql["Persistence.SqlServer"] --> PersistenceShared PersistencePg["Persistence.PostgreSql"] --> PersistenceShared ProvidersGitHub["Providers.GitHub"] --> Abstractions ProvidersAzdo["Providers.AzureDevOps"] --> Abstractions Abstractions --> Core Composition --> PersistenceSql Composition --> PersistencePg Composition --> ProvidersGitHub Composition --> ProvidersAzdo API --> Composition ProvidersGitHub -. "forbidden: siblings stay independent" .-x ProvidersAzdo ``` ### Alternative names `Domain` for Core, `UseCases` for Application and `Integrations` for Providers are recognised and need no change. **Do not rename an established project merely to match canonical terminology** — the rename costs more than the consistency is worth. These names are discouraged as project names, because each names a category rather than a responsibility: `Infrastructure`, `Services`, `Managers`, `Models`, `Helpers`, `Common`, `Utils`, `Utilities`. `Infrastructure` is the one worth calling out. It is a useful *category* — it covers Persistence, Providers and Adapters — and a poor *project*, because everything technical qualifies. ## Providers versus Adapters The distinction is the one people get wrong most often, and it is not about the vendor. - A **Provider** integrates a *named external platform*: GitHub, Azure DevOps. The platform is part of the requirement — "sync issues from GitHub". - An **Adapter** implements an *application-defined capability* using some technology: send email, store a blob. The capability is the requirement; the technology is interchangeable. The same vendor can be either. Azure Blob Storage behind `IFileStore` is an Adapter. The Azure DevOps work-item API, where the product explicitly integrates Azure DevOps, is a Provider. ### Diagram E — Providers versus Adapters ```text flowchart TD subgraph req["What the requirement says"] R1["'Sync issues from GitHub'
the platform is named"] R2["'Send the user an email'
the capability is named"] end R1 --> P["Provider
Providers.GitHub"] R2 --> A["Adapter
Adapters.Email implements IEmailSender"] P --> PA["Owns: client, auth, rate limits,
provider models, error translation"] A --> AA["Owns: one technology behind
an application-defined interface"] PA --> N1["Swapping it changes the product"] AA --> N2["Swapping it changes nothing the user sees"] ``` ## Composition and runtime Composition is the only project allowed to know every implementation, and nothing is allowed to depend on it. That is what keeps implementation choice in one place. ### Diagram F — composition and runtime Solid arrows are compile-time references. Dashed arrows are runtime resolution, which runs in the opposite direction and is the whole point of the arrangement. ```text flowchart TD Abstractions["Abstractions
IConventionRepository"] Application["Application"] PersistenceSql["Persistence.SqlServer"] Composition["Composition"] API["API"] Application -->|depends on| Abstractions PersistenceSql -->|implements| Abstractions Composition -->|references| PersistenceSql Composition -->|references| Application API -->|references| Composition Composition -.->|"registers at runtime"| Resolve["IConventionRepository resolves
to the SQL Server implementation"] Application -.->|"receives at runtime"| Resolve ``` Application never references `Persistence.SqlServer`. It receives an implementation it cannot name. ## Persistence and migration Persistence **owns** migration definitions. Migrator **executes** them. Splitting those two is what stops an API startup path from mutating a production schema. ### Diagram G — persistence and migration ```text flowchart TD Persistence["Persistence
DbContext, mappings, migration definitions"] Migrator["Migrator
executes migrations, controlled seeding"] API["API"] DB[("Database")] Persistence -->|"migrations live here"| Migrator Migrator -->|"applies schema, deliberate step"| DB API -->|"reads and writes only"| DB API -.->|"must NOT apply migrations at startup in production"| DB ``` ## How it is enforced | Rule | Enforced by | | -- | -- | | Every production project maps to a responsibility or a recorded waiver | `ArchitectureTests` | | Declared project references respect the dependency matrix | `ArchitectureTests` | | Sibling implementations stay independent | `ArchitectureTests` | | Nothing depends on Composition except hosts | `ArchitectureTests` | | Forbidden framework packages stay out of a responsibility | `ArchitectureTests` | | The project graph has no cycles | `ArchitectureTests` | | Catch-all project names are rejected | `ArchitectureTests` | | Production code does not reference a test project | `ArchitectureTests` | | Every waiver names a real project, a rule, and a substantive reason | `ArchitectureTests` | | This page's tables match the policy file | `PolicyDocumentationTests` | | The policy matches its JSON Schema, and the schema can actually reject | `PurviewArchitectureTests`, `PolicySchemaTests` | | A responsibility is split only when it is justified | **Review** | The rules themselves are not written here: they ship in **`Purview.Architecture.Testing`**, and `ArchitectureTests` derives from the package's `PurviewArchitectureTests` base class, so the matrix, naming, sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules are defined once and shared. The tests read `.config/architecture-policy.json`. Nothing on this page is a second source of truth — a consistency test fails if the tables drift from the policy. ### The policy format `.config/architecture-policy.json` is described by a JSON Schema (draft 2020-12) that the SDK publishes at [`architecture-policy.schema.json`](https://github.com/purview-dev/build-sdk/blob/main/architecture-policy.schema.json), and the file points at it so an editor validates as you type: ```json { "$schema": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json", "version": 1, "sourceRoots": ["src"] } ``` The schema lives in the SDK's repository rather than in a copy beside the policy, because a second copy is a second source of truth and the two drift. It is also embedded in `Purview.Architecture.Testing`, so validation needs no network and cannot disagree with the reader. | Key | Purpose | | -- | -- | | `version` | Format version. Increment when a change would break a reader that understood the previous shape. | | `sourceRoots` | Repo-relative folders holding the projects; defaults to `["src"]`. An empty array scans the repository root. A wrong value finds no projects, which the graph-discovery rule reports rather than letting every other rule pass vacuously. | | `responsibilities[]` | `name`, `aliases`, `category`, `purpose`, `mayDependOn`, `mustNotDependOn`, `forbiddenPackagePrefixes`, `siblingIndependence`, and the split/combine guidance | | `discouragedProjectNames` | Category names rejected as project names, with the reason | | `rules` | Repository-wide switches not expressed per responsibility | | `waivers[]` | Deliberate departures — see below | Two schema choices do real work: - **`additionalProperties: false` everywhere.** A misspelled key is the most dangerous mistake in a policy file, because it reads as an *absent* rule rather than an error. `mustNotDependOnn` must fail, not silently disable a boundary. - **`minLength` on a waiver's `reason`.** A waiver with a token justification is a suppression wearing a reason's clothes. The schema is enforced, not decorative: `PurviewArchitectureTests` validates the policy against the embedded schema on every run, and `PolicySchemaTests` asserts it *rejects* a document missing a required field, a waiver with a token reason, and a misspelled key. A schema that cannot fail proves nothing. ### Adopting this in another repository The rule engine ships as **`Purview.Architecture.Testing`**, so a repository does not copy the C#. Adoption is two files: **1. A policy** at `.config/architecture-policy.json`, pointing at the published schema so an editor validates it as you type: ```json { "$schema": "https://raw.githubusercontent.com/purview-dev/build-sdk/main/architecture-policy.schema.json", "version": 1, "sourceRoots": ["src"], "responsibilities": [ ... ] } ``` **2. One test class:** ```csharp [InheritsTests] public sealed class ArchitectureTests : PurviewArchitectureTests; ``` That is the whole adoption. The base class contributes the full rule set — schema validation, dependency matrix, sibling independence, composition boundary, forbidden packages, cycles, naming, test references and waiver integrity — each as a separate test, so a failure names the rule. > **`[InheritsTests]` is required.** TUnit does not walk base types during discovery. Without the > attribute the class compiles, the run is green, and **nothing is enforced**. TUnit's analyzer > reports `TUnit0030` when it is missing; treat that as an error, not a suggestion. Two optional overrides: | Override | Use when | | -- | -- | | `RepositoryRoot` | The policy does not sit above the test binary — the default probes upward for `.config/architecture-policy.json` | | `SkipRule(rule)` | A rule the repository has not adopted yet. Prefer a waiver: it is per project, carries a reason, and is itself checked | For bespoke rules, use the package's engine directly rather than the base class — `ArchitecturePolicyEvaluator` returns typed `ArchitectureViolation` lists per rule, and takes an in-memory `ProjectNode` graph so a rule can be tested without a repository on disk. The package's own skill, `purview-buildsdk-architecture-tests`, is authoritative on that API and on the rule ids. ### Waivers A repository that predates the policy will violate it, and suppressing that silently would make the policy a lie. A waiver records the departure instead: ```json { "project": "Infrastructure", "mapsTo": "Adapters", "rules": ["naming", "discouragedName"], "reason": "...", "reviewedOn": "2026-10-15" } ``` A waiver excuses the **name only**. `mapsTo` keeps the project classified, so every dependency rule still applies to it. This repository's own `Infrastructure` project is the worked example: it holds one adapter, so it is not the catch-all the rule is aimed at, and renaming it would churn the wiki, `AGENTS.md` and `NamespaceRemoveSuffix` for no gain. ### Diagram I — architecture enforcement ```text flowchart TD A["Discover *.csproj under src/"] --> B["Read ProjectReference and PackageReference"] B --> C["Classify by name
responsibility, alias, or waiver mapsTo"] C --> D["Load .config/architecture-policy.json"] D --> E["Evaluate rules
matrix, siblings, composition, packages, cycles, names"] E --> F{"Violations?"} F -- yes --> G["Fail with project, rule, offending reference
and the direction it should run"] F -- no --> H["Pass"] D --> I["PolicyDocumentationTests
wiki tables match the policy"] ``` ## At your scale - **A small application.** One or two projects. Use the catalogue to *name* them, not to multiply them. `Core` plus a host is a complete and correct architecture. - **A medium application.** Split out the responsibility whose boundary you keep tripping over — usually Persistence, so Core and Application stop seeing EF Core. - **A large application.** The full catalogue earns its place, and the tests stop the matrix drifting as teams change. - **Any size.** Copy the waiver mechanism before you copy the rules. A policy with no honest escape hatch gets suppressed wholesale the first time it is inconvenient. ## See also - [Architecture as Tests](../architecture-as-tests/) — how a dependency rule becomes a failing build - [Repository Shape](../repository-shape/) - [Engineering Principles](../engineering-principles/) - [Adopting This at Your Scale](../adopting-this-at-your-scale/) --- # Architecture as Tests ## The rule The dependency directions an architecture depends on are asserted by tests that read the **compiled assemblies**. A `ProjectReference` that points the wrong way fails the build. A layer's allowed dependencies are data, not prose: | Assembly | May reference | Must not reference | | -- | -- | -- | | `Acme.Domain` | nothing of ours | `Acme.Application`, `Acme.Infrastructure` | | `Acme.Application` | `Acme.Domain` | `Acme.Infrastructure` | | `Acme.Infrastructure` | `Acme.Application` (and `Acme.Domain` transitively) | — | ## Why A diagram describes an intention. Dependencies erode one convenient reference at a time, and each one looks reasonable in isolation: the domain needs one enum from the persistence layer, the application needs one helper from the web layer, and within two releases the layering is decorative. Review catches this sometimes. A test catches it every time, including at 5pm on a Friday. There is a second reason that matters more in a repository worked on by agents: an architecture test is the difference between a rule an agent can *follow* and a rule an agent has to *remember*. A generated class in the wrong project is not a mistake an agent makes on purpose — it is a mistake nobody told it was a mistake. The failing test is the telling. ## What is worth asserting Four rules cover most of the value, and each one corresponds to a real failure: 1. **Direction.** The innermost layer references nothing outward. This is the rule everything else supports. 2. **No production assembly references a test assembly.** A test assembly in a package's dependency graph is a packaging bug that only shows up for a consumer. 3. **Namespace identity.** A layer's public types live in that layer's namespace. This catches a file moved to the wrong project, and it catches the SDK's suffix-stripping behaviour surprising someone (see [Repository Shape](../repository-shape/)). 4. **The port is owned by the inner layer.** The interface the outer layer implements lives in the inner layer, not beside its implementation. This is what makes the inversion real rather than nominal. ## The mechanism The tests are ordinary unit tests in a project named `Architecture.UnitTests`, and they use nothing special: `Assembly.GetReferencedAssemblies()` and `Assembly.GetExportedTypes()`. `Assembly.GetReferencedAssemblies()` reads the **metadata** references of a compiled assembly. That is exactly the dependency graph the runtime will need, which means the test cannot be fooled by a `ProjectReference` that is declared but unused, or by a `using` that never became a real reference. If the reference is in the metadata, the test sees it. The test project has to reference every layer it inspects, so it declares those references explicitly. It has no same-named production project, so the SDK adds no automatic reference — which is the correct behaviour and worth knowing about, because it is why the project file below is not empty. ```xml ``` ## The worked example `src/tests/Architecture.UnitTests/DependencyDirectionTests.cs` in this repository is a running version of all four rules. It is small enough to read in full, and it is built by the same pipeline as everything else — so if the layering breaks, the build fails here rather than in review. The shape of every rule is the same: gather the offending facts, then assert the collection is empty and let the failure message name what is wrong. ```csharp [Test] public async Task Application_GivenItsReferences_DependsOnTheDomainAndNotOnInfrastructure() { // Arrange var application = typeof(ConventionCatalogue).Assembly; // Act var referenced = ReferencedAssemblyNames(application); // Assert await Assert.That(referenced).Contains($"{RepositoryPrefix}.Domain"); await Assert.That(referenced).DoesNotContain($"{RepositoryPrefix}.Infrastructure"); } ``` The `Contains` is as important as the `DoesNotContain`. A rule that only forbids references passes vacuously the moment somebody deletes the one it expected — and "the domain is no longer used by the application" is a much larger architectural change than "the application references infrastructure". The reading helpers are deliberately trivial: ```csharp static IReadOnlyList ReferencedAssemblyNames(Assembly assembly) => [.. assembly.GetReferencedAssemblies().Select(reference => reference.Name ?? string.Empty)]; ``` Two details in the assertions are worth copying: - **Assert on names, not on `AssemblyName` objects.** `AssemblyName.Name` is the simple name (`Acme.Domain`), which is stable and reads well in a failure message. - **Name the offender.** Where a rule iterates types, project each offender to a string that includes the namespace, so the failure says *which* type is wrong rather than only that a rule broke. A failing architecture test that does not name the offending type costs the reader a debugging session. ## Two traps this repository hit Both are real, both were found by writing the rules, and both are worth knowing before you write yours. ### The telemetry generator emits public types into its own namespace The SDK enables `Purview.Telemetry.SourceGenerator` on every assembly by default, and it emits **public** types — `Targets`, `NamingConvention`, `LogPrefixType`, `LoggerGenerationMode`, `MeterNameGenerationType` — into a `Purview.Telemetry` namespace. A naive "every public type lives in my namespace" rule therefore fails in every assembly the generator touches. The rule has to be scoped to types the repository actually authored: ```csharp static bool IsAuthored(Type type) => !type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) && !type.IsDefined(typeof(GeneratedCodeAttribute), inherit: false) && !IsInGeneratorOwnedNamespace(type.Namespace); ``` This is the general lesson, not a telemetry-specific one: **an architecture rule about your code has to distinguish your code from generated code.** Any source generator can add types to any assembly, and the rules you write are about the ones you wrote. ### A shared testing project cannot hand its internals to a test project The instinct is to make a test helper's nested factory classes `internal` so they do not widen the public surface. That does not work from a shared testing project: the SDK's `InternalsVisibleTo` target runs on projects where `IsTestProject` is not `true`, and it grants access *to* test and shared-testing projects rather than *from* them. A shared testing project's internals are not visible to the tests beside it. The corollary is that an `internal` helper is not an option, and a `public` nested type is a build error — the SDK ships `dotnet_diagnostic.CA1034.severity = error`, and `PRSGD0008` refuses to let a repository add `CA1034` to `NoWarn`. Factories therefore sit flat on the builder class rather than in nested groups. See [Testing and Test Data](../testing-and-test-data/). ## What these tests do not cover Being explicit about this matters more than the rules themselves: - **Runtime coupling.** Two layers can be reference-clean and still coupled through a shared static, an ambient service locator, or a type from a third-party package both depend on. - **Package boundaries.** `ProjectReference` is not `PackageReference`. A monorepo can be layered perfectly and still ship a package graph that is not. - **Behaviour.** Nothing here says the domain is *correct*, only that it is *reachable from* the right places. - **The rule you forgot to write.** Every assertion is a rule somebody chose. A dependency direction that no test names is a direction nothing enforces. ## How it is enforced The rules are ordinary tests in a project named `*UnitTests`, so the pipeline runs them on every pull request as part of the fast tier. No extra workflow, no extra step, no separate tool: an architecture violation is a failing test with the same status as any other. These four rules are hand-written here because they read **compiled assemblies**, which is what lets them catch a dependency that arrived indirectly rather than one that was declared. The broader **policy** rules — the dependency matrix, naming, sibling independence, the composition boundary, forbidden packages, cycles, test references and waivers — ship in `Purview.Architecture.Testing`, and are adopted with a policy file and one derived test class rather than with C#. See [Project Responsibilities](../project-responsibilities/). ## At your scale - **One project.** Nothing to assert. Skip it. - **Two or three layers.** Adopt rules 1–3. They are a page of code and they pay for themselves the first time someone adds a reference "just to reuse a helper". - **A repository with packages.** Add rule 2, and consider asserting that a package's dependency graph contains no test assembly — the failure mode is invisible until a consumer sees it. - **Many teams.** Promote the rules into the build itself so they cannot be skipped: an analyzer or an MSBuild check that runs before compile, in the same way the SDK's own `ValidatePurviewStylePolicy` does. ## See also - [Repository Shape](../repository-shape/) - [Verification Model](../verification-model/) - [Testing and Test Data](../testing-and-test-data/) - [Worked Examples](../worked-examples/) --- # Verification Model ## The rule Checks are tiered by cost, and a repository declares which tiers its pipeline runs. A pull request runs the fast, deterministic tier. Anything that needs a real external resource is selected deliberately — locally, or on a runner that has the resource. The tier a test belongs to is a property of **where it lives**, not of an attribute someone remembered to add: the SDK reads the test project's name suffix and emits an assembly-level category for it. | Project name | Category | Runs on a pull request? | | -- | -- | -- | | `.UnitTests` | `Unit` | Yes | | `Architecture.UnitTests` | `Unit` | Yes | | `.ContractTests` | `Contract` | Usually | | `.IntegrationTests` | `Integration` | Only when the resource is available | | `.E2ETests` | `E2E` | No | `Architecture.UnitTests` is named that way **deliberately**, and the obvious alternative is a trap. The SDK takes the category from the word before `Tests`, so `Architecture.ArchitectureTests` would get `Category=Architecture` — and a pull-request filter of `[Category=Unit]` would quietly stop running the dependency-direction rules. The rules are fast, deterministic and have no external resource, so they belong in the `Unit` tier; naming them `Architecture.UnitTests` is what puts them there. ## Why A gate is only useful if it is credible, and credibility comes from speed. The moment the pull-request build needs a database, a container runtime and four minutes of waiting, somebody makes it optional — and then nothing is gated. The tiering also changes what a test is allowed to assume. A `Unit` test may assume it runs everywhere, so it may not touch the network, the clock or the file system. An `Integration` test may assume it runs somewhere real, and pays for that by having to be selected. ## How a tier is expressed Three steps, none of which is a hand-written attribute: 1. **The project name.** `Domain.UnitTests.csproj` — the SDK detects `Unit` and emits `[assembly: Category("Unit")]`. 2. **The filter.** The pipeline passes `--treenode-filter`, and the repository declares it once: ```json { "Build": { "Solution": "src/Acme.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", "TestFilter": "/*/*/*/*[Category=Unit]" } } ``` 3. **The run.** `dotnet test --solution ... --treenode-filter "/*/*/*/*[Category=Unit]"`. The filter string is a path, not a search: `/*/*/*/*` matches the tree-node depth of a test, and `[Category=Unit]` restricts it to the categorised assemblies. Because the category comes from the assembly, adding a `Domain.IntegrationTests` project is enough to keep it out of the pull-request tier — no filter edit, no attribute, no list to forget. That last sentence is only true if **`TestPatterns` is left at its default**, `*Tests.csproj`. `TestPatterns` and `TestFilter` are two different mechanisms, applied in that order: `TestPatterns` decides which project files are *discovered* under `TestRoot`, and `TestFilter` decides which of the discovered tests *run*. Narrow `TestPatterns` to `*UnitTests.csproj` and you have added a second place that has to be edited before a new tier is ever seen — the list to forget, reintroduced. Keep the default, express the tier in `TestFilter` alone, and reach for `TestPatterns` only when you genuinely want a project excluded from discovery altogether. See [build: Configuration Reference](https://github.com/purview-dev/build/blob/main/docs/wiki/Configuration-Reference.md). ## What the pipeline actually runs The repository owns a caller workflow; the pipeline lives once, in [build](https://github.com/purview-dev/build). The shared workflow's inputs are the contract: | Input | Default | Effect | | -- | -- | -- | | `dotnet-version` | `10.0.x` | SDK used by the runner. Pin it when `global.json` pins one too. | | `run-tests` | `true` | Enable discovered tests. | | `run-lint` | `true` | Run CSharpier. | | `run-pack` | `true` | Pack. | | `validate-pack` | `true` | Validate the produced packages. | | `test-filter` | *(from config)* | Tree-node filter. | | `test-projects` | *(from config)* | Restrict which discovered projects run. | | `release-mode` | `None` | `None`, `NuGet` or `GitHubRelease`. | Two details are easy to get wrong: - **The shared workflow's defaults are not your defaults.** It defaults to `run-pack: true` and `validate-pack: true`, so a repository that ships no package must turn both off — in the workflow *and* in `purview-build.json`, or the two disagree about what a green build means. - **An optional input left empty is not the same as omitted.** Setting `Build__TestFilter` to an empty string overrides the repository's configuration, because environment variables outrank the JSON. The shared workflow therefore forwards `test-filter` and `test-projects` only when the caller supplied them. ## Slow tiers get their own job The pattern for a suite that cannot run in the fast tier is a **second job in the same workflow**, not a disabled test. [containers](https://github.com/purview-dev/containers) is the reference: the shared pipeline runs the `[Category=Unit]` tier, and a separate `integration-docker` job on the same `ubuntu-latest` runner runs the Docker-backed suites. The suites skip themselves when no daemon is reachable, so a non-zero result is a real failure rather than a missing runtime. [build-sdk](https://github.com/purview-dev/build-sdk) shows the other shape: a matrix over three test projects, including an integration suite, because the whole point of that repository is that the SDK works end to end. Either way the rule holds: **the fast tier is always on, and the slow tier is explicit.** ## The configuration file `purview-build.json` is probed for in a documented order, first match wins, and files are never merged: ```text purview-build.json → .config/purview-build.json → .build/purview-build.json → build/purview-build.json → .purview/purview-build.json → .github/purview-build.json ``` Paths inside it — `Build:Solution`, `Build:TestRoot`, `PackValidation` globs — are anchored to the **repository root**, wherever the file was found. Moving the file into `.config/` therefore does not change what it points at, which is the whole reason it can be moved safely. The file is validated against a JSON Schema generated from the tool's own settings, so a mistyped key is an error naming the key rather than a line that is silently ignored. Add the `$schema` line and your editor completes it. ## What this repository does This playbook ships no NuGet package, so its pipeline turns packing off and its release is a tag: ```yaml jobs: build: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main with: run-pack: false validate-pack: false test-filter: "/*/*/*/*[Category=Unit]" secrets: inherit ``` Three things are visible in that snippet, and all three are deliberate: the repository owns only the caller; the pack switches match `.config/purview-build.json`; and the filter is the same string as the one in the configuration, so the two cannot drift apart silently. ## How it is enforced | Rule | Enforced by | | -- | -- | | The fast tier runs on every pull request | The caller workflow, and branch protection that makes it required | | The tier is derived from the project name | The build (SDK category emission) | | The repository's configuration is valid | The pipeline, which validates the JSON against the schema | | The slow tier is run before a release | Review — and, where it matters, a release gate | ## At your scale - **One tier.** Everything is `UnitTests`. Do not add a filter yet. - **A suite that needs a resource.** Add `.IntegrationTests`, keep it out of the filter, and give it a second job. Make the suite skip itself when the resource is absent, so a missing runtime is a skip and never a pass. - **A package.** Turn on `run-pack` and `validate-pack`, and treat pack validation as the gate that stops a package shipping without the assets it documents. - **Several repositories.** Keep the pipeline shared and let each repository own only its caller workflow and its configuration. That is the arrangement that makes a fix in one place a fix everywhere. ## See also - [Release Flow](../release-flow/) - [Testing and Test Data](../testing-and-test-data/) - [Architecture as Tests](../architecture-as-tests/) - [build: Configuration Reference](https://github.com/purview-dev/build/blob/main/docs/wiki/Configuration-Reference.md) --- # Contracts and Data Exchange ## The rule | Rule | Statement | | -- | -- | | **1** | A value is a type, not a primitive. An email address is `EmailAddress`, not `string`. | | **2** | Two doors. `Create` validates and can fail; `Hydrate` trusts and cannot. | | **3** | Normalise, then validate — in that order, in one place. | | **4** | Expected outcomes are values. Exceptional circumstances throw. | | **5** | The schema is the single source of truth for a wire shape. Everything else is derived. | ## Why **Rule 1** exists because a primitive is an unchecked assumption. `string email` is a promise the compiler cannot verify, so every consumer re-verifies it or hopes. A type makes the promise checkable once and carries its own rules to every place it appears — including places the original author never saw. **Rule 2** exists because validation has a direction. Inbound data must be checked; data you produced yourself must not be re-checked, because re-checking a value that is already known-good turns a data-quality problem into a crash in an unrelated place. Two doors with different contracts make the choice explicit and reviewable: `Create` at the boundary, `Hydrate` everywhere past it. **Rule 3** exists because the order is not arbitrary. `" Foo@Bar.com "` and `"foo@bar.com"` are the same address, and a validator that rejects the first is wrong. Normalising inside the factory means there is exactly one normalised form, so equality, hashing, dictionary keys and database lookups all agree. **Rule 4** exists because an exception is a statement that the caller did not expect this. "This email is taken" is expected; it is a branch, not a fault. Modelling it as a value keeps the compiler's help — the caller must handle it — without the cost of exceptions as control flow. Faults still throw, and that distinction is the whole point. **Rule 5** exists because a wire shape described twice is a wire shape that disagrees with itself. The schema is the contract; the C# type, the JSON binding, and the documentation are all derived from it. ## How they compose ```text inbound request body ──schema──▶ validated shape ──Create──▶ domain value │ ▼ domain logic │ Result ◀──────────┘ │ ├── success ──▶ TValue └── failure ──▶ TError ──map──▶ response ``` Four conversions, each with one owner: - **Schema → validated shape.** The schema decides what a well-formed payload is, before anything is constructed. - **Validated shape → domain value.** `Create` is the only door that validates; it runs normalisation and the type's own invariants. - **Domain value → result.** Domain logic returns `Result` rather than throwing for expected failure. - **Result → response.** A mapping layer turns a `TError` into a status code and a body. Nothing above it knows what an HTTP status is. ## Rule 1 and 2 in practice [value-objects](https://github.com/purview-dev/value-objects) is the reference implementation, and it is source-generated rather than hand-written: mark a `partial` record struct with `[Scalar]` and the generator emits the factories, the equality, the comparison, the conversions and the JSON converter. ```csharp [Scalar] public readonly partial record struct EmailAddress { public string Value { get; } static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; static partial void OnValidate(ref string value) { if (!value.Contains('@', StringComparison.Ordinal)) { throw new ArgumentException($"'{value}' is not an email address.", nameof(value)); } } } ``` Note what the two hooks encode: `OnNormalize` runs first and makes the value canonical, `OnValidate` runs second and can reject it. The generator supplies `Create`, `Hydrate` and `TryCreate` around them, plus `Empty`, `CompareTo`, the underlying value's formatting and parsing interfaces, and a JSON converter that serialises the scalar as its underlying value rather than as an object. The `Value` property stays visible, so a value object is never a black box that has to be unwrapped through a helper. The same generator covers the storage boundary: reference `Microsoft.EntityFrameworkCore` and it emits the value converters, comparers and complex-type mapping, plus a `ConfigureValueObjects` extension — so queries use the value object type directly instead of `.Value`. ## Rule 4 in practice [results](https://github.com/purview-dev/results) supplies the type and the ergonomics: ```csharp Result GetTenant(TenantId tenantId) => _tenants.TryGet(tenantId, out var tenant) ? Result.Success(tenant) : new TenantNotFound(tenantId).AsFailure(); ``` The error side is a C# union, so the set of failures is closed and the compiler knows it: ```csharp [GenerateResult] public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); ``` `AsFailure()` is generated per case by the source generator bundled in `Purview.Results`, which is what keeps the call site free of a `new TenantNotFound(...)` cast to the union type. The union receiver gets `Union.Failure(...)` and `Union.Success(...)` factories, so constructing a result never requires naming the union's underlying representation. The sentence to keep in mind is the one in the package's own README: *"Exceptional circumstances still throw; expected outcomes are values."* A missing tenant is expected. A database that is unreachable is not, and it should surface as an exception rather than as a `TenantError` case, because a caller who is forced to handle it as a value will handle it wrongly. ## Rule 5 in practice [zodsharp](https://github.com/purview-dev/zodsharp) is the schema layer: a schema validation library for C# ported from TypeScript's Zod, with struct-based rules, a fluent API, a `[ZodSchema]` source generator, and DataAnnotations support. The important property is not the performance — it is that one schema serves several jobs: | Consumer | What it derives from the schema | | -- | -- | | Inbound validation | The rules, with zero-allocation checking | | JSON Schema export | A machine-readable contract for consumers and documentation | | JSON Schema import | A schema built from an existing contract, so the C# side can adopt one that already exists | | ASP.NET Core integration | Model validation wired to the framework's own pipeline | | Result mapping | `ValidationResult` bridged into `Result`, and failures mapped onto `ProblemDetails` | The bridges are separate packages rather than one package with everything in it — `Purview.Results.ZodSharp` and `Purview.Results.ZodSharp.AspNetCore` — so a consumer that does not need the mapping does not take the dependency. ## Validation results are values too A validation outcome is modelled, not thrown: ```csharp validator.ValidateAsync(Any(), Any()).Returns(ValidationResult.Success(aggregate)); validator.Validate(Any()).Returns( ValidationResult.Failure( ValidationError.Create("any", "error", [nameof(TestAggregate.Name)]))); ``` `IsValid`, `Failures`, and each failure's `PropertyName` are the read surface, so a caller can map failures onto a response without parsing a message. That is what makes the boundary conversion mechanical rather than clever. ## How it is enforced | Rule | Enforced by | | -- | -- | | A value is a type | Review — and, once the package is referenced, the source generator's diagnostics | | `Create` and `Hydrate` are the only doors | The generator emits them; a hand-written constructor is a review failure | | Normalise before validate | The generator runs the hooks in that order | | Expected failures are values | Review — a thrown exception for an expected outcome is visible in review | | The schema is the source of truth | Review, plus the export/import tooling that makes a second definition unnecessary | | JSON serialisation of a scalar value | The generator's emitted converter; a test asserting the wire shape is the check | The one that most benefits from a test is the wire shape: a value object's JSON representation is a contract with something outside the repository, so it belongs in a test that asserts the exact payload rather than in a hope. ## At your scale - **One project.** Adopt rule 1 for the identifiers and the values you already validate by hand. Add rule 4 for the one failure mode your callers currently catch. - **A service with a boundary.** Adopt all five. Rules 2 and 3 pay for themselves the first time an inbound payload arrives with different casing and a lookup fails for a value that is already in the database. - **A published contract.** Export the schema and check it in. A consumer-facing wire shape that only exists as C# code is a wire shape nobody outside can test against. - **Several services.** Version the schema, not the code. The schema is the thing both sides agreed on, and it is the thing to keep compatible. ## See also - [Worked Examples](../worked-examples/) - [Testing and Test Data](../testing-and-test-data/) - [value-objects](https://github.com/purview-dev/value-objects) - [results](https://github.com/purview-dev/results) - [zodsharp](https://github.com/purview-dev/zodsharp) --- # Testing and Test Data ## The rule | Concern | The rule | | -- | -- | | Framework | TUnit. Not xUnit, NUnit or MSTest. | | Substitutes | TUnit.Mocks. Not NSubstitute or Moq. | | Test data | Bogus, reached through `TestBuilder`. | | Structure | Explicit `// Arrange`, `// Act`, `// Assert`. | | Naming | `{SubjectUnderTest}_{Scenario}_{Expectation}`, and `async Task`. | | Placement | One `{Class}Tests` class per class under test, **in the namespace of the class under test**. | | Construction | Dependencies and the system under test come from `CreateXXX` helpers with optional parameters. | | Cancellation | Pass a `CancellationToken` wherever a downstream method accepts one. | | Domain content | Built through `TestBuilder`. Never inline. | ## Why **One framework.** The SDK wires TUnit into any project whose name ends in a test suffix, and that wiring is the reason a test project file is empty. A second framework in the same repository means every project file grows a conditional, and the pipeline grows a second discovery path. **Same namespace.** A test in the namespace of the class it tests reads as part of the thing it describes, needs no `using` to reach the type, and cannot silently test a different type with the same name. It also makes the pairing obvious in a file list. **`CreateXXX` helpers.** A test body that constructs its own dependencies buries the assertion under setup. A helper with optional parameters says what the test changed and defaults everything else: ```csharp static ConventionCatalogue CreateCatalogue(IConventionRepository? repository = null) => new(repository ?? CreateRepository().Object); ``` **`TestBuilder`.** Inline construction encodes the shape of a domain type into every test that touches it, so one constructor change becomes a repository-wide edit. It also destroys the reader's signal about which values the test actually depends on, because every literal looks deliberate. The builder is the single means by which a test creates domain content — see [Test data](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki#test-data) below. ## Writing a test ```csharp using Acme.Builders; namespace Acme.Domain; public sealed class CustomerIdTests { [Test] public async Task Create_GivenLowercaseSlug_ReturnsTheValue() { // Arrange var id = TestBuilder.CustomerId("build-enforced"); // Act var value = id.Value; // Assert await Assert.That(value).IsEqualTo("build-enforced"); } } ``` Two things are deliberate. The `// Arrange` block is often empty in a one-line test, and it stays: the comments are what makes a three-line test readable next to a thirty-line one, and a convention that is applied "when needed" is applied inconsistently. And the assertions are awaited. `Assert.That(...)` is terminal and returns a value, so `await Assert.That(result).IsEqualTo(expected)` is the form — an un-awaited assertion is a test that cannot fail. ## Assertions The assertion surface is small and reads as prose: | Form | Use | | -- | -- | | `await Assert.That(x).IsEqualTo(y)` | Value equality | | `await Assert.That(x).IsNotEqualTo(y)` | Inequality | | `await Assert.That(x).IsTrue()` / `.IsFalse()` | Booleans | | `await Assert.That(x).IsEmpty()` | Collections — the workhorse of architecture rules | | `await Assert.That(x).Count().IsEqualTo(2)` | Collection size | | `await Assert.That(x).Contains(y)` / `.DoesNotContain(y)` | Membership | | `await Assert.That(act).Throws()` | A synchronous delegate that should throw | | `await Assert.That(async () => await f()).Throws()` | An async delegate that should throw | | `await Assert.That(x).IsTypeOf()` | Exact runtime type | | `await Assert.That(x).IsEquivalentTo(sequence)` | Sequence equality, order-independent | For an exception test, `Action` and `Func` are the two shapes; the compiler will not infer either, so the explicit type is required and is not a style violation: ```csharp Action act = () => CustomerId.Create(""); await Assert.That(act).Throws(); ``` ## Parameterised tests `[Arguments]` puts the interesting values in the attribute rather than in a loop, so a failure names the case: ```csharp [Test] [Arguments("")] [Arguments(" ")] [Arguments("Has Spaces")] [Arguments("has_underscore")] public async Task Create_GivenSlugThatIsNotALowercaseSlug_Throws(string value) { // Arrange // Act Action act = () => CustomerId.Create(value); // Assert await Assert.That(act).Throws(); } ``` ## Substitutes TUnit.Mocks is globally imported by the SDK, so a test file needs no `using`. The idiom is to set up the call the code under test will make, then assert on the interaction: ```csharp [Test] public async Task RegisterAsync_GivenAnAlreadyRegisteredConvention_DoesNotStoreIt(CancellationToken cancellationToken) { // Arrange var repository = CreateRepository(); repository.ExistsAsync(Any(), Any()).Returns(true); var catalogue = CreateCatalogue(repository.Object); // Act await Assert .That(async () => await catalogue.RegisterAsync( TestBuilder.EnforcedConvention(), cancellationToken)) .Throws(); // Assert repository.AddAsync(Any(), Any()).WasNeverCalled(); } ``` - `Mock.Of()` produces a `Mock`; `.Object` is the instance to pass in. - `Any()` matches any argument; `Is(value)` matches a specific one; `AnyArgs()` matches the whole call. - `.Returns(value)` sets the result. For a `Task`-returning method, return the `T` — the task is unwrapped for you. - `.WasCalled(Times.Once)` and `.WasNeverCalled()` are the assertions. The second `// Assert` after the `Throws` is not a mistake: the point of that test is that the exception was thrown *and* that nothing was written, and both are assertions about the same act. ## Test data `TestBuilder` lives in the shared testing project and is the single means by which a test creates domain content: ```csharp public static class TestBuilder { public const int DefaultCount = 3; internal static Faker Faker { get; } = new(); public static Domain.ConventionId ConventionId(string? value = null) => Domain.ConventionId.Create(value ?? $"{Slug()}-{Faker.Random.String2(6, LowercaseAlphabet)}"); public static Domain.Convention Convention( Domain.ConventionId? id = null, string? title = null, VerificationTier? tier = null, bool? enforcedByBuild = null) => Domain.Convention.Create( id ?? ConventionId(), title ?? Faker.Lorem.Sentence(4), tier ?? Faker.Random.Enum(), enforcedByBuild ?? Faker.Random.Bool()); } ``` Four rules make it work: - **Omitted arguments are generated; supplied arguments are used exactly.** So a test overrides only the value it is proving, and every other value is realistic without the test saying so. - **A collection factory comes with a companion.** `Conventions(int count = DefaultCount)` generates a distinct set, so a test that needs "some" gets "some" without a loop of its own. - **Bogus is a global `using`.** `Faker` is available with no import — which is also why an explicit `using Bogus;` in a shared testing project is reported as unnecessary. - **A value-object test is the one exception.** A test proving what `Create` and `Hydrate` accept and reject has to name the rejected input itself, so it constructs the invalid value directly. Everything valid still goes through the builder. ## Two constraints that shaped the builder Both were found by building it, and both are consequences of SDK rules rather than choices. **The factories are flat, not grouped.** The natural shape is a nested class per feature area (`TestBuilder.Catalogue.Convention(...)`). That cannot compile here: the SDK ships `dotnet_diagnostic.CA1034.severity = error`, so a publicly visible nested type is a build failure, and `PRSGD0008` refuses to let a repository add `CA1034` to `NoWarn`. Making the group `internal` does not help either, because the SDK's `InternalsVisibleTo` target runs on non-test projects and grants access *to* test and shared-testing projects rather than *from* them — a shared testing project's internals are not visible to the tests beside it. An area that outgrows a flat builder gets its own top-level builder class instead. **`var` is the house style, with one carve-out.** The SDK's `.editorconfig` sets `csharp_style_var_elsewhere = true:warning`, so an explicit type on a declaration initialised from a method call is a warning (`IDE0007`). The carve-out is object creation: the SDK's `PDS0003` analyzer prefers an explicit type with target-typed `new()` over `var`. Together those give: ```csharp var id = TestBuilder.ConventionId("build-enforced"); // method call -> var string title = new('a', Convention.MaxTitleLength); // new() -> explicit type ``` ## How it is enforced | Rule | Enforced by | | -- | -- | | TUnit is the only framework | The build — the SDK adds it; a second framework has to be added by hand | | Test projects are discovered and categorised | The build (name-suffix detection) | | A shared testing project is referenced, not executed | The build (`IsSharedTestingProject` handling) | | Style, including the `var` carve-out | The build (analyzers, at `warning` or above) | | AAA structure, naming, `CreateXXX` helpers, `TestBuilder` usage | Review | | A `CancellationToken` is passed through | Review | ## At your scale - **One test project.** TUnit, AAA, the naming convention, and `TestBuilder`. That is the whole of it. - **A shared fixture.** Move it into `SharedTestingInfra` and make the class public — a shared testing project is a library, so its fixtures are legitimately public API and `CA1515` does not apply. - **A database or a container.** [`Purview.Containers`](https://purview.dev/docs/containers/) is the organisation's answer: throwaway Linux containers behind one API, over either Microsoft WSL Containers (no Docker installation on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, Redis, MySQL, RabbitMQ, Azurite and NATS. Give the suite its own test project so the tier is expressed by the name, and make it skip itself when no backend is reachable. It is **experimental and prerelease** — the API, defaults and packaging rules can change between releases — so treat it as the direction of travel rather than a stability guarantee, and pin the version you adopt. - **A package with a documented contract.** Add the consumer-verification script described in [Engineering Principles](../engineering-principles/). ## See also - [Verification Model](../verification-model/) - [Architecture as Tests](../architecture-as-tests/) - [Worked Examples](../worked-examples/) - [build-sdk: Testing Wiring](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/Testing-Wiring.md) --- # Agent Enablement ## The rule A repository tells an agent what it is, how it is laid out, and what its conventions are — in the repository, in files an agent reads without being pointed at them. Four paths do the work: | Path | What it is | | -- | -- | | `AGENTS.md` | The repository-wide source of truth for agents. Purpose, authority, layout, conventions, local commands, completion checklist. | | `.github/copilot-instructions.md` | One job: defer to `AGENTS.md`. | | `skills/` | The skills this repository authors and publishes. Tracked. | | `.agents/skills/` | Skills installed from other repositories by the skills CLI. Gitignored; `skills-lock.json` is the tracked record. | The rule has a corollary that is easy to miss: **nothing important may be discoverable only by reading the code.** If the answer to "which of these two patterns is the intended one?" lives in the commit history or in a colleague's head, the repository is not legible, and an agent will pick the wrong one at a rate that looks like randomness. ## Why A coding agent has the repository and nothing else. It has no colleague to ask, no institutional memory, no sense of which of two plausible patterns the team settled on, and no way to tell a deliberate pattern from an accident. It will confidently generalise from whatever it finds first. This is not really an agent problem. Every failure mode above is also a new-joiner failure mode, and the guidance that fixes it is the same guidance: state the purpose, draw the layout, name the conventions, and write down the definition of done. The agent makes the cost visible sooner, because an agent does not ask. ## `AGENTS.md` It should answer, in this order: 1. **Purpose and authority.** What the repository contains, that this file is the source of truth, that a more specific `AGENTS.md` in a subtree takes precedence, and what the agent must not do (operate outside the repository, read or commit secrets). 2. **Source of truth and layout.** A table of path → purpose. This is the single highest-value section: it is what stops an agent inventing a folder. 3. **Conventions.** The rules that are not derivable from the code — test naming, where domain content comes from, which project owns which concern. 4. **Local commands.** The `just` recipes, and what each is for. 5. **Completion checklist.** What "done" means: what to update, what to run, and what to state about the validation that ran. Two properties matter more than completeness: - **It is a table of contents, not an encyclopedia.** Long guidance belongs in the wiki, where it is published and reviewed; `AGENTS.md` points at it. - **It is specific.** "Follow established patterns" is not guidance. "Domain content in a test comes from `TestBuilder`; never construct a domain type inline" is guidance. `AGENTS.md` and the wiki cross-reference each other deliberately. A rule that only an agent is told is a rule the humans have not written down, and a rule that only the wiki states is one the agent will not find. ## The `.agents/` folder ```text skills/ skills this repository authors, tracked in git └── / ├── SKILL.md the skill itself └── references/ optional deeper material the skill links to .agents/ └── skills/ skills installed from other repositories, by the skills CLI; gitignored └── / skills-lock.json the committed record of what the CLI installed ``` Note the split, and that it is a **single-writer** split. A skill you **author** lives at the repository root in `skills/`, where it is tracked and where a cloning installer can find it. A skill you **receive** from somewhere else arrives in `.agents/skills/`, written by the CLI and gitignored. The same folder cannot do both jobs. `.agents/skills/` is the CLI's universal install location, so a repository that also mirrored its own skills there would have two writers on one set of paths — and the build would silently overwrite a pinned install. Link to a skill by its **authored** path, `skills//SKILL.md`. A link into `.agents/skills/` points at a gitignored file and is broken in a fresh clone. A **skill** is a self-contained instruction set for one kind of task. It opens with YAML frontmatter whose `description` is the trigger — it is what tells an agent that this skill applies: ```markdown --- name: purview-persistence description: >- Use when a .NET project actually persists data and you are choosing a storage approach, deciding whether to abstract EF Core, or selecting a GUID primary-key strategy. --- ``` Writing a good trigger is most of writing a good skill. "Use when X, and you need Y" is far more useful than a topic label, because the agent is matching a situation, not searching an index. Prefer a skill to a prompt, and a skill to a shipped sub-agent definition. A prompt or a mirrored agent file is inert: it lands somewhere no agent reads natively, so somebody has to already know it exists. A skill carries a `description` an agent matches against the task, so it fires when it is relevant. Anything task-shaped and reusable belongs in `skills/`. And guidance about working *in this repository* belongs in `AGENTS.md`, not in a skill — a skill ships to somebody else. ## Getting somebody else's guidance Install it, explicitly: ```bash npx skills add purview-dev/build-sdk npx skills add purview-dev/engineering-playbook#v2.1.0 # pinned ``` The CLI writes into each agent's own directories and records the source and ref in `skills-lock.json`, which is committed. Everything it writes is gitignored, because it is all reproducible from the lockfile with `npx skills experimental_install`. > An earlier arrangement had the SDK mirror a package's agent content into `.agents/` on build. It > was removed in `Purview.BuildSdk` 1.0.6: it duplicated the CLI, wrote to the same paths, and made > an SDK upgrade capable of replacing a pinned install. > [Agent Skills Distribution](../agent-skills-distribution/) has the reasoning. The SDK still keeps its own state in `.purview/`, whose `.gitignore` is a single `*` — so a cache never appears in a status output. ## How it is enforced | Rule | Enforced by | | -- | -- | | An authored skill is well formed, correctly named, and its links resolve | `ValidatePurviewSkills` at pack time, and `PurviewSkillTests` on every test run | | An empty `skills/` folder is reported rather than passing vacuously | `PurviewSkillTests` discovery rule | | A build installs nothing into an agent directory | Test asserts no lockfile and no agent directory after a full build | | Installed skills are reproducible | `skills-lock.json`, committed; everything the CLI writes is gitignored | | The SDK's cache never appears as a change | `.purview/.gitignore` | | `AGENTS.md` exists, is accurate, and is specific | Review | | `copilot-instructions.md` defers to `AGENTS.md` | Review | ## At your scale - **Any repository.** Write `AGENTS.md`. It is the cheapest thing on this page and the one with the largest effect on an agent's first ten minutes. - **A repository with conventions worth repeating.** Add a skill. The test is whether you have written the same explanation twice in review comments. - **A repository others depend on.** Publish your skills from it, with a vendor-and-product name prefix, and validate them in the test suite so a broken one fails where it was written. See [Agent Skills Distribution](../agent-skills-distribution/). ## See also - [Agent Skills Distribution](../agent-skills-distribution/) - [Documentation Convention](../documentation-convention/) - [Repository Shape](../repository-shape/) - [Agent Skills specification](https://agentskills.io/specification) --- # Agent Skills Distribution ## The rule A skill is published **once**, at the repository root in `skills//SKILL.md`, and travels to its consumers by **one** route: the skills CLI clones the git repository and installs into each agent's own directories. **Installation is always explicit.** It is never a side effect of `dotnet restore`, `dotnet build`, a design-time build in an IDE, or a test run. Nothing packs a skill into a NuGet package. `Purview.BuildSdk` 1.0.6 removed both the packing and the build-time `.agents/` mirror that delivered it — see [Why one route](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki#why-one-route). ## Why A skill is guidance that goes stale against the code it describes. Three failures follow, and the design answers each one. **Guidance that does not travel with the code.** If a library's conventions live only in its own repository's wiki, every consumer re-derives them, and an agent in a consuming repository has no access to them at all. Publishing the skill from the library's repository means one `npx skills add` is enough. **Guidance that does not match the version in use.** A project pinned to an older release that receives the newest skill is told about APIs it does not have. The CLI installs from a **ref**, and records it, so a repository can pin a skill to the commit or tag that matches the version it uses. **An agent directory modified by a build.** A build that writes into `.claude/`, `.codex/` or a peer's equivalent has an invisible side effect: it mutates a developer's tooling, races between parallel projects, and makes a restore non-reproducible. Installation is a decision a person makes. ## Why one route There used to be two: the CLI, and a NuGet package whose `.agents/skills/` content the SDK mirrored into the consuming repository on build. The second was removed because it was **a second writer on the first one's paths**. The CLI's universal install location *is* `.agents/skills/`. So a build could silently overwrite a pinned install, and the SDK's own drift check would then compare a lockfile ref against content the mirror had already replaced. Duplication was not neutral here; it was a correctness problem. It also never closed the gap it appeared to close. `.agents/` is a convention, not a directory any agent reads natively — Claude Code, Codex, Copilot and the rest each look somewhere else. Mirroring a skill into `.agents/` made it *present*, not *active*. The CLI knows where each agent actually looks, and that knowledge changes on its own schedule, so it belongs in a tool that tracks it rather than in a build. ## Skill format The Agent Skills convention, unchanged: ```text skills/ └── purview-example/ ├── SKILL.md required; YAML frontmatter plus the main workflow ├── references/ deeper material the skill links to, read on demand ├── scripts/ deterministic operations └── assets/ templates and fixtures ``` `SKILL.md` opens with frontmatter whose `description` is the activation trigger: ```markdown --- name: purview-persistence description: >- Use when a .NET project actually persists data and you are choosing a storage approach... --- ``` The specification defines six fields and no more: `name`, `description`, `license`, `compatibility`, `metadata` and `allowed-tools`. Anything else belongs under `metadata`. `name` is 1–64 characters of lowercase `a-z0-9` and single hyphens, and **must match the folder name** — the CLI installs by the frontmatter name while a human reads the folder, so a mismatch installs under a name nobody expects. Use progressive disclosure. The frontmatter says *when* the skill applies; `SKILL.md` carries the workflow; `references/` holds the detail only some tasks need. A skill that inlines everything costs context on every activation, whether or not the detail was relevant — keep `SKILL.md` under 500 lines. Link to the authoritative documentation rather than restating it. A skill that paraphrases a library's own wiki is a second source of truth, and it is the one that will drift. **Never link from one skill into another skill's folder.** Only a skill's own folder is installed with it, so a relative path that escapes it works in the authoring repository and nowhere else. Name the other skill in prose. ## Naming Skills install into **one flat directory per agent**, shared with every skill a developer has installed from anywhere. `sdk-configuration`, `project-placement`, `dotnet-tunit` and `git-conventional-commits` are all names somebody else will also pick, and the loser of a collision is whichever installed second. So every published skill carries a vendor-and-product prefix: `purview-value-objects-efcore`, `purview-sourcegenerator-codewriter`, `purview-telemetry-diagnostics`. The prefix is not decoration; it is the only thing keeping two unrelated skills from being the same file. ## Publication and distribution **Diagram J — skill publication and distribution.** ```text flowchart TD A["skills/<name>/SKILL.md
the single authored copy"] A --> G["git: tracked at the repository root"] G --> C["skills CLI
clone at ref"] C --> AD["Agent directories
.claude/, .codex/, ..."] C --> L["skills-lock.json
source + ref, committed"] ``` The CLI discovers skills **only** in a root `skills/` folder. It does not look in nested or dotted folders, even with `--full-depth`. `skills-lock.json` is the committed record of what a repository installed, so the same set can be restored later with `npx skills experimental_install`. Everything the CLI writes under `.agents/skills/` and the per-agent directories is gitignored — it is all reproducible from the lockfile. ## Responsibilities The separation matters more than any single piece of it. ```text flowchart LR subgraph Repo["The authoring repository"] S["skills/<name>/SKILL.md"] V["ValidatePurviewSkills (pack-time)
PurviewSkillTests (test-time)"] end subgraph SDK["Purview.BuildSdk"] T["Explicit targets
ListPurviewSkills
InstallPurviewSkills"] D["CLI detection
DetectPurviewSkillsCli"] R["Repository-root resolution
FindPurviewRepositoryRoot"] end subgraph CLI["Skill installer - npx skills"] I1["Agent-specific destinations"] I2["symlink or copy"] I3["skills-lock.json"] end S --> V R --> T D --> T T -->|"invokes, never installs itself"| CLI ``` The SDK keeps no copy of a skill per supported agent. Agent discovery paths differ, are numerous, and change on their own schedule; the CLI tracks them, so the SDK does not have to. Two invariants govern how the SDK launches it: - **Any package runner, defaulting to `npx`.** `PurviewSkillsRunner` selects one (`npx`, `bunx`, `bun x`, `pnpm dlx`, `yarn dlx`, `deno run -A npm:`); an unknown runner falls through to ` skills`. Detection probes `PATH` for `skills` first, then the configured runner, then the candidates — honouring `PATHEXT` on Windows. `PATH` is read directly rather than by spawning a version check: deterministic, no process, and it cannot hang on a cold download. - **Always the repository root.** `skills-lock.json` and the agent directories belong to the repository, not to a project, so every skill target runs from one resolved root. There is deliberately no project-directory fallback — that would scatter a lockfile and an agent directory through the tree, one per project. ## How it is enforced | Rule | Enforced by | | -- | -- | | A skill's frontmatter is valid, its name matches its folder and is unique | `ValidatePurviewSkills` before `Pack` (`PRSKL0005`) | | The same rules hold on every test run | `PurviewSkillTests` from `Purview.Skills.Testing`, inherited by one derived class per repository | | Every `references/...` link in a skill resolves, and resolves *inside* the skill | Both of the above | | `SKILL.md` stays within the recommended size | `PurviewSkillTests` | | An empty `skills/` folder is reported rather than passing vacuously | `PurviewSkillTests` discovery rule | | An ordinary restore-and-build installs nothing | Test asserts no lockfile and no agent directory after a full build | | Repeated installation is idempotent | The CLI skips a skill already installed from the same source and ref | | The guidance in a skill is good, and an agent follows it | **Review** | The last row is a review obligation and stays one. A build can check that a skill is well formed; it cannot check that prose is correct or that an agent obeyed it. See [Agent Guidance Skills](../agent-guidance-skills/) for the scenarios used to review that by hand. **`[InheritsTests]` is load-bearing** on the derived test class. TUnit does not walk base types during discovery, so without it the class compiles, the run is green, and nothing is validated — the worst failure this arrangement could have. ## Using it ```bash npx skills add purview-dev/engineering-playbook # install npx skills add purview-dev/engineering-playbook#v2.1.0 # pinned npx skills list # what is installed npx skills update # newest published npx skills remove npx skills experimental_install # restore from skills-lock.json ``` Through the SDK, so a repository exercises the path it ships to consumers: ```bash dotnet msbuild -t:ListPurviewSkills # diagnostics; installs nothing dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/engineering-playbook ``` | Property | Default | Purpose | | -- | -- | -- | | `PurviewSkillsSource` | *(empty)* | Install from an explicit git source; append `#` to pin | | `PurviewSkillsAgents` | `*` | Which agents to install to | | `PurviewSkillsRunner` | `npx` | Which package runner launches the CLI | | `PurviewSkillsCommand` | *(detected)* | Overrides the launcher entirely, skipping detection | | `PurviewSkillsInstallRoot` | *(resolved)* | Forces the repository root | ### One repository, several skills A repository publishes the whole of its `skills/` folder — there is no per-project subset any more, because there is no package to subset. Scope is expressed in the **names**: ```text skills/ ├── purview-results-core ├── purview-results-union-errors ├── purview-results-http-mapping ├── purview-results-zodsharp-validation └── purview-results-zodsharp-problems ``` Guidance about working *in this repository* — its test runner, its commit conventions — does not belong in `skills/` at all. A skill here ships to somebody else; put repository-local guidance in `AGENTS.md`. ### Troubleshooting | Symptom | Cause | | -- | -- | | `PRSKL0004` | Neither `skills` nor a package runner is on `PATH`. Install Node.js, or `npm install -g skills`. `ListPurviewSkills` still works without it. | | `PRSKL0005` | A skill is malformed: no `SKILL.md`, no frontmatter, no `name`/`description`, a name that does not match its folder, or a broken `references/` link. | | `PRSKL0003` | The repository root could not be resolved for an install. Pass `PurviewSkillsInstallRoot`. | | "No skills found" from the CLI | The source repository has no **root** `skills/` folder. Nested and dotted folders are not discovered. | | A skill installed but the agent ignores it | Check the `description`. It is the trigger, and a topic label does not match a situation. | | `PRSKL0010`–`PRSKL0014` | Recommendation diagnostics raised against packages built by an older SDK that still carried `.agents/skills/` metadata. | ## Publishing skills for a new library 1. Author the skill at the repository root: `skills//SKILL.md`. 2. Give it a vendor-and-product prefix. 3. Add `[InheritsTests] public sealed class SkillTests : PurviewSkillTests;` to a test project referencing `Purview.Skills.Testing`, so the rule set runs on every build. 4. Verify with `npx skills add / -l` — if it reports no skills, the folder is in the wrong place. ## At your scale - **One repository, one agent.** You do not need any of this. Put `SKILL.md` files in `skills/` and run `npx skills add .` when they change. The format is the part worth adopting first. - **Several repositories sharing conventions.** Publish from the repository that owns the convention and install with the CLI. Pin the ref; commit `skills-lock.json`. - **A library with consumers.** Same mechanism, plus a named prefix and the conformance test, so a broken skill fails in the repository that owns it rather than in somebody else's agent. - **A platform with many repositories.** The thing to copy is the **separation**: authoring and validation in the owning repository, installation in a tool that owns agent paths, and exactly one writer per path. ## See also - [Agent Enablement](../agent-enablement/) — `AGENTS.md`, `.agents/`, and what makes a good skill - [Documentation Convention](../documentation-convention/) - [skills CLI](https://github.com/vercel-labs/skills) - [Agent Skills specification](https://agentskills.io/specification) --- # Agent Guidance Skills ## The rule The skills this repository publishes carry the guidance that is not specific to any one Purview library, and they are the only place that guidance is written: | Skill | Answers | | -- | -- | | `purview-engineering-principles` | What are the ten repository conventions, what does each prevent, and is it enforced by a build or by a reviewer? | | `purview-architecture` | How much structure does this project actually need, and what do I need to know before saying? | | `purview-architecture-testing` | How does a boundary become a failing build rather than a review comment? | | `purview-testing-conventions` | How are tests written and fed — framework, structure, naming, and `TestBuilder`? | | `purview-telemetry` | What instrumentation does a project of this size get? (Answer: always some.) | | `purview-persistence` | How is data stored, and — only when it applies — how are GUID keys generated? | Two obligations fall out of that, and both are enforced only by review: - **Context before recommendation.** An agent must establish project complexity, project type, persistence and telemetry before making a decision that depends on them — and must read those from the repository rather than asking. - **Guidance is written once.** A library skill describes its library. It does not restate the architecture, telemetry or persistence advice; it links to it. ## Why A coding agent will give an answer whether or not it has the context to give a good one. Asked to "add a service", it will produce whatever shape its training makes most available — which is usually the shape of a large enterprise codebase, because that is what gets written about. The result is a four-project Clean Architecture solution with a repository interface and a mapping layer, wrapped around one table and one caller. Nothing in it is wrong in isolation. All of it is cost that will be paid at every future change, for a failure that will never happen. The opposite failure is quieter and more common: telemetry deferred because the application is "small", and then never added, because by the time it is needed the code has no seams for it. And the third failure is irrelevant advice — asking a stateless CLI which database GUID strategy it wants. That trains the user to stop reading what the agent says. ## Project-context discovery **Diagram L — project-context discovery and skill activation.** ```text flowchart TD A["Request touching architecture
or substantial code generation"] --> B["Read the repository"] B --> C["Complexity: small / medium / large
from requirements, not file count"] B --> D["Project type
SDK attribute, OutputType, AppHost"] B --> E["Persistence
provider package, DbContext, connection string"] B --> F["Telemetry
ILogger, ActivitySource, AddOpenTelemetry"] C --> G{"Any dimension still
unknown AND material?"} D --> G E --> G F --> G G -- no --> H["Decide - ask nothing"] G -- yes --> I["One concise batched question
with a stated default"] I --> H H --> J["purview-architecture
always"] H --> K["purview-telemetry
always, at every size"] E --> L{"Persistence real?"} L -- yes --> M["purview-persistence"] L -- no --> N["purview-persistence stays silent"] ``` The branch that matters most is `G`. A dimension answered by a project file, existing source, or an earlier turn is answered; re-asking it is the single most common way this guidance is applied badly. ## Telemetry Instrumentation is constant across project sizes. What scales is the export and operations story. Conflating the two is what produces "we are too small for telemetry". **Diagram M — telemetry recommendation workflow.** ```text flowchart TD A["Project needs telemetry advice"] --> B["Inspect existing instrumentation"] B --> C{"Telemetry already
configured?"} C -- yes --> D["Preserve and extend
never duplicate a registration"] C -- no --> E{"Reusable library
or application host?"} E -- library --> F["ILogger, ActivitySource, Meter
publish source names
NEVER configure a global provider"] E -- host --> G{"Project size?"} G -- small --> H["Console or single OTLP endpoint
no collector, no dashboards"] G -- medium --> I["OTLP, dependency instrumentation
trace propagation, metrics, health checks"] G -- large --> J["End-to-end tracing, resource attributes
dashboards, alerting, SLIs, sampling policy"] D --> K["Recommend Purview.Telemetry.SourceGenerator
where the TFM is compatible"] F --> K H --> K I --> K J --> K ``` Note that no path reaches "no telemetry". The small branch reaches *small* telemetry. The library branch is the one most often got wrong: a library that calls `AddOpenTelemetry` breaks every consumer that already configured a provider, and wins or loses silently depending on initialisation order. ## Persistence and GUID keys This guidance activates only when persistence is real and GUIDs are keys. For a stateless CLI or a pure library, the correct output is silence. **Diagram N — persistence-specific GUID strategy selection.** ```text flowchart TD A["Persistence confirmed?"] -- no --> Z["Out of scope - ask nothing"] A -- yes --> B{"GUIDs used as
primary keys?"} B -- no --> Z B -- yes --> C{"Identifier needed
before SaveChanges?"} C -- yes --> D["Application must generate it"] C -- no --> E["Database may generate it"] D --> F{"Provider?"} E --> G{"Provider?"} F -- "SQL Server" --> H["EF Core SequentialGuidValueGenerator"] F -- "PostgreSQL" --> I["Guid.CreateVersion7 on .NET 9+
ValueGeneratedNever"] G -- "SQL Server" --> J["NEWSEQUENTIALID()"] G -- "PostgreSQL" --> K{"Server version 18+?"} K -- yes --> L["uuidv7()"] K -- no --> M["gen_random_uuid(), or generate v7 in the app"] H --> N{"Must be
unpredictable?"} I --> N J --> N L --> N M --> N N -- yes --> O["Random v4 - ordered keys leak creation time"] N -- no --> P["Keep the ordered choice"] ``` **The two providers do not share an answer.** SQL Server compares `uniqueidentifier` with the last six bytes most significant, so a byte-ordered UUIDv7 is effectively random to its clustered index. PostgreSQL compares `uuid` in byte order, so UUIDv7 is genuinely sequential there. A strategy carried across without re-deriving it is the failure this diagram exists to prevent. There is no Purview GUID generation package. Prefer a framework or database capability; a custom generator is a correctness risk — endianness, clock regression, counter rollover and thread safety are all live hazards, and the failure shows up as duplicate keys in production. ## How it is enforced | Rule | Enforced by | | -- | -- | | Every `SKILL.md` has valid frontmatter, a unique name matching its folder, and existing references | `ValidatePurviewSkills` (fails the pack) | | A skill is reachable without loading every skill | The `description` frontmatter is the activation trigger | | Context is established before a recommendation | **Review** | | Telemetry is recommended at every project size | **Review** | | GUID guidance stays silent without persistence | **Review** | The last three are review obligations and this page does not pretend otherwise. A build can check that a skill is well-formed; it cannot check that an agent followed prose. See [Validation scenarios](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki#validation-scenarios) for how they are reviewed instead. ## Validation scenarios These are the scenarios used to review the guidance by hand. They are **not** automated tests, and passing them does not prove an agent will always comply — it demonstrates the guidance says the right thing when read. | Scenario | Expected | | -- | -- | | "Add a command to this CLI" (no persistence) | No new projects, no repository, no DI indirection. Telemetry recommended. **No GUID question asked.** | | "Build a small HTTP API" | Single project. Structured logging and an `ActivitySource` from the first commit. Console or one OTLP endpoint — no collector, no dashboards. | | "Add telemetry to this reusable library" | `ILogger` / `ActivitySource` / `Meter`, names published for the host. **No `AddOpenTelemetry`, no exporter, no backend requirement.** | | "Use GUID keys on SQL Server, high write volume" | `NEWSEQUENTIALID()`, or `SequentialGuidValueGenerator` if the value is needed before insert. **Not** UUIDv7. | | "Use GUID keys on PostgreSQL, high write volume" | `Guid.CreateVersion7()` with `ValueGeneratedNever()`. `uuidv7()` only if the server is 18+. | | Repository already has `AddOpenTelemetry` | Existing setup extended, not replaced. No duplicate registration. | | "Should I add a repository over EF Core?" | Only with a named reason other than testability — `Purview.Containers` gives a real database in tests. | | Project file already states the type and persistence | No questions asked at all. | | "Scaffold a modular monolith with GitHub and Azure DevOps integrations" | `Providers.GitHub` and `Providers.AzureDevOps`, independent of each other. Shared code goes to an abstraction or the unqualified project, never sibling-to-sibling. | | "Distributed app, SQL Server and PostgreSQL" | `Persistence.SqlServer` and `Persistence.PostgreSql`. An unqualified `Persistence` **only** if there is genuinely shared mapping code. GUID guidance differs per provider. | | "Add a second adapter for file storage" | Same project as the first if dependencies and lifecycle match; a separate `Adapters.*` project only if they diverge. | | Repository names a project `Infrastructure` | Flag the catch-all risk, but **do not propose a rename**. Fix wrong dependency directions; record a waiver for the name. | ## At your scale - **Any project.** The context habit is the whole value: read the repository before recommending. It costs nothing and removes most bad answers. - **A repository with conventions worth repeating.** Write the architecture guidance down once and link to it, rather than restating it in each library's skill. - **A platform with several libraries.** Keep shared guidance in shared skills, as here, so a change to the telemetry position is one edit rather than one per library. ## See also - [Agent Skills Distribution](../agent-skills-distribution/) — how these skills reach a consumer - [Agent Enablement](../agent-enablement/) - [Engineering Principles](../engineering-principles/) - [Adopting This at Your Scale](../adopting-this-at-your-scale/) --- # Documentation Convention ## The rule The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its `mkdocs.yml`, its navigation and its `edit_uri`. | Path | Purpose | | -- | -- | | `docs/wiki/index.md` | The landing page. Short, with links in. | | `docs/wiki/Home.md` | The hub: what the project is, how to read a page, and a table linking every page. | | `docs/wiki/_Sidebar.md` | The navigation list. The leading underscore keeps it out of the content listing. | | `docs/wiki/.md` | One page per concern. Title case, hyphens between words. | | `mkdocs.yml` | Site configuration; `docs_dir: docs/wiki`. | | `README.md` | The repository's front door on GitHub. Points into the wiki; does not duplicate it. | | `AGENTS.md` | The agent's front door. Points into the wiki; does not duplicate it. | ## Why **Ownership.** Documentation that lives outside the repository it describes is updated outside it. A page with no owner and no review path is wrong within one release, and nobody can tell when it happened. **Discoverability has two halves.** `_Sidebar.md` is what the aggregating site builds its navigation from, and `Home.md` is what a reader lands on. A page in neither is a page that exists only for people who already know its URL — which is to say, for nobody. **Three front doors, three audiences.** `README.md` is for someone deciding whether to use the thing. `Home.md` is for someone who has decided and needs to know where to look. `AGENTS.md` is for an agent that needs the layout and the conventions. They link to the same pages and should not restate them; a fact stated twice is a fact that will disagree with itself. ## `mkdocs.yml` ```yaml site_name: Acme site_description: Developer documentation for Acme repo_url: https://github.com/acme/acme edit_uri: edit/main/docs/wiki/ docs_dir: docs/wiki theme: name: material palette: - media: "(prefers-color-scheme: light)" scheme: default primary: indigo accent: cyan - media: "(prefers-color-scheme: dark)" scheme: slate primary: indigo accent: cyan features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] plugins: [search, techdocs-core] markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] ``` Three of those lines are load-bearing: - **`edit_uri`.** It is the "edit this page" link, and it is the only mechanism that turns a reader into a contributor. Get the branch wrong and the link 404s. - **`docs_dir: docs/wiki`.** The wiki is a folder inside the repository rather than the repository root, so the root stays available for the things that belong there. - **`plugins: [search, techdocs-core]`.** `techdocs-core` is what the aggregating portal expects. Without it the site builds and the portal does not pick the pages up. > **Mermaid is not enabled.** `pymdownx.superfences` is configured without a Mermaid custom fence, so a > ` ```mermaid ` block renders as unstyled text. Raw Mermaid source in a ` ```text ` block is the honest > workaround: it is copy-pasteable into any renderer, and it does not pretend to be a diagram. ## How to write a page Every content page uses the same four sections, in this order: | Section | Answers | | -- | -- | | **The rule** | What is expected. | | **Why** | The failure the rule prevents. | | **How it is enforced** | The mechanism that makes it true — or an explicit statement that it is a review obligation. | | **At your scale** | What to do when the repository is smaller or larger than the one the rule came from. | Three pages are deliberately not content pages. `Home.md` is the hub and `index.md` is the landing page, so both are navigation rather than rules. `Engineering-Principles.md` is an index: it states each of the ten rules in one line and links to the page that states it in full, so a rule is never written twice. The shape is not decoration. It forces three questions that a page written as prose usually skips: *is this actually a rule, or an opinion?*; *what breaks if we drop it?*; and *is it enforced, or are we asking a reviewer to remember?* The third section is the one that earns the most. Labelling something a review obligation is uncomfortable, and that discomfort is the point: it is how a team discovers that half its "conventions" are aspirations. A rule that claims to be enforced when it is not is worse than no rule, because people rely on the guarantee and stop looking. The fourth section is what makes a playbook reusable. A rule that only makes sense at one size is a rule that gets cargo-culted at another. Naming the smaller version explicitly — *"one tier is fine until the slow tests exist"* — is what lets a reader take the part that fits. ## Two habits that keep it accurate **Link to the authority instead of paraphrasing it.** The SDK's own wiki is the authority on what the SDK does. A paraphrase in this playbook is a second source of truth, and the two will drift. Where a rule is the SDK's, link to it; where a rule is a consequence of the SDK's behaviour that is not written down anywhere else, state it here and say why. **Say which repository the evidence came from.** Every rule in this playbook came from a real repository and a real failure. Naming the repository turns a claim into something a reader can check against running code, and it is the fastest way for a reader to decide whether the rule applies to them. ## Writing style - **ATX headings (`##`), one `#` per page.** The `#` is the page title; the navigation already knows it. - **Code fences always declare a language.** ` ```csharp `, ` ```xml `, ` ```json `, ` ```yaml `, ` ```text ` for output and diagrams. An unlabelled fence renders as a plain block and loses highlighting. - **Tables for anything enumerable.** A rule list, an input list or a mapping reads better as a table than as five paragraphs, and it survives editing. - **Short lines.** A page is read in a diff as often as in a browser. Wrapping at roughly 110 columns keeps a documentation change reviewable. - **British spelling in prose, exact spelling in identifiers.** `normalise` in a sentence, `Normalize` if that is the API's name. Never "fix" a name to match the prose. ## How it is enforced | Rule | Enforced by | | -- | -- | | The site configuration is present and points at `docs/wiki` | Review | | Every content page is in `_Sidebar.md` and the `Home.md` table, and their links resolve | `DocumentationTests` | | Pages follow the four-section shape | Review | | Fences declare a language | `DocumentationTests` | | `README.md` and `AGENTS.md` point in rather than duplicate | Review | The navigation and the fences are the two conventions a machine can check, and they are the two that break silently: a page nobody can reach is a page that does not exist, and a bare fence renders as an untyped block. Both are enforced by `DocumentationTests` in `src/tests/Repository.UnitTests`. The rest is a review obligation, and this page says so rather than implying otherwise. ## At your scale - **One page.** `docs/wiki/index.md` plus `Home.md` and `_Sidebar.md`, and a `mkdocs.yml`. Three small files. - **A library.** One page per public concern, plus a getting-started page that is the shortest path to a working example. Keep the consumer contract on its own page — it is the page that must not drift. - **A platform.** Add a page per subsystem, and keep a single hub that links them all. The hub is what stops a wiki becoming a search problem. - **Several repositories.** Keep each wiki with its code, and let one site aggregate them. The aggregation is a build concern; ownership stays with the repository. ## See also - [Home](../) - [Agent Enablement](../agent-enablement/) - [Contributing](../contributing/) --- # Worked Examples ## The rule `src/` in this repository is a deliberately small layered repository that is built, linted and tested by the same pipeline every Purview repository uses. It exists so that the rules in this playbook are not just prose: the dependency-direction rules fail the build when they are broken, and the test-data conventions are shown in the form they are meant to be copied. It is small on purpose. There is one aggregate, one port, one adapter and four test projects — enough to show every mechanism, and little enough that you can read all of it in ten minutes. ## Why A rule you have not watched fail is a rule you are guessing about. Prose can be read and agreed with; a reference implementation can be built, run, and broken on purpose to see the failure. It is also the one place this playbook can prove that its own conventions compile and pass — the pipeline that runs here is the pipeline every Purview repository uses, so a convention that cannot survive its own reference implementation is a convention that will not survive contact with a real one. ## The shape ```text src/ ├── EngineeringPlaybook.slnx ├── Directory.Build.props NamespacePrefix + SDK import + NamespaceRemoveSuffix ├── Directory.Build.targets ├── src/ │ ├── Domain/ Purview.EngineeringPlaybook.Domain │ │ ├── ConventionId.cs a value object: Create validates, Hydrate trusts │ │ ├── VerificationTier.cs the tier a convention is proved in │ │ └── Convention.cs the aggregate │ ├── Application/ Purview.EngineeringPlaybook.Application │ │ ├── IConventionRepository.cs the port — owned here, not beside its implementation │ │ └── ConventionCatalogue.cs the service │ └── Infrastructure/ Purview.EngineeringPlaybook.Infrastructure │ └── InMemoryConventionRepository.cs └── tests/ ├── SharedTestingInfra/ TestBuilder ├── Domain.UnitTests/ value-object behaviour, direct construction ├── Application.UnitTests/ the service, against a TUnit.Mocks substitute ├── Architecture.UnitTests/ the dependency-direction rules, and the architecture policy └── Repository.UnitTests/ the repository itself: its skills and its wiki ``` ## The one architectural decision Everything else follows from this: **the port lives in the layer that needs it.** ```csharp // Purview.EngineeringPlaybook.Application public interface IConventionRepository { Task ExistsAsync(ConventionId id, CancellationToken cancellationToken); Task AddAsync(Convention convention, CancellationToken cancellationToken); Task> ListAsync(CancellationToken cancellationToken); } ``` The interface is in `Application`, and the class that implements it is in `Infrastructure`. That is what makes the dependency direction one-way and assertable: - `Application` references `Domain` only. It names `IConventionRepository`, which is its own type. - `Infrastructure` references `Application`, because it implements that type. - `Domain` references nothing of ours, and has no idea a repository exists. The alternative — the interface beside the implementation, in `Infrastructure` — reverses the dependency: `Application` would have to reference `Infrastructure` to name the port, and the innermost layer would depend on the outermost. The architecture tests exist to make that mistake fail rather than be debated. ## The domain `ConventionId` is the value object, hand-written here so the pattern is visible without a source generator in the way: ```csharp public sealed record ConventionId { public const int MaxLength = 64; ConventionId(string value) => Value = value; public string Value { get; } public static ConventionId Create(string value) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("A convention identifier cannot be empty.", nameof(value)); } // ... length, leading/trailing hyphen, and per-character checks ... return new ConventionId(value); } public static ConventionId Hydrate(string value) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("A hydrated convention identifier cannot be empty.", nameof(value)); } return new ConventionId(value); } public override string ToString() => Value; } ``` Three details are deliberate: - **The constructor is private.** The only doors are `Create` and `Hydrate`, so there is no path into the type that skips the rules. - **`Hydrate` is not `Create` with a flag.** It rejects only the value that cannot possibly be valid — null or whitespace — and trusts everything else. That is the whole contract: a hydrated value already came through a trusted boundary, and re-validating it would turn a data problem into a crash somewhere unrelated. - **It is a `record`.** Equality is by value, which is what makes it usable as a dictionary key and in an assertion. `Convention` adds the aggregate rules — a title that is non-empty, trimmed, and no longer than `MaxTitleLength` — and carries `EnforcedByBuild`, which is the playbook's own distinction between a rule the build refuses to let you break and a rule a reviewer has to notice. ## The application layer `ConventionCatalogue` holds the rules and nothing else: ```csharp public sealed class ConventionCatalogue { readonly IConventionRepository _repository; public ConventionCatalogue(IConventionRepository repository) => _repository = repository ?? throw new ArgumentNullException(nameof(repository)); public async Task RegisterAsync(Convention convention, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(convention); var alreadyRegistered = await _repository .ExistsAsync(convention.Id, cancellationToken) .ConfigureAwait(false); if (alreadyRegistered) { throw new InvalidOperationException($"Convention '{convention.Id}' is already registered."); } await _repository.AddAsync(convention, cancellationToken).ConfigureAwait(false); } } ``` Note the absence of a database, an ORM, a logger and a configuration object. The class depends on one interface, which is why its tests need no infrastructure — and why the architecture rules can be strict without being annoying. ## The adapter ```csharp public sealed class InMemoryConventionRepository : IConventionRepository { readonly ConcurrentDictionary _conventions = new(StringComparer.Ordinal); public Task ExistsAsync(ConventionId id, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(id); cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(_conventions.ContainsKey(id.Value)); } // ... } ``` It exists so the reference implementation has a real adapter on the far side of the port without dragging in a database. The point being demonstrated is the shape — an adapter implementing a port owned by the layer above it — not the storage. It also demonstrates a detail that is easy to get wrong: **an interface implementation keeps `public`.** The SDK's style policy is `omit_if_default`, so a private field has no modifier, but a member that satisfies an interface is explicitly `public` because `public` is not the default there. ## The tests Four projects, each demonstrating a different thing. **`Domain.UnitTests`** is the value-object exception to the "build everything through `TestBuilder`" rule. A test that proves what `Create` rejects has to name the rejected input itself: ```csharp [Test] [Arguments("")] [Arguments(" ")] [Arguments("Build-Enforced")] [Arguments("build_enforced")] [Arguments("-leading-hyphen")] [Arguments("trailing-hyphen-")] [Arguments("has a space")] [Arguments("has.a.dot")] public async Task Create_GivenSlugThatIsNotALowercaseSlug_Throws(string value) { // Arrange // Act Action act = () => ConventionId.Create(value); // Assert await Assert.That(act).Throws(); } ``` Valid values still come from the builder. The exception is about *invalid* input, where the literal is the point of the test. **`Application.UnitTests`** uses a TUnit.Mocks substitute, and asserts on the interaction rather than on state: ```csharp [Test] public async Task RegisterAsync_GivenAnAlreadyRegisteredConvention_DoesNotStoreIt(CancellationToken cancellationToken) { // Arrange var repository = CreateRepository(); repository.ExistsAsync(Any(), Any()).Returns(true); var catalogue = CreateCatalogue(repository.Object); // Act await Assert .That(async () => await catalogue.RegisterAsync( TestBuilder.EnforcedConvention(), cancellationToken)) .Throws(); // Assert repository.AddAsync(Any(), Any()).WasNeverCalled(); } ``` **`Architecture.UnitTests`** runs two kinds of rule. `DependencyDirectionTests` is the four rules from [Architecture as Tests](../architecture-as-tests/), reading the compiled assemblies; it is the reason the `Application` project file carries a comment explaining that a reference to `Infrastructure` would fail the build. `ArchitectureTests` derives from `PurviewArchitectureTests` and enforces the policy in `.config/architecture-policy.json`, so the dependency matrix, naming, sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules come from `Purview.Architecture.Testing` rather than from C# written here. `PolicyDocumentationTests` keeps the wiki's tables in step with that policy. **`Repository.UnitTests`** turns the repository itself into the subject. `SkillTests` derives from the skill-conformance rule set the SDK ships, and `DocumentationTests` asserts that every wiki page is in the navigation and that every fence declares a language. A convention that has to hold across the whole repository, and that a reviewer would otherwise have to remember, belongs in a test here. ## The test data builder `TestBuilder` is flat rather than grouped, and [Testing and Test Data](../testing-and-test-data/#two-constraints-that-shaped-the-builder) explains why. The contract is the interesting part: ```csharp public static Domain.Convention Convention( Domain.ConventionId? id = null, string? title = null, VerificationTier? tier = null, bool? enforcedByBuild = null) => Domain.Convention.Create( id ?? ConventionId(), title ?? Faker.Lorem.Sentence(4), tier ?? Faker.Random.Enum(), enforcedByBuild ?? Faker.Random.Bool()); ``` An omitted argument is generated; a supplied argument is used exactly. A test therefore reads as a list of the values it actually cares about, and everything else is realistic without the test saying so. ## How it is enforced The reference implementation is built, linted and tested by the same pipeline every Purview repository uses, so a broken convention fails here first. ```text just build # dotnet build (Debug) just test # dotnet test just lint-check # CSharpier check just pipeline-pr # restore, build, lint, test through the shared Purview.Build tool ``` On a clean checkout of this repository those give: ```text Build succeeded. 0 Warning(s) 0 Error(s) Test run summary: Passed! total: 71 failed: 0 succeeded: 71 ``` The zero-warning result is worth noticing, because it took work: the telemetry source generator's public types, the SDK's `var` preference, and the flat-factory constraint all surfaced as build diagnostics rather than as documentation. Each is written down on the page where it belongs. ### Break it on purpose The fastest way to understand a rule is to watch it fail. Three edits, each of which should fail the build: 1. **Add `` to `Application.csproj`.** `Application_GivenItsReferences_DependsOnTheDomainAndNotOnInfrastructure` fails. 2. **Rename `src/src/Domain/Domain.csproj` to `DomainAggregate.csproj`.** `PurviewProjectFileNameMismatch` fails, because the project filename no longer matches its directory. 3. **Move a `Convention` type into a namespace outside the repository's prefix, such as `Acme.Elsewhere`.** The namespace-identity rule fails and names the type. ## What this repository deliberately does not do - **No package.** It ships no NuGet package, so there is no pack metadata, no pack validation manifest and no consumer-verification script. Those mechanisms are documented, and [containers](https://github.com/purview-dev/containers) is the reference for them. - **No persistence.** The adapter is in-memory. There is no migration, no schema, no connection string. - **No web layer.** The result-to-response mapping in [Contracts and Data Exchange](../contracts-and-data-exchange/) is described, not implemented, because [results](https://github.com/purview-dev/results) already implements it and a second implementation here would be a worse copy. - **No integration tier.** Every test is deterministic, so the pipeline needs no second job. ## At your scale This is the **Medium** profile: a few layers, a shared testing project, and architecture tests. A small repository takes the SDK import, `Directory.Packages.props`, `AGENTS.md` and `TestBuilder` and stops there; a large one adds pack validation, consumer verification and a release gate. [Adopting This at Your Scale](../adopting-this-at-your-scale/) is the page that decides. ## See also - [Getting Started](../getting-started/) - [Architecture as Tests](../architecture-as-tests/) - [Testing and Test Data](../testing-and-test-data/) - [Contracts and Data Exchange](../contracts-and-data-exchange/) --- # Adopting This at Your Scale ## The rule Every rule in this playbook came from a repository that needed it. That is the argument for the rule and also the reason not to take all of them: a rule adopted before the problem it prevents exists is pure cost. Find the profile that matches, take what it lists, and leave the rest — with the knowledge that the rest is there when the repository grows into it. ## Why Structure is a cost paid now against a failure expected later. A boundary added in anticipation of a second caller that never arrives is maintained forever; a boundary added when the second caller appears is cheap. The same is true of a CI tier, a pack manifest or an agent skill: taken early it is ceremony, taken late it is a migration. Naming the smaller version explicitly is what lets a reader take the part that fits instead of cargo-culting the whole playbook. ## How it is enforced This page is guidance rather than a rule, so nothing here is enforced by a build. The rules it chooses between are enforced as their own pages say; what this page adds is the decision about which of them to adopt, and that decision is a review obligation. ## At your scale ### The three profiles | | Small | Medium | Large | | -- | -- | -- | -- | | **Shape** | One project | A few layers or modules | Several packages | | **Repository** | `src/src/`, one test project | `src/src/*` + `src/tests/*` + a shared testing project | As medium, plus `Sdk/` assets per package and `samples/` | | **SDK** | Yes | Yes | Yes | | **Test data builder** | Yes, from the first test | Yes | Yes | | **Architecture tests** | No | Yes | Yes, and promoted into the build | | **CI tiers** | One | Two (`Unit`, plus one slow tier) | Several, with a release gate | | **Pack validation** | n/a | Only if it packs | Yes, exhaustively | | **Consumer verification** | No | No | Yes | | **Wiki** | `index`, `Home`, `_Sidebar`, one page | One page per public concern | One page per subsystem, plus a hub | | **`AGENTS.md`** | Yes | Yes | Yes | | **Agent skills** | Install what you need with `npx skills add` | Plus one per repeated review comment | Plus your own, published from the repository that owns the convention | ### Small — one project Adopt: - The SDK import in `Directory.Build.props` and a pinned `global.json`. This is the single highest-value change in the whole playbook: it removes the target framework, language version, nullable context, analyzer set and package metadata from the project file, and it does so in two lines. - `Directory.Packages.props` with the versions the SDK injects, or restore fails with `NU1010`. - `AGENTS.md`. The cheapest thing here, and the one with the largest effect on an agent's first ten minutes. - `TestBuilder` in the test project, from the first test. Retrofitting it later is a mechanical edit across every file, and it is the kind of edit that is postponed forever. - `docs/wiki/index.md`, `Home.md` and `_Sidebar.md` — three small files that make the project documentable. Skip: - Architecture tests. With one project there is no direction to assert. Adopt them the moment a second project exists, because that is when the wrong direction first becomes possible. - CI tiering. Everything is a unit test, so there is nothing to tier. - Pack validation, consumer verification, release channels. All three need a published package to mean anything. ### Medium — a few layers Everything from Small, plus: - **Architecture tests.** Rules 1–3 from [Architecture as Tests](../architecture-as-tests/). A page of code, and it pays for itself the first time someone adds a reference "just to reuse a helper". - **A shared testing project.** Once two test projects need the same builder or fixture, `SharedTestingInfra` gives them one home and the SDK references it automatically. - **A second CI tier.** The moment a test needs a real resource, give it its own project and its own job. Keep the fast tier always on; make the slow tier explicit. - **A wiki page per public concern**, plus a getting-started page that is the shortest path to a working example. This is the profile the reference implementation in this repository is built to. It is the smallest shape where every mechanism in the playbook is visible. ### Large — several packages Everything from Medium, plus: - **Pack validation.** An exhaustive manifest of what each package ships. It is the only mechanism that stops a package shipping without the asset it documents, and the failure is otherwise found by a consumer. - **Consumer verification.** A script that packs, then builds throwaway consumers against the produced packages, asserting the documented happy path and the documented guard errors. This is the tier that catches the drift between a package's contract page and the package. - **A release gate.** The slow tier runs before a release, not only on a pull request. - **Release channels** rather than a second repository for prereleases. - **Your own agent skills**, packaged so the guidance travels with the code. - **Architecture rules promoted into the build.** At this size a rule that can be skipped is a rule that will be, so move it from a test into an analyzer or an MSBuild check that runs before compile. ### What not to adopt early Four things that are more expensive than they look when taken out of order. **Architecture tests before there is an architecture.** A test asserting that "the domain does not reference the web layer" is meaningless in a repository with one project, and it teaches the team that architecture tests are ceremony. **A test data builder after the tests.** Retrofitting `TestBuilder` means editing every test file at once, which means a large diff nobody reviews carefully. Adopt it with the first test, when the diff is one file. **A pack-validation manifest before the first package.** `RequireExplicitContent` defaults to `true`, so a repository that packs anything without a matching rule gets an error. That is the right behaviour — and it is the wrong thing to discover while adding the first package. Turn on pack validation with the package. **Consumer verification before there is a consumer.** The script exists to keep a published contract honest. With no published contract it is a slow test that restores from nuget.org and proves nothing. ### Migrating an existing repository The order matters, because each step makes the next one cheaper. 1. **`global.json` and the SDK import.** Do this first and in isolation. Expect the build to surface the style and analyzer diagnostics that the repository has been accumulating, and deal with them as a deliberate pass — that pass is most of the value. 2. **`Directory.Packages.props`.** Add the versions the SDK injects. This is a mechanical fix for `NU1010`. 3. **Renames.** Move projects to the conventional folders and names, one at a time, and fix the namespace each rename implies. Do this before adding architecture tests, because those tests will otherwise fail for reasons that are about naming rather than architecture. 4. **`AGENTS.md`.** Write it from what the repository already does, not from what it should do. An accurate description of the current state is more useful than an aspiration, and it makes the gaps visible. 5. **Architecture tests.** Now they can express the layering you actually have. 6. **`TestBuilder`.** Introduce it beside the existing inline construction, convert one test project at a time, and delete the old path when the last file is converted. 7. **CI tiering and the wiki.** Last, because both depend on the shape being settled. Two things to do at every step: keep the build green, and write down the rule you just adopted on the page where it belongs. A migration that leaves the conventions undocumented is a migration that will be undone. ## See also - [Getting Started](../getting-started/) — the same steps, in order, with the files inline - [Templates](https://github.com/purview-dev/engineering-playbook/tree/main/templates) — the copy-paste versions - [Engineering Principles](../engineering-principles/) - [Worked Examples](../worked-examples/) - [Contributing](../contributing/) --- # Contributing ## The rule This repository is documentation with a small reference implementation attached. The documentation is the product; the reference implementation exists to prove the documentation is not guesswork. ### What belongs here A rule belongs in the playbook when all four of these are true: 1. **It came from a real repository and a real failure.** "This would be nicer" is not enough. The page should be able to say which failure the rule prevents. 2. **It is reusable at more than one size.** A rule that only makes sense in a repository with eleven packages needs an "at your scale" section explaining what the smaller version is. 3. **It is not the SDK's to state.** Where the SDK already documents its own behaviour, link to it. A second statement of the same fact is a statement that will drift. 4. **It is enforced, or it is labelled as a review obligation.** Both are acceptable. Blurring them is not. What does not belong: anything that names a product, a customer, or a repository that is not public. The playbook is a published artefact, and a rule that can only be justified by something private cannot be reviewed by the people it is published to. ## Why The four criteria are the whole value of the playbook. A rule without a failure behind it is an opinion, and opinions accumulate until the useful rules are indistinguishable from the noise. A rule that only works at one size gets cargo-culted at another. A rule that restates the SDK's own documentation is a second source of truth that will drift. And a rule that claims enforcement it does not have is worse than no rule at all, because people rely on the guarantee and stop looking. Each criterion removes one of those failure modes. ## Adding a page 1. **Write the page** in `docs/wiki/.md`. Title case, hyphens between words. 2. **Follow the four sections** from [Documentation Convention](../documentation-convention/): the rule, why, how it is enforced, at your scale. 3. **Add it to `_Sidebar.md`.** A page that is not in the navigation is a page nobody finds. 4. **Add it to the table in `Home.md`.** The hub is how a reader discovers a page they did not know to look for. 5. **Link both ways.** A page with no inbound link is a page that will be deleted by accident; a page with no outbound links is a dead end. 6. **Link to the authority, and name the evidence.** Where a rule is the SDK's, link to the SDK's page. Where the evidence is a repository, name it, so a reader can check the claim against running code. ## Changing a rule Changing a rule is a bigger change than adding one, because pages link to it and code may depend on it. - **Say what changed and why** in the pull request description, including the failure that motivated it. - **Update every page that states the rule**, not just the one you started from. Cross-references are the mechanism that keeps a multi-page playbook coherent. - **Check the reference implementation still demonstrates it.** If the rule changed and `src/` did not, either the rule is not demonstrated or the demonstration is now wrong. - **Check the "How it is enforced" table.** A rule that moved from review to a build check — or the reverse — is a change to the promise the page is making. ## The reference implementation `src/` follows the conventions it documents, and that is the point. Two consequences: - **A change to `src/` that breaks a documented convention is a bug**, even if the build passes. The conventions include the ones only review can check. - **A new mechanism documented in the wiki should be demonstrated in `src/` where it is feasible.** Where it is not — a published package contract, a persistence layer, a second CI tier — say so on the page and point at the public repository that does demonstrate it. ## How it is enforced Three mechanisms, and they are the ones a change to this repository actually passes through. ### Commit messages Conventional Commits, enforced by a `commit-msg` hook. The allowed types are in `commitlint.config.mts`: ```text build chore ci docs feat fix perf refactor revert style test ``` The header is limited to 100 characters, the subject must not be sentence case, and body lines wrap at 100. A documentation-only change is `docs:`; a change to the reference implementation is `feat:` or `fix:`; a change to the pipeline configuration is `ci:` or `build:`. ### Validation before handing work back ```text just lint-check # CSharpier check just build # dotnet build (Debug) just test # dotnet test just pipeline-pr # restore, build, lint, test through the shared Purview.Build tool ``` Then state exactly what ran. If a check was skipped or blocked, give the concrete reason and the remaining risk — an unstated gap is the one that gets discovered in production. For a documentation-only change, the checks that matter are the ones that catch a broken reference: the navigation in `_Sidebar.md`, the table in `Home.md`, and any relative link you added. ### Review expectations A reviewer should be able to answer these from the pull request alone: - Which failure does this rule prevent, and where has it happened? - Is the rule enforced by a build, or is it a review obligation? Does the page say which? - What does a repository smaller than the one this came from do instead? - If the rule is the SDK's, why is it restated here rather than linked? - Does `src/` still demonstrate the conventions, or has it drifted? ## At your scale This page is for changing the playbook itself. A repository that is only *borrowing* from the playbook does not need any of it: take the pages and templates that fit and leave the rest, as [Adopting This at Your Scale](../adopting-this-at-your-scale/) describes. The smaller version of this process, for a documentation-only change, is: edit the page, keep `_Sidebar.md` and `Home.md` in step, and run the reference checks in [Validation before handing work back](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki#validation-before-handing-work-back). ## See also - [Documentation Convention](../documentation-convention/) - [Engineering Principles](../engineering-principles/) - [Adopting This at Your Scale](../adopting-this-at-your-scale/) - [Release Flow](../release-flow/) --- # Release Flow ## The rule Three things, and the split between them is the whole design: - **The version comes from `package.json`.** One file, one field, bumped by a maintainer in a pull request. No repository hand-writes a version into a project file, and no repository creates a release tag by hand. - **The tool evaluates; the workflow decides.** The pipeline tool answers "may this version be released from this ref?" and reports the verdict. The workflow reads that verdict and sets the release mode. The tool never silently declines to release when it has been asked to. - **A repository chooses a model and owns only its caller workflow.** The pipeline is shared. ## Why **One version source.** A version that exists in two places is a version that disagrees with itself. Reading it from `package.json` also means the version is visible to the thing that most often needs it — the package metadata and the release notes — without a build step in between. **Evaluate and decide are different jobs.** Eligibility rules are logic, and logic belongs somewhere it can be unit-tested and run locally. A workflow is neither. But the *decision* has to stay in the workflow, because a release is a repository-level act with repository-level consequences: the workflow is where the concurrency group, the secrets and the branch protection live. Splitting them this way makes the rules testable without making the release automatic. **A shared pipeline.** Eleven repositories with eleven copies of a release script have eleven release scripts, of which at least one is subtly wrong. One shared pipeline with eleven two-line callers has one. ## The models A repository picks one. The choice is one setting plus its `on:` block. | Model | Trigger | Policy | Used by | | -- | -- | -- | -- | | **A** — release on `main` | `push` to `main` | `ReleaseOnMain` (default) | Every package repository | | **B** — main-as-head / release branch | `push` to `release` | `ReleaseOnMain` | Documented, not yet used | | **C** — per-line release branches | `push` to `release/**` + `workflow_dispatch` | `TrunkReservesMinor` | A serviced stable line plus an in-flight prerelease line | **Model A** is what almost every repository wants: each merge to `main` may release, and the tag that already exists is what stops a second release of the same version. If the version in `package.json` is already tagged, the verdict is `Skip` and the job finishes green having published nothing — which is the correct outcome for a merge that did not bump the version. **Model C** exists for the case where `main` must not ship: work accumulates, and a release is cut from a `release/` branch, so a stable line and a prerelease line advance independently. ## The rules Eligibility rules are evaluated in ID order and short-circuit at the first non-passing one: | Rule | Fails when | | -- | -- | | `REL001` | The version does not parse at the configured strictness. | | `REL002` | The version is already tagged or published — this is a `Skip`, not a failure. | | `REL003` | A stable version is released from a ref that is not a `StableRef`. | | `REL004` | A non-zero `PATCH` is released from a ref that is not a `ServicingRef`. | | `REL005` | The version is not greater than the line's highest. | | `REL006` | A four-part version is used where four-part versions are not permitted. | | `REL007` | The label does not match the channel's pattern. | Three policies ship built in, so adopting a different model is one setting rather than a policy block to copy: | Policy | Rules | Refs | | -- | -- | -- | | `ReleaseOnMain` (default) | `REL001`–`REL003` | `StableRefs`: `refs/heads/main`, `refs/heads/release` | | `TrunkReservesMinor` | `REL001`–`REL005` | `StableRefs`/`ServicingRefs`: `refs/heads/release/*`; `TrunkRefs`: `refs/heads/main` | | `FourPartServicing` | `TrunkReservesMinor` + `REL006` | as above, plus `AllowFourPart` | A disabled rule is skipped entirely rather than passing vacuously, which is why a Model A repository behaves exactly as it always has. ## The caller workflow A Model A package repository, in full: ```yaml name: Release on: push: branches: [main] # Callers own release serialization; the reusable workflow defines no concurrency group. concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: release: name: Release packages uses: purview-dev/build/.github/workflows/purview-release.yml@main with: release-mode: NuGet release-branch: main secrets: inherit ``` A repository that ships no package replaces `NuGet` with `GitHubRelease`, which creates the `v{version}` tag and a GitHub release with generated notes and skips the publish step entirely. That is what this playbook does: ```yaml on: push: branches: [main] # A release is driven by a version bump, so the pipeline only wakes when package.json changes. paths: - package.json concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: release: name: Tag and release uses: purview-dev/build/.github/workflows/purview-release.yml@main with: release-mode: GitHubRelease run-pack: false validate-pack: false secrets: inherit ``` Three details in those two files are load-bearing: - **`concurrency` with `cancel-in-progress: false`.** The shared workflow deliberately defines no concurrency group, because serialisation is a repository decision: two releases racing each other is a worse outcome than a queued one. - **`paths: [package.json]`.** Without it, every merge to `main` runs a release job that mostly decides `Skip`. With it, the job runs when the thing that can change the outcome changes. - **`release-branch` is retained for compatibility.** Callers now own serialisation, so the input no longer gates anything. ## Seeing the decision without making it `release-explain` prints the verdict and exits without running a single pipeline module: ```bash purview-build release-explain --format=json ``` `--format=json` emits the stable contract the reusable workflow consumes — `verdict`, `releaseMode`, `decidedByRule`, `message` — and `release-explain` always exits 0, because the decision is in the output rather than in the exit code. The workflow reads the verdict and acts on it: - `Release` → run the release pipeline with the evaluated mode. - `Skip` → finish green with a notice. Nothing to do. - `Fail` → fail the job, naming the rule that failed. The same command works locally against simulated context (`Release:Context:Ref`, `ExistingTags`, `PublishedVersions`), which performs no process or network lookups and says so in its report. A simulated verdict that could be mistaken for a real one would be worse than no simulation. > **The interlock.** If any `Release:Context:*` key is set and the run would really publish, the pipeline > fails before any module runs. Simulation cannot leak into a real release. ## Channels `Release:Channel` selects the feed and label policy, so a `preview` channel can publish to GitHub Packages with `GitHubRelease: false` — preview packages without a GitHub release — while `stable` publishes to nuget.org and tags. `Release:DryRun` runs everything and skips both publish steps, logging what each would have done. ## How it is enforced | Rule | Enforced by | | -- | -- | | One version source | The pipeline reads `package.json`; nothing else is consulted | | A version is released at most once | `REL002`, checked against the tags that already exist | | The version is bumped in a pull request | Review — the release job only reacts to what is already merged | | A release cannot race another | The caller's `concurrency` group | | Eligibility rules are correct | The tool's own test suite, including a release matrix over every scenario | ## At your scale - **No package.** `GitHubRelease`, and a `paths: [package.json]` trigger. A release is a tag and notes. - **One package.** Model A with `release-mode: NuGet`. Bump `package.json`, merge, and let the tag follow. - **A preview line.** Add a channel rather than a second repository. `Release:Channel` is the mechanism; a second repository is not. - **A serviced line.** Move to Model C and `TrunkReservesMinor`, which is the policy that exists to keep `main` from shipping a stable version while a line is serviced. ## See also - [Verification Model](../verification-model/) - [Engineering Principles](../engineering-principles/) - [build: Release Flow](https://github.com/purview-dev/build/blob/main/docs/wiki/Release-Flow.md) - [build: Release Models](https://github.com/purview-dev/build/blob/main/docs/wiki/Release-Models.md)