proposal-system/docs/adr/0004-optimistic-concurrency-convention.md

67 lines
3.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR 0004 — Optimistic concurrency: SHOC wire contract on a Postgres version column
- **Status:** Accepted (2026-07-13)
- **Decision owner:** Adam Moussa
- **Scope:** `api/` proposal aggregate, `shared/api-contracts`, all clients (web, mobile, suggestions Lambda)
## Context
SHOC-alignment Phase 6 ports shoc-backend's optimistic-concurrency convention
(PRs #10/#13–#18) to the proposal aggregate. SHOC's mechanics are built on SQL
Server `rowversion` (`byte[8]`, auto-rotated, base64 on the wire) with a
double-guard: pre-check the client token against the loaded row, stamp it as
EF's original value so the UPDATE's WHERE clause re-enforces it, and on a lost
race reload and embed the winner's state in a 409. PostgreSQL has no
`rowversion`; the candidates were the `xmin` system column or an explicit
version column.
## Decision
1. **Explicit `long Version` column** on `Proposals` and `LineItems`,
`IsConcurrencyToken`, additive migration with `DEFAULT 1`, incremented by
the mutating services. Not `xmin`: the xUnit suite runs on InMemory/SQLite
where xmin doesn't exist (SHOC needed an InMemory shim for the same
reason), the handbook expects a real, reversible migration, and xmin leaks
storage internals onto the wire.
2. **SHOC's wire contract verbatim.** Tokens are opaque base64 strings
(`RowVersionCodec`: 8-byte big-endian long — same shape as SHOC's
`"AQAAAAAAAAA="` tokens). Responses carry `rowVersion`; guarded requests
carry `proposalVersion`. Missing token → 422 `ProposalVersionRequired`;
malformed → 422 `InvalidRowVersion`; conflict → **409
`{ message, currentState }`** with the reloaded `ProposalResponse`
embedded; unguarded races → 409 `{ status, message, code }` fallback.
Both envelopes are deliberately **not** ProblemDetails (SHOC parity) and
serialize with the MVC pipeline's conventions (camelCase, string enums).
3. **One aggregate, one token.** Deviation from the drafted plan, forced by
the code: bulk line-item update is delete-all-and-recreate, so per-item
tokens are meaningless. The proposal token guards proposal fields, state
transitions, and the bulk replace; every line-item mutation
(create/bulk/delete) bumps the proposal version so nothing goes stale
silently. `LineItem.Version` exists (additive, on the wire) for future
per-item mutations only.
4. **Guard scope per SHOC precedent.** Updates and state transitions demand
the token; creates and deletes don't, but still bump the aggregate version
— a lost race there surfaces as the fallback 409 instead of a silent
overwrite (this includes `VendorProposalsController`'s vendor-cost
recalc). Internal writers (suggestions Lambda) fetch-and-echo the token
with one conflict retry.
5. **Caller contract:** the 409 `currentState` reload has no ownership
filter, so guard-reaching endpoints must stay admin-gated — enforced by
`GuardedEndpointAuthorizationTests`. Extend the guard with an ownership
predicate before wiring it to any dispatcher-reachable write.
6. **Audit atomicity (same phase):** `IAuditService.Stage` adds to the shared
context; every mutation stages before its single `SaveChangesAsync`, so
the domain change and its audit row commit or fail together. Self-saving
`LogAsync` remains for standalone events only.
## Consequences
- Breaking API change for mutating clients; web, mobile, and the suggestions
Lambda ship the token pass-through in the same change set.
- Deploys: migration auto-applies at API startup under `pg_advisory_lock`;
the column is additive with a default, so the previous Lambda version keeps
working against the migrated schema. Manual RDS snapshot before deploy;
down-script is two `DropColumn`s, to be tested against a snapshot-restored
copy before any production rollback.
- ADR 0002's boundary holds: the convention and wire contract converge with
SHOC; the platform (Postgres, integer column, explicit increments) does not.