Split out of the #494 review (PR #503, codex round 3). Accepted there as a documented residual because the fix needs a migration, which #494 was specified not to do.
The defect
AuditEventRepository.GetProvenanceChunkAsync picks the last change with:
ORDER BY e."EntityId", e."OccurredAtUtc" DESC, e."Id" DESC
AuditWriter mints Guid.NewGuid() — a random v4 — so "Id" carries no chronology. When two reportable changes for one entity share an OccurredAtUtc, the tiebreak resolves arbitrarily and the wrong actor can be named as the last changer. The timestamp displayed is correct either way; only the name is at risk.
ListAsync uses the same tiebreak. There it is merely arbitrary rather than wrong — paging only needs a deterministic order — but the Audit page can still display a same-instant pair in the wrong order.
Why it is reachable, not theoretical
Most concurrent changes to one record cannot collide: the aggregate Version concurrency token serialises them, and the loser gets a 409 having written nothing (audit shares the transaction).
RecordBirdMovementHandler escapes that argument. It writes an audit event keyed to the flocks id while only inserting a BirdMovement row — it never mutates Flock, so no Version bump, so it does not serialise against Flock.Update. Two such requests can land in the same microsecond against the same flock.
What is needed
A durable monotonic ordering value on AuditEvents — a bigint GENERATED ALWAYS AS IDENTITY or a sequence-backed column — used as the tiebreak in both queries.
Note the alternatives that do not work, so they are not re-proposed:
- UUIDv7 for new ids — only orders to millisecond granularity and keeps a random tail, so it does not resolve a microsecond collision; and existing rows are v4, so a mixed table orders meaninglessly.
ctid — physical location, not durable across VACUUM FULL.
Cost
A migration (#407: its own dotnet ef migrations add, never folded into InitialCreate), a docs/schema/ regeneration committed in the same PR (#417), and a sim-harness pass (#370).
Deliberately not done in #503
No test pins the current arbitrary outcome — pinning a known loss turns "did not fix" into "spec". The behaviour is recorded in a comment at the query and here.
Split out of the #494 review (PR #503, codex round 3). Accepted there as a documented residual because the fix needs a migration, which #494 was specified not to do.
The defect
AuditEventRepository.GetProvenanceChunkAsyncpicks the last change with:AuditWritermintsGuid.NewGuid()— a random v4 — so"Id"carries no chronology. When two reportable changes for one entity share anOccurredAtUtc, the tiebreak resolves arbitrarily and the wrong actor can be named as the last changer. The timestamp displayed is correct either way; only the name is at risk.ListAsyncuses the same tiebreak. There it is merely arbitrary rather than wrong — paging only needs a deterministic order — but the Audit page can still display a same-instant pair in the wrong order.Why it is reachable, not theoretical
Most concurrent changes to one record cannot collide: the aggregate
Versionconcurrency token serialises them, and the loser gets a 409 having written nothing (audit shares the transaction).RecordBirdMovementHandlerescapes that argument. It writes an audit event keyed to the flocks id while only inserting aBirdMovementrow — it never mutatesFlock, so noVersionbump, so it does not serialise againstFlock.Update. Two such requests can land in the same microsecond against the same flock.What is needed
A durable monotonic ordering value on
AuditEvents— abigint GENERATED ALWAYS AS IDENTITYor a sequence-backed column — used as the tiebreak in both queries.Note the alternatives that do not work, so they are not re-proposed:
ctid— physical location, not durable acrossVACUUM FULL.Cost
A migration (#407: its own
dotnet ef migrations add, never folded intoInitialCreate), adocs/schema/regeneration committed in the same PR (#417), and a sim-harness pass (#370).Deliberately not done in #503
No test pins the current arbitrary outcome — pinning a known loss turns "did not fix" into "spec". The behaviour is recorded in a comment at the query and here.