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