Skip to content

refactor(accounts): move the cross-farm account reads behind IFarmDirectory - #1043

Merged
mforce merged 5 commits into
mainfrom
refactor/858-p3-farm-directory
Oct 3, 2026
Merged

mforce merged 5 commits into
mainfrom
refactor/858-p3-farm-directory

Conversation

@mforce

@mforce mforce commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Nothing changes for users or operators. list-accounts, the farm-code lookup behind seed --farm-code, suspend-account, reactivate-account and rename-account, and the daily-entry lock sweep send the same SQL and print the same output.

For maintainers, the three cross-farm reads of Accounts that sat in a CLI verb, a CLI helper and a job now live in Farm, behind a new contract interface, IFarmDirectory. Three #850 compatibility rows go with them. A new guard keeps request paths away from the directory, because each of its reads ignores the tenant filter.

This is P3 of #858's approved plan (plan comment). It was built on main at fd8960e4 (#1039's query-shape rule), then main at 75e2b1be (#1037, #857 B) was merged in. That merge conflicted only in the generated coupling-matrix.md, which I regenerated: it differs from main only in the Farm adapter cell this PR changes. The Application suite is 703 of 703 after the merge.

Refs #858

Fix round (c022ab28)

Tests, comments and one decision-record note; no production change.

  • Astra F1 (P2): fixed.
    • FarmDirectoryCallerTests parsed source with no preprocessor symbols, so #if NET10_0 code was invisible to it. It now reuses ModuleLedgerScanner.ParseOptions, with no second symbol list.
    • A fixture case pins a watched name inside an active framework conditional.
    • Astra's m10 and my m11 (dropping the shared options) are red below.
  • Opus F1 (P2): comments corrected. The guard and IFarmDirectory comments now say what the guard actually holds: it keeps the name out of request code, but does not follow a forwarder in an allowed place. Today the forwarder is AccountSlugLookup.ResolveAsync.
  • Opus F6 (851 record): fixed. docs/decisions/851-farm-contract.md gains an amendment note pointing at this PR, so it no longer contradicts src/AGENTS.md. P8 records the final state.
  • Opus F5 (heap order): hardened. The ordering test now compares the whole printed list with the database's own ORDER BY "Slug", keeping only three-column farm rows from stdout (which also carries the host's JSON log lines). A dropped ORDER BY now fails on any inversion, not only on the seeded pair. M2 is red again.
  • Other guards with the same no-symbols parse, listed and not fixed here:
    • GuardScanner.cs:157, :380, :816 (tenant bypass);
    • InsightsReadOnlyTests.cs:65;
    • EggLotLockSqlTests.cs:25;
    • TransactionDelegateShapeTests.cs:109;
    • ExportSnapshotSourceTests.cs:41.
  • Follow-ups, not in this PR:
    • Opus F2: key the caller allowance by declared namespace, not path.
    • Opus F3: normalize inside FindIdBySlugAsync, so both slug lookups share one contract.
    • Opus F4: a separate FarmDirectory(AppDbContext) type, so AccountRepository stops being a watched name.
    • Opus's pre-existing O5: IAccountRepository.FindBySlugAsync is a cross-farm read on Farm's seam that no caller guard covers.
    • Astra's M7: a forwarder added inside an allowed file. Same limit as Opus F1.
  • Merge with refactor(repositories): delete the generic repository base and its unused members #1042: both PRs edit the same src/AGENTS.md paragraph. Whichever lands second keeps both edits: this PR's Farm-directory sentence, and refactor(repositories): delete the generic repository base and its unused members #1042's Insights base-port and Program.cs wording.

What changed

  • IFarmDirectory (Application.Features.Accounts) has three members, one per former call site. Each query moved verbatim:

    Member Former site Query
    ListTimeZonesAsync DailyEntryLockSweep.RunAsync every farm's id and time zone, suspended farms included
    ListAsync ListAccountsCliCommand.RunAsync slug, name and active state, ORDER BY "Slug"
    FindIdBySlugAsync AccountSlugLookup.ResolveAsync ids where the slug matches; null unless exactly one

    The results are two records, FarmTimeZone and FarmListing, so no entity crosses the contract. The anonymous projections became record projections; the SQL is unchanged (trace below).

  • AccountRepository implements it. It is already a listed Farm implementation, so the ledger needs no new implementations entry. It is registered as scoped. The sweep is a singleton, so it keeps resolving the directory inside its existing enumeration scope and never takes it in its constructor.

  • Callers.

  • Ledger.

    • Farm's contract gains IFarmDirectory, FarmTimeZone and FarmListing.
    • Three compatibilityExceptions rows with deleteWhen: #858 are deleted (35 → 32).
    • Three adapter rows are added, each reaching Farm: AccountSlugLookup.ResolveAsync, ListAccountsCliCommand.RunAsync and DailyEntryLockSweep.RunAsync. The matrix cell Platform/adapters → Farm goes from A (17) to A (20).
  • Tenant-bypass allow-list. The three IgnoreQueryFilters sites moved, so their rows moved: three new AccountRepository rows, and four stale ones deleted. The fourth, SuspendAccountCliCommand.RunAsync, was flagged only as a same-file caller of AccountSlugLookup, which is no longer a forwarding wrapper. The allow-list is keyed by symbol and file, so no token hash changes.

  • src/AGENTS.md. The Farm paragraph names IFarmDirectory and the new guard.

The new guard, and why it is needed

On main an endpoint cannot list every farm without adding an IgnoreQueryFilters site, which TenantBypassRealTreeTests makes someone justify. Putting the directory on Farm's contract would remove that friction: the ledger guards let any adapter take a contract type, and the directory's own sites are already allow-listed. So FarmDirectoryCallerTests walks every file under src. It fails when a file outside Api/Cli/ and Infrastructure/Jobs/, other than the directory's declaration, implementation and registration, names IFarmDirectory or AccountRepository. It is name-based like InsightsReadOnlyTests, so an alias or reflection evades it; it says so. P5 extends its allow-list to the seeders, and P7 to the new registration file.

A pin that did not exist

The plan promised to keep list-accounts' ordering, but no test checked it. ListAccounts_PrintsFarmsInSlugOrder renames two seeded farms so their heap order is the reverse of their slug order, then asserts the printed order. Dropping ORDER BY turns it red (M2).

SQL trace

A scratch integration test (not committed) recorded every statement the three call sites send, on main fd8960e4 and on this head, against a fresh database with one extra seeded farm:

  • the sweep's RunAsync, through a command interceptor;
  • AccountSlugLookup.ResolveAsync for a real code and an unknown one, through a command interceptor;
  • a real list-accounts subprocess, with EF's command log raised to Information.

diff trace-main.txt trace-head.txt prints nothing. Head output:

## sweep (locked 0)
SELECT a."Id", a."TimeZoneId"
FROM "Accounts" AS a
## slug lookup (found True, missing True)
SELECT a."Id"
FROM "Accounts" AS a
WHERE a."Slug" = @slug
SELECT a."Id"
FROM "Accounts" AS a
WHERE a."Slug" = @slug
## list-accounts (exit 0, lists seeded True, default True)
SELECT a."Slug", a."Name", a."IsActive"
FROM "Accounts" AS a
ORDER BY a."Slug"

Change map

BEFORE
 list-accounts (Api.Cli)            --> AppDbContext: Accounts, IgnoreQueryFilters        [#850 row, deleteWhen #858]
 AccountSlugLookup (Api.Cli)        --> AppDbContext: Accounts, IgnoreQueryFilters        [#850 row, deleteWhen #858]
   callers: seed --farm-code, suspend-account, reactivate-account, rename-account (unchanged)
 DailyEntryLockSweep.RunAsync (Jobs) --> AppDbContext: Accounts, IgnoreQueryFilters       [#850 row, deleteWhen #858]
   then per farm: own scope, TenantContext.Resolve, IEggOperationsModule.LockSubmittedEntriesAsync

AFTER
 list-accounts, AccountSlugLookup, DailyEntryLockSweep.RunAsync
     --> IFarmDirectory [Farm contract, scoped] --> AccountRepository [listed Farm implementation]
 FarmDirectoryCallerTests: only Api/Cli, Infrastructure/Jobs, and the declaration/implementation/registration may name it
 ledger: Farm contract +3 types; adapters +3 rows reaching Farm; compatibilityExceptions 35 -> 32; matrix Platform/adapters->Farm A(17) -> A(20)
 tenant-bypass allow-list: 3 rows moved to AccountRepository, SuspendAccountCliCommand.RunAsync's wrapper-caller row retired

Files #857 also touches:

Mutation table

Each mutant was an exact single-match replacement in this head, run against the named classes, then reverted. The script and logs are under 858-logs/p3/ on the worker host. Integration mutants ran with #1039's query-shape initializer on.

# Mutant Red Failing assertion, quoted
M1 ListTimeZonesAsync keeps only active farms (.Where(a => a.IsActive)) DailyEntryAdjustTests.LockSweep_StillLocksEntries_ForASuspendedFarm Assert.Equal() Failure: Values differ Expected: Locked Actual: Submitted
M2 ListAsync drops .OrderBy(a => a.Slug) ListAccountsCommandTests.ListAccounts_PrintsFarmsInSlugOrder (new) expected order-e7e46d9c4a70-aa before order-e7e46d9c4a70-zz
M3 FindIdBySlugAsync drops its Where and answers the first row SeedCommand_Demo_WithUnknownFarmCode_ExitsNonZeroAndNamesListAccounts; UnknownSlug_ExitsOne_AndChangesNothing for suspend-account and reactivate-account Assert.Contains() Failure: Sub-string not found; Expected: 1 Actual: 0
M4 ListTimeZonesAsync drops IgnoreQueryFilters() both DailyEntryAdjustTests.LockSweep_* tests Expected: "Locked" Actual: "Submitted"
M5 AccountEndpoints gains a helper taking IFarmDirectory FarmDirectoryCallerTests (new), and AdapterReachRealTreeTests because the helper is a new member These files name IFarmDirectory or AccountRepository outside the operator verbs and jobs. … src/Cluckwork.Api/Endpoints/Accounts/AccountEndpoints.cs
M5b AccountEndpoints.GetSettings, which already reaches Farm, also takes IFarmDirectory only FarmDirectoryCallerTests; adapter, peer and tenant-bypass guards stay green the same message
M6 AccountEndpoints gains a helper taking the concrete AccountRepository only FarmDirectoryCallerTests (Infrastructure is the free Platform hub to the ledger) the same message
m10 (Astra, fix round) GetSettings takes IFarmDirectory and returns every farm, inside #if NET10_0 red at c022ab28: FarmDirectoryCallerTests names AccountEndpoints.cs. At 991b1164 seven guard classes were green and an Owner's GET /account/settings returned every farm (Astra's HTTP proof) These files name IFarmDirectory or AccountRepository outside the operator verbs and jobs. … src/Cluckwork.Api/Endpoints/Accounts/AccountEndpoints.cs
m11 (fix round) the guard parses without ModuleLedgerScanner.ParseOptions again red: the new fixture case ANameInsideAnActiveFrameworkConditional_IsSeen: Assert.True() Failure Expected: True Actual: False
M2 (rerun, fix round) ListAsync drops .OrderBy(a => a.Slug), against the hardened ordering test red Assert.Equal() Failure: Collections differ Expected: ["default-farm", "order-d6b7975ddbfa-aa", "order-d6b7975ddbfa-zz"] Actual: ["default-farm", "order-d6b7975ddbfa-zz", "order-d6b7975ddbfa-aa"]
All reverted Application 704 of 704; ListAccountsCommandTests + SchemaDocsTests 12 of 12

M5b and M6 are why the new guard exists: without it, either change passes every other guard.

Test counts

Suite main fd8960e4 head 57a19466 head after merging 75e2b1be (991b1164)
Whole-solution build (warnings as errors) 0 errors 0 errors 0 errors
Cluckwork.Application.Tests (full) 702 703 (+FarmDirectoryCallerTests) 703
Integration, targeted, with #1039's query-shape initializer: ListAccountsCommandTests, AccountLifecycleCommandTests, DailyEntryAdjustTests.LockSweep*, OneShotVerbMinimalConfigTests, CliDispatcherTests, DurableJobWorkerLeaderGateTests, PostgresLeaderLeaseTests, the three SeedCommandTests farm-code cases, QueryShapeWarningTests, SchemaDocsTests — 75 of 75 (+ListAccounts_PrintsFarmsInSlugOrder) 75 of 75

The full integration suite was not run locally, to spare memory while #857 runs in parallel. CI runs it.

Deslop record

/deslop over git diff origin/main removed nothing more. The src diff moves three queries and deletes their inline copies. Each new comment states a reason the code cannot show:

  • why the directory's reads must ignore the tenant filter;
  • why no request path may call it;
  • why the slug lookup answers null on two matches.

The comments that described the old inline IgnoreQueryFilters call sites were rewritten to point at IFarmDirectory. The three callers lose their Microsoft.EntityFrameworkCore using. The sweep keeps Infrastructure.Persistence for TenantContext.

Try to refute

  • No query changed. The trace above shows the same statements at main and at head.
  • No farm is dropped from the sweep. Suspended farms are still listed (M1), and the read still ignores the tenant filter (M4).
  • No request path can reach the directory. FarmDirectoryCallerTests is the only thing that says so, and it is name-based.
  • The scoped registration cannot be captured by the singleton sweep. The sweep resolves it per run from its own scope, as it resolved AppDbContext before.

mforce added 2 commits October 3, 2026 20:07
…ectory

list-accounts, the farm-code lookup and the daily-entry lock sweep each
read Accounts with the tenant filter off, directly through AppDbContext.
They now call Farm's new IFarmDirectory, which AccountRepository
implements with the same three queries, moved verbatim. A scratch SQL
trace of all three call sites is identical before and after.

That deletes three #850 compatibility rows. The directory is on Farm's
contract, so the ledger guards would let any endpoint take it.
FarmDirectoryCallerTests therefore allows only the operator verbs, the
jobs and the directory's own files to name it or AccountRepository.
ListAccounts_PrintsFarmsInSlugOrder pins the ordering list-accounts
promised but nothing checked.

Refs #858
…directory

# Conflicts:
#	tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md
@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Independent security and code-quality review (Claude Opus 5.5: security-review, thermo-nuclear and ponytail) at head 991b1164. Posted by the coordinator.

PR #1043 security review: Opus 5.5, independent

  • PR: [C] #514 slice 16: Platform composition, jobs, CLI and seeder conversion #858 P3, IFarmDirectory.
  • Head: 991b116407496bfc749302808693b44ed248ab78. Base: 75e2b1be. 12 files, +185/-94.
  • Checkout: detached /home/mforce/.cluckwork-slices/review-1043-opus. git diff 75e2b1be...HEAD is non-empty and matches the saved diff.
  • Logs and mutation script: /home/mforce/.cluckwork-slices/1043-opus-logs/ (mutate.py, run-guards.sh, one log per mutant).
  • Order of work: I formed my view from the diff and the source first. Then I read gh pr view 1043, and then I reproduced its mutants.
  • Not run: the full integration suite (memory limit). PR CI at review time: application, domain, apphost, build, CodeQL and both images pass. Integration, web and Playwright are pending.

Bottom line

There is no product defect and no security defect in this PR.

The findings are about how strong the new guard is, plus comment and doc drift.

  • F1: the guard checks who names the directory, not who reaches it. Mutation O1 shows a request path resolving another farm's id through an allowed-directory helper with every guard green.
  • That gap already existed on main (the tenant-bypass wrapper walk only follows calls within one file), so this PR adds no regression.
  • The PR's comments, however, claim a stronger boundary than the guard enforces.

Findings

F1: P2 (guard soundness). The caller guard checks naming, not reach, and the comments overclaim. CONFIRMED

  • Where:
    • src/Cluckwork.Application/Features/Accounts/IFarmDirectory.cs:4-5: "a request path must never call it; FarmDirectoryCallerTests holds that boundary".
    • tests/Cluckwork.Application.Tests/TenantBypass/FarmDirectoryCallerTests.cs:8-10.
    • src/Cluckwork.Api/Cli/SuspendAccountCliCommand.cs:92-93: the forwarder.
  • Defect: FarmDirectoryCallerTests fails only when a file outside the allowed places contains the identifier IFarmDirectory or AccountRepository. Code inside Api/Cli/ or Infrastructure/Jobs/ may forward the capability, and its callers are invisible to the guard. AccountSlugLookup.ResolveAsync is such a forwarder today: internal static in the Api assembly, where every endpoint lives.
  • Scenario (mutant O1): AccountEndpoints.GetSettings gains HttpContext http and calls Cluckwork.Api.Cli.AccountSlugLookup.ResolveAsync(http.RequestServices, "another-farm").
    • Result: 12 of 12 guard tests green. The guards are FarmDirectoryCaller, AdapterReach, PeerContract, TenantBypass, CompatibilityException and ModuleLedger.
    • An Owner-only request resolves another farm's id from its farm code.
  • Is it new? No.
    • On main the same call stays green, because the tenant-bypass wrapper walk only catches callers in the same file (GuardScanner.cs:171-174, unchanged by this PR).
    • I did not run O1 on main; the test binaries and allow-list differ there. That part is PLAUSIBLE, reasoned from the walk's own comment.
  • How big? Today only a Guid leaks.
    • No forwarder in the allowed directories returns FarmListing or FarmTimeZone.
    • A new forwarder would be a new member that resolves IFarmDirectory, so AdapterReachRealTreeTests would ask for a ledger row (compare M5).
  • The alias caveat is stated the wrong way round. The test comment says "an alias or reflection evades it".
    • O2: a global using FarmDir = …IFarmDirectory; file in Api/Cli/, used by GetSettings. ModuleLedgerRealTreeTests fails ("a global import hides every dependency on it from this walk").
    • A per-file alias names IFarmDirectory in the file itself, so the new guard catches it.
    • The real gap is forwarding, which the comment does not mention.
  • Fix (small):
    1. Delete the AccountSlugLookup.ResolveAsync pass-through. The four callers (seed, suspend, reactivate, rename) are all in Api/Cli/ and can call GetRequiredService<IFarmDirectory>().FindIdBySlugAsync(slug) directly. After that, every route to the directory from a file outside Cli/ and Jobs/ must name the type or call an ICliCommand or sweep RunAsync. Neither of those returns farm data. The PR kept the wrapper to keep [C] #514 slice 15: Access contract — security hold point, runs last #857 D's hunk small, so this MAY wait until D lands.
    2. Reword both comments: the guard holds direct naming; a forwarder in an allowed directory is not followed.

F2: P3. The allow-list is keyed by path, not by what the code declares. CONFIRMED

  • Where: FarmDirectoryCallerTests.cs:15-26, :43-44.
  • Defect: any file under src/Cluckwork.Api/Cli/ may name the directory, whatever it declares. Root AGENTS.md (Replace TenantBypass line-number anchors with stable query identities #632) says to key a registry "by what the code says about itself, never by where it sits".
  • Scenario (mutant O4): a new file src/Cluckwork.Api/Cli/FarmsEndpoints.cs declares namespace Cluckwork.Api.Endpoints.Accounts and maps GET /farms to ListFarms(IFarmDirectory directory, …) => Results.Ok(await directory.ListAsync(ct)).
    • FarmDirectoryCallerTests stays green.
    • Only AdapterReachRealTreeTests fails. Its message hands over a paste-ready {"symbol": "Cluckwork.Api.Endpoints.Accounts.FarmsEndpoints.ListFarms", "reaches": ["Farm"]}, the same shape this PR adds three times.
    • With that row pasted, every authenticated user could list every farm.
  • Fix: key the allowance on the namespace of the enclosing declaration (Cluckwork.Api.Cli, Cluckwork.Infrastructure.Jobs), the same keys the ledger uses for CLI and job adapters. Or require both the path and the namespace to match.
  • While there: the guard does not fail on parse diagnostics. GuardScanner fails on any Error-severity parse diagnostic.

F3: P3. Two cross-farm slug reads with opposite contracts on one class. CONFIRMED (no live defect)

  • Where: AccountRepository.cs:42 vs :71; IFarmDirectory.cs:14; IAccountRepository.cs:39-51.
  • Defect: the two lookups disagree.
    • FindBySlugAsync normalizes inside: "One place, impossible to forget".
    • FindIdBySlugAsync says "The caller normalizes the code".
    • They also differ on duplicates: first match vs exactly one.
  • Scenario: all four current callers call AccountSlugLookup.Normalize first, so nothing is wrong today. A future caller that passes "Farm-A" gets null, "no such farm". That fails closed.
  • Fix: normalize inside FindIdBySlugAsync. The SQL is unchanged, and the "caller normalizes" contract goes away.

F4: P3 (design). The cross-farm capability lives on the tenant-scoped repository. Suggestion

  • Where: AccountRepository.cs:8, with the registrations in CluckworkFeatureServiceCollectionExtensions.cs:139-140.
  • Defect: AccountRepository(AppDbContext, TenantContext) implements both IAccountRepository and IFarmDirectory. Three consequences:
    • The guard has to watch the concrete name AccountRepository, so P5 and P7 must widen the allow-list whenever a file legitimately names it. The PR body plans exactly that.
    • Every request-scoped IAccountRepository instance is an IFarmDirectory at runtime. A downcast names the type and is caught, so this is not exploitable.
    • The "no tenant" property is not visible in the type.
  • Fix: a separate FarmDirectory(AppDbContext db) with the three methods. The constructor shows it has no tenant, and AccountRepository stops being a watched name. The cost is one owners.Farm.implementations entry. Not blocking.

F5: P3. The ordering pin depends on heap layout. PLAUSIBLE

  • Where: tests/Cluckwork.Api.IntegrationTests/ListAccountsCommandTests.cs:104.
  • Evidence: M2 (drop .OrderBy(a => a.Slug)) turned the test red on 2 of 2 isolated runs (int-m2-1.log, int-m2-2.log).
  • Scenario (not reproduced): in a crowded shared database, page pruning during the second UPDATE can put the -aa tuple on an earlier page than -zz. A dropped ORDER BY would then survive.
  • Impact: the test never fails falsely when the code is correct. No action is needed beyond knowing its limit.

F6: P3. Doc and comment drift. CONFIRMED

Context: pre-existing, outside this slice, not a finding against the PR

IAccountRepository.FindBySlugAsync is a cross-farm read that returns the full Account entity. It sits on Farm's seam, which every peer module may take, and no caller guard covers it.

  • Mutant O5: CreateProductHandler, which already holds IAccountRepository, gains await accounts.FindBySlugAsync("another-farm", ct). Result: 12 of 12 guards green.
  • Only IdentityProvider (login) calls it today.
  • It is a candidate for the end-of-session issue batch: restrict callers of the seam's cross-farm member the way this PR restricts IFarmDirectory. Not filed.

Verbatim and invariant checks (all CONFIRMED by reading head)

Check Result
Sweep query Same IgnoreQueryFilters().AsNoTracking(), projection Id, TimeZoneId, no IsActive filter, no order. Tuple → FarmTimeZone and foreach (var (accountId, timeZoneId) …) deconstructs positionally.
Sweep scope IFarmDirectory is resolved inside using (var scope = scopeFactory.CreateScope()) (DailyEntryLockSweep.cs:46-49). The singleton constructor still takes only IServiceScopeFactory and the logger. The per-farm TenantContext.Resolve scopes are unchanged.
#271 single leader DurableJobWorker, PostgresLeaderLease and CluckworkJobServiceCollectionExtensions are absent from the diff.
list-accounts Same OrderBy(a => a.Slug), projection Slug, Name, IsActive, no AsNoTracking (as before). SanitizeForDisplay and the output format are unchanged.
Slug lookup Same Where(Slug == slug).Select(Id).ToListAsync() and Count == 1 ? [0] : null. All four callers still normalize first. An unknown code gives null and exit 1 (PR's M3).
#1039 GetCurrentSharedLockedAsync and GetCurrentLockedAsync keep SingleOrDefaultAsync (AccountRepository.cs:31, :38). The new reads use ToListAsync with no row limit, so no query-shape warning.
DI AddScoped<IFarmDirectory, AccountRepository> beside AddScoped<IAccountRepository, AccountRepository>: separate scoped instances on the same scoped AppDbContext. No captive dependency.
Tenant-bypass rows 3 moved to AccountRepository. 4 deleted, including SuspendAccountCliCommand.RunAsync, which is correct: ResolveAsync no longer contains a banned call. TenantBypassRealTreeTests green.

Mutation results

Guard set for the unit mutants: FarmDirectoryCallerTests, AdapterReachRealTreeTests, PeerContractRealTreeTests, TenantBypassRealTreeTests, CompatibilityExceptionRealTreeTests and ModuleLedgerRealTreeTests. That is 12 tests; baseline 12 of 12 green.

# Mutant Red Matches PR?
M5b GetSettings also takes IFarmDirectory only FarmDirectoryCallerTests yes
M5 AccountEndpoints helper taking IFarmDirectory FarmDirectoryCallerTests + AdapterReachRealTreeTests yes
M6 AccountEndpoints helper taking concrete AccountRepository only FarmDirectoryCallerTests yes
M2 ListAsync drops OrderBy (integration, 2 runs, query-shape initializer on) ListAccounts_PrintsFarmsInSlugOrder both runs; head 8 of 8 green yes
O1 endpoint calls AccountSlugLookup.ResolveAsync nothing, 12 of 12 green F1
O2 global alias of IFarmDirectory in Api/Cli/, used by an endpoint ModuleLedgerRealTreeTests only (new guard green) F1 caveat
O3 peer handler CreateProductHandler takes IFarmDirectory only FarmDirectoryCallerTests n/a
O4 endpoint class in a file under Api/Cli/ AdapterReachRealTreeTests only (new guard green) F2
O5 peer handler calls the seam's FindBySlugAsync nothing, 12 of 12 green context, pre-existing

Every mutant was reverted; git status was clean after each.

security-review skill output

The skill's own git context was empty: it read my session's worktree, not the review checkout. I applied its method to the PR diff directly, and without subagents, as the brief requires. Candidates and confidence:

  1. Cross-farm data exposure through a contract type that adapters may take. No request path does this at head; it needs a further code change. Confidence 3: excluded.
  2. Guard bypass through a forwarding helper (F1). Latent and pre-existing. Confidence 3: excluded as hardening.
  3. SQL injection in the moved queries. They are LINQ with parameterized @slug (PR trace). The test's ExecuteSqlAsync($"…") is a FormattableString, parameterized, and test-only. Not a vulnerability.
  4. Scoped-in-singleton capture in the sweep. Resolved per scope; not a security issue.
  5. Terminal injection in list-accounts output. SanitizeForDisplay is still applied to Name; Slug is regex-bound. Unchanged.

Result: no HIGH or MEDIUM vulnerabilities with confidence ≥ 8.

pstack:thermo-nuclear-code-quality-review (P3, this slice only)

No structural regression, and no file approaches 1k lines (AccountRepository.cs is 83). There is no new branching in existing flows. The PR deletes three inline queries and their Microsoft.EntityFrameworkCore usings, which is the right direction.

  • Code-judo move: delete the forwarder (F1). AccountSlugLookup.ResolveAsync is now an identity pass-through. It is the one piece of indirection in the diff, and it is also the guard's blind spot. Removing it deletes a layer and makes the name guard complete for today's tree in one move.
  • Boundary: put the cross-farm reads on their own type (F4). Then the "no tenant" fact lives in a constructor signature rather than in a comment and a test's watched-name list.
  • Contract coherence: one slug-normalization rule across both slug lookups (F3).
  • Guard design: key by declared namespace, not directory (F2). That matches the ledger's own adapter keys and Replace TenantBypass line-number anchors with stable query identities #632.
  • Rejected alternative: making IFarmDirectory a restricted contract type inside the ledger walk. The adapter walk reads only parameter types and service resolutions, so it would miss a body-level new AccountRepository(…) or a cast that the name scan catches. The boring name scan is the better base.

ponytail:ponytail-review (P3, this slice only)

  • src/Cluckwork.Api/Cli/SuspendAccountCliCommand.cs:L92-93: delete: identity pass-through AccountSlugLookup.ResolveAsync. Four callers call GetRequiredService<IFarmDirectory>().FindIdBySlugAsync(slug) directly, and the ledger row moves to the verbs. (After [C] #514 slice 15: Access contract — security hold point, runs last #857 D.)
  • tests/.../FarmDirectoryCallerTests.cs:L15-26,L43-44: shrink: AllowedFiles plus AllowedDirectories become one AllowedPrefixes array with one StartsWith check. Only .cs files are walked, so an exact file is its own prefix.
  • tests/.../FarmDirectoryCallerTests.cs:L36-38: shrink: parse file directly, then map to the relative path, instead of building the relative path and re-joining it to repoRoot.

net: -7 lines possible.

Verdict

Merge. There is no product or security defect. F1's comment correction and the forwarder deletion SHOULD follow once #857 D lands; F2–F6 are P3.

@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Review of record (Codex Astra, xhigh) at head 991b1164. Posted by the coordinator.

PR #1043 security review

Reviewed head 991b116407496bfc749302808693b44ed248ab78 against 75e2b1be, including the supplied diff, both #858 design reviews, the requested decisions, src/AGENTS.md, the PR's trace/mutation evidence, and #1042 at 0229361374157d48f5dab8f35c5f18022c9a4d9a. All source references below are at the reviewed head.

No product behavior defect or currently reachable cross-farm HTTP disclosure was found in the unmodified PR. There is one confirmed defect in its security regression guard. The query move preserves behavior, but the guard can miss an ordinary direct reference in code compiled into the application.

Finding

F1. P2, CONFIRMED. Parse the caller guard with the application's framework symbols.

Location: tests/Cluckwork.Application.Tests/TenantBypass/FarmDirectoryCallerTests.cs:37.

The new CSharpSyntaxTree.ParseText(...) call supplies no preprocessor symbols. It treats #if NET10_0 code as disabled trivia, although the .NET 10 compiler includes that code in both Debug and Release. DescendantNodes().OfType<IdentifierNameSyntax>() therefore never sees an IFarmDirectory parameter in that branch. This defeats the guard's stated direct-name coverage, without aliases, reflection, a new allowance, or an unregistered adapter.

Failure scenario: an existing Farm-reaching endpoint such as AccountEndpoints.GetSettings gains an IFarmDirectory parameter inside #if NET10_0 and returns await directory.ListAsync(ct). A farm's Owner receives every farm's slug, name and active state. The existing endpoint authorization still runs, but it does not authorize cross-farm reads.

Evidence: I made exactly that temporary mutation. The caller, adapter, peer, tenant-bypass, module-ledger, compatibility-exception and coupling-matrix classes all passed. A scratch integration test built the API, seeded two farms, authenticated as the first farm's Owner, called /api/v1/account/settings, and confirmed that the response contained both farms and default-farm. The test passed against real Postgres with #1039's initializer active. Replacing only the guard's parser options with the existing ModuleLedgerScanner.ParseOptions made the same mutant fail with AccountEndpoints.cs named in the guard's assertion. All changes were reverted.

Remedy: reuse ModuleLedgerScanner.ParseOptions, already used by the architecture scanners, and retain a regression case for a watched type in an active framework conditional. Do not maintain a second symbol list.

Evidence files: mutant diff, seven guard results, HTTP proof source, HTTP proof result, same mutant rejected with shared parse options. Reproduction script.

Query and scope parity

Check Result
Sweep list AccountRepository.cs:57 retains IgnoreQueryFilters, AsNoTracking, and precisely Id/TimeZoneId. No active-farm predicate was added. Anonymous projection plus tuple conversion became a two-field record projection.
Operator list AccountRepository.cs:64 retains IgnoreQueryFilters, OrderBy(a => a.Slug), and precisely Slug/Name/IsActive. ListAccountsCliCommand.cs:30 retains the tab-separated format, active/suspended text and control-character sanitization of the name.
Slug lookup AccountRepository.cs:71 retains IgnoreQueryFilters, AsNoTracking, exact slug equality, the ID projection, ToListAsync, and null unless exactly one row matches. AccountSlugLookup.Normalize remains unchanged at SuspendAccountCliCommand.cs:89; seed, suspend, reactivate and rename still call it before lookup. Unknown-code branches remain unchanged.
#1039 Both SingleOrDefaultAsync calls remain at AccountRepository.cs:31 and :38, on the FOR SHARE and FOR UPDATE reads. The directory lookup's ToListAsync is the original query, not a regression to a warning-producing terminal operation.
Lifetime CluckworkFeatureServiceCollectionExtensions.cs:140 registers IFarmDirectory as scoped. The singleton sweep takes no directory constructor parameter. DailyEntryLockSweep.cs:46 resolves it inside the enumeration scope and materializes the results before disposing that scope.
Per-farm scopes and leadership DailyEntryLockSweep.cs:78 still creates a new scope per farm, resolves that scope's tenant, and invokes Egg Operations there. Cancellation and per-farm error isolation are unchanged. DurableJobWorker, PostgresLeaderLease and AddCluckworkJobs have no diff; the worker still acquires leadership before polling or sweeping.

The queries are behaviorally identical, rather than literally identical source text, because two projections now construct records. I compared the supplied 858-logs/p3/trace-main.txt and trace-head.txt; they are byte-identical and agree with the source. Those traces are the author's evidence, not a second independently captured baseline trace.

Caller boundary and reproduced mutations

IAccountRepository does not inherit IFarmDirectory, expose it through a property/result, or declare the new listing members. Its pre-existing filter-free FindBySlugAsync is unchanged. An ordinary cast to the directory or concrete repository must name a watched type and is caught outside the allowed files. An inheritance change in IAccountRepository.cs would also name the watched interface outside the allowlist.

Mutation Independently observed result
M5b: existing GetSettings takes IFarmDirectory Only FarmDirectoryCallerTests fails; adapter, peer and tenant-bypass classes pass.
M6: endpoint helper takes concrete AccountRepository Only FarmDirectoryCallerTests fails; the same three other classes pass.
M8: endpoint helper resolves GetRequiredService<IFarmDirectory>() Caller guard fails.
M9: mapped typed lambda takes IFarmDirectory Caller guard fails.
M10: existing GetSettings takes the directory under #if NET10_0 Seven guard classes pass; compiled HTTP proof discloses other farms. F1.

I also tried a forwarding bypass absent from the PR's table. A new DirectoryForReview(IServiceProvider) helper in the allowed registration file returns IFarmDirectory; an endpoint calls the helper and then .ListAsync() without spelling either watched type. The caller, adapter, peer and tenant-bypass classes all pass, and the API compiles. See M7 diff, results and build.

That forwarding example requires adding new code to an explicitly trusted file. It demonstrates the existing limitation of a name-based boundary and #846's non-transitive scan, rather than a leaking path already present in this PR. Review must still inspect new methods in allowed files. The guard is useful direct-reference protection after F1, not a runtime authorization boundary or a proof that arbitrary request code cannot reach the directory. I do not request a general call-graph scanner.

Ledger and allowlists

  • Exactly three [C] #514 slice 16: Platform composition, jobs, CLI and seeder conversion #858 compatibility rows disappear, reducing that issue's rows from 35 to 32. AccountRepository was already a Farm implementation; the moved reads qualify there.
  • Exactly three adapter rows appear, for AccountSlugLookup.ResolveAsync, ListAccountsCliCommand.RunAsync and DailyEntryLockSweep.RunAsync. The adapter registry grows from 153 to 156 rows. Each new row reaches Farm.
  • Three bypass entries move to the corresponding directory methods. A fourth old row is deleted, making the entire bypass allowlist 40 to 39 entries. These are IgnoreQueryFilters allowances keyed by file and symbol, not the separate Replace TenantBypass line-number anchors with stable query identities #632 filter-free-set token-hash registry. No query-hash re-pinning is required.
  • The deleted SuspendAccountCliCommand.RunAsync row is truly stale. GuardScanner.cs:165 discovers only same-file wrappers that contain a banned invocation. AccountSlugLookup now contains none. Restoring just that old row makes TenantBypassRealTreeTests.RealSourceTree_AllBypassesAreAllowListed fail with entry matches no occurrence in src/. Proof.
  • The generated matrix changes only Platform/adapters to Farm, A (17) to A (20), in its two renderings. The real matrix regeneration comparison passes.

#1042 merge overlap

They do not merge cleanly in either order. The only conflict is documentation. I ran git merge-tree --write-tree both ways against #1042's observed open head 02293613. Both report one conflict in src/AGENTS.md. The effective PR diffs share no other path; #1042 does not edit IAccountRepository or AccountRepository in its effective diff. The two resulting trees differ only in that conflict's ordering, so the code combines identically.

Resolve the long rule paragraph by retaining this PR's Farm-directory/caller-guard wording and #1042's Insights base-port exclusion and temporary Program.cs registration wording. Do not select one whole side. Neither PR needs to undo #1039's locking-query changes. P2-first merge, P3-first merge.

Code quality

The strict maintainability pass found no new state machinery, speculative layers, branching growth or source file crossing 1,000 lines. The directory has three real consumers and moves ownership without duplicating the queries. Reusing the existing repository and retaining the original scope boundaries is appropriate. F1 should reuse the existing parser configuration rather than add another parser or symbol registry.

ponytail:ponytail-review: Lean already. Ship. This is the complexity-only verdict; F1 blocks the overall review. No deletion proposed, net: -0 lines possible.

Nits

None requiring changes. The forwarding limitation above should be remembered when describing the guard's guarantee.

Verification

All review mutations and the scratch HTTP test were reverted before clean-head validation. Nine targeted Application classes passed, 17 tests total: the caller, adapter, peer, tenant-bypass, module-ledger, compatibility-exception, coupling-matrix, module-contract and seam guards.

Clean-head integration selection Passed
ListAccountsCommandTests 8
DailyEntryAdjustTests.LockSweep* 2
AccountLifecycleCommandTests 24
OneShotVerbMinimalConfigTests 20
DurableJobWorkerLeaderGateTests 4
PostgresLeaderLeaseTests 4
QueryShapeWarningTests 3
The three SeedCommandTests farm-code cases 3
Total 68

The API and affected test projects rebuilt successfully. Every test invocation selected one class, integration commands used sg docker -c, and #1039's module initializer remained active. I did not run the full integration suite. git diff --check passed. Validation log, validation script, mutation script.

The assigned detached checkout was clean and has been removed with the requested git worktree remove command. No commits, pushes or GitHub comments were made, and no other checkout was edited. The report, reproduction scripts and logs remain in this reviews directory.

Verdict: REQUEST CHANGES for the confirmed P2 caller-guard defect; the production query move preserves behavior.

…s symbols

FarmDirectoryCallerTests parsed source with no preprocessor symbols, so a
reference under `#if NET10_0` was invisible to it while the compiler built
it (Astra's m10 leaked every farm through GetSettings past every guard). It
now reuses ModuleLedgerScanner.ParseOptions, with a fixture case for a
watched name inside an active framework conditional.

The guard and IFarmDirectory comments now say what the guard holds: it keeps
the name out of request code, but does not follow a forwarder such as
AccountSlugLookup.ResolveAsync, which #858 P8 deletes after #857 D.
The 851 record gains an amendment note, and the slug-order test compares the
whole printed list with the database's own ORDER BY, reading only farm rows
from stdout.

Refs #858
@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Reply to the Codex Astra review, fixed in c022ab28.

F1 (P2): fixed. FarmDirectoryCallerTests now parses with ModuleLedgerScanner.ParseOptions; there is no second symbol list. A new fixture case, ANameInsideAnActiveFrameworkConditional_IsSeen, pins an IFarmDirectory parameter under #if NET10_0.

  • Your m10 (applied from 1043-astra-m10.diff) is now red: These files name IFarmDirectory or AccountRepository outside the operator verbs and jobs. … src/Cluckwork.Api/Endpoints/Accounts/AccountEndpoints.cs.
  • m11, the guard parsing without the shared options again, turns the fixture case red (Expected: True Actual: False). So the regression case bites.

Other guards that parse real src with no preprocessor symbols, listed and not fixed here:

  • GuardScanner.cs:157, :380, :816 (tenant bypass);
  • InsightsReadOnlyTests.cs:65;
  • EggLotLockSqlTests.cs:25;
  • TransactionDelegateShapeTests.cs:109;
  • ExportSnapshotSourceTests.cs:41.

The InsightsReadOnlyTests.cs:119 call parses fixture strings only.

M7 (forwarder in an allowed file): agreed, it is a limit of a name-based guard. The comments now say so, and the one forwarder that exists today, AccountSlugLookup.ResolveAsync, is down for deletion in #858 P8, after #857 D.

#1042 overlap: noted in the PR body. Whichever PR lands second keeps both src/AGENTS.md edits.

After the fix round: Application 704 of 704; ListAccountsCommandTests and SchemaDocsTests 12 of 12.

@mforce

mforce commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Reply to the Claude Opus review, fixed in c022ab28.

  • F1 (P2): comments corrected; the deletion is deferred.
  • F5: hardened. The ordering test compares the whole printed list with the database's own ORDER BY "Slug", reading only three-column farm rows from stdout. A dropped ORDER BY fails on any inversion. M2 is red: Expected: [… "-aa", … "-zz"] Actual: [… "-zz", … "-aa"].
  • F6: fixed.
    • 851-farm-contract.md has an amendment note pointing at this PR.
    • The AccountSlugLookup comment names all four callers.
    • AccountRenameService's comment no longer cites the moved call site.
  • F2, F3, F4 and O5: follow-ups, listed in the PR body: a namespace-keyed allowance, one slug-normalization contract, a separate FarmDirectory type, and the uncovered FindBySlugAsync on the seam.

mforce added 2 commits October 3, 2026 20:50
…directory

# Conflicts:
#	tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant