fix(management): CAS-guard secured-write reject (WHERE Status='Pending') so reject can never falsify an executed two-person write (arch-review 2026-08-19 P6-1 High)
Reject read the row as Pending, then landed a blind full-row UpdateAsync. A concurrent approve could win its own Pending->Approved CAS in that gap and enter the multi-second WriteTagAsync relay; the reject's update then stamped Status/ExecutedAtUtc/ExecutionError to Rejected mid-relay, so the two-person-control record said Rejected for a write that physically executed at the plant. Works cross-node (both centrals share one MS SQL). - ISecuredWriteRepository/SecuredWriteRepository: new TryMarkRejectedAsync, mirroring TryMarkApprovedAsync exactly (conditional UPDATE ... WHERE Status='Pending', writes only the columns the reject transition owns). No schema change, no migration, no rowversion column. - HandleRejectSecuredWrite: uses the CAS; on loss re-reads the row and returns the status that actually won (same UX shape as approve's lost-race error), and emits the SecuredWriteReject audit row ONLY on a won CAS. - Audited the other lifecycle writes: approve/reject/expiry (inline + the opportunistic list sweep) now ALL transition only out of Pending, so winning the approve CAS gives that leg exclusive ownership of the row and its post-relay Executed/Failed update stays safe as an unconditional write. Documented in place. - Tests: repository-level approve-vs-reject races in both orders (exactly one wins, loser false, row reflects the winner only) plus reject-vs-expiry; handler-level lost-race reject (conflict error, no write, no audit row) and approve on an already-rejected row (no CAS, no relay, no audit).
This commit is contained in:
@@ -86,6 +86,35 @@ public interface ISecuredWriteRepository
|
||||
DateTime decidedAtUtc,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically flips a row from <c>Pending</c> to <c>Rejected</c>, stamping the
|
||||
/// verifier identity, comment, and decision time, but ONLY if the row is still
|
||||
/// <c>Pending</c>. The reject counterpart of <see cref="TryMarkApprovedAsync"/>, and
|
||||
/// the compare-and-swap guard for the approve-vs-reject race: a stale-read reject
|
||||
/// used to land a blind full-row update while a concurrent approve's multi-second
|
||||
/// site relay was still in flight, marking the row <c>Rejected</c> while the plant
|
||||
/// write physically executed (arch-review 2026-08-19 P6-1). The conditional
|
||||
/// <c>WHERE Status='Pending'</c> makes exactly one of approve/reject/expiry win.
|
||||
/// The loser observes <c>false</c> and must record nothing — no status change and no
|
||||
/// reject audit row.
|
||||
/// </summary>
|
||||
/// <param name="id">Identity of the pending secured write.</param>
|
||||
/// <param name="verifierUser">The rejecting verifier's username.</param>
|
||||
/// <param name="verifierComment">Optional free-text comment from the verifier.</param>
|
||||
/// <param name="decidedAtUtc">UTC instant the rejection decision was made.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A task resolving to <c>true</c> if this caller won the rejection (exactly one
|
||||
/// row transitioned), or <c>false</c> if the row was no longer <c>Pending</c>
|
||||
/// (already approved, rejected, or expired).
|
||||
/// </returns>
|
||||
Task<bool> TryMarkRejectedAsync(
|
||||
long id,
|
||||
string verifierUser,
|
||||
string? verifierComment,
|
||||
DateTime decidedAtUtc,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically flips a row from <c>Pending</c> to <c>Expired</c>, stamping the
|
||||
/// expiry as the decision time, but ONLY if the row is still <c>Pending</c>.
|
||||
|
||||
Reference in New Issue
Block a user