Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ dotnet test Cluckwork.sln # 2295 tests as of 2026-09; integrati

### Data and correctness

- **Business dates, row timestamps and list sequences are separate facts (#819).** Keep farm operational dates as `DateOnly`. Every mapped business record except the explicitly excluded `AuditEvent` carries a PostgreSQL-stamped `DateTimeOffset CreatedAtUtc`; a record that supports an in-place update also carries `DateTimeOffset UpdatedAtUtc`, set equal to creation time on insert and replaced on each update. Factories and handlers never stamp these generic fields. A chronological list orders by business date, `CreatedAtUtc`, then a shadow per-table `bigint GENERATED ALWAYS AS IDENTITY` `Sequence`, all in the list's direction; a list with no business date omits that key. `Sequence` resolves equal timestamps and completes the order for a fixed dataset; it does not repair clock rollback or make offset pagination snapshot-stable across concurrent inserts. Give `Sequence` a database uniqueness constraint and never expose it through domain objects, responses, exports or screens. Name-ordered lookup lists and canonical FIFO or `FOR UPDATE` ordering do not gain a sequence. Classify every new mapped table through `ICreatedRecord`, `IMutableRecord` or an explicit framework or infrastructure exclusion, and add it to the chronological census only when users page or read it by time. Preserve action-specific timestamps and `Version`. Legacy audit backfills use only exact whitelisted row events and fall back to the documented unknown sentinel. → [`819-business-record-chronology.md`](docs/decisions/819-business-record-chronology.md)

- **Every aggregate mutation must bump `Version`.** `Version` is an EF concurrency token (`IsConcurrencyToken`): EF puts the *original* value in the UPDATE's `WHERE` but never auto-increments it, so a mutation without `Version++` silently loses concurrent races — both writers match `WHERE Version = N` — instead of 409ing. This shipped three times; each fix carries a parallel-race integration test, and so must any new mutation.
- **Multi-tenancy:** every tenant-owned entity has `AccountId`, enforced by an EF **global query filter** plus a **`TenantStampInterceptor`** (stamps on insert, and rejects a mismatched write on update/delete). `TenantContext` resolves per-request from the JWT `account_id` claim and is **single-assignment** — a differing re-resolve throws; at startup it is unresolved, so seeders use `IgnoreQueryFilters()`. Several farms now coexist on one deployment: sign-in takes a farm code, and one email address can belong to a user in more than one farm. **`AccountId` is also an EF concurrency token on every entity that carries one (#562):** the `UPDATE`/`DELETE` the database runs carries `AccountId = <original>`, so a detached stub aimed at another farm's row matches nothing — the interceptor alone cannot see a detached write, and an owned-only edit on an attached stub was writing through until the token landed. The token comes from a model walk in `AppDbContext.OnModelCreating`, so a new entity is covered automatically; never remove it, and a database refusal under a resolved tenant is logged as `Tenant.WriteRefusedByDatabase`. **`AccountId` must be a non-nullable `Guid`, and both layers now fail closed on anything else (#673):** the walk throws `TenantAccountIdShapeException` at model build for any other CLR type and the interceptor throws it at `SaveChanges` for a mapped type that is not `Guid` or a value that does not box to one — before, a `Guid?` or a strongly-typed id got no token and no check, which is #562's detached-write hole reopened by one mapping with every test green. Map a new tenant-owned entity's `AccountId` as a plain `Guid`; a strongly-typed id there needs this decision reopened, not a cast. **Identity's `AspNetUserRoles` carries a shadow `AccountId` plus a composite foreign key to `AspNetUsers(Id, AccountId)` (#670):** both layers select by property NAME, so the shadow column is stamped, verified and tokened like any other, and the FK makes a role grant to another farm's user — or under no resolved tenant — a database refusal; the other four user-keyed Identity tables have no writer in `src/` and are recorded as accepted risk. → [`530-multi-farm-tenancy.md`](docs/decisions/530-multi-farm-tenancy.md)
- **A farm code changes only through `Account.Rename`, reached by the `rename-account` verb (#732).** No endpoint, no Settings field, and never a raw `UPDATE` — the hand-guarded `UPDATE "Accounts"` that preceded it bumped `Version` by hand, wrote no audit row and checked neither the slug pattern nor the reserved set. `Rename` validates through the same `TryValidateSlug` provisioning uses, bumps `Version` on a real change and deliberately does **not** bump it on a no-op (a rename to the code the farm already has mutates nothing, so "every mutation bumps `Version`" still holds and a Farm Settings form held open does not break). The service resolves the code to an id, resolves the tenant, takes the tenant-keyed `FOR UPDATE`, and then **re-compares the locked row against the snapshot the lookup took — both its slug and its `Version`** — lookup and lock are two statements, so a write that committed in between would otherwise be silently overwritten. Neither half of that comparison is redundant: `Version` catches the ABA schedule (renamed away and back, so the code the lookup read is the code the lock finds), the slug comparison catches a code change that never advanced `Version` (a raw `UPDATE`), and the price is that a rename racing a Farm Settings save or a suspend also returns `Account.SlugStale`. `IX_Accounts_Slug` and the `DbUpdateException` catch are the authority on the destination code; the pre-read in front of them is convenience. There is **no retired-code list**: a code a farm has moved off is immediately reusable, so `--slug <retired>` targets whoever holds it now — an accepted cost, which is why the verb and the runbook both say to run `list-accounts` immediately first. → [`732-farm-code-rename.md`](docs/decisions/732-farm-code-rename.md)
Expand Down
108 changes: 108 additions & 0 deletions docs/decisions/819-business-record-chronology.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Business dates, row timestamps, and list sequences are separate facts (#819)

## What happened

Several lists ordered rows by a farm business date and then by a random UUID.
Rows from the same day therefore had a stable but arbitrary order. A recent row
could appear below older rows or beyond the first page. The first change added a
database identity sequence to five lists. A full model walk found six more
chronological lists and showed that most business tables also lacked consistent
creation and update metadata.

## The rule

Farm business dates remain `DateOnly`. They describe the day on which farm work
belongs. `CreatedAtUtc` and `UpdatedAtUtc` describe database writes. Every mapped
business record except `AuditEvent` implements `ICreatedRecord`. A record that
supports an in-place update implements `IMutableRecord`. PostgreSQL stamps both
fields with `timestamp with time zone` values. On insert, mutable rows receive
the same value for both fields. On update, PostgreSQL preserves `CreatedAtUtc`
and replaces `UpdatedAtUtc`.

The business-record census includes the 25 domain record types other than
`AuditEvent`, plus `ApplicationUser`. `AuditEvent` keeps its established
`OccurredAtUtc` meaning. ASP.NET Identity support types, including passkey data,
and the operational refresh-token, idempotency, durable-job, and simulation-seed
tables retain their own lifecycle fields and do not join this convention.

Eleven chronological tables also carry a shadow `bigint GENERATED ALWAYS AS
IDENTITY` property named `Sequence`: `SalesOrders`, `Expenses`, `DailyEntries`,
`EggLots`, `BirdMovements`, `Payments`, `InventoryLots`, `FeedUsages`,
`WaterUsages`, `InventoryMovements`, and `EggInventoryMovements`. Each table has
a unique constraint on `Sequence`. Interactive lists with a business date order
by business date, creation time, and sequence, all descending.
`EggInventoryMovements` has no independent business date and orders by creation
time and sequence. The sequence stays outside domain objects, API responses,
exports, and screens.

The generic timestamps are persistence metadata in this change. Existing API
and CSV fields stay compatible; a later contract change can expose the new
timestamps where a user needs them. Datasets that already exported a creation
timestamp continue to do so.

Exports keep their established direction but use the same complete tuple in
that direction. FIFO selection and `FOR UPDATE` lock acquisition keep their
existing business-date and UUID order because those queries define allocation
behavior and canonical lock order rather than presentation chronology.
The flock export also keeps its established `PlacementDate, Id` order: flock
screens are name-ordered, so flocks are not a chronological paged list and do
not gain a persistence sequence solely for export formatting.

## Existing rows

The migration preserves every stored `CreatedAtUtc`. For a table that did not
store it, the migration uses the earliest exact creation audit whose
`EntityType`, `EntityId`, and action match a frozen table-specific whitelist.
If no such event exists, it uses `1970-01-01T00:00:00Z` as an explicit unknown
sentinel. The sentinel is not a recovered historical time.
Fresh databases replay the same migration history, so base reference rows that
predate this migration also receive the sentinel; it means unknown there too,
not that those records were created in 1970.

For mutable rows, the migration uses the latest exact mutating audit from a
frozen table-specific whitelist. It ignores read-only actions and events aimed
at a parent record. If no qualifying update exists, `UpdatedAtUtc` equals
`CreatedAtUtc`. The migration never infers a write time from a business date or
a UUID.

Legacy sequence values provide deterministic order only. They cannot recover
the original insertion order. For new rows, PostgreSQL allocates `Sequence` at
insert time, not commit time. A transaction that commits later may have a lower
sequence than a transaction that commits earlier. Neither a timestamp nor an
identity sequence claims exact commit chronology.

`CreatedAtUtc` deliberately precedes `Sequence` in the agreed display tuple.
The sequence therefore resolves equal timestamps; it does not repair a system
clock that moves backwards. Offset pagination has a complete order for a fixed
dataset, but concurrent inserts can still move rows between pages. Cursor or
snapshot pagination would be a separate contract.

## Why PostgreSQL owns the timestamps

EF interceptors do not see `ExecuteUpdateAsync` or raw SQL. Cluckwork uses both,
and an owned-value update can also change a database row without presenting the
same tracked shape as an ordinary aggregate edit. PostgreSQL triggers cover all
of these write paths. Read-only interfaces expose the metadata without giving
handlers or factories another stamping path.

Action-specific fields remain separate. `OccurredAtUtc`, `LockedAtUtc`,
`ReleasedOnUtc`, `DisabledAt`, `FarmLogo.UpdatedAt`, and
`FarmLogo.BannerUpdatedAt` keep their existing meanings. Generic timestamps do
not replace audit history, domain event times, or optimistic `Version` tokens.

## Migration and enforcement

The unmerged sequence migration is replaced by one chronology migration. The
migration adds and backfills columns before it installs triggers. It takes table
locks and runs through the pre-deploy migration process. Schema documentation
is regenerated from the final model.

The migration begins with a five-second `lock_timeout`. That limits how long it
waits to acquire a lock, not how long acquired locks remain held; the migration
transaction retains them until commit. CI proves the upgrade on its fixture,
not its duration at production volume, so deployment must time it on a
production-sized copy before running the pre-deploy job.

An executable model census classifies every mapped type as mutable,
create-only, or excluded. It also fixes the set of chronological tables. A new
mapped type cannot rely on a developer remembering this document alone.
Loading
Loading