diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55cf5cd..c807f8e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,25 @@
All notable changes to the Goldpath packages are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning: SemVer.
+## [Unreleased]
+
+### Added
+- **Goldpath.Approvals** (new Ring B module, core landed 2026-08-18): human approval
+ workflows — amount-laddered authority chains declared as data, four-eyes/maker-checker
+ enforcement, bounded delegation (depth one), deadline escalation with top-rung expiry,
+ the worklist, and the full audit trail; lifecycle published as integration events.
+ 17 deterministic tests; `features.approvals` + `goldpath add feature approvals`.
+- **Goldpath.FileExchange** (new Ring B module, core landed 2026-08-18): file-based
+ integration rails as a unit — rails declared as data with baked closures, file-level
+ contracts, idempotent `(file, line)` ingestion, per-row quarantine that never stops the
+ batch, zero-duplicate replay/reprocess, archive marks; lifecycle published as
+ 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).
+
## [0.1.0-preview.6] - 2026-08-03
The operations train. Campaign revision R1 gives long-running campaigns their calendar
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 87021ed..6392802 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,6 +8,7 @@
+
@@ -22,6 +23,7 @@
+
diff --git a/Goldpath.sln b/Goldpath.sln
index f4bbc6c..a3f3008 100644
--- a/Goldpath.sln
+++ b/Goldpath.sln
@@ -107,6 +107,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.Console.Tests", "t
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.Sdk", "packages\Goldpath.Sdk\Goldpath.Sdk.csproj", "{58BE5B0A-D181-48EA-974F-8D924A553186}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.Approvals", "packages\Goldpath.Approvals\Goldpath.Approvals.csproj", "{0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.Approvals.Tests", "tests\Goldpath.Approvals.Tests\Goldpath.Approvals.Tests.csproj", "{163EB0DB-9785-4C80-815F-863670023278}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.FileExchange", "packages\Goldpath.FileExchange\Goldpath.FileExchange.csproj", "{4F6B1244-4268-4E2F-BCB5-58A3C8B93311}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Goldpath.FileExchange.Tests", "tests\Goldpath.FileExchange.Tests\Goldpath.FileExchange.Tests.csproj", "{49780B31-D85B-437B-BDE1-6A47769D7C66}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -681,6 +689,54 @@ Global
{58BE5B0A-D181-48EA-974F-8D924A553186}.Release|x64.Build.0 = Release|Any CPU
{58BE5B0A-D181-48EA-974F-8D924A553186}.Release|x86.ActiveCfg = Release|Any CPU
{58BE5B0A-D181-48EA-974F-8D924A553186}.Release|x86.Build.0 = Release|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Debug|x64.Build.0 = Debug|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Debug|x86.Build.0 = Debug|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Release|x64.ActiveCfg = Release|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Release|x64.Build.0 = Release|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Release|x86.ActiveCfg = Release|Any CPU
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE}.Release|x86.Build.0 = Release|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Debug|x64.Build.0 = Debug|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Debug|x86.Build.0 = Debug|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Release|Any CPU.Build.0 = Release|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Release|x64.ActiveCfg = Release|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Release|x64.Build.0 = Release|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Release|x86.ActiveCfg = Release|Any CPU
+ {163EB0DB-9785-4C80-815F-863670023278}.Release|x86.Build.0 = Release|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Debug|x64.Build.0 = Debug|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Debug|x86.Build.0 = Debug|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Release|x64.ActiveCfg = Release|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Release|x64.Build.0 = Release|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Release|x86.ActiveCfg = Release|Any CPU
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311}.Release|x86.Build.0 = Release|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Debug|x64.Build.0 = Debug|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Debug|x86.Build.0 = Debug|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Release|Any CPU.Build.0 = Release|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Release|x64.ActiveCfg = Release|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Release|x64.Build.0 = Release|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Release|x86.ActiveCfg = Release|Any CPU
+ {49780B31-D85B-437B-BDE1-6A47769D7C66}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -733,5 +789,9 @@ Global
{1616453C-824E-42DB-8FB1-63E824736568} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
{C44229F0-A168-466E-8E29-EC6321827C59} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{58BE5B0A-D181-48EA-974F-8D924A553186} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
+ {0A6CFB92-354E-4C57-AAB8-3F52F9646AEE} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
+ {163EB0DB-9785-4C80-815F-863670023278} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {4F6B1244-4268-4E2F-BCB5-58A3C8B93311} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
+ {49780B31-D85B-437B-BDE1-6A47769D7C66} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/docs/rfc/README.md b/docs/rfc/README.md
index 9eaa557..727c9d6 100644
--- a/docs/rfc/README.md
+++ b/docs/rfc/README.md
@@ -44,5 +44,7 @@ All "implemented" modules are published on nuget.org at `0.1.0-preview.6` (the c
| [goldpath-messaging-exit](goldpath-messaging-exit.md) | the messaging dependency: measured exposure, options, and the triggers that would move us | **ACCEPTED** (2026-08-10) — option **A**: stay pinned on 8.x, fallback D; a move would be a MAJOR version, not an internal swap. §5 implemented (the publish seam + GP0404); the watch lives as `open-threads.md` T18 |
| [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 | **proposed** — owner acceptance pending; v0 scope is engagement-shaped (§5.1 by-product rule); opens the second product repo beside the API Portal pilot, recorded as an owner ordering decision (RFC D5) |
+| [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) |
| [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
new file mode 100644
index 0000000..977ac4b
--- /dev/null
+++ b/docs/rfc/goldpath-approvals.md
@@ -0,0 +1,106 @@
+# RFC: Goldpath.Approvals — Human Approval Workflows
+
+**Status:** accepted (owner, 2026-08-18) — the owner pulled the Ring B trigger the same day ("nothing left incomplete"); recorded as an explicit owner ordering decision, mirroring qorpe-sync D5
+**Date:** 2026-08-18
+**Constitution grounding:** ADR-0003 (compose, don't rewrite — this module orchestrates PEOPLE'S
+decisions, not systems; process orchestration remains the decided non-goal the closed T19 thread
+proved composable), foundation §5.1 (by-product timing rule), §6.2 Ring B criteria,
+open-threads **T21** (this RFC is that thread's first DoD row).
+
+---
+
+## 1. Scope / Non-Goals
+
+**Scope.** The approval mechanics that today live in e-mail threads at every enterprise adopter,
+as one composable Ring B module:
+
+- **Approval definitions as data** — maker-checker, four-eyes, and amount-laddered authority
+ chains (e.g. expert → deputy → manager → GM) declared as schema-validated artifacts, never
+ code. A new ladder is a new definition, not a fork.
+- **Decision lifecycle** — request → pending → granted/rejected/expired, with every transition
+ audited; a guarded action proceeds only on a granted decision.
+- **Delegation and escalation** — bounded delegation (no cycles, windowed), deadline-driven
+ escalation up the ladder.
+- **Worklist** — the pending-approvals inbox surface (per-approver, per-ladder) that feeds
+ operator UIs and the family console.
+
+**Non-goals.** Not a BPM/workflow engine (no arbitrary process graphs); not process
+orchestration across services (that composes as a state machine per the T19 proof — this module
+is what such a flow CALLS when a step needs a human); not an identity/org-chart system —
+authority levels map onto the app's existing Auth roles/claims.
+
+## 2. Seam Map
+
+- **Auth** — who MAY decide at each rung; authority mapping is claims-based.
+- **AuditTrail** — every request, decision, delegation, and escalation is an audited event;
+ the module's value proposition IS the audit trail e-mail never had.
+- **Notification** — pending/escalation nudges replace the e-mail thread, not augment it.
+- **Jobs** — escalation and expiry timers (Quartz-backed, no bespoke scheduler).
+- **Messaging** — `ApprovalGranted`/`ApprovalRejected`/`ApprovalExpired` as
+ `IIntegrationEvent`s; GP0401–0403 hold the boundary unchanged.
+
+## 3. Manifest Surface
+
+`features.approvals` — enabled or absent (compile-time composition; a manifest without it has
+NO approvals code). Ladder definitions live beside the manifest as versioned declarative
+artifacts validated at build by the standard schema gate.
+
+## 4. API Surface
+
+Admin-contract (R3) shaped, so the family console federates it without bespoke work:
+worklist (repeatable OR filters), decide, delegate, history per subject; all responses typed
+in OpenAPI. The application-facing surface is one interface: request an approval for a subject
+under a ladder, and observe/await its outcome.
+
+## 5. Analyzer Rules
+
+- A guarded operation (one declaring it requires approval) reachable without an approval-gate
+ check fails the build — the same "the standard ships its verifier" rule every module obeys.
+- Approval events not marked `IIntegrationEvent` are already caught by GP0401.
+
+## 6. Ops Package ("no runbook = no module")
+
+Runbook + dashboard: pending-decision age (the number the e-mail world cannot produce),
+escalations fired, per-ladder throughput, expiry rate. Alarm on oldest-pending breaching the
+ladder's own deadline.
+
+## 7. Test Plan
+
+- Ladder boundary values (the adopter PoC's amount ladder is the seed shape: each rung's
+ inclusive edge, above-top-rung routing).
+- Delegation cycle guard and window expiry; escalation timers on a virtual clock.
+- Audit completeness: every lifecycle transition appears exactly once in the trail.
+- Adopter proof (the T21 proof column): one REAL amount-laddered flow from the first adopter
+ runs end to end on the module, audit trail inspected.
+
+## 8. DoD
+
+- [x] RFC accepted; the four Ring B entry criteria confirmed (≥2 industries named:
+ banking-class approvals, insurance underwriting sign-off, telco credit overrides).
+- [x] Ladders declared as data through the fluent surface; declaration-time validation
+ (no top rung / non-increasing ceilings) rejects malformed chains at composition.
+ (A standalone YAML schema for config-file ladders is a follow-on, not shipped.)
+- [x] Lifecycle + delegation + escalation proven by the §7 deterministic tests
+ (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).
+- [ ] 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
+
+- **D1 — Ring B, born as a by-product (§5.1):** built when the first adopter's implementation
+ phase needs its first systematized approval flow — this RFC prepares the shelf; it does not
+ open a front. Six of the factoring-class engagement's twelve common processes run approvals
+ over e-mail today; the ladder shape is domain-agnostic.
+- **D2 — Definitions are data:** ladders, quorums, deadlines, delegation windows are versioned
+ declarative artifacts. Code changes are module changes; ladder changes are config reviews.
+- **D3 — The human/saga boundary:** a compensating flow (T19) orchestrates SYSTEMS and may
+ request an approval as one of its steps; Approvals never drives system steps itself. One
+ sentence each side, so neither module grows into the other.
+- **D4 — Worklist is part of the module,** not left to each adopter's UI team: without the
+ inbox, adopters fall back to e-mail and the audit value evaporates.
diff --git a/docs/rfc/goldpath-fileexchange.md b/docs/rfc/goldpath-fileexchange.md
new file mode 100644
index 0000000..67ee4c0
--- /dev/null
+++ b/docs/rfc/goldpath-fileexchange.md
@@ -0,0 +1,110 @@
+# RFC: Goldpath.FileExchange — File-Based Integration Rails
+
+**Status:** accepted (owner, 2026-08-18) — the owner pulled the Ring B trigger the same day ("nothing left incomplete"); recorded as an explicit owner ordering decision, mirroring qorpe-sync D5
+**Date:** 2026-08-18
+**Constitution grounding:** ADR-0003 (compose transports and parsers, don't rewrite),
+foundation §5.1 (by-product timing rule), §6.2 Ring B criteria, open-threads **T22**
+(this RFC is that thread's first DoD row).
+
+---
+
+## 1. Scope / Non-Goals
+
+**Scope.** The file rail as a UNIT — today Jobs+Bulk cover pieces (a schedule, a large
+ingestion) but an adopter still hand-writes the rail around them every time:
+
+- **Pick-up / drop** — scheduled collection and delivery over composed adapters (SFTP,
+ network share, object store); no transport code rewritten.
+- **Validation** — declared per-rail format contracts (schema-validated artifacts); a file
+ that fails contract never reaches ingestion.
+- **Idempotent ingestion** — `(file, line)`-keyed dedup so a re-delivered or re-processed
+ file applies zero duplicates (the adopter PoC's registry-file processor is the seed shape).
+- **Quarantine** — a bad ROW quarantines, the BATCH continues; quarantined rows carry the
+ reason and are individually reprocessable.
+- **Reprocessing & replay** — re-run a file or a quarantine subset with the same zero-duplicate
+ guarantee.
+- **Archival** — processed files retained per rail policy, composing the Archival module.
+- **Outbound** — generated files with delivery confirmation and the same archival discipline.
+
+**Non-goals.** Not ETL/BI; not the CDC/streaming leg (that is qorpe.sync's Capture — Sync
+moves STORES, FileExchange integrates COUNTERPARTIES); no format libraries rewritten (parsers
+are composed); no domain knowledge — rail definitions are adopter data.
+
+## 2. Seam Map
+
+- **Jobs** — schedules drive pick-up/drop; no second scheduler.
+- **Bulk** — large ingestions run through the existing batch machinery.
+- **Idempotency** — the `(file, line)` dedup rides the module's store, not a bespoke table.
+- **Archival** — retention/purge policy per rail.
+- **AuditTrail** — every file's lifecycle (received → validated → ingested/quarantined →
+ archived) is audited.
+- **Messaging** — `FileReceived`/`FileIngested`/`RowsQuarantined` as `IIntegrationEvent`s;
+ GP0401–0403 unchanged.
+- **Mockifyr** — counterparty endpoints stubbed in tests; air-gapped runs stay first-class.
+
+## 3. Manifest Surface
+
+`features.fileExchange` — enabled or absent (compile-time composition). Rail definitions
+(endpoint, schedule, format contract, quarantine and retention policy) are versioned
+declarative artifacts beside the manifest, schema-validated at build.
+
+## 4. API Surface
+
+Admin-contract (R3) shaped: rail status, per-file lifecycle, quarantine list with repeatable
+OR filters, `reprocess` verb (file or quarantine subset), all typed in OpenAPI so the family
+console federates it.
+
+## 5. Analyzer Rules
+
+- A rail definition referencing an undeclared format contract fails the build (schema gate).
+- Ingestion handlers bypassing the idempotency key surface are flagged — the standard ships
+ its verifier.
+
+## 6. Ops Package ("no runbook = no module")
+
+Runbook + dashboard: rail lag (expected vs actual arrival), quarantine depth and age,
+reprocess counts, outbound delivery confirmations outstanding. Alarm on a missed arrival
+window — the incident file rails actually have.
+
+## 7. Test Plan
+
+- Planted-fault rig, answer-key discipline: a bad row, a duplicate file, a truncated file,
+ an out-of-window arrival, a replay — every planted fault must be caught, quarantined, or
+ deduplicated exactly as declared.
+- Zero-duplicate replay proof: ingest, replay the same file, assert identical end state.
+- Quarantine-continues proof: N bad rows quarantine while the batch completes.
+- Adopter proof (the T22 proof column): one REAL bidirectional file rail runs with replay +
+ idempotency + failure reprocessing + its ops runbook.
+
+## 8. DoD
+
+- [x] RFC accepted; Ring B criteria confirmed (file rails recur across banking
+ registries/statements, insurance bordereaux, telco interconnect settlement).
+- [x] Rails declared as data through the fluent surface with baked, compile-checked
+ closures; declaration-time validation rejects a rail without ParseLine/Handle.
+ (A standalone YAML schema for config-file rails is a follow-on, not shipped.)
+- [x] The §7 planted-fault rig runs green (7 tests: clean file exactly-once, bad rows
+ quarantine while the batch continues, duplicate-file replay applies zero duplicates,
+ 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).
+- [ ] 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).
+- [ ] The adopter proof runs (§7 last row) — the row that actually closes T22.
+
+### Decisions
+
+- **D1 — Ring B, born as a by-product (§5.1):** the trigger is the first adopter integration
+ that is file-based and bidirectional entering an implementation backlog. Three of the
+ factoring-class engagement's common processes are file rails; the pattern already has a
+ private seed consumer (the adopter PoC's registry-file processor).
+- **D2 — The rail is the unit,** not the pieces: Jobs+Bulk existing was the reason T22 waited;
+ the recurring hand-written glue (validate → dedup → quarantine → archive → reprocess) is
+ the module.
+- **D3 — Rail definitions are data** — versioned, schema-validated; a new counterparty is a
+ new definition, not new code.
+- **D4 — Boundary with qorpe.sync:** Sync migrates and reconciles STORES during a
+ transformation; FileExchange is the PERMANENT integration surface with counterparties.
+ A transformation may use both; neither absorbs the other.
diff --git a/docs/rfc/qorpe-sync.md b/docs/rfc/qorpe-sync.md
index 4de65c2..75ffad2 100644
--- a/docs/rfc/qorpe-sync.md
+++ b/docs/rfc/qorpe-sync.md
@@ -1,6 +1,6 @@
# RFC: qorpe.sync — the Migration & Co-existence Product Module
-**Status:** proposed — owner acceptance pending
+**Status:** accepted (owner, 2026-08-18)
**Date:** 2026-08-18
**Constitution grounding:** ADR-0012 (product modules on the platform — Sync is one of the two
first-party examples the ADR itself names), ADR-0003 (compose, don't rewrite), foundation §9
@@ -70,8 +70,9 @@ count, replay depth, difference-list age). "No runbook = no module" applies unch
## 8. DoD (v0 — engagement-shaped)
-- [ ] RFC accepted by the owner (this row flips on the owner's word; the master-plan records
- the second-product-repo ordering decision alongside).
+- [x] RFC accepted by the owner (2026-08-18 — the build is scheduled for the following week,
+ two-store rig first; the master-plan records the second-product-repo ordering decision
+ alongside).
- [ ] Private repo stands with the exported gate set; binds the published train only.
- [ ] Capture→Stream→Transform→Reconcile runs end to end on the two-store rig; every planted
difference caught; reconciliation report generated.
diff --git a/docs/strategy/open-threads.md b/docs/strategy/open-threads.md
index 40c27e0..66e7d55 100644
--- a/docs/strategy/open-threads.md
+++ b/docs/strategy/open-threads.md
@@ -24,10 +24,9 @@ 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. No RFC yet; passes the four Ring B entry criteria on paper only | 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 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. 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 |
| 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 |
-| T19 | **Orchestration composes on top of Goldpath** — the unproven half of the saga non-goal. **2026-08-17: a candidate naming adopter exists** — the factoring-class engagement's cancellation/reversal flows (post-payout reversal: accounting reversal + registry notification + risk correction) look like real compensation; its discovery must evaluate, and if orchestration is named, this proof runs BEFORE that engagement's Phase-1 scope freezes | Saga is a decided non-goal (the ecosystem ships MassTransit state machines, NServiceBus, Dapr Workflow, Temporal; ADR-0003 forbids rewriting them). The decision rests on a claim — that the outbox's atomicity, the run model's kill-9 survival and idempotent retry are exactly what an orchestrator needs from its host — and **no sample had ever run one** — until 2026-08-17: an adopter-shaped PoC (private repo factoring-poc, on the published preview.6 train) composed a MassTransit state machine for a post-payout cancellation flow and proved, by test, the compensation path (the accounting reversal is itself reversed on registry failure) and duplicate-step idempotency at the saga level. 2026-08-18 the persistence legs ran too, against REAL Postgres saga storage: the saga parks mid-flow with state in the database, the process dies, a fresh bus over the same store resumes and completes, and a post-restart duplicate reply yields exactly one completion — and the test's first run caught a real race (a step reply outrunning the saga's INSERT was silently discarded; fixed with OnMissingInstance→Fault + retry). ONE assertion remains before this thread closes — the saga row and the outbox row committing in ONE transaction inside the app's own pipeline (the PoC used a dedicated saga DbContext); the rehearsal runs that leg on the app's DbContext and closes the thread. The portal pilot does NOT need it: gateway provisioning converges by idempotent retry plus a reconciliation job, which is the right answer against an external gateway, not a compensating saga | the first adopter whose requirement names orchestration with real compensation across services (a portal charge-event flow does not qualify) | a sample composes a real orchestrator on a Goldpath app, and the three host properties are shown to hold: the state machine's persistence joins the outbox transaction · a killed instance resumes without duplicate side effects · GP0401-0403 still hold the event boundary |
## Closed threads
@@ -39,6 +38,24 @@ threads whose triggers had genuinely fired (T1, T7) among rows that were already
- **T14** — Four admin responses were **untyped in OpenAPI** ([#98](https://github.com/omercelikdev/goldpath/issues/98)) — CLOSED 2026-08-05.
- **T15** — **Campaign R1 — device-fleet parity** (`ExcludedDays`, `EndDate`, per-item auto-retry ladder, `GlobalTps`) — CLOSED 2026-08-03.
- **T8** — Bulk's **`?definition=` filter** on `/batches` ([#72](https://github.com/omercelikdev/goldpath/issues/72)) — CLOSED 2026-08-03.
+- **T19** — **Orchestration composes on top of Goldpath** — CLOSED 2026-08-18. The saga
+ non-goal's claim — that the host's atomic outbox, kill-9-survivable run model and
+ idempotent retry are exactly what an orchestrator needs — now stands on TEST, not on
+ assertion. An adopter-shaped PoC (private repo, binding the published preview.6 train
+ like any adopter) composed a MassTransit state machine for a post-payout cancellation
+ flow and proved all three host properties: **compensation + duplicate-step idempotency**
+ at the saga level; **restart-resume on real Postgres saga storage** (whose first run
+ caught a genuine distributed race — a step reply outrunning the saga's INSERT was
+ silently discarded; fixed with OnMissingInstance→Fault + retry — the gate-catches-bugs
+ thesis, demonstrated on ourselves); and the final leg, **the atomic outbox-join**: the
+ saga's state mapped into the app's OWN DbContext (the one carrying the outbox tables),
+ with an injected SaveChanges fault proving the saga row and its outbox rows commit or
+ roll back in ONE transaction, then commit together on redelivery. GP0401 held the event
+ boundary throughout — the analyzer itself forced `IIntegrationEvent` on the saga's
+ events at first compile. The thread's proof column had assigned this last leg to the
+ rehearsal; the PoC ran it earlier, on the app's own pipeline, which is the stronger
+ venue. The rehearsal (T20) still owns the METHOD's end-to-end proof — but the saga
+ non-goal no longer owes it anything.
Kept short on purpose — the point of the list is what is still open.
diff --git a/packages/Goldpath.Approvals/Goldpath.Approvals.csproj b/packages/Goldpath.Approvals/Goldpath.Approvals.csproj
new file mode 100644
index 0000000..ba6ec55
--- /dev/null
+++ b/packages/Goldpath.Approvals/Goldpath.Approvals.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0;net10.0
+ Goldpath.Approvals
+ Ring B human approval workflows for the Goldpath enterprise asset: amount-laddered authority chains declared as data, four-eyes and maker-checker enforcement, bounded delegation, deadline escalation, and the worklist — with every lifecycle step audited and published as integration events. Replaces the e-mail approval thread, not the humans in it.
+ Goldpath
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/Goldpath.Approvals/GoldpathApprovalEngine.cs b/packages/Goldpath.Approvals/GoldpathApprovalEngine.cs
new file mode 100644
index 0000000..2370fd1
--- /dev/null
+++ b/packages/Goldpath.Approvals/GoldpathApprovalEngine.cs
@@ -0,0 +1,322 @@
+using Microsoft.Extensions.Logging;
+
+namespace Goldpath;
+
+/// Where approval state lives. The module ships an in-memory store for tests and
+/// single-node hosts; a database-backed store composes through this seam.
+public interface IGoldpathApprovalStore
+{
+ /// Adds a new request.
+ Task AddAsync(GoldpathApprovalRequest request, CancellationToken cancellationToken = default);
+
+ /// Loads one request, or null.
+ Task GetAsync(Guid id, CancellationToken cancellationToken = default);
+
+ /// Persists a mutated request.
+ Task UpdateAsync(GoldpathApprovalRequest request, CancellationToken cancellationToken = default);
+
+ /// All PENDING requests (the worklist and the escalation sweep read this).
+ Task> GetPendingAsync(CancellationToken cancellationToken = default);
+
+ /// Adds a delegation.
+ Task AddDelegationAsync(GoldpathApprovalDelegation delegation, CancellationToken cancellationToken = default);
+
+ /// Active delegations (unexpired).
+ Task> GetDelegationsAsync(DateTimeOffset now, CancellationToken cancellationToken = default);
+}
+
+/// The outcome of a decision attempt — refusals are values, not exceptions.
+public enum GoldpathApprovalDecisionOutcome
+{
+ /// The decision was applied.
+ Applied,
+
+ /// Unknown request id.
+ NotFound,
+
+ /// The request is not pending.
+ NotPending,
+
+ /// Four-eyes: the requester may never decide their own request.
+ FourEyesViolation,
+
+ /// The decider does not hold the pending rung's role (nor a valid delegation).
+ WrongRole,
+}
+
+///
+/// The approvals engine: routes by amount, enforces four-eyes, applies decisions,
+/// honors bounded delegation, and escalates on rung deadlines. Every step lands in the
+/// request's trail — the audit value the e-mail thread never had.
+///
+public sealed class GoldpathApprovalEngine
+{
+ private readonly GoldpathApprovalsOptions _options;
+ private readonly IGoldpathApprovalStore _store;
+ private readonly TimeProvider _time;
+ private readonly IIntegrationEventPublisher? _publisher;
+ private readonly ILogger _logger;
+
+ /// Creates the engine (the publisher is optional — no broker, no events).
+ public GoldpathApprovalEngine(
+ GoldpathApprovalsOptions options,
+ IGoldpathApprovalStore store,
+ TimeProvider time,
+ ILogger logger,
+ IIntegrationEventPublisher? publisher = null)
+ {
+ _options = options;
+ _store = store;
+ _time = time;
+ _logger = logger;
+ _publisher = publisher;
+ }
+
+ /// Requests an approval; the amount routes the rung.
+ public async Task RequestAsync(string ladderName, string subject, decimal amount, string requestedBy, CancellationToken cancellationToken = default)
+ {
+ if (!_options.Ladders.TryGetValue(ladderName, out var ladder))
+ {
+ throw new InvalidOperationException($"Ladder '{ladderName}' is not declared — approvals run on declared ladders only.");
+ }
+
+ var now = _time.GetUtcNow();
+ var rung = ladder.Route(amount);
+ var request = new GoldpathApprovalRequest
+ {
+ Id = Guid.NewGuid(),
+ Ladder = ladder.Name,
+ Subject = subject,
+ Amount = amount,
+ RequestedBy = requestedBy,
+ RequestedAt = now,
+ PendingRole = rung.Role,
+ PendingSince = now,
+ Status = GoldpathApprovalStatus.Pending,
+ };
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, requestedBy, "requested", $"routed to {rung.Role} for {amount}"));
+ await _store.AddAsync(request, cancellationToken);
+ await PublishAsync(new GoldpathApprovalRequested(request.Id, ladder.Name, subject, amount, rung.Role), cancellationToken);
+ return request;
+ }
+
+ /// Applies a grant/reject decision under four-eyes and role checks.
+ public async Task DecideAsync(Guid id, string decidedBy, string deciderRole, bool granted, string reason, CancellationToken cancellationToken = default)
+ {
+ var request = await _store.GetAsync(id, cancellationToken);
+ if (request is null)
+ {
+ return GoldpathApprovalDecisionOutcome.NotFound;
+ }
+
+ if (request.Status != GoldpathApprovalStatus.Pending)
+ {
+ return GoldpathApprovalDecisionOutcome.NotPending;
+ }
+
+ if (string.Equals(request.RequestedBy, decidedBy, StringComparison.OrdinalIgnoreCase))
+ {
+ return GoldpathApprovalDecisionOutcome.FourEyesViolation;
+ }
+
+ var now = _time.GetUtcNow();
+ if (!string.Equals(request.PendingRole, deciderRole, StringComparison.OrdinalIgnoreCase)
+ && !await HasDelegationToRoleAsync(decidedBy, request.PendingRole, now, cancellationToken))
+ {
+ return GoldpathApprovalDecisionOutcome.WrongRole;
+ }
+
+ request.Status = granted ? GoldpathApprovalStatus.Granted : GoldpathApprovalStatus.Rejected;
+ request.DecidedBy = decidedBy;
+ request.Reason = reason;
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, decidedBy, granted ? "granted" : "rejected", reason));
+ await _store.UpdateAsync(request, cancellationToken);
+
+ if (granted)
+ {
+ await PublishAsync(new GoldpathApprovalGranted(request.Id, request.Ladder, request.Subject, decidedBy), cancellationToken);
+ }
+ else
+ {
+ await PublishAsync(new GoldpathApprovalRejected(request.Id, request.Ladder, request.Subject, decidedBy, reason), cancellationToken);
+ }
+
+ return GoldpathApprovalDecisionOutcome.Applied;
+ }
+
+ ///
+ /// Delegates 's pending decisions to for a
+ /// bounded window. Depth is one: a delegate cannot re-delegate — the cycle guard is
+ /// structural, not a graph search.
+ ///
+ public async Task DelegateAsync(string from, string to, TimeSpan window, CancellationToken cancellationToken = default)
+ {
+ if (string.Equals(from, to, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("Delegation to self is meaningless.");
+ }
+
+ if (window > _options.MaxDelegationWindow)
+ {
+ throw new InvalidOperationException($"Delegation window exceeds the declared maximum ({_options.MaxDelegationWindow}).");
+ }
+
+ var now = _time.GetUtcNow();
+ var active = await _store.GetDelegationsAsync(now, cancellationToken);
+ if (active.Any(d => string.Equals(d.To, from, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new InvalidOperationException($"'{from}' currently holds a delegation and cannot re-delegate (depth is one).");
+ }
+
+ await _store.AddDelegationAsync(new GoldpathApprovalDelegation(from, to, now + window), cancellationToken);
+ }
+
+ ///
+ /// The escalation sweep: pending requests past their rung's deadline move UP one rung;
+ /// overdue at the top rung EXPIRES. Schedule this through the Jobs module.
+ ///
+ public async Task EscalateOverdueAsync(CancellationToken cancellationToken = default)
+ {
+ var now = _time.GetUtcNow();
+ var moved = 0;
+ foreach (var request in await _store.GetPendingAsync(cancellationToken))
+ {
+ if (!_options.Ladders.TryGetValue(request.Ladder, out var ladder))
+ {
+ continue;
+ }
+
+ var rung = ladder.Rungs.FirstOrDefault(r => string.Equals(r.Role, request.PendingRole, StringComparison.OrdinalIgnoreCase));
+ if (rung is null || now - request.PendingSince < rung.EscalateAfter)
+ {
+ continue;
+ }
+
+ var above = ladder.Above(rung);
+ if (above is null)
+ {
+ request.Status = GoldpathApprovalStatus.Expired;
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, "system", "expired", $"overdue at top rung {rung.Role}"));
+ await _store.UpdateAsync(request, cancellationToken);
+ await PublishAsync(new GoldpathApprovalExpired(request.Id, request.Ladder, request.Subject, rung.Role), cancellationToken);
+ }
+ else
+ {
+ request.PendingRole = above.Role;
+ request.PendingSince = now;
+ request.Trail.Add(new GoldpathApprovalTrailEntry(now, "system", "escalated", $"{rung.Role} -> {above.Role}"));
+ await _store.UpdateAsync(request, cancellationToken);
+ await PublishAsync(new GoldpathApprovalEscalated(request.Id, request.Ladder, request.Subject, rung.Role, above.Role), cancellationToken);
+ }
+
+ moved++;
+ }
+
+ if (moved > 0)
+ {
+ _logger.LogInformation("Approvals escalation sweep moved {Count} request(s).", moved);
+ }
+
+ return moved;
+ }
+
+ ///
+ /// The worklist: pending requests the given identity (holding the given role) may decide —
+ /// their role's rung plus anything delegated to them. Four-eyes filters their own requests out.
+ ///
+ public async Task> WorklistAsync(string identity, string role, CancellationToken cancellationToken = default)
+ {
+ var now = _time.GetUtcNow();
+ var delegations = await _store.GetDelegationsAsync(now, cancellationToken);
+ var pending = await _store.GetPendingAsync(cancellationToken);
+ return pending
+ .Where(r => !string.Equals(r.RequestedBy, identity, StringComparison.OrdinalIgnoreCase))
+ .Where(r => string.Equals(r.PendingRole, role, StringComparison.OrdinalIgnoreCase)
+ || delegations.Any(d => string.Equals(d.To, identity, StringComparison.OrdinalIgnoreCase)))
+ .OrderBy(r => r.PendingSince)
+ .ToList();
+ }
+
+ private async Task HasDelegationToRoleAsync(string identity, string pendingRole, DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ // A delegation transfers the DELEGATOR's decision right; v0 models identity-level
+ // delegation, so holding any active delegation admits the delegate to the rung.
+ var delegations = await _store.GetDelegationsAsync(now, cancellationToken);
+ return delegations.Any(d => string.Equals(d.To, identity, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private Task PublishAsync(TEvent integrationEvent, CancellationToken cancellationToken)
+ where TEvent : class, IIntegrationEvent
+ => _publisher?.PublishAsync(integrationEvent, cancellationToken) ?? Task.CompletedTask;
+}
+
+/// In-memory store: tests and single-node hosts; database stores compose via the seam.
+public sealed class GoldpathInMemoryApprovalStore : IGoldpathApprovalStore
+{
+ private readonly object _gate = new();
+ private readonly Dictionary _requests = [];
+ private readonly List _delegations = [];
+
+ ///
+ public Task AddAsync(GoldpathApprovalRequest request, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _requests.Add(request.Id, request);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task GetAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ return Task.FromResult(_requests.TryGetValue(id, out var request) ? request : null);
+ }
+ }
+
+ ///
+ public Task UpdateAsync(GoldpathApprovalRequest request, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _requests[request.Id] = request;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task> GetPendingAsync(CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ IReadOnlyList pending =
+ _requests.Values.Where(r => r.Status == GoldpathApprovalStatus.Pending).ToList();
+ return Task.FromResult(pending);
+ }
+ }
+
+ ///
+ public Task AddDelegationAsync(GoldpathApprovalDelegation delegation, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _delegations.Add(delegation);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task> GetDelegationsAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ IReadOnlyList active = _delegations.Where(d => d.Until > now).ToList();
+ return Task.FromResult(active);
+ }
+ }
+}
diff --git a/packages/Goldpath.Approvals/GoldpathApprovalModel.cs b/packages/Goldpath.Approvals/GoldpathApprovalModel.cs
new file mode 100644
index 0000000..fe4077d
--- /dev/null
+++ b/packages/Goldpath.Approvals/GoldpathApprovalModel.cs
@@ -0,0 +1,78 @@
+namespace Goldpath;
+
+/// Where an approval request stands in its lifecycle.
+public enum GoldpathApprovalStatus
+{
+ /// Waiting for the current rung's decision.
+ Pending,
+
+ /// Granted — the guarded action may proceed.
+ Granted,
+
+ /// Rejected — the guarded action must not proceed.
+ Rejected,
+
+ /// The top rung's deadline passed without a decision.
+ Expired,
+}
+
+/// One approval request and its full decision trail.
+public sealed class GoldpathApprovalRequest
+{
+ /// Request identity.
+ public Guid Id { get; init; }
+
+ /// The ladder this request runs.
+ public required string Ladder { get; init; }
+
+ /// What is being approved (an adopter-meaningful subject key).
+ public required string Subject { get; init; }
+
+ /// The amount that routed the rung.
+ public decimal Amount { get; init; }
+
+ /// Who asked — the four-eyes rule bars this identity from deciding.
+ public required string RequestedBy { get; init; }
+
+ /// When the request was made.
+ public DateTimeOffset RequestedAt { get; init; }
+
+ /// The role currently expected to decide.
+ public required string PendingRole { get; set; }
+
+ /// When the current rung took the request (deadlines count from here).
+ public DateTimeOffset PendingSince { get; set; }
+
+ /// Lifecycle status.
+ public GoldpathApprovalStatus Status { get; set; }
+
+ /// Who decided, when terminal.
+ public string? DecidedBy { get; set; }
+
+ /// Decision reason, when terminal.
+ public string? Reason { get; set; }
+
+ /// Every lifecycle step, oldest first — the audit value e-mail never had.
+ public List Trail { get; } = [];
+}
+
+/// One audited lifecycle step.
+public sealed record GoldpathApprovalTrailEntry(DateTimeOffset At, string Actor, string Action, string Detail);
+
+/// An active delegation: From's pending items may be decided by To.
+public sealed record GoldpathApprovalDelegation(string From, string To, DateTimeOffset Until);
+
+/// An approval was requested and routed to a rung.
+public sealed record GoldpathApprovalRequested(Guid ApprovalId, string Ladder, string Subject, decimal Amount, string PendingRole) : IIntegrationEvent;
+
+/// An approval was granted — the guarded action may proceed.
+public sealed record GoldpathApprovalGranted(Guid ApprovalId, string Ladder, string Subject, string DecidedBy) : IIntegrationEvent;
+
+/// An approval was rejected.
+public sealed record GoldpathApprovalRejected(Guid ApprovalId, string Ladder, string Subject, string DecidedBy, string Reason) : IIntegrationEvent;
+
+/// A pending approval escalated one rung on deadline.
+public sealed record GoldpathApprovalEscalated(Guid ApprovalId, string Ladder, string Subject, string FromRole, string ToRole) : IIntegrationEvent;
+
+/// A pending approval expired at the top rung.
+public sealed record GoldpathApprovalExpired(Guid ApprovalId, string Ladder, string Subject, string AtRole) : IIntegrationEvent;
diff --git a/packages/Goldpath.Approvals/GoldpathApprovalsExtensions.cs b/packages/Goldpath.Approvals/GoldpathApprovalsExtensions.cs
new file mode 100644
index 0000000..06f113c
--- /dev/null
+++ b/packages/Goldpath.Approvals/GoldpathApprovalsExtensions.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+
+namespace Goldpath;
+
+///
+/// Composition entry point. Declare the ladders with AddGoldpathApprovals; schedule
+/// the escalation sweep through the Jobs module; the events publish through the messaging
+/// seam when a broker is composed (and stay silent when not).
+///
+public static class GoldpathApprovalsExtensions
+{
+ /// Registers the approvals engine and the declared ladders.
+ public static TBuilder AddGoldpathApprovals(this TBuilder builder, Action configure)
+ where TBuilder : IHostApplicationBuilder
+ {
+ var options = new GoldpathApprovalsOptions();
+ builder.Configuration.GetSection("Goldpath:Approvals").Bind(options);
+ configure(options);
+ builder.Services.AddSingleton(options);
+ builder.Services.TryAddSingleton(TimeProvider.System);
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+ return builder;
+ }
+}
diff --git a/packages/Goldpath.Approvals/GoldpathApprovalsOptions.cs b/packages/Goldpath.Approvals/GoldpathApprovalsOptions.cs
new file mode 100644
index 0000000..01ff49f
--- /dev/null
+++ b/packages/Goldpath.Approvals/GoldpathApprovalsOptions.cs
@@ -0,0 +1,123 @@
+namespace Goldpath;
+
+///
+/// Module options: the declared approval ladders. Ladders are DATA (approvals RFC D2) —
+/// a new authority chain is a new declaration, never a fork of the engine.
+///
+public sealed class GoldpathApprovalsOptions
+{
+ internal Dictionary LadderMap { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /// How long a delegation may last at most (cycle guard's time bound).
+ public TimeSpan MaxDelegationWindow { get; set; } = TimeSpan.FromDays(14);
+
+ /// The declared ladders by name.
+ public IReadOnlyDictionary Ladders => LadderMap;
+
+ /// Declares one amount-laddered authority chain.
+ public GoldpathApprovalsOptions AddLadder(string name, Action configure)
+ {
+ var builder = new GoldpathApprovalLadderBuilder(name);
+ configure(builder);
+ LadderMap[name] = builder.Build();
+ return this;
+ }
+}
+
+/// A declared ladder: ordered rungs, lowest authority first.
+public sealed class GoldpathApprovalLadder
+{
+ internal GoldpathApprovalLadder(string name, IReadOnlyList rungs)
+ {
+ Name = name;
+ Rungs = rungs;
+ }
+
+ /// Ladder name (the key an approval request names).
+ public string Name { get; }
+
+ /// The rungs, lowest authority first; the last rung is unbounded.
+ public IReadOnlyList Rungs { get; }
+
+ /// The rung an amount routes to: the first whose ceiling covers it.
+ public GoldpathApprovalRung Route(decimal amount)
+ {
+ foreach (var rung in Rungs)
+ {
+ if (rung.UpToInclusive is null || amount <= rung.UpToInclusive)
+ {
+ return rung;
+ }
+ }
+
+ return Rungs[^1];
+ }
+
+ /// The rung above , or null at the top.
+ public GoldpathApprovalRung? Above(GoldpathApprovalRung rung)
+ {
+ var index = -1;
+ for (var i = 0; i < Rungs.Count; i++)
+ {
+ if (Rungs[i].Role == rung.Role)
+ {
+ index = i;
+ break;
+ }
+ }
+
+ return index >= 0 && index + 1 < Rungs.Count ? Rungs[index + 1] : null;
+ }
+}
+
+/// One authority rung: a role, its amount ceiling, and its decision deadline.
+public sealed record GoldpathApprovalRung(string Role, decimal? UpToInclusive, TimeSpan EscalateAfter);
+
+/// Fluent shape for one ladder.
+public sealed class GoldpathApprovalLadderBuilder
+{
+ private readonly string _name;
+ private readonly List _rungs = [];
+
+ 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)
+ {
+ _rungs.Add(new GoldpathApprovalRung(role, upToInclusive, escalateAfter));
+ return this;
+ }
+
+ /// Adds the top rung — unbounded ceiling; overdue at the top EXPIRES.
+ public GoldpathApprovalLadderBuilder TopRung(string role, TimeSpan escalateAfter)
+ {
+ _rungs.Add(new GoldpathApprovalRung(role, null, escalateAfter));
+ return this;
+ }
+
+ internal GoldpathApprovalLadder Build()
+ {
+ if (_rungs.Count == 0)
+ {
+ throw new InvalidOperationException($"Ladder '{_name}' declares no rungs — an authority chain must be modeled, never guessed.");
+ }
+
+ if (_rungs[^1].UpToInclusive is not null)
+ {
+ throw new InvalidOperationException($"Ladder '{_name}' has no TopRung — every amount must route somewhere (declare the unbounded rung).");
+ }
+
+ decimal previous = 0;
+ foreach (var ceiling in _rungs.Select(r => r.UpToInclusive).OfType())
+ {
+ if (ceiling <= previous)
+ {
+ throw new InvalidOperationException($"Ladder '{_name}' ceilings must strictly increase.");
+ }
+
+ previous = ceiling;
+ }
+
+ return new GoldpathApprovalLadder(_name, _rungs.ToList());
+ }
+}
diff --git a/packages/Goldpath.Approvals/PublicAPI.Shipped.txt b/packages/Goldpath.Approvals/PublicAPI.Shipped.txt
new file mode 100644
index 0000000..7dc5c58
--- /dev/null
+++ b/packages/Goldpath.Approvals/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt b/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt
new file mode 100644
index 0000000..d8d50a1
--- /dev/null
+++ b/packages/Goldpath.Approvals/PublicAPI.Unshipped.txt
@@ -0,0 +1,157 @@
+#nullable enable
+Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalDecisionOutcome.Applied = 0 -> Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalDecisionOutcome.FourEyesViolation = 3 -> Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalDecisionOutcome.NotFound = 1 -> Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalDecisionOutcome.NotPending = 2 -> Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalDecisionOutcome.WrongRole = 4 -> Goldpath.GoldpathApprovalDecisionOutcome
+Goldpath.GoldpathApprovalDelegation
+Goldpath.GoldpathApprovalDelegation.From.get -> string!
+Goldpath.GoldpathApprovalDelegation.From.init -> void
+Goldpath.GoldpathApprovalDelegation.GoldpathApprovalDelegation(string! From, string! To, System.DateTimeOffset Until) -> void
+Goldpath.GoldpathApprovalDelegation.To.get -> string!
+Goldpath.GoldpathApprovalDelegation.To.init -> void
+Goldpath.GoldpathApprovalDelegation.Until.get -> System.DateTimeOffset
+Goldpath.GoldpathApprovalDelegation.Until.init -> void
+Goldpath.GoldpathApprovalEngine
+Goldpath.GoldpathApprovalEngine.DecideAsync(System.Guid id, string! decidedBy, string! deciderRole, bool granted, string! reason, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathApprovalEngine.DelegateAsync(string! from, string! to, System.TimeSpan window, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathApprovalEngine.EscalateOverdueAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathApprovalEngine.GoldpathApprovalEngine(Goldpath.GoldpathApprovalsOptions! options, Goldpath.IGoldpathApprovalStore! store, System.TimeProvider! time, Microsoft.Extensions.Logging.ILogger! logger, Goldpath.IIntegrationEventPublisher? publisher = null) -> void
+Goldpath.GoldpathApprovalEngine.RequestAsync(string! ladderName, string! subject, decimal amount, string! requestedBy, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathApprovalEngine.WorklistAsync(string! identity, string! role, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Goldpath.GoldpathApprovalEscalated
+Goldpath.GoldpathApprovalEscalated.ApprovalId.get -> System.Guid
+Goldpath.GoldpathApprovalEscalated.ApprovalId.init -> void
+Goldpath.GoldpathApprovalEscalated.FromRole.get -> string!
+Goldpath.GoldpathApprovalEscalated.FromRole.init -> void
+Goldpath.GoldpathApprovalEscalated.GoldpathApprovalEscalated(System.Guid ApprovalId, string! Ladder, string! Subject, string! FromRole, string! ToRole) -> void
+Goldpath.GoldpathApprovalEscalated.Ladder.get -> string!
+Goldpath.GoldpathApprovalEscalated.Ladder.init -> void
+Goldpath.GoldpathApprovalEscalated.Subject.get -> string!
+Goldpath.GoldpathApprovalEscalated.Subject.init -> void
+Goldpath.GoldpathApprovalEscalated.ToRole.get -> string!
+Goldpath.GoldpathApprovalEscalated.ToRole.init -> void
+Goldpath.GoldpathApprovalExpired
+Goldpath.GoldpathApprovalExpired.ApprovalId.get -> System.Guid
+Goldpath.GoldpathApprovalExpired.ApprovalId.init -> void
+Goldpath.GoldpathApprovalExpired.AtRole.get -> string!
+Goldpath.GoldpathApprovalExpired.AtRole.init -> void
+Goldpath.GoldpathApprovalExpired.GoldpathApprovalExpired(System.Guid ApprovalId, string! Ladder, string! Subject, string! AtRole) -> void
+Goldpath.GoldpathApprovalExpired.Ladder.get -> string!
+Goldpath.GoldpathApprovalExpired.Ladder.init -> void
+Goldpath.GoldpathApprovalExpired.Subject.get -> string!
+Goldpath.GoldpathApprovalExpired.Subject.init -> void
+Goldpath.GoldpathApprovalGranted
+Goldpath.GoldpathApprovalGranted.ApprovalId.get -> System.Guid
+Goldpath.GoldpathApprovalGranted.ApprovalId.init -> void
+Goldpath.GoldpathApprovalGranted.DecidedBy.get -> string!
+Goldpath.GoldpathApprovalGranted.DecidedBy.init -> void
+Goldpath.GoldpathApprovalGranted.GoldpathApprovalGranted(System.Guid ApprovalId, string! Ladder, string! Subject, string! DecidedBy) -> void
+Goldpath.GoldpathApprovalGranted.Ladder.get -> string!
+Goldpath.GoldpathApprovalGranted.Ladder.init -> void
+Goldpath.GoldpathApprovalGranted.Subject.get -> string!
+Goldpath.GoldpathApprovalGranted.Subject.init -> void
+Goldpath.GoldpathApprovalLadder
+Goldpath.GoldpathApprovalLadder.Above(Goldpath.GoldpathApprovalRung! rung) -> Goldpath.GoldpathApprovalRung?
+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.GoldpathApprovalRejected
+Goldpath.GoldpathApprovalRejected.ApprovalId.get -> System.Guid
+Goldpath.GoldpathApprovalRejected.ApprovalId.init -> void
+Goldpath.GoldpathApprovalRejected.DecidedBy.get -> string!
+Goldpath.GoldpathApprovalRejected.DecidedBy.init -> void
+Goldpath.GoldpathApprovalRejected.GoldpathApprovalRejected(System.Guid ApprovalId, string! Ladder, string! Subject, string! DecidedBy, string! Reason) -> void
+Goldpath.GoldpathApprovalRejected.Ladder.get -> string!
+Goldpath.GoldpathApprovalRejected.Ladder.init -> void
+Goldpath.GoldpathApprovalRejected.Reason.get -> string!
+Goldpath.GoldpathApprovalRejected.Reason.init -> void
+Goldpath.GoldpathApprovalRejected.Subject.get -> string!
+Goldpath.GoldpathApprovalRejected.Subject.init -> void
+Goldpath.GoldpathApprovalRequest
+Goldpath.GoldpathApprovalRequest.Amount.get -> decimal
+Goldpath.GoldpathApprovalRequest.Amount.init -> void
+Goldpath.GoldpathApprovalRequest.DecidedBy.get -> string?
+Goldpath.GoldpathApprovalRequest.DecidedBy.set -> void
+Goldpath.GoldpathApprovalRequest.GoldpathApprovalRequest() -> void
+Goldpath.GoldpathApprovalRequest.Id.get -> System.Guid
+Goldpath.GoldpathApprovalRequest.Id.init -> void
+Goldpath.GoldpathApprovalRequest.Ladder.get -> string!
+Goldpath.GoldpathApprovalRequest.Ladder.init -> void
+Goldpath.GoldpathApprovalRequest.PendingRole.get -> string!
+Goldpath.GoldpathApprovalRequest.PendingRole.set -> void
+Goldpath.GoldpathApprovalRequest.PendingSince.get -> System.DateTimeOffset
+Goldpath.GoldpathApprovalRequest.PendingSince.set -> void
+Goldpath.GoldpathApprovalRequest.Reason.get -> string?
+Goldpath.GoldpathApprovalRequest.Reason.set -> void
+Goldpath.GoldpathApprovalRequest.RequestedAt.get -> System.DateTimeOffset
+Goldpath.GoldpathApprovalRequest.RequestedAt.init -> void
+Goldpath.GoldpathApprovalRequest.RequestedBy.get -> string!
+Goldpath.GoldpathApprovalRequest.RequestedBy.init -> void
+Goldpath.GoldpathApprovalRequest.Status.get -> Goldpath.GoldpathApprovalStatus
+Goldpath.GoldpathApprovalRequest.Status.set -> void
+Goldpath.GoldpathApprovalRequest.Subject.get -> string!
+Goldpath.GoldpathApprovalRequest.Subject.init -> void
+Goldpath.GoldpathApprovalRequest.Trail.get -> System.Collections.Generic.List!
+Goldpath.GoldpathApprovalRequested
+Goldpath.GoldpathApprovalRequested.Amount.get -> decimal
+Goldpath.GoldpathApprovalRequested.Amount.init -> void
+Goldpath.GoldpathApprovalRequested.ApprovalId.get -> System.Guid
+Goldpath.GoldpathApprovalRequested.ApprovalId.init -> void
+Goldpath.GoldpathApprovalRequested.GoldpathApprovalRequested(System.Guid ApprovalId, string! Ladder, string! Subject, decimal Amount, string! PendingRole) -> void
+Goldpath.GoldpathApprovalRequested.Ladder.get -> string!
+Goldpath.GoldpathApprovalRequested.Ladder.init -> void
+Goldpath.GoldpathApprovalRequested.PendingRole.get -> string!
+Goldpath.GoldpathApprovalRequested.PendingRole.init -> void
+Goldpath.GoldpathApprovalRequested.Subject.get -> string!
+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.Role.get -> string!
+Goldpath.GoldpathApprovalRung.Role.init -> void
+Goldpath.GoldpathApprovalRung.UpToInclusive.get -> decimal?
+Goldpath.GoldpathApprovalRung.UpToInclusive.init -> void
+Goldpath.GoldpathApprovalsExtensions
+Goldpath.GoldpathApprovalsOptions
+Goldpath.GoldpathApprovalsOptions.AddLadder(string! name, System.Action! configure) -> Goldpath.GoldpathApprovalsOptions!
+Goldpath.GoldpathApprovalsOptions.GoldpathApprovalsOptions() -> void
+Goldpath.GoldpathApprovalsOptions.Ladders.get -> System.Collections.Generic.IReadOnlyDictionary!
+Goldpath.GoldpathApprovalsOptions.MaxDelegationWindow.get -> System.TimeSpan
+Goldpath.GoldpathApprovalsOptions.MaxDelegationWindow.set -> void
+Goldpath.GoldpathApprovalStatus
+Goldpath.GoldpathApprovalStatus.Expired = 3 -> Goldpath.GoldpathApprovalStatus
+Goldpath.GoldpathApprovalStatus.Granted = 1 -> Goldpath.GoldpathApprovalStatus
+Goldpath.GoldpathApprovalStatus.Pending = 0 -> Goldpath.GoldpathApprovalStatus
+Goldpath.GoldpathApprovalStatus.Rejected = 2 -> Goldpath.GoldpathApprovalStatus
+Goldpath.GoldpathApprovalTrailEntry
+Goldpath.GoldpathApprovalTrailEntry.Action.get -> string!
+Goldpath.GoldpathApprovalTrailEntry.Action.init -> void
+Goldpath.GoldpathApprovalTrailEntry.Actor.get -> string!
+Goldpath.GoldpathApprovalTrailEntry.Actor.init -> void
+Goldpath.GoldpathApprovalTrailEntry.At.get -> System.DateTimeOffset
+Goldpath.GoldpathApprovalTrailEntry.At.init -> void
+Goldpath.GoldpathApprovalTrailEntry.Detail.get -> string!
+Goldpath.GoldpathApprovalTrailEntry.Detail.init -> void
+Goldpath.GoldpathApprovalTrailEntry.GoldpathApprovalTrailEntry(System.DateTimeOffset At, string! Actor, string! Action, string! Detail) -> void
+Goldpath.GoldpathInMemoryApprovalStore
+Goldpath.GoldpathInMemoryApprovalStore.AddAsync(Goldpath.GoldpathApprovalRequest! request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathInMemoryApprovalStore.AddDelegationAsync(Goldpath.GoldpathApprovalDelegation! delegation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathInMemoryApprovalStore.GetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathInMemoryApprovalStore.GetDelegationsAsync(System.DateTimeOffset now, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Goldpath.GoldpathInMemoryApprovalStore.GetPendingAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Goldpath.GoldpathInMemoryApprovalStore.GoldpathInMemoryApprovalStore() -> void
+Goldpath.GoldpathInMemoryApprovalStore.UpdateAsync(Goldpath.GoldpathApprovalRequest! request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.IGoldpathApprovalStore
+Goldpath.IGoldpathApprovalStore.AddAsync(Goldpath.GoldpathApprovalRequest! request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.IGoldpathApprovalStore.AddDelegationAsync(Goldpath.GoldpathApprovalDelegation! delegation, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.IGoldpathApprovalStore.GetAsync(System.Guid id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.IGoldpathApprovalStore.GetDelegationsAsync(System.DateTimeOffset now, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+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
diff --git a/packages/Goldpath.Approvals/README.md b/packages/Goldpath.Approvals/README.md
new file mode 100644
index 0000000..2bc5d3b
--- /dev/null
+++ b/packages/Goldpath.Approvals/README.md
@@ -0,0 +1,37 @@
+# Goldpath.Approvals
+
+Human approval workflows as a composable Ring B module: amount-laddered authority chains
+**declared as data**, four-eyes and maker-checker enforcement, bounded delegation, deadline
+escalation, and the worklist — with every lifecycle step audited and published as
+integration events. This module replaces the e-mail approval thread, not the humans in it.
+
+```csharp
+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))
+ .TopRung("general-manager", TimeSpan.FromHours(24))));
+```
+
+- **Request** — `engine.RequestAsync("credit-limit", subject, amount, requestedBy)` routes
+ the amount to its rung and starts the trail.
+- **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.
+- **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
+ the top rung expires. Schedule it through the Jobs module.
+- **Worklist** — `WorklistAsync(identity, role)`: what this person may decide, oldest first.
+
+Events (`GoldpathApprovalRequested/Granted/Rejected/Escalated/Expired`) carry the
+`IIntegrationEvent` marker and publish through the messaging seam when a broker is
+composed — and stay silent when not.
+
+State lives behind `IGoldpathApprovalStore`; the in-memory store ships for tests and
+single-node hosts, a database-backed store composes through the seam.
+
+This is a saga's counterpart, not its competitor: a compensating flow orchestrates
+SYSTEMS and may request an approval as one of its steps; Approvals never drives system
+steps itself (RFC D3).
diff --git a/packages/Goldpath.Approvals/ops/approvals.md b/packages/Goldpath.Approvals/ops/approvals.md
new file mode 100644
index 0000000..064fce4
--- /dev/null
+++ b/packages/Goldpath.Approvals/ops/approvals.md
@@ -0,0 +1,28 @@
+# Approvals — Ops Runbook
+
+## "Why is this request still pending" triage
+1. Read the request's TRAIL (`GetAsync` / the admin surface): every routing, escalation and
+ delegation step is in it with its actor and timestamp — the answer is usually the last line.
+2. Pending age counts from `PendingSince` (it RESETS on escalation — a rung's deadline is that
+ rung's, not the request's lifetime). Compare against the ladder's `EscalateAfter` per rung.
+3. If nothing escalates at all: the sweep is a scheduled job — check the Jobs console for the
+ escalation job's last run before suspecting the engine.
+
+## Decision refused — outcome decoding
+- `FourEyesViolation` — the requester tried to decide their own request. Not a bug, the rule.
+- `WrongRole` — decider holds neither the pending rung's role nor an active delegation.
+ Check delegation expiry (`Until` is absolute UTC) before re-granting roles.
+- `NotPending` — someone else decided first, or it expired. The trail says which.
+
+## Escalation storms
+A spike of `GoldpathApprovalEscalated` events means a rung stopped deciding (vacation,
+role change, worklist not being watched). Delegate that rung's holder or shorten nothing —
+fix the staffing; the ladder's deadlines are the SLA you declared.
+
+## Expiries
+`GoldpathApprovalExpired` at the top rung is a governance signal, never noise: the request
+needed the highest authority and did not get it in time. Alarm on expiry count > 0.
+
+## Store
+The in-memory store loses state on restart — it is for tests and single-node demos.
+Compose a database-backed `IGoldpathApprovalStore` before production.
diff --git a/packages/Goldpath.FileExchange/Goldpath.FileExchange.csproj b/packages/Goldpath.FileExchange/Goldpath.FileExchange.csproj
new file mode 100644
index 0000000..2710fe7
--- /dev/null
+++ b/packages/Goldpath.FileExchange/Goldpath.FileExchange.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0;net10.0
+ Goldpath.FileExchange
+ Ring B file-based integration rails for the Goldpath enterprise asset: rails declared as data with baked, compile-checked closures — file-level contracts, idempotent (file, line) ingestion, per-row quarantine that never stops the batch, zero-duplicate replay/reprocess, and archive marks — with the lifecycle published as integration events. The rail is the unit, not the pieces.
+ Goldpath
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/Goldpath.FileExchange/GoldpathFileExchangeExtensions.cs b/packages/Goldpath.FileExchange/GoldpathFileExchangeExtensions.cs
new file mode 100644
index 0000000..28ee780
--- /dev/null
+++ b/packages/Goldpath.FileExchange/GoldpathFileExchangeExtensions.cs
@@ -0,0 +1,25 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+
+namespace Goldpath;
+
+///
+/// Composition entry point. Declare the rails with AddGoldpathFileExchange; schedule
+/// pick-up through the Jobs module; the events publish through the messaging seam when a
+/// broker is composed (and stay silent when not).
+///
+public static class GoldpathFileExchangeExtensions
+{
+ /// Registers the rail engine and the declared rails.
+ public static TBuilder AddGoldpathFileExchange(this TBuilder builder, Action configure)
+ where TBuilder : IHostApplicationBuilder
+ {
+ var options = new GoldpathFileExchangeOptions();
+ configure(options);
+ builder.Services.AddSingleton(options);
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+ return builder;
+ }
+}
diff --git a/packages/Goldpath.FileExchange/GoldpathFileExchangeOptions.cs b/packages/Goldpath.FileExchange/GoldpathFileExchangeOptions.cs
new file mode 100644
index 0000000..8bca021
--- /dev/null
+++ b/packages/Goldpath.FileExchange/GoldpathFileExchangeOptions.cs
@@ -0,0 +1,141 @@
+namespace Goldpath;
+
+///
+/// Module options: the declared file rails. A rail is DATA plus baked, compile-checked
+/// closures (fileexchange RFC D3) — a new counterparty is a new declaration, never new
+/// engine code.
+///
+public sealed class GoldpathFileExchangeOptions
+{
+ internal Dictionary RailMap { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /// The declared rails by name.
+ public IReadOnlyDictionary Rails => RailMap;
+
+ /// Declares one inbound file rail for rows of .
+ public GoldpathFileExchangeOptions AddRail(string name, Action> configure)
+ {
+ var builder = new GoldpathFileRailBuilder(name);
+ configure(builder);
+ RailMap[name] = builder.Build();
+ return this;
+ }
+}
+
+/// A declared rail with its execution baked as typed closures at registration time.
+public sealed class GoldpathFileRailDefinition
+{
+ internal GoldpathFileRailDefinition(string name)
+ {
+ Name = name;
+ }
+
+ /// Rail name (the key a file names on arrival).
+ public string Name { get; }
+
+ /// Lines skipped before data starts (a declared header).
+ public int HeaderLines { get; internal set; }
+
+ internal Func, string?> ValidateFileCore { get; set; } = _ => null;
+ internal Func> ProcessRowCore { get; set; } = null!;
+}
+
+/// Fluent shape for one rail.
+public sealed class GoldpathFileRailBuilder
+{
+ private readonly string _name;
+ private int _headerLines;
+ private Func, string?>? _validateFile;
+ private Func? _parse;
+ private Func? _validateRow;
+ private Func? _handle;
+
+ internal GoldpathFileRailBuilder(string name) => _name = name;
+
+ /// Declares header lines to skip before data rows begin.
+ public GoldpathFileRailBuilder Header(int lines)
+ {
+ _headerLines = lines;
+ return this;
+ }
+
+ /// File-level contract: return a reason to REJECT the whole file (truncation,
+ /// trailer-count mismatch), or null to accept. A rejected file ingests nothing.
+ public GoldpathFileRailBuilder ValidateFile(Func, string?> validate)
+ {
+ _validateFile = validate;
+ return this;
+ }
+
+ /// Parses one data line; a throw here quarantines the row.
+ public GoldpathFileRailBuilder ParseLine(Func parse)
+ {
+ _parse = parse;
+ return this;
+ }
+
+ /// Row-level contract: return a reason to QUARANTINE the row, or null.
+ public GoldpathFileRailBuilder ValidateRow(Func validate)
+ {
+ _validateRow = validate;
+ return this;
+ }
+
+ /// The business handler for one accepted row; a throw here quarantines it.
+ public GoldpathFileRailBuilder Handle(Func handle)
+ {
+ _handle = handle;
+ return this;
+ }
+
+ internal GoldpathFileRailDefinition Build()
+ {
+ if (_parse is null || _handle is null)
+ {
+ throw new InvalidOperationException($"Rail '{_name}' needs ParseLine(...) and Handle(...) — the contract must be modeled, never guessed.");
+ }
+
+ var parse = _parse;
+ var validateRow = _validateRow;
+ var handle = _handle;
+
+ var definition = new GoldpathFileRailDefinition(_name) { HeaderLines = _headerLines };
+ if (_validateFile is not null)
+ {
+ definition.ValidateFileCore = _validateFile;
+ }
+
+ // Baked per-row pipeline: parse -> validate -> handle. Returns a quarantine
+ // reason, or null when the row applied.
+ definition.ProcessRowCore = async (line, ct) =>
+ {
+ TRow row;
+ try
+ {
+ row = parse(line);
+ }
+ catch (Exception ex)
+ {
+ return $"parse: {ex.Message}";
+ }
+
+ if (validateRow?.Invoke(row) is { } reason)
+ {
+ return reason;
+ }
+
+ try
+ {
+ await handle(row, ct);
+ }
+ catch (Exception ex)
+ {
+ return $"handle: {ex.Message}";
+ }
+
+ return null;
+ };
+
+ return definition;
+ }
+}
diff --git a/packages/Goldpath.FileExchange/GoldpathFileRailEngine.cs b/packages/Goldpath.FileExchange/GoldpathFileRailEngine.cs
new file mode 100644
index 0000000..f746d9c
--- /dev/null
+++ b/packages/Goldpath.FileExchange/GoldpathFileRailEngine.cs
@@ -0,0 +1,210 @@
+using Microsoft.Extensions.Logging;
+
+namespace Goldpath;
+
+/// Where rail progress lives: processed row keys, quarantine records, archive
+/// marks. The in-memory ledger ships for tests and single-node hosts; a database-backed
+/// ledger composes through this seam.
+public interface IGoldpathFileLedger
+{
+ /// Whether a row already applied — the (rail, file, line) idempotency key.
+ Task IsProcessedAsync(string rail, string file, int line, CancellationToken cancellationToken = default);
+
+ /// Marks a row applied.
+ Task MarkProcessedAsync(string rail, string file, int line, CancellationToken cancellationToken = default);
+
+ /// Records a quarantined row with its reason (upserts on reprocess).
+ Task QuarantineAsync(string rail, string file, int line, string reason, CancellationToken cancellationToken = default);
+
+ /// Clears a row's quarantine record (it applied on reprocess).
+ Task ReleaseQuarantineAsync(string rail, string file, int line, CancellationToken cancellationToken = default);
+
+ /// The current quarantine list for a file.
+ Task> GetQuarantineAsync(string rail, string file, CancellationToken cancellationToken = default);
+
+ /// Marks the file archived (retention is the Archival module's business).
+ Task MarkArchivedAsync(string rail, string file, CancellationToken cancellationToken = default);
+}
+
+/// One quarantined row: where and why.
+public sealed record GoldpathQuarantinedRow(string Rail, string File, int Line, string Reason);
+
+/// The outcome of one rail run over one file.
+public sealed record GoldpathFileRailResult(
+ string Rail,
+ string File,
+ string? FileRejectedReason,
+ int Processed,
+ int SkippedAsDuplicate,
+ IReadOnlyList Quarantined);
+
+/// A file arrived on a rail.
+public sealed record GoldpathFileReceived(string Rail, string File, int DataRows) : IIntegrationEvent;
+
+/// A rail run finished ingesting a file.
+public sealed record GoldpathFileIngested(string Rail, string File, int Processed, int SkippedAsDuplicate, int Quarantined) : IIntegrationEvent;
+
+/// A rail run quarantined rows — the batch CONTINUED (that is the point).
+public sealed record GoldpathRowsQuarantined(string Rail, string File, int Count) : IIntegrationEvent;
+
+/// A file failed its file-level contract and ingested NOTHING.
+public sealed record GoldpathFileRejected(string Rail, string File, string Reason) : IIntegrationEvent;
+
+///
+/// The rail engine: validate the file, then per row — dedup on (rail, file, line),
+/// parse/validate/handle, quarantine on failure WITHOUT stopping the batch. Re-running
+/// the same file (redelivery or reprocess) applies zero duplicates and retries only what
+/// quarantined. Scheduled pick-up rides the Jobs module; this engine owns one run.
+///
+public sealed class GoldpathFileRailEngine
+{
+ private readonly GoldpathFileExchangeOptions _options;
+ private readonly IGoldpathFileLedger _ledger;
+ private readonly ILogger _logger;
+ private readonly IIntegrationEventPublisher? _publisher;
+
+ /// Creates the engine (the publisher is optional — no broker, no events).
+ public GoldpathFileRailEngine(
+ GoldpathFileExchangeOptions options,
+ IGoldpathFileLedger ledger,
+ ILogger logger,
+ IIntegrationEventPublisher? publisher = null)
+ {
+ _options = options;
+ _ledger = ledger;
+ _logger = logger;
+ _publisher = publisher;
+ }
+
+ /// Runs one file through its rail. Idempotent per (rail, file, line).
+ public async Task ProcessAsync(string railName, string file, IReadOnlyList lines, CancellationToken cancellationToken = default)
+ {
+ if (!_options.Rails.TryGetValue(railName, out var rail))
+ {
+ throw new InvalidOperationException($"Rail '{railName}' is not declared — files ingest on declared rails only.");
+ }
+
+ if (rail.ValidateFileCore(lines) is { } fileReason)
+ {
+ _logger.LogWarning("Rail {Rail} rejected file {File}: {Reason}", rail.Name, file, fileReason);
+ await PublishAsync(new GoldpathFileRejected(rail.Name, file, fileReason), cancellationToken);
+ return new GoldpathFileRailResult(rail.Name, file, fileReason, 0, 0, []);
+ }
+
+ var dataRows = lines.Count - rail.HeaderLines;
+ await PublishAsync(new GoldpathFileReceived(rail.Name, file, dataRows), cancellationToken);
+
+ var processed = 0;
+ var skipped = 0;
+ var quarantined = new List();
+ for (var i = rail.HeaderLines; i < lines.Count; i++)
+ {
+ var lineNo = i + 1; // 1-based, matches what operators see in the file
+ if (await _ledger.IsProcessedAsync(rail.Name, file, lineNo, cancellationToken))
+ {
+ skipped++;
+ continue;
+ }
+
+ if (await rail.ProcessRowCore(lines[i], cancellationToken) is { } reason)
+ {
+ await _ledger.QuarantineAsync(rail.Name, file, lineNo, reason, cancellationToken);
+ quarantined.Add(new GoldpathQuarantinedRow(rail.Name, file, lineNo, reason));
+ continue; // the batch does NOT stop — quarantine is per row
+ }
+
+ await _ledger.MarkProcessedAsync(rail.Name, file, lineNo, cancellationToken);
+ await _ledger.ReleaseQuarantineAsync(rail.Name, file, lineNo, cancellationToken);
+ processed++;
+ }
+
+ if (quarantined.Count > 0)
+ {
+ await PublishAsync(new GoldpathRowsQuarantined(rail.Name, file, quarantined.Count), cancellationToken);
+ }
+
+ await _ledger.MarkArchivedAsync(rail.Name, file, cancellationToken);
+ await PublishAsync(new GoldpathFileIngested(rail.Name, file, processed, skipped, quarantined.Count), cancellationToken);
+ return new GoldpathFileRailResult(rail.Name, file, null, processed, skipped, quarantined);
+ }
+
+ private Task PublishAsync(TEvent integrationEvent, CancellationToken cancellationToken)
+ where TEvent : class, IIntegrationEvent
+ => _publisher?.PublishAsync(integrationEvent, cancellationToken) ?? Task.CompletedTask;
+}
+
+/// In-memory ledger: tests and single-node hosts; database ledgers compose via the seam.
+public sealed class GoldpathInMemoryFileLedger : IGoldpathFileLedger
+{
+ private readonly object _gate = new();
+ private readonly HashSet<(string Rail, string File, int Line)> _processed = [];
+ private readonly Dictionary<(string Rail, string File, int Line), string> _quarantine = [];
+ private readonly HashSet<(string Rail, string File)> _archived = [];
+
+ ///
+ public Task IsProcessedAsync(string rail, string file, int line, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ return Task.FromResult(_processed.Contains((rail, file, line)));
+ }
+ }
+
+ ///
+ public Task MarkProcessedAsync(string rail, string file, int line, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _processed.Add((rail, file, line));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task QuarantineAsync(string rail, string file, int line, string reason, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _quarantine[(rail, file, line)] = reason;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task ReleaseQuarantineAsync(string rail, string file, int line, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _quarantine.Remove((rail, file, line));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task> GetQuarantineAsync(string rail, string file, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ IReadOnlyList rows = _quarantine
+ .Where(kv => kv.Key.Rail == rail && kv.Key.File == file)
+ .OrderBy(kv => kv.Key.Line)
+ .Select(kv => new GoldpathQuarantinedRow(kv.Key.Rail, kv.Key.File, kv.Key.Line, kv.Value))
+ .ToList();
+ return Task.FromResult(rows);
+ }
+ }
+
+ ///
+ public Task MarkArchivedAsync(string rail, string file, CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ _archived.Add((rail, file));
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/packages/Goldpath.FileExchange/PublicAPI.Shipped.txt b/packages/Goldpath.FileExchange/PublicAPI.Shipped.txt
new file mode 100644
index 0000000..7dc5c58
--- /dev/null
+++ b/packages/Goldpath.FileExchange/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/packages/Goldpath.FileExchange/PublicAPI.Unshipped.txt b/packages/Goldpath.FileExchange/PublicAPI.Unshipped.txt
new file mode 100644
index 0000000..04c9ef2
--- /dev/null
+++ b/packages/Goldpath.FileExchange/PublicAPI.Unshipped.txt
@@ -0,0 +1,94 @@
+#nullable enable
+Goldpath.GoldpathFileExchangeExtensions
+Goldpath.GoldpathFileExchangeOptions
+Goldpath.GoldpathFileExchangeOptions.AddRail(string! name, System.Action!>! configure) -> Goldpath.GoldpathFileExchangeOptions!
+Goldpath.GoldpathFileExchangeOptions.GoldpathFileExchangeOptions() -> void
+Goldpath.GoldpathFileExchangeOptions.Rails.get -> System.Collections.Generic.IReadOnlyDictionary!
+Goldpath.GoldpathFileIngested
+Goldpath.GoldpathFileIngested.File.get -> string!
+Goldpath.GoldpathFileIngested.File.init -> void
+Goldpath.GoldpathFileIngested.GoldpathFileIngested(string! Rail, string! File, int Processed, int SkippedAsDuplicate, int Quarantined) -> void
+Goldpath.GoldpathFileIngested.Processed.get -> int
+Goldpath.GoldpathFileIngested.Processed.init -> void
+Goldpath.GoldpathFileIngested.Quarantined.get -> int
+Goldpath.GoldpathFileIngested.Quarantined.init -> void
+Goldpath.GoldpathFileIngested.Rail.get -> string!
+Goldpath.GoldpathFileIngested.Rail.init -> void
+Goldpath.GoldpathFileIngested.SkippedAsDuplicate.get -> int
+Goldpath.GoldpathFileIngested.SkippedAsDuplicate.init -> void
+Goldpath.GoldpathFileRailBuilder
+Goldpath.GoldpathFileRailBuilder.Handle(System.Func! handle) -> Goldpath.GoldpathFileRailBuilder!
+Goldpath.GoldpathFileRailBuilder.Header(int lines) -> Goldpath.GoldpathFileRailBuilder!
+Goldpath.GoldpathFileRailBuilder.ParseLine(System.Func! parse) -> Goldpath.GoldpathFileRailBuilder!
+Goldpath.GoldpathFileRailBuilder.ValidateFile(System.Func!, string?>! validate) -> Goldpath.GoldpathFileRailBuilder!
+Goldpath.GoldpathFileRailBuilder.ValidateRow(System.Func! validate) -> Goldpath.GoldpathFileRailBuilder!
+Goldpath.GoldpathFileRailDefinition
+Goldpath.GoldpathFileRailDefinition.HeaderLines.get -> int
+Goldpath.GoldpathFileRailDefinition.Name.get -> string!
+Goldpath.GoldpathFileRailEngine
+Goldpath.GoldpathFileRailEngine.GoldpathFileRailEngine(Goldpath.GoldpathFileExchangeOptions! options, Goldpath.IGoldpathFileLedger! ledger, Microsoft.Extensions.Logging.ILogger! logger, Goldpath.IIntegrationEventPublisher? publisher = null) -> void
+Goldpath.GoldpathFileRailEngine.ProcessAsync(string! railName, string! file, System.Collections.Generic.IReadOnlyList! lines, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathFileRailResult
+Goldpath.GoldpathFileRailResult.File.get -> string!
+Goldpath.GoldpathFileRailResult.File.init -> void
+Goldpath.GoldpathFileRailResult.FileRejectedReason.get -> string?
+Goldpath.GoldpathFileRailResult.FileRejectedReason.init -> void
+Goldpath.GoldpathFileRailResult.GoldpathFileRailResult(string! Rail, string! File, string? FileRejectedReason, int Processed, int SkippedAsDuplicate, System.Collections.Generic.IReadOnlyList! Quarantined) -> void
+Goldpath.GoldpathFileRailResult.Processed.get -> int
+Goldpath.GoldpathFileRailResult.Processed.init -> void
+Goldpath.GoldpathFileRailResult.Quarantined.get -> System.Collections.Generic.IReadOnlyList!
+Goldpath.GoldpathFileRailResult.Quarantined.init -> void
+Goldpath.GoldpathFileRailResult.Rail.get -> string!
+Goldpath.GoldpathFileRailResult.Rail.init -> void
+Goldpath.GoldpathFileRailResult.SkippedAsDuplicate.get -> int
+Goldpath.GoldpathFileRailResult.SkippedAsDuplicate.init -> void
+Goldpath.GoldpathFileReceived
+Goldpath.GoldpathFileReceived.DataRows.get -> int
+Goldpath.GoldpathFileReceived.DataRows.init -> void
+Goldpath.GoldpathFileReceived.File.get -> string!
+Goldpath.GoldpathFileReceived.File.init -> void
+Goldpath.GoldpathFileReceived.GoldpathFileReceived(string! Rail, string! File, int DataRows) -> void
+Goldpath.GoldpathFileReceived.Rail.get -> string!
+Goldpath.GoldpathFileReceived.Rail.init -> void
+Goldpath.GoldpathFileRejected
+Goldpath.GoldpathFileRejected.File.get -> string!
+Goldpath.GoldpathFileRejected.File.init -> void
+Goldpath.GoldpathFileRejected.GoldpathFileRejected(string! Rail, string! File, string! Reason) -> void
+Goldpath.GoldpathFileRejected.Rail.get -> string!
+Goldpath.GoldpathFileRejected.Rail.init -> void
+Goldpath.GoldpathFileRejected.Reason.get -> string!
+Goldpath.GoldpathFileRejected.Reason.init -> void
+Goldpath.GoldpathInMemoryFileLedger
+Goldpath.GoldpathInMemoryFileLedger.GetQuarantineAsync(string! rail, string! file, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Goldpath.GoldpathInMemoryFileLedger.GoldpathInMemoryFileLedger() -> void
+Goldpath.GoldpathInMemoryFileLedger.IsProcessedAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathInMemoryFileLedger.MarkArchivedAsync(string! rail, string! file, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathInMemoryFileLedger.MarkProcessedAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathInMemoryFileLedger.QuarantineAsync(string! rail, string! file, int line, string! reason, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathInMemoryFileLedger.ReleaseQuarantineAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.GoldpathQuarantinedRow
+Goldpath.GoldpathQuarantinedRow.File.get -> string!
+Goldpath.GoldpathQuarantinedRow.File.init -> void
+Goldpath.GoldpathQuarantinedRow.GoldpathQuarantinedRow(string! Rail, string! File, int Line, string! Reason) -> void
+Goldpath.GoldpathQuarantinedRow.Line.get -> int
+Goldpath.GoldpathQuarantinedRow.Line.init -> void
+Goldpath.GoldpathQuarantinedRow.Rail.get -> string!
+Goldpath.GoldpathQuarantinedRow.Rail.init -> void
+Goldpath.GoldpathQuarantinedRow.Reason.get -> string!
+Goldpath.GoldpathQuarantinedRow.Reason.init -> void
+Goldpath.GoldpathRowsQuarantined
+Goldpath.GoldpathRowsQuarantined.Count.get -> int
+Goldpath.GoldpathRowsQuarantined.Count.init -> void
+Goldpath.GoldpathRowsQuarantined.File.get -> string!
+Goldpath.GoldpathRowsQuarantined.File.init -> void
+Goldpath.GoldpathRowsQuarantined.GoldpathRowsQuarantined(string! Rail, string! File, int Count) -> void
+Goldpath.GoldpathRowsQuarantined.Rail.get -> string!
+Goldpath.GoldpathRowsQuarantined.Rail.init -> void
+Goldpath.IGoldpathFileLedger
+Goldpath.IGoldpathFileLedger.GetQuarantineAsync(string! rail, string! file, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Goldpath.IGoldpathFileLedger.IsProcessedAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.IGoldpathFileLedger.MarkArchivedAsync(string! rail, string! file, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Goldpath.IGoldpathFileLedger.MarkProcessedAsync(string! rail, string! file, int line, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+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
diff --git a/packages/Goldpath.FileExchange/README.md b/packages/Goldpath.FileExchange/README.md
new file mode 100644
index 0000000..cd9c9a0
--- /dev/null
+++ b/packages/Goldpath.FileExchange/README.md
@@ -0,0 +1,38 @@
+# Goldpath.FileExchange
+
+File-based integration rails as a **unit**: rails declared as data with baked,
+compile-checked closures — file-level contracts, idempotent `(file, line)` ingestion,
+per-row quarantine that never stops the batch, zero-duplicate replay/reprocess, and
+archive marks — with the lifecycle published as integration events. Jobs+Bulk cover the
+pieces; the recurring hand-written glue around them (validate → dedup → quarantine →
+archive → reprocess) is this module.
+
+```csharp
+builder.AddGoldpathFileExchange(files => files
+ .AddRail("registry-daily", rail => rail
+ .Header(1)
+ .ValidateFile(lines => TrailerCountMatches(lines) ? null : "truncated")
+ .ParseLine(line => RegistryRow.Parse(line))
+ .ValidateRow(row => row.Amount > 0 ? null : "non-positive amount")
+ .Handle((row, ct) => ApplyAsync(row, ct))));
+```
+
+- **File contract** — `ValidateFile` rejects the WHOLE file (truncation, trailer
+ mismatch); a rejected file ingests nothing and publishes `GoldpathFileRejected`.
+- **Idempotent ingestion** — the `(rail, file, line)` key: a re-delivered or replayed
+ file applies zero duplicates.
+- **Quarantine** — a bad row (parse throw, row-contract failure, handler throw)
+ quarantines with its reason; the batch CONTINUES. `GoldpathRowsQuarantined` fires once
+ per run.
+- **Reprocess** — run the file again: good rows dedup, fixed rows apply and their
+ quarantine records clear.
+- **Archive mark** — every completed run marks the file archived; retention/purge is the
+ Archival module's business.
+
+Progress lives behind `IGoldpathFileLedger`; the in-memory ledger ships for tests and
+single-node hosts, a database-backed ledger composes through the seam. Scheduled pick-up
+and drop ride the Jobs module; transports (SFTP, share, object store) are composed
+adapters, never rewritten (RFC §1).
+
+Boundary with qorpe.sync: Sync migrates and reconciles STORES during a transformation;
+FileExchange is the PERMANENT integration surface with counterparties (RFC D4).
diff --git a/packages/Goldpath.FileExchange/ops/fileexchange.md b/packages/Goldpath.FileExchange/ops/fileexchange.md
new file mode 100644
index 0000000..6503031
--- /dev/null
+++ b/packages/Goldpath.FileExchange/ops/fileexchange.md
@@ -0,0 +1,31 @@
+# FileExchange — Ops Runbook
+
+## "The counterparty says they sent it" triage
+1. `GoldpathFileReceived` fired? If not, the file never reached the rail — check the
+ pick-up job's last run in the Jobs console before suspecting the engine.
+2. `GoldpathFileRejected` instead? The file failed its FILE-level contract (truncation,
+ trailer mismatch) and ingested NOTHING — the reason is on the event and in the log.
+ Ask the counterparty to resend; do not hand-edit the file.
+
+## Quarantine depth and age
+- Quarantined rows carry their reason and 1-based line number (what the operator sees in
+ the file). A rising quarantine count on one rail is a counterparty format drift —
+ compare reasons before opening N tickets for one cause.
+- Reprocessing is safe BY CONSTRUCTION: run the file again — applied rows dedup on the
+ `(rail, file, line)` key, fixed rows apply and their quarantine records clear. Zero
+ duplicates is the tested invariant, not an aspiration.
+
+## Duplicate-file storms
+`SkippedAsDuplicate == row count` means the transport re-delivered a file already
+ingested. That is the rail working as designed — alarm only if the SOURCE generates
+distinct files with identical names (then the file naming, not the rail, is the bug).
+
+## Missed arrival windows
+The rail engine owns one run; SCHEDULES are Jobs-module business. Alarm on the pick-up
+job's deadline, not on the absence of events — an empty day and a dead schedule look the
+same from the event stream alone.
+
+## Ledger
+The in-memory ledger loses state on restart — tests and single-node demos only. Compose a
+database-backed `IGoldpathFileLedger` before production; the idempotency guarantee is
+only as durable as the ledger under it.
diff --git a/schemas/manifest/v1/goldpath-manifest.schema.json b/schemas/manifest/v1/goldpath-manifest.schema.json
index d865472..8067fe2 100644
--- a/schemas/manifest/v1/goldpath-manifest.schema.json
+++ b/schemas/manifest/v1/goldpath-manifest.schema.json
@@ -320,7 +320,7 @@
"oracle"
],
"default": "postgresql",
- "$comment": "postgresql and sqlserver are WIRED (template + GM matrix). `oracle` is SCHEMA-ONLY \u2014 accepted here so a manifest can declare intent, but no EF provider ships (open thread T16: the first committed Oracle-mandating adopter). Declaring it does not make it run."
+ "$comment": "postgresql and sqlserver are WIRED (template + GM matrix). `oracle` is SCHEMA-ONLY — accepted here so a manifest can declare intent, but no EF provider ships (open thread T16: the first committed Oracle-mandating adopter). Declaring it does not make it run."
},
"cache": {
"enum": [
@@ -337,7 +337,7 @@
"none"
],
"default": "rabbitmq",
- "$comment": "rabbitmq and none are WIRED. `kafka` and `inmemory` are SCHEMA-ONLY \u2014 no transport wiring ships for either (issue #11 carries the Kafka rider, triggered by the 30M-scale campaign)."
+ "$comment": "rabbitmq and none are WIRED. `kafka` and `inmemory` are SCHEMA-ONLY — no transport wiring ships for either (issue #11 carries the Kafka rider, triggered by the 30M-scale campaign)."
},
"auth": {
"enum": [
@@ -441,7 +441,7 @@
"eventstream"
],
"default": "database",
- "$comment": "`database` is WIRED. `eventstream` is RESERVED \u2014 a written deferral in the AuditTrail RFC, no code path ships."
+ "$comment": "`database` is WIRED. `eventstream` is RESERVED — a written deferral in the AuditTrail RFC, no code path ships."
}
},
"additionalProperties": false
@@ -606,6 +606,14 @@
"campaign": {
"$ref": "#/$defs/toggle",
"description": "Governed mass-execution (L4): durable target plan, single-leader pacing under a LIVE policy (TPS/quota/window/max-in-flight), claim-before-execute broker fan-out, batching outcome sink, audited verbs. Runs on the jobs module and REQUIRES a broker (rule below)."
+ },
+ "approvals": {
+ "$ref": "#/$defs/toggle",
+ "description": "Human approval workflows: amount-laddered authority chains declared as data, four-eyes/maker-checker enforcement, bounded delegation, deadline escalation, and the worklist — every lifecycle step audited and published as integration events."
+ },
+ "fileExchange": {
+ "$ref": "#/$defs/toggle",
+ "description": "File-based integration rails: rails declared as data — file-level contracts, idempotent (file, line) ingestion, per-row quarantine that never stops the batch, zero-duplicate replay/reprocess, archive marks. Scheduled pick-up rides the jobs module."
}
},
"additionalProperties": false
@@ -759,7 +767,7 @@
},
"products": {
"type": "array",
- "description": "First-party product modules riding the platform (ADR-0012; platform RFC D2). Every entry is NAMED with a NAMESPACED vendor.product id (e.g. qorpe.sync) so a future core feature can never collide with a vendor key; the core `features` enumeration stays closed. An array of named entries rather than a map because the engine's never-guess schema vocabulary has no patternProperties \u2014 the namespace is enforced by `pattern` on `name`. Each product's own RFC refines its options; `enabled: false` means the product is absent from the composition (ADR-0001 toggle semantics). DECISION (review R5 on the introducing PR): duplicate names are INVALID, but the engine's vocabulary cannot express name-uniqueness across array entries (uniqueItems compares whole objects) \u2014 the refusal lives in the composition layer (goldpath CLI wiring, mechanical with the pilot product); until then a duplicate name is caught at generation, never silently merged.",
+ "description": "First-party product modules riding the platform (ADR-0012; platform RFC D2). Every entry is NAMED with a NAMESPACED vendor.product id (e.g. qorpe.sync) so a future core feature can never collide with a vendor key; the core `features` enumeration stays closed. An array of named entries rather than a map because the engine's never-guess schema vocabulary has no patternProperties — the namespace is enforced by `pattern` on `name`. Each product's own RFC refines its options; `enabled: false` means the product is absent from the composition (ADR-0001 toggle semantics). DECISION (review R5 on the introducing PR): duplicate names are INVALID, but the engine's vocabulary cannot express name-uniqueness across array entries (uniqueItems compares whole objects) — the refusal lives in the composition layer (goldpath CLI wiring, mechanical with the pilot product); until then a duplicate name is caught at generation, never silently merged.",
"items": {
"type": "object",
"required": [
diff --git a/tests/Goldpath.Approvals.Tests/ApprovalEngineTests.cs b/tests/Goldpath.Approvals.Tests/ApprovalEngineTests.cs
new file mode 100644
index 0000000..ea2b283
--- /dev/null
+++ b/tests/Goldpath.Approvals.Tests/ApprovalEngineTests.cs
@@ -0,0 +1,197 @@
+using Goldpath;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Time.Testing;
+using Xunit;
+
+namespace Goldpath.Approvals.Tests;
+
+public class ApprovalEngineTests
+{
+ 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)
+ .Rung("deputy-manager", 5_000_000m, RungDeadline)
+ .Rung("manager", 15_000_000m, RungDeadline)
+ .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-18T09: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);
+ }
+
+ [Theory]
+ [InlineData(1_000_000, "expert")] // inclusive ceiling stays on the rung
+ [InlineData(1_000_000.01, "deputy-manager")] // one cent over crosses it
+ [InlineData(5_000_000, "deputy-manager")]
+ [InlineData(15_000_000, "manager")]
+ [InlineData(15_000_000.01, "general-manager")]
+ [InlineData(999_999_999, "general-manager")] // above every ceiling routes to the top
+ public async Task Amount_routes_the_rung_boundaries_inclusive(decimal amount, string expectedRole)
+ {
+ var (engine, _, _, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-1", amount, "maker");
+ Assert.Equal(expectedRole, request.PendingRole);
+ }
+
+ [Fact]
+ public async Task Four_eyes_the_requester_may_never_decide_their_own_request()
+ {
+ var (engine, _, _, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-2", 500_000m, "maker");
+ var outcome = await engine.DecideAsync(request.Id, "maker", "expert", granted: true, "self-serve attempt");
+ Assert.Equal(GoldpathApprovalDecisionOutcome.FourEyesViolation, outcome);
+ Assert.Equal(GoldpathApprovalStatus.Pending, (await Reload(store, request.Id)).Status);
+ }
+
+ [Fact]
+ public async Task Wrong_role_is_refused_and_right_role_is_applied()
+ {
+ var (engine, _, events, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-3", 500_000m, "maker");
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.WrongRole,
+ await engine.DecideAsync(request.Id, "checker", "manager", true, "wrong rung"));
+
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.DecideAsync(request.Id, "checker", "expert", true, "fits the limit"));
+ Assert.Contains(events.Published, e => e is GoldpathApprovalGranted g && g.ApprovalId == request.Id);
+ }
+
+ [Fact]
+ public async Task A_decided_request_cannot_be_decided_again()
+ {
+ var (engine, _, _, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-4", 500_000m, "maker");
+ await engine.DecideAsync(request.Id, "checker", "expert", false, "insufficient collateral");
+ Assert.Equal(GoldpathApprovalDecisionOutcome.NotPending,
+ await engine.DecideAsync(request.Id, "other", "expert", true, "second opinion"));
+ }
+
+ [Fact]
+ public async Task Escalation_moves_one_rung_on_deadline_and_resets_the_clock()
+ {
+ var (engine, clock, events, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-5", 500_000m, "maker");
+
+ clock.Advance(RungDeadline - TimeSpan.FromMinutes(1));
+ Assert.Equal(0, await engine.EscalateOverdueAsync()); // not overdue yet
+
+ clock.Advance(TimeSpan.FromMinutes(1));
+ Assert.Equal(1, await engine.EscalateOverdueAsync());
+ var reloaded = await Reload(store, request.Id);
+ Assert.Equal("deputy-manager", reloaded.PendingRole);
+ Assert.Contains(events.Published, e => e is GoldpathApprovalEscalated m && m.ToRole == "deputy-manager");
+
+ // The clock reset: the next rung gets its OWN full deadline.
+ clock.Advance(TimeSpan.FromMinutes(1));
+ Assert.Equal(0, await engine.EscalateOverdueAsync());
+ }
+
+ [Fact]
+ public async Task Overdue_at_the_top_rung_expires_not_loops()
+ {
+ var (engine, clock, events, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-6", 99_000_000m, "maker");
+ Assert.Equal("general-manager", request.PendingRole);
+
+ clock.Advance(TimeSpan.FromHours(24));
+ Assert.Equal(1, await engine.EscalateOverdueAsync());
+ Assert.Equal(GoldpathApprovalStatus.Expired, (await Reload(store, request.Id)).Status);
+ Assert.Contains(events.Published, e => e is GoldpathApprovalExpired);
+ }
+
+ [Fact]
+ public async Task Delegation_admits_the_delegate_and_depth_is_one()
+ {
+ var (engine, _, _, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-7", 500_000m, "maker");
+
+ await engine.DelegateAsync("expert-user", "stand-in", TimeSpan.FromDays(3));
+ Assert.Equal(GoldpathApprovalDecisionOutcome.Applied,
+ await engine.DecideAsync(request.Id, "stand-in", "no-role", true, "delegated decision"));
+
+ // The stand-in holds a delegation — re-delegating would be a chain; refused.
+ await Assert.ThrowsAsync(
+ () => engine.DelegateAsync("stand-in", "third", TimeSpan.FromDays(1)));
+ }
+
+ [Fact]
+ public async Task Expired_delegation_admits_nobody()
+ {
+ var (engine, clock, _, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-8", 500_000m, "maker");
+ await engine.DelegateAsync("expert-user", "stand-in", TimeSpan.FromDays(3));
+ clock.Advance(TimeSpan.FromDays(3) + TimeSpan.FromSeconds(1));
+ Assert.Equal(GoldpathApprovalDecisionOutcome.WrongRole,
+ await engine.DecideAsync(request.Id, "stand-in", "no-role", true, "too late"));
+ }
+
+ [Fact]
+ public async Task Worklist_is_role_scoped_four_eyes_filtered_and_oldest_first()
+ {
+ var (engine, clock, _, store) = Build();
+ var first = await engine.RequestAsync("credit-limit", "K26-9", 500_000m, "maker");
+ clock.Advance(TimeSpan.FromMinutes(5));
+ var second = await engine.RequestAsync("credit-limit", "K26-10", 700_000m, "checker");
+ await engine.RequestAsync("credit-limit", "K26-11", 9_000_000m, "maker"); // manager rung
+
+ var checkerList = await engine.WorklistAsync("checker", "expert");
+ Assert.Equal([first.Id], checkerList.Select(r => r.Id)); // own request filtered, manager rung excluded
+
+ var expertList = await engine.WorklistAsync("someone-else", "expert");
+ Assert.Equal([first.Id, second.Id], expertList.Select(r => r.Id)); // oldest first
+ }
+
+ [Fact]
+ public async Task The_trail_records_every_lifecycle_step()
+ {
+ var (engine, clock, _, store) = Build();
+ var request = await engine.RequestAsync("credit-limit", "K26-12", 500_000m, "maker");
+ clock.Advance(RungDeadline);
+ await engine.EscalateOverdueAsync();
+ await engine.DecideAsync(request.Id, "deputy", "deputy-manager", true, "approved on review");
+
+ var trail = (await Reload(store, request.Id)).Trail;
+ Assert.Equal(["requested", "escalated", "granted"], trail.Select(t => t.Action));
+ }
+
+ [Fact]
+ public void A_ladder_without_a_top_rung_is_rejected_at_declaration()
+ {
+ Assert.Throws(() => new GoldpathApprovalsOptions()
+ .AddLadder("broken", l => l.Rung("expert", 1_000_000m, RungDeadline)));
+ }
+
+ [Fact]
+ public void Non_increasing_ceilings_are_rejected_at_declaration()
+ {
+ Assert.Throws(() => new GoldpathApprovalsOptions()
+ .AddLadder("broken", l => l
+ .Rung("expert", 5_000_000m, RungDeadline)
+ .Rung("deputy", 1_000_000m, RungDeadline)
+ .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/Goldpath.Approvals.Tests.csproj b/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj
new file mode 100644
index 0000000..ccbed93
--- /dev/null
+++ b/tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs b/tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs
index 5a8bf13..97cbb27 100644
--- a/tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs
+++ b/tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs
@@ -352,13 +352,33 @@ public void Campaign_on_sqlserver_pins_the_store_provider_when_it_opens_the_comp
}
[Fact]
- public void The_feature_list_is_the_eleven_ring_b_features()
+ public void The_feature_list_is_the_thirteen_recipes()
{
Assert.Equal(
- ["multitenancy", "audittrail", "softdelete", "idempotency", "dataprotection", "caching", "locking", "archival", "bulk", "notification", "campaign"],
+ ["multitenancy", "audittrail", "softdelete", "idempotency", "dataprotection", "caching", "locking", "approvals", "fileexchange", "archival", "bulk", "notification", "campaign"],
FeatureRecipes.Names);
}
+ [Fact]
+ public void Approvals_recipe_wires_the_module_without_touching_the_database()
+ {
+ var facts = new AppFacts { DbContextName = "X", DatabaseProvider = "postgres", ConnectionName = "shopdb", CachingWired = false, JobsWired = false, MessagingWired = false, AuthWired = false };
+ 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);
+ }
+
+ [Fact]
+ public void Fileexchange_recipe_wires_the_module_without_touching_the_database()
+ {
+ var facts = new AppFacts { DbContextName = "X", DatabaseProvider = "postgres", ConnectionName = "shopdb", CachingWired = false, JobsWired = false, MessagingWired = false, AuthWired = false };
+ 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);
+ }
+
[Fact]
public void AppFacts_reads_dbcontext_provider_connection_and_caching()
{
diff --git a/tests/Goldpath.FileExchange.Tests/FileRailEngineTests.cs b/tests/Goldpath.FileExchange.Tests/FileRailEngineTests.cs
new file mode 100644
index 0000000..560ec8b
--- /dev/null
+++ b/tests/Goldpath.FileExchange.Tests/FileRailEngineTests.cs
@@ -0,0 +1,158 @@
+using Goldpath;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Goldpath.FileExchange.Tests;
+
+///
+/// The planted-fault rig (fileexchange RFC §7): a registry-style CSV rail with a header,
+/// a trailer count, and rows "reference;amount". Every planted fault — bad row, duplicate
+/// file, truncated file, replay — must be caught, quarantined, or deduplicated exactly as
+/// declared, and the batch must never stop for a row.
+///
+public class FileRailEngineTests
+{
+ private sealed record RegistryRow(string Reference, decimal Amount);
+
+ private static GoldpathFileExchangeOptions Options() => new GoldpathFileExchangeOptions()
+ .AddRail("registry-daily", rail => rail
+ .Header(1)
+ .ValidateFile(lines =>
+ {
+ // Trailer contract: the header's declared row count must match reality.
+ if (lines.Count == 0 || !lines[0].StartsWith("H;", StringComparison.Ordinal))
+ {
+ return "missing header";
+ }
+
+ var declared = int.Parse(lines[0].Split(';')[1]);
+ return declared == lines.Count - 1 ? null : $"truncated: header declares {declared} rows, file has {lines.Count - 1}";
+ })
+ .ParseLine(line =>
+ {
+ var parts = line.Split(';');
+ return new RegistryRow(parts[0], decimal.Parse(parts[1]));
+ })
+ .ValidateRow(row => row.Amount > 0 ? null : $"non-positive amount for {row.Reference}")
+ .Handle((row, _) =>
+ {
+ Applied.Add(row.Reference);
+ return Task.CompletedTask;
+ }));
+
+ private static readonly List Applied = [];
+
+ private static (GoldpathFileRailEngine Engine, GoldpathInMemoryFileLedger Ledger, RecordingPublisher Events) Build()
+ {
+ Applied.Clear();
+ var ledger = new GoldpathInMemoryFileLedger();
+ var events = new RecordingPublisher();
+ var engine = new GoldpathFileRailEngine(Options(), ledger, NullLogger.Instance, events);
+ return (engine, ledger, events);
+ }
+
+ private static readonly string[] CleanFile = ["H;3", "A-1;100.50", "A-2;200.00", "A-3;50.25"];
+
+ [Fact]
+ public async Task Clean_file_ingests_every_row_once()
+ {
+ var (engine, _, events) = Build();
+ var result = await engine.ProcessAsync("registry-daily", "reg-0817.csv", CleanFile);
+
+ Assert.Null(result.FileRejectedReason);
+ Assert.Equal(3, result.Processed);
+ Assert.Empty(result.Quarantined);
+ Assert.Equal(["A-1", "A-2", "A-3"], Applied);
+ Assert.Contains(events.Published, e => e is GoldpathFileIngested { Processed: 3, Quarantined: 0 });
+ }
+
+ [Fact]
+ public async Task Bad_rows_quarantine_and_the_batch_continues()
+ {
+ var (engine, ledger, events) = Build();
+ string[] file = ["H;4", "A-1;100.50", "garbage-line", "A-3;-5", "A-4;70.00"];
+ var result = await engine.ProcessAsync("registry-daily", "reg-0818.csv", file);
+
+ Assert.Equal(2, result.Processed); // the batch did NOT stop
+ Assert.Equal([3, 4], result.Quarantined.Select(q => q.Line));
+ Assert.StartsWith("parse:", result.Quarantined[0].Reason);
+ Assert.Equal("non-positive amount for A-3", result.Quarantined[1].Reason);
+ Assert.Equal(["A-1", "A-4"], Applied);
+
+ var quarantine = await ledger.GetQuarantineAsync("registry-daily", "reg-0818.csv");
+ Assert.Equal(2, quarantine.Count);
+ Assert.Contains(events.Published, e => e is GoldpathRowsQuarantined { Count: 2 });
+ }
+
+ [Fact]
+ public async Task Replaying_the_same_file_applies_zero_duplicates()
+ {
+ var (engine, _, _) = Build();
+ await engine.ProcessAsync("registry-daily", "reg-0817.csv", CleanFile);
+ var replay = await engine.ProcessAsync("registry-daily", "reg-0817.csv", CleanFile);
+
+ Assert.Equal(0, replay.Processed);
+ Assert.Equal(3, replay.SkippedAsDuplicate);
+ Assert.Equal(["A-1", "A-2", "A-3"], Applied); // still exactly once
+ }
+
+ [Fact]
+ public async Task Truncated_file_is_rejected_whole_and_ingests_nothing()
+ {
+ var (engine, _, events) = Build();
+ string[] truncated = ["H;3", "A-1;100.50", "A-2;200.00"]; // header says 3, file has 2
+ var result = await engine.ProcessAsync("registry-daily", "reg-0819.csv", truncated);
+
+ Assert.NotNull(result.FileRejectedReason);
+ Assert.Equal(0, result.Processed);
+ Assert.Empty(Applied);
+ Assert.Contains(events.Published, e => e is GoldpathFileRejected);
+ Assert.DoesNotContain(events.Published, e => e is GoldpathFileIngested);
+ }
+
+ [Fact]
+ public async Task Reprocess_after_fix_retries_only_the_quarantined_row()
+ {
+ var (engine, ledger, _) = Build();
+ string[] broken = ["H;2", "A-1;100.50", "A-2;-1"];
+ await engine.ProcessAsync("registry-daily", "reg-0820.csv", broken);
+ Assert.Equal(["A-1"], Applied);
+
+ // The counterparty resends the file with the row fixed. The good row dedups,
+ // the fixed row applies, and its quarantine record clears.
+ string[] fixedFile = ["H;2", "A-1;100.50", "A-2;75.00"];
+ var result = await engine.ProcessAsync("registry-daily", "reg-0820.csv", fixedFile);
+
+ Assert.Equal(1, result.Processed);
+ Assert.Equal(1, result.SkippedAsDuplicate);
+ Assert.Equal(["A-1", "A-2"], Applied);
+ Assert.Empty(await ledger.GetQuarantineAsync("registry-daily", "reg-0820.csv"));
+ }
+
+ [Fact]
+ public async Task An_undeclared_rail_is_refused()
+ {
+ var (engine, _, _) = Build();
+ await Assert.ThrowsAsync(
+ () => engine.ProcessAsync("no-such-rail", "x.csv", CleanFile));
+ }
+
+ [Fact]
+ public void A_rail_without_parse_or_handle_is_rejected_at_declaration()
+ {
+ Assert.Throws(() => new GoldpathFileExchangeOptions()
+ .AddRail("broken", rail => rail.Header(1)));
+ }
+
+ 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.FileExchange.Tests/Goldpath.FileExchange.Tests.csproj b/tests/Goldpath.FileExchange.Tests/Goldpath.FileExchange.Tests.csproj
new file mode 100644
index 0000000..d9e993e
--- /dev/null
+++ b/tests/Goldpath.FileExchange.Tests/Goldpath.FileExchange.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Goldpath.IntegrationTests/Goldpath.IntegrationTests.csproj b/tests/Goldpath.IntegrationTests/Goldpath.IntegrationTests.csproj
index cef8c01..5736d94 100644
--- a/tests/Goldpath.IntegrationTests/Goldpath.IntegrationTests.csproj
+++ b/tests/Goldpath.IntegrationTests/Goldpath.IntegrationTests.csproj
@@ -13,6 +13,9 @@
+
+
diff --git a/tools/Goldpath.Cli/FeatureRecipes.cs b/tools/Goldpath.Cli/FeatureRecipes.cs
index 583e3d0..e739926 100644
--- a/tools/Goldpath.Cli/FeatureRecipes.cs
+++ b/tools/Goldpath.Cli/FeatureRecipes.cs
@@ -111,18 +111,20 @@ public static AppFacts Read(AppFiles files)
}
///
-/// The ELEVEN feature recipes — seven Ring B cross-cutting features (multitenancy,
-/// audittrail, softdelete, idempotency, dataprotection, caching, locking) 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; specdrift stays the
-/// acceptance test for both paths.
+/// The THIRTEEN feature recipes — nine Ring B cross-cutting features (multitenancy,
+/// audittrail, softdelete, idempotency, dataprotection, caching, locking, approvals,
+/// 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.
///
public static class FeatureRecipes
{
/// The feature names goldpath add feature understands.
public static readonly IReadOnlyList Names =
- ["multitenancy", "audittrail", "softdelete", "idempotency", "dataprotection", "caching", "locking", "archival", "bulk", "notification", "campaign"];
+ ["multitenancy", "audittrail", "softdelete", "idempotency", "dataprotection", "caching", "locking", "approvals", "fileexchange", "archival", "bulk", "notification", "campaign"];
/// Builds the plan for one feature against the app's read context.
public static RecipePlan Build(string feature, AppFacts app)
@@ -233,6 +235,40 @@ public static RecipePlan Build(string feature, AppFacts app)
return plan;
}
+ case "approvals":
+ {
+ var plan = new RecipePlan { ManifestKey = "approvals" };
+ plan.ApiPackages.Add("Goldpath.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.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;
+ }
+
+ case "fileexchange":
+ {
+ var plan = new RecipePlan { ManifestKey = "fileExchange" };
+ plan.ApiPackages.Add("Goldpath.FileExchange");
+ 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.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;
+ }
+
case "archival":
{
var connection = app.ConnectionName
diff --git a/ui/console/package.json b/ui/console/package.json
index f17e6af..1ac9181 100644
--- a/ui/console/package.json
+++ b/ui/console/package.json
@@ -19,7 +19,7 @@
"devDependencies": {
"@axe-core/playwright": "4.10.1",
"@playwright/test": "1.50.1",
- "@qorpe/ui": "0.1.2",
+ "@qorpe/ui": "0.5.0",
"@tailwindcss/vite": "^4.3.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
diff --git a/ui/console/pnpm-lock.yaml b/ui/console/pnpm-lock.yaml
index 3d3557a..25be7b5 100644
--- a/ui/console/pnpm-lock.yaml
+++ b/ui/console/pnpm-lock.yaml
@@ -31,8 +31,8 @@ importers:
specifier: 1.50.1
version: 1.50.1
'@qorpe/ui':
- specifier: 0.1.2
- version: 0.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: 0.5.0
+ version: 0.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@tailwindcss/vite':
specifier: ^4.3.2
version: 4.3.3(vite@8.1.5(jiti@2.7.0))
@@ -451,8 +451,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
- '@qorpe/ui@0.1.2':
- resolution: {integrity: sha512-T+ASwTJbx8lWpRDc6B92OO/mmxN8Llz3MF5eFuSJDk2BT68xrtRojCPu/khFza2mpocBSO9eoDEwnX6d8fEPrA==}
+ '@qorpe/ui@0.5.0':
+ resolution: {integrity: sha512-eCDQv7Y5Rikkb2VtEtrfOylql7WwmtCgVWQUbLdcv/NubiVmi2VWWraNxk33sZC4u0juJmjL/t8cukl/Ie/Rcg==}
peerDependencies:
react: ^19.0.0
react-dom: ^19.0.0
@@ -2802,7 +2802,7 @@ snapshots:
dependencies:
playwright: 1.50.1
- '@qorpe/ui@0.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@qorpe/ui@0.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@codemirror/commands': 6.10.4
'@codemirror/lang-json': 6.0.2