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
8 changes: 8 additions & 0 deletions docs/decisions/851-farm-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
**Status:** accepted
**Date:** 2026-10-02

> **Amended 2026-10-03 (#858 P3, PR #1043):** the contract now also lists
> `IFarmDirectory`, `FarmTimeZone` and `FarmListing`. Unlike every member below,
> the directory reads every farm with the tenant filter off, for the operator
> verbs and the lock sweep only. `DailyEntryLockSweep.RunAsync`,
> `AccountSlugLookup.ResolveAsync` and `ListAccountsCliCommand.RunAsync` read
> through it and are no longer compatibility exceptions. #858 P8 records the
> final state.

## What happened

No incident. This is epic #514, Track C slice 9, the second module behind a
Expand Down
2 changes: 1 addition & 1 deletion src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ record you must read before changing the rule. Persistence edits also read

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

- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `I<Module>Module` interface in the module's own Application namespace, implemented by a `<Module>Module` that delegates to the existing handlers and repositories and is registered beside them (Insights' facade is registered in `Program.cs` until #858 P7 moves it). `owners.<Module>.contract` in `module-ledger.json` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator<TCommand>` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations, Commerce and Insights are contracted today. `IInsightsModule` inherits `IReportQueries` and `IExportQueries`; keep both off Insights' list, because listing either lets any adapter or peer inject it past the ledger guards and skip the facade. → [`849-module-contract.md`](../docs/decisions/849-module-contract.md) **Peer modules reach a contracted module only through its contract or `seam` (#1023).** `PeerContractRealTreeTests` runs the adapter walk over every module's own namespaces and fails when a member takes another contracted module's type listed in neither `owners.<Module>.contract` nor `owners.<Module>.seam`. Adapters never get the seam. Farm's seam is `IAccountRepository`, `Account` and `UserRoleAssignment`; add to it only on purpose. `owners.<Module>.types` claims a type that sits in a Platform namespace, so the check stops treating it as free hub. Every `contract`, `seam` and `types` entry names a top-level, non-generic type, and a claimed type declares no nested types, because the walk drops generic arity. Like the adapter check, it reads parameter types and service resolutions, never return types or method bodies. → [`1023-peer-contract-guard.md`](../docs/decisions/1023-peer-contract-guard.md) **Farm's contract stops at settings, logo and banner (#851).** Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](../docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. A peer that injects `IFlockRepository` again fails `PeerContractRealTreeTests` (#1023). → [`852-flock-contract.md`](../docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](../docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](../docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a planner over the locked lots that changes nothing, so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. The port refuses to run without a transaction, and a reservation refuses to plan or draw once the transaction that locked it is no longer current (`EggStockTransactionTests`). Commerce never sees `EggLot`: `PeerContractRealTreeTests` fails when a Commerce type takes an Egg Operations type outside its contract, though it reads only parameter types and service resolutions, not return types or method bodies. `ConfirmSaleAtomicityTests` fails when the confirm runs outside its transaction or saves a draw before the end, each on its own. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](../docs/decisions/854-commerce-contract.md)
- **Adapters reach a contracted module only through the types its ledger row lists (#514/#849).** A module contract is one `I<Module>Module` interface in the module's own Application namespace, implemented by a `<Module>Module` that delegates to the existing handlers and repositories and is registered beside them (Insights' facade is registered in `Program.cs` until #858 P7 moves it). `owners.<Module>.contract` in `module-ledger.json` lists every contract type: the interface, its commands and its `*Details` result records. `AdapterReachRealTreeTests` fails on an adapter parameter or service resolution that reaches the owner through any other type. It also fails on a listed type that is not declared or not owned by that module. `ModuleContractRealAssemblyTests` rejects any persistence type, entity or aggregate inside a contract type at any depth. That rule is stricter than #847, which allows concrete aggregates on other seams. Endpoints still call `IValidator<TCommand>` because commands are contract types. Finance, Farm, Flock Management, General Inventory, Egg Operations, Commerce and Insights are contracted today. `IInsightsModule` inherits `IReportQueries` and `IExportQueries`; keep both off Insights' list, because listing either lets any adapter or peer inject it past the ledger guards and skip the facade. → [`849-module-contract.md`](../docs/decisions/849-module-contract.md) **Peer modules reach a contracted module only through its contract or `seam` (#1023).** `PeerContractRealTreeTests` runs the adapter walk over every module's own namespaces and fails when a member takes another contracted module's type listed in neither `owners.<Module>.contract` nor `owners.<Module>.seam`. Adapters never get the seam. Farm's seam is `IAccountRepository`, `Account` and `UserRoleAssignment`; add to it only on purpose. `owners.<Module>.types` claims a type that sits in a Platform namespace, so the check stops treating it as free hub. Every `contract`, `seam` and `types` entry names a top-level, non-generic type, and a claimed type declares no nested types, because the walk drops generic arity. Like the adapter check, it reads parameter types and service resolutions, never return types or method bodies. → [`1023-peer-contract-guard.md`](../docs/decisions/1023-peer-contract-guard.md) **Farm's contract stops at settings, logo and banner (#851), plus the cross-farm `IFarmDirectory` (#858).** `IFarmDirectory` ignores the tenant filter so the operator verbs and the lock sweep can list farms and resolve a farm code; `FarmDirectoryCallerTests` fails when a file outside `Api/Cli`, `Infrastructure/Jobs` and the directory's declaration, implementation and registration names it or `AccountRepository`, because a request path that reached it could show one farm another's. Sign-in resolves its farm code through `IIdentityProvider.ResolveFarmCodeAsync`, never `IFarmModule`, because that lookup establishes identity before `TenantContext` exists. `FarmClock` stays on `IAccountRepository` because it sits in the Platform hub. Peer modules keep calling `IAccountRepository` and `Domain.Accounts` directly, the #162 locked currency snapshot included; do not fold that seam into the contract. → [`851-farm-contract.md`](../docs/decisions/851-farm-contract.md) **Flock Management has three contract interfaces (#852).** Adapters call `IFlockModule`; peer modules call the `IFlockLookup` read port and the `IMortalityLedger` write port, so a peer request does not construct six lifecycle handlers it never uses. `IMortalityLedger.AppendAsync` adds the movement to the caller's unit of work and never saves; keep it that way, because `MortalityLedgerAtomicityTests` is all that holds Submit's mortality in the entry's commit. `IFlockLookup.ResolveByNameAsync` returns `Ambiguous` with every candidate on a duplicate name, never a first match. A peer that injects `IFlockRepository` again fails `PeerContractRealTreeTests` (#1023). → [`852-flock-contract.md`](../docs/decisions/852-flock-contract.md) **General Inventory keeps `RecordFeedUsage`'s lock order (#855).** The handler locks the item, reads flock eligibility through `IFlockLookup`, then locks the FIFO lots, in one transaction; `FeedUsageLockOrderTests` fails if either read moves. Keep `IFlockLookup`'s reads untracked (#1022): a tracked read hands the in-transaction check the flock loaded before the transaction, stale. → [`855-inventory-contract.md`](../docs/decisions/855-inventory-contract.md) **Egg lots lock in `(ProductionDate, Id)` order on every path (#853).** Confirm, sale void and daily-entry adjust and void each take `FOR UPDATE` on `EggLots` in that order, so they queue instead of deadlocking; before #853 nothing failed when it changed. `EggLotLockOrderTests` fails when a request locks the later of two lots first, on different dates and on one date. `EggLotLockSqlTests` fails when any `FOR UPDATE` over `EggLots` stops ending `ORDER BY "ProductionDate", "Id"`; it covers the one change the behaviour test cannot see, a dropped `Id` tie-breaker on the sale-void read, whose rows the confirm has already rewritten in canonical order. Egg Operations splits like #852: adapters call `IEggOperationsModule`, and peers call `IEggGradeLookup` and `IDailyEntryLookup`; `SaleEndpoints`, an adapter that needs only grade names, also takes `IEggGradeLookup`. → [`853-egg-operations-contract.md`](../docs/decisions/853-egg-operations-contract.md) **Commerce reaches egg stock only through `IEggStock` (#854).** Confirm and void call Egg Operations' `IEggStock`: one farm-wide `FOR UPDATE` per confirm, a planner over the locked lots that changes nothing, so the #612 assigned-flock retry takes no second lock, and draws and restores that join the caller's transaction and never save. The port refuses to run without a transaction, and a reservation refuses to plan or draw once the transaction that locked it is no longer current (`EggStockTransactionTests`). Commerce never sees `EggLot`: `PeerContractRealTreeTests` fails when a Commerce type takes an Egg Operations type outside its contract, though it reads only parameter types and service resolutions, not return types or method bodies. `ConfirmSaleAtomicityTests` fails when the confirm runs outside its transaction or saves a draw before the end, each on its own. `EggLotLockOrderTests` has a two-grade confirm because a single grade cannot show a lock per grade. Farm's currency probe asks every `ICurrencyBoundRowSource`, which the repository owning each probed table implements; a new table that stores a farm-currency amount registers one. Adapters call `ICommerceModule`; peers read packed-unit conversions through `IEggUnitConversionLookup`. → [`854-commerce-contract.md`](../docs/decisions/854-commerce-contract.md)
- **Register every read of a contracted module's tables from outside it (#514/#850).** `CompatibilityExceptionRealTreeTests` compiles `src/Cluckwork.Infrastructure` from source, and the projects referencing it with errors tolerated. It finds every member that obtains a `DbSet<T>`, maps `T` through the EF model to the tables a query touches (its own, owned values mapped apart and derived types), and takes each table's owner from the ledger's `tables`, never the entity's namespace. A read of a contracted module's table is allowed for the module's own types, a type its ledger edge's `symbols` names, a type listed under `owners.<Module>.implementations`, and a `DbSet` property whose whole body is `Set<T>()`. Every other read needs a `compatibilityExceptions` row in `module-ledger.json`, keyed by namespace, type and member, with `reaches`, the `tables` read, a ledger `owner`, a `reason` and `deleteWhen`, the slice issue that deletes it. `deleteWhen` is `#<issue>`, never a date. A new read, a table the row does not name, a `DbSet` of a type parameter and an unbound candidate name fail; a row or table no longer read fails as stale; an Infrastructure compile error fails closed. SQL text is not read. The next contract slice lists its implementations and registers its module's other readers. → [`850-compatibility-exceptions.md`](../docs/decisions/850-compatibility-exceptions.md)

### Data and correctness
Expand Down
17 changes: 5 additions & 12 deletions src/Cluckwork.Api/Cli/ListAccountsCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Cluckwork.Infrastructure.Persistence;
using Cluckwork.Application.Features.Accounts;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;
Expand All @@ -10,9 +9,9 @@ namespace Cluckwork.Api.Cli;
// the farm code mandatory at login, so the first upgraded deployment needs a
// supported way to discover its own code BEFORE provisioning (#533) exists.
//
// Reads ACROSS accounts, so it runs with an unresolved tenant and must use
// IgnoreQueryFilters() on purpose (that call site is on #536's justified
// allow-list). Run-then-exit like the other verbs; classified OneShot
// Reads ACROSS accounts with an unresolved tenant, through Farm's
// IFarmDirectory (#858), whose query is on #536's justified allow-list.
// Run-then-exit like the other verbs; classified OneShot
// automatically via CliDispatcher.Commands (#347) and NOT environment-gated.
// It does not migrate — like recover-admin it only reads an already-migrated
// database — and it wraps its work fail-loud so a missing/unreachable database
Expand All @@ -26,13 +25,7 @@ public async Task<int> RunAsync(WebApplication app, string[] args)
try
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

var accounts = await db.Accounts
.IgnoreQueryFilters()
.OrderBy(a => a.Slug)
.Select(a => new { a.Slug, a.Name, a.IsActive })
.ToListAsync();
var accounts = await scope.ServiceProvider.GetRequiredService<IFarmDirectory>().ListAsync();

foreach (var account in accounts)
await Console.Out.WriteLineAsync(
Expand Down
30 changes: 7 additions & 23 deletions src/Cluckwork.Api/Cli/SuspendAccountCliCommand.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using Cluckwork.Application.Features.Accounts;
using Cluckwork.Infrastructure.Identity;
using Cluckwork.Infrastructure.Persistence;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Cluckwork.Api.Cli;
Expand Down Expand Up @@ -78,11 +77,10 @@ await Console.Out.WriteLineAsync(result.Value.Changed
}
}

// Shared by both lifecycle verbs (#534). Reads ACROSS accounts with no tenant
// resolved, so IgnoreQueryFilters() is required rather than defensive — without
// it the account query filter matches Guid.Empty and returns zero rows for every
// real farm. Same justified call site as ListAccountsCliCommand's; #536
// enumerates both.
// Shared by seed, suspend-account, reactivate-account and rename-account (#534).
// The cross-farm read lives in Farm's IFarmDirectory (#858). ResolveAsync only
// forwards to it, which hides the directory from FarmDirectoryCallerTests; #858
// P8 inlines it into the four verbs once #857 D has moved them.
internal static class AccountSlugLookup
{
// Slugs are stored already-lowercased (Account.ValidateSlug REJECTS uppercase
Expand All @@ -93,20 +91,6 @@ internal static class AccountSlugLookup
internal static string? Normalize(string? slug) =>
string.IsNullOrWhiteSpace(slug) ? null : slug.Trim().ToLowerInvariant();

internal static async Task<Guid?> ResolveAsync(IServiceProvider services, string slug)
{
var db = services.GetRequiredService<AppDbContext>();
var matches = await db.Accounts
.IgnoreQueryFilters()
.AsNoTracking()
.Where(account => account.Slug == slug)
.Select(account => account.Id)
.ToListAsync();
// Slug carries a unique index, so 0 or 1 — SingleOrDefault would be
// equally correct and would THROW on a hand-corrupted database rather
// than picking one arbitrarily; this returns null, which the caller
// reports as "no such farm". Either is defensible; not-found is the
// quieter failure for a break-glass-adjacent tool.
return matches.Count == 1 ? matches[0] : null;
}
internal static Task<Guid?> ResolveAsync(IServiceProvider services, string slug) =>
services.GetRequiredService<IFarmDirectory>().FindIdBySlugAsync(slug);
}
Loading
Loading