diff --git a/CHANGELOG.md b/CHANGELOG.md index c807f8e..653a142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning: integration events. 7-test planted-fault rig; `features.fileExchange` + `goldpath add feature fileexchange`. - Both cores ship with in-memory stores behind seams (`IGoldpathApprovalStore`, - `IGoldpathFileLedger`); database-backed stores, template flags and console federation - are tracked in the RFCs' open DoD rows (T21/T22). + Both modules ship database-backed stores on the app's own DbContext + (`AddGoldpathApprovalModel` / `AddGoldpathFileExchangeModel`, in-memory fallbacks behind + the same seams) and full composition: manifest keys, `goldpath add feature`, and + `dotnet new goldpath-solution --features approvals/fileexchange`. Console federation and + the adopter proofs are tracked in the RFCs' open DoD rows (T21/T22). ## [0.1.0-preview.6] - 2026-08-03 diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 727c9d6..5d8fdf6 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -45,6 +45,6 @@ All "implemented" modules are published on nuget.org at `0.1.0-preview.6` (the c | [spec-engine-v1](spec-engine-v1.md) | specdrift (separate repo) | implemented — 0.4.2 published (NuGet tool + MCP + Docker + Action) | | [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 + 17 tests + manifest/CLI wiring landed; console federation, DB store and the adopter proof stay open (T21) | -| [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 + 7-test planted-fault rig + manifest/CLI wiring landed; transports, console federation, DB ledger and the adopter proof stay open (T22) | +| [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-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.md b/docs/rfc/goldpath-approvals.md index 977ac4b..2f529b5 100644 --- a/docs/rfc/goldpath-approvals.md +++ b/docs/rfc/goldpath-approvals.md @@ -84,11 +84,16 @@ ladder's own deadline. (17 tests: rung boundaries inclusive, four-eyes, wrong-role refusal, double-decide, escalation resets the rung clock, top-rung expiry, delegation depth-one + expiry, worklist ordering, trail completeness — `tests/Goldpath.Approvals.Tests`). -- [x] `features.approvals` manifest key + `goldpath add feature approvals` CLI recipe wired - (template flag lands with the module's template pass). +- [x] `features.approvals` manifest key + `goldpath add feature approvals` CLI recipe wired. +- [x] Template flag: `dotnet new goldpath-solution --features approvals` generates the + manifest line, package reference, EF-store registration and model call — proven by + generating and BUILDING an app with the flag against the train's packages. +- [x] Database-backed store: `GoldpathEfApprovalStore` on the app's own DbContext + (`modelBuilder.AddGoldpathApprovalModel()`) — requests, trails (JSON column) and + delegations survive restarts; proven on real SQLite storage (3 tests: restarted + engine sees the same worklist, trail round-trips, delegations persist and expire). - [ ] Admin surface federates in the family console against a real app. - [x] Runbook ships (`packages/Goldpath.Approvals/ops/approvals.md`); dashboard JSON open. -- [ ] Database-backed `IGoldpathApprovalStore` (the in-memory store is single-node). - [ ] The adopter proof runs (§7 last row) — the row that actually closes T21. ### Decisions diff --git a/docs/rfc/goldpath-fileexchange.md b/docs/rfc/goldpath-fileexchange.md index 67ee4c0..680369b 100644 --- a/docs/rfc/goldpath-fileexchange.md +++ b/docs/rfc/goldpath-fileexchange.md @@ -88,10 +88,18 @@ window — the incident file rails actually have. truncated file rejected whole, reprocess-after-fix retries only the quarantined row — `tests/Goldpath.FileExchange.Tests`). - [x] `features.fileExchange` manifest key + `goldpath add feature fileexchange` CLI recipe - wired (template flag lands with the module's template pass). + wired. +- [x] Template flag: `dotnet new goldpath-solution --features fileexchange` generates the + manifest line, package reference, EF-ledger registration and model call — proven by + generating and BUILDING an app with the flag against the train's packages. +- [x] Database-backed ledger: `GoldpathEfFileLedger` on the app's own DbContext + (`modelBuilder.AddGoldpathFileExchangeModel()`) — processed keys, quarantine and + archive marks survive restarts; proven on real SQLite storage (2 tests: zero + duplicates across a restart, quarantine persists with its reason and clears on + reprocess). - [ ] Admin surface federates in the family console against a real app. - [x] Runbook ships (`packages/Goldpath.FileExchange/ops/fileexchange.md`); dashboard JSON open. -- [ ] Database-backed `IGoldpathFileLedger` + transport adapters (SFTP/share/object store). +- [ ] Transport adapters (SFTP/share/object store) — composed, per RFC §1. - [ ] The adopter proof runs (§7 last row) — the row that actually closes T22. ### Decisions diff --git a/docs/strategy/open-threads.md b/docs/strategy/open-threads.md index 66e7d55..d3e6253 100644 --- a/docs/strategy/open-threads.md +++ b/docs/strategy/open-threads.md @@ -24,8 +24,8 @@ 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. Open before the thread closes: template flag, database-backed store, 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 | -| 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. Open before the thread closes: template flag, database-backed ledger, 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 | +| 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 | +| 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 | ## Closed threads diff --git a/packages/Goldpath.Approvals/Goldpath.Approvals.csproj b/packages/Goldpath.Approvals/Goldpath.Approvals.csproj index ba6ec55..d62d9ba 100644 --- a/packages/Goldpath.Approvals/Goldpath.Approvals.csproj +++ b/packages/Goldpath.Approvals/Goldpath.Approvals.csproj @@ -14,6 +14,10 @@ + + diff --git a/packages/Goldpath.Approvals/GoldpathApprovalEfStore.cs b/packages/Goldpath.Approvals/GoldpathApprovalEfStore.cs new file mode 100644 index 0000000..06fef11 --- /dev/null +++ b/packages/Goldpath.Approvals/GoldpathApprovalEfStore.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.Extensions.DependencyInjection; + +namespace Goldpath; + +/// An active delegation row (the record itself has no key; the row carries one). +public sealed class GoldpathApprovalDelegationRow +{ + /// Row id. + public long Id { get; set; } + + /// Who delegated. + public string From { get; set; } = ""; + + /// Who received the delegation. + public string To { get; set; } = ""; + + /// Absolute UTC expiry. + public DateTimeOffset Until { get; set; } +} + +/// Model mapping for the database-backed approval store. +public static class GoldpathApprovalModelExtensions +{ + private static readonly JsonSerializerOptions TrailJson = JsonSerializerOptions.Default; + + /// Maps the approval tables. Call from the app context's OnModelCreating. + public static ModelBuilder AddGoldpathApprovalModel(this ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("GoldpathApprovals"); + e.HasKey(x => x.Id); + e.Property(x => x.Ladder).HasMaxLength(128); + e.Property(x => x.Subject).HasMaxLength(256); + e.Property(x => x.RequestedBy).HasMaxLength(128); + e.Property(x => x.PendingRole).HasMaxLength(128); + e.Property(x => x.DecidedBy).HasMaxLength(128); + e.Property(x => x.Status).HasConversion().HasMaxLength(16); + e.HasIndex(x => x.Status); + // The trail is one audit document, read and written with its request — a JSON + // column, not a join, so the store stays two tables on every provider. + e.Property(x => x.Trail) + .HasConversion( + trail => JsonSerializer.Serialize(trail, TrailJson), + json => JsonSerializer.Deserialize>(json, TrailJson) ?? new List(), + new ValueComparer>( + (a, b) => JsonSerializer.Serialize(a, TrailJson) == JsonSerializer.Serialize(b, TrailJson), + v => JsonSerializer.Serialize(v, TrailJson).GetHashCode(), + v => JsonSerializer.Deserialize>(JsonSerializer.Serialize(v, TrailJson), TrailJson)!)) + .UsePropertyAccessMode(PropertyAccessMode.Field); + }); + + modelBuilder.Entity(e => + { + e.ToTable("GoldpathApprovalDelegations"); + e.HasKey(x => x.Id); + e.Property(x => x.From).HasMaxLength(128); + e.Property(x => x.To).HasMaxLength(128); + e.HasIndex(x => x.Until); + }); + + return modelBuilder; + } +} + +/// +/// The database-backed store: approval state lives in the app's own DbContext (mapped by +/// ), so requests +/// survive restarts and every node sees the same worklist. Each call runs in its own scope — +/// the engine stays a singleton. +/// +public sealed class GoldpathEfApprovalStore : IGoldpathApprovalStore + where TContext : DbContext +{ + private readonly IServiceScopeFactory _scopes; + + /// Registered by AddGoldpathApprovals<TBuilder, TContext>. + public GoldpathEfApprovalStore(IServiceScopeFactory scopes) => _scopes = scopes; + + /// + public async Task AddAsync(GoldpathApprovalRequest request, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Add(request); + await db.SaveChangesAsync(cancellationToken); + } + + /// + public async Task GetAsync(Guid id, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Set().AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + /// + public async Task UpdateAsync(GoldpathApprovalRequest request, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Update(request); + await db.SaveChangesAsync(cancellationToken); + } + + /// + public async Task> GetPendingAsync(CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Set().AsNoTracking() + .Where(x => x.Status == GoldpathApprovalStatus.Pending) + .ToListAsync(cancellationToken); + } + + /// + public async Task AddDelegationAsync(GoldpathApprovalDelegation delegation, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Add(new GoldpathApprovalDelegationRow { From = delegation.From, To = delegation.To, Until = delegation.Until }); + await db.SaveChangesAsync(cancellationToken); + } + + /// + public async Task> GetDelegationsAsync(DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + // DateTimeOffset comparison does not translate on every provider (SQLite); + // delegations are few by nature, so the expiry filter runs client-side. + var rows = await db.Set().AsNoTracking() + .ToListAsync(cancellationToken); + return rows.Where(r => r.Until > now) + .Select(r => new GoldpathApprovalDelegation(r.From, r.To, r.Until)).ToList(); + } +} diff --git a/packages/Goldpath.Approvals/GoldpathApprovalsExtensions.cs b/packages/Goldpath.Approvals/GoldpathApprovalsExtensions.cs index 06f113c..cda2eaf 100644 --- a/packages/Goldpath.Approvals/GoldpathApprovalsExtensions.cs +++ b/packages/Goldpath.Approvals/GoldpathApprovalsExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -25,4 +26,17 @@ public static TBuilder AddGoldpathApprovals(this TBuilder builder, Act builder.Services.TryAddSingleton(); return builder; } + + /// + /// Registers the approvals engine with the DATABASE-backed store on the app's own + /// DbContext (map it with modelBuilder.AddGoldpathApprovalModel()) — requests + /// survive restarts and every node sees the same worklist. + /// + public static TBuilder AddGoldpathApprovals(this TBuilder builder, Action configure) + where TBuilder : IHostApplicationBuilder + where TContext : DbContext + { + builder.Services.AddSingleton>(); + return builder.AddGoldpathApprovals(configure); + } } diff --git a/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt b/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt index d8d50a1..b7fecda 100644 --- a/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt +++ b/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt @@ -155,3 +155,24 @@ Goldpath.IGoldpathApprovalStore.GetDelegationsAsync(System.DateTimeOffset now, S Goldpath.IGoldpathApprovalStore.GetPendingAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Goldpath.IGoldpathApprovalStore.UpdateAsync(Goldpath.GoldpathApprovalRequest! request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static Goldpath.GoldpathApprovalsExtensions.AddGoldpathApprovals(this TBuilder builder, System.Action! configure) -> TBuilder +Goldpath.GoldpathApprovalDelegationRow +Goldpath.GoldpathApprovalDelegationRow.GoldpathApprovalDelegationRow() -> void +Goldpath.GoldpathApprovalDelegationRow.Id.get -> long +Goldpath.GoldpathApprovalDelegationRow.Id.set -> void +Goldpath.GoldpathApprovalDelegationRow.From.get -> string! +Goldpath.GoldpathApprovalDelegationRow.From.set -> void +Goldpath.GoldpathApprovalDelegationRow.To.get -> string! +Goldpath.GoldpathApprovalDelegationRow.To.set -> void +Goldpath.GoldpathApprovalDelegationRow.Until.get -> System.DateTimeOffset +Goldpath.GoldpathApprovalDelegationRow.Until.set -> void +Goldpath.GoldpathApprovalModelExtensions +static Goldpath.GoldpathApprovalModelExtensions.AddGoldpathApprovalModel(this Microsoft.EntityFrameworkCore.ModelBuilder! modelBuilder) -> Microsoft.EntityFrameworkCore.ModelBuilder! +Goldpath.GoldpathEfApprovalStore +Goldpath.GoldpathEfApprovalStore.GoldpathEfApprovalStore(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory! scopes) -> void +Goldpath.GoldpathEfApprovalStore.AddAsync(Goldpath.GoldpathApprovalRequest! request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Goldpath.GoldpathEfApprovalStore.GetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Goldpath.GoldpathEfApprovalStore.UpdateAsync(Goldpath.GoldpathApprovalRequest! request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Goldpath.GoldpathEfApprovalStore.GetPendingAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +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 diff --git a/packages/Goldpath.FileExchange/Goldpath.FileExchange.csproj b/packages/Goldpath.FileExchange/Goldpath.FileExchange.csproj index 2710fe7..b02a8d2 100644 --- a/packages/Goldpath.FileExchange/Goldpath.FileExchange.csproj +++ b/packages/Goldpath.FileExchange/Goldpath.FileExchange.csproj @@ -14,6 +14,10 @@ + + diff --git a/packages/Goldpath.FileExchange/GoldpathEfFileLedger.cs b/packages/Goldpath.FileExchange/GoldpathEfFileLedger.cs new file mode 100644 index 0000000..84279db --- /dev/null +++ b/packages/Goldpath.FileExchange/GoldpathEfFileLedger.cs @@ -0,0 +1,167 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Goldpath; + +/// A processed-row mark: the durable half of the (rail, file, line) idempotency key. +public sealed class GoldpathFileProcessedRow +{ + /// Rail name. + public string Rail { get; set; } = ""; + + /// File name. + public string File { get; set; } = ""; + + /// 1-based line number. + public int Line { get; set; } +} + +/// A quarantined row with its reason. +public sealed class GoldpathFileQuarantineRow +{ + /// Rail name. + public string Rail { get; set; } = ""; + + /// File name. + public string File { get; set; } = ""; + + /// 1-based line number. + public int Line { get; set; } + + /// Why the row quarantined. + public string Reason { get; set; } = ""; +} + +/// An archive mark for a completed file run. +public sealed class GoldpathFileArchiveRow +{ + /// Rail name. + public string Rail { get; set; } = ""; + + /// File name. + public string File { get; set; } = ""; +} + +/// Model mapping for the database-backed file ledger. +public static class GoldpathFileExchangeModelExtensions +{ + /// Maps the ledger tables. Call from the app context's OnModelCreating. + public static ModelBuilder AddGoldpathFileExchangeModel(this ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("GoldpathFileProcessed"); + e.HasKey(x => new { x.Rail, x.File, x.Line }); + e.Property(x => x.Rail).HasMaxLength(128); + e.Property(x => x.File).HasMaxLength(256); + }); + modelBuilder.Entity(e => + { + e.ToTable("GoldpathFileQuarantine"); + e.HasKey(x => new { x.Rail, x.File, x.Line }); + e.Property(x => x.Rail).HasMaxLength(128); + e.Property(x => x.File).HasMaxLength(256); + e.Property(x => x.Reason).HasMaxLength(1024); + }); + modelBuilder.Entity(e => + { + e.ToTable("GoldpathFileArchive"); + e.HasKey(x => new { x.Rail, x.File }); + e.Property(x => x.Rail).HasMaxLength(128); + e.Property(x => x.File).HasMaxLength(256); + }); + return modelBuilder; + } +} + +/// +/// The database-backed ledger: rail progress lives in the app's own DbContext (mapped by +/// ), so the +/// zero-duplicate guarantee survives restarts and holds across nodes. Each call runs in its +/// own scope — the engine stays a singleton. +/// +public sealed class GoldpathEfFileLedger : IGoldpathFileLedger + where TContext : DbContext +{ + private readonly IServiceScopeFactory _scopes; + + /// Registered by AddGoldpathFileExchange<TBuilder, TContext>. + public GoldpathEfFileLedger(IServiceScopeFactory scopes) => _scopes = scopes; + + /// + public async Task IsProcessedAsync(string rail, string file, int line, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Set().AsNoTracking() + .AnyAsync(x => x.Rail == rail && x.File == file && x.Line == line, cancellationToken); + } + + /// + public async Task MarkProcessedAsync(string rail, string file, int line, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Add(new GoldpathFileProcessedRow { Rail = rail, File = file, Line = line }); + await db.SaveChangesAsync(cancellationToken); + } + + /// + public async Task QuarantineAsync(string rail, string file, int line, string reason, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var existing = await db.Set() + .SingleOrDefaultAsync(x => x.Rail == rail && x.File == file && x.Line == line, cancellationToken); + if (existing is null) + { + db.Add(new GoldpathFileQuarantineRow { Rail = rail, File = file, Line = line, Reason = reason }); + } + else + { + existing.Reason = reason; + } + + await db.SaveChangesAsync(cancellationToken); + } + + /// + public async Task ReleaseQuarantineAsync(string rail, string file, int line, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var existing = await db.Set() + .SingleOrDefaultAsync(x => x.Rail == rail && x.File == file && x.Line == line, cancellationToken); + if (existing is not null) + { + db.Remove(existing); + await db.SaveChangesAsync(cancellationToken); + } + } + + /// + public async Task> GetQuarantineAsync(string rail, string file, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var rows = await db.Set().AsNoTracking() + .Where(x => x.Rail == rail && x.File == file) + .OrderBy(x => x.Line) + .ToListAsync(cancellationToken); + return rows.Select(r => new GoldpathQuarantinedRow(r.Rail, r.File, r.Line, r.Reason)).ToList(); + } + + /// + public async Task MarkArchivedAsync(string rail, string file, CancellationToken cancellationToken = default) + { + await using var scope = _scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var exists = await db.Set().AsNoTracking() + .AnyAsync(x => x.Rail == rail && x.File == file, cancellationToken); + if (!exists) + { + db.Add(new GoldpathFileArchiveRow { Rail = rail, File = file }); + await db.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/packages/Goldpath.FileExchange/GoldpathFileExchangeExtensions.cs b/packages/Goldpath.FileExchange/GoldpathFileExchangeExtensions.cs index 28ee780..a0d5c77 100644 --- a/packages/Goldpath.FileExchange/GoldpathFileExchangeExtensions.cs +++ b/packages/Goldpath.FileExchange/GoldpathFileExchangeExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -22,4 +23,17 @@ public static TBuilder AddGoldpathFileExchange(this TBuilder builder, builder.Services.TryAddSingleton(); return builder; } + + /// + /// Registers the rail engine with the DATABASE-backed ledger on the app's own DbContext + /// (map it with modelBuilder.AddGoldpathFileExchangeModel()) — the zero-duplicate + /// guarantee survives restarts and holds across nodes. + /// + public static TBuilder AddGoldpathFileExchange(this TBuilder builder, Action configure) + where TBuilder : IHostApplicationBuilder + where TContext : DbContext + { + builder.Services.AddSingleton>(); + return builder.AddGoldpathFileExchange(configure); + } } diff --git a/packages/Goldpath.FileExchange/PublicAPI.Unshipped.txt b/packages/Goldpath.FileExchange/PublicAPI.Unshipped.txt index 04c9ef2..a06ff40 100644 --- a/packages/Goldpath.FileExchange/PublicAPI.Unshipped.txt +++ b/packages/Goldpath.FileExchange/PublicAPI.Unshipped.txt @@ -92,3 +92,38 @@ Goldpath.IGoldpathFileLedger.MarkProcessedAsync(string! rail, string! file, int Goldpath.IGoldpathFileLedger.QuarantineAsync(string! rail, string! file, int line, string! reason, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Goldpath.IGoldpathFileLedger.ReleaseQuarantineAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static Goldpath.GoldpathFileExchangeExtensions.AddGoldpathFileExchange(this TBuilder builder, System.Action! configure) -> TBuilder +Goldpath.GoldpathFileProcessedRow +Goldpath.GoldpathFileProcessedRow.GoldpathFileProcessedRow() -> void +Goldpath.GoldpathFileProcessedRow.Rail.get -> string! +Goldpath.GoldpathFileProcessedRow.Rail.set -> void +Goldpath.GoldpathFileProcessedRow.File.get -> string! +Goldpath.GoldpathFileProcessedRow.File.set -> void +Goldpath.GoldpathFileProcessedRow.Line.get -> int +Goldpath.GoldpathFileProcessedRow.Line.set -> void +Goldpath.GoldpathFileQuarantineRow +Goldpath.GoldpathFileQuarantineRow.GoldpathFileQuarantineRow() -> void +Goldpath.GoldpathFileQuarantineRow.Rail.get -> string! +Goldpath.GoldpathFileQuarantineRow.Rail.set -> void +Goldpath.GoldpathFileQuarantineRow.File.get -> string! +Goldpath.GoldpathFileQuarantineRow.File.set -> void +Goldpath.GoldpathFileQuarantineRow.Line.get -> int +Goldpath.GoldpathFileQuarantineRow.Line.set -> void +Goldpath.GoldpathFileQuarantineRow.Reason.get -> string! +Goldpath.GoldpathFileQuarantineRow.Reason.set -> void +Goldpath.GoldpathFileArchiveRow +Goldpath.GoldpathFileArchiveRow.GoldpathFileArchiveRow() -> void +Goldpath.GoldpathFileArchiveRow.Rail.get -> string! +Goldpath.GoldpathFileArchiveRow.Rail.set -> void +Goldpath.GoldpathFileArchiveRow.File.get -> string! +Goldpath.GoldpathFileArchiveRow.File.set -> void +Goldpath.GoldpathFileExchangeModelExtensions +static Goldpath.GoldpathFileExchangeModelExtensions.AddGoldpathFileExchangeModel(this Microsoft.EntityFrameworkCore.ModelBuilder! modelBuilder) -> Microsoft.EntityFrameworkCore.ModelBuilder! +Goldpath.GoldpathEfFileLedger +Goldpath.GoldpathEfFileLedger.GoldpathEfFileLedger(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory! scopes) -> void +Goldpath.GoldpathEfFileLedger.IsProcessedAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Goldpath.GoldpathEfFileLedger.MarkProcessedAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Goldpath.GoldpathEfFileLedger.QuarantineAsync(string! rail, string! file, int line, string! reason, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Goldpath.GoldpathEfFileLedger.ReleaseQuarantineAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Goldpath.GoldpathEfFileLedger.GetQuarantineAsync(string! rail, string! file, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Goldpath.GoldpathEfFileLedger.MarkArchivedAsync(string! rail, string! file, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Goldpath.GoldpathFileExchangeExtensions.AddGoldpathFileExchange(this TBuilder builder, System.Action! configure) -> TBuilder diff --git a/templates/goldpath-solution/.goldpath/manifest.yaml b/templates/goldpath-solution/.goldpath/manifest.yaml index e444dc6..3f2dd31 100644 --- a/templates/goldpath-solution/.goldpath/manifest.yaml +++ b/templates/goldpath-solution/.goldpath/manifest.yaml @@ -16,7 +16,7 @@ providers: cache: redis broker: GOLDPATH-BROKER auth: GOLDPATH-AUTH -#if (UseBroker || UseMultiTenancy || UseAuditTrail || UseSoftDelete || UseIdempotency || UseDataProtection || UseCaching || UseLocking || UseArchival || UseBulk || UseNotification || UseCampaign) +#if (UseBroker || UseMultiTenancy || UseAuditTrail || UseSoftDelete || UseIdempotency || UseDataProtection || UseCaching || UseLocking || UseArchival || UseBulk || UseNotification || UseCampaign || UseApprovals || UseFileExchange) features: #endif #if (UseBroker) @@ -62,3 +62,9 @@ features: #if (UseCampaign) campaign: true #endif +#if (UseApprovals) + approvals: true +#endif +#if (UseFileExchange) + fileExchange: true +#endif diff --git a/templates/goldpath-solution/.template.config/template.json b/templates/goldpath-solution/.template.config/template.json index 703d818..c1c4e79 100644 --- a/templates/goldpath-solution/.template.config/template.json +++ b/templates/goldpath-solution/.template.config/template.json @@ -120,6 +120,14 @@ { "choice": "campaign", "description": "Governed mass-execution (L4): paced broker fan-out under a live policy (runs on jobs; REQUIRES a broker)" + }, + { + "choice": "approvals", + "description": "Human approval workflows: amount ladders as data, four-eyes, delegation, escalation, worklist" + }, + { + "choice": "fileexchange", + "description": "File rails: format contracts, idempotent (file,line) ingestion, quarantine, zero-duplicate replay" } ], "defaultValue": "", @@ -169,6 +177,14 @@ "type": "computed", "value": "(features == \"campaign\")" }, + "UseApprovals": { + "type": "computed", + "value": "(features == \"approvals\")" + }, + "UseFileExchange": { + "type": "computed", + "value": "(features == \"fileexchange\")" + }, "UseAuth": { "type": "computed", "value": "(auth != \"none\")" diff --git a/templates/goldpath-solution/Directory.Packages.props b/templates/goldpath-solution/Directory.Packages.props index 925360c..a58696a 100644 --- a/templates/goldpath-solution/Directory.Packages.props +++ b/templates/goldpath-solution/Directory.Packages.props @@ -31,6 +31,8 @@ + + diff --git a/templates/goldpath-solution/src/GoldpathTemplate.Api/GoldpathTemplate.Api.csproj b/templates/goldpath-solution/src/GoldpathTemplate.Api/GoldpathTemplate.Api.csproj index a0b6d75..e0465a3 100644 --- a/templates/goldpath-solution/src/GoldpathTemplate.Api/GoldpathTemplate.Api.csproj +++ b/templates/goldpath-solution/src/GoldpathTemplate.Api/GoldpathTemplate.Api.csproj @@ -64,6 +64,12 @@ + + + + + + diff --git a/templates/goldpath-solution/src/GoldpathTemplate.Api/Orders/OrdersDbContext.cs b/templates/goldpath-solution/src/GoldpathTemplate.Api/Orders/OrdersDbContext.cs index cda018f..1b1ef3f 100644 --- a/templates/goldpath-solution/src/GoldpathTemplate.Api/Orders/OrdersDbContext.cs +++ b/templates/goldpath-solution/src/GoldpathTemplate.Api/Orders/OrdersDbContext.cs @@ -19,6 +19,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) #if (UseAuditTrail) modelBuilder.AddGoldpathAuditLog(); #endif +#if (UseApprovals) + modelBuilder.AddGoldpathApprovalModel(); // approvals + delegations (worklist survives restarts) +#endif +#if (UseFileExchange) + modelBuilder.AddGoldpathFileExchangeModel(); // processed keys + quarantine + archive marks +#endif #if (UseSoftDelete) modelBuilder.ApplyGoldpathSoftDelete(); #endif diff --git a/templates/goldpath-solution/src/GoldpathTemplate.Api/Program.cs b/templates/goldpath-solution/src/GoldpathTemplate.Api/Program.cs index f39c3b4..fdeeea8 100644 --- a/templates/goldpath-solution/src/GoldpathTemplate.Api/Program.cs +++ b/templates/goldpath-solution/src/GoldpathTemplate.Api/Program.cs @@ -39,6 +39,24 @@ //#if (UseDataProtection) builder.AddGoldpathDataProtection(); //#endif +//#if (UseApprovals) +builder.AddGoldpathApprovals(approvals => +{ + // Declare YOUR authority chains here (goldpath never guesses who may approve): + // approvals.AddLadder("credit-limit", l => l + // .Rung("expert", 1_000_000m, TimeSpan.FromHours(8)) + // .TopRung("general-manager", TimeSpan.FromHours(24))); +}); +//#endif +//#if (UseFileExchange) +builder.AddGoldpathFileExchange(files => +{ + // Declare YOUR rails here (goldpath never guesses a counterparty format): + // files.AddRail("registry-daily", r => r.Header(1) + // .ParseLine(MyRow.Parse).ValidateRow(x => x.IsValid ? null : "reason") + // .Handle((row, ct) => ApplyAsync(row, ct))); +}); +//#endif //#if (UseLocking && UsePostgres) builder.AddGoldpathLocking(o => { diff --git a/templates/goldpath-solution/src/GoldpathTemplate.Infrastructure/GoldpathTemplate.Infrastructure.csproj b/templates/goldpath-solution/src/GoldpathTemplate.Infrastructure/GoldpathTemplate.Infrastructure.csproj index 72a726e..b9596f4 100644 --- a/templates/goldpath-solution/src/GoldpathTemplate.Infrastructure/GoldpathTemplate.Infrastructure.csproj +++ b/templates/goldpath-solution/src/GoldpathTemplate.Infrastructure/GoldpathTemplate.Infrastructure.csproj @@ -16,6 +16,12 @@ + + + + + + diff --git a/templates/goldpath-solution/src/GoldpathTemplate.Infrastructure/Persistence/OrdersDbContext.cs b/templates/goldpath-solution/src/GoldpathTemplate.Infrastructure/Persistence/OrdersDbContext.cs index 54d64c0..119e41d 100644 --- a/templates/goldpath-solution/src/GoldpathTemplate.Infrastructure/Persistence/OrdersDbContext.cs +++ b/templates/goldpath-solution/src/GoldpathTemplate.Infrastructure/Persistence/OrdersDbContext.cs @@ -23,6 +23,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) #if (UseAuditTrail) modelBuilder.AddGoldpathAuditLog(); #endif +#if (UseApprovals) + modelBuilder.AddGoldpathApprovalModel(); // approvals + delegations (worklist survives restarts) +#endif +#if (UseFileExchange) + modelBuilder.AddGoldpathFileExchangeModel(); // processed keys + quarantine + archive marks +#endif #if (UseSoftDelete) modelBuilder.ApplyGoldpathSoftDelete(); #endif diff --git a/tests/Goldpath.Approvals.Tests/EfApprovalStoreTests.cs b/tests/Goldpath.Approvals.Tests/EfApprovalStoreTests.cs new file mode 100644 index 0000000..169b9dd --- /dev/null +++ b/tests/Goldpath.Approvals.Tests/EfApprovalStoreTests.cs @@ -0,0 +1,108 @@ +using Goldpath; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Goldpath.Approvals.Tests; + +/// +/// The database-backed store on a real (SQLite) database: requests, trails and delegations +/// SURVIVE the store instance — a "restarted" engine over the same database sees the same +/// worklist, which is exactly what the in-memory store cannot promise. +/// +public sealed class EfApprovalStoreTests : IDisposable +{ + public sealed class ApprovalsDbContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.AddGoldpathApprovalModel(); + } + + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + + public EfApprovalStoreTests() + { + _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(); + } + + private GoldpathApprovalEngine BuildEngine(FakeTimeProvider clock) => new( + new GoldpathApprovalsOptions().AddLadder("credit-limit", l => l + .Rung("expert", 1_000_000m, TimeSpan.FromHours(8)) + .TopRung("general-manager", TimeSpan.FromHours(24))), + new GoldpathEfApprovalStore(_provider.GetRequiredService()), + clock, NullLogger.Instance); + + [Fact] + public async Task Requests_survive_the_engine_a_restarted_engine_sees_the_same_worklist() + { + var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-08-18T09:00:00Z")); + var first = BuildEngine(clock); + var request = await first.RequestAsync("credit-limit", "K26-100", 500_000m, "maker"); + + // A brand-new engine + store over the SAME database — the restart. + var restarted = BuildEngine(clock); + var worklist = await restarted.WorklistAsync("checker", "expert"); + Assert.Equal([request.Id], worklist.Select(r => r.Id)); + + Assert.Equal(GoldpathApprovalDecisionOutcome.Applied, + await restarted.DecideAsync(request.Id, "checker", "expert", true, "fits")); + Assert.Equal(GoldpathApprovalStatus.Granted, (await Reload(request.Id)).Status); + } + + [Fact] + public async Task The_trail_round_trips_through_the_json_column() + { + var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-08-18T09:00:00Z")); + var engine = BuildEngine(clock); + var request = await engine.RequestAsync("credit-limit", "K26-101", 500_000m, "maker"); + clock.Advance(TimeSpan.FromHours(8)); + await engine.EscalateOverdueAsync(); + await engine.DecideAsync(request.Id, "gm", "general-manager", false, "declined on review"); + + var reloaded = await Reload(request.Id); + Assert.Equal(["requested", "escalated", "rejected"], reloaded.Trail.Select(t => t.Action)); + Assert.Equal("declined on review", reloaded.Reason); + } + + [Fact] + public async Task Delegations_persist_and_expire_by_the_clock() + { + var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-08-18T09:00:00Z")); + var engine = BuildEngine(clock); + var request = await engine.RequestAsync("credit-limit", "K26-102", 500_000m, "maker"); + await engine.DelegateAsync("expert-user", "stand-in", TimeSpan.FromDays(2)); + + // A restarted engine honors the persisted delegation... + var restarted = BuildEngine(clock); + Assert.Equal(GoldpathApprovalDecisionOutcome.Applied, + await restarted.DecideAsync(request.Id, "stand-in", "no-role", true, "delegated")); + + // ...and its expiry. + var late = await restarted.RequestAsync("credit-limit", "K26-103", 500_000m, "maker"); + clock.Advance(TimeSpan.FromDays(2) + TimeSpan.FromMinutes(1)); + Assert.Equal(GoldpathApprovalDecisionOutcome.WrongRole, + await restarted.DecideAsync(late.Id, "stand-in", "no-role", true, "too late")); + } + + private async Task Reload(Guid id) + { + var store = new GoldpathEfApprovalStore(_provider.GetRequiredService()); + return await store.GetAsync(id) ?? throw new InvalidOperationException("missing request"); + } +} diff --git a/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj b/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj index ccbed93..6b28838 100644 --- a/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj +++ b/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj @@ -7,6 +7,8 @@ + + diff --git a/tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs b/tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs index 97cbb27..137c374 100644 --- a/tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs +++ b/tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs @@ -366,7 +366,8 @@ public void Approvals_recipe_wires_the_module_without_touching_the_database() var plan = FeatureRecipes.Build("approvals", facts); Assert.Equal(["Goldpath.Approvals"], plan.ApiPackages); Assert.Contains(" approvals: true", plan.ManifestLines); - Assert.Contains("builder.AddGoldpathApprovals(approvals =>", plan.Registrations); + Assert.Contains("builder.AddGoldpathApprovals(approvals =>", plan.Registrations); + Assert.Contains(plan.ModelCalls, m => m.Contains("AddGoldpathApprovalModel")); } [Fact] @@ -376,7 +377,8 @@ public void Fileexchange_recipe_wires_the_module_without_touching_the_database() var plan = FeatureRecipes.Build("fileexchange", facts); Assert.Equal(["Goldpath.FileExchange"], plan.ApiPackages); Assert.Contains(" fileExchange: true", plan.ManifestLines); - Assert.Contains("builder.AddGoldpathFileExchange(files =>", plan.Registrations); + Assert.Contains("builder.AddGoldpathFileExchange(files =>", plan.Registrations); + Assert.Contains(plan.ModelCalls, m => m.Contains("AddGoldpathFileExchangeModel")); } [Fact] diff --git a/tests/Goldpath.FileExchange.Tests/EfFileLedgerTests.cs b/tests/Goldpath.FileExchange.Tests/EfFileLedgerTests.cs new file mode 100644 index 0000000..17c9b7f --- /dev/null +++ b/tests/Goldpath.FileExchange.Tests/EfFileLedgerTests.cs @@ -0,0 +1,96 @@ +using Goldpath; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Goldpath.FileExchange.Tests; + +/// +/// The database-backed ledger on a real (SQLite) database: the zero-duplicate guarantee +/// SURVIVES the engine instance — a "restarted" engine over the same database skips every +/// already-applied row, which is exactly what the in-memory ledger cannot promise. +/// +public sealed class EfFileLedgerTests : IDisposable +{ + public sealed class LedgerDbContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.AddGoldpathFileExchangeModel(); + } + + private sealed record Row(string Reference, decimal Amount); + + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + private readonly List _applied = []; + + public EfFileLedgerTests() + { + _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(); + } + + private GoldpathFileRailEngine BuildEngine() => new( + new GoldpathFileExchangeOptions().AddRail("registry-daily", rail => rail + .Header(1) + .ParseLine(line => + { + var parts = line.Split(';'); + return new Row(parts[0], decimal.Parse(parts[1])); + }) + .ValidateRow(row => row.Amount > 0 ? null : "non-positive amount") + .Handle((row, _) => + { + _applied.Add(row.Reference); + return Task.CompletedTask; + })), + new GoldpathEfFileLedger(_provider.GetRequiredService()), + NullLogger.Instance); + + [Fact] + public async Task Zero_duplicates_survive_a_restart() + { + string[] file = ["H;2", "A-1;100.50", "A-2;200.00"]; + var first = BuildEngine(); + Assert.Equal(2, (await first.ProcessAsync("registry-daily", "reg.csv", file)).Processed); + + // A brand-new engine + ledger over the SAME database — the redelivery after restart. + var restarted = BuildEngine(); + var replay = await restarted.ProcessAsync("registry-daily", "reg.csv", file); + Assert.Equal(0, replay.Processed); + Assert.Equal(2, replay.SkippedAsDuplicate); + Assert.Equal(["A-1", "A-2"], _applied); + } + + [Fact] + public async Task Quarantine_persists_with_its_reason_and_clears_on_reprocess() + { + string[] broken = ["H;2", "A-1;100.50", "A-2;-1"]; + var engine = BuildEngine(); + await engine.ProcessAsync("registry-daily", "reg2.csv", broken); + + var restarted = BuildEngine(); + var ledger = new GoldpathEfFileLedger(_provider.GetRequiredService()); + var quarantine = await ledger.GetQuarantineAsync("registry-daily", "reg2.csv"); + Assert.Equal([(3, "non-positive amount")], quarantine.Select(q => (q.Line, q.Reason))); + + string[] fixedFile = ["H;2", "A-1;100.50", "A-2;75.00"]; + var result = await restarted.ProcessAsync("registry-daily", "reg2.csv", fixedFile); + Assert.Equal(1, result.Processed); + Assert.Equal(1, result.SkippedAsDuplicate); + Assert.Empty(await ledger.GetQuarantineAsync("registry-daily", "reg2.csv")); + } +} diff --git a/tests/Goldpath.FileExchange.Tests/Goldpath.FileExchange.Tests.csproj b/tests/Goldpath.FileExchange.Tests/Goldpath.FileExchange.Tests.csproj index d9e993e..e55878e 100644 --- a/tests/Goldpath.FileExchange.Tests/Goldpath.FileExchange.Tests.csproj +++ b/tests/Goldpath.FileExchange.Tests/Goldpath.FileExchange.Tests.csproj @@ -6,6 +6,8 @@ + + diff --git a/tools/Goldpath.Cli/FeatureRecipes.cs b/tools/Goldpath.Cli/FeatureRecipes.cs index e739926..9f6119c 100644 --- a/tools/Goldpath.Cli/FeatureRecipes.cs +++ b/tools/Goldpath.Cli/FeatureRecipes.cs @@ -116,9 +116,8 @@ public static AppFacts Read(AppFiles files) /// fileexchange) plus the four execution-ladder modules (archival, bulk, notification, /// campaign), which the CLI wires the same way even though they are not Ring B. Every line /// mirrors what dotnet new goldpath-solution --features X would have generated — the -/// CLI adds nothing the template would not (approvals/fileexchange land in the template -/// with their module template pass; until then the CLI is their only automated path); -/// specdrift stays the acceptance test for both paths. +/// CLI adds nothing the template would not; specdrift stays the acceptance test for both +/// paths. /// public static class FeatureRecipes { @@ -239,16 +238,16 @@ public static RecipePlan Build(string feature, AppFacts app) { var plan = new RecipePlan { ManifestKey = "approvals" }; plan.ApiPackages.Add("Goldpath.Approvals"); - plan.Registrations.Add("builder.AddGoldpathApprovals(approvals =>"); + plan.Registrations.Add($"builder.AddGoldpathApprovals(approvals =>"); plan.Registrations.Add("{"); plan.Registrations.Add(" // Declare YOUR authority chains here (goldpath never guesses who may approve):"); plan.Registrations.Add(" // approvals.AddLadder(\"credit-limit\", l => l"); plan.Registrations.Add(" // .Rung(\"expert\", 1_000_000m, TimeSpan.FromHours(8))"); plan.Registrations.Add(" // .TopRung(\"general-manager\", TimeSpan.FromHours(24)));"); plan.Registrations.Add("});"); + plan.ModelCalls.Add(" modelBuilder.AddGoldpathApprovalModel(); // approvals + delegations (worklist survives restarts)"); plan.ManifestLines.Add(" approvals: true"); plan.NextSteps.Add("declare ladders in AddGoldpathApprovals; schedule EscalateOverdueAsync through the jobs module"); - plan.NextSteps.Add("the in-memory store is single-node — compose a database-backed IGoldpathApprovalStore before production"); return plan; } @@ -256,16 +255,16 @@ public static RecipePlan Build(string feature, AppFacts app) { var plan = new RecipePlan { ManifestKey = "fileExchange" }; plan.ApiPackages.Add("Goldpath.FileExchange"); - plan.Registrations.Add("builder.AddGoldpathFileExchange(files =>"); + plan.Registrations.Add($"builder.AddGoldpathFileExchange(files =>"); plan.Registrations.Add("{"); plan.Registrations.Add(" // Declare YOUR rails here (goldpath never guesses a counterparty format):"); plan.Registrations.Add(" // files.AddRail(\"registry-daily\", r => r.Header(1)"); plan.Registrations.Add(" // .ParseLine(MyRow.Parse).ValidateRow(x => x.IsValid ? null : \"reason\")"); plan.Registrations.Add(" // .Handle((row, ct) => ApplyAsync(row, ct)));"); plan.Registrations.Add("});"); + plan.ModelCalls.Add(" modelBuilder.AddGoldpathFileExchangeModel(); // processed keys + quarantine + archive marks"); plan.ManifestLines.Add(" fileExchange: true"); plan.NextSteps.Add("declare rails in AddGoldpathFileExchange; schedule pick-up through the jobs module"); - plan.NextSteps.Add("the in-memory ledger is single-node — compose a database-backed IGoldpathFileLedger before production"); return plan; }