Skip to content

fix(api): order same-instant audit events by a durable monotonic key, not a random Guid #508

Description

@mforce

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions