From 887fd4d92ea26e49b74d3bfdad141cb52dcf9d84 Mon Sep 17 00:00:00 2001 From: Omer Celik Date: Mon, 17 Aug 2026 17:40:59 +0300 Subject: [PATCH 1/3] threads: T19 CLOSED on the outbox-join proof; qorpe-sync accepted; T21/T22 RFCs drafted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adopter PoC's final leg ran: saga state in the app's OWN DbContext, fault injection proving the saga row and outbox rows commit or roll back in ONE transaction. All three host properties now proven by test — T19 moves to Closed threads. qorpe-sync flips to accepted on the owner's word (build next week, two-store rig first). Goldpath.Approvals and Goldpath.FileExchange get their RFCs (proposed) so the T21/T22 shelves are prepared; builds still wait on their Ring B triggers per the §5.1 by-product rule. Co-Authored-By: Claude Fable 5 --- docs/rfc/README.md | 4 +- docs/rfc/goldpath-approvals.md | 98 ++++++++++++++++++++++++++++ docs/rfc/goldpath-fileexchange.md | 102 ++++++++++++++++++++++++++++++ docs/rfc/qorpe-sync.md | 7 +- docs/strategy/open-threads.md | 23 ++++++- 5 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 docs/rfc/goldpath-approvals.md create mode 100644 docs/rfc/goldpath-fileexchange.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 9eaa557..c7cb500 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 | **proposed** — the T21 shelf prepared; build waits on the Ring B trigger (§5.1 by-product rule) | +| [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 | **proposed** — the T22 shelf prepared; build waits on the Ring B trigger (§5.1 by-product rule) | | [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..e0ead24 --- /dev/null +++ b/docs/rfc/goldpath-approvals.md @@ -0,0 +1,98 @@ +# RFC: Goldpath.Approvals — Human Approval Workflows + +**Status:** proposed +**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 + +- [ ] RFC accepted; the four Ring B entry criteria confirmed in review (≥2 industries named: + banking-class approvals, insurance underwriting sign-off, telco credit overrides). +- [ ] Ladder/definition schema published; validator wired into the standard gate. +- [ ] Lifecycle + delegation + escalation proven by the §7 deterministic tests. +- [ ] Admin surface federates in the family console against a real app. +- [ ] Ops pack ships (runbook + dashboard JSON). +- [ ] 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..5a70282 --- /dev/null +++ b/docs/rfc/goldpath-fileexchange.md @@ -0,0 +1,102 @@ +# RFC: Goldpath.FileExchange — File-Based Integration Rails + +**Status:** proposed +**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 + +- [ ] RFC accepted; Ring B criteria confirmed in review (file rails recur across banking + registries/statements, insurance bordereaux, telco interconnect settlement). +- [ ] Rail-definition schema published; validator wired into the standard gate. +- [ ] The §7 planted-fault rig runs green in CI. +- [ ] Admin surface federates in the family console against a real app. +- [ ] Ops pack ships (runbook + dashboard JSON). +- [ ] 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..7aa3d23 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 DRAFTED ([goldpath-approvals](../rfc/goldpath-approvals.md), proposed 2026-08-18) — the shelf is prepared; the build still waits for the trigger | 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 DRAFTED ([goldpath-fileexchange](../rfc/goldpath-fileexchange.md), proposed 2026-08-18); the build still waits for the trigger | 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. From 2d49fdc304d07dabfc0aff69197a88fd681ace14 Mon Sep 17 00:00:00 2001 From: Omer Celik Date: Tue, 18 Aug 2026 09:20:58 +0300 Subject: [PATCH 2/3] modules: Goldpath.Approvals + Goldpath.FileExchange cores land by owner decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner pulled the T21/T22 Ring B triggers early ('nothing left incomplete'). Approvals: amount-laddered chains as data, four-eyes, depth-one delegation, deadline escalation with top-rung expiry, worklist, full audit trail — 17 deterministic tests. FileExchange: rails as data with baked closures, file contracts, idempotent (file,line) ingestion, quarantine-continues, zero-dup replay/reprocess — 7-test planted-fault rig. Both wired into the manifest schema and 'goldpath add feature'; RFCs flip to accepted with honest open DoD rows (DB stores, template flags, console federation, adopter proofs). Also lifts transitive SSH.NET past GHSA-q939-rpr3-3284 (NU1903 was red). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 19 ++ Directory.Packages.props | 2 + Goldpath.sln | 60 ++++ docs/rfc/README.md | 4 +- docs/rfc/goldpath-approvals.md | 18 +- docs/rfc/goldpath-fileexchange.md | 20 +- docs/strategy/open-threads.md | 4 +- .../Goldpath.Approvals.csproj | 27 ++ .../GoldpathApprovalEngine.cs | 322 ++++++++++++++++++ .../GoldpathApprovalModel.cs | 78 +++++ .../GoldpathApprovalsExtensions.cs | 28 ++ .../GoldpathApprovalsOptions.cs | 123 +++++++ .../Goldpath.Approvals/PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 157 +++++++++ packages/Goldpath.Approvals/README.md | 37 ++ packages/Goldpath.Approvals/ops/approvals.md | 28 ++ .../Goldpath.FileExchange.csproj | 27 ++ .../GoldpathFileExchangeExtensions.cs | 25 ++ .../GoldpathFileExchangeOptions.cs | 141 ++++++++ .../GoldpathFileRailEngine.cs | 210 ++++++++++++ .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 94 +++++ packages/Goldpath.FileExchange/README.md | 38 +++ .../Goldpath.FileExchange/ops/fileexchange.md | 31 ++ .../manifest/v1/goldpath-manifest.schema.json | 16 +- .../ApprovalEngineTests.cs | 197 +++++++++++ .../Goldpath.Approvals.Tests.csproj | 19 ++ tests/Goldpath.Cli.Tests/RecipeGoldenTests.cs | 24 +- .../FileRailEngineTests.cs | 158 +++++++++ .../Goldpath.FileExchange.Tests.csproj | 18 + .../Goldpath.IntegrationTests.csproj | 3 + tools/Goldpath.Cli/FeatureRecipes.cs | 50 ++- 32 files changed, 1952 insertions(+), 28 deletions(-) create mode 100644 packages/Goldpath.Approvals/Goldpath.Approvals.csproj create mode 100644 packages/Goldpath.Approvals/GoldpathApprovalEngine.cs create mode 100644 packages/Goldpath.Approvals/GoldpathApprovalModel.cs create mode 100644 packages/Goldpath.Approvals/GoldpathApprovalsExtensions.cs create mode 100644 packages/Goldpath.Approvals/GoldpathApprovalsOptions.cs create mode 100644 packages/Goldpath.Approvals/PublicAPI.Shipped.txt create mode 100644 packages/Goldpath.Approvals/PublicAPI.Unshipped.txt create mode 100644 packages/Goldpath.Approvals/README.md create mode 100644 packages/Goldpath.Approvals/ops/approvals.md create mode 100644 packages/Goldpath.FileExchange/Goldpath.FileExchange.csproj create mode 100644 packages/Goldpath.FileExchange/GoldpathFileExchangeExtensions.cs create mode 100644 packages/Goldpath.FileExchange/GoldpathFileExchangeOptions.cs create mode 100644 packages/Goldpath.FileExchange/GoldpathFileRailEngine.cs create mode 100644 packages/Goldpath.FileExchange/PublicAPI.Shipped.txt create mode 100644 packages/Goldpath.FileExchange/PublicAPI.Unshipped.txt create mode 100644 packages/Goldpath.FileExchange/README.md create mode 100644 packages/Goldpath.FileExchange/ops/fileexchange.md create mode 100644 tests/Goldpath.Approvals.Tests/ApprovalEngineTests.cs create mode 100644 tests/Goldpath.Approvals.Tests/Goldpath.Approvals.Tests.csproj create mode 100644 tests/Goldpath.FileExchange.Tests/FileRailEngineTests.cs create mode 100644 tests/Goldpath.FileExchange.Tests/Goldpath.FileExchange.Tests.csproj 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 c7cb500..727c9d6 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -45,6 +45,6 @@ All "implemented" modules are published on nuget.org at `0.1.0-preview.6` (the c | [spec-engine-v1](spec-engine-v1.md) | specdrift (separate repo) | implemented — 0.4.2 published (NuGet tool + MCP + Docker + Action) | | [qorpe-ui](qorpe-ui.md) | @qorpe/ui — the family UI kit extraction (platform-sdk D5) | **implemented** (step 6.0, 2026-08-07): the kit lives in [qorpe/ui](https://github.com/qorpe/ui), publishes to npm via OIDC trusted publishing, and BOTH consumers run on the published package — `ui/kit` is deleted from this repo | | [qorpe-sync](qorpe-sync.md) | qorpe.sync (separate private repo) — the migration & co-existence product module (ADR-0012's own named example): CDC capture, ordered replayable stream, adapter-based versioned mapping, event-id reconciliation composing db-compare | **accepted** (owner, 2026-08-18) — v0 scope is engagement-shaped (§5.1 by-product rule); build scheduled, two-store rig first; the second-product-repo ordering is an owner decision recorded in RFC D5 | -| [goldpath-approvals](goldpath-approvals.md) | Goldpath.Approvals — human approval workflows: maker-checker, four-eyes, amount-laddered authority, delegation, escalation, worklist; definitions as versioned data | **proposed** — the T21 shelf prepared; build waits on the Ring B trigger (§5.1 by-product rule) | -| [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 | **proposed** — the T22 shelf prepared; build waits on the Ring B trigger (§5.1 by-product rule) | +| [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 index e0ead24..977ac4b 100644 --- a/docs/rfc/goldpath-approvals.md +++ b/docs/rfc/goldpath-approvals.md @@ -1,6 +1,6 @@ # RFC: Goldpath.Approvals — Human Approval Workflows -**Status:** proposed +**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 @@ -75,12 +75,20 @@ ladder's own deadline. ## 8. DoD -- [ ] RFC accepted; the four Ring B entry criteria confirmed in review (≥2 industries named: +- [x] RFC accepted; the four Ring B entry criteria confirmed (≥2 industries named: banking-class approvals, insurance underwriting sign-off, telco credit overrides). -- [ ] Ladder/definition schema published; validator wired into the standard gate. -- [ ] Lifecycle + delegation + escalation proven by the §7 deterministic tests. +- [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. -- [ ] Ops pack ships (runbook + dashboard JSON). +- [x] Runbook ships (`packages/Goldpath.Approvals/ops/approvals.md`); dashboard JSON open. +- [ ] Database-backed `IGoldpathApprovalStore` (the in-memory store is single-node). - [ ] The adopter proof runs (§7 last row) — the row that actually closes T21. ### Decisions diff --git a/docs/rfc/goldpath-fileexchange.md b/docs/rfc/goldpath-fileexchange.md index 5a70282..67ee4c0 100644 --- a/docs/rfc/goldpath-fileexchange.md +++ b/docs/rfc/goldpath-fileexchange.md @@ -1,6 +1,6 @@ # RFC: Goldpath.FileExchange — File-Based Integration Rails -**Status:** proposed +**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** @@ -44,7 +44,7 @@ are composed); no domain knowledge — rail definitions are adopter data. ## 3. Manifest Surface -`features.fileexchange` — enabled or absent (compile-time composition). Rail definitions +`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. @@ -78,12 +78,20 @@ window — the incident file rails actually have. ## 8. DoD -- [ ] RFC accepted; Ring B criteria confirmed in review (file rails recur across banking +- [x] RFC accepted; Ring B criteria confirmed (file rails recur across banking registries/statements, insurance bordereaux, telco interconnect settlement). -- [ ] Rail-definition schema published; validator wired into the standard gate. -- [ ] The §7 planted-fault rig runs green in CI. +- [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. -- [ ] Ops pack ships (runbook + dashboard JSON). +- [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 diff --git a/docs/strategy/open-threads.md b/docs/strategy/open-threads.md index 7aa3d23..66e7d55 100644 --- a/docs/strategy/open-threads.md +++ b/docs/strategy/open-threads.md @@ -24,8 +24,8 @@ pending forever. | T17 | **Per-package ops packs for the three floor packages** (Messaging, Data, ApiDefaults) | Their RFC §6 sections describe dashboards (consumer lag, outbox backlog, EF query duration, deprecated-version traffic) that are NOT packaged — the signals reach OTel through ServiceDefaults, whose ops pack is what an adopter gets. Found by an audit 2026-08-09; the RFC sections now say so | the first adopter who asks for a floor dashboard, OR the first incident whose triage needed one | the pack ships with the same shape the five module packs have (runbook + Grafana JSON), and the RFC §6 correction note is removed | | T18 | **The MassTransit 8.x exit** — the watch that keeps option A honest | [goldpath-messaging-exit](../rfc/goldpath-messaging-exit.md) decided **A: stay pinned on 8.5.10** (Apache-2.0), because a move is a MAJOR version, not an internal swap — measured, not assumed. The publish seam already shipped (`IIntegrationEventPublisher` + GP0404), so a generated app's COMMAND HANDLERS no longer name the library; its CONSUMERS still do, deliberately | **any one of four**: an unpatched CVE in the 8.x line · the vendor's v8 maintenance window closing (~end 2026 per their own statements) · an adopter's licensing constraint · a customer requiring a bus we do not compose | the five proofs in that RFC §7 — outbox atomicity on the new transport · tenant+correlation headers still propagate (H4) · GP0401-0403 still mean something · the golden-manifest matrix green on every broker-bearing shape · CorPay migrated with a written guide an adopter could actually follow | | T20 | **The specanchor rehearsal** — the transformation composition's proof ([specanchor-composition](../rfc/specanchor-composition.md)) | specanchor's own chain is proven on its rig (74 tests, mutation 94.8%, first skill eval 7/7), but the COMPOSITION — §9's method running on specanchor's machinery with Goldpath as target — has never run end to end; per the RFC's D4/D5, no profile code is written before it does | the fake-legacy → Goldpath migration rehearsal is scheduled (before the factoring engagement's PoC) | Discovery Zero → dual-track slices → approved specs → `goldpath new` target → parity with Mockifyr equalization → db-compare reconciliation → cutover evidence bundle, with the Discovery Zero playbook written from the rehearsal's diary; only then do ai-sdlc-status's `reverse-engineer`/`differential-test` rows leave NOT BUILT | -| T21 | **`Goldpath.Approvals`** — human approval workflows: maker-checker, four-eyes, amount-laddered authority, delegation, escalation, and the task worklist/inbox that feeds them | A Ring B candidate surfaced by the factoring-class engagement: SIX of its twelve common processes run approvals over e-mail today, and the amount ladder (expert → deputy → manager → GM) is domain-agnostic across banking/insurance/telco. Distinct from the T19 saga non-goal — these are HUMAN approval chains, not process orchestration. The RFC is DRAFTED ([goldpath-approvals](../rfc/goldpath-approvals.md), proposed 2026-08-18) — the shelf is prepared; the build still waits for the trigger | 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 DRAFTED ([goldpath-fileexchange](../rfc/goldpath-fileexchange.md), proposed 2026-08-18); the build still waits for the trigger | 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 | ## Closed threads 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 From a545e818d822fe35fa892bbdb14b4e947d2e2197 Mon Sep 17 00:00:00 2001 From: Omer Celik Date: Tue, 18 Aug 2026 09:26:56 +0300 Subject: [PATCH 3/3] console: kit pin 0.1.2 -> 0.5.0 (kit-freshness gate) The published @qorpe/ui moved 4 minors ahead; the gate went red by design. Console rebuilt and 217/217 unit tests pass on 0.5.0 with the coverage floors enforced. Co-Authored-By: Claude Fable 5 --- ui/console/package.json | 2 +- ui/console/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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