Skip to content

feat(eggs): make cracked and dirty eggs sellable stock via condition grades (#396) - #407

Merged
mforce merged 17 commits into
mainfrom
feat/396-quality-eggs
Aug 3, 2026
Merged

mforce merged 17 commits into
mainfrom
feat/396-quality-eggs

Conversation

@mforce

@mforce mforce commented Aug 3, 2026 •

Copy link
Copy Markdown
Owner

Closes #396.

Cracked and dirty eggs are real, sellable stock on most farms — they go out at a discount rather than into the bin. Today the Daily Entry counts them, subtracts them from sellable, and then loses them: they never become a lot, never appear in stock, and can never be sold. This makes them stock.

The shape of the change

The counters bind to grade identities, not to grade names.

EggGrade gains a DailyEntryKind (Manual / Cracked / Dirty). The two condition grades ship active and saleable on a new installation, and a partial unique index allows exactly one of each kind per farm while leaving Manual grades unconstrained. Renaming "Cracked" to "Segunda" therefore does not detach the counter — which a name match would.

The entry snapshots what each counter resolved to, at the moment it first became official (DailyEntry.CrackedGradeId / DirtyGradeId, written once at submit and never re-resolved). Two consequences that are the whole point:

  • Turning a grade off later never rewrites a past day.
  • Turning one on never invents stock for a day that was recorded as a loss.

null is a durable outcome ("resolved to nothing"), not a missing value. The snapshot columns deliberately carry no FK, so deleting a grade cannot rewrite history.

Resolution requires the grade to be both active and saleable. Those are genuinely independent — EggGrade.Deactivate() leaves IsSaleable set — so inactive-but-saleable is reachable and is treated as a loss.

Enforcement is server-side, not a UI affordance

Excluding condition grades from the Grading pane is a convenience. The enforcement has to be in the handler, because the eligibility check a manual line passes is "active and saleable" with no kind restriction — and this feature makes Cracked and Dirty saleable.

So a stale or direct API client could name a Cracked id as a manual line, pass the exact-total check (it is only a sum), and produce two lots for one grade on one day. ConditionGradeGuard refuses it in the record and adjust handlers. Per #395 this belongs in the handler and not in a validator: the seeders call HandleAsync directly and never run ValidateAsync, so a validator-only rule is invisible to all five seeder tests.

A bug in this PR's own first draft

AdjustDailyEntryHandler built its target set from entry.Grades — the grade lines. Condition lots are not grade lines, so they read as target zero and would have been silently emptied on any adjustment.

Worth flagging because of how it hid: Adjusting_a_condition_counter_to_zero_empties_its_lot passed before the fix. At zero the bug and the correct behaviour agree. The tests that actually distinguish them are the upward and unchanged cases.

The mutation check also caught a surviving mutant on the zero-quantity new-lot guard — every existing test submitted a positive counter, so that path was never exercised and the guard was an untested claim. Adjusting_a_day_that_never_had_cracked_eggs_still_succeeds closes it.

Reports and History

A new Condition column: eggs that became stock without being hand-graded, counted only where that entry resolved them to a grade (e.CrackedGradeId != null ? e.CrackedEggs : 0) — reading the counter alone would report stock for days recorded as a loss.

Shown beside Sellable, not folded into it. Sellable is the hand-graded remainder and the figure grading counts down to; the two answer different questions and are added to get everything the day produced that can be sold. History shows — for Draft entries, since nothing has resolved yet.

Non-CI callers (#395)

Verified by reading each caller's payload, not by running anything — k6's dailyEntryScreen counts a tolerated 422 as a passing check(), so a broken write workload would have shown a 100% green baseline.

Caller Status
SimulationDataSeeder Broken — would have failed closed. Its manifest asserts counts exactly and EggLots came from GradesPerDailyEntry alone. Its counter math is structurally positive (cracked = 4 + d % 3, dirty = 2 + d % 2), so every submitted entry gained exactly two lots and a correct seed would have reported Failed. Fixed with a separate ConditionLotsPerDailyEntry.
DemoDataSeeder Sound — grade lines name Large/Medium/Small explicitly, so the guard never fires.
tools/simulation/k6/bundles.js Unaffected. Posts no grades array, zeroes both counters, and records without ever submitting.
Playwright specs Unaffected. worker.spec.ts's GRADES is exactly the five Manual saleable grades; getByLabel("Cracked") stays unambiguous because Cracked was previously non-saleable (absent from the pane) and is now saleable but excluded by kind. manager.spec.ts submits with cracked=1 and now silently exercises minting, adjust and void.

Both fixtures gained a product mapped to a condition grade. Without one the feature reads as stock appearing that nothing can sell — ~180 lots of dead inventory in the simulation fixture — and stock reconciliation (#406) does not exist yet.

One guarantee that was implicit

Voiding empties the condition lots too. VoidDailyEntryHandler reverses whatever GetByDailyEntryLockedAsync returns — grade-agnostic, so condition lots are covered by construction rather than by a second code path. That is exactly why it was worth pinning: narrowing that query to grade lines (the shape this PR fixed in adjust) would leave a voided day carrying live, sellable stock with no entry behind it, silently, since nothing looks at a voided entry again.

Docs

specs/product/GLOSSARY.md gains Condition grade and Condition (Reports / History column); the SPA Help page and the en/es/tl catalogs are updated in the same PR. Spanish and Tagalog are machine-drafted pending native review, per #182.

Verification

  • 1253/1253 dotnet — 114 application, 303 domain, 836 integration
  • 1340/1340 vitest across 69 files, typecheck clean
  • 0 build warnings
  • Every new test mutation-checked: baseline green → mutant red → restore and rebuild → green. One surviving mutant was found and closed with a new test; it is described above rather than omitted.

Deliberately out of scope

#406 (stock reconciliation for unaccounted cracked eggs) — filed separately and added to epic #14, gated to AuthPolicies.AdminOnly. Raised while reviewing this work; it is a new write path, not part of making condition eggs into stock.

This PR is the migration cutover

Added after the feature work, because merging this is the moment the #245 rule stops being true.

Until now a schema change was hand-folded into InitialCreate. That was only safe while the application had never been deployed: no database had applied that file yet, so rewriting it was free. This PR is the last change permitted to do it. From the commit that merges it, a database exists which has already applied InitialCreate, EF will never re-run it, and a hand-folded column is a column that silently does not exist.

Worth stating why that deserves a rule rather than a note: it surfaces as broken behaviour, not as a migration error. #399's amendment did exactly this to the dev database and presented as a failing login.

The load-bearing part is not the prose. MigrationSecurityReviewTests asserted Assert.Single(AllMigrations()) — so the first post-cutover PR to add a migration would have gone red regardless of what any document said. Documenting the cutover without moving that fence would have been a no-op.

It is replaced by a guard that still has teeth rather than deleted: InitialCreate must stay first and keep its recorded id 20260801190854_InitialCreate. Regenerating it mints a new timestamp, which reorders it behind the migrations that depend on it and desynchronises __EFMigrationsHistory on every deployed database — now a red test instead of a failed deploy. Ordering is asserted on the migration ID, never on list position: AllMigrations() comes from Assembly.GetTypes(), whose order is explicitly unspecified, so a positional check would pass or fail on reflection ordering.

Relaxing that one test is safe because every other assertion in the file already fans out across the whole history (AllInsertDataOperations / AllSqlOperations) — the credential and reference-data guarantees extend to post-cutover migrations for free.

Docs updated where the rule is actually stated or implied:

  • AGENTS.md — the canonical bullet.
  • README.md gains a "Changing the database schema" section, which did not previously exist. The rule lived only in AGENTS.md, so a human following the README had nothing telling them to add a migration. Includes Fail closed when the design-time migration connection is unset or insecure #318's fail-closed design-time connection and its loopback opt-out, since ef migrations add needs both.
  • Both note that a dev database created before this merges predates the folded-in columns and must be recreated.

deploy/README.md's #364 line is deliberately untouched — it describes what was true of that release at the time, not a live rule.

Mutation-checked: pointing the pinned id at a different timestamp fails the test; restored and rebuilt back to green.

Verified after the feature work, by execution

Beyond the unit/integration suites, all three non-CI callers were actually run:

  • Simulation seed — exit 0, complete=true, 880 egg lots = exactly 5.0 per submitted entry (3 graded + 2 condition) against real Postgres at full 90-day depth. Pre-fix this reported Failed.
  • Playwright — 28 passed, 1 skipped, 0 failed, including manager.spec.ts submitting with cracked=1 then adjusting and voiding.
  • k6 persona smoke — exit 0, unexpected_status: 0; sales_create_order/sales_add_item both 201 against the 5-product catalog.
  • k6's daily-entry payload — 201, DB 183 → 184. Checked separately and deliberately: the write is gated at DAILY_ENTRY_WRITE_PROB = 0.12 and never fired in the 20s smoke, and a 422 sits in the tolerated set, so unexpected_status would have stayed 0 while the workload silently stopped landing. The DB snapshot is what proves it, not the k6 exit code.

mforce added 10 commits August 3, 2026 00:11
…ities (#396)

Task 1 of the plan, domain half. Carries the plan and design docs from the
closed #397 so the reviewed rules land with the code that implements them.

EggGrade gains DailyEntryKind (Manual/Cracked/Dirty) — an IDENTITY for which
counter feeds a grade, deliberately not a name match: a farm can rename
"Cracked" to "Segunda" through the ordinary grade endpoint, and a name-matching
resolver would then detach the farm's own counter from its own grade. Immutable
after creation, for a stronger reason than GradeType's: an official entry
snapshots the id its counter resolved to, so re-pointing a kind afterwards would
restate what a past day produced. Update takes no kind parameter, which is the
enforcement.

DailyEntry gains CrackedGradeId/DirtyGradeId, written once at the first official
transition and never re-resolved. Null means "resolved to nothing" — a durable
record that the condition was a loss — not "not yet known". The domain does not
resolve them (it has no catalog); the handler passes what it resolved, having
required both Active and IsSaleable.

That both-flags rule is pinned at its source by Deactivate_leaves_saleability_alone:
EggGrade.Deactivate() flips Active and leaves IsSaleable set, so
inactive-but-saleable is an ordinary reachable state. A saleability-only resolver
would mint lots under a grade the farm has retired from capture — the defect
#397's review round 3 caught in prose, now caught by a test.

Mutation-checked, baseline green (303/303, solution builds 0 warnings):
dropping the Submit assignment turns Submit_records_the_resolved_quality_grades
red; making ManagerAdjust clear the snapshot turns
Adjust_keeps_the_original_snapshot_and_never_re_resolves red.

Still to come in Task 1: EF configuration, the hand-carried InitialCreate
columns/partial index/seed defaults, and the two export projections.
Task 1, persistence half. Hand-carried into InitialCreate per #245 — no second
migration. The delta was generated with `ef migrations add` against a scratch,
read, folded in, and the scratch deleted; both model snapshots were rebuilt so
the model and the single migration still agree.

Schema: EggGrades."DailyEntryKind" (varchar(16), default 'Manual', stored as a
string like GradeType so an enum reorder cannot repoint existing rows), and
DailyEntries."CrackedGradeId"/"DirtyGradeId" (nullable uuid). The snapshots take
no FK deliberately — they must survive their grade being deleted, exactly as
they must survive it being renamed.

Index: IX_EggGrades_AccountId_FarmId_DailyEntryKind, unique and PARTIAL
(WHERE "DailyEntryKind" <> 'Manual'). One Cracked and one Dirty per farm, since
the entry has exactly one counter for each and a second claimant would make
resolution silently pick one. The filter is what leaves the many ordinary
Manual grades unconstrained, which is also why the column default must stay
'Manual'.

Seed: Cracked and Dirty are now saleable by default and carry their kinds;
Discarded stays a loss on 'Manual' — binding it would make discarded eggs
resolvable to stock. Statement and row counts are unchanged (12 INSERTs, 21
rows), the grades guard stays whole-set and blind to "Name", so
MigrationSecurityReviewTests holds.

The existing base-data test would have passed with every new flag wrong — it
counts ten grades and checks the name index. Two tests added, both asserting
through observable behaviour rather than names:
- the two condition grades are saleable and bound, Discarded is neither, and
  the other eight are Manual
- a second Cracked is refused by the partial index WHILE an eleventh Manual
  grade is still accepted — the half a filterless index would break

Mutation-checked, baseline green (13/13 migration, 303/303 domain, 0 warnings):
seeding Cracked non-saleable turns exactly one test red; dropping the index
filter turns the whole class red (a filterless unique index cannot even seed
eight Manual grades).
…ports (#396)

Completes Task 1. An export is the farm's own copy of its records, and without
these columns it states that a day had N cracked eggs while omitting whether
they became saleable stock or a loss — which is NOT recoverable afterwards, since
the snapshot exists precisely because the grade catalog may have changed since.

daily-entries gains crackedGradeId/dirtyGradeId next to the counters they
explain; egg-grades gains dailyEntryKind, the only field naming which counter a
grade serves and the only one that survives a rename.

ExportTests previously asserted dataset names and content types only, so both
projections could have been left out silently. The new test checks the header
AND a value — a header-only assertion passes against a column wired to the wrong
property.

Mutation-checked, baseline green (6/6 export, 13/13 migration, 0 warnings):
dropping dailyEntryKind from the projection turns exactly that test red.
)

Task 2, first slice — the hole this feature opens, closed before the code that
opens it.

Making Cracked and Dirty saleable makes them pass the manual-line eligibility
check, which asks "active and saleable" with no kind restriction. So a direct or
stale API client could name a Cracked id as a graded line; #394's exact-total
check does not catch it, because that check is only a sum and the sum still
reconciles. Submission would then create the manual lot AND the counter-backed
quality lot for the same grade — two lots for one grade on one day,
double-counting stock and breaking the one-lot-per-grade assumption lot
reconciliation depends on.

Enforced in the handlers, not a validator (#395/#394): the demo and simulation
seeders construct commands and call HandleAsync directly, skipping validators
entirely, so a validator-only rule would let a fixture build exactly this state.

ConditionGradeGuard is deliberately NOT folded into either handler's existing
allow-set. The two ask different questions, and adjust's set additionally unions
the lines already on the entry, so a guard sharing it could be talked past.

Tests assert the durable state, not the status code: a refused record leaves no
entry at all, and a refused adjust leaves status, version and lot count
byte-identical — adjust is the path that has already generated lots, so a
partial failure there is worse than on record.

Mutation-checked with a rebuild after each restore (a --no-build confirmation
re-runs the mutant and can report a false green): removing the guard from record
turns both record tests red, removing it from adjust turns the adjust test red.
Baseline green — 34/34 daily-entry integration, 114/114 application, 0 warnings.

One comment corrected rather than left overstated: the guard reads the whole
catalog, and I had written that as though it closed a reachable gap. It does not
today — an inactive grade is already refused upstream, so narrowing it would
change the error code, not the outcome. The comment now says that plainly and
says why the shape is still worth keeping.
…lots (#396)

Completes Task 2. Submission is the one point at which the Cracked and Dirty
counters resolve to a grade, and the resolution requires BOTH Active and
IsSaleable.

Neither flag implies the other. EggGrade.Deactivate() leaves IsSaleable set, so
"inactive but saleable" is an ordinary reachable state — an owner retiring a
condition grade without first clearing its saleability — and resolving on
saleability alone would mint stock under a grade the farm has already removed
from capture. That was #397's review round 3 finding, stated there as prose; it
is now pinned by a test that goes red when the Active half is dropped.

Unresolved is recorded as NULL, which is a durable outcome ("these eggs were a
loss") rather than a gap to be filled in later. The lot loop reads the entry's
own SNAPSHOT rather than the ids just resolved, so this loop and every later
reader agree by construction and an adjustment — which never re-resolves — takes
the identical path.

A zero counter still snapshots but mints no lot: EggLot.Create rejects a zero
quantity, and "the farm was selling cracked eggs that day" remains a fact worth
recording. Snapshot and lot are separate decisions, and that test is what proves
they are.

Worth recording about the tests themselves: the two "resolves to nothing" cases
passed VACUOUSLY before this commit, because nothing resolved at all. They only
became meaningful once resolution existed, which is why each was mutation-checked
against the specific rule it covers rather than trusted for being green.

Mutation-checked, rebuilding after every restore, baseline green (44/44
integration, 303/303 domain, 114/114 application, 0 warnings):
- resolving from the full catalog instead of the active slice → the
  inactive-but-saleable test alone goes red
- dropping the IsSaleable requirement → the non-saleable test alone goes red
- dropping the zero-counter skip → the zero-counter test alone goes red
…manual ones (#396)

A defect introduced by the previous commit in this branch, found by writing the
adjustment tests rather than by reasoning about the change.

The reconciler drives EVERY lot linked to the entry toward a target built from
entry.Grades. Condition lots are deliberately absent from entry.Grades (a
condition grade is never a manual line), so they had no target — and a lot with
no target does not get skipped, it reads as target ZERO. Any adjustment to an
entry carrying quality stock would therefore have emptied that stock silently,
including adjustments that had nothing to do with the condition counters.

Targets now include both snapshots, read from the ENTRY rather than re-resolved
from the catalog: a farm that turns Cracked non-saleable after submitting must
still be able to correct that day, and the day keeps the meaning it was recorded
with. The existing new-lot loop then covers the other direction — a counter that
goes from zero to positive gains a lot dated and linked like the rest.

Mutation results, reported in full because one is not a clean pass:
- removing the condition targets → the grow test and the manual-lot-untouched
  test go red
- removing the zero-quantity skip in the new-lot loop → SURVIVED on the first
  attempt. Every existing test submitted a positive counter, so the
  zero-with-no-lot path was never exercised and the guard was an untested claim.
  Adjusting_a_day_that_never_had_cracked_eggs_still_succeeds now covers it —
  an ordinary good day has a snapshot and no lot, and EggLot.Create rejects a
  zero quantity, so without the skip the adjustment throws. Re-run: killed.

Also worth recording: Adjusting_a_condition_counter_to_zero_empties_its_lot
passed BEFORE the fix, because the bug and the correct behaviour agree at zero.
It was green for the wrong reason and only became meaningful afterwards.

Baseline green: 66/66 across the daily-entry, quality-egg and race suites,
0 warnings. Version handling is unchanged — no new aggregate mutation path — so
the existing parallel submit/adjust race coverage still applies and passes.
#396)

Owner decision: keep Sellable at its existing meaning and show the condition
figure BESIDE it, rather than folding the two together.

For a 1000-egg day with 900 hand-graded, 60 cracked, 30 dirty and 10 discarded,
the report showed "Sellable: 900" while the farm could actually sell 990. The
new FromCounts column carries the 90 — the eggs that became stock without
passing through the Grading pane.

Deliberately a separate figure, not a widened Sellable. The two answer different
questions ("how many did we grade" vs "how many can we sell"), were only ever
equal because conditions used to be losses, and Sellable is also the target
Daily Entry counts down to and that #394 requires the grade lines to reconcile
against exactly — moving it would have silently changed what the capture screen
validates.

Only SNAPSHOT-BACKED conditions count. A null snapshot records that those eggs
were a loss that day, so summing the raw counters would invent stock the farm
never had — most visibly for entries recorded before this feature, whose
conditions were never saleable at all.

The test fixture makes Cracked saleable and Dirty NOT, so the naive rule reports
9 where the answer is 6. Mutation-checked, baseline green (7/7 reports,
0 warnings): summing the raw counters turns exactly that test red.

Still to come: the Reports and History screens need the column rendered, and the
label wants deciding — "From counts" is used here rather than "Ungraded",
because those eggs do carry a grade (Cracked/Dirty) and appear in the report's
own grade breakdown, so "ungraded" would contradict the table beside it.
Owner-named column: "Condition" — the term specs.md and the glossary already
use for Cracked and Dirty. Deliberately not "Ungraded" (those eggs do carry a
grade, shown in the same screen's by-grade breakdown), not "Seconds" (a real
seeded grade name, so the column and the breakdown would disagree), and not
"Bad eggs" (in food that reads as spoiled, and these are being sold — the app
already has Discarded for eggs thrown away).

Reports gains the column beside Sellable, never folded into it: Sellable stays
the hand-graded remainder and the figure Daily Entry counts down to. History
gains the same column per entry.

History reads each entry's own SNAPSHOT, never the current grade catalog. A farm
that switches Cracked off today must still see past days as they were recorded,
and one that switches it on must not see past losses become stock. A draft shows
an em dash rather than 0 — nothing has resolved yet, and 0 would assert "these
were a loss", a different fact that is not yet true.

API: DailyEntryResponse gains crackedGradeId/dirtyGradeId (null on a draft and
on a resolved-to-loss entry alike; readers distinguish by Status). The ADJUST
REQUEST deliberately does not carry them — an adjustment never re-resolves —
and a test now pins that, after a broad fixture edit of mine wrongly added them
to that assertion and the suite caught it.

Help page and GLOSSARY.md updated in the same change, per the standing rule.
The glossary gains "Condition grade" (the kind binding, the snapshot, the both-
flags rule, and why a condition grade can never be a manual line) and
"Condition" as the column. All three catalogs carry the strings; es/tl are
machine-drafted pending native review (#182).

Assertions are cell-indexed, not text-matched: "0" and the em dash both occur in
sibling cells, so a text query passes against the wrong column. That is not
hypothetical — the first draft of both tests did exactly that and had to be
tightened.

Mutation-checked, baseline green (1300/1300 vitest, typecheck clean, 0 warnings):
summing the raw counters in History turns 2 tests red; dropping the draft em
dash turns 1 red; wiring the Reports column to sellable turns 3 red.
Completes Task 3. EggGradeResponse gains dailyEntryKind ("Manual" | "Cracked" |
"Dirty"); the SPA cannot infer it from name (renameable) or gradeType (a farm
can have many Quality grades, only one of which is the Cracked counter's).

Both grading panes filtered on saleability alone, and this feature makes Cracked
and Dirty saleable — so both would have offered them as grade fields. Daily
Entry's catalog filter and History's panelGrades now also require the Manual
kind. History excludes it from the CATALOG half only: an existing line stays
correctable whatever it names.

This is an affordance, not the enforcement. The server refuses a manual line
naming a condition grade regardless (ConditionGradeGuard, 7370dce); what this
removes is a control whose only possible outcome is a rejection.

Both tests had to be tightened after failing for the right reason. "Cracked" is
a deliberately ambiguous label on these screens — Egg counts has a Cracked
COUNTER, which must stay — so an unscoped query matches the counter and passes
whatever the grading pane does. The Daily Entry test now scopes to the Grading
section and additionally asserts the counter is still present in Egg counts;
the History test asserts the dialog shows exactly ONE "Cracked" control, the
counter, so a grade field would make two.

Mutation-checked, baseline green (1302/1302 vitest, 16/16 egg-grade + quality
integration, typecheck clean, 0 warnings): dropping the kind check from either
filter turns exactly its own test red.
…ixtures (#396)

Closes out #396's non-CI-caller obligation (AGENTS.md #395/#394): a change to
what submit writes has to be carried into every automated caller in the same
PR, and coverage over those callers is not uniform.

What actually broke, and what did not — established by reading each caller's
payload rather than by running it, since k6's `dailyEntryScreen` counts a
tolerated 422 as a passing check and would have stayed green either way:

* `SimulationDataSeeder` — BROKEN, and it would have failed closed. Its
  manifest asserts every count exactly, and `EggLots` was derived from
  `GradesPerDailyEntry` alone. Submit now also mints a lot per resolved
  condition counter, and this seeder's deterministic math keeps both counters
  strictly positive on every entry (`cracked = 4 + d % 3`, `dirty = 2 + d % 2`),
  so every submitted entry gained exactly two lots. `ValidateCounts` would have
  reported `Failed` on a correct seed. Added `ConditionLotsPerDailyEntry`
  beside the existing constant rather than merging the two into a `5`: they
  answer different questions (grade LINES vs counter-backed lots) and drift for
  different reasons.
* Its grade lines name Large/Medium/Small explicitly, so `ConditionGradeGuard`
  never fires — checked, not assumed.
* `DemoDataSeeder` — sound as written, same explicit grade lines.
* `tools/simulation/k6/bundles.js` — unaffected. It posts no `grades` array at
  all and zeroes both counters, and it records without ever submitting, so
  neither the guard nor condition resolution is reachable.
* Playwright — unaffected. `worker.spec.ts`'s `GRADES` list is exactly the five
  Manual saleable grades. `getByLabel("Cracked")` stays unambiguous for a
  reason worth recording: Cracked was previously non-saleable and so absent
  from the grading pane; it is now saleable but excluded by kind, so the
  counter remains the only match. `manager.spec.ts` submits with cracked=1 and
  now silently exercises condition-lot minting, then adjusts and voids it —
  all three paths hold.

Both fixtures also gained a product mapped to a condition grade. Without one
the feature reads as stock appearing that nothing can ever sell — ~180 lots of
dead inventory in the simulation fixture — and #406 (stock reconciliation) does
not exist yet. Priced below Small, which is the point of the feature.

Also pins a guarantee that was implicit and unproven: voiding empties the
condition lots too. `VoidDailyEntryHandler` reverses whatever
`GetByDailyEntryLockedAsync` returns — grade-agnostic, so condition lots are
covered by construction rather than by a second code path. Narrowing that query
to the entry's grade lines (the exact shape this PR already fixed in
`AdjustDailyEntryHandler`) would leave a voided day carrying live, sellable
cracked and dirty stock with no entry behind it. Mutation-checked: baseline
12/12 green, the narrowed-query mutant leaves the cracked lot at 10 and fails
the test, restored and rebuilt back to 12/12.

Verified: 1253/1253 dotnet (114 application + 303 domain + 836 integration),
1340/1340 vitest across 69 files, typecheck clean, 0 build warnings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f8a3b409ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +33 to +35
FromCounts = g.Sum(e =>
(e.CrackedGradeId != null ? e.CrackedEggs : 0)
+ (e.DirtyGradeId != null ? e.DirtyEggs : 0)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include condition stock in grade totals

For every official entry whose cracked or dirty snapshot is non-null, these lines add the eggs to FromCounts, but the gradeTotals query below still aggregates only DailyEntryGrades. Condition-backed production creates EggLot rows and never creates DailyEntryGrade rows, so the report's “By grade” breakdown systematically omits all cracked/dirty stock even though the same response reports it as produced stock. Aggregate the snapshot-backed counters into their snapshotted grade IDs as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 082aa15 — this was a real defect, and the reasoning is exactly right.

Verified before accepting: GradeTotals came from DailyEntryGrades alone, and condition-backed production creates an EggLot but never a DailyEntryGrade row — and never can, because ConditionGradeGuard refuses a manual line naming a condition grade precisely so one day cannot produce two lots for one grade. So sum(GradeTotals) equalled TotalSellable while the stock that exists is TotalSellable + TotalFromCounts.

The fix aggregates the snapshot-backed counters into their snapshotted grade ids and merges them in. Three details that were not obvious:

  • Keyed on the snapshot, not the live catalog — so turning a grade off later still cannot rewrite a past period, which is the same invariant FromCounts already relied on.
  • Gated on counter > 0, mirroring SubmitDailyEntryHandler's own lot condition. A snapshot is recorded even on a zero-cracked day, and counting those would put a grade in the breakdown the farm holds no stock under — reading as an empty grade rather than as absence.
  • Accumulates rather than assigns, so a farm that pointed both kinds at one catalog row gets correct arithmetic instead of one counter silently dropped.

Test asserts the arithmetic a reader of the screen actually does: sum(GradeTotals) == TotalSellable + TotalFromCounts. The fixture already had the ideal shape (Cracked saleable, Dirty not), so it also pins the other half — Dirty must stay absent, since folding the counters in must not resurrect conditions the entry recorded as losses.

Mutation-checked, and the first attempt was a false pass worth reporting honestly: an if (false) mutant failed to compile under warnings-as-errors (CS0162), so dotnet test --no-build ran the stale binary and reported 7/7 green while testing nothing. Redone with a mutant that compiles — baseline 7/7 green, merge removed → red, restored and rebuilt → 7/7 green.

No SPA change needed: ReportsPage maps whatever gradeTotals the API returns.

Full run: 1253/1253 dotnet, 1340/1340 vitest, 0 build warnings.

Codex review of #407, P1 — a real defect and mine.

`GetProductionAsync` returns both `TotalFromCounts` (eggs that became stock
without being hand-graded) and `GradeTotals` (the By-grade breakdown), and the
breakdown was built from `DailyEntryGrades` alone. Condition-backed production
creates an `EggLot` but never a `DailyEntryGrade` row — and never can, because
`ConditionGradeGuard` refuses a manual line naming a condition grade precisely
so one day cannot produce two lots for one grade. So the same response reported
N cracked eggs as produced stock while the By-grade table omitted every one of
them: `sum(GradeTotals)` equalled `TotalSellable` when the stock that exists is
`TotalSellable + TotalFromCounts`.

Folds the snapshot-backed counters in under the grade each entry actually
resolved to, keyed on the SNAPSHOT rather than the live catalog, so turning a
grade off later still cannot rewrite a past period. Two queries rather than one:
the counters sit in different columns under different snapshot keys, which a
single GROUP BY cannot express without a UNION EF would not translate. Gated on
`counter > 0`, mirroring SubmitDailyEntryHandler's own lot condition — a
snapshot is recorded even on a zero-cracked day, and counting those would put a
grade in the breakdown the farm holds no stock under, reading as an empty grade
rather than as absence. The merge accumulates rather than assigns, so a farm
that pointed both kinds at one catalog row gets correct arithmetic instead of a
silently dropped counter.

The assertion that states the guarantee is the arithmetic a reader of the screen
actually does: `sum(GradeTotals) == TotalSellable + TotalFromCounts`. The
existing fixture already had the ideal shape (Cracked saleable, Dirty not), so
the test also pins the other half — Dirty must stay absent, since folding the
counters in must not resurrect conditions the entry recorded as losses.

Mutation-checked, and the first attempt was a false pass worth recording: an
`if (false)` mutant failed to COMPILE under warnings-as-errors (CS0162), so
`dotnet test --no-build` ran the stale binary and reported 7/7 green while
testing nothing. Redone with a mutant that compiles: baseline 7/7 green, the
merge removed fails the test, restored and rebuilt back to 7/7.

No SPA change needed — ReportsPage maps whatever `gradeTotals` the API returns.

Also folds in the #396 specification section from the closed #397, which was the
only place it existed; specs.md on main had no section for this behaviour at
all. Corrected one sentence that had gone stale against the single-migration
rule: it described migrating "existing official entries" with null snapshots,
but a virgin database is the only starting state, so there is no such
population. Restated as a property of the columns (never backfilled, null is the
durable "was a loss that day" answer) rather than as a migration step.

Verified: 1253/1253 dotnet, 1340/1340 vitest across 69 files, 0 build warnings.
@mforce

mforce commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex the P1 on ReportQueries.cs (condition stock missing from the By-grade breakdown) is addressed in 082aa15.

You were right, and it was mine: GradeTotals read DailyEntryGrades alone, and a condition lot can never have a DailyEntryGrade row — ConditionGradeGuard forbids naming a condition grade as a manual line, so one cannot exist. sum(GradeTotals) therefore equalled TotalSellable while the actual stock is TotalSellable + TotalFromCounts.

Fixed by aggregating the snapshot-backed counters under their snapshotted grade ids: keyed on the snapshot rather than the live catalog, gated on counter > 0 to mirror the lot condition, and accumulating rather than assigning. Test pins sum(GradeTotals) == TotalSellable + TotalFromCounts and that non-saleable Dirty stays absent. Mutation-checked green → red → green.

Same push also folds in the #396 specification section from the closed #397 (specs.md on main had none) and corrects one sentence in it that had gone stale against the single-migration rule.

Please re-review — particularly the report change and the seeder-count math in f8a3b40.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 082aa15d18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…396)

Until now a schema change was hand-folded into `InitialCreate` (#245). That was
only safe while the application had never been deployed: no database had applied
that file yet, so rewriting it was free. This PR is the last change permitted to
do it — from the commit that merges it, a database exists which has already
applied `InitialCreate`, EF will never re-run it, and a hand-folded column is a
column that silently does not exist.

That failure mode is why this is worth a rule rather than a note: it surfaces as
broken behaviour, not as a migration error. #399's amendment did exactly this to
the dev database and presented as a failing login.

The load-bearing part is NOT the prose. `MigrationSecurityReviewTests` asserted
`Assert.Single(AllMigrations())`, so the first post-cutover PR to add a migration
would have gone red no matter what the docs said. Documenting the cutover without
moving that fence would have been a no-op.

Replaced it with a guard that still has teeth, rather than deleting it:
`InitialCreate` must stay FIRST and keep its recorded id
(`20260801190854_InitialCreate`). Regenerating it mints a new timestamp, which
reorders it behind the migrations that depend on it and desynchronises
`__EFMigrationsHistory` on every deployed database — now a red test instead of a
failed deploy. Ordering is asserted on the migration ID, never on list position:
`AllMigrations()` is built from `Assembly.GetTypes()`, whose order is explicitly
unspecified, so a positional check would pass or fail on reflection ordering.

Relaxing that one test is safe because every other assertion in the file already
fans out across the whole migration history (`AllInsertDataOperations` /
`AllSqlOperations`) — the credential and reference-data guarantees extend to
post-cutover migrations for free. They were written that way from the start.

Mutation-checked: pointing the pinned id at a different timestamp fails the test;
restored and rebuilt back to green.

Docs updated in the three places the rule is actually stated or implied:
- AGENTS.md's migration bullet — the canonical rule.
- README gains a "Changing the database schema" section, which did not exist:
  the rule lived only in AGENTS.md, so a human following the README had nothing
  telling them to add a migration. Includes the #318 fail-closed design-time
  connection and its loopback opt-out, since `ef migrations add` needs both.
- Both note that a dev database created BEFORE this merges predates the folded-in
  columns and must be recreated.

`deploy/README.md`'s #364 line is deliberately untouched — it describes what was
true of that release at the time, not a live rule.

Verified: 1253/1253 dotnet, 0 build warnings.
@mforce

mforce commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex new commit 6c84403 — please re-review.

It does something the earlier commits did not: this PR is now the migration cutover. Until now a schema change was hand-folded into InitialCreate (#245), safe only because the app had never been deployed. From the commit that merges this, a database exists that has already applied InitialCreate, so a hand-folded column silently does not exist — it surfaces as broken behaviour, not a migration error.

The part worth reviewing hardest is the test change. MigrationSecurityReviewTests asserted Assert.Single(AllMigrations()), which would have failed the first post-cutover PR to add a migration no matter what the docs said. I replaced it rather than deleted it: InitialCreate must stay first and keep its recorded id, asserted on the migration ID rather than list position (AllMigrations() comes from Assembly.GetTypes(), whose order is unspecified).

Specifically worth challenging:

  • Is "first + pinned id" actually sufficient, or does relaxing Assert.Single open a hole the other assertions do not already cover? My claim is they do, because AllInsertDataOperations/AllSqlOperations already fan out across the whole history.
  • Does the README snippet for dotnet ef migrations add match what Fail closed when the design-time migration connection is unset or insecure #318's fail-closed design-time factory really requires?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c84403996

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +115 to +117
Assert.NotEmpty(ordered);
Assert.Equal("InitialCreate", ordered[0].Migration.GetType().Name);
Assert.Equal(InitialCreateMigrationId, ordered[0].Id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin InitialCreate's operations, not just its ID

When a later schema PR edits InitialCreate.cs and its designer/snapshot instead of adding a second migration, these assertions remain green because the class name and migration attribute do not change. The remaining tests only inspect credential/reference-data inserts and four expression indexes; none detects a new AddColumn, CreateTable, or other ordinary schema operation folded into InitialCreate. A database that already recorded this ID would therefore skip the edit and run code against a missing schema element, so add an immutable operation fingerprint or equivalent guard for InitialCreate while still allowing later migration IDs.

AGENTS.md reference: AGENTS.md:L48-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 1c81df1. You found the hole in my own fence, and the reasoning is exactly right.

I pinned InitialCreate's class name and migration id — which proves it is still first, but says nothing about what it contains. The regression the cutover exists to prevent is precisely an edit to its contents, and that edit changes neither the class name nor the MigrationAttribute. So the guard passed on exactly the change it was written to stop.

Now fingerprinted: a pinned operation count plus a digest over a structural description of every UpOperation. Any added, removed, or retyped column/table/index/raw-SQL statement fails with a message naming the fix (add a migration) rather than inviting a re-baseline.

Two choices worth flagging, since both cost something the other way:

  • The description is structural, not a serialization of EF's operation objects. A serialization would break on an EF version bump that touched an internal property nobody edited — training people to re-baseline the constant on sight, which is how a fence like this dies quietly.
  • The count is pinned alongside the digest, not instead of it. Describe() collapses unusual operation types to their type name, so two different DropColumns would describe identically; the count catches an added or removed operation even when the description is coarse. That limit is written into the test rather than papered over.

Mutation-checked with the real regression rather than a proxy — folding MutantFoldedColumn into an existing CreateTable in InitialCreate. It failed; restored and rebuilt back to green.

One result from that run is worth reporting, because it sharpens your finding: the operation count stayed at 114 and only the digest moved. The column went inside an existing CreateTable, so a count-only guard would have passed too. Both halves are load-bearing.

1254/1254 dotnet, 0 build warnings.

…ntity (#396)

Codex review of #407, P1 — correct, and it found the hole in my own fence.

The cutover commit pinned InitialCreate's class name and migration id. That
proves the migration is still FIRST, but says nothing about what it CONTAINS —
and the regression the cutover exists to prevent is precisely an edit to its
contents. A later PR folding a column into InitialCreate.cs changes neither the
class name nor the MigrationAttribute, and the other tests in this file only
inspect credential-shaped inserts, the reference-data SQL, and the four
expression indexes. An ordinary AddColumn/CreateTable edit was invisible to all
of them, so the guard passed on exactly the change it was written to stop.

Fixed by fingerprinting the operations: a pinned count plus a digest over a
structural description of every UpOperation. Any added, removed, or retyped
column/table/index/raw-SQL statement fails the test with a message naming the
fix (add a migration), rather than inviting a re-baseline.

Two deliberate choices, both of which cost something if taken the other way:

* The description is STRUCTURAL, not a serialization of EF's operation objects.
  A serialization would break on an EF version bump that touched an internal
  property nobody edited — training people to re-baseline the constant on sight,
  which is how a fence like this dies quietly. Describe() reads only the
  properties that define what the migration does.
* The count is pinned ALONGSIDE the digest rather than instead of it. Describe()
  collapses unusual operation types to their type name, so two different
  DropColumns would describe identically; the count catches an added or removed
  operation even when its description is coarse. That limit is stated in the
  test rather than papered over.

Mutation-checked with the real regression rather than a proxy: folding
`MutantFoldedColumn` into an existing CreateTable in InitialCreate fails the
test; restored and rebuilt back to green. Worth recording what that run showed —
the operation COUNT stayed at 114 and only the digest moved, because the column
went inside an existing CreateTable. A count-only guard would have passed. That
is the finding's point, demonstrated rather than asserted.

AGENTS.md's migration bullet updated to describe both halves of the fence and
why identity alone was insufficient.

Verified: 1254/1254 dotnet (114 application + 303 domain + 837 integration),
0 build warnings.
@mforce

mforce commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex the P1 on MigrationSecurityReviewTests (pin operations, not just the ID) is addressed in 1c81df1.

You were right: pinning the class name and migration id proves InitialCreate is still first but says nothing about what it contains, and an edit to its contents changes neither. The guard passed on exactly the change it existed to stop.

Now fingerprinted — pinned operation count plus a digest over a structural description of every UpOperation, failing with a message that names the fix (add a migration) rather than inviting a re-baseline.

Mutation-checked with the real regression: folding a column into an existing CreateTable in InitialCreate fails the test. Notably the count stayed unchanged and only the digest moved, so a count-only guard would have passed — both halves are load-bearing.

Please re-review, particularly:

  • Whether Describe() misses an operation shape that could carry a schema change (its _ fallback collapses unusual types to their type name — I pin the count alongside the digest to cover that, and say so in the test).
  • Whether choosing a structural description over serializing EF's operation objects leaves a gap I have not accounted for.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c81df1e6f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +193 to +194
CreateTableOperation t =>
$"CreateTable({t.Name}:{string.Join('|', t.Columns.Select(c => $"{c.Name}:{c.ColumnType}:{c.IsNullable}"))})",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include nested constraints and defaults in the fingerprint

Changing an existing CreateTable operation can still leave both checks unchanged: this description omits column defaults and annotations as well as the table's nested primary keys, foreign keys, unique constraints, and delete behaviors. For example, changing AspNetUsers.CredentialEpoch from defaultValue: 1 to 0, or changing a foreign key's onDelete, preserves the operation count and every emitted description while materially altering the schema, so the freeze test remains green. Include all schema-defining properties of the currently present operation shapes (especially CreateTableOperation's columns and nested constraints) in the structural digest.

AGENTS.md reference: AGENTS.md:L46-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 4889567. Right again — and this was the third miss of the same kind, which is the part worth acting on.

Your CredentialEpoch example is the one that stings: defaultValue: 1 -> 0 is a value #364 declares permanently retired, and it left the operation count and the entire digest unchanged.

The prior digest used a hand-written switch listing "the properties that matter". Enumerating harder is the bet that had already lost twice, so I changed the approach rather than extending the list. Describe() now walks every public readable property of an operation, recursing into nested column definitions, constraints, foreign keys, annotations and sequences. A schema-defining property is included because it exists, not because someone remembered it — adding one to an operation type cannot silently escape the digest.

Details that turned out to be load-bearing:

  • Properties ordered by name, never reflection order. Type.GetProperties() is unspecified — the same hazard the ordering test already avoids with GetTypes(). A flaky fence is worse than none, so I verified determinism empirically: three consecutive runs, byte-identical digests.
  • Collection order preserved, not sorted — a composite key's column order is itself schema, so sorting would hide a reordering.
  • Annotations described, not skipped — Npgsql's ValueGenerationStrategy rides there, so skipping them would drop identity/serial changes.
  • A property that throws is recorded as having thrown, rather than falling back to null, which would be a silent hole.

Mutation-checked against both cases you named, rather than a proxy:

mutation before after
CredentialEpoch defaultValue: 1 -> 0 green (missed) RED
FK onDelete Cascade -> Restrict green (missed) RED

Both restored and rebuilt back to green, with the migration file confirmed byte-identical to HEAD afterwards.

The EF-version-churn concern that motivated the original hand-written approach still applies and I am accepting it deliberately: this reads EF's public operation model, not internals, and a genuine change there is worth a human look. The failure message says re-baselining is almost never the right response.

1254/1254 dotnet, 0 build warnings.

…cked list (#396)

Codex review of #407 round 2, P1 — right again, and the third miss of the same
kind, which is the actual lesson.

The previous digest described operations with a hand-written switch: for a
column, name + type + nullability. It omitted the column's DEFAULT, and a
CreateTable's nested primary key, foreign keys, unique constraints and delete
behaviours. So the schema could change materially with the operation count and
every emitted description identical, leaving the freeze test green. Codex's
example is the one that stings: changing AspNetUsers.CredentialEpoch from
`defaultValue: 1` to `0` — a value #364 declares PERMANENTLY RETIRED, the whole
point of that design — was invisible.

Enumerating harder is the same bet that had already lost twice, so this changes
the approach instead. Describe() now walks every public readable property of an
operation, recursing into nested column definitions, constraints, foreign keys,
annotations and sequences. A schema-defining property is included because it
EXISTS, not because someone remembered it; adding one to an operation type
cannot silently escape the digest.

Details that are load-bearing rather than incidental:

* Properties are ordered by NAME, never reflection order. Type.GetProperties()
  is explicitly unspecified — the same hazard the ordering test above already
  avoids with GetTypes(). A digest built on reflection order would churn between
  runs, and a flaky fence is worse than none.
* Collection ORDER is preserved, not sorted. For a composite key or an index's
  columns the order is itself schema, so sorting would hide a reordering.
* Annotations are described, not skipped — Npgsql's ValueGenerationStrategy
  rides there, so skipping them would drop identity/serial changes.
* A property that throws is recorded as having thrown rather than falling back
  to null, which would be a silent hole.
* Determinism verified empirically, not assumed: three consecutive runs produce
  byte-identical digests.

The EF-version-churn concern that motivated the original hand-written approach
still applies and is accepted deliberately. This reads EF's PUBLIC operation
model — the documented shape of a migration — not internals, and a genuine
change there is worth a human look. The failure message says plainly that
re-baselining is almost never the right response.

Mutation-checked against BOTH cases codex named, not a proxy for them:
  A. CredentialEpoch defaultValue 1 -> 0        -> RED (was green before)
  B. a foreign key's onDelete Cascade -> Restrict -> RED (was green before)
Both restored and rebuilt back to green, with the migration file confirmed
byte-identical to HEAD afterwards.

Verified: 1254/1254 dotnet (114 application + 303 domain + 837 integration),
0 build warnings.
@mforce

mforce commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex the P1 on nested constraints and defaults is addressed in 4889567.

You were right, and it was the third miss of the same kind — a hand-written list of "the properties that matter". So I changed the approach instead of extending the list: Describe() now walks every public readable property, recursing into nested column definitions, constraints, foreign keys, annotations and sequences. A property is included because it exists, not because someone remembered it.

Mutation-checked against both cases you named — CredentialEpoch defaultValue: 1 -> 0, and a FK onDelete Cascade -> Restrict. Both were green before and RED now. Determinism verified across three runs (byte-identical digests), since a flaky fence would be worse than none.

Please re-review. Specific things I would rather you attack than take on trust:

  • Whether walking public properties can still miss schema carried somewhere other than a property — e.g. state reachable only through a method, or an operation whose meaning depends on something not exposed publicly.
  • Whether preserving collection order (deliberate: composite-key column order is schema) versus sorting introduces any false-positive churn I have not considered.
  • The accepted trade-off: this reads EF's public operation model, so an EF upgrade that legitimately reshapes it will fail this test. I judged that worth a human look rather than designing around it — tell me if that is wrong.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4889567bbc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +256 to +260
var members = value.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.OrderBy(p => p.Name, StringComparer.Ordinal)
.Select(p => $"{p.Name}={DescribeValue(SafeGet(p, value), depth + 1)}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include operation annotations in the fingerprint

EF exposes an operation's annotations through GetAnnotations(), not through a public non-indexed property, so this reflection walk never reaches the IAnnotation branch above. Fresh evidence is the two Npgsql:ValueGenerationStrategy annotations already present in InitialCreate.cs: changing or removing either leaves the operation count and every reflected property unchanged, allowing a material identity-generation change to pass the freeze test. Explicitly enumerate each IReadOnlyAnnotatable's annotations, sorting them by name for determinism, before walking its properties.

AGENTS.md reference: AGENTS.md:L48-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 6b3ee09. Correct, and this is the worst of the four — the previous commit did not merely miss something, it shipped a comment claiming coverage the code did not have.

GetAnnotations() is a method, the walk enumerates public non-indexed properties, so the IAnnotation branch I wrote to handle them was unreachable dead code — sitting directly under a comment saying annotations "are described rather than skipped". That sentence was false when written.

Verified as a before/after on the identical mutation rather than argued:

Npgsql:ValueGenerationStrategy IdentityByDefaultColumn -> IdentityAlwaysColumn
4889567 (previous) GREEN — hole confirmed
6b3ee09 (this) RED

Same mutation, same migration; only the fingerprint changed between the two runs.

Annotations are now enumerated explicitly from IReadOnlyAnnotatable and emitted first, so every annotatable in the graph contributes — the operation itself and each AddColumnOperation nested inside a CreateTable. Sorted by name, since IReadOnlyAnnotatable promises no order; determinism re-verified across three consecutive runs.

The pattern across these four rounds is the part I have taken away, and AGENTS.md now records it: every version of this fence shipped a comment asserting coverage it did not have, and every one was caught by review rather than by the test suite. The standard for changing this fingerprint is a mutation that goes red, not an argument that it should.

1254/1254 dotnet, 0 build warnings, migration byte-identical to HEAD afterwards.

…perty walk cannot (#396)

Codex review of #407 round 3, P1 — correct, and the worst of the four, because
the previous commit did not merely miss something: it shipped a comment claiming
coverage the code did not have.

EF exposes an operation's annotations through GetAnnotations(), a METHOD. The
reflection walk enumerates public non-indexed PROPERTIES, so it never reached
them — and the IAnnotation branch written to handle them was unreachable dead
code sitting under a comment that said annotations "are described rather than
skipped". That sentence was false when written.

Proven rather than argued, and the cleanest before/after available: with the
previous commit in place, flipping InitialCreate's Npgsql:ValueGenerationStrategy
from IdentityByDefaultColumn to IdentityAlwaysColumn — a material
identity-generation change — left the freeze test GREEN. The SAME mutation
against this commit fails it. Only the fingerprint changed between those two
runs; the mutation and the migration were identical.

Annotations are now enumerated explicitly from IReadOnlyAnnotatable and emitted
FIRST, so every annotatable in the graph contributes — the operation itself and
each AddColumnOperation nested inside a CreateTable. Sorted by name, because
IReadOnlyAnnotatable promises no order and an unsorted digest would churn
between runs; determinism re-verified across three consecutive runs.

The pattern across these four rounds is the finding worth keeping, and AGENTS.md
now records it: each version of this fence shipped a comment asserting coverage
it did not have, and each was caught by review rather than by the test suite.
The standard for changing this fingerprint is a mutation that goes RED, never an
argument that it should.

Verified: 1254/1254 dotnet (114 application + 303 domain + 837 integration),
0 build warnings. Migration file confirmed byte-identical to HEAD after the
mutation runs.
@mforce

mforce commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex the P1 on operation annotations is addressed in 6b3ee09.

You were right, and this was the worst of the four: the previous commit shipped an IAnnotation branch that was unreachable dead code, under a comment claiming annotations were covered. GetAnnotations() is a method; the walk enumerated properties.

Before/after on the identical mutation — Npgsql:ValueGenerationStrategy IdentityByDefaultColumn -> IdentityAlwaysColumn: green on 4889567, red on 6b3ee09. Same mutation, same migration, only the fingerprint changed.

Annotations now enumerated explicitly from IReadOnlyAnnotatable, emitted first, sorted by name; determinism re-verified over three runs.

Please re-review. Given this fence has now been wrong four times in a row, I would rather you attack it than confirm it:

  • Is there any other schema-carrying state reachable only through a method rather than a property, the way GetAnnotations() was?
  • Does emitting annotations before properties create any collision or ambiguity between an annotation named X and a property named X?
  • Is IReadOnlyAnnotatable the right interface to test against here, or can an operation carry annotations without implementing it?

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 6b3ee09735

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

… on (#396)

CI failure on 6b3ee09, and it was mine — not a flake.

  CI:    e4d0e30a8d59a2c7c75730d1f3203f0256424a662d20df1c90be22b7609f3c39
  local: 320493300753dc57278ec990ff678e8fd7fed1a82ac438ea8563acda65756efc
  count: 114 on both

ColumnOperation.ClrType is a System.Type, and the reflection walk recursed into
it — out through Assembly.Location / Module.FullyQualifiedName / CodeBase, which
are ABSOLUTE PATHS to System.Private.CoreLib.dll. The digest therefore encoded
where the .NET runtime happens to live: a /nix/store path here, something else
on the runner. Same migration, different answer.

It also made the described text ~168 MB, because Type -> Assembly -> Module
cycles back into the reflection graph until MaxDescribeDepth cuts it off. The
frozen test took 2s; it now takes 17ms.

Fixed by treating a System.Type as a LEAF, described by FullName. That is the
schema-relevant part — a column's CLR type matters, the file its metadata lives
in does not.

The process failure is worth more than the bug. I verified determinism by
running the test three times ON ONE MACHINE and reported it as verified.
Same-run determinism is not portability, and the check I ran could not have
detected this defect in principle. CI caught what my verification was
structurally incapable of catching.

So the missing check is now a test rather than a habit:
InitialCreate_Description_ContainsNoEnvironmentSpecificData asserts the
described text names no assembly file and contains neither AppContext
.BaseDirectory nor the runtime directory, plus a size ceiling as a cheap canary
for the same recursion blowing up again. A future property that leaks
environment fails there by name instead of failing as a mystery digest mismatch
in CI.

Re-verified after the fix, not assumed to still hold:
  * annotation mutant (ValueGenerationStrategy IdentityByDefault -> IdentityAlways) -> RED
  * default-value mutant (CredentialEpoch defaultValue 1 -> 0)               -> RED
  * migration file byte-identical to HEAD afterwards
  * 3 consecutive local runs green (necessary, and — as established above — not
    sufficient on its own; the environment test is what covers the rest)

AGENTS.md records both the Type-leaf rule and the lesson that repeated runs on
one machine prove nothing about portability.

Verified: 1255/1255 dotnet (114 application + 303 domain + 838 integration),
0 build warnings.
@mforce

mforce commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex please review 0367bf7 — it landed after your clean pass on 6b3ee09 and is therefore unreviewed.

It fixes a defect CI caught and I did not: the operation digest was machine-dependent. ColumnOperation.ClrType is a System.Type, and the reflection walk recursed into it out through Assembly.Location, embedding an absolute path to System.Private.CoreLib.dll — a /nix/store/... path on my box, something else on the runner. Same migration, different digest: green locally, red in CI. It also inflated the described text to ~168 MB by cycling Type -> Assembly -> Module, so the test ran in 2s instead of 17ms.

System.Type is now a leaf described by FullName. Added InitialCreate_Description_ContainsNoEnvironmentSpecificData, which asserts the described text names no assembly file and contains neither AppContext.BaseDirectory nor the runtime directory, plus a size ceiling as a canary for the recursion blowing up again.

Worth stating plainly: I had reported determinism as "verified" on the strength of three runs on one machine. That check could not have detected this in principle — same-run determinism is not portability. CI is now the evidence: the digest computes identically on NixOS and the runner, and both fence tests pass there.

Re-verified after the fix rather than assumed to still hold — annotation mutant RED, default-value mutant RED, migration byte-identical to HEAD.

This is the sixth change to this fence and the previous five each had a real finding, so please be adversarial rather than confirmatory:

  • Does treating System.Type as a leaf drop anything schema-relevant? My claim is that a column's CLR type matters and the file its metadata lives in does not.
  • Is FullName sufficient, or should it be AssemblyQualifiedName — and does using FullName make two distinct types from different assemblies collide?
  • Can any other property in the walk still carry environment, culture, or ordering that the new guard would not catch?

…fiedName (#396)

Comment only — no behaviour change, digest unchanged, 11/11 green.

Pre-empts the obvious review suggestion, which would be a regression rather
than a fix: AssemblyQualifiedName carries the assembly VERSION, so a routine
dependency bump would move this digest with no schema change — the same
false-red churn the Assembly.Location leak caused, arriving by a tidier route.

Checked rather than asserted: every ClrType InitialCreate actually uses is a BCL
primitive (Guid, string, int, long, decimal, bool, DateTime, DateTimeOffset,
DateOnly), all from one assembly, so the FullName collision this trades away
cannot occur here.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 0367bf7be8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

mforce added a commit that referenced this pull request Sep 12, 2026
Adds coverage measurement for the four backend test projects. **Report
only: there is no threshold, floor, or gate anywhere in this PR**, which
is #776's explicit instruction and the order `web/vite.config.ts`
followed for the SPA — measure first, decide whether to floor it later,
from actuals.

Closes #776

## What lands

`tools/coverage/collect.sh` builds once, runs each test project under
coverlet, merges the per-project cobertura output through
ReportGenerator into per-project and combined reports, and writes
`coverage-out/SUMMARY.md`. The script is table-driven: one `ROWS` array
of `name|csproj|meaning`, so a fifth test project is one row and nothing
else. `--project <name>` runs one project alone for local iteration.

`tools/coverage/coverlet.runsettings` carries exactly one interpretive
filter, the EF migrations exclusion. Everything else stays at coverlet's
default deliberately, so this first measurement is untuned.

`.github/workflows/coverage.yml` runs it Mondays at 05:00, on
`workflow_dispatch`, and on a `pull_request` scoped to the coverage
tooling's own paths. `ci.yml` is untouched.

`coverlet.collector` is versioned centrally per #684 with a bare
reference in `tests/Directory.Build.props`, and the ReportGenerator tool
joins the existing `dotnet-ef` manifest entry. All four
`packages.lock.json` files are regenerated in the same commit as the
manifest change.

## First measurement, 2026-09-12

| Project | Assemblies | Line | Branch |
|---|---|---|---|
| Domain | 1 | 81.4% (1480/1817) | 74.9% (651/869) |
| Application | 3 | 13.9% (1666/11901) | 3.6% (114/3129) |
| Integration | 4 | 93% (15698/16866) | 76.4% (3814/4989) |
| AppHost | 1 | 100% (30/30) | 100% (2/2) |
| Combined | 5 | 94% (15890/16896) | 79.5% (3968/4991) |

Integration's four assemblies split unevenly: `Cluckwork.Api`
92.3%/72.8%, `Cluckwork.Application` 95.4%/81.7%, `Cluckwork.Domain`
83.6%/66.2%, `Cluckwork.Infrastructure` 94.9%/84.4%.

## Two readings these numbers invite, and both are wrong

**The unit suites are not redundant, whatever the overlap says.** Domain
and Application together add 162 covered lines beyond what Integration
and AppHost already reach, over an identical 16,896-line denominator.
Read naively that says 781 unit tests are nearly free to delete.
Coverage cannot tell a test that asserts a domain invariant from one
that happens to execute the same line while asserting an HTTP status,
and #771 already found three fully-covered guards that survived the
exact mutations they were named for. The redundancy question needs
mutation testing; this is not it, and the decision record says so.

**Application's 13.9% is not "the Application layer is 14% tested."**
Integration measures `Cluckwork.Application` at 95.4%. The 13.9% is what
the `Application.Tests` project's own 290 tests, largely validators and
architectural guards, reach across the three assemblies they
incidentally touch.

## The three decisions #776 asked to be made explicitly

Per-project is the primary view and combined is secondary, because a
single figure averages a unit-tested Domain against an end-to-end suite.
Collection runs on a schedule rather than on every PR, because `Build
and test` is already the ~570s critical path that #775 exists to
shorten. Integration's EXECUTED-not-ASSERTED caveat is printed in the
report itself rather than left for a reader to infer. All three are
recorded in `docs/decisions/776-backend-coverage.md`.

## The one filter, and how it was checked

`src/Cluckwork.Infrastructure/Persistence/Migrations` is 39,088 of the
76,977 lines of C# under `src/`. It is EF-generated, #407 freezes it,
and every integration test executes all of it on container boot
regardless of what that test asserts, so including it would swamp the
measurement in both directions.

Verified against the run's output, not assumed from the config: the
Integration report lists zero `*.Migrations.*` classes, the raw
cobertura XML contains zero `Persistence/Migrations` paths (so the
exclusion drops files before cobertura records them, not just from the
rendered report), and all 33 files in that directory are EF-generated —
16 timestamped migrations as `.cs`/`.Designer.cs` pairs plus
`AppDbContextModelSnapshot.cs` — so nothing hand-written was dropped.

## Reviewing this

The measurement reproduces: `tools/coverage/collect.sh`, or
`tools/coverage/collect.sh --project Domain` for a fast single-project
check. The `pull_request` path filter means this PR runs the new
workflow on itself, so the CI run here is the proof the tooling works
rather than a description of it.

`ci.yml` is not modified and no security gate is touched. No
user-visible behaviour changes, so no screenshots and no glossary or
Help page update.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added automated backend test coverage reporting across the test suite.
- Coverage reports include per-project and combined summaries in HTML,
Markdown, and text formats.
- Reports can be generated for the full suite or an individual test
area.

- **CI/CD**
- Coverage runs weekly, manually on demand, and for pull requests
affecting coverage tooling.
- Coverage results are report-only and do not block builds or releases.

- **Documentation**
- Added guidance explaining coverage measurement, scope, exclusions, and
limitations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
mforce pushed a commit that referenced this pull request Sep 29, 2026
Review of #985 found the branch's own documentation contradicting what it
enforces, plus two missing artifacts the repo's rules require.

- .editorconfig's header and block comment still called using placement
  "a recorded preference at silent severity, not a gate" and said "nothing
  fails if it does not". Both are false at this head. Rewritten to state
  that any `:warning` entry in this file is a build error.
- Dropped a dead `block_scoped:silent` from the fixture exemption. IDE0161
  never reports on a compilation unit declaring more than one namespace, so
  the line configured nothing. Verified by building without it.
- Corrected the migrations exemption's reason. It is there because
  `dotnet ef migrations add` scaffolds block-scoped namespaces, so it must
  keep working for migrations nobody has written yet, not because #407
  freezes the existing ones. The old comment also claimed migrations are
  "never hand-edited", which AddBusinessRecordChronology contradicts.
- AGENTS.md paragraph and docs/decisions/985-csharp-style-gate.md, both
  required for a build-gating convention. The record keeps the measurement
  that the gate's effective set is exactly {IDE0065, IDE0161}, and the
  correction that outside placement is not inherently safer.
- move-usings-outside.py: the mixed-file branch collapsed blank lines across
  the whole file while a comment below it said a global pass is churn the
  change has no business making. Narrowed to the seam, shared with the other
  branch. The docstring also said 586 files and claimed mixed files are
  skipped; both were wrong. Re-running the fixed script on main reproduces
  all 589 files byte for byte.
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.

Improve daily entry: saleable Cracked and Dirty quality eggs

1 participant