Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ dotnet test Cluckwork.sln # 2295 tests as of 2026-09; integrati
- **A reference from one business module to another is declared in the module ledger (#514/#842).** `tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json` names nine owners by namespace — Access, Farm, FlockManagement, EggOperations, Commerce, GeneralInventory, Finance, Insights and Platform — and every cross-owner edge as a cell (`from`, `to`, `kind`, `reason`) listing the top-level types that realise it. `ModuleLedgerRealTreeTests` walks every `.cs` under `src/` and fails on an undeclared edge, a **stale row**, a namespace no owner claims, a parse error or a file count under the floor; an undeclared edge prints the JSON to add, so the fix is to read the code and extend the cell, never to widen a reason. Keyed by owner and top-level type, never by namespace or `file:line` (#632): `Customer.cs` living under `Domain/Sales/` is not an edge because both ends are Commerce. **Platform is the free hub** — `Cluckwork.Api.*`, `Infrastructure.*` except `Identity`, `Domain.Common`, `Domain.Auditing` and `Application.Common` may reference anything and be referenced by anything; narrowing that is Track B (#845–#847). `kind` (`W` writes through the other owner in one transaction, `R` reads or validates) is review-checked only. The ledger authorises no move: the design's status line still governs. → [`514-module-ledger.md`](docs/decisions/514-module-ledger.md)
- **No persistence type crosses an Application seam (#514/#847).** A public interface under `Cluckwork.Application.Features.*` or `Cluckwork.Application.Common` may not reach `DbContext`, `DbSet<>`, `IQueryable`, any `Microsoft.EntityFrameworkCore` type, the bases `Entity`/`AggregateRoot` as a declared type, or any `Cluckwork.Infrastructure` type — through parameters, return types, generic arguments or the properties of a `Cluckwork.*` type it returns. `SeamSurfaceRealAssemblyTests` reflects over every such interface and fails closed if it finds fewer than 30; a second assertion pins that the Application assembly references no EF, Npgsql or Infrastructure assembly, which is what keeps the first from being vacuous. Return a DTO, a value object, a concrete aggregate or a `PagedResult`. → [`847-seam-surface-guard.md`](docs/decisions/847-seam-surface-guard.md)

- **Every adapter declares its module reach, and shrinking it is free (#514/#846).** `AdapterReachRealTreeTests` walks every method and declared constructor under the ledger's endpoint, CLI and job namespaces and its two seeder types, including private helpers and primary constructors, plus direct route handlers in the ledger-selected top-level programs. `Architecture/Data/module-ledger.json` records each non-empty set of module owners by enclosing type plus member name, or project, Program, mapping method and route for top-level handlers, including literal HTTP method lists and named handlers; a new owner fails with the crossing type, `file:line` and a generated JSON row. Platform is the free hub. This is a ceiling, not a multi-module ban: unused owners and deleted adapters stay green and appear in `Loosenable` for pruning. **Endpoints, including top-level Program route handlers, forbid `AppDbContext`, `DbContext`, `DbSet` and `IQueryable` parameters or service resolutions regardless of the ledger**; CLI verbs, jobs and seeders may hold those persistence types without counting them as reach. The walk reads parameter types, including typed lambda, local-function and anonymous-method parameters, and generic or `typeof` service resolutions from one API table, shares the module ledger's owner resolution, fails on parse or registry errors and enforces a 40-adapter floor. It over-approximates adapters and cannot bind every simple name or infer transitive reach; #843's adapter tiers layer on top later. → [`846-adapter-reach-ratchet.md`](docs/decisions/846-adapter-reach-ratchet.md)
- **Every adapter declares its module reach, and shrinking it is free (#514/#846).** `AdapterReachRealTreeTests` walks every method and declared constructor under the ledger's endpoint, CLI and job namespaces and its two seeder types, including private helpers and primary constructors, plus direct route handlers in the ledger-selected top-level programs. `Architecture/Data/module-ledger.json` records each non-empty set of module owners by enclosing type plus member name, or project, Program, mapping method and route for top-level handlers, including literal HTTP method lists and named handlers; a new owner fails with the crossing type, `file:line` and a generated JSON row. Platform is the free hub. This is a ceiling, not a multi-module ban: unused owners and deleted adapters stay green and appear in `Loosenable` for pruning. **Endpoints, including top-level Program route handlers, forbid `AppDbContext`, `DbContext`, `DbSet` and `IQueryable` parameters or service resolutions regardless of the ledger**; CLI verbs, jobs and seeders may hold those persistence types without counting them as reach. The walk reads parameter types, including typed lambda, local-function and anonymous-method parameters, and generic or `typeof` service resolutions from one API table, shares the module ledger's owner resolution, fails on parse or registry errors and enforces a 40-adapter floor. It over-approximates adapters and cannot bind every simple name or infer transitive reach; #843's adapter tiers layer on top. → [`846-adapter-reach-ratchet.md`](docs/decisions/846-adapter-reach-ratchet.md)

- **A tier declares a privilege a module may not otherwise have, before the surface that needs it exists (#514/#843).** `adapterTiers` in `module-ledger.json` names a namespace, a closed-set `privilege` (`DirectRepository` today), a closed-set `surface` (`MapMcp` today, both read from the shared `AdapterTier.KnownSurfaces` map the scanner also walks), and a `reviewBy` issue so the exemption has an end date; `AdapterTierScanner` fails on a `[McpServerToolType]`-attributed type outside every declared tier namespace, on a mapped surface (`MapMcp`) with no tier row, and prints the JSON row to add. A tier row whose surface is unmapped and whose namespace holds no type is `Dormant` — green, informational, because the row is committed before the code exists: today's `Cluckwork.Api.Mcp` row for #806 is exactly this, and it goes stale (not red) the day #806 lands. A tier also unions into the reach ratchet's namespace roots and its persistence-forbidden set, so a tool class's repository parameters still need an `adapters` row and an `AppDbContext` parameter still fails. → [`843-adapter-tiers.md`](docs/decisions/843-adapter-tiers.md)

- **The coupling matrix is generated from the ledger and its three walks (#848).** `CouplingMatrixRealTreeTests` validates the module-edge, EF-model and adapter reports before rendering `tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md`, then compares normalized line endings with the committed file. Regenerate with `CLUCKWORK_REGENERATE_MATRIX=1 dotnet test tests/Cluckwork.Application.Tests --filter FullyQualifiedName~CouplingMatrixRealTreeTests`; do not hand-edit the Markdown. Its observable-differences table has seven `W`/`R`/dash mismatches with §3.4. A separate table records its unobservable `E`, `Q` and Platform cells. `P` is the free Platform hub, and `A` counts adapter reaches. → [`848-generated-coupling-matrix.md`](docs/decisions/848-generated-coupling-matrix.md)

Expand Down
153 changes: 153 additions & 0 deletions docs/decisions/843-adapter-tiers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Declare adapter tiers before the surfaces that need them exist (#843)

> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md);
> this file records the rationale and the limits of the guard.

**Status:** accepted
**Date:** 2026-09-15

## What happened

No incident. This is epic #514, slice 2, Track A, stacked on #846's reach
ratchet. #806 is about to add MCP tool classes under `src/Cluckwork.Api/Mcp/`
that inject repositories directly — `docs/plans/770-mcp-server/01-design.md:112`
sanctions `WaterTools(McpCallContext call, IWaterUsageRepository water)`. #514's
own adapter rule says an adapter may call a module contract or an approved
workflow, not a repository. Both cannot hold at once. Waiting for #806 to merge
and then weakening the reach ratchet to fit it is the failure #407 spent five
review rounds on: a guard read as safety after it had already been loosened to
match the violation. This slice records the exemption on #514's own side,
named and dated, before the code that needs it exists.

## The rule

A tier declares a named privilege a module may not otherwise have, over a
namespace, tied to the surface that will map it and a review issue that gives
the exemption an end date. `adapterTiers` in
`tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json` is an
array of `{ namespace, privilege, surface, reason, reviewBy }` rows; today it
holds one, `Cluckwork.Api.Mcp` → `DirectRepository`, surfaced by `MapMcp`,
reviewed by `#806`. `AdapterTierScanner` walks every `.cs` under `src/` for a
type carrying `[McpServerToolType]` (simple, qualified, or `Attribute`-suffixed,
or reached through a `using X = ...;` alias — a file-level or namespace-block
alias in the same file, or a `global using` alias declared anywhere in the same
project) and every invocation of a declared surface call (`MapMcp` today). A tool type
outside every declared tier namespace is red. A mapped surface with no tier row
is red, and the failure prints the JSON row to add. A tier row whose surface is
never invoked and whose namespace holds no type is `Dormant` — informational,
never red, because the row is meant to be committed before the code exists.
`privilege` and `surface` are each a closed set, both read from
`AdapterTier.KnownSurfaces` beside `AdapterTier.DirectRepositoryPrivilege` — a
map from each supported surface to the privilege it grants, today just
`MapMcp` → `DirectRepository`. `AdapterTierScanner` walks invocations of the
same map's keys, so a row naming a surface the scanner does not walk is a
registry error rather than a silently inert exemption. `reviewBy` must match
`^#[0-9]+$`, so an exemption with no end date is a registry error, alongside a
blank or missing field and a duplicated `namespace` or `surface`.

A tier is also an adapter root with a privilege. `AdapterReachScanner` unions
every `adapterTiers[].namespace` into its own namespace roots — so a tool
class's repository parameters are walked for module reach exactly like an
endpoint's — and into its persistence-forbidden set, because `DirectRepository`
authorizes a repository, not a direct `AppDbContext`. A tool class reaching an
undeclared module still needs an `adapters` row; one that takes `AppDbContext`
still fails.

## Why not the obvious alternative

Keying the tier on the folder (`src/Cluckwork.Api/Mcp/` existing) was rejected:
that folder does not exist yet, so a guard keyed on it would be red from the
moment this slice lands until #806 merges, training everyone to ignore it
exactly the way a chronically-red CI check does. `Dormant` is the alternative —
green today, informational, and it goes stale (not red) the day #806 adds a
type under the namespace or maps `/mcp`, which is the signal for that PR's
author to drop the real-tree Dormant assertion rather than route around it.

Binding the tool-attribute detection to the actual
`ModelContextProtocol.Server.McpServerToolType` symbol was rejected for the
same reason the reach ratchet stays syntax-only: no MCP package reference
exists in `src/` yet, so a semantic check has nothing to bind against. A syntax
match on the attribute's last identifier, both bare and `Attribute`-suffixed,
is what a guard that must be committable today can do.

## What this does NOT cover

The walk is syntax-only, exactly like #846's. It matches on the attribute's
last identifier segment, so it cannot distinguish a real
`ModelContextProtocol.Server.McpServerToolType` from an unrelated type of the
same name; that is the same trade #846 makes for `AppDbContext`/`DbContext`.
The surface walk (`AdapterTier.KnownSurfaces`) matches on the invoked method
name only, receiver-independent — a call to an unrelated `MapMcp` method on a
different type would also count. Today's tree has none, and the ledger's tier
row is what a reviewer reads against the real one when #806 lands. The closed
privilege and surface sets each hold one value because MCP is the only tier
this slice adds; growing either needs the value added to
`AdapterTier`'s closed set and a stated reason, not a silent widening. This
slice changes no `src/` code, CI workflow, or package dependency, and it does
not ask #806–#809 to call contracts instead of repositories — that remains a
Track C question this ledger absorbs for now.

The walk still parses every file with one fixed preprocessor symbol list
(`ModuleLedgerScanner.ParseOptions`), so a project-defined constant's `#if`
branch is never walked — an inactive branch is invisible to Roslyn's own
parse, not merely to this scanner. Nothing here reads an inactive branch;
instead, `ModuleLedgerScanner.UndeclaredDefineConstants` enumerates every
`*.csproj`, `*.props` and `*.targets` under the repo root (skipping `bin/`,
`obj/`, `node_modules/`, `.git/` and `web/`), collects each `<DefineConstants>`
value, and fails the walk-trust check the moment any of them defines a symbol
the fixed list does not carry. It does not resolve MSBuild imports — there is
no project graph to resolve against — so it over-approximates instead: it
reads every matching file textually, whatever file or `Condition` it sits in,
so a constant defined ANYWHERE under the repo root is red even if no project
actually imports that file. That keeps the fixed list honest without walking
inactive branches or resolving imports: the day a project needs `#if MCP`,
adding `MCP` to `DefineConstants` reds this check first, which is the prompt
to add `MCP` to `ParseOptions` too, before the guarded code can hide behind
it. `$(DefineConstants)` (the MSBuild reference to the inherited value) is
not itself a symbol and is skipped.

## How it is enforced

`tests/Cluckwork.Application.Tests/Architecture/AdapterTierTests.cs` exercises
the scanner on temporary trees: one test per registry error, `ToolTypeOutsideTier`,
`SurfaceWithoutTier`, a green `Dormant` row, a green in-tier type, both attribute
spellings, a file-local alias and a same-project `global using` alias resolving
to the tool attribute, an alias to an unrelated type not matching, a surface
call inside a lambda or local function, a parse error, a fixture `.csproj`
defining an undeclared preprocessor symbol, and one defining only
`$(DefineConstants);TRACE`.
`AdapterTierRealTreeTests` gates the real `src/` tree, confirms the `MapMcp` row
is declared, and pins it `Dormant` today — that last assertion is written to go
stale, not to stay true forever. `AdapterReachTests` adds two integration
cases: a tool class under a tier namespace with an undeclared repository
parameter is red, and one with `AppDbContext` is a persistence violation.

Seven real-tree mutations were run against the built scanners and reverted with
`git checkout --` or by deleting the added file. Mutations touching only
`src/` ran with `--no-build`; mutations 2 and 3 edit the ledger's JSON content
file, which `--no-build` does not recopy into the test output directory, so
those two ran after a rebuild. Output files are local evidence under
`/tmp/514/mutations-843/`.

| Mutation | Result | Output file |
|---|---|---|
| `[McpServerToolType] class Probe {}` under `Cluckwork.Api.Endpoints` | RED, `ToolTypeOutsideTier` at `Probe.cs:3` | `1-tooltype-outside-tier.txt` |
| Add `app.MapMcp("/mcp");` to `Program.cs`, empty `adapterTiers` | RED, `SurfaceWithoutTier`, prints the JSON row to add | `2-surface-without-tier.txt` |
| Remove `reviewBy` from the MCP row | RED, registry error: blank `reviewBy` | `3-remove-reviewby.txt` |
| `WaterTools(IWaterUsageRepository water)` under `Cluckwork.Api.Mcp` | RED, undeclared reach `WaterTools.ctor -> GeneralInventory` | `4-tier-namespace-undeclared-reach.txt` |
| Same class taking `AppDbContext` | RED, forbidden persistence type at `WaterTools.cs:3` | `5-tier-namespace-appdbcontext.txt` |
| codex-sol review of #880: a class outside `Cluckwork.Api.Mcp` carrying a locally-declared `McpServerToolTypeAttribute`, referenced only through a `using ToolMarker = ...;` alias | RED, `ToolTypeOutsideTier` at `AdapterTierAliasProbe.cs:7` | `6-alias-outside-tier.txt` |
| codex-sol review of #880: `<DefineConstants>MCP</DefineConstants>` added to `src/Cluckwork.Api/Cluckwork.Api.csproj` | RED, "the walk cannot be trusted: project src/Cluckwork.Api/Cluckwork.Api.csproj defines MCP, add it to ModuleLedgerScanner.ParseOptions" | `7-csproj-defines-mcp.txt` |

Run the gate with:

```sh
dotnet test tests/Cluckwork.Application.Tests \
--filter FullyQualifiedName~AdapterTierRealTreeTests \
--logger 'console;verbosity=detailed'
```

When #806 lands `/mcp` and its tool classes under `Cluckwork.Api.Mcp`: add each
tool class's repository reach as an `adapters` row exactly like an endpoint's,
and drop `AdapterTierRealTreeTests.McpTierRow_IsDormantToday` — its own failure
is the signal that the row it pins is no longer dormant.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Starting a new record: copy [`TEMPLATE.md`](TEMPLATE.md).
| Decision | Rule lives in |
|---|---|
| [Adapter module reach may shrink without a ledger edit (#846)](846-adapter-reach-ratchet.md) | AGENTS · Application shape |
| [Declare adapter tiers before the surfaces that need them exist (#843)](843-adapter-tiers.md) | AGENTS · Application shape |
| [Generate the coupling matrix from live evidence (#848)](848-generated-coupling-matrix.md) | AGENTS · Application shape |
| [Table-owner completeness from the EF model (#845)](845-table-owners.md) | AGENTS · Data and correctness |
| [Credential epoch revocation (#364)](364-credential-epoch-revocation.md) | AGENTS · Conventions |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,13 @@ public static AdapterReachReport Scan(string srcRoot, string ledgerPath)
{
var ns = NamespaceOf(type);
var typeName = TypeName(type);
if (!ledger.AdapterRoots.Namespaces.Any(prefix => Under(ns, prefix))
if (!ledger.AdapterNamespaces.Any(prefix => Under(ns, prefix))
&& !ledger.AdapterRoots.Types.Contains(typeName, StringComparer.Ordinal))
{
continue;
}

var banPersistence = ledger.AdapterRoots.PersistenceForbiddenNamespaces.Any(prefix => Under(ns, prefix));
var banPersistence = ledger.PersistenceForbiddenNamespaces.Any(prefix => Under(ns, prefix));
var adapters = type.Members.OfType<BaseMethodDeclarationSyntax>()
.Where(m => m is MethodDeclarationSyntax or ConstructorDeclarationSyntax)
.Select(m => (Node: (SyntaxNode)m,
Expand Down
Loading
Loading