Skip to content

feat(api,web): show who created and last changed a record, inline on its own page (#494) - #503

Merged
mforce merged 9 commits into
mainfrom
feat/494-record-provenance
Aug 11, 2026
Merged

mforce merged 9 commits into
mainfrom
feat/494-record-provenance

Conversation

@mforce

@mforce mforce commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #494.

Admins and workers can now see, on a record's own list page, who created it and when — and who last changed it, if anyone has. Covers Flocks, Egg grades, Daily entry history, Sales and Expenses.

Derived entirely from the existing append-only audit trail: no schema change, no migration, no new endpoint.

The gap that had to be closed first

None of the five create paths wrote an audit event at all, so "earliest event = created" would have silently resolved to the first correction. All five now write one, with matching vocabulary entries on both the server registry and the SPA's list, which an existing coverage test holds in lockstep.

Design notes

Tenant scoping is manual and load-bearing. GetProvenanceAsync runs two raw-SQL DISTINCT ON queries, because Postgres DISTINCT ON has no LINQ translation. Raw SQL opts out of EF's tenant query filter, so the hand-written AccountId predicate is the only thing scoping the read. A test fails if either half is removed.

Oversized batches are chunked, not refused. The egg-grade list has no pagination and grades are never deleted, so that catalog outgrows any fixed cap eventually. An earlier revision threw above 500 ids, which would have 500'd the whole endpoint the daily-capture flow depends on.

The server decides whether a change happened, and sends nulls when nothing has. It compares event identity, not timestamps — two distinct events can share an instant, and a timestamp comparison hides a real edit. The client must not re-derive this.

Visibility inherits each endpoint's existing read access. No new authorization branch. This does newly surface a coworker's email address on screens that previously showed none — the deliberate product decision recorded in the issue, not an oversight.

No backfill. Records created before this show a blank column, as the issue specifies.

Verification

  • 1547 backend tests, 1708 web tests, build clean under warnings-as-errors, typecheck clean, SPA coverage gate passing.
  • Five mutation checks, each confirmed red-then-green: the create-vs-append gate, the tenant predicate, the SQL ordering, the batch chunking, and the page-level column wiring.
  • Reviewed by codex, pi, and two Claude agents. Codex found the same-instant defect described above and a conventions pass found the missing page-wiring coverage; both are fixed here.

Non-CI callers checked, per the write-contract rule

  • k6 — presence-based checks, and it does not touch the five changed endpoints. No change needed.
  • Playwright — the one index-based selector is on the Audit table, untouched here; the five changed pages use text-based row filters, so a new column cannot shift them. No change needed.
  • Seeders — they resolve the tenant before invoking handlers, so nothing throws. They do stamp "(unresolved)" as the actor, which this change makes visible for the first time. Filed separately as fix(seed): seeded audit events carry "(unresolved)" as the actor, now visible on record History columns #500 rather than widening this PR.

graphify-out/ is deliberately not included, per the reasoning in #497.

…its own page (#494)

Admins and workers can now see, on a record's own list page, who created it and
when — and who last changed it, if anyone has. Covers Flocks, Egg grades, Daily
entry history, Sales and Expenses.

Derived from the existing append-only audit trail, so there is no schema change
and no new endpoint.

A gap had to be closed first: none of the five create paths wrote an audit event
at all, so "earliest event" would have resolved to the first correction. All five
now write one, with matching vocabulary entries on both the server registry and
the SPA's list.

- AuditEventRepository.GetProvenanceAsync: two raw-SQL DISTINCT ON queries per
  batch of ids, earliest event as the creation and latest as the last change.
  Raw SQL means EF's tenant query filter is opted out of, so the hand-written
  AccountId predicate is the only thing scoping the read — both halves are
  load-bearing and covered by a test that fails if either goes.
- Oversized inputs are chunked rather than refused. The egg-grade list has no
  pagination and grades are never deleted, so that catalog outgrows any fixed
  cap eventually; refusing would have 500'd the whole endpoint.
- The server decides whether a change happened, comparing event IDENTITY rather
  than timestamps, and sends nulls when nothing has. Two distinct events can
  share an instant, so a timestamp comparison would hide a real edit.
- Visibility inherits each endpoint's existing read access — no new
  authorization branch. This does newly surface a coworker's email on screens
  that showed none before, which was the deliberate product decision.
- Records predating this get no backfill and show a blank column.

Docs per the sync rule: a "Record history" glossary entry and two Help page
bullets, in en/es/tl.

Verified: 1547 backend tests, 1708 web tests, build clean under
warnings-as-errors, typecheck clean, SPA coverage gate passing. Five mutation
checks confirmed red-then-green — the create-vs-append gate, the tenant
predicate, the SQL ordering, the batch chunking, and the page-level column
wiring.

Follow-up filed as #500: seeded audit events carry "(unresolved)" as the actor,
which this change makes visible on the new columns for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mforce

mforce commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

@codex please review. Note two things you flagged in a local pre-review round are already addressed in this branch:

  1. Same-instant events hiding a real change — fixed. The server now decides whether a change occurred by comparing audit event IDENTITY rather than timestamps, and sends nulls when nothing has changed; the client no longer re-derives it. Pinned by Provenance_WhenTwoEventsShareAnInstant_StillReportsTheChange and a matching ProvenanceCell case, both mutation-checked.

  2. Legacy corrections misreported as creation — a pre-Show "created by / when" and "last changed by / when" inline on record pages #494 record with no Create event but an existing correction will show that corrector as its creator. This is specified behaviour, not an oversight: issue Show "created by / when" and "last changed by / when" inline on record pages #494 states it "falls back to its earliest correction event" and the owner confirmed shipping it. Flagging it here so a re-review does not re-raise it as new.

Worth your attention on this round: the manual tenant predicate in the raw-SQL path, the chunking loop, and whether the new nullable lastChanged* semantics are handled consistently across all five endpoints.

@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: de6ebb336b

ℹ️ 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".

.ToListAsync(ct);
}

// #494 — earliest event per id is the creation, latest is the last change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Audit every mutation used by last-changed provenance

For post-#494 records, this assumption is false for two of the five extended resources: re-recording or submitting a DailyEntry, and adding/updating/removing lines, confirming, or cancelling a SalesOrder, mutate the displayed record without writing an audit event in their handlers. Consequently, ordinary workflows such as creating an order and then adding its first item still return lastChangedByEmail: null, while the UI claims the record has never changed since creation; these mutations need corresponding entity audit events or provenance must come from a source that observes every mutation.

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.

Correct finding. Partially addressed in c91edb9 — one of the two resources is fixed, the other is an open product decision with the repo owner, so I am flagging rather than closing this.

Daily entries — done. SubmitDailyEntryHandler now writes DailyEntry.Submit into its own unit of work, so it commits with the lots and movements or not at all.

That surfaced a product question your finding does not cover. Auditing submit alone makes the common case read wrong: one person saves a draft and submits it, changing nothing in between, and the row would then claim somebody changed their work. So "last changed" is now the latest event that is neither the creation nor a submit by the creator. Written as an exclusion from the candidate set rather than as "if the last event is a self-submit, report nothing" — the latter also discards an earlier edit that the submit happens to sit on top of, which would hide a real change by a different person. The exclusion is keyed on the action, never the actor alone, so correcting a locked entry always shows even when the corrector created it.

Draft re-recording — deliberately not audited. Re-saving a draft is not a stock-altering change and the owner does not want it on the trail. The consequence is stated rather than hidden: a draft edited by a second person before submission leaves no event.

Sales orders — open, and genuinely not decided. AddOrderItem / UpdateOrderItem / RemoveOrderItem / ConfirmSale / CancelSalesOrder still write nothing, so your "create an order, add its first item, still reports null" example stands unfixed today. Orders are created as Draft, so the same question applies as for daily entries and the answer is not mechanical — it is the repo owner's call, not mine to make inside a review round. I would rather leave this thread open and honest than mark it resolved.

Verification for what did land: 1554 backend tests, 1708 web tests, typecheck clean. Three mutants confirmed red-then-green — dropping the exclusion, reducing it to an actor-only rule, and writing the wrong submit action. A fourth mutant (deleting the audit write outright) was discarded as unusable: it leaves the primary-constructor parameter unread, and warnings-as-errors turns that into a build failure, which produces no test result at all rather than a kill.

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.

Sales orders are now done too — dbb6240. This thread is fully addressed, with one deliberate exception stated at the end.

Rather than work from the five handlers you listed, I walked every mutation handler across all five extended resources and classified each. That found Sales was the only resource with gaps, and that it had six unaudited handlers, not five — RecordPayment was missing from the list. It turns out not to belong here (it inserts a Payment row without touching the order aggregate), but Payment.Void is audited while Payment.Create is not, which is a real asymmetry your finding did not reach.

The order state machine then decided the rest, since AddItem / UpdateItem / RemoveItem / Cancel are all Draft-only and Confirm is what allocates:

handler applies to allocates? audited
AddItem / UpdateItem / RemoveItem Draft no no
Confirm Draft to Confirmed yes, FIFO yes
Cancel Draft to Cancelled no, but terminal yes
Void Confirmed to Voided yes already was

Confirm is a promotion — the action that turns a draft into the official record, alongside DailyEntry.Submit — so the self-promotion exclusion now covers both and confirming your own order reports no change. Cancel is deliberately not a promotion: it kills a draft rather than making it official, so cancelling your own order stays reportable. A mutant that adds Cancel to the promotion list pins that distinction rather than leaving it to a comment.

The write sits inside the existing FIFO transaction, so the event commits with the allocations or rolls back with them.

The deliberate exception, stated plainly: your worked example still reports null. Create an order, add its first item, and the History column still says nothing has changed — because draft line edits are not audited, exactly as re-recording a draft daily entry is not. Assembling a draft is not a change to report. That is the rule the owner chose, applied consistently; it is not an oversight, and I would rather say so than quietly leave you to re-find it.

Verification: 1560 backend tests, 1708 web tests, typecheck clean. Seven mutants confirmed red-then-green, each against a named test — no exclusion, actor-only rule, promotion list missing Confirm, promotion list over-including Cancel, and a wrong action at each of the three capture points.

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.

Addressed across 01d7573e (drafting writes) and d0447929 (this round).

01d7573e added the six missing mutations to the trail — DailyEntry.Update, SalesOrder.AddItem / UpdateItem / RemoveItem, plus Confirm and Cancel — so a draft edit is no longer invisible to the last-changed column.

d0447929 closes the consequence that landed with them: hiding the creator's own drafting is right while the draft is theirs alone, but it was still applied after a colleague had edited it, so the column named the colleague whose numbers had since been overwritten. A shared CTE now marks records carrying a drafting event by an actor other than the creator, and the exclusion lifts for those.

Verification: 1577 backend tests green (325 domain / 145 application / 1107 integration), 1713 web, typecheck clean. Mutation set is 27 mutants, 26 killed by a named test; the one survivor is recorded in the source with why no fixture can currently distinguish it.

WHERE "AccountId" = {accountId}
AND "EntityType" = {entityType}
AND "EntityId" = ANY({ids})
ORDER BY "EntityId", "OccurredAtUtc" ASC, "Id" ASC

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve actual event order when timestamps collide

When two events share OccurredAtUtc, ordering by Id does not preserve their chronology because AuditWriter assigns each event a random Guid.NewGuid(). The smaller UUID is therefore reported as the creator and the larger as the last changer regardless of which event occurred first, so the actors can be reversed; the matching same-instant test is also probabilistic for the same reason. Use a durable monotonic ordering value rather than a random UUID as the tiebreaker.

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.

Real, and worse than the wording suggests — the test I wrote for this case was itself a coin flip. Fixed in c91edb9.

You are right that Id carries no chronology: AuditWriter mints a random v4 Guid, so the tiebreaker named whichever event happened to sort first and could reverse creator and changer. The fix does not try to recover the true order — it stops depending on order for that question. Creation is now identified by its action ("Action" LIKE '%.Create'), of which there is at most one per entity, so a same-instant pair resolves the same way every time regardless of how it sorts.

Ordering still picks the last change, and a same-instant pair there is still arbitrary — but every candidate in that set genuinely is a change, so naming either is true. That is a materially weaker claim than naming a corrector as the author, and it is the one the code now makes.

Verification, including the part that embarrasses me: the original same-instant test passed 4 runs in 10 against the old ordering. I had run it once, seen green, and called it mutation-verified. It now passes 10/10. Full suite 1554 backend / 1708 web, typecheck clean.

…ng as a change

Two codex findings on #503, and the product decision behind them.

Creation is now identified by its ACTION rather than by its position on the
trail. Position could not answer it: events sharing an OccurredAtUtc have no
knowable order, because AuditWriter mints a random v4 Guid, so the Id
tiebreaker named whichever event happened to sort first — reversing creator
and changer at random. The test written for that case was itself a coin
flip: 4 passes in 10 runs. It is now deterministic, verified 10/10.

Submitting a daily entry is now audited. It mints the egg lots, so it alters
stock on its own merits, and it is the only record of who made a day's
numbers official.

That alone would have made the common case read wrong: one person saves a
draft and submits it, changing nothing in between, and the row would claim
somebody changed their work. So the last change is now the latest event that
is neither the creation NOR a submit by the creator. Stated as an exclusion
from the candidate set, deliberately — the naive "if the last event is a
self-submit, report nothing" also discards an earlier edit the submit sits on
top of, hiding a real change by a different person.

The exclusion is keyed on the action, never on the actor alone: correcting a
locked entry always shows, including when the person correcting it is the one
who created it.

Verification: 1554 backend tests, 1708 web tests, typecheck clean. Three
mutants confirmed red-then-green — dropping the exclusion, reducing it to an
actor-only rule, and writing the wrong submit action.

Refs #494.
@mforce

mforce commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

@codex both findings answered in c91edb9 — one closed, one deliberately left open.

"Preserve actual event order when timestamps collide" — closed. Creation no longer depends on trail position at all; it is identified by its action (%.Create), so a same-instant pair resolves identically however it sorts. The test written for that case was itself a coin flip (4 passes in 10 against the old ordering); it now passes 10/10.

"Audit every mutation used by last-changed provenance" — half closed. DailyEntry.Submit is now written. Sales-order mutations (add/update/remove line, confirm, cancel) still write nothing, so your worked example stands — that is a product decision sitting with the repo owner, not something to settle inside a review round. Draft re-recording is deliberately excluded and the consequence is documented.

Worth a fresh look, since it is where new defects would live: the last change is now the latest event that is neither the creation nor a submit by the creator. That is stated as an exclusion from the candidate set rather than as a check on the last event — the naive form would hide an earlier edit by a different person that a self-submit happens to sit on top of. It is keyed on the action, not the actor, so a self-adjust of a locked entry still shows.

Verification: 1554 backend, 1708 web, typecheck clean, three mutants confirmed red-then-green.

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: c91edb9952

ℹ️ 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".

…d history

Closes the sales-order half of codex's "audit every mutation used by
last-changed provenance" on #503.

A walk of every mutation handler across all five resources — rather than the
list codex gave — found Sales was the only one with gaps, and that it had six
unaudited handlers rather than five. The order state machine then decided
which of them belong on the trail:

  AddItem / UpdateItem / RemoveItem   Draft-only, allocate nothing  -> no
  Confirm                             Draft to Confirmed, FIFO      -> yes
  Cancel                              Draft to Cancelled, terminal  -> yes
  Void                                already audited

That is the daily-entry rule applied unchanged: draft edits are not stock
movements and stay off the trail, exactly as re-recording a draft entry does.

Confirm is a PROMOTION — the action that turns a draft into the official
record, alongside DailyEntry.Submit — so the self-promotion exclusion now
covers both, and confirming your own order reports no change. Cancel is
deliberately NOT a promotion: it kills a draft rather than making it official,
so cancelling your own order stays a reportable change. A mutant that adds
Cancel to the promotion list pins that distinction.

Confirm writes inside the existing FIFO transaction, so the event commits with
the allocations or rolls back with them.

Known and intended: creating an order and adding its first line still reports
no change, because assembling a draft is not a change to report.

Verification: 1560 backend tests, 1708 web tests, typecheck clean. Seven
mutants confirmed red-then-green, each against a named test.

Refs #494.
@mforce

mforce commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

@codex sales-order auditing landed in dbb6240 — the half of "audit every mutation used by last-changed provenance" I left open is now closed.

Worth re-checking, since it is new logic rather than a repeat of what you already passed:

  • SalesOrder.Confirm and SalesOrder.Cancel are now audited. Confirm writes inside the existing FIFO allocation transaction.
  • The self-promotion exclusion widened from %.Submit to %.Submit + %.Confirm. Cancel is deliberately excluded from that list — it kills a draft rather than promoting it, so cancelling your own order stays a reportable change.
  • Draft line edits (AddItem / UpdateItem / RemoveItem) remain unaudited by design, so your worked example still returns null and is expected to. Assembling a draft is not a change to report.

One thing your finding did not reach, found by walking every handler rather than the listed five: RecordPayment writes no event while Payment.Void does. It does not mutate the order aggregate so it is out of scope for the History column, but the create/void asymmetry is real and I have left it alone rather than widen this PR.

Verification: 1560 backend, 1708 web, typecheck clean, seven mutants confirmed red-then-green.

@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: dbb62408e3

ℹ️ 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".

AND e."Action" NOT LIKE {createAction}
AND NOT (e."Action" LIKE ANY({promotionActions})
AND e."ActorUserId" IS NOT DISTINCT FROM c."ActorUserId")
ORDER BY e."EntityId", e."OccurredAtUtc" DESC, e."Id" DESC

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop using random IDs to order colliding changes

When two reportable changes for the same entity have an identical OccurredAtUtc—for example, an update and archive recorded within the database timestamp precision—the selected last changer still depends on Id, although AuditWriter generates random v4 GUIDs. Fresh evidence after c91edb9 is that creation is now identified correctly by action, but this new latest-change query still orders equal-timestamp changes by random ID, so it can attribute the earlier change as the latest one; use a durable monotonic ordering value for this tie-break instead.

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.

You are right, and I checked the reachability rather than arguing from the code shape. Documented in 859eca0; split out as #508.

Confirmed real. AuditWriter mints Guid.NewGuid(), a random v4, so the "Id" tiebreak carries no chronology and a same-instant pair of reportable changes resolves arbitrarily. The timestamp displayed is correct either way; the name is what can be wrong.

Where I expected you to be wrong, and was not. My first instinct was that two changes to one record cannot truly collide: the aggregate Version token serialises them, and the loser 409s having written nothing, since audit shares the transaction. That argument holds for most handlers — and then fails. RecordBirdMovementHandler writes an audit event keyed to the flocks id while only inserting a BirdMovement row; it never mutates Flock, so there is no Version bump and it does not serialise against Flock.Update. Two such requests can land in the same microsecond against the same flock. That is the concrete path, and it is why I am not arguing this one away.

Why it is not fixed here. Your remedy — a durable monotonic ordering value — does not exist on this table, and creating one is a migration. #494s stated premise is no schema change, so the correct fix is out of scope for this PR by construction rather than by preference. #508 carries it, including the two alternatives that do not work so they are not re-proposed: UUIDv7 orders only to millisecond granularity and keeps a random tail (and would mix with existing v4 rows), and ctid is not durable across VACUUM FULL.

No test pins the current outcome, deliberately. Pinning a known loss would turn "did not fix" into "spec", and the next person would read it as intended behaviour.

For completeness: ListAsync shares the tiebreak. There it is merely arbitrary rather than wrong, since paging only needs a deterministic order, but the Audit page can still show a same-instant pair in the wrong order. #508 covers both call sites.

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.

Still open by design, and now tracked as #508 rather than left implicit.

The ordering is only arbitrary when two reportable changes share an OccurredAtUtc, and fixing it properly needs a durable monotonic column on AuditEvents — a migration, which #494 was specified not to do. The timestamp shown is correct either way; only the name is at risk, and the path is narrow (the aggregate Version token serialises most concurrent writes, so the loser 409s having written nothing; RecordBirdMovement is the exception, and it writes against the flock without touching Flock.Version).

The reasoning and the reachable path are both in the source comment at the query that has it, so the next person meets it where the risk is. There is deliberately no test pinning the current outcome — that would promote a known loss into a specification.

… has it

Codex's third round on PR #503 is correct: the last-change tiebreak still
orders by "Id", and AuditWriter mints a random v4 Guid, so two reportable
changes sharing an OccurredAtUtc resolve arbitrarily and the wrong actor can
be named. The timestamp shown is right either way; only the name is at risk.

Reachable, not theoretical, and the reason matters. Most concurrent changes to
one record cannot collide: the aggregate Version token serialises them and the
loser 409s having written nothing. RecordBirdMovement escapes that argument —
it writes an audit event against the FLOCK's id while only inserting a
movement row, never touching Flock.Version, so it does not serialise against
Flock.Update.

Fixing it properly needs a durable monotonic column on AuditEvents, which is a
migration — the one thing #494 was specified not to do. Tracked in #508 rather
than smuggled into a PR whose premise was no schema change.

No test pins the current arbitrary outcome. Pinning a known loss would turn
"did not fix" into "spec".

Refs #494, #508.
@mforce

mforce commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

@codex the same-instant ordering finding is accepted, not fixed here — 859eca0 documents it and #508 carries the fix.

Short version: you are right that "Id" is a random v4 and cannot order a same-instant pair, and I confirmed a concrete path rather than taking it on trust — RecordBirdMovement writes an event against the flocks id without touching Flock.Version, so it does not serialise against Flock.Update the way the concurrency token makes most handlers serialise.

The remedy you propose needs a durable monotonic column on AuditEvents, i.e. a migration, and #494s premise is no schema change. So it is #508, with the non-working alternatives (UUIDv7, ctid) written down so they are not re-proposed. No test pins the current arbitrary outcome — that would turn a known loss into a spec.

Everything else on this PR is landed and verified: 1560 backend tests, 1708 web, typecheck clean, seven mutants confirmed red-then-green.

Two things I would genuinely value a fresh look at, both new since your last clean pass:

  1. The promotion exclusion now spans %.Submit + %.Confirm, with %.Cancel deliberately outside it. If that distinction is wrong, the failure is silent.
  2. SalesOrder.Confirm writes inside the existing FIFO allocation transaction — worth checking the placement is genuinely on the commit path and after every failure return.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 859eca09e9

ℹ️ 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".

…ated

Local adversarial review of PR #503 found, by mutation rather than argument,
that three predicates on the new last-change query were unguarded. All three
survived deletion against the entire suite:

  outer last-change query loses AccountId    28/28 still pass
  creator CTE loses AccountId                28/28 still pass
  creator CTE loses the ".Create" filter     28/28 still pass

The cause is that Provenance_IsScopedToTheTenant gives each account a DISTINCT
entityId. The foreign row is therefore excluded before those predicates are
ever consulted, so the test that the file's own comment cited as holding them
in place did no such thing. That comment was the defect: a guard that reads as
safety while enforcing nothing.

Two tests now hold them, both mutation-verified red-then-green:

  Provenance_LastChange_IsScopedToTheTenant shares ONE entityId across two
  accounts, with the rival's Create dated EARLIER and its Adjust LATER, so
  dropping either AccountId predicate changes the reported answer.

  Provenance_TheCreatorForTheExclusion_IsFoundByActionNotByPosition places a
  correction before the creation, the only arrangement where position and
  action disagree.

Neither is a live leak: entity ids are unique across accounts and callers pass
their own already-filtered ids. The fix is to the guard, not to a leak.

Also from the same pass:

- The promotion set is now the exact constants rather than a "%.Submit" /
  "%.Confirm" suffix pattern. Creation rightly keeps its wildcard, since any
  ".Create" IS a creation, but promotion is a closed set and a pattern would
  silently swallow a future action that merely ends the same way.
- The EntityProvenance doc comment described the position-based algorithm this
  branch abandoned, and claimed LastChanged is null only when both ends of the
  trail are the same event. Both false since c91edb9.
- The IS NOT DISTINCT FROM justification claimed to prevent something that
  cannot be observed, since such records are dropped by the keying first. The
  operator stays; the claim is now honest about not being load-bearing.
- Three end-to-end tests discarded the status of the call they depend on. A
  submit or add-item that started 422ing would have left them green and empty.

Verification: 1562 backend tests, and 10 mutants confirmed red-then-green,
each against a named test.

Refs #494.
@mforce

mforce commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

@codex 6c9318f — a local adversarial pass found, by mutation rather than argument, that three predicates on the new last-change query were unguarded. All three survived deletion against the entire suite:

  • outer last-change query loses AccountId — 28/28 still passed
  • creator CTE loses AccountId — 28/28 still passed
  • creator CTE loses the .Create filter — 28/28 still passed

Cause: Provenance_IsScopedToTheTenant gives each account a distinct entityId, so the foreign row is excluded before those predicates are consulted. The test the file cited as holding them did no such thing. Not a live leak — entity ids are unique and callers pass their own filtered ids — but the guard was stated rather than real, which is the worse failure.

Two new tests hold them now, both mutation-verified: one shares a single entityId across two accounts with the rivals Create dated earlier and its Adjust later; the other places a correction before the creation, the only arrangement where position and action disagree.

Also: the promotion set is now the exact constants instead of a %.Submit/%.Confirm suffix pattern, and three end-to-end tests that discarded the status of the call they depend on now assert it.

10 mutants confirmed red-then-green, each against a named test. 1562 backend tests green.

Three behavioural consequences surfaced by that pass are with the repo owner as product decisions, not defects — I will not close them silently: a co-author who only edits a draft leaves no trace when the creator promotes; the promotion timestamp (when stock is actually minted) is no longer visible on the records own page; and a pre-#494 record with real changes renders fully blank rather than showing the change we do have attribution for.

@chatgpt-codex-connector

Copy link
Copy Markdown

The adversarial additions at 6c9318f correctly exercise the previously unguarded predicates:

I found no additional defect requiring a code change. The three surfaced behavioral questions are appropriately left as explicit product decisions rather than silently encoded here. Because the working tree remains clean, I did not create a commit or a follow-up PR.

Testing

  • ⚠️ dotnet test tests/Cluckwork.Api.IntegrationTests/Cluckwork.Api.IntegrationTests.csproj --filter 'FullyQualifiedName~AuditProvenanceTests' --no-restore (the environment does not have the dotnet executable installed)
  • ✅ git status --short (clean working tree)

View task →

…ed it

Excluding a self-promotion from "last changed by" removed the noise but also
removed the only visible trace of WHEN the record became official — and that
instant is when stock is minted or allocated. A daily entry drafted Monday and
submitted Friday read as "created Monday" and nothing else; Friday existed only
in the admin audit log, because DailyEntry stores no submission timestamp of
its own, just LockedAtUtc. The same applied to a sales order's confirm, which
is the FIFO allocation moment. Raised by the local adversarial review of #503.

EntityProvenance now carries MadeOfficialAtUtc, reported whoever performed the
promotion, so the suppression costs a name and not a time. It is null for a
draft still awaiting promotion and for resources with no promotion step at all.

Read by a third query rather than folded into the two above it. Those two are
the ones under mutation guard and are already dense; this is a plain indexed
lookup on the same (AccountId, EntityId) index, and at most one promotion
exists per entity since neither DailyEntry nor SalesOrder has a path back to
Draft.

Only the two resources that have a promotion expose the field. On the SPA the
caller declares which concept applies — "submitted" for daily entries,
"confirmed" for sales orders — so flocks, egg grades and expenses cannot render
the line at all, and each gets a full translated phrase rather than a verb
composed with a date, which would put word order beyond a translator's reach.

Its tenant predicate got its own guard immediately, rather than repeating the
mistake the previous commit fixed: two predicates had sat unguarded because
every tenant test used distinct ids per account.

Verification: 1566 backend tests, 1712 web tests, typecheck clean. 12 mutants
confirmed red-then-green, each against a named test.

Refs #494.
@mforce

mforce commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

@codex baba9a2 adds the promotion instant to the record-history column.

The gap: excluding a self-promotion from "last changed by" removed the noise but also removed the only visible trace of when the record became official — the moment stock is minted or allocated. A daily entry drafted Monday and submitted Friday read as "created Monday" and nothing else, because DailyEntry stores no submission timestamp of its own, only LockedAtUtc. EntityProvenance now carries MadeOfficialAtUtc, reported whoever promoted it, so the suppression costs a name and not a time.

New surface worth a fresh look:

  • A third raw-SQL query in GetProvenanceChunkAsync, with its own tenant predicate. It got a guard in the same commit rather than after the fact — the previous commit existed because two predicates had sat unguarded, since every tenant test used distinct ids per account.
  • Only the two resources with a promotion step expose the field. On the SPA the caller declares which concept applies, so flocks, grades and expenses cannot render the line even if the field arrived.
  • Null for a draft awaiting promotion; no zero-date invented.

12 mutants confirmed red-then-green, each against a named test. 1566 backend, 1712 web, typecheck clean.

Two findings from that same local pass remain open as product decisions, deliberately not fixed: a co-author who only edits a draft leaves no trace when the creator promotes (draft edits are unaudited by design), and a pre-#494 record with real changes renders fully blank because the result is keyed off the creation event.

@chatgpt-codex-connector

Copy link
Copy Markdown

No additional defect found in baba9a2e.

The acknowledged pre-#494 blank-record behavior remains unchanged: ProvenanceCell returns the blank placeholder when creation provenance is unavailable, even if another timestamp exists. web/src/components/ProvenanceCell.tsxL37-L44

The working tree remained clean, so I made no commit and did not create a follow-up PR.

Testing

  • ✅ npm run typecheck
  • ⚠️ dotnet test tests/Cluckwork.Api.IntegrationTests/Cluckwork.Api.IntegrationTests.csproj --filter 'FullyQualifiedName~AuditProvenanceTests' --no-restore (the environment does not have dotnet installed)
  • ⚠️ npm test -- --run src/components/ProvenanceCell.test.tsx (the installed Node 20 runtime rejects the configured --no-experimental-webstorage worker option)
  • ✅ git status --short (clean working tree)

View task →

…redates #494

Two findings from the local adversarial review, both owner-decided.

FIRST — a co-author on a shared draft was invisible. ana records an entry, bo
re-records it changing 100 eggs to 500, ana submits. The 500 came from bo, and
the column read "created by ana, never changed": ana was credited with bo's
numbers. Same shape for an order whose lines somebody else assembled.

Draft edits are now on the trail — DailyEntry.Update and the three
SalesOrder line actions. They move no stock, which is why they were left off,
but they are the only thing binding a person to the numbers.

The noise that omission was avoiding stays avoided, because the exclusion
widened with it: the last change is now the latest event that is neither the
creation nor the creator's own DRAFTING of it, drafting being draft edits plus
the promotion that ends them. Editing your own draft is part of writing it and
stays hidden; a different person's edit surfaces. Keyed on the actor, so the
same event is hidden for one person and shown for another.

SECOND — a record whose creation predates #494 rendered completely blank even
when it had a change with real attribution. The result was keyed off the
creation event, so a flock archived by somebody yesterday showed nothing at
all. Refusing to invent a creator and discarding a change we can prove are
separate decisions; only the first was intended. The two halves are now
independent and CreatedBy* is nullable.

DailyEntry_ReRecordedOnTheSameKey_WritesOnlyOneCreateEvent asserted the
re-record left NO event. Its real invariant — never a second CREATION, which
would give an entry two candidate authors — is intact and now asserted
directly, rather than as a side effect of a total count.

Verification: 1568 backend tests, 1713 web tests, typecheck clean. 16 mutants
confirmed red-then-green, each against a named test. Three patterns in the
mutation script had gone stale against the reshaped code and reported
themselves as inert rather than passing silently; they were repaired and the
whole set re-run.

Refs #494.
@mforce

mforce commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

@codex 01d7573 closes the two behavioural findings I had deliberately left open. Both were owner decisions, now made — this is the round where a fresh look is worth most, because the rule itself changed rather than an implementation detail.

Draft edits are now audited — DailyEntry.Update, SalesOrder.AddItem / UpdateItem / RemoveItem. They move no stock, which is why they were originally left off, but they were the only thing that could bind a person to the numbers. Without them: ana records an entry, bo re-records it changing 100 eggs to 500, ana submits — and the column read "created by ana, never changed", crediting ana with bos numbers.

The exclusion widened with them, so the noise stays suppressed. Last change is now the latest event that is neither the creation nor the creators own drafting of it — drafting being draft edits plus the promotion that ends them. Editing your own draft is part of writing it and stays hidden; a different persons edit surfaces. Keyed on the actor, so the same action is hidden for one person and shown for another — that asymmetry is the part I would attack first.

A record whose creation predates #494 no longer renders blank when it has a change with real attribution. The result was keyed off the creation event, so a flock archived by somebody yesterday showed nothing at all. Refusing to invent a creator and discarding a provable change were being made as one decision; only the first was intended. CreatedByEmail is nullable now and the two halves are independent.

Also in range, from the previous rounds: MadeOfficialAtUtc (a third raw-SQL query, its own tenant predicate), and the tenant-guard repair.

Worth checking hardest:

  1. Three raw-SQL queries plus a CTE now, each with its own AccountId predicate. The last round existed because two of them survived deletion against the entire suite. Each has a named guard now — please verify that independently rather than trusting it.
  2. The union keying: the result is built from three dictionaries, so an id present in only one of them still produces an entry. Can that be wrong or misleading?
  3. DailyEntry_ReRecordedOnTheSameKey_WritesOnlyOneCreateEvent — I changed its assertion, since a re-record now legitimately writes an event. Its real invariant (never a second CREATION) should still be caught.

Verification: 1568 backend, 1713 web, typecheck clean, 16 mutants confirmed red-then-green. Three of those mutant patterns had gone stale against the reshaped code and reported themselves inert rather than passing silently; they were repaired and the whole set re-run.

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • Corrected the product glossary: legacy records do not invent an unknown creator, but still display later changes with provable attribution. specs/product/GLOSSARY.mdL459-L469
  • Updated the user-facing record-history guidance in English, Spanish, and Tagalog to match the new independent creator/change behavior. web/src/i18n/en.tsL2561-L2564 web/src/i18n/es.tsL2154-L2157 web/src/i18n/tl.tsL2236-L2239
  • Committed the follow-up as 4879997 fix(web): describe legacy record history accurately.
  • Attempted to create the required follow-up PR titled “fix(web): align legacy history guidance with attributed changes”, but the configured make_pr MCP server could not start because its environment is missing mcp.server.fastmcp.

Testing

  • ✅ npm run typecheck
  • ✅ git diff HEAD^ --check
  • ✅ git status --short
  • ⚠️ npm test -- --run src/components/ProvenanceCell.test.tsx (the installed Node 20 runtime rejects the configured --no-experimental-webstorage worker option)
  • ⚠️ dotnet test tests/Cluckwork.Application.Tests/Cluckwork.Application.Tests.csproj --no-restore (the environment does not have dotnet installed)
  • ⚠️ Screenshot not captured because the full application cannot be launched without the .NET runtime.

View task →

mforce added 2 commits August 10, 2026 20:49
… predicate

Local adversarial review of #503, round two. Three findings, each proven by
mutation against the full 1104-test integration suite rather than argued.

1. SalesOrder.UpdateItem and SalesOrder.RemoveItem could both be deleted from
   the drafting set with the entire suite still green, while their two siblings
   were held. Two of four covered is worse than none: the pattern reads as
   complete. ana adding a line, correcting its quantity and confirming — the
   most common order-editing path — would have reported "last changed by ana",
   the exact noise this feature exists to remove.

2. Three of the four EntityType predicates likewise survived deletion. Same
   cause as the tenant miss this file already documents: the obvious test gives
   each type a distinct entityId, so the later predicates never run in it. The
   most visible consequence was a FLOCK reporting MadeOfficialAtUtc — an entity
   with no promotion step claiming it had been made official.

3. The comment asserting IS NOT DISTINCT FROM was "NOT currently load-bearing"
   became false in the same branch that wrote it. Replacing the creation-keyed
   result with a three-way union means a legacy record whose only event is a
   drafting action now reaches the output; under a plain "=", Guid = NULL yields
   SQL NULL and WHERE drops the row, losing a real attributable change. That
   sentence would have licensed the next person to simplify the line.

Also: DailyEntry_ReRecordedOnTheSameKey compared an ordered sequence against an
unordered query, which Postgres never promised.

Verification: 1572 backend tests, 1713 web tests, typecheck clean. 22 mutants
confirmed red-then-green, each against a named test.

Refs #494.
…ed the draft

The History column hid every edit the creator made to their own draft. That
is right while the draft is theirs alone — writing a record and submitting it
is one act, not a change — but it was still applied after a colleague had
edited it. The column then named the colleague, whose numbers had since been
overwritten, and dated the record to an edit that no longer existed in it,
while the numbers that actually went into stock were the creator's.

The exclusion now lifts for a record somebody else drafted: a `shared` CTE
marks entities carrying a drafting event by an actor other than the creator,
and the creator's own edits are only suppressed when that flag is absent.
Their promotion stays suppressed either way — it has its own "Submitted"
line, and repeating it as the last change would say that making a record
official changed it.

The last-change query now reads its creator and its shared set from ONE
`scoped` CTE rather than restating the account/type/id triple per CTE. That
triple was twice shipped mostly unguarded, both times because a duplicate
predicate does no work in a test that gives each account a distinct entity
id; stating it once removes the place that failure hides.

27 mutants, 26 killed by a named test. The survivor is recorded in the
source: `shared` counts drafting by somebody else rather than any event by
somebody else, and no fixture can currently tell those apart, because every
foreign non-drafting action on these aggregates only becomes reachable once
drafting has ended.
@mforce

mforce commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

@codex two commits since your last look, and the second changes a product rule rather than an implementation detail — that is where a fresh read is worth most.

4486b1af — adversarial round. Two of the four draftingActions members and three of the four EntityType predicates survived deletion against the full 1104-test suite; both are now held by tests that share one id across the dimension being scoped.

d0447929 — the behavioural fix. Hiding the creator's own draft edits is correct while the draft is theirs alone, but it was still applied after a colleague had edited it: the column then named the colleague whose numbers had since been overwritten, and dated the record to an edit that no longer existed in it. A shared CTE now marks records carrying a drafting event by a non-creator, and the exclusion lifts for those; the creator's promotion stays hidden either way, since it has its own "Submitted" line.

The same commit restructures the last-change query onto a single scoped CTE so the account/type/id triple is stated once instead of per-CTE — that duplication is what let the two misses above hide.

1577 backend / 1713 web green, typecheck clean, 26 of 27 mutants killed by a named test. The survivor is documented in the source: shared counts drafting by somebody else rather than any event by somebody else, and nothing reachable today distinguishes them.

Worth your attention: whether the shared carve-out has an ordering or NULL case I have not covered, and whether folding four CTEs onto one scoped changed any scoping I have not noticed.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: d044792934

ℹ️ 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
mforce merged commit 4ffa7f1 into main Aug 11, 2026
10 checks passed
@mforce
mforce deleted the feat/494-record-provenance branch August 11, 2026 16:34
@mforce mforce mentioned this pull request Aug 11, 2026
90 tasks done
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.

Show "created by / when" and "last changed by / when" inline on record pages

1 participant