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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ 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)

### Data and correctness

- **Every mapped table has one ledger owner (#514/#845).** `TableOwnerRealModelTests` walks the EF model and checks `Architecture/Data/module-ledger.json` for missing, duplicate, stale, or unknown table owners, and for undeclared or stale cross-owner foreign keys by constraint name and direction. The walk fails below 30 distinct tables. Table ownership must match the CLR namespace's longest owner prefix, including exact claims as subtree claims for this check, unless a reasoned `tableOwnerOverrides` row records a design exception. Platform tables still need ownership rows; FKs touching Platform are untracked, and Insights owns no tables. This is enforcement only: no schema change, configuration move, or permission to write another owner's data. → [`845-table-owners.md`](docs/decisions/845-table-owners.md)
Expand Down
184 changes: 184 additions & 0 deletions docs/decisions/846-adapter-reach-ratchet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Declare each adapter's module reach (#846)

> **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-14

## What happened

No incident. This is epic #514, slice 4, Track B, stacked on #845's table-owner
ledger. The module ledger treats Platform as a free hub, so it cannot detect an
endpoint or infrastructure adapter acquiring another business module's port.

The baseline walk finds **400 adapter declarations and 150 non-empty adapter
rows**. Rows come from `AdapterReachScanner.RenderAdapters`, rather than a manual
inventory. `ExpenseEndpoints.ListExpenses` reaches Farm, Finance,
FlockManagement, and Insights. Its `IAuditEventRepository` belongs to Insights
under the existing ledger; Platform owns the domain audit types.

## The rule

Declare each adapter's set of module-kind owners in `module-ledger.json` and
review any newly reached owner before extending that set. A live owner outside
the declared set fails the guard, even if the total owner count stays the same.
Removing a crossing, deleting an adapter, or leaving an unused allowance stays
green and appears in `Loosenable` for pruning. Platform owners do not count.
Reject `AppDbContext`, `DbContext`, `DbSet`, and `IQueryable` in endpoint parameters
or service resolutions regardless of declared reach. This persistence ban applies
under `Cluckwork.Api.Endpoints` and to direct route handlers selected by
`topLevelPrograms`. CLI verbs, jobs, and seeders legitimately
hold persistence types for migration, maintenance, and seeding; those types do
not themselves count as module reach there and do not fail the guard.

The ledger's `adapterRoots` declares three namespace subtrees and two exact
seeder types. Its `persistenceForbiddenNamespaces` declares the endpoint-only
ban. `topLevelPrograms` lists `Cluckwork.Api`, identified by the first directory
beneath `src/`, using the module scanner's project-root namespace convention.
The scanner does not hardcode these roots. Every method, regardless of
accessibility or static status, and every declared constructor counts. Primary
constructors count too. A nested type under a namespace root is included;
the two exact seeder roots select those types themselves.

In a listed program's global statements, inline lambdas, anonymous methods,
and local-function or method-group handlers passed to `MapGet`, `MapPost`,
`MapPut`, `MapDelete`, `MapPatch`, `MapMethods`, `MapFallback`, or `Map` count as
endpoint adapters. The key includes the mapping method and first route string
literal, for example `Cluckwork.Api.Program.MapGet(/api/v1/x)`. Review found
that a route-only key merged GET and POST allowances for the same path;
`MapGet(/api/v1/x)` and `MapPost(/api/v1/x)` now have independent reach.
`MapMethods` includes its fully literal HTTP method list when present, for
example `MapMethods(/api/v1/x;[GET,POST])`. Literal arrays, collection
expressions and collection initializers use ordinally sorted, distinct method
values so formatting and method order do not change the key. Runtime method
list variables are not resolved. Method-group names follow the route, for
example `MapGet(/api/v1/x).Read` or `MapFallback().Read` without a literal route.
Handler declarations supply their parameter types and service calls.
Other startup composition is outside reach. The real `Program.cs` has three
such adapters: `Map(/error)`, `Map(/api/{**rest})`, and `Map(/health/{**rest})`.
None reaches a module, so regeneration leaves the 150 adapter rows unchanged. The total rises
from 397 to 400 walked adapters.

## Why not the obvious alternative

This is a ceiling, not a ban on reaching multiple modules. An immediate ban
would require more than forty exemptions before it could pass on this tree.
The ceiling records the current reach and requires review when it grows.
An allowance remains usable until someone prunes it; the guard does not store
a historical minimum outside Git.

The adapter definition deliberately over-approximates runtime entry points.
Private endpoint helpers and request-record constructors count even when the
HTTP router never invokes them directly. That can raise the recorded ceiling,
but it avoids hiding dependencies behind a remembered list of routed handlers.
Inside a type's adapter, lambdas, local functions, and anonymous methods are
not independent adapters. Their typed parameters and service resolutions
are attributed to the enclosing method. Untyped lambda parameters cannot be resolved and are ignored. Review found that the
initial walk missed typed lambda parameters; adding them generated three new
mapping-method rows for Products, Egg Grades, and Inventory, increasing the
ledger from 147 to 150 rows without changing an existing row.
Overloads share the specified enclosing-type-plus-member key and their reach
is combined. Moving a file or adding a comment does not change the member key. Generic
enclosing types retain their arity in that key.

Applying the persistence ban to every adapter would fail on existing migration
verbs, background sweeps, and the simulation seeder. The accepted scope is an
endpoint-only ban, with no source refactoring or persistence exceptions ledger.

## What this does NOT cover

The walk is syntax-only and does not boot a host or bind a Roslyn compilation.
It reads method and constructor parameter types, typed parameters of lambdas,
local functions and anonymous methods in their bodies, and each type's generic
arguments. One `ResolverCalls` table defines generic and direct `typeof`
resolution for `GetService`, `GetRequiredService`, `GetServices`,
`GetKeyedService`, `GetRequiredKeyedService`, `GetKeyedServices`,
`ActivatorUtilities.CreateInstance`, and
`ActivatorUtilities.GetServiceOrCreateInstance`. It does not follow return types,
fields, properties, arbitrary object creation, service types passed through
variables instead of `typeof`, reflection, inferred types, dependency
forwarding, or the transitive dependencies of an injected handler. Primary constructors
contribute their parameter types, not field initializers.

Top-level method-group resolution follows visible local functions, named
source types, static imports, and same-file partial `Program` methods. All
matching source overloads contribute to the route's reach. It cannot infer an
instance receiver's type or bind an external assembly's handler. An unresolved
named route handler fails the walk instead of silently losing its reach. An
inline route handler without a string literal also fails because it has no
stable route or method name for the ledger. Delegate factories and custom
mapping extensions are outside this syntax selector.

Owner resolution reuses the module ledger's longest namespace prefix. Exact
namespace claims also cover referenced types beneath them. Fully qualified names,
file-scoped imports, namespace-scoped imports, aliases, and relative namespace
names are resolved syntactically. A declaration index lets simple names resolve
through an import or to a type in the same namespace or enclosing type. The
scanner does not bind overloads, generic constraints, assembly references,
extern aliases, or competing imports. It does not expand imports from another
file. The existing module-ledger guard rejects global module imports. Both
walks share the same .NET 10, DEBUG, and TRACE parse symbols; other conditional
compilation branches are outside this walk.

A simple name without a matching import or local declaration is ignored and
listed in `UnresolvedTypes`; it is never assigned to an arbitrary imported
module. The baseline has 620 such references, covering these 37 external names,
and **no unresolved Cluckwork type**:

`Action`, `CancellationToken`, `ClaimsPrincipal`, `CookieOptions`, `DateOnly`,
`DateTimeOffset`, `EndpointFilterDelegate`, `EndpointFilterInvocationContext`,
`EntityTagHeaderValue`, `Func`, `Guid`, `HttpContext`, `HttpRequest`, `HttpResponse`,
`IAuthorizationService`, `IEnumerable`, `ILogger`, `IOptions`, `IOptionsSnapshot`,
`IReadOnlyDictionary`, `IReadOnlyList`, `IServiceProvider`, `IServiceScopeFactory`,
`IValidator`, `IWebHostEnvironment`, `JsonElement`, `List`, `ModelBuilder`,
`NpgsqlConnection`, `PipeWriter`, `RouteGroupBuilder`, `Stream`, `Task`,
`TimeProvider`, `TimeSpan`, `UserManager`, and `WebApplication`.

The persistence check recognizes the four type names syntactically, including
qualified names and aliases. It does not prove inheritance from `DbContext`.
Generic arguments are still walked independently, including those inside a
persistence container. #843's adapter-tier concept layers on top of this later;
this guard assigns no tier and grants no runtime authorization. It changes no
`src/` code, CI workflow, or package dependency.

## How it is enforced

`tests/Cluckwork.Application.Tests/Architecture/AdapterReachTests.cs` exercises
the scanner on temporary trees. `AdapterReachRealTreeTests` gates the real
`src/` tree. Parse errors make the walk untrusted. Duplicate adapter symbols,
blank symbols, unknown reach owners, and platform reach owners are registry
errors. The real-tree floor is 40 adapters, independently asserted by the
second real-tree test. Temporary fixtures use their own adapter count as the
floor.

An undeclared crossing reports the adapter, owner, crossing type, and
`file:line`, followed by the generated JSON row to review. Rows and owner lists
are sorted ordinally. `Loosenable` never enters the gate's failure list.
`AdapterReachRealTreeTests.AssertNoLoosenable` is a test-only assertion for an
explicit pruning pass, and the regular gate prints the pruning list without
asserting that it is empty.

Run the gate and print its measured count and pruning list with:

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

Six real-tree mutations were run against the built syntax scanner with
`--no-build`, so compiler failures from intentionally incomplete edits could
not substitute for guard failures. Each was reverted with `git checkout -- src`.
The output files are local evidence under `/tmp/514/mutations-846/`.
Mutation 6 predates the mapping-method key and records the former
`Cluckwork.Api.Program./probe` symbol:

| Mutation | Result | Output file |
|---|---|---|
| Add `IProductRepository products` to `ListExpenses` | RED, `ListExpenses -> Commerce` | `1-commerce-parameter.txt` |
| Add `AppDbContext db` to `ListExpenses` | RED, forbidden persistence type at `ListExpenses` | `2-endpoint-dbcontext.txt` |
| Remove `IFlockRepository flocks` from `ListExpenses` | GREEN, `Loosenable` names `ListExpenses -> FlockManagement` | `3-remove-flock-parameter.txt` |
| Resolve `CreateFlockHandler` in `MigrateCliCommand.RunAsync` | RED, `MigrateCliCommand.RunAsync -> FlockManagement` | `4-cli-flock-service.txt` |
| Add `AppDbContext db` to the inline deactivate handler in `MapEggGradeEndpoints` | RED, forbidden persistence type at `EggGradeEndpoints.cs:37` | `5-lambda-dbcontext.txt` |
| Add `app.MapGet("/probe", (AppDbContext db) => Results.Ok())` before `app.Run()` | RED, forbidden persistence type in `Cluckwork.Api.Program./probe` at `Program.cs:573` | `6-program-mapget-dbcontext.txt` |
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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 |
| [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 |
| [Base reference data via guarded raw-SQL migrations (#283)](283-migrations-base-provisioning.md) | AGENTS · Conventions |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Cluckwork.Application.Tests.Architecture;

using Cluckwork.Application.Tests.TenantBypass;
using Xunit.Abstractions;

public sealed class AdapterReachRealTreeTests(ITestOutputHelper output)
{
private static AdapterReachReport Scan() => AdapterReachScanner.Scan(
Path.Combine(GuardScanner.FindRepoRoot(AppContext.BaseDirectory)
?? throw new InvalidOperationException("repo root not found"), "src"),
Path.Combine(AppContext.BaseDirectory, "Architecture", "Data", "module-ledger.json"));

[Fact]
public void RealSourceTree_EveryAdapterReachIsDeclared()
{
var report = Scan();
output.WriteLine($"Walked {report.WalkedAdapterCount} adapters; {report.LiveReach.Select(r => r.Symbol).Distinct().Count()} non-empty adapter rows.");
output.WriteLine($"Top-level Program adapters: {report.TopLevelProgramAdapterCount}.");
output.WriteLine("Loosenable:\n" + DescribeLoosenable(report));
var failures = AdapterReachScanner.Evaluate(report);
Assert.True(failures.Count == 0, "adapter reach guard failed:\n" + string.Join("\n", failures));
}

[Fact]
public void RealSourceTree_WalksAtLeastFortyAdapters()
{
var report = Scan();
Assert.Equal(40, report.ExpectedAdapterCountFloor);
Assert.True(report.WalkedAdapterCount >= 40, $"walked {report.WalkedAdapterCount} adapters, expected at least 40");
}

internal static void AssertNoLoosenable(AdapterReachReport report) =>
Assert.True(report.Loosenable.Count == 0, "Loosenable:\n" + DescribeLoosenable(report));

private static string DescribeLoosenable(AdapterReachReport report) =>
string.Join("\n", report.Loosenable.Select(a => $"{a.Symbol} -> {string.Join(", ", a.Reaches)}"));
}
Loading
Loading