Skip to content

fix(audit): store catalog enums by name and guard the add-item transaction shape - #751

Merged
mforce merged 3 commits into
mainfrom
fix/746-743-audit-correctness
Sep 10, 2026
Merged

mforce merged 3 commits into
mainfrom
fix/746-743-audit-correctness

Conversation

@mforce

@mforce mforce commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Two audit-payload correctness items on the code #722 just shipped, in one PR because neither is more than a handful of lines and both are the same subject.

#746 — catalog enums stored as ordinals

AuditWriter serialises details with new JsonSerializerOptions(JsonSerializerDefaults.Web) and registers no JsonStringEnumConverter, so a bare enum in a payload is stored as its underlying integer. Two call sites did exactly that:

File Field Was
CreateProductHandler.cs:65 ProductType ordinal
UpdateProductHandler.cs:78 DefaultUnit ordinal

A stored ordinal is only meaningful against the enum's member order at the time it was written. Reorder or insert a member and every historical row silently re-reads as a different value, with nothing failing — the JSON is still valid and the number is still a number. That is a data-retention defect in the one table whose purpose is being trustworthy after the fact.

Both now store the name, which is the house idiom for this payload shape (UpdateFarmSettingsHandler.cs:158-164, UpdateEggUnitConversionHandler.cs:30, and #722's own listPriceBasis).

Deliberately not done: adding a converter to AuditWriter's options. That would silently change the shape of every existing payload across ~25 call sites.

Existing rows are not repaired. They carry ordinals and cannot be recovered from the data alone — a backfill would need the enum's member order at write time, which is not recorded. This stops the defect widening.

#743 — nothing notices an exit below the inner save

AddOrderItemHandler calls unitOfWork.SaveChangesAsync(token) inside its ExecuteInTransactionAsync delegate, so the audit row can carry the SalesOrderItem.Id EF assigns at that save. That is deliberate (#722) and correct.

The hazard is what happens after it. If the delegate exits below that save, the transaction rolls back — but the order and the new item are now tracked as Unchanged, i.e. they look persisted. A later flush on the same scoped AppDbContext would not re-write them; it would silently drop them. UpdateFarmSettingsHandler guards the analogous case with accounts.DiscardChanges(account) on its !committed branch (:104-109); this delegate has no equivalent.

It is not a bug today — the only return false sits above the save, and all three of SalesOrder.AddItem's failure paths return before _items.Add. It becomes one the moment someone adds an exit below the save, and the compiler does not stop them: if (result.Value.Id == Guid.Empty) return false; inserted there builds clean. That was verified by running it, not assumed.

So this adds the guard that notices. TransactionDelegateShapeTests walks every .cs under src/ with Roslyn, finds every ExecuteInTransactionAsync delegate that saves inside itself, and fails on a return false or throw below that save. It is deliberately fail-closed in four ways: a parse error fails, a call site whose shape it does not recognise fails rather than being skipped, a file-count floor catches a path-filter bug that silently excludes a subtree, and it asserts the in-scope set is non-empty and still contains AddOrderItemHandler.cs — so a future refactor that moves the save out shows up as "this guard went vacuous" instead of passing forever on an empty set.

Its stated limit, recorded in the file header: it catches a syntactic return false or throw. It does not catch an exception thrown by a call below the save — banning that would ban the audit write itself. That case is real and is covered behaviourally by AddItem_WhenTheAuditWriteFails_RollsBackTheLine, which drives exactly that path.

AddOrderItemHandler now carries a comment naming the guard, per the repo rule that a syntax guard has to be readable from the call site it guards.

Verification

Driver-verified on this head (not quoted from the implementer):

Check Result
dotnet build Cluckwork.sln 0 Warning(s), 0 Error(s)
Cluckwork.Domain.Tests 388 / 388, unchanged
Cluckwork.Application.Tests 260 → 261, all green (+1: the guard)
Cluckwork.Api.IntegrationTests 1726 → 1728, all green (+2: the payload tests)

The before-counts are read off main's own CI run at 1a07441, not remembered.

Mutation ledger

The control row is the one that matters most — a ledger of only-red rows cannot show that a guard discriminates rather than merely fires.

Row Mutation Expected Observed
C (control) return false above the inner save — legal GREEN GREEN
M3 return false below the inner save — #743 itself RED RED
M4 inner save deleted — the guard's anchor gone RED (fail-closed) RED
M5 the same hazard with no literal false — return shouldAbort ? false : true; RED RED
M1 drop .ToString() on ProductType RED RED
M2 drop .ToString() on DefaultUnit RED RED

Each mutant was built, run, then restored root-anchored (git restore --source=HEAD --staged --worktree) with git diff --exit-code clean, rebuilt and re-run green. M1 and M2 each reddened only their own test, so they are targeted rather than a blanket break. The whole ledger was re-run on the final head after the last review commit — a value carried from an earlier head is not a value here.

What review actually bought

Three findings, all in the guard, none in the two handler edits:

  1. The file-count floor was 300 against a real tree of 454 files (measured, not remembered) — 154 files of slack, enough for a path-filter bug that dropped a whole project to still read as "no violations". Raised to 400, matching GuardScanner.RealTreeFileFloor on the same tree.
  2. Assert.Empty(violations) truncated the message xUnit prints, so the guard's instruction — the entire product of Sales: AddOrderItemHandler saves inside its transaction delegate with no rollback cleanup for tracked entities #743 — was unreadable when it fired. Now message-carrying.
  3. M5 above. The guard originally matched only a literal false, so return shouldAbort ? false : true; below the save built clean and the guard stayed green — the identical defect, invisible. Found by an adversarial seat that ran the mutation instead of reading the code. The rule is now an allow-list: below the last save, the only permitted exit is return true;, which is also the simpler guard.

Closes #746.
Closes #743.

…ction shape

CreateProductHandler and UpdateProductHandler serialised ProductType and
DefaultUnit as bare enums. AuditWriter registers no JsonStringEnumConverter,
so both stored the ordinal — meaningful only against the member order at
write time, and silently re-read as a different member after any reorder.
Both now store the name, the way every other payload in the repo does.

AddOrderItemHandler saves inside its ExecuteInTransactionAsync delegate so
the audit row can carry the EF-assigned line id. Nothing noticed if an exit
were added below that save, which would leave rolled-back entities tracked
as Unchanged and silently droppable by a later flush. A Roslyn guard now
walks every such delegate under src/ and fails on one.

Closes #746.
Closes #743.
…messages

Review round 1: the file-count floor (300) left 154 files of slack against
the measured 454 .cs files under src/ — enough for a path-filter bug to
exclude a whole subtree and still pass. Raised to 400 to match the sibling
guard on the same tree, GuardScanner.RealTreeFileFloor, and corrected the
comment to the measured count and date.

Assert.Empty(violations) truncates xUnit's collection preview to ~100 chars,
hiding the guard's entire payload — the file, line, and remediation text —
exactly when it fires for real. Replaced the three collection assertions
with message-carrying Assert.True forms so the full text always prints.

Re-verified with the same mutant used in the original red proof (an exit
below the inner SaveChangesAsync in AddOrderItemHandler.cs): the guard now
prints its full, untruncated message with no debug workaround needed.
…return value

Review round 2 (proved by mutation): matching only the literal `false`
missed the same #743 hazard written any other way — `return shouldAbort ?
false : true;`, a bool variable, or a call — because none of those is a
LiteralExpressionSyntax of kind FalseLiteralExpression. All built clean and
stayed green.

Inverted the rule to an allow-list: below the last SaveChangesAsync inside
ExecuteInTransactionAsync's delegate, the only legitimate exit is exactly
`return true;` (commit). Every other return value is flagged, regardless of
how it's spelled. Updated the file header to state the wider rule and its
one remaining, unchanged limit (a throw from a nested call that isn't a
`throw` statement in this delegate).

Verified by three mutation runs: the reviewer's non-literal mutant now reds,
the original literal `return false;` mutant still reds, and the same
literal exit placed ABOVE the save (the control) stays green.
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 2d558afe-3324-447c-b3b6-30eec3653c07


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mforce
mforce merged commit 23609ff into main Sep 10, 2026
11 checks passed
@mforce
mforce deleted the fix/746-743-audit-correctness branch September 10, 2026 17:16
mforce pushed a commit that referenced this pull request Sep 12, 2026
🤖 I have created a release *beep* *boop*
---


## [0.1.0](v0.0.4...v0.1.0)
(2026-09-12)


### ⚠ BREAKING CHANGES

* log in by farm code, with per-account email identity
([#532](#532)) (#564)

### Features

* **accounts:** add Account.Slug (farm code), suspend/reactivate,
list-accounts verb
([#531](#531))
([3fe9754](3fe9754))
* **accounts:** provision additional farms
([#581](#581))
([006f298](006f298))
* add Aspire local development AppHost
([#567](#567))
([2c9e6b9](2c9e6b9))
* add configurable worker sale allocation
([#619](#619))
([0955095](0955095))
* add searchable entity pickers
([#642](#642))
([60d2053](60d2053))
* **api:** provision-account takes an optional --timezone at creation
([#603](#603))
([#694](#694))
([a0aee39](a0aee39))
* **audit:** show the sales-line audit payload as a readable Details
column ([#745](#745))
([#749](#749))
([d26d389](d26d389))
* **auth:** add ApplicationUser.StepUpLogoutEpoch column
([#338](#338))
([#554](#554))
([18306ee](18306ee))
* certify over-cap simulation fixture bands
([#633](#633))
([a67b2e1](a67b2e1)),
closes [#627](#627)
* **cli:** rename-account verb to change a farm code
([#732](#732))
([#733](#733))
([4b70559](4b70559))
* **customers:** edit existing customer details
([#625](#625))
([#626](#626))
([062a55c](062a55c))
* **jobs:** single-runner leader gate for the durable job worker
([#271](#271))
([#555](#555))
([4148f9b](4148f9b))
* let owners change user email addresses
([#605](#605))
([842347b](842347b))
* log in by farm code, with per-account email identity
([#532](#532))
([#564](#564))
([68adb62](68adb62))
* **ratelimit:** distributed IP-keyed auth limiters
([#544](#544))
([#558](#558))
([ec14972](ec14972))
* **ratelimit:** distributed per-account report concurrency cap with
local-ceiling fallback
([#545](#545))
([#559](#559))
([1522e4e](1522e4e))
* **sales:** mark discounted lines, total the discount, and show it in
the Orders list ([#723](#723),
[#724](#724))
([#741](#741))
([1a07441](1a07441))
* **sales:** record list, old and new price in the order-line audit
payload ([#722](#722))
([#742](#742))
([97c866f](97c866f))
* **sales:** refuse an over-ceiling confirm from a Sales user
([#727](#727))
([#766](#766))
([8c0792a](8c0792a))
* **sales:** show what each order still owes, and filter the list to
unpaid ([#771](#771))
([ca59d68](ca59d68))
* **sales:** snapshot the list price on the order line and show the
discount ([#734](#734))
([cffed5e](cffed5e))
* **sales:** snapshot the product name and unit in the order-line audit
payload ([#747](#747))
([#748](#748))
([0481c06](0481c06))
* scope Worker reads to assigned flocks
([#388](#388))
([#611](#611))
([5884a9a](5884a9a))
* shared-state ports with Redis + in-process fallback
([#543](#543))
([#552](#552))
([f767fa9](f767fa9))
* suspend-account / reactivate-account operator verbs
([#534](#534))
([#573](#573))
([d0be26c](d0be26c))
* **tenancy:** write-side tenant guard + single-assignment TenantContext
([#546](#546))
([#561](#561))
([f371f1d](f371f1d))
* **web:** dashboard rework — capture-status tiles, 14-day trend, stock
as a stacked bar
([#654](#654))
([396ba23](396ba23))
* **web:** date-range filters on audit and expenses, and the stock lot
filter gets its bounded toolbar
([#666](#666),
[#667](#667),
[#653](#653))
([94b188f](94b188f))
* **web:** elevation hierarchy and sentence-case labels
([#651](#651),
[#652](#652))
([#661](#661))
([28db4c7](28db4c7))
* **web:** Expenses and Audit keep a clear-filters control while rows
are still showing
([#679](#679))
([#697](#697))
([b859982](b859982))
* **web:** expenses filters by a date range like its sibling screens
([#667](#667))
([f13858f](f13858f))
* **web:** key the farm brand palette per farm
([#586](#586))
([#600](#600))
([7183a43](7183a43))
* **web:** let operators forget remembered farms
([#598](#598))
([577d94e](577d94e))
* **web:** one-line provenance, bounded date filters, and empty states
that invite action
([#653](#653),
[#655](#655))
([#668](#668))
([80b53f4](80b53f4))
* **web:** prefill the farm code from ?farm= and remember it
([#535](#535))
([#588](#588))
([b7f5cc6](b7f5cc6))
* **web:** split authenticated routes into lazy chunks
([#620](#620))
([5089271](5089271))
* **web:** the audit log filters by a date range, and says which window
is empty ([#666](#666))
([63027e0](63027e0))
* **web:** typeset numbers as numbers and refresh the Help glossary
([#650](#650),
[#657](#657))
([af4fe11](af4fe11))


### Bug fixes

* **api:** order same-instant audit events by a durable monotonic key
([#700](#700))
([8fcf084](8fcf084))
* **api:** print the farm code from bootstrap-admin
([#589](#589))
([#594](#594))
([34032ac](34032ac))
* **audit:** show the price a line sold for, not its list price
([#759](#759))
([e6b37d0](e6b37d0))
* **audit:** store catalog enums by name and guard the add-item
transaction shape
([#751](#751))
([23609ff](23609ff))
* **auth:** reject invalid account claims
([#622](#622))
([8d6c7fe](8d6c7fe))
* **auth:** require step-up for durable user access
([#360](#360))
([#607](#607))
([f767dce](f767dce))
* **ci:** bound the npm audit calls and give the web job room to finish
([#686](#686))
([153b7a8](153b7a8))
* **ci:** escalate the audit bound to SIGKILL, so it actually bounds
([#686](#686))
([a0c8f4e](a0c8f4e))
* **ci:** fail closed on invalid vulnerability config
([#621](#621))
([1690db8](1690db8))
* **ci:** lockfix covers the two AppHost lock files, derived from the
sln
([efb05e6](efb05e6))
* **ci:** lockfix covers the two AppHost lock files, derived from the
sln
([8986d77](8986d77))
* **ci:** remove invalid XML comment from nuget.lockfix.config
([#541](#541))
([5f1bc0a](5f1bc0a))
* **ci:** the advisory vuln gate no longer blocks on an unusable report
([#686](#686))
([aaf6934](aaf6934))
* **ci:** the advisory vuln gate no longer blocks on an unusable report
([#686](#686))
([64f1f53](64f1f53))
* **i18n:** tl help text names the saleable flag and unit-system setting
what their labels call them
([#688](#688))
([#696](#696))
([bfd24d7](bfd24d7))
* **infra:** AccountId must be a non-nullable Guid or both tenant write
layers refuse ([#673](#673))
([#695](#695))
([2470c4e](2470c4e))
* require step-up for flock scope changes
([#609](#609))
([4151f89](4151f89))
* **sales:** keep a line's discount markers agreeing while its price is
edited ([#752](#752))
([#753](#753))
([c159b4b](c159b4b))
* **sales:** say which kind of missing list price a line has
([#774](#774))
([489180e](489180e))
* scope legacy logout to selected farm
([#624](#624))
([fae8d82](fae8d82))
* **seed:** drain the daily-entry lock sweep so deep simulation fixtures
validate ([#644](#644))
([730fa23](730fa23)),
closes [#638](#638)
* **tenancy:** AccountId is a concurrency token, so the database refuses
a detached cross-tenant write
([#562](#562))
([4d1dfa3](4d1dfa3))
* **tenancy:** AspNetUserRoles carries a tenant column, so a role write
naming another farm's user is refused
([#670](#670))
([fc0552a](fc0552a))
* **tests:** bump the image-pin allow-list counts for the AppHost
LocalPorts tests
([#593](#593))
([58d3056](58d3056))
* **tests:** the OTLP collector survives a lost port race and ignores
traffic that is not an export
([#672](#672),
[#676](#676))
([#677](#677))
([965c737](965c737))
* **web:** a scoped audit view filtered to nothing names both the record
and the range ([#666](#666))
([41bbfe1](41bbfe1))
* **web:** an abandoned dialog attempt's success no longer hijacks the
replacement on Customers, Daily Entry, Flocks, Grades and Products
([#703](#703))
([#705](#705))
([85605db](85605db))
* **web:** an abandoned dialog attempt's success no longer hijacks the
replacement on Inventory, Expenses, History and Stock
([#703](#703))
([#706](#706))
([60a4997](60a4997))
* **web:** an abandoned edit's success no longer hijacks the dialog that
replaced it on Users
([#703](#703))
([#710](#710))
([778faab](778faab))
* **web:** an abandoned order attempt's success no longer hijacks the
dialog that replaced it
([#702](#702))
([522c699](522c699))
* **web:** capture screens open on the flock you last used, and
assigning one no longer guesses
([#646](#646))
([#699](#699))
([7f8f317](7f8f317))
* **web:** constrain dialog session helpers to declared scopes
([#715](#715))
([389e3c8](389e3c8))
* **web:** date validation gets one boundary table instead of one case
per review round
([#666](#666))
([215f830](215f830))
* **web:** keep a paged window and an item panel on the user's newest
intent ([#645](#645))
([d81bccf](d81bccf))
* **web:** keep Sales order panels closed after pending writes
([#711](#711))
([f0f7492](f0f7492))
* **web:** keep Sales panels closed after pending Open reads
([#716](#716))
([620411f](620411f))
* **web:** make login take the cross-tab cookie lock so a racing refresh
cannot restore the wrong session
([#648](#648))
([ff18beb](ff18beb))
* **web:** make the entity picker read as a search field and focus it on
open ([#736](#736))
([66ef667](66ef667)),
closes [#735](#735)
* **web:** page truncated customer and movement tables with usePagedList
([7cfe4d6](7cfe4d6))
* **web:** reconcile Sales line edits with refreshed orders
([#717](#717))
([d7dd2c9](d7dd2c9))
* **web:** the audit date filter accepts low-numbered years, and its
empty state covers every narrowing
([#666](#666))
([af52d25](af52d25))
* **web:** the audit date filter rejects impossible dates, and its
history guard actually guards
([#666](#666))
([8d51846](8d51846))
* **web:** the expense range bounds are not capped at today, which the
month-end default exceeds
([#667](#667))
([7e01864](7e01864))
* **web:** the help text calls the expiry field what the field calls
itself ([#666](#666))
([2fd1f3c](2fd1f3c))
* **web:** the stock lot date range sits in the bounded toolbar
([#653](#653))
([43dec5e](43dec5e))


### Refactoring

* **web:** extract SalesPage's dialog-write wrapper into a shared
useDialogAction hook
([#703](#703))
([#704](#704))
([60ee9d9](60ee9d9))


### Documentation

* add k6 preparation steps to the dev-database fixture runbook
([#643](#643))
([a4f1f09](a4f1f09))
* add runbook for loading the simulation fixture into a dev database
([#639](#639))
([2d143b8](2d143b8))
* **agents:** a PR closes its issue from the body, not the title
([#744](#744))
([39be13c](39be13c))
* **agents:** drop the commit and push gate, and require screenshots on
UI changes ([#757](#757))
([6225172](6225172))
* **agents:** find guards by grepping registry readers; amend issues a
PR overtakes ([#580](#580))
([fe3fde8](fe3fde8))
* **agents:** the Playwright specs have been in CI since 2026-08-08
([#768](#768))
([68ee612](68ee612))
* **aspire:** record the second local database and pin the AppHost
dashboard ports ([#623](#623))
([713b941](713b941))
* compress AGENTS.md to one paragraph per rule, and draw the two orders
that matter ([#551](#551))
([997ae8a](997ae8a))
* item 7 names each screen's actual initial filter value
([#666](#666))
([70a53d8](70a53d8))
* multi-farm tenancy decision record and AGENTS/GLOSSARY sync
([#537](#537))
([#601](#601))
([2c34771](2c34771))
* name the scoped filtered-empty key and state the
[#653](#653) relationship
plainly ([#666](#666))
([0e93dac](0e93dac))
* note that a PackageReference in Directory.Build.props is invisible to
the dependency graph
([4845724](4845724))
* **plans:** commit the
[#722](#722) and
[#745](#745) design records
([#754](#754))
([c942fcd](c942fcd))
* record [#579](#579) as
won't-fix — suspension is immediate for use, not issuance
([#582](#582))
([7a3be40](7a3be40))
* record the [#508](#508)
audit ordering key and the tracked-file guard lesson
([#701](#701))
([08964e9](08964e9))
* **runbooks:** add procedure to rename the default farm's code after
upgrade ([#731](#731))
([2f6e242](2f6e242))
* screenshots of the running SPA in the README
([#550](#550))
([711488a](711488a))
* **sim:** commit the dashboard screenshot, capture the palette matrix,
and record the
[#651](https://github.com/mforce/cluckwork/issues/651)/[#652](https://github.com/mforce/cluckwork/issues/652)
conventions ([#660](#660),
[#662](#662),
[#663](#663),
[#664](#664))
([#665](#665))
([930ea30](930ea30))
* specify searchable entity picker
([#641](#641))
([91d4300](91d4300))
* split the README into audience-scoped docs and adopt repo-template
scaffolding ([#548](#548))
([b3f3fcf](b3f3fcf))
* surface Aspire local development workflow
([#568](#568))
([a343baa](a343baa))
* **web:** record the per-screen idempotency-key policies and runWrite's
refresh contract
([#703](#703))
([#707](#707))
([8bee651](8bee651))
* **web:** the date-cap help text covers every stocked item, not only
feed ([#666](#666),
[#667](#667))
([c8433c5](c8433c5))
* **web:** the help text claims only what is true of recording, and says
nothing about filter caps
([#666](#666),
[#667](#667))
([e2f63d1](e2f63d1))
* **web:** the help text describes the date-range filters that shipped
([#666](#666),
[#667](#667))
([c3275b7](c3275b7))
* **web:** the help text stops describing a cap the filters no longer
have ([#666](#666),
[#667](#667))
([49654cd](49654cd))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: cluckwork-lockfix[bot] <309265648+cluckwork-lockfix[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant