# 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)