diff --git a/docs/rfc/README.md b/docs/rfc/README.md
index ed8b8f0..012c605 100644
--- a/docs/rfc/README.md
+++ b/docs/rfc/README.md
@@ -46,6 +46,6 @@ All "implemented" modules are published on nuget.org at `0.1.0-preview.6` (the c
| [qorpe-ui](qorpe-ui.md) | @qorpe/ui — the family UI kit extraction (platform-sdk D5) | **implemented** (step 6.0, 2026-08-07): the kit lives in [qorpe/ui](https://github.com/qorpe/ui), publishes to npm via OIDC trusted publishing, and BOTH consumers run on the published package — `ui/kit` is deleted from this repo |
| [qorpe-sync](qorpe-sync.md) | qorpe.sync (separate private repo) — the migration & co-existence product module (ADR-0012's own named example): CDC capture, ordered replayable stream, adapter-based versioned mapping, event-id reconciliation composing db-compare | **accepted** (owner, 2026-08-18) — v0 scope is engagement-shaped (§5.1 by-product rule); build scheduled, two-store rig first; the second-product-repo ordering is an owner decision recorded in RFC D5 |
| [goldpath-approvals](goldpath-approvals.md) | Goldpath.Approvals — human approval workflows: maker-checker, four-eyes, amount-laddered authority, delegation, escalation, worklist; definitions as versioned data | **accepted** (owner, 2026-08-18) — build started same day by owner decision; core + 20 tests + manifest/CLI/template wiring + EF store landed; console federation and the adopter proof stay open (T21) |
-| [goldpath-approvals-v2](goldpath-approvals-v2.md) | The product-proven ladder deltas: withdraw, quorum rungs, distinct-eyes, mandatory rejection reason, reject→resubmit chain — additive, non-breaking | **proposed** (2026-08-26; source: the api-portal product's engine, exam-proven) |
+| [goldpath-approvals-v2](goldpath-approvals-v2.md) | The product-proven ladder deltas: withdraw, quorum rungs, distinct-eyes, mandatory rejection reason, reject→resubmit chain — additive, non-breaking | **accepted** (owner, 2026-08-26) — implemented 2026-08-27: withdraw, quorum rungs, chain-wide distinct-eyes, mandatory rejection reason, resubmit chain; 17 new facts + the package's own mutation gate |
| [goldpath-fileexchange](goldpath-fileexchange.md) | Goldpath.FileExchange — file-based integration rails as a unit: scheduled pick-up/drop, format contracts, idempotent `(file,line)` ingestion, quarantine, reprocessing, archival, outbound | **accepted** (owner, 2026-08-18) — build started same day by owner decision; core + 9 tests (planted-fault rig + EF ledger) + manifest/CLI/template wiring landed; transports, console federation and the adopter proof stay open (T22) |
| [specanchor-composition](specanchor-composition.md) | specanchor (separate repo) — the transformation package's deterministic core, composed per foundation §9/§5.1 | **ACCEPTED** (2026-08-16, #165) — the toolchain is real ([qorpe/specanchor](https://github.com/qorpe/specanchor), Apache-2.0; 74 tests, mutation 94.8%, first skill eval 7/7); the composition proof it owes is `open-threads.md` T20 (the rehearsal) |
diff --git a/docs/rfc/goldpath-approvals-v2.md b/docs/rfc/goldpath-approvals-v2.md
index 289edd3..306f181 100644
--- a/docs/rfc/goldpath-approvals-v2.md
+++ b/docs/rfc/goldpath-approvals-v2.md
@@ -1,6 +1,7 @@
# RFC: Approvals v2 — the product-proven rules the ladder engine is missing
-Status: **Proposed** (owner decision pending — per the constitution, code follows approval).
+Status: **Accepted** (owner, 2026-08-26) — implemented 2026-08-27: all six rules landed in
+`Goldpath.Approvals` with the engine/store tests and the package's own mutation gate.
Source of truth for the deltas: the api-portal product's approval engine, where every rule
below shipped, survived contract tests and ran live (multi-stage chains proven 2026-08-24;
the whole engine exercised daily by three exams).
@@ -50,3 +51,17 @@ the CorPay sample's approval story exercises withdraw + quorum; mutation gate ho
Approve → implementation lands as one PR with tests and ledger updates. Reject/amend →
this file records why, per the ledger discipline.
+
+## 6. Implementation record (2026-08-27)
+
+All six deltas landed additively: `Withdrawn` status + `WithdrawAsync` (requester-only,
+`NotRequester` refusal), `RequiredApprovals` on the rung with per-rung signature quorum,
+chain-wide distinct-eyes (`AlreadySigned` — held across escalation), `ReasonRequired` on
+blank rejection, `ResubmitAsync` with `SupersedesId` and cross-referenced trails, and the
+store seam's `AddSignatureAsync`/`GetSignaturesAsync` on both stores (the EF store maps a
+`GoldpathApprovalSignatures` table). Refusal-as-value stayed the decision-verb contract;
+`ResubmitAsync` throws on misuse like `RequestAsync`/`DelegateAsync` (creation verbs).
+Proof: 17 new deterministic facts (engine + EF restart/round-trip) beside the original 20,
+and the package joined the mutation gate (`stryker/Goldpath.Approvals.json`, break 70).
+The CorPay approval story remains with T21's adopter proof — the sample does not compose
+approvals yet, so wiring it there would be a demo invented for the test's sake.
diff --git a/docs/strategy/open-threads.md b/docs/strategy/open-threads.md
index 0b94b8e..e672345 100644
--- a/docs/strategy/open-threads.md
+++ b/docs/strategy/open-threads.md
@@ -24,7 +24,7 @@ pending forever.
| T17 | **Per-package ops packs for the three floor packages** (Messaging, Data, ApiDefaults) | Their RFC §6 sections describe dashboards (consumer lag, outbox backlog, EF query duration, deprecated-version traffic) that are NOT packaged — the signals reach OTel through ServiceDefaults, whose ops pack is what an adopter gets. Found by an audit 2026-08-09; the RFC sections now say so | the first adopter who asks for a floor dashboard, OR the first incident whose triage needed one | the pack ships with the same shape the five module packs have (runbook + Grafana JSON), and the RFC §6 correction note is removed |
| T18 | **The MassTransit 8.x exit** — the watch that keeps option A honest | [goldpath-messaging-exit](../rfc/goldpath-messaging-exit.md) decided **A: stay pinned on 8.5.10** (Apache-2.0), because a move is a MAJOR version, not an internal swap — measured, not assumed. The publish seam already shipped (`IIntegrationEventPublisher` + GP0404), so a generated app's COMMAND HANDLERS no longer name the library; its CONSUMERS still do, deliberately | **any one of four**: an unpatched CVE in the 8.x line · the vendor's v8 maintenance window closing (~end 2026 per their own statements) · an adopter's licensing constraint · a customer requiring a bus we do not compose | the five proofs in that RFC §7 — outbox atomicity on the new transport · tenant+correlation headers still propagate (H4) · GP0401-0403 still mean something · the golden-manifest matrix green on every broker-bearing shape · CorPay migrated with a written guide an adopter could actually follow |
| T20 | **The specanchor rehearsal** — the transformation composition's proof ([specanchor-composition](../rfc/specanchor-composition.md)) | specanchor's own chain is proven on its rig (74 tests, mutation 94.8%, first skill eval 7/7), but the COMPOSITION — §9's method running on specanchor's machinery with Goldpath as target — has never run end to end; per the RFC's D4/D5, no profile code is written before it does | the fake-legacy → Goldpath migration rehearsal is scheduled (before the factoring engagement's PoC) | Discovery Zero → dual-track slices → approved specs → `goldpath new` target → parity with Mockifyr equalization → db-compare reconciliation → cutover evidence bundle, with the Discovery Zero playbook written from the rehearsal's diary; only then do ai-sdlc-status's `reverse-engineer`/`differential-test` rows leave NOT BUILT |
-| T21 | **`Goldpath.Approvals`** — human approval workflows: maker-checker, four-eyes, amount-laddered authority, delegation, escalation, and the task worklist/inbox that feeds them | A Ring B candidate surfaced by the factoring-class engagement: SIX of its twelve common processes run approvals over e-mail today, and the amount ladder (expert → deputy → manager → GM) is domain-agnostic across banking/insurance/telco. Distinct from the T19 saga non-goal — these are HUMAN approval chains, not process orchestration. The RFC is ACCEPTED and the build STARTED 2026-08-18 by explicit owner decision (the trigger pulled early — recorded in the RFC header): `Goldpath.Approvals` core landed with 17 deterministic tests, manifest key + CLI recipe wired. 2026-08-18 (later the same day) the database-backed store landed (EF on the app's own DbContext, proven on real storage) and the template flag shipped (a generated app with the flag BUILDS against the train). Open before the thread closes: console federation, dashboard JSON — and the adopter proof below | The engagement's implementation phase needs its first systematized approval flow (the §5.1 by-product rule: born inside a real deliverable, never as a third front) | The RFC through the eight sections; the four Ring B criteria confirmed in review; one real amount-laddered approval flow from the adopter runs end to end on the module, with its audit trail **2026-08-26 addendum:** the api-portal product built the sibling governed-change engine and proved SIX rules the ladder lacks — recorded as [goldpath-approvals-v2](../rfc/goldpath-approvals-v2.md), proposed, code awaits the owner's yes |
+| T21 | **`Goldpath.Approvals`** — human approval workflows: maker-checker, four-eyes, amount-laddered authority, delegation, escalation, and the task worklist/inbox that feeds them | A Ring B candidate surfaced by the factoring-class engagement: SIX of its twelve common processes run approvals over e-mail today, and the amount ladder (expert → deputy → manager → GM) is domain-agnostic across banking/insurance/telco. Distinct from the T19 saga non-goal — these are HUMAN approval chains, not process orchestration. The RFC is ACCEPTED and the build STARTED 2026-08-18 by explicit owner decision (the trigger pulled early — recorded in the RFC header): `Goldpath.Approvals` core landed with 17 deterministic tests, manifest key + CLI recipe wired. 2026-08-18 (later the same day) the database-backed store landed (EF on the app's own DbContext, proven on real storage) and the template flag shipped (a generated app with the flag BUILDS against the train). Open before the thread closes: console federation, dashboard JSON — and the adopter proof below | The engagement's implementation phase needs its first systematized approval flow (the §5.1 by-product rule: born inside a real deliverable, never as a third front) | The RFC through the eight sections; the four Ring B criteria confirmed in review; one real amount-laddered approval flow from the adopter runs end to end on the module, with its audit trail **2026-08-26 addendum:** the api-portal product built the sibling governed-change engine and proved SIX rules the ladder lacks — recorded as [goldpath-approvals-v2](../rfc/goldpath-approvals-v2.md); owner approved 2026-08-26 and the six rules LANDED 2026-08-27 (withdraw, quorum rungs, chain-wide distinct-eyes, mandatory rejection reason, resubmit chain, signature store seam — 17 new facts, mutation-gated). Still open here: console federation, dashboard JSON, and the adopter proof |
| T22 | **`Goldpath.FileExchange`** — file-based integration rails: scheduled pick-up/drop, validation, idempotent ingestion, reprocessing, archival | Banking file rails (nightly registry FTP batches, 2×/day bank status files, MT940, CSV extraction contracts) recur in THREE of the engagement's common processes; Jobs+Bulk cover pieces but not the rail as a unit. The RFC is ACCEPTED and the build STARTED 2026-08-18 by explicit owner decision (the trigger pulled early — recorded in the RFC header): `Goldpath.FileExchange` core landed with the 7-test planted-fault rig, manifest key + CLI recipe wired. 2026-08-18 (later the same day) the database-backed ledger landed (EF on the app's own DbContext, proven on real storage) and the template flag shipped (a generated app with the flag BUILDS against the train). Open before the thread closes: transport adapters, console federation, dashboard JSON — and the adopter proof below | The first adopter integration that is file-based and bidirectional enters an implementation backlog | One real file rail runs with replay + idempotency + failure reprocessing + its ops runbook, per the module ops-pack shape |
| T23 | **Factoring industry pack + vertical template** — glossary seeds (with false-friend notes), edge-case catalogs, regulatory constraint register skeleton, accounting event dictionary patterns, DMN pricing patterns, integration blueprints (spec + Mockifyr stubs), and a `goldpath new` template pre-setting the domain's bounded-context skeleton | Foundation §6.3's industry-pack layer is the sanctioned home for domain knowledge — the core stays domain-free. The pack is DISTILLED from engagement work, never built up front; the leakage red line holds: public/regulatory knowledge + generalized patterns only, client IP contractually excluded | The engagement's first phase proves the patterns in production AND a second factoring-class prospect appears | The template stands up the domain skeleton via `goldpath new`; the pack's seeds pass the same eval discipline as skills; zero client-confidential content, confirmed by written IP review |
diff --git a/packages/Goldpath.Approvals/GoldpathApprovalEfStore.cs b/packages/Goldpath.Approvals/GoldpathApprovalEfStore.cs
index 06fef11..b655dce 100644
--- a/packages/Goldpath.Approvals/GoldpathApprovalEfStore.cs
+++ b/packages/Goldpath.Approvals/GoldpathApprovalEfStore.cs
@@ -21,6 +21,25 @@ public sealed class GoldpathApprovalDelegationRow
public DateTimeOffset Until { get; set; }
}
+/// One collected quorum signature as a row (the record itself has no key; the row carries one).
+public sealed class GoldpathApprovalSignatureRow
+{
+ /// Row id.
+ public long Id { get; set; }
+
+ /// The request the signature belongs to.
+ public Guid RequestId { get; set; }
+
+ /// Who signed.
+ public string SignedBy { get; set; } = "";
+
+ /// The rung the signature counts for.
+ public string Role { get; set; } = "";
+
+ /// When it was signed (UTC).
+ public DateTimeOffset At { get; set; }
+}
+
/// Model mapping for the database-backed approval store.
public static class GoldpathApprovalModelExtensions
{
@@ -62,6 +81,15 @@ public static ModelBuilder AddGoldpathApprovalModel(this ModelBuilder modelBuild
e.HasIndex(x => x.Until);
});
+ modelBuilder.Entity(e =>
+ {
+ e.ToTable("GoldpathApprovalSignatures");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.SignedBy).HasMaxLength(128);
+ e.Property(x => x.Role).HasMaxLength(128);
+ e.HasIndex(x => x.RequestId);
+ });
+
return modelBuilder;
}
}
@@ -137,4 +165,28 @@ public async Task> GetDelegationsAsync
return rows.Where(r => r.Until > now)
.Select(r => new GoldpathApprovalDelegation(r.From, r.To, r.Until)).ToList();
}
+
+ ///
+ public async Task AddSignatureAsync(GoldpathApprovalSignature signature, CancellationToken cancellationToken = default)
+ {
+ await using var scope = _scopes.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Add(new GoldpathApprovalSignatureRow { RequestId = signature.RequestId, SignedBy = signature.SignedBy, Role = signature.Role, At = signature.At });
+ await db.SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ public async Task> GetSignaturesAsync(Guid requestId, CancellationToken cancellationToken = default)
+ {
+ await using var scope = _scopes.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ // Ordered client-side for the same provider reason as the delegations above —
+ // DateTimeOffset ordering does not translate on SQLite, and one request's
+ // signatures are few by nature.
+ var rows = await db.Set().AsNoTracking()
+ .Where(x => x.RequestId == requestId)
+ .ToListAsync(cancellationToken);
+ return rows.OrderBy(r => r.At)
+ .Select(r => new GoldpathApprovalSignature(r.RequestId, r.SignedBy, r.Role, r.At)).ToList();
+ }
}
diff --git a/packages/Goldpath.Approvals/GoldpathApprovalEngine.cs b/packages/Goldpath.Approvals/GoldpathApprovalEngine.cs
index 2370fd1..ff5634d 100644
--- a/packages/Goldpath.Approvals/GoldpathApprovalEngine.cs
+++ b/packages/Goldpath.Approvals/GoldpathApprovalEngine.cs
@@ -23,6 +23,12 @@ public interface IGoldpathApprovalStore
/// Active delegations (unexpired).
Task> GetDelegationsAsync(DateTimeOffset now, CancellationToken cancellationToken = default);
+
+ /// Adds one collected grant toward a rung's quorum.
+ Task AddSignatureAsync(GoldpathApprovalSignature signature, CancellationToken cancellationToken = default);
+
+ /// All signatures collected on one request, oldest first.
+ Task> GetSignaturesAsync(Guid requestId, CancellationToken cancellationToken = default);
}
/// The outcome of a decision attempt — refusals are values, not exceptions.
@@ -42,6 +48,15 @@ public enum GoldpathApprovalDecisionOutcome
/// The decider does not hold the pending rung's role (nor a valid delegation).
WrongRole,
+
+ /// Distinct-eyes: this identity already signed this request — one signature per person, across rungs.
+ AlreadySigned,
+
+ /// Rejection without a reason teaches the requester nothing; the reason is mandatory.
+ ReasonRequired,
+
+ /// Only the requester may withdraw their own pending request.
+ NotRequester,
}
///
@@ -100,7 +115,11 @@ public async Task RequestAsync(string ladderName, strin
return request;
}
- /// Applies a grant/reject decision under four-eyes and role checks.
+ ///
+ /// Applies a grant/reject decision under four-eyes, role and distinct-eyes checks. A
+ /// grant on a quorum rung collects a SIGNATURE; the request completes when the rung's
+ /// quorum is met. A rejection is terminal at any point and its reason is mandatory.
+ ///
public async Task DecideAsync(Guid id, string decidedBy, string deciderRole, bool granted, string reason, CancellationToken cancellationToken = default)
{
var request = await _store.GetAsync(id, cancellationToken);
@@ -126,24 +145,139 @@ public async Task DecideAsync(Guid id, string d
return GoldpathApprovalDecisionOutcome.WrongRole;
}
- request.Status = granted ? GoldpathApprovalStatus.Granted : GoldpathApprovalStatus.Rejected;
+ if (!granted)
+ {
+ if (string.IsNullOrWhiteSpace(reason))
+ {
+ return GoldpathApprovalDecisionOutcome.ReasonRequired;
+ }
+
+ request.Status = GoldpathApprovalStatus.Rejected;
+ request.DecidedBy = decidedBy;
+ request.Reason = reason;
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, decidedBy, "rejected", reason));
+ await _store.UpdateAsync(request, cancellationToken);
+ await PublishAsync(new GoldpathApprovalRejected(request.Id, request.Ladder, request.Subject, decidedBy, reason), cancellationToken);
+ return GoldpathApprovalDecisionOutcome.Applied;
+ }
+
+ // Distinct-eyes reads the WHOLE chain: an identity that signed a lower rung may not
+ // sign again after escalation, even holding the higher role — otherwise a two-step
+ // chain is one person clicking twice.
+ var signatures = await _store.GetSignaturesAsync(request.Id, cancellationToken);
+ if (signatures.Any(s => string.Equals(s.SignedBy, decidedBy, StringComparison.OrdinalIgnoreCase)))
+ {
+ return GoldpathApprovalDecisionOutcome.AlreadySigned;
+ }
+
+ await _store.AddSignatureAsync(new GoldpathApprovalSignature(request.Id, decidedBy, request.PendingRole, now), cancellationToken);
+ // Quorum counts per RUNG (signatures carry the rung they covered); an escalated
+ // request starts its new rung's count from zero while the chain-wide list above
+ // keeps every earlier signer barred.
+ var collected = signatures.Count(s => string.Equals(s.Role, request.PendingRole, StringComparison.OrdinalIgnoreCase)) + 1;
+ var required = RequiredApprovalsFor(request);
+ if (collected < required)
+ {
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, decidedBy, "signed", $"{collected}/{required} at {request.PendingRole}: {reason}"));
+ await _store.UpdateAsync(request, cancellationToken);
+ return GoldpathApprovalDecisionOutcome.Applied;
+ }
+
+ request.Status = GoldpathApprovalStatus.Granted;
request.DecidedBy = decidedBy;
request.Reason = reason;
- request.Trail.Add(new GoldpathApprovalTrailEntry(now, decidedBy, granted ? "granted" : "rejected", reason));
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, decidedBy, "granted", reason));
await _store.UpdateAsync(request, cancellationToken);
+ await PublishAsync(new GoldpathApprovalGranted(request.Id, request.Ladder, request.Subject, decidedBy), cancellationToken);
+ return GoldpathApprovalDecisionOutcome.Applied;
+ }
- if (granted)
+ /// Takes back a PENDING request — only its requester may; the step lands in the trail.
+ public async Task WithdrawAsync(Guid id, string requestedBy, CancellationToken cancellationToken = default)
+ {
+ var request = await _store.GetAsync(id, cancellationToken);
+ if (request is null)
{
- await PublishAsync(new GoldpathApprovalGranted(request.Id, request.Ladder, request.Subject, decidedBy), cancellationToken);
+ return GoldpathApprovalDecisionOutcome.NotFound;
}
- else
+
+ if (request.Status != GoldpathApprovalStatus.Pending)
{
- await PublishAsync(new GoldpathApprovalRejected(request.Id, request.Ladder, request.Subject, decidedBy, reason), cancellationToken);
+ return GoldpathApprovalDecisionOutcome.NotPending;
+ }
+
+ if (!string.Equals(request.RequestedBy, requestedBy, StringComparison.OrdinalIgnoreCase))
+ {
+ return GoldpathApprovalDecisionOutcome.NotRequester;
}
+ var now = _time.GetUtcNow();
+ request.Status = GoldpathApprovalStatus.Withdrawn;
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, requestedBy, "withdrawn", "taken back by the requester"));
+ await _store.UpdateAsync(request, cancellationToken);
return GoldpathApprovalDecisionOutcome.Applied;
}
+ ///
+ /// Resubmits a REJECTED request as a fresh one: re-routed by the same amount, linked by
+ /// , both trails cross-referenced — the
+ /// audit reads request → reject → rework as one story. Anything but a rejected request
+ /// throws: resubmission is rework, not a retry channel.
+ ///
+ public async Task ResubmitAsync(Guid rejectedId, string requestedBy, CancellationToken cancellationToken = default)
+ {
+ var rejected = await _store.GetAsync(rejectedId, cancellationToken);
+ if (rejected is null)
+ {
+ throw new InvalidOperationException($"Request '{rejectedId}' does not exist — only a rejected request may be resubmitted.");
+ }
+
+ if (rejected.Status != GoldpathApprovalStatus.Rejected)
+ {
+ throw new InvalidOperationException($"Request '{rejectedId}' is {rejected.Status} — only a rejected request may be resubmitted.");
+ }
+
+ if (!_options.Ladders.TryGetValue(rejected.Ladder, out var ladder))
+ {
+ throw new InvalidOperationException($"Ladder '{rejected.Ladder}' is not declared — approvals run on declared ladders only.");
+ }
+
+ var now = _time.GetUtcNow();
+ var rung = ladder.Route(rejected.Amount);
+ var request = new GoldpathApprovalRequest
+ {
+ Id = Guid.NewGuid(),
+ Ladder = ladder.Name,
+ Subject = rejected.Subject,
+ Amount = rejected.Amount,
+ RequestedBy = requestedBy,
+ RequestedAt = now,
+ PendingRole = rung.Role,
+ PendingSince = now,
+ Status = GoldpathApprovalStatus.Pending,
+ SupersedesId = rejected.Id,
+ };
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, requestedBy, "resubmitted", $"supersedes {rejected.Id}; routed to {rung.Role} for {rejected.Amount}"));
+ await _store.AddAsync(request, cancellationToken);
+
+ rejected.Trail.Add(new GoldpathApprovalTrailEntry(now, requestedBy, "superseded", $"resubmitted as {request.Id}"));
+ await _store.UpdateAsync(rejected, cancellationToken);
+
+ await PublishAsync(new GoldpathApprovalRequested(request.Id, ladder.Name, request.Subject, request.Amount, rung.Role), cancellationToken);
+ return request;
+ }
+
+ private int RequiredApprovalsFor(GoldpathApprovalRequest request)
+ {
+ if (!_options.Ladders.TryGetValue(request.Ladder, out var ladder))
+ {
+ return 1;
+ }
+
+ var rung = ladder.Rungs.FirstOrDefault(r => string.Equals(r.Role, request.PendingRole, StringComparison.OrdinalIgnoreCase));
+ return rung?.RequiredApprovals ?? 1;
+ }
+
///
/// Delegates 's pending decisions to for a
/// bounded window. Depth is one: a delegate cannot re-delegate — the cycle guard is
@@ -256,6 +390,7 @@ public sealed class GoldpathInMemoryApprovalStore : IGoldpathApprovalStore
private readonly object _gate = new();
private readonly Dictionary _requests = [];
private readonly List _delegations = [];
+ private readonly List _signatures = [];
///
public Task AddAsync(GoldpathApprovalRequest request, CancellationToken cancellationToken = default)
@@ -319,4 +454,26 @@ public Task> GetDelegationsAsync(DateT
return Task.FromResult(active);
}
}
+
+ ///
+ public Task AddSignatureAsync(GoldpathApprovalSignature signature, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _signatures.Add(signature);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task> GetSignaturesAsync(Guid requestId, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ IReadOnlyList signatures =
+ _signatures.Where(s => s.RequestId == requestId).OrderBy(s => s.At).ToList();
+ return Task.FromResult(signatures);
+ }
+ }
}
diff --git a/packages/Goldpath.Approvals/GoldpathApprovalModel.cs b/packages/Goldpath.Approvals/GoldpathApprovalModel.cs
index fe4077d..e8c03f4 100644
--- a/packages/Goldpath.Approvals/GoldpathApprovalModel.cs
+++ b/packages/Goldpath.Approvals/GoldpathApprovalModel.cs
@@ -14,6 +14,9 @@ public enum GoldpathApprovalStatus
/// The top rung's deadline passed without a decision.
Expired,
+
+ /// Taken back by its own requester before any decision.
+ Withdrawn,
}
/// One approval request and its full decision trail.
@@ -52,6 +55,9 @@ public sealed class GoldpathApprovalRequest
/// Decision reason, when terminal.
public string? Reason { get; set; }
+ /// The rejected request this one resubmits — the audit chain reads request → reject → rework as ONE story.
+ public Guid? SupersedesId { get; init; }
+
/// Every lifecycle step, oldest first — the audit value e-mail never had.
public List Trail { get; } = [];
}
@@ -62,6 +68,14 @@ public sealed record GoldpathApprovalTrailEntry(DateTimeOffset At, string Actor,
/// An active delegation: From's pending items may be decided by To.
public sealed record GoldpathApprovalDelegation(string From, string To, DateTimeOffset Until);
+///
+/// One collected grant toward a rung's quorum. Role is the RUNG the signature counts
+/// for (the request's pending role at signing time — a delegated signature still counts for
+/// the rung it covered); the distinct-eyes rule reads SignedBy across ALL of a
+/// request's signatures, regardless of rung.
+///
+public sealed record GoldpathApprovalSignature(Guid RequestId, string SignedBy, string Role, DateTimeOffset At);
+
/// An approval was requested and routed to a rung.
public sealed record GoldpathApprovalRequested(Guid ApprovalId, string Ladder, string Subject, decimal Amount, string PendingRole) : IIntegrationEvent;
diff --git a/packages/Goldpath.Approvals/GoldpathApprovalsOptions.cs b/packages/Goldpath.Approvals/GoldpathApprovalsOptions.cs
index 01ff49f..28abf27 100644
--- a/packages/Goldpath.Approvals/GoldpathApprovalsOptions.cs
+++ b/packages/Goldpath.Approvals/GoldpathApprovalsOptions.cs
@@ -70,8 +70,11 @@ public GoldpathApprovalRung Route(decimal amount)
}
}
-/// One authority rung: a role, its amount ceiling, and its decision deadline.
-public sealed record GoldpathApprovalRung(string Role, decimal? UpToInclusive, TimeSpan EscalateAfter);
+///
+/// One authority rung: a role, its amount ceiling, its decision deadline, and how many
+/// DISTINCT grants complete it ("two managers" is a rung property, not a second ladder).
+///
+public sealed record GoldpathApprovalRung(string Role, decimal? UpToInclusive, TimeSpan EscalateAfter, int RequiredApprovals = 1);
/// Fluent shape for one ladder.
public sealed class GoldpathApprovalLadderBuilder
@@ -81,17 +84,17 @@ public sealed class GoldpathApprovalLadderBuilder
internal GoldpathApprovalLadderBuilder(string name) => _name = name;
- /// Adds a rung with an inclusive amount ceiling and its decision deadline.
- public GoldpathApprovalLadderBuilder Rung(string role, decimal upToInclusive, TimeSpan escalateAfter)
+ /// Adds a rung with an inclusive amount ceiling, its decision deadline, and its quorum (distinct grants required).
+ public GoldpathApprovalLadderBuilder Rung(string role, decimal upToInclusive, TimeSpan escalateAfter, int requiredApprovals = 1)
{
- _rungs.Add(new GoldpathApprovalRung(role, upToInclusive, escalateAfter));
+ _rungs.Add(new GoldpathApprovalRung(role, upToInclusive, escalateAfter, requiredApprovals));
return this;
}
/// Adds the top rung — unbounded ceiling; overdue at the top EXPIRES.
- public GoldpathApprovalLadderBuilder TopRung(string role, TimeSpan escalateAfter)
+ public GoldpathApprovalLadderBuilder TopRung(string role, TimeSpan escalateAfter, int requiredApprovals = 1)
{
- _rungs.Add(new GoldpathApprovalRung(role, null, escalateAfter));
+ _rungs.Add(new GoldpathApprovalRung(role, null, escalateAfter, requiredApprovals));
return this;
}
@@ -102,6 +105,11 @@ internal GoldpathApprovalLadder Build()
throw new InvalidOperationException($"Ladder '{_name}' declares no rungs — an authority chain must be modeled, never guessed.");
}
+ if (_rungs.Any(r => r.RequiredApprovals < 1))
+ {
+ throw new InvalidOperationException($"Ladder '{_name}' declares a rung with a quorum below one — a rung nobody can complete is a modeling error.");
+ }
+
if (_rungs[^1].UpToInclusive is not null)
{
throw new InvalidOperationException($"Ladder '{_name}' has no TopRung — every amount must route somewhere (declare the unbounded rung).");
diff --git a/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt b/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt
index b7fecda..38c9f7e 100644
--- a/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt
+++ b/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt
@@ -58,8 +58,8 @@ Goldpath.GoldpathApprovalLadder.Name.get -> string!
Goldpath.GoldpathApprovalLadder.Route(decimal amount) -> Goldpath.GoldpathApprovalRung!
Goldpath.GoldpathApprovalLadder.Rungs.get -> System.Collections.Generic.IReadOnlyList!
Goldpath.GoldpathApprovalLadderBuilder
-Goldpath.GoldpathApprovalLadderBuilder.Rung(string! role, decimal upToInclusive, System.TimeSpan escalateAfter) -> Goldpath.GoldpathApprovalLadderBuilder!
-Goldpath.GoldpathApprovalLadderBuilder.TopRung(string! role, System.TimeSpan escalateAfter) -> Goldpath.GoldpathApprovalLadderBuilder!
+Goldpath.GoldpathApprovalLadderBuilder.Rung(string! role, decimal upToInclusive, System.TimeSpan escalateAfter, int requiredApprovals = 1) -> Goldpath.GoldpathApprovalLadderBuilder!
+Goldpath.GoldpathApprovalLadderBuilder.TopRung(string! role, System.TimeSpan escalateAfter, int requiredApprovals = 1) -> Goldpath.GoldpathApprovalLadderBuilder!
Goldpath.GoldpathApprovalRejected
Goldpath.GoldpathApprovalRejected.ApprovalId.get -> System.Guid
Goldpath.GoldpathApprovalRejected.ApprovalId.init -> void
@@ -112,7 +112,9 @@ Goldpath.GoldpathApprovalRequested.Subject.init -> void
Goldpath.GoldpathApprovalRung
Goldpath.GoldpathApprovalRung.EscalateAfter.get -> System.TimeSpan
Goldpath.GoldpathApprovalRung.EscalateAfter.init -> void
-Goldpath.GoldpathApprovalRung.GoldpathApprovalRung(string! Role, decimal? UpToInclusive, System.TimeSpan EscalateAfter) -> void
+Goldpath.GoldpathApprovalRung.GoldpathApprovalRung(string! Role, decimal? UpToInclusive, System.TimeSpan EscalateAfter, int RequiredApprovals = 1) -> void
+Goldpath.GoldpathApprovalRung.RequiredApprovals.get -> int
+Goldpath.GoldpathApprovalRung.RequiredApprovals.init -> void
Goldpath.GoldpathApprovalRung.Role.get -> string!
Goldpath.GoldpathApprovalRung.Role.init -> void
Goldpath.GoldpathApprovalRung.UpToInclusive.get -> decimal?
@@ -176,3 +178,39 @@ Goldpath.GoldpathEfApprovalStore.GetPendingAsync(System.Threading.Canc
Goldpath.GoldpathEfApprovalStore.AddDelegationAsync(Goldpath.GoldpathApprovalDelegation! delegation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Goldpath.GoldpathEfApprovalStore.GetDelegationsAsync(System.DateTimeOffset now, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
static Goldpath.GoldpathApprovalsExtensions.AddGoldpathApprovals(this TBuilder builder, System.Action! configure) -> TBuilder
+Goldpath.GoldpathApprovalStatus.Withdrawn = 4 -> Goldpath.GoldpathApprovalStatus
+Goldpath.GoldpathApprovalDecisionOutcome.AlreadySigned = 5 -> Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalDecisionOutcome.ReasonRequired = 6 -> Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalDecisionOutcome.NotRequester = 7 -> Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalRequest.SupersedesId.get -> System.Guid?
+Goldpath.GoldpathApprovalRequest.SupersedesId.init -> void
+Goldpath.GoldpathApprovalSignature
+Goldpath.GoldpathApprovalSignature.GoldpathApprovalSignature(System.Guid RequestId, string! SignedBy, string! Role, System.DateTimeOffset At) -> void
+Goldpath.GoldpathApprovalSignature.RequestId.get -> System.Guid
+Goldpath.GoldpathApprovalSignature.RequestId.init -> void
+Goldpath.GoldpathApprovalSignature.SignedBy.get -> string!
+Goldpath.GoldpathApprovalSignature.SignedBy.init -> void
+Goldpath.GoldpathApprovalSignature.Role.get -> string!
+Goldpath.GoldpathApprovalSignature.Role.init -> void
+Goldpath.GoldpathApprovalSignature.At.get -> System.DateTimeOffset
+Goldpath.GoldpathApprovalSignature.At.init -> void
+Goldpath.GoldpathApprovalEngine.WithdrawAsync(System.Guid id, string! requestedBy, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathApprovalEngine.ResubmitAsync(System.Guid rejectedId, string! requestedBy, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.IGoldpathApprovalStore.AddSignatureAsync(Goldpath.GoldpathApprovalSignature! signature, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.IGoldpathApprovalStore.GetSignaturesAsync(System.Guid requestId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Goldpath.GoldpathInMemoryApprovalStore.AddSignatureAsync(Goldpath.GoldpathApprovalSignature! signature, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathInMemoryApprovalStore.GetSignaturesAsync(System.Guid requestId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Goldpath.GoldpathEfApprovalStore.AddSignatureAsync(Goldpath.GoldpathApprovalSignature! signature, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathEfApprovalStore.GetSignaturesAsync(System.Guid requestId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Goldpath.GoldpathApprovalSignatureRow
+Goldpath.GoldpathApprovalSignatureRow.GoldpathApprovalSignatureRow() -> void
+Goldpath.GoldpathApprovalSignatureRow.Id.get -> long
+Goldpath.GoldpathApprovalSignatureRow.Id.set -> void
+Goldpath.GoldpathApprovalSignatureRow.RequestId.get -> System.Guid
+Goldpath.GoldpathApprovalSignatureRow.RequestId.set -> void
+Goldpath.GoldpathApprovalSignatureRow.SignedBy.get -> string!
+Goldpath.GoldpathApprovalSignatureRow.SignedBy.set -> void
+Goldpath.GoldpathApprovalSignatureRow.Role.get -> string!
+Goldpath.GoldpathApprovalSignatureRow.Role.set -> void
+Goldpath.GoldpathApprovalSignatureRow.At.get -> System.DateTimeOffset
+Goldpath.GoldpathApprovalSignatureRow.At.set -> void
diff --git a/packages/Goldpath.Approvals/README.md b/packages/Goldpath.Approvals/README.md
index 2bc5d3b..2a3aa13 100644
--- a/packages/Goldpath.Approvals/README.md
+++ b/packages/Goldpath.Approvals/README.md
@@ -10,7 +10,7 @@ builder.AddGoldpathApprovals(approvals => approvals
.AddLadder("credit-limit", ladder => ladder
.Rung("expert", upToInclusive: 1_000_000m, escalateAfter: TimeSpan.FromHours(8))
.Rung("deputy-manager", 5_000_000m, TimeSpan.FromHours(8))
- .Rung("manager", 15_000_000m, TimeSpan.FromHours(8))
+ .Rung("manager", 15_000_000m, TimeSpan.FromHours(8), requiredApprovals: 2)
.TopRung("general-manager", TimeSpan.FromHours(24))));
```
@@ -19,6 +19,16 @@ builder.AddGoldpathApprovals(approvals => approvals
- **Decide** — `engine.DecideAsync(id, decidedBy, deciderRole, granted, reason)` enforces
four-eyes (the requester may never decide their own request) and the rung's role.
Refusals are values (`GoldpathApprovalDecisionOutcome`), not exceptions.
+- **Quorum** — a rung may demand N DISTINCT grants (`requiredApprovals`); each grant is a
+ signature toward the rung, and distinct-eyes holds across the WHOLE chain: one identity
+ signs at most once per request, even after escalation (`AlreadySigned`).
+- **Rejection** — terminal at any point, and its reason is MANDATORY: a blank reason is
+ refused (`ReasonRequired`) — a bare "no" teaches the requester nothing.
+- **Withdraw** — `WithdrawAsync(id, requestedBy)` takes back a pending request; only its
+ requester may (`NotRequester` otherwise), and the step lands in the trail.
+- **Resubmit** — `ResubmitAsync(rejectedId, requestedBy)` reworks a rejected request as a
+ fresh one, linked by `SupersedesId` with both trails cross-referenced — the audit reads
+ request → reject → rework as one story.
- **Delegate** — bounded window, depth one (a delegate cannot re-delegate; the cycle guard
is structural).
- **Escalate** — `EscalateOverdueAsync()` moves overdue requests one rung up; overdue at
diff --git a/stryker/Goldpath.Approvals.json b/stryker/Goldpath.Approvals.json
new file mode 100644
index 0000000..7057d1d
--- /dev/null
+++ b/stryker/Goldpath.Approvals.json
@@ -0,0 +1,19 @@
+{
+ "stryker-config": {
+ "project": "Goldpath.Approvals.csproj",
+ "test-projects": [
+ "tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj"
+ ],
+ "target-framework": "net10.0",
+ "reporters": [
+ "progress",
+ "markdown"
+ ],
+ "thresholds": {
+ "high": 85,
+ "low": 75,
+ "break": 70
+ },
+ "mutation-level": "Standard"
+ }
+}
diff --git a/tests/Goldpath.Approvals.Tests/ApprovalCompositionTests.cs b/tests/Goldpath.Approvals.Tests/ApprovalCompositionTests.cs
new file mode 100644
index 0000000..70e9d5b
--- /dev/null
+++ b/tests/Goldpath.Approvals.Tests/ApprovalCompositionTests.cs
@@ -0,0 +1,59 @@
+using Goldpath;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Goldpath.Approvals.Tests;
+
+///
+/// The composition seam itself: what AddGoldpathApprovals registers, and that the
+/// declared configuration section actually binds — the lines an app trusts blindly.
+///
+public class ApprovalCompositionTests
+{
+ [Fact]
+ public void AddGoldpathApprovals_registers_the_engine_over_the_in_memory_store()
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings());
+ builder.Services.AddLogging();
+ builder.AddGoldpathApprovals(approvals => approvals
+ .AddLadder("credit-limit", l => l.TopRung("gm", TimeSpan.FromHours(24))));
+
+ using var app = builder.Build();
+ Assert.IsType(app.Services.GetRequiredService());
+ var options = app.Services.GetRequiredService();
+ Assert.True(options.Ladders.ContainsKey("credit-limit"));
+ Assert.NotNull(app.Services.GetRequiredService());
+ }
+
+ [Fact]
+ public void The_Goldpath_Approvals_configuration_section_binds_the_options()
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings());
+ builder.Services.AddLogging();
+ builder.Configuration["Goldpath:Approvals:MaxDelegationWindow"] = "2.00:00:00";
+ builder.AddGoldpathApprovals(approvals => approvals
+ .AddLadder("credit-limit", l => l.TopRung("gm", TimeSpan.FromHours(24))));
+
+ using var app = builder.Build();
+ Assert.Equal(TimeSpan.FromDays(2), app.Services.GetRequiredService().MaxDelegationWindow);
+ }
+
+ [Fact]
+ public void The_database_overload_registers_the_EF_store()
+ {
+ using var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+ var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings());
+ builder.Services.AddLogging();
+ builder.Services.AddDbContext(b => b.UseSqlite(connection));
+ builder.AddGoldpathApprovals(approvals => approvals
+ .AddLadder("credit-limit", l => l.TopRung("gm", TimeSpan.FromHours(24))));
+
+ using var app = builder.Build();
+ Assert.IsType>(
+ app.Services.GetRequiredService());
+ }
+}
diff --git a/tests/Goldpath.Approvals.Tests/ApprovalEngineDetailTests.cs b/tests/Goldpath.Approvals.Tests/ApprovalEngineDetailTests.cs
new file mode 100644
index 0000000..9cd22c0
--- /dev/null
+++ b/tests/Goldpath.Approvals.Tests/ApprovalEngineDetailTests.cs
@@ -0,0 +1,206 @@
+using Goldpath;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Time.Testing;
+using Xunit;
+
+namespace Goldpath.Approvals.Tests;
+
+///
+/// The details an auditor and an operator actually read: trail wording, refusal messages,
+/// and the engine's behavior when configuration drifted under a live request. Pinned
+/// because they are the module's HUMAN surface — a mutated message is a broken feature.
+///
+public class ApprovalEngineDetailTests
+{
+ private static readonly TimeSpan RungDeadline = TimeSpan.FromHours(8);
+
+ private static GoldpathApprovalsOptions Options() => new GoldpathApprovalsOptions()
+ .AddLadder("credit-limit", ladder => ladder
+ .Rung("expert", 1_000_000m, RungDeadline)
+ .TopRung("general-manager", TimeSpan.FromHours(24)));
+
+ private static (GoldpathApprovalEngine Engine, FakeTimeProvider Clock, GoldpathInMemoryApprovalStore Store) Build(GoldpathApprovalsOptions? options = null)
+ {
+ var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-08-26T09:00:00Z"));
+ var store = new GoldpathInMemoryApprovalStore();
+ var engine = new GoldpathApprovalEngine(
+ options ?? Options(), store, clock,
+ NullLogger.Instance);
+ return (engine, clock, store);
+ }
+
+ [Fact]
+ public async Task The_trail_wording_is_the_audit_surface()
+ {
+ var (engine, clock, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-300", 500_000m, "maker");
+ Assert.Equal("routed to expert for 500000", request.Trail[0].Detail);
+ Assert.Equal("maker", request.Trail[0].Actor);
+
+ clock.Advance(RungDeadline);
+ await engine.EscalateOverdueAsync();
+ var escalated = (await Reload(store, request.Id)).Trail[^1];
+ Assert.Equal("expert -> general-manager", escalated.Detail);
+ Assert.Equal("system", escalated.Actor);
+
+ clock.Advance(TimeSpan.FromHours(24));
+ await engine.EscalateOverdueAsync();
+ var expired = (await Reload(store, request.Id)).Trail[^1];
+ Assert.Equal("overdue at top rung general-manager", expired.Detail);
+ }
+
+ [Fact]
+ public async Task Withdraw_and_resubmit_trail_wording()
+ {
+ var (engine, _, store) = Build();
+ var withdrawn = await engine.RequestAsync("credit-limit", "K26-301", 500_000m, "maker");
+ await engine.WithdrawAsync(withdrawn.Id, "maker");
+ Assert.Equal("taken back by the requester", (await Reload(store, withdrawn.Id)).Trail[^1].Detail);
+
+ var rejected = await engine.RequestAsync("credit-limit", "K26-302", 500_000m, "maker");
+ await engine.DecideAsync(rejected.Id, "checker", "expert", false, "collateral missing");
+ var resubmitted = await engine.ResubmitAsync(rejected.Id, "maker");
+ Assert.Equal($"supersedes {rejected.Id}; routed to expert for 500000", resubmitted.Trail[0].Detail);
+ Assert.Equal($"resubmitted as {resubmitted.Id}", (await Reload(store, rejected.Id)).Trail[^1].Detail);
+ }
+
+ [Fact]
+ public async Task Refusal_messages_name_the_rule_that_refused()
+ {
+ var (engine, _, _) = Build();
+ var undeclared = await Assert.ThrowsAsync(
+ () => engine.RequestAsync("ghost-ladder", "K26-303", 1m, "maker"));
+ Assert.Contains("'ghost-ladder' is not declared", undeclared.Message);
+
+ var pending = await engine.RequestAsync("credit-limit", "K26-304", 500_000m, "maker");
+ var notRejected = await Assert.ThrowsAsync(
+ () => engine.ResubmitAsync(pending.Id, "maker"));
+ Assert.Contains($"'{pending.Id}' is Pending", notRejected.Message);
+
+ var missing = Guid.NewGuid();
+ var notFound = await Assert.ThrowsAsync(
+ () => engine.ResubmitAsync(missing, "maker"));
+ Assert.Contains($"'{missing}' does not exist", notFound.Message);
+
+ var self = await Assert.ThrowsAsync(
+ () => engine.DelegateAsync("expert-user", "expert-user", TimeSpan.FromDays(1)));
+ Assert.Contains("Delegation to self", self.Message);
+
+ var window = await Assert.ThrowsAsync(
+ () => engine.DelegateAsync("expert-user", "stand-in", TimeSpan.FromDays(15)));
+ Assert.Contains("exceeds the declared maximum", window.Message);
+
+ await engine.DelegateAsync("expert-user", "stand-in", TimeSpan.FromDays(1));
+ var depth = await Assert.ThrowsAsync(
+ () => engine.DelegateAsync("stand-in", "third", TimeSpan.FromDays(1)));
+ Assert.Contains("depth is one", depth.Message);
+ }
+
+ [Theory]
+ [InlineData("no-rungs")]
+ [InlineData("no-top")]
+ [InlineData("ceilings")]
+ [InlineData("quorum")]
+ public void Declaration_errors_name_the_broken_ladder_and_the_rule(string kind)
+ {
+ var ex = Assert.Throws(() => new GoldpathApprovalsOptions()
+ .AddLadder("broken", l =>
+ {
+ switch (kind)
+ {
+ case "no-top":
+ l.Rung("expert", 1_000_000m, RungDeadline);
+ break;
+ case "ceilings":
+ l.Rung("expert", 5_000_000m, RungDeadline).Rung("deputy", 5_000_000m, RungDeadline).TopRung("gm", RungDeadline);
+ break;
+ case "quorum":
+ l.Rung("expert", 1_000_000m, RungDeadline, requiredApprovals: 0).TopRung("gm", RungDeadline);
+ break;
+ }
+ }));
+ Assert.Contains("'broken'", ex.Message);
+ Assert.Contains(kind switch
+ {
+ "no-rungs" => "declares no rungs",
+ "no-top" => "has no TopRung",
+ "ceilings" => "must strictly increase",
+ _ => "quorum below one",
+ }, ex.Message);
+ }
+
+ [Fact]
+ public async Task A_request_whose_ladder_was_undeclared_after_the_fact_still_decides_with_a_quorum_of_one()
+ {
+ // Configuration drift: the app dropped the ladder while a request was pending.
+ // Deciding still works (quorum falls back to one); escalation skips it rather than
+ // guessing a deadline.
+ var (engine, _, store) = Build();
+ var orphan = new GoldpathApprovalRequest
+ {
+ Id = Guid.NewGuid(),
+ Ladder = "retired-ladder",
+ Subject = "K26-305",
+ Amount = 1m,
+ RequestedBy = "maker",
+ PendingRole = "expert",
+ Status = GoldpathApprovalStatus.Pending,
+ };
+ await store.AddAsync(orphan);
+
+ Assert.Equal(0, await engine.EscalateOverdueAsync());
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.DecideAsync(orphan.Id, "checker", "expert", true, "fits"));
+ Assert.Equal(GoldpathApprovalStatus.Granted, (await Reload(store, orphan.Id)).Status);
+ }
+
+ [Fact]
+ public async Task A_request_pending_on_a_role_the_ladder_no_longer_declares_falls_back_to_a_quorum_of_one()
+ {
+ var (engine, _, store) = Build();
+ var orphan = new GoldpathApprovalRequest
+ {
+ Id = Guid.NewGuid(),
+ Ladder = "credit-limit",
+ Subject = "K26-306",
+ Amount = 1m,
+ RequestedBy = "maker",
+ PendingRole = "retired-role",
+ Status = GoldpathApprovalStatus.Pending,
+ };
+ await store.AddAsync(orphan);
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.DecideAsync(orphan.Id, "checker", "retired-role", true, "fits"));
+ Assert.Equal(GoldpathApprovalStatus.Granted, (await Reload(store, orphan.Id)).Status);
+ }
+
+ [Fact]
+ public async Task In_memory_signatures_come_back_oldest_first_and_scoped_to_the_request()
+ {
+ var store = new GoldpathInMemoryApprovalStore();
+ var requestId = Guid.NewGuid();
+ var later = DateTimeOffset.Parse("2026-08-26T12:00:00Z");
+ var earlier = DateTimeOffset.Parse("2026-08-26T09:00:00Z");
+ await store.AddSignatureAsync(new GoldpathApprovalSignature(requestId, "manager-two", "manager", later));
+ await store.AddSignatureAsync(new GoldpathApprovalSignature(requestId, "manager-one", "manager", earlier));
+ await store.AddSignatureAsync(new GoldpathApprovalSignature(Guid.NewGuid(), "stranger", "manager", earlier));
+
+ Assert.Equal(["manager-one", "manager-two"],
+ (await store.GetSignaturesAsync(requestId)).Select(s => s.SignedBy));
+ }
+
+ [Fact]
+ public async Task An_in_memory_delegation_expiring_exactly_now_admits_nobody()
+ {
+ var store = new GoldpathInMemoryApprovalStore();
+ var now = DateTimeOffset.Parse("2026-08-26T09:00:00Z");
+ await store.AddDelegationAsync(new GoldpathApprovalDelegation("expert-user", "stand-in", now));
+
+ Assert.Empty(await store.GetDelegationsAsync(now)); // Until > now is strict
+ Assert.Single(await store.GetDelegationsAsync(now - TimeSpan.FromSeconds(1)));
+ }
+
+ private static async Task Reload(IGoldpathApprovalStore store, Guid id)
+ => await store.GetAsync(id) ?? throw new InvalidOperationException($"request {id} not in the store");
+}
diff --git a/tests/Goldpath.Approvals.Tests/ApprovalEngineV2Tests.cs b/tests/Goldpath.Approvals.Tests/ApprovalEngineV2Tests.cs
new file mode 100644
index 0000000..4ba28b8
--- /dev/null
+++ b/tests/Goldpath.Approvals.Tests/ApprovalEngineV2Tests.cs
@@ -0,0 +1,249 @@
+using Goldpath;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Time.Testing;
+using Xunit;
+
+namespace Goldpath.Approvals.Tests;
+
+///
+/// The v2 rules (RFC goldpath-approvals-v2) — the api-portal product's seven multi-stage
+/// facts translated to rungs, plus withdraw and the reject→resubmit chain. Every fact here
+/// shipped and ran live in the product before it landed in the package.
+///
+public class ApprovalEngineV2Tests
+{
+ private static readonly TimeSpan RungDeadline = TimeSpan.FromHours(8);
+
+ // "two managers" on the first rung: quorum is a rung property, not a second ladder.
+ private static GoldpathApprovalsOptions Options() => new GoldpathApprovalsOptions()
+ .AddLadder("payment-run", ladder => ladder
+ .Rung("manager", 5_000_000m, RungDeadline, requiredApprovals: 2)
+ .TopRung("general-manager", TimeSpan.FromHours(24)));
+
+ private static (GoldpathApprovalEngine Engine, FakeTimeProvider Clock, RecordingPublisher Events, IGoldpathApprovalStore Store) Build()
+ {
+ var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-08-26T09:00:00Z"));
+ var events = new RecordingPublisher();
+ var store = new GoldpathInMemoryApprovalStore();
+ var engine = new GoldpathApprovalEngine(
+ Options(), store, clock,
+ NullLogger.Instance, events);
+ return (engine, clock, events, store);
+ }
+
+ [Fact]
+ public async Task A_quorum_rung_stays_pending_until_the_required_distinct_grants_arrive()
+ {
+ var (engine, _, events, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-1", 1_000_000m, "maker");
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.DecideAsync(request.Id, "manager-one", "manager", true, "first signature"));
+ var afterFirst = await Reload(store, request.Id);
+ Assert.Equal(GoldpathApprovalStatus.Pending, afterFirst.Status);
+ Assert.Null(afterFirst.DecidedBy);
+ Assert.DoesNotContain(events.Published, e => e is GoldpathApprovalGranted);
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.DecideAsync(request.Id, "manager-two", "manager", true, "second signature"));
+ var afterSecond = await Reload(store, request.Id);
+ Assert.Equal(GoldpathApprovalStatus.Granted, afterSecond.Status);
+ Assert.Equal("manager-two", afterSecond.DecidedBy);
+ Assert.Contains(events.Published, e => e is GoldpathApprovalGranted g && g.DecidedBy == "manager-two");
+ }
+
+ [Fact]
+ public async Task The_same_identity_may_not_sign_twice_on_one_rung()
+ {
+ var (engine, _, _, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-2", 1_000_000m, "maker");
+ await engine.DecideAsync(request.Id, "manager-one", "manager", true, "first");
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.AlreadySigned,
+ await engine.DecideAsync(request.Id, "manager-one", "manager", true, "again"));
+ Assert.Equal(GoldpathApprovalStatus.Pending, (await Reload(store, request.Id)).Status);
+ }
+
+ [Fact]
+ public async Task Distinct_eyes_holds_across_escalation_a_lower_rung_signer_may_not_sign_the_higher_rung()
+ {
+ var (engine, clock, _, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-3", 1_000_000m, "maker");
+ await engine.DecideAsync(request.Id, "shape-shifter", "manager", true, "signed at manager");
+
+ clock.Advance(RungDeadline);
+ Assert.Equal(1, await engine.EscalateOverdueAsync());
+ Assert.Equal("general-manager", (await Reload(store, request.Id)).PendingRole);
+
+ // Even holding the higher role, the earlier signer is barred for this request.
+ Assert.Equal(GoldpathApprovalDecisionOutcome.AlreadySigned,
+ await engine.DecideAsync(request.Id, "shape-shifter", "general-manager", true, "second bite"));
+ }
+
+ [Fact]
+ public async Task An_escalated_rung_counts_its_own_quorum_from_zero()
+ {
+ var (engine, clock, events, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-4", 1_000_000m, "maker");
+ await engine.DecideAsync(request.Id, "manager-one", "manager", true, "one of two");
+
+ clock.Advance(RungDeadline);
+ await engine.EscalateOverdueAsync();
+
+ // The top rung requires ONE grant; the manager-rung signature does not complete it,
+ // but a fresh GM signature does.
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.DecideAsync(request.Id, "the-gm", "general-manager", true, "gm sign-off"));
+ Assert.Equal(GoldpathApprovalStatus.Granted, (await Reload(store, request.Id)).Status);
+ Assert.Contains(events.Published, e => e is GoldpathApprovalGranted g && g.DecidedBy == "the-gm");
+ }
+
+ [Fact]
+ public async Task Four_eyes_bars_the_requester_from_a_quorum_rung_too()
+ {
+ var (engine, _, _, _) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-5", 1_000_000m, "maker");
+ Assert.Equal(GoldpathApprovalDecisionOutcome.FourEyesViolation,
+ await engine.DecideAsync(request.Id, "maker", "manager", true, "self-serve"));
+ }
+
+ [Fact]
+ public async Task A_rejection_mid_quorum_is_terminal()
+ {
+ var (engine, _, events, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-6", 1_000_000m, "maker");
+ await engine.DecideAsync(request.Id, "manager-one", "manager", true, "one of two");
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.DecideAsync(request.Id, "manager-two", "manager", false, "duplicate invoice"));
+ var reloaded = await Reload(store, request.Id);
+ Assert.Equal(GoldpathApprovalStatus.Rejected, reloaded.Status);
+ Assert.Equal("duplicate invoice", reloaded.Reason);
+ Assert.Contains(events.Published, e => e is GoldpathApprovalRejected r && r.Reason == "duplicate invoice");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task A_rejection_without_a_reason_is_refused(string blank)
+ {
+ var (engine, _, events, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-7", 1_000_000m, "maker");
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.ReasonRequired,
+ await engine.DecideAsync(request.Id, "manager-one", "manager", false, blank));
+ Assert.Equal(GoldpathApprovalStatus.Pending, (await Reload(store, request.Id)).Status);
+ Assert.DoesNotContain(events.Published, e => e is GoldpathApprovalRejected);
+ }
+
+ [Fact]
+ public async Task The_trail_reads_signed_then_granted_with_the_quorum_count()
+ {
+ var (engine, _, _, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-8", 1_000_000m, "maker");
+ await engine.DecideAsync(request.Id, "manager-one", "manager", true, "first signature");
+ await engine.DecideAsync(request.Id, "manager-two", "manager", true, "second signature");
+
+ var trail = (await Reload(store, request.Id)).Trail;
+ Assert.Equal(["requested", "signed", "granted"], trail.Select(t => t.Action));
+ Assert.Equal("1/2 at manager: first signature", trail[1].Detail);
+ }
+
+ [Fact]
+ public async Task Withdraw_takes_back_a_pending_request_and_lands_in_the_trail()
+ {
+ var (engine, _, _, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-9", 1_000_000m, "maker");
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.WithdrawAsync(request.Id, "maker"));
+ var reloaded = await Reload(store, request.Id);
+ Assert.Equal(GoldpathApprovalStatus.Withdrawn, reloaded.Status);
+ Assert.Equal(["requested", "withdrawn"], reloaded.Trail.Select(t => t.Action));
+
+ // Withdrawn is terminal: nobody can decide it afterwards.
+ Assert.Equal(GoldpathApprovalDecisionOutcome.NotPending,
+ await engine.DecideAsync(request.Id, "manager-one", "manager", true, "too late"));
+ }
+
+ [Fact]
+ public async Task Only_the_requester_may_withdraw()
+ {
+ var (engine, _, _, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-10", 1_000_000m, "maker");
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.NotRequester,
+ await engine.WithdrawAsync(request.Id, "someone-else"));
+ Assert.Equal(GoldpathApprovalStatus.Pending, (await Reload(store, request.Id)).Status);
+ }
+
+ [Fact]
+ public async Task A_decided_request_cannot_be_withdrawn()
+ {
+ var (engine, _, _, _) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-11", 1_000_000m, "maker");
+ await engine.DecideAsync(request.Id, "manager-one", "manager", false, "declined");
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.NotPending,
+ await engine.WithdrawAsync(request.Id, "maker"));
+ Assert.Equal(GoldpathApprovalDecisionOutcome.NotFound,
+ await engine.WithdrawAsync(Guid.NewGuid(), "maker"));
+ }
+
+ [Fact]
+ public async Task Resubmit_links_the_chain_and_routes_the_fresh_request()
+ {
+ var (engine, _, events, store) = Build();
+ var request = await engine.RequestAsync("payment-run", "P26-12", 1_000_000m, "maker");
+ await engine.DecideAsync(request.Id, "manager-one", "manager", false, "wrong cost center");
+
+ var resubmitted = await engine.ResubmitAsync(request.Id, "maker");
+
+ Assert.Equal(GoldpathApprovalStatus.Pending, resubmitted.Status);
+ Assert.Equal(request.Id, resubmitted.SupersedesId);
+ Assert.Equal("manager", resubmitted.PendingRole);
+ Assert.Equal(request.Amount, resubmitted.Amount);
+ Assert.Equal(["resubmitted"], resubmitted.Trail.Select(t => t.Action));
+
+ // Both trails tell the one story: the old request records what superseded it.
+ var oldTrail = (await Reload(store, request.Id)).Trail;
+ Assert.Equal(["requested", "rejected", "superseded"], oldTrail.Select(t => t.Action));
+ Assert.Contains(resubmitted.Id.ToString(), oldTrail[^1].Detail);
+
+ Assert.Contains(events.Published, e => e is GoldpathApprovalRequested r && r.ApprovalId == resubmitted.Id);
+ }
+
+ [Fact]
+ public async Task Only_a_rejected_request_may_be_resubmitted()
+ {
+ var (engine, _, _, _) = Build();
+ var pending = await engine.RequestAsync("payment-run", "P26-13", 1_000_000m, "maker");
+
+ await Assert.ThrowsAsync(() => engine.ResubmitAsync(pending.Id, "maker"));
+ await Assert.ThrowsAsync(() => engine.ResubmitAsync(Guid.NewGuid(), "maker"));
+ }
+
+ [Fact]
+ public void A_rung_quorum_below_one_is_rejected_at_declaration()
+ {
+ Assert.Throws(() => new GoldpathApprovalsOptions()
+ .AddLadder("broken", l => l
+ .Rung("manager", 1_000_000m, RungDeadline, requiredApprovals: 0)
+ .TopRung("gm", RungDeadline)));
+ }
+
+ private static async Task Reload(IGoldpathApprovalStore store, Guid id)
+ => await store.GetAsync(id) ?? throw new InvalidOperationException($"request {id} not in the store");
+
+ private sealed class RecordingPublisher : IIntegrationEventPublisher
+ {
+ public List Published { get; } = [];
+
+ public Task PublishAsync(TEvent integrationEvent, CancellationToken cancellationToken = default)
+ where TEvent : class, IIntegrationEvent
+ {
+ Published.Add(integrationEvent);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Goldpath.Approvals.Tests/EfApprovalStoreTests.cs b/tests/Goldpath.Approvals.Tests/EfApprovalStoreTests.cs
index 169b9dd..89012ab 100644
--- a/tests/Goldpath.Approvals.Tests/EfApprovalStoreTests.cs
+++ b/tests/Goldpath.Approvals.Tests/EfApprovalStoreTests.cs
@@ -100,6 +100,51 @@ public async Task Delegations_persist_and_expire_by_the_clock()
await restarted.DecideAsync(late.Id, "stand-in", "no-role", true, "too late"));
}
+ [Fact]
+ public async Task Quorum_signatures_survive_the_engine_a_restarted_engine_completes_the_rung()
+ {
+ var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-08-26T09:00:00Z"));
+ var quorumOptions = new GoldpathApprovalsOptions().AddLadder("payment-run", l => l
+ .Rung("manager", 5_000_000m, TimeSpan.FromHours(8), requiredApprovals: 2)
+ .TopRung("general-manager", TimeSpan.FromHours(24)));
+ GoldpathApprovalEngine BuildQuorumEngine() => new(
+ quorumOptions,
+ new GoldpathEfApprovalStore(_provider.GetRequiredService()),
+ clock, NullLogger.Instance);
+
+ var first = BuildQuorumEngine();
+ var request = await first.RequestAsync("payment-run", "K26-104", 1_000_000m, "maker");
+ await first.DecideAsync(request.Id, "manager-one", "manager", true, "first signature");
+
+ // A brand-new engine over the SAME database: the collected signature is still there —
+ // the same signer is barred, a distinct one completes the rung.
+ var restarted = BuildQuorumEngine();
+ Assert.Equal(GoldpathApprovalDecisionOutcome.AlreadySigned,
+ await restarted.DecideAsync(request.Id, "manager-one", "manager", true, "again"));
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await restarted.DecideAsync(request.Id, "manager-two", "manager", true, "second signature"));
+ Assert.Equal(GoldpathApprovalStatus.Granted, (await Reload(request.Id)).Status);
+ }
+
+ [Fact]
+ public async Task Withdraw_and_the_resubmit_chain_round_trip_through_the_database()
+ {
+ var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-08-26T09:00:00Z"));
+ var engine = BuildEngine(clock);
+
+ var withdrawn = await engine.RequestAsync("credit-limit", "K26-105", 500_000m, "maker");
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied, await engine.WithdrawAsync(withdrawn.Id, "maker"));
+ Assert.Equal(GoldpathApprovalStatus.Withdrawn, (await Reload(withdrawn.Id)).Status);
+
+ var rejected = await engine.RequestAsync("credit-limit", "K26-106", 500_000m, "maker");
+ await engine.DecideAsync(rejected.Id, "checker", "expert", false, "collateral missing");
+ var resubmitted = await engine.ResubmitAsync(rejected.Id, "maker");
+
+ var reloaded = await Reload(resubmitted.Id);
+ Assert.Equal(rejected.Id, reloaded.SupersedesId);
+ Assert.Equal(["requested", "rejected", "superseded"], (await Reload(rejected.Id)).Trail.Select(t => t.Action));
+ }
+
private async Task Reload(Guid id)
{
var store = new GoldpathEfApprovalStore(_provider.GetRequiredService());
diff --git a/tests/Goldpath.Approvals.Tests/EfModelShapeTests.cs b/tests/Goldpath.Approvals.Tests/EfModelShapeTests.cs
new file mode 100644
index 0000000..9146c94
--- /dev/null
+++ b/tests/Goldpath.Approvals.Tests/EfModelShapeTests.cs
@@ -0,0 +1,129 @@
+using Goldpath;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Goldpath.Approvals.Tests;
+
+///
+/// The mapped model is a CONTRACT: table and column shapes are what an adopter's migration
+/// freezes, and what a raw psql session reads years later. These facts pin every name,
+/// length and conversion the mapping declares.
+///
+public sealed class EfModelShapeTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly ServiceProvider _provider;
+
+ public EfModelShapeTests()
+ {
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+ _provider = new ServiceCollection()
+ .AddDbContext(b => b.UseSqlite(_connection))
+ .BuildServiceProvider(true);
+ using var scope = _provider.CreateScope();
+ scope.ServiceProvider.GetRequiredService().Database.EnsureCreated();
+ }
+
+ public void Dispose()
+ {
+ _provider.Dispose();
+ _connection.Dispose();
+ }
+
+ [Theory]
+ [InlineData(typeof(GoldpathApprovalRequest), "GoldpathApprovals")]
+ [InlineData(typeof(GoldpathApprovalDelegationRow), "GoldpathApprovalDelegations")]
+ [InlineData(typeof(GoldpathApprovalSignatureRow), "GoldpathApprovalSignatures")]
+ public void The_three_tables_keep_their_declared_names(Type entity, string table)
+ {
+ using var scope = _provider.CreateScope();
+ var model = scope.ServiceProvider.GetRequiredService().Model;
+ Assert.Equal(table, model.FindEntityType(entity)!.GetTableName());
+ }
+
+ [Theory]
+ [InlineData(typeof(GoldpathApprovalRequest), "Ladder", 128)]
+ [InlineData(typeof(GoldpathApprovalRequest), "Subject", 256)]
+ [InlineData(typeof(GoldpathApprovalRequest), "RequestedBy", 128)]
+ [InlineData(typeof(GoldpathApprovalRequest), "PendingRole", 128)]
+ [InlineData(typeof(GoldpathApprovalRequest), "DecidedBy", 128)]
+ [InlineData(typeof(GoldpathApprovalRequest), "Status", 16)]
+ [InlineData(typeof(GoldpathApprovalDelegationRow), "From", 128)]
+ [InlineData(typeof(GoldpathApprovalDelegationRow), "To", 128)]
+ [InlineData(typeof(GoldpathApprovalSignatureRow), "SignedBy", 128)]
+ [InlineData(typeof(GoldpathApprovalSignatureRow), "Role", 128)]
+ public void Column_lengths_stay_as_the_migration_froze_them(Type entity, string property, int maxLength)
+ {
+ using var scope = _provider.CreateScope();
+ var model = scope.ServiceProvider.GetRequiredService().Model;
+ Assert.Equal(maxLength, model.FindEntityType(entity)!.FindProperty(property)!.GetMaxLength());
+ }
+
+ [Theory]
+ [InlineData(typeof(GoldpathApprovalRequest), "Status")]
+ [InlineData(typeof(GoldpathApprovalDelegationRow), "Until")]
+ [InlineData(typeof(GoldpathApprovalSignatureRow), "RequestId")]
+ public void The_declared_indexes_exist(Type entity, string property)
+ {
+ using var scope = _provider.CreateScope();
+ var model = scope.ServiceProvider.GetRequiredService().Model;
+ Assert.Contains(model.FindEntityType(entity)!.GetIndexes(),
+ i => i.Properties.Single().Name == property);
+ }
+
+ [Fact]
+ public async Task Status_is_stored_as_a_readable_string_not_an_int()
+ {
+ var store = new GoldpathEfApprovalStore(
+ _provider.GetRequiredService());
+ var request = new GoldpathApprovalRequest
+ {
+ Id = Guid.NewGuid(),
+ Ladder = "credit-limit",
+ Subject = "K26-200",
+ RequestedBy = "maker",
+ PendingRole = "expert",
+ Status = GoldpathApprovalStatus.Withdrawn,
+ };
+ await store.AddAsync(request);
+
+ using var command = _connection.CreateCommand();
+ command.CommandText = "SELECT Status FROM GoldpathApprovals WHERE Id = $id";
+ command.Parameters.AddWithValue("$id", request.Id);
+ Assert.Equal("Withdrawn", (string?)await command.ExecuteScalarAsync());
+ }
+
+ [Fact]
+ public async Task Signatures_come_back_oldest_first_regardless_of_insertion_order()
+ {
+ var store = new GoldpathEfApprovalStore(
+ _provider.GetRequiredService());
+ var requestId = Guid.NewGuid();
+ var later = DateTimeOffset.Parse("2026-08-26T12:00:00Z");
+ var earlier = DateTimeOffset.Parse("2026-08-26T09:00:00Z");
+ await store.AddSignatureAsync(new GoldpathApprovalSignature(requestId, "manager-two", "manager", later));
+ await store.AddSignatureAsync(new GoldpathApprovalSignature(requestId, "manager-one", "manager", earlier));
+ await store.AddSignatureAsync(new GoldpathApprovalSignature(Guid.NewGuid(), "stranger", "manager", earlier));
+
+ var signatures = await store.GetSignaturesAsync(requestId);
+ Assert.Equal(["manager-one", "manager-two"], signatures.Select(s => s.SignedBy));
+ Assert.All(signatures, s => Assert.Equal(requestId, s.RequestId));
+ Assert.All(signatures, s => Assert.Equal("manager", s.Role));
+ }
+
+ [Fact]
+ public async Task A_delegation_expiring_exactly_now_admits_nobody()
+ {
+ var store = new GoldpathEfApprovalStore(
+ _provider.GetRequiredService());
+ var now = DateTimeOffset.Parse("2026-08-26T09:00:00Z");
+ await store.AddDelegationAsync(new GoldpathApprovalDelegation("expert-user", "stand-in", now));
+
+ Assert.Empty(await store.GetDelegationsAsync(now)); // Until > now is strict
+ var stillActive = await store.GetDelegationsAsync(now - TimeSpan.FromSeconds(1));
+ Assert.Equal("stand-in", Assert.Single(stillActive).To);
+ }
+}
diff --git a/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj b/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj
index 6b28838..1c05e61 100644
--- a/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj
+++ b/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj
@@ -6,6 +6,7 @@
+