Concho Modernization Stakeholder Review

Petra (OpenPetra) Gift Processing Subsystem
Legacy C# / AngularJS to ASP.NET Core 10 / Angular 20 on Azure App Service Modernization
Stakeholder Review
Generated by Concho.AI — May 30, 2026 · updated June 19, 2026

OpenPetra Modernization — Executive Summary

Finance — Gift Processing subsystem · C# .NET Framework 4.7 / jQuery / SOAP → a modernized Angular 20 single-page UI presenting the whole gift-batch journey as one continuous flow, on .NET 10 / Azure App Service + PostgreSQL
Prepared by Concho.AI · May 2026 · Run 004
~2 hrs Report Time vs 16–29 weeks estimated manual effort
572K Lines Analyzed 1,396 files, 27 business functions cataloged
12/14 Rules Verbatim +1 deliberate improvement, 1 needs mitigation
9.6/10 Verification Score ~135 claims verified, 0 hallucinations found
7.8/10 Service Score 2 services via 3-lens unanimous consensus
20/28 EOL Dependencies Retired 10 removed + 10 replaced (.NET Fwk 4.7, Mono, jQuery, NAnt, ASMX SOAP…); the other 8: 7 upgraded, 1 kept outside slice

OpenPetra is a mature open-source ERP platform serving non-profit organizations worldwide. Its Finance — Gift Processing subsystem handles donation batch management, multi-currency gift processing, tax-deductible receipt generation, and general ledger integration — core revenue operations that must continue uninterrupted during modernization. Today this subsystem runs on .NET Framework 4.7 with ASP.NET Web Services (SOAP/.asmx), a jQuery front-end, and Mono/FastCGI hosting — a stack that limits developer recruitment, blocks modern browser capabilities, and carries 7 architectural concerns alongside 28 outdated dependencies, 20 of which are end-of-life and retired outright by the target (.NET Framework 4.7, Mono, jQuery 3.6, NAnt, ASMX SOAP, TPetraPrincipal session auth, browserify, axios with two known CVEs, and 12 polyfill / connector / build-tool packages).

This report recommends a classical strangler-fig modernization to .NET 10, Angular 20, and Azure App Service with PostgreSQL preserved. Azure API Management incrementally shifts gift-processing traffic from legacy SOAP endpoints to modern REST APIs — the posted/unposted batch state machine is the natural routing key, and Debezium + Azure Service Bus carry change events to the modernized side during coexistence. The subsystem decomposes into 2 services — Gift Processing API and Receipt Generation Service — validated by a three-perspective (Domain-Driven Design, Technical, Business) agent consensus scoring 7.8/10 with all three lenses converging on the same shape. Because the modernization is C# → C#, domain knowledge embedded in 279 source files transfers directly; the work is architectural — replacing typed datasets with EF Core, static utilities with injectable services, synchronous SOAP with async REST, and jQuery DOM manipulation with Angular reactive components.

Of 14 behavioral rules governing gift processing, 12 transfer with no behavioral change, 1 (BR-GIFT-002 period validation) is a deliberate improvement, and 5 carry legacy NUnit test corroboration. The one rule needing remediation — BR-GIFT-006 confidential gift privacy enforcement — requires an authorization-policy mitigation pattern documented in Section 9. All 161 schema columns across 10 tables map to the target with zero fidelity findings, six deliberate width-widenings, and 27 cross-slice foreign-key constraints resolved by the project-level drop-constraints policy. Platform-affinity analysis surfaced 9 entries (8 ELIMINATE / 1 HYBRID / 0 PRESERVE) at 100% three-run consensus resolution. Concho executed roughly 135 MCP queries across 28 sub-agent dispatches in 11 phases; the resulting report passed final verification at 9.6/10.

What Happens Next — Automated Code Generation

This report is not the end of the process — it is the input to a companion agentic code generation workflow that turns the architectural guidance, schema decisions, business rule specifications, and UI transformation patterns documented here into runnable implementation artifacts: ASP.NET Core 10 services, EF Core entity models, Angular 20 components, PostgreSQL schemas, Dockerfiles, Bicep IaC templates, and GitHub Actions CI/CD pipelines.

The code generation workflow uses a BDD/TDD iterate-till-green approach: it generates code from the behavioral rule specifications (Given-When-Then), writes automated tests derived from those specs, executes the full test suite, and — if any test fails — identifies the discrepancy, corrects the generated artifact, and re-runs until all tests pass. A human never reviews a broken artifact. Only when the modernized subsystem passes all behavioral, integration, and smoke tests does the workflow present it for human review. It does not arrive and get polished — it arrives already verified.

Section Takeaways

§ Section Key Takeaway
4 Target Architecture 2-service ASP.NET Core 10 deployment on Azure App Service (P1v3 Linux) with Angular 20 SPA, Azure Database for PostgreSQL Flexible Server, EF Core 10 + Npgsql, and APIM-based strangler-fig routing — Bicep IaC, OpenTelemetry + Application Insights observability, Polly resilience, Managed Identity service-to-service auth, and slot-swap deployments built in.
5 Platform Affinity Analysis 9 platform-driven entries reconciled across 3 independent runs at 100% consensus (0 split decisions): 8 ELIMINATE / 1 HYBRID / 0 PRESERVE. Every platform-driven limitation is either retired (.NET 4.7 runtime ceiling, Mono FastCGI, ASMX, jQuery + Bootstrap 4 + i18next + browserify, typed datasets, Newtonsoft.Json) or mitigated (the multi-currency engine becomes a hybrid).
6 Technical Debt Analysis 7 architectural concerns (1 high / 4 medium / 2 low) and 5 architectural strengths cataloged; 28 dependencies audited — 10 removed, 10 replaced, 7 upgraded, 1 outside the slice; 1 known-CVE entry (axios 0.21.4) is removed entirely. The XML-driven schema, validation engine, and integration-test infrastructure are preserved as strengths.
7 UI/UX Transformation Three legacy jQuery + AngularJS screens (GiftBatches, GiftDetailEntry, MotivationPicker) collapse into a single Unified Gift Batch Workspace with donor autocomplete, live multi-currency totals, live period validation, a 0–100 tax-deductible clamp, and a pre-commit GL journal preview the legacy system could never offer. Walked through six scenes from empty state to side-by-side legacy coexistence.
8 Code Translation Same-language C# modernization — typed datasets become EF Core entities, static utilities become injectable services, SOAP / .asmx endpoints become async Minimal APIs, string-concatenated SQL becomes parameterized LINQ, Newtonsoft.Json becomes source-generated System.Text.Json, NUnit becomes xUnit + WebApplicationFactory + Testcontainers.
9 Business Rules Analysis 14 rules (4 validation / 3 calculation / 2 state transition / 3 workflow / 2 authorization) with formal Given-When-Then specs at mean confidence 0.78; 5 carry legacy NUnit test corroboration; 12 transfer verbatim, 1 (BR-GIFT-002 period validation) is a deliberate improvement, 1 (BR-GIFT-006 confidential gift privacy) requires an explicit authorization-policy + DTO redaction mitigation.
10 Data Mapping Strategy 161 columns across 10 tables (8 preserved + 2 new-operational) mapped from petra.xml typed datasets to EF Core entities on PostgreSQL; 0 fidelity findings, 6 declared width-widenings for UTF-8 + modern usage, and 27 cross-slice foreign keys resolved via the project-level drop-constraints policy with application-layer validation through the strangler-fig bridge.
11 Modernization Strategy & Coexistence Classical strangler-fig via Azure API Management with the posted/unposted batch state machine as the routing key, Debezium + WAL log-tail CDC on the 8 gift tables, and Azure Service Bus session-ordered topics carrying sage-domain-event-v1 envelopes. Reads migrate before writes; consumer idempotency on natural composite keys; six-phase choreography with explicit per-phase rollback.
12 How Concho Enabled This Analysis Three-way comparison (Concho + Claude / Claude Code alone / Manual Review) across 6 capabilities and 6 worked examples. Concho’s Concho Context Graph surfaced the three non-idempotent write rules in 2 queries — the discovery that ruled out symmetric dual-write and selected classical strangler-fig. ~135 Concho queries vs an estimated 16–29 weeks of senior-architect manual effort.

1. Introduction

1.1 About This Document

This document is a modernization stakeholder review for the Finance — Gift Processing subsystem of OpenPetra (project key petra), an open-source administrative ERP for non-profit organizations. It documents the path from OpenPetra's legacy n-tier .NET Framework 4.7 implementation — ASP.NET Web Services (.asmx / SOAP), code-generated data access driven by an XML schema definition, and a JavaScript/jQuery web client — to a modern .NET 10 + Angular 20 stack targeted at Azure App Service, with PostgreSQL as the primary data store.

The analysis was produced by a multi-agent modernization workflow that queries the Concho Context Graph for codebase intelligence at every phase — architecture, entities, business rules, integration patterns, technical debt — and then composes a target architecture, code-translation examples, data-mapping strategy, and a legacy/modern coexistence plan grounded in that evidence. Every claim in this report traces back to a Concho MCP query, an artifact identifier, or a generated handoff file in run 004's workspace.

1.2 Candidate Selection Process

OpenPetra spans 27 documented business function subjects covering CRM, financial management, gift processing, conference administration, personnel, sponsorship, and reporting. Picking the right modernization slice is itself an architectural decision: too narrow and the demo is unconvincing; too broad and the strangler-fig boundary becomes ill-defined. Section 2 (Modernization Scope) and Appendix A (Multi-Agent Subsystem Selection) document a three-run consensus selection in which independent agent runs each scored every documented subsystem on revenue impact, behavioural-rule density, coupling, and tractability, then voted. Finance — Gift Processing emerged as the consensus pick: it is OpenPetra's revenue-generating core, has the densest catalog of formalizable business rules, and has clean integration seams to General Ledger and Partner Management that make a strangler-fig boundary viable.

1.3 Report Contents

  • Executive Summary — one-page framing for non-technical stakeholders.
  • Section 1 — Introduction (this section).
  • Section 2 — Modernization Scope — subsystem boundary, in-scope/out-of-scope, target stack.
  • Section 3 — Legacy System Analysis — Concho-vended architecture, entities, integrations.
  • Section 4 — Target Architecture — .NET 10 services on Azure App Service.
  • Section 5 — Platform Affinity Analysis — how each legacy element maps onto the target stack.
  • Section 6 — Technical Debt Analysis — what carries forward, what gets retired.
  • Section 7 — UI/UX Transformation — jQuery forms to Angular 20 components.
  • Section 8 — Code Translation — concrete C# legacy-to-modern examples.
  • Section 9 — Business Rules Analysis — behavioural rules in Given-When-Then form.
  • Section 10 — Data Mapping Strategy — schema translation and slice-boundary FK policy.
  • Section 11 — Modernization Execution & Legacy/Modern Coexistence — strangler-fig sequencing.
  • Section 12 — How Concho Enabled This Analysis — the Concho capability story.
  • Appendix A — Multi-Agent Subsystem Selection; Appendix B — Service Architecture; Appendix C — Deployment.

1.4 What This Report Does NOT Include

To keep the stakeholder review focused on architectural and behavioural decisions, the following are deliberately out of scope for this document:

  • Cost estimates. No Azure consumption forecasts, licensing models, or labour-day pricing — those depend on tenancy decisions and SLAs that have not yet been negotiated.
  • Timelines and project plans. No Gantt charts, sprint plans, or release dates — the report defines what needs to happen and in what order, not when.
  • Deployment plans for the existing OpenPetra installations. Migration runbooks, cutover scripts, and data-extraction tooling for live customer instances are downstream of architectural alignment.
  • Organizational change management. Training plans, role redefinitions, and operational hand-offs are owned by the customer and depend on staffing.

1.5 About OpenPetra (Petra)

OpenPetra is a free, open-source administrative system that helps non-profit organizations manage donor contacts, gift processing, accounting, sponsorship programs, conference logistics, publications, and tax-compliant receipting. It was conceived as an ERP for mission organizations and is notable for its multi-currency, multi-country tax compliance features and its International Clearing House mechanism that minimizes currency-exchange charges between sister organizations. The codebase is built around an unusual but highly disciplined pattern: a single master XML schema (db/petra.xml, 24,924 lines) drives code generation of typed datasets, ORM layers, and validation classes across the entire system.

Project Overview — Concho Cycle 23 Snapshot

Project namePetra (OpenPetra)
Total lines of code (analysed)572,757 LOC  (architecture-tree breakdown: 400,437 LOC after excluding non-source assets)
Primary implementation languageC# (.NET Framework 4.7) — 517 files (39.4% of cataloged files)
Front-endJavaScript / jQuery (56 files, 4.3%); HTML/CSS form templates
Client-server protocolASP.NET Web Services (.asmx, SOAP) and HTTP-RPC web connectors
Data stores supportedPostgreSQL (primary), MySQL, SQLite — multi-RDBMS abstraction layer
Business function subjects (documented)27 subjects spanning Finance, Partner, Sponsorship, Conference, Personnel, Reporting, System Management
Aggregate roots (Concho entity model)48 aggregate roots across 34 bounded contexts (average 6.4 relationships per entity)
Business rules catalogued by Concho216 business rules in the Business Rule Matrix artifact (overall mean confidence 0.70)
Integration boundaries150–151 integration points (83 bidirectional; 28% file-based, 25% web-connector RPC)
Selected subsystem for this reportFinance — Gift Processing
Target stackASP.NET Core 10 (server) + Angular 20 (client), PostgreSQL, Azure App Service
Concho sourceConcho Context Graph, project petra cycle 23

1.6 Stakeholder Reading Guide

1. This Is a Modernization Stakeholder Review

This document is the output of an automated multi-agent modernization workflow — analysis, target-architecture design, behavioural-rule extraction, code translation, data mapping, and a legacy/modern coexistence plan, all composed in a few hours from a single Concho Context Graph of the OpenPetra codebase. It is structured as a stakeholder review — the way a senior architect would present a modernization proposal to an organization for feedback and alignment.

  • Executives see the business case, the scope of the Gift Processing slice, and the modernization strategy at a glance.
  • Architects see the target .NET 10 / Angular 20 design on Azure App Service, the platform-affinity table, and the service decomposition rationale.
  • Developers see concrete C# and TypeScript code translations, Given-When-Then business-rule specifications, and data-mapping examples grounded in OpenPetra's typed-dataset / ORM-generation patterns.
  • Infrastructure teams see the Azure App Service deployment pattern, the PostgreSQL data tier, and the legacy/modern coexistence bridge that lets a live OpenPetra instance keep operating during a strangler-fig cutover.

2. Human-in-the-Loop Iteration

This stakeholder review is designed to be read, challenged, and iterated. Disagree with the subsystem boundary? Adjust the scope in Section 2 and the agents will replan. Want a different Azure pattern (App Service vs. Container Apps vs. AKS)? Swap the target profile and rerun. Think a particular business rule was misclassified? Drop it from the catalog, add a counter-example, and the rule-extraction phase will reconcile. The point of the document is to compress weeks of manual architectural discovery into a single artifact that can absorb stakeholder feedback at the speed of conversation, rather than at the speed of a consulting engagement.

And because this is a Claude Code workflow, iterating is a conversation. Tell Claude — in plain language — “retarget this to ECS Fargate on AWS instead of Azure App Service,” and it does the right thing: it knows the framework’s structured set of agents, skills, prompts, and stored handoff artifacts, so it reruns only the steps that actually change (target architecture, platform affinity, deployment, IaC) and leaves the language-level analysis and business-rule catalog untouched. The same applies to swapping a UI framework, a data-access library, or an auth pattern. For finer control, the decisions live in low-level JSON artifacts you can edit directly — and Claude can tell you exactly which file and field to change. There is no proprietary console to learn: the operating principles are encoded in the framework, and Claude already knows how to drive them.

3. This Report Feeds Automation — A Human Never Reviews a Broken Artifact

Every section of this document produces structured handoff artifacts — the chosen subsystem boundary, the platform-affinity decisions, the Given-When-Then behavioural-rule catalog, the entity-to-schema mapping, the UI storyboard JSON, the slice-boundary FK policy, the coexistence-bridge declarations — that feed directly into a companion agentic code-generation workflow. That workflow consumes the handoffs and emits runnable artifacts on Azure App Service:

  • Services — ASP.NET Core 10 web APIs scaffolded from the platform-affinity decisions and the Concho-vended entry-point catalog.
  • Entity models — C# domain types and EF Core configurations derived from OpenPetra's typed-dataset definitions.
  • UI components — Angular 20 standalone components rendered from the storyboard JSON, replacing the legacy jQuery forms.
  • Database schemas — PostgreSQL DDL generated from the entity model with the configured slice-boundary FK policy (drop-constraints at the Gift / Partner seam).
  • Dockerfiles & IaC templates — container build files and Bicep/Terraform for Azure App Service, Azure Database for PostgreSQL, and Application Insights observability.
  • CI/CD pipelines — GitHub Actions or Azure DevOps definitions that build, test, and deploy the modernized stack.
  • Behavioural tests — xUnit tests for the rule catalog and Playwright end-to-end tests for the Angular UI.

The code-generation workflow runs a BDD/TDD iterate-till-green loop per artifact. The tests are generated from the derived business rules — every Given-When-Then specification in Section 9, plus the field-level rules auto-derived from the UI inventory in Section 9.8, becomes an executable xUnit or Playwright assertion. The workflow generates code from those same specs, runs the suite against the generated code, inspects failures, self-corrects, and re-runs until green. Because both sides — the implementation and its test oracle — are derived from one source of truth, the rule catalog validates itself.

And an adversarial validation agent runs at every step, not just at the end. The same discipline that produced this report — every generation step is followed by an independent validator agent that tries to break the output, checks it against the source evidence, and either passes it or sends it back for correction — carries straight into code generation. Schema mapping, service scaffolding, UI rendering, and the rule-to-test translation are each adversarially checked before the next step consumes them. A human never reviews a broken artifact. What you see in the downstream review is code that has already passed its own behavioural tests and survived an adversarial validator at each stage of its construction.

This means Sections 7 (UI), 8 (Code Translation), 9 (Business Rules), 10 (Data Mapping), and Appendices B–C are both human-readable evidence and machine-consumable specifications. The same artifact that aligns stakeholders is the artifact that drives the code generator.

2. Modernization Scope

This report leverages Concho's deep insight into all 27 subsystems in Petra (OpenPetra). A variety of criteria was applied against each one in order to find the right balance between technical feasibility, business value, and migration risk in order to select a candidate subsystem for this first migration project.

Three independent evaluation passes were conducted — standard weighted scoring, technical-feasibility emphasis, and business-value emphasis. Two of the three runs (Runs 2 and 3) selected the same candidate; Run 1 dissented in favor of a lower-risk alternative. The 2/3 majority prevails, with Run 1's case for the alternative explicitly documented in Appendix A: Multi-Agent Subsystem Selection.

Recommended Modernization Candidate: Finance — Gift Processing

Consensus: 2 of 3 runs  |  Average Weighted Score: +2.47 (across 3 independent runs; rank 1 of 27 by majority recommendation)

Risk: 5.8 / 10 (moderate)  |  Feasibility: 7.0 / 10 (high)  |  Strategic Value: 9.0 / 10 (very high)

2.1 Why Finance — Gift Processing

Gift processing is OpenPetra's flagship donor workflow. A donor sends money, the back office posts a gift batch with motivations and tax-deductibility classification, and the system generates annual tax receipts. It is the defining operational capability of a non-profit ERP, spanning bank import through GL posting through year-end receipting.

This is the right pilot for four reasons:

  • Marquee business value. Donations are the primary revenue mechanism for non-profit organizations. Modernizing gift processing directly impacts the headline workflow and provides the highest-visibility demonstration of modernization ROI to executive sponsors and boards.
  • Pattern reusability across the programme. Gift Processing exercises every spine pattern the rest of the modernization needs: typed-dataset ORM to Entity Framework Core, ASMX web service to ASP.NET Core REST, AngularJS jQuery forms to Angular 20 reactive components, code-generated DAL re-targeting, batch-state machinery for strangler routing, multi-currency and tax-receipt rendering. Patterns proven here transfer directly to Donations Processing, Finance — Banking, Finance — Budgeting, and Reporting — Financial Statements.
  • Clean strangler boundary. The implementation is concentrated in a small set of named seams — TGiftTransactionWebConnector, TGiftBatchFunctions, and the AGift/AGiftBatch/AGiftDetail tables. The posted/unposted batch state machine gives the strangler-fig migration a natural routing key.
  • Right-sized scope. 279 files is meaningful enough to validate the modernization approach, yet bounded enough to complete as a focused pilot. Substantially smaller than Finance — Accounting (370 files) or System Management — Users (390 files), yet large enough to demonstrate real migration capability — unlike Sponsorship — Program Management (12 files) or Hospitality — Accommodation (5 files).

2.2 Technical Scope and Dependencies

Finance — Gift Processing comprises 279 C# files. Its primary integration affinity is to Donations Processing (0.8) and Finance — Accounting (0.6). The web-connector RPC pattern maps cleanly to ASP.NET Core controllers, and the batch posting workflow provides a natural state-based seam for the classical strangler-fig migration strategy specified in the architecture plan. Integration with the legacy MFinance GL is direct but narrow (batch posting contract), enabling a strangler boundary that preserves legacy ledger writes while the new system fronts donor entry and receipting.

2.3 Alternative Methodology: Multi-Agent Subsystem Selection

Finance — Gift Processing was identified through Concho's multi-agent consensus methodology, where three independent AI agents evaluate all subsystems from different perspectives using quantified criteria, then reach consensus on optimal modernization priorities. Two of the three runs converged on Gift Processing; Run 1 advocated for Finance — Banking as a lower-risk alternative. See Appendix A: Multi-Agent Subsystem Selection for the complete three-run analysis, scoring matrices, divergence rationale, and the credible-alternative case for Banking.

3. Legacy System Analysis

TL;DR. Concho's Context Graph (project petra, cycle 23) analysed 572,757 lines of code across 1,396 files at 100% coverage and surfaced a precise picture of OpenPetra's legacy shape: a layered monolith with a code-generation pipeline driven by a 24,924-line master XML schema, 48 aggregate roots across 34 bounded contexts, 216 business rules in the Business Rule Matrix artifact (mean confidence 0.70), 150–151 integration boundaries (83 of them bidirectional), 100 workflows (70% high-complexity), and 148 entry points dominated by REST (47) and SOAP (25). The architecture-layer diagram and data-flow diagram in this section are Concho-vended artifacts rendered verbatim — not agent reconstructions. This section establishes the baseline knowledge that drives every decision in Sections 4–12: target architecture, platform-affinity mapping, technical-debt triage, code translation, business-rules formalization, data-mapping strategy, and the legacy/modern coexistence plan.

3.1 High-Level System Architecture

OpenPetra implements a layered monolithic architecture with extensive code generation driven by XML schema definitions. The Database Schema Definition (db/petra.xml) serves as the single source of truth, orchestrating a Code Generation Pipeline that produces strongly-typed datasets, ORM layers, and RPC interfaces across every layer. The result is a data-centric design: the schema is not merely persistence, it is the architectural backbone.

Five characteristics stand out from the Concho analysis:

  • Report-heavy presentation layer — XML report templates account for 42,307 lines (12.6% of source code), reflecting the regulatory and transparency demands of non-profit operations.
  • Multi-database abstraction — a unified data-access layer supports PostgreSQL, MySQL, and SQLite via a single typed-dataset / ORM-generation pattern (TDBType enum, CommonTypes.ParseDBType()).
  • Extensive validation framework — 14,465 lines (4.3% of source code) of cross-domain validation, ensuring data integrity for financial operations.
  • Bidirectional integration patterns — 83 of 150 integration points are bidirectional, indicating heavy synchronous request/response coupling.
  • Legacy .NET Framework foundation — .NET Framework 4.7 with .NET Remoting underpinning RPC-based client-server communication.

The Mermaid diagram below is the Concho-vended architecture-layer diagram retrieved from the Concho Context Graph artifact store.

Source: Concho Context Graph — architecture_layer_diagram artifact, confidence 0.95.

graph TB subgraph PL["Presentation Layer - 62,824 lines"] WC[Web Client Interface
7,759 lines
presentation controller] RT[XML Report Templates
42,307 lines
computation engine] FT[Form Templates
5,850 lines] ET[Email Templates
186 lines] LOC[Localization
3,390 lines] end subgraph AL["Application Layer - 123,408 lines"] FE[Finance Operations Engine
54,657 lines
computation engine] PM[Partner Management Operations
30,146 lines
service facade] WS[Web Service API Layer
8,982 lines
service facade] CM[Conference Management
2,756 lines] SA[System Administration
5,542 lines] end subgraph CC["Cross-Cutting Concerns - 51,879 lines"] CU[Common Utilities Framework
16,159 lines
shared kernel] VF[Business Rules Validation
14,465 lines
domain core] EH[Exception Handling Framework
8,550 lines
infrastructure plumbing] PF[Document Generation Engine
6,735 lines
computation engine] SF[Security Framework
2,067 lines] end subgraph DL["Data Layer - 48,404 lines"] DS[Database Schema Definition
24,924 lines
data access] DA[Database Abstraction Layer
9,887 lines
data access] BD[Base Data
1,897 lines] CH[Caching
2,320 lines] DU[Database Upgrades
1,684 lines] end subgraph IL["Integration Layer - 15,288 lines"] FIE[File Import Export Gateway
9,830 lines
integration gateway] BI[Bank Import
4,250 lines] SE[SEPA Export
614 lines] EA[External APIs
88 lines] end subgraph INF["Infrastructure - 15,496 lines"] SC[Server Configuration
3,281 lines] SM[Session Management
2,204 lines] RF[Remoting Framework
1,674 lines] SFR[Security Framework
2,725 lines] WEB[Web Services
1,297 lines] LOG[Logging
1,172 lines] end subgraph BLD["Build & Deployment - 36,986 lines"] CG[Code Generation Pipeline
14,508 lines
computation engine] DT[Development Tools Suite
7,519 lines
computation engine] BS[Build Scripts
4,307 lines] DM[Dependency Management
6,690 lines] end WC --> WS RT --> FE RT --> PM WC --> FE WC --> PM FE --> VF PM --> VF WS --> FE WS --> PM FE --> DA PM --> DA WS --> DA DA --> DS AL -.-> CC PL -.-> CC DL -.-> CC IL -.-> CC FE --> FIE PM --> FIE WS --> IL AL --> INF PL --> INF IL --> INF DS -.->|entanglement| CG CG --> DS CG --> DA CG --> AL classDef majorElement fill:#e1f5fe classDef secondaryElement fill:#f3e5f5 classDef infrastructureElement fill:#fff3e0 class FE,RT,PM,DS majorElement class CU,CG,VF,WC,DA,FIE,WS,EH,DT,PF secondaryElement class INF,BLD infrastructureElement

3.2 Architectural Layers

Concho's architecture-tree analysis decomposes the codebase into eight top-level layers with a line-count distribution that immediately reveals where the legacy weight lives.

Layer Lines of Code Share Largest sub-cluster (per Concho)
Application Layer116,77529.2%Finance Management 61,632 LOC (General Ledger 22,615; Gift Processing 17,103)
Presentation Layer64,64816.1%Report Templates 43,521 LOC (Finance Reports 16,674)
Data Layer48,03312.0%Database Schema 24,925 LOC (the master petra.xml)
Cross-Cutting Concerns40,80410.2%Validation Framework 9,535 LOC; Common Utilities 9,214 LOC
Infrastructure36,9779.2%Web Services 12,994 LOC; Security Framework 7,165 LOC
Build & Deployment34,1128.5%Development Tools 13,819 LOC; Code Generation 12,379 LOC
Test26,8496.7%Integration Tests 15,402 LOC; Test Utilities 11,447 LOC
Documentation19,3544.8%Database Documentation 15,820 LOC (schema diagrams)
Integration Layer12,8853.2%File Import/Export 7,413 LOC; Bank Import 3,876 LOC
Total (architecture-tree scope)400,437100%Total codebase (incl. assets): 572,757 LOC

Three observations matter for modernization planning:

  • Finance Management dominates application logic (61,632 LOC), and within it Gift Processing (17,103 LOC) sits next to General Ledger (22,615 LOC) — the chosen subsystem boundary therefore has a real, measurable presence rather than being a token slice.
  • Report Templates are the largest single sub-cluster (43,521 LOC). They use a proprietary NO-SQL template syntax that escapes static analysis; the modernization plan needs an explicit strategy for them (see Section 6 and Section 10).
  • Code Generation is itself 12,379 LOC. The build system generates a significant share of the C# in the Data Layer and Application Layer — meaning the modernization plan cannot simply "translate the code"; it must decide whether to keep the generation pipeline, port it, or replace it (Section 4, Section 8).

3.3 Data Entities

Concho's entity extraction discovered 48 aggregate roots across 34 bounded contexts with 100% STRUCTURAL evidence (i.e., every root is grounded in a concrete code structure, not inferred). The average is 6.4 relationships per entity, and the domain enforces 265 business rules across these roots at an average confidence of 0.80. The table below lists the highest-confidence aggregate roots that participate in or border the Finance — Gift Processing slice.

Aggregate root Confidence Relationships Rules Role in Gift Processing
Partner0.911715Donor / recipient / family / organisation; addresses; consent
Gift0.8777Individual donation transactions (one-time and recurring) with multi-currency / SEPA mandate handling
GiftBatch0.8755Batch lifecycle (DRAFT → VALIDATED → READY_FOR_POSTING → POSTED → COMPLETED); tax-receipt generation
GeneralLedger0.8988Posting target for gift batches; chart of accounts and cost centres
FinancialLedger0.8565Multi-currency operations; retained earnings; cost-centre hierarchy
PartnerFamily0.8354Household / family aggregation for receipting
UserAccount0.91108Identity of the user posting a gift batch (authentication / authorization)
UserPermission0.8575Module permissions controlling who may import, validate, or post
DataImportExport0.8745CSV / XML / Excel import of gift batches; GDPdU export
Report0.871010XML-template reporting (e.g., donor statements, gift receipts)

Source: Concho search_entities (aggregate_root, confidence ≥ 0.75) plus the business_domain_map and bounded_context_map artifacts. The full Concho catalogue contains 40 aggregate roots at confidence ≥ 0.75 across all domains; only those participating in or bordering the Gift Processing slice are shown here.

The ER diagram below is derived from Concho entity discovery (relationships above are extracted from the Concho aggregate-root metadata; cardinalities follow the OpenPetra typed-dataset definitions referenced by Concho as PPartnerTable, AGiftBatchTable, AGiftTable, AGiftDetailTable). It is intentionally scoped to the Gift Processing slice and its immediate borders.

Derived from Concho entity / architecture discovery (search_entities + business_domain_map). Not a single Concho-vended artifact; constructed by the project-intel agent from Concho data.

erDiagram Partner ||--o{ Gift : "donates" Partner ||--o{ Gift : "recipient" Partner }o--|| PartnerFamily : "belongs to" Partner ||--o{ PartnerAddress : "has" Partner ||--o{ PartnerConsent : "grants" GiftBatch ||--|{ Gift : "contains" Gift ||--|{ GiftDetail : "split into" GiftBatch }o--|| GeneralLedger : "posts to" GiftBatch }o--|| FinancialLedger : "operates within" GiftBatch }o--|| UserAccount : "created by" UserAccount }o--o{ UserPermission : "granted" DataImportExport ||--o{ GiftBatch : "imports/exports" Report }o--o{ Gift : "summarises" Report }o--o{ GiftBatch : "reports on" Partner { long PartnerKey PK string PartnerClass "PERSON / FAMILY / ORG / UNIT / CHURCH / BANK / VENUE" string StatusCode bool ReceiptEachGift } Gift { long LedgerNumber PK long BatchNumber PK long GiftTransactionNumber PK long DonorKey FK decimal GiftAmount string CurrencyCode date DateEntered } GiftDetail { long LedgerNumber PK long BatchNumber PK long GiftTransactionNumber PK long DetailNumber PK long RecipientKey FK string MotivationCode decimal GiftAmount } GiftBatch { long LedgerNumber PK long BatchNumber PK string BatchStatus "Unposted / Posted / Cancelled" date GlEffectiveDate string CurrencyCode } GeneralLedger { long LedgerNumber PK int CurrentPeriod string BaseCurrency string IntlCurrency } FinancialLedger { long LedgerNumber PK string LedgerName string CostCentreCode } UserAccount { string UserId PK long PartnerKey FK bool AccountLocked } UserPermission { string UserId PK string ModuleId PK bool CanCreate bool CanModify } DataImportExport { string Source string Format "CSV / XML / XLSX / ODS" } Report { string ReportId string Template }

3.4 Business Rules & Behavior

Concho's Business Rule Matrix artifact (confidence 0.92) catalogues 216 business rules distributed across 48 aggregate roots and 100 workflows within 33 bounded contexts:

  • 43% validation rules — data integrity, format constraints, domain invariants.
  • 17% authorization rules — module-level access control, permission gates.
  • 16% derivation rules — computed fields, currency conversions, allocations.
  • The remaining 24% cover orchestration, lifecycle, and integration rules.
  • Overall mean confidence: 0.70; 40 rules are explicitly flagged as compliance-relevant.

Section 9 (Business Rules Analysis) populates this rule catalogue with explicit Given-When-Then specifications for the rules that govern the Gift Processing slice — sequential batch numbering, financial-period validation, partner existence, motivation-code assignment, multi-currency exchange-rate lookup, SEPA mandate compliance, posting-status transitions, and tax-receipt eligibility. These specifications then feed the BDD/TDD code-generation loop described in Section 1.6.

Placeholder: the full Given-When-Then catalogue is generated by the behavioral-rules phase (Section 9) and is not duplicated here.

3.5 Technology Stack

OpenPetra's documented technology footprint as catalogued by Concho:

Layer / concern Technology Prevalence (per Concho) Notes
Server languageC# on .NET Framework 4.739.4% (517 files)Primary implementation; locked to legacy .NET Framework
Client-server protocolASP.NET Web Services (.asmx / SOAP) + HTTP-RPC web connectors + .NET Remoting12 .asmx files; 4,095 LOC remoting frameworkSession-managed via OpenPetraSessionID cookie; binary-serialized SortedList parameters
REST surfaceServer — REST Services (Web Connectors)47 REST entry points (per entry_point_catalog)Coexists with 25 SOAP entry points; 148 entry points in total
Front-endJavaScript / jQuery + HTML/CSS form templates4.3% (56 files)Bootstrap modals, AJAX over HTTP-RPC; TypeScript present in selected modules
Primary data storePostgreSQL4.5% file share (59 files)Target persistence for modernization
Alternate data storesMySQL, SQLite Supported via the multi-RDBMS abstraction layer
Schema definitionXML (Data Models)20.5% (269 files)Master schema db/petra.xml (24,924 LOC) drives all code generation
Build systemNAnt1.4% (18 files)Custom build pipeline including ORM & interface generation
ReportingXML report templates (proprietary "NO-SQL" syntax)43,521 LOC of templatesEscapes static analysis; PDF / HTML generation via HtmlAgilityPack
TestingNUnit unit/integration tests + Cypress E2E31 test files / 27,050 LOC (1:19 test-to-source)Per Concho Testing Profile artifact
Banking integrationCAMT, MT940, SEPA XML parsers3,876 LOC bank import; 245 LOC SEPA exportEuropean direct-debit and statement formats
Security / authCustom password hashing, .NET Remoting auth, partner ACLs7,165 LOC security frameworkConcho Security Posture flagged configurable SSL bypass & 226 hard-coded constraints

Across all 34 technology subjects, three observations frame the target-architecture decisions in Section 4: (1) the language is already C#, so the move to .NET 10 is a runtime/library port rather than a re-write; (2) the front-end is jQuery, so an Angular 20 rebuild is unavoidable but localized; (3) the data tier already runs PostgreSQL, so the modernization can lift-and-shift schemas while flattening the multi-RDBMS abstraction layer.

3.6 Integration Patterns

Concho's Integration Boundary Map (confidence 0.95) documents 150–151 integration points across 216 files: 28% file integrations, 25% web connectors, 88.7% supported by STRUCTURAL evidence, average confidence 0.71. Bidirectional connections dominate (83 of 151), indicating heavy synchronous request/response coupling rather than event-driven decoupling. The dominant integrations are TSponsorshipWebConnector, TImportExportWebConnector, the OpenPetra SOAP Web Service, Partner Contact Search, and TGiftTransactionWebConnector.

The data-flow diagram below is the Concho-vended data_flow_diagram artifact. It highlights four primary data channels: web-form imports, automated bank imports, gift-batch CSV imports, and report generation. All four converge on the Finance Operations Engine and Partner Management Operations, which persist through the multi-RDBMS Database Abstraction Layer.

Source: Concho Context Graph — data_flow_diagram artifact, confidence 0.88.

graph LR subgraph EntryPoints["Entry Points"] EP1[Web Forms] -->|partner JSON| WF1[PartnerImportProcessing] EP2[Bank Import Files] -->|MT940/CAMT/CSV| WF2[BankStatementImportMatching] EP3[Gift Import] -->|CSV batches| WF3[GiftBatchProcessing] EP4[Report Requests] -->|parameters| WF4[FinancialReportGeneration] end subgraph AppLayer["Application Layer Processing"] WF1 -->|validated partner data| PMO[Partner Management Operations] WF2 -->|matched transactions| FOE[Finance Operations Engine] WF3 -->|posted gifts| FOE WF4 -->|template processing| XRTD[XML Report Template Definitions] PMO -->|partner relationships| AGG1[(Partner)] FOE -->|financial transactions| AGG2[(GeneralLedger)] FOE -->|gift records| AGG3[(GiftBatch)] end subgraph DataLayer["Data Layer Persistence"] AGG1 -->|SQL queries| DBA[Database Abstraction Layer] AGG2 -->|GL entries| DBA AGG3 -->|batch posting| DBA DBA -->|typed datasets| DB[(PostgreSQL/MySQL)] CGP[Code Generation Pipeline] -->|generates ORM| DBA DSB[Database Schema Definition] -->|petra.xml| CGP end subgraph Boundaries["Integration Boundaries"] IB1[OpenPetra Web Connector RPC] -.->|HTTP RPC| PMO IB1 -.->|session management| FOE XRTD -->|PDF/HTML output| IB2[Document Generation Engine] AGG1 -->|extract generation| IB3[File Import Export Gateway] end

Three properties of this picture become design constraints for the modernization:

  • The Gift Processing slice has a sharp inbound seam (CSV/MT940/CAMT) and a sharp outbound seam (GL posting). Both are good strangler-fig boundaries: replaceable independently of the rest of the application.
  • RPC web connectors are the dominant client/server protocol, not REST or events. The target architecture replaces these with explicit REST endpoints on ASP.NET Core 10 (Section 4 and Appendix B).
  • The Code Generation Pipeline is wired into the runtime data path, not just the build — the ORM that runs is generated by the same pipeline that builds. The legacy/modern coexistence plan in Section 11 has to keep that pipeline working on the legacy side while the modern side migrates to EF Core.

4. Target Architecture

TL;DR — The Finance — Gift Processing subsystem modernizes to a 2-service .NET 10 architecture on Azure App Service, with an Angular 20 SPA and Azure Database for PostgreSQL. Azure API Management provides classical strangler-fig routing so legacy and modern subsystems run side-by-side throughout the modernization. Infrastructure as Code (Bicep), OpenTelemetry observability, Polly resilience, and Azure Key Vault secrets are wired in from day one.

This section defines the target Azure App Service architecture for the modernized Finance — Gift Processing subsystem (279 files, DonationManagement bounded context), including design principles, technology stack, code-level architecture decisions, and target data model. A multi-agent service architecture analysis determined the optimal decomposition: 2 microservices for this modernization — Gift Processing API and Receipt Generation Service.

The 2-service architecture was selected through unanimous consensus of Domain-Driven Design, Technical Architecture, and Business Capability analyses, achieving a composite decision score of 7.8 / 10 (gate: ≥ 7.0). Section 4.5 summarizes the decision; the full multi-perspective analysis — per-lens proposals, rejected alternatives, per-service API contracts, and the implementation roadmap — is documented in Appendix B: Service Architecture.

4.1 Target Architecture Overview

The modernized Gift Processing subsystem is deployed as a multi-service .NET 10 application on Azure App Service, replacing the legacy Mono/FastCGI-hosted ASP.NET Web Services (.asmx/SOAP) architecture with a modern REST API backed by Entity Framework Core on Azure Database for PostgreSQL. The Angular 20 single-page application replaces the legacy jQuery/AngularJS front-end, delivering a responsive donor management experience served as static files from the same App Service plan.

The architecture follows the classical strangler-fig modernization strategy: Azure API Management routes gift-processing traffic to the new .NET 10 services while legacy subsystems (GL posting, Partner management) continue operating on the existing infrastructure. As each subsystem is modernized, traffic shifts from legacy to modern without requiring a big-bang cutover. The DonationManagement bounded context (confidence 0.84) maps directly to the service boundary, with read-only Partner validation and one-way GL posting accessed through well-defined integration interfaces fronted by the strangler bridge.

Key Architecture Principles

  • API-first design. All gift processing operations are exposed through ASP.NET Core Minimal APIs with OpenAPI documentation (Swashbuckle), enabling both the Angular SPA and future integrations to consume the same contract.
  • Bounded context alignment. Service decomposition maps to the DonationManagement bounded context identified by Concho analysis, keeping gift batch processing, receipt generation, and recurring gift management within a cohesive service boundary.
  • Same-language modernization. C# to C# transition (from .NET Framework 4.7 to .NET 10) preserves domain knowledge embedded in the existing codebase while modernizing the runtime, dependency injection, async patterns, and configuration model.
  • Strangler-fig routing. Azure API Management owns the public surface and routes per-path: modern endpoints to the new App Services, unmodified routes (Partner, GL, Reporting until later phases) to the legacy server. This enables incremental traffic shifting without coordinated big-bang cutovers.
  • Observability by default. OpenTelemetry tracing and Serilog structured logging with correlation IDs feed into Azure Application Insights, providing end-to-end visibility across gift processing workflows from bank import through GL posting and annual receipt generation.
  • Infrastructure as Code. All Azure resources are defined in Bicep templates, enabling repeatable provisioning across development, staging, and production environments with slot-based blue/green deployments.
  • Resilience through Polly. Transient fault handling with exponential backoff and circuit breaker patterns protect against downstream dependency failures (Partner validation, GL posting bridge), ensuring gift processing degrades gracefully rather than failing completely.

4.2 Target Architecture Diagram

graph TB
    subgraph "Client Layer"
        SPA["Angular 20 SPA
(Gift Batches, Recurring Gifts,
Motivations, Bank Import,
Annual Receipts)"] end subgraph "Edge & Gateway" FD["Azure Front Door
(WAF + TLS Termination)"] APIM["Azure API Management
(Strangler-Fig Routing)"] end subgraph "Azure App Service Plan (P1v3 Linux)" API["Gift Processing API
(.NET 10 Minimal API)
- Batch CRUD & Posting
- Tax Deductibility Calc
- Multi-Currency Handling
- Recurring Gift Mgmt"] RECEIPT["Receipt Generation Service
(.NET 10)
- Annual Receipt PDF
- HTML Template Processing
- Donor Statement Export"] end subgraph "Data & State" PG[("Azure Database
for PostgreSQL
(Gift Batches, Gifts,
Gift Details, Motivations,
Recurring Gifts)")] REDIS["Azure Cache
for Redis
(Session, Caching)"] BLOB["Azure Blob Storage
(Receipt PDFs,
Import Files)"] end subgraph "Security & Config" KV["Azure Key Vault
(Connection Strings,
API Keys)"] ENTRA["Microsoft Entra ID
(User Auth)"] end subgraph "Observability" AI["Azure Application Insights
(Traces, Metrics, Logs)"] LAW["Log Analytics
Workspace"] end subgraph "Legacy Bridge (Strangler-Fig)" LEGACY_PARTNER["Legacy Partner Service
(Read-Only Validation)"] LEGACY_GL["Legacy GL Service
(One-Way Posting)"] end SPA --> FD FD --> APIM APIM --> API APIM --> RECEIPT APIM -.->|"unmodified routes"| LEGACY_PARTNER APIM -.->|"unmodified routes"| LEGACY_GL API --> PG API --> REDIS API --> BLOB API --> KV API --> ENTRA API --> AI API -.->|"REST/JSON"| LEGACY_PARTNER API -.->|"REST/JSON"| LEGACY_GL RECEIPT --> PG RECEIPT --> BLOB RECEIPT --> AI AI --> LAW classDef modern fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px classDef azure fill:#e3f2fd,stroke:#1565c0,stroke-width:2px classDef legacy fill:#fff3e0,stroke:#e65100,stroke-width:1px,stroke-dasharray:5 classDef client fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px class SPA client class API,RECEIPT modern class FD,APIM,PG,REDIS,BLOB,KV,ENTRA,AI,LAW azure class LEGACY_PARTNER,LEGACY_GL legacy

Target Azure App Service architecture for Finance — Gift Processing — 2-microservice decomposition (Gift Processing API + Receipt Generation Service) from multi-agent consensus analysis (composite score 7.8/10). Full analysis in Appendix B.

4.3 Target Technology Stack

Category Technology Version / SKU Rationale
Compute Azure App Service (Linux) P1v3 (PremiumV3) Managed PaaS with slot-based blue/green deployments, autoscale (2–6 instances), and always-on for production workloads. Lower operational ceiling than AKS for a small-fleet topology.
Server Runtime .NET 10 (ASP.NET Core Minimal API) 8.0 LTS Same-language C# modernization from .NET Framework 4.7 preserves domain logic. Minimal APIs reduce ceremony versus MVC controllers. LTS support through November 2026.
Client Framework Angular 18 Replaces legacy jQuery/AngularJS forms with a component-based SPA. Strong TypeScript typing, reactive forms for complex gift batch entry, and Angular CLI for standardized builds.
Database Azure Database for PostgreSQL Flexible Server (General Purpose, 2 vCores) Legacy system already supports PostgreSQL as a backend through the multi-database abstraction layer. Managed service eliminates DBA overhead for patching, backups (7-day retention), and high availability.
ORM / Data Access Entity Framework Core 8 (Npgsql provider) Replaces legacy typed datasets generated from db/petra.xml. Code-first migrations enable version-controlled schema evolution. Connection resiliency via execution strategy.
Caching Azure Cache for Redis Standard C1 Session state, motivation code lookups, and exchange rate caching. Reduces database round-trips for frequently accessed reference data.
Object Storage Azure Blob Storage Standard LRS, Hot tier Receipt PDF storage, bank statement import files (CSV, MT940, CAMT, ZIP), and HTML receipt templates. Replaces file-system storage from the legacy Mono deployment.
API Gateway Azure API Management Standard V2 Strangler-fig routing: gift-processing requests to new services, unmodified subsystems to legacy. Rate limiting, idempotency key injection, and OpenAPI aggregation.
Edge / WAF Azure Front Door Premium TLS termination, WAF in Prevention mode, and geo-routing for multi-region non-profit deployments.
Identity Microsoft Entra ID + Managed Identity User authentication via JWT Bearer tokens. Service-to-service auth via Managed Identity (no credential rotation). Replaces legacy session-based TOpenPetraOrgSessionManager.
Secrets Azure Key Vault Standard (RBAC) Connection strings, API keys, and SMTP credentials stored with RBAC authorization. App Service references secrets via @Microsoft.KeyVault(SecretUri=...) syntax. Purge protection and 90-day soft-delete enabled.
Observability Azure Application Insights + Log Analytics 100% sampling, 90-day retention OpenTelemetry traces + Serilog structured logs converge in Application Insights. End-to-end transaction correlation across gift batch entry, posting, and receipt generation.
IaC Bicep Azure-native declarative infrastructure. Simpler than ARM templates, tighter Azure integration than Terraform for this scope.
CI/CD GitHub Actions Build → Test → Deploy-Staging → Smoke-Test → Swap-to-Production pipeline with slot-swap rollback.

The technology choices above are deliberately conservative for a pilot modernization: Azure App Service eliminates container orchestration complexity, PostgreSQL preserves the existing data engine, and the C#-to-C# path reduces translation risk. Each choice is proven at scale in Azure-hosted .NET workloads and provides a reusable pattern for subsequent subsystem modernizations (Finance — Banking, Finance — Accounting).

4.3.1 Target Code-Level Architecture Decisions

The following table documents code-level patterns and library choices that govern all generated code examples throughout this report (Sections 7–9 and Appendix C). These decisions are resolved from the Azure App Service architecture template's targetPatterns block (no project-specific overrides applied) and apply uniformly across all services in the subsystem.

Category Decision Library / Tool Rationale
API Framework ASP.NET Core Minimal APIs for all HTTP endpoints ASP.NET Core 10 Minimal API Reduces boilerplate versus MVC controllers; natural mapping from legacy .asmx operations to MapPost/MapGet endpoint groups. OpenAPI auto-generation via Swashbuckle.
Input Validation FluentValidation at all API boundaries FluentValidation Replaces legacy server-side TVerificationResultCollection pattern with declarative, testable validation rules. Applied to every request DTO entering the API layer.
Error Handling Domain exceptions with RFC 7807 Problem Details responses ASP.NET Core ProblemDetails + custom domain exceptions Typed exception hierarchy (e.g., GiftBatchNotFoundException, PostingPeriodClosedException) replaces legacy numeric return codes and TVerificationResult objects. RFC 7807 provides machine-readable error responses.
Resilience Polly for retry, circuit breaker, and timeout policies Polly (exponential backoff + circuit breaker) Protects calls to legacy Partner validation bridge and GL posting bridge. Exponential backoff for transient failures; circuit breaker prevents cascade failures during legacy outages.
Database Access Entity Framework Core with code-first migrations EF Core 10 + Npgsql + EF Core Migrations Replaces petra.xml-generated typed datasets with strongly-typed DbContext models. Code-first migrations replace manual schema upgrade scripts (e.g., Upgrade202206_202207.cs). Connection resiliency via EF Core execution strategy.
Serialization System.Text.Json for all request/response serialization System.Text.Json (source-generated) Built-in .NET 10 serializer with source-generated serialization for performance. Replaces legacy XML/SOAP serialization with JSON. Camel-case naming by convention.
Observability — Tracing OpenTelemetry for distributed tracing OpenTelemetry .NET SDK Vendor-neutral CNCF standard. Auto-instrumentation for ASP.NET Core, EF Core, and HttpClient. Traces exported to Application Insights via OTLP.
Observability — Logging Serilog with JSON structured logging and correlation IDs Serilog + Serilog.AspNetCore + Serilog.Sinks.ApplicationInsights Replaces legacy TLogging with structured, machine-parseable JSON logs. Correlation IDs from OpenTelemetry context injected into every log entry for trace-to-log correlation.
Observability — Metrics OpenTelemetry metrics exported to Application Insights OpenTelemetry .NET Metrics API Business metrics (gifts processed, batches posted, receipts generated) alongside standard HTTP/DB metrics. Single pipeline for traces, logs, and metrics.
Health Checks ASP.NET Core Health Checks with readiness, liveness, and startup probes Microsoft.Extensions.Diagnostics.HealthChecks Readiness probe (/health/ready) verifies PostgreSQL and Redis connectivity. Liveness probe (/health/live) confirms process responsiveness. Startup probe (/health/startup) gates traffic until initialization completes.
Configuration & Secrets appsettings.json + environment variables + Azure Key Vault references ASP.NET Core Configuration + Azure Key Vault 12-factor configuration via environment variables in App Service. Secrets stored in Key Vault with @Microsoft.KeyVault(SecretUri=...) reference syntax. No secrets in code or config files.
Inter-Service Communication REST/HTTP/JSON (synchronous); Azure Service Bus (asynchronous, on-demand) HttpClient + System.Text.Json; Azure.Messaging.ServiceBus Synchronous REST for on-demand operations between services. Service Bus available for bulk receipt generation and event-driven scenarios (not provisioned by default; introduced only when an async slice is in scope).
Testing xUnit with WebApplicationFactory for integration tests xUnit + Microsoft.AspNetCore.Mvc.Testing In-memory integration tests verify API contracts, validation rules, and database operations. 80% coverage target. Replaces legacy NUnit test infrastructure.
API Documentation OpenAPI 3.0 specification auto-generated from endpoints Swashbuckle.AspNetCore Interactive Swagger UI for developer experience. Schema generated from Minimal API endpoint metadata and FluentValidation rules.
SPA Hosting Angular 20 static files served from App Service Angular CLI + App Service static file middleware Angular app built by CI/CD and deployed alongside the .NET API. No separate static hosting service needed for a small-fleet topology.

4.4 Target Data Model

The target data model is derived from Concho's analysis of the legacy petra.xml-generated typed datasets and the 11 SQL query files associated with the Finance — Gift Processing subsystem. The model normalizes the legacy schema into PostgreSQL-native types while preserving the core entity relationships: gift batches contain gifts, gifts contain gift details, and motivations categorize donations for financial reporting and tax compliance.

erDiagram
    GIFT_BATCH {
        int batch_id
        int ledger_id
        varchar batch_description
        varchar batch_status "Posted | Unposted | Cancelled"
        date gl_effective_date
        varchar currency_code
        decimal exchange_rate_to_base
        int batch_year
        int batch_period
        timestamp created_at
        timestamp updated_at
    }

    GIFT {
        int gift_id
        int batch_id
        int gift_transaction_number
        bigint donor_partner_key
        date date_entered
        boolean receipt_printed
        boolean first_time_gift
        varchar method_of_giving_code
        varchar method_of_payment_code
        varchar receipt_letter_code
        varchar reference
    }

    GIFT_DETAIL {
        int gift_detail_id
        int gift_id
        int detail_number
        bigint recipient_partner_key
        decimal gift_transaction_amount
        decimal gift_amount_in_base_currency
        decimal gift_amount_intl
        varchar motivation_group_code
        varchar motivation_detail_code
        varchar cost_centre_code
        varchar account_code
        decimal tax_deductible_pct
        boolean tax_deductible
        varchar gift_comment_one
        varchar gift_comment_two
        varchar gift_comment_three
        boolean modified_detail
        boolean confidential_gift
    }

    MOTIVATION_GROUP {
        varchar motivation_group_code
        int ledger_id
        varchar motivation_group_description
        boolean group_status_active
    }

    MOTIVATION_DETAIL {
        varchar motivation_group_code
        varchar motivation_detail_code
        int ledger_id
        varchar motivation_detail_desc
        varchar account_code
        varchar cost_centre_code
        decimal tax_deductible_pct
        boolean receipt_eligible
        boolean active
    }

    RECURRING_GIFT_BATCH {
        int batch_id
        int ledger_id
        varchar batch_description
        varchar batch_status
        varchar currency_code
        decimal exchange_rate_to_base
    }

    RECURRING_GIFT {
        int recurring_gift_id
        int batch_id
        bigint donor_partner_key
        varchar frequency_code
        date start_donations
        date end_donations
        varchar sepa_mandate_reference
        date sepa_mandate_date
    }

    RECURRING_GIFT_DETAIL {
        int recurring_gift_detail_id
        int recurring_gift_id
        int detail_number
        bigint recipient_partner_key
        decimal gift_amount
        decimal gift_amount_intl
        varchar motivation_group_code
        varchar motivation_detail_code
        varchar cost_centre_code
        varchar account_code
        decimal tax_deductible_pct
    }

    GIFT_BATCH ||--o{ GIFT : "contains"
    GIFT ||--o{ GIFT_DETAIL : "has details"
    GIFT_DETAIL }o--|| MOTIVATION_DETAIL : "categorized by"
    MOTIVATION_GROUP ||--o{ MOTIVATION_DETAIL : "groups"
    RECURRING_GIFT_BATCH ||--o{ RECURRING_GIFT : "contains"
    RECURRING_GIFT ||--o{ RECURRING_GIFT_DETAIL : "has details"
    RECURRING_GIFT_DETAIL }o--|| MOTIVATION_DETAIL : "categorized by"
  

Target Table Structure Details

Table Purpose Key Columns Indexes
gift_batch Gift batch headers with posting status and financial period assignment batch_id (PK), ledger_id, batch_status, gl_effective_date, currency_code IX_gift_batch_ledger_status (ledger_id, batch_status), IX_gift_batch_period (batch_year, batch_period)
gift Individual gift transactions within a batch, linked to donor partner gift_id (PK), batch_id (FK), donor_partner_key, date_entered IX_gift_batch (batch_id), IX_gift_donor (donor_partner_key), IX_gift_date (date_entered)
gift_detail Line items with amounts, motivation codes, tax deductibility, and recipient gift_detail_id (PK), gift_id (FK), motivation_group_code, motivation_detail_code, tax_deductible_pct IX_gift_detail_gift (gift_id), IX_gift_detail_motivation (motivation_group_code, motivation_detail_code), IX_gift_detail_recipient (recipient_partner_key)
motivation_group Top-level motivation categories for donation classification motivation_group_code (PK), ledger_id (PK) Primary key composite index
motivation_detail Specific motivation codes within groups, with account mappings and tax rules motivation_group_code (PK/FK), motivation_detail_code (PK), ledger_id (PK), account_code, tax_deductible_pct Primary key composite index, IX_motivation_detail_account (account_code)
recurring_gift_batch Recurring donation batch headers for automated collection batch_id (PK), ledger_id, batch_status, currency_code IX_recurring_batch_ledger (ledger_id)
recurring_gift Recurring donation definitions with SEPA mandate references recurring_gift_id (PK), batch_id (FK), donor_partner_key, frequency_code, sepa_mandate_reference IX_recurring_gift_batch (batch_id), IX_recurring_gift_donor (donor_partner_key)
recurring_gift_detail Recurring donation line items with amounts and motivation mappings recurring_gift_detail_id (PK), recurring_gift_id (FK), motivation_group_code, motivation_detail_code IX_recurring_detail_gift (recurring_gift_id)

Cross-service data access: The donor_partner_key and recipient_partner_key columns reference Partner records managed by the legacy system. These are not foreign keys in the target database — the drop-constraints slice-boundary FK policy (specified in report-plan.json) means partner validation is enforced at the application layer through the strangler-fig bridge to the legacy Partner service, not through database-level referential integrity. This enables independent deployment of the gift-processing slice without requiring Partner modernization to complete first.

For detailed schema mappings from legacy typed datasets and SQL query files to this target data model, see Section 10: Data Mapping Strategy.

4.5 Target Service Architecture

Consensus outcome: 2 services. Three independent perspectives — Domain-Driven Design, Technical Architecture, and Business Capability — all converged on a two-service decomposition. Composite weighted score: 7.8 / 10 (gate: ≥ 7.0). Full multi-perspective analysis in Appendix B.

The two target services are Gift Processing API (donation processing, bank import, recurring gifts, motivation administration, posting state machine) and Receipt Generation Service (annual tax-receipt PDFs, template registry, donor statement export).

4.5.1 Consensus Across Lenses

Lens Service Count Weighted Score Key Insight
Domain-Driven Design 2 7.9 Single bounded context (DonationManagement, confidence 0.84) splits into two aggregate clusters: Gift Lifecycle (transactional) and Receipt Issuance (document pipeline).
Technical Architecture 2 7.7 Year-end receipt CPU/memory spike has a fundamentally different scaling profile from year-round gift entry; deployment independence keeps template churn off the transactional core.
Business Capability 2 7.9 Two jobs-to-be-done with different cadences and often different humans: daily donation processing (finance ops) and year-end receipt issuance (compliance/tax).

4.5.2 Open Questions Resolved

Question Consensus Resolution
Final service count 2. All three lenses agree; no dissent.
Receipt Generation data access API-only. Receipt Generation reads posted gifts via GET /api/gift-processing/v1/donors/{partnerKey}/posted-gifts. No shared database access — preserves the GiftBatch posted-state invariant at exactly one boundary.
Async pathway for bulk receipts Optional / on-demand. Synchronous bulk generation with progress reporting is sufficient at pilot scale. Azure Service Bus is provisioned only if year-end runtime exceeds user tolerance or durable retry becomes necessary.
Bank Import as separate service? No. Bank statement parsing (CAMT/MT940/CSV/ZIP) is a workflow that writes the GiftBatch aggregate — it stays as an internal handler inside Gift Processing API.
Motivation Administration as separate service? No. Motivation codes are reference data referenced by every gift detail. A separate service would force a cross-service call on the hottest read path. Stays inside Gift Processing API as an admin sub-controller.

4.5.3 Target Service Summary

Service Primary Responsibilities Owned Tables Scaling Profile
Gift Processing API Gift batch CRUD & posting, bank import, recurring gifts & SEPA mandates, motivation administration, multi-currency, tax-deductibility calculation gift_batch, gift, gift_detail, motivation_group, motivation_detail, recurring_gift_batch, recurring_gift, recurring_gift_detail (8) Year-round steady; autoscale 2–4 instances; user-driven load
Receipt Generation Service Annual tax-receipt PDF generation (single and bulk), HTML template registry, donor statement export, receipt sequence numbering, reprint with audit trail receipt_issuance, receipt_template (2) Year-end spike (Jan–Feb); autoscale 1–6 instances; bursty CPU/memory for PDF rendering

4.5.4 Target Integration Pattern

Gift Processing API is the authoritative source of donation data. Receipt Generation Service is a one-way downstream consumer that reads posted gifts via REST and produces immutable PDF artifacts. There is no shared database, no shared EF Core context, and no Gift Processing dependency on Receipt Generation. Both services sit behind Azure API Management with the same authentication, observability, and strangler-fig routing surface described in Section 4.1.

For the complete multi-perspective analysis — per-lens proposals, rationale for rejecting alternatives, full per-service API contracts, sample C# interfaces, OpenAPI excerpts, message envelopes, and the implementation roadmap — see Appendix B: Service Architecture.

5. Platform Affinity Analysis

TL;DR — Concho’s architectural assessment surfaced 7 architectural concerns and 28 outdated dependencies (Section 6). This section curates 9 platform-unlock entries across four categories: capacity (1), processing model (3), user interface (3), and data type (2). Of these, 8 are classified ELIMINATE (pure platform artifacts) and 1 is HYBRID (the multi-currency engine, where platform-imposed observability vacuum is removed and the underlying business math is preserved verbatim). 100% of platform-driven limitations analyzed in this section are retired or mitigated by the .NET 10 + Angular 20 on Azure App Service target. This fragment is the reconciled consensus of three independent platform-affinity runs.

Purpose: Identifying Legacy Constraints That Should NOT Transfer

Not all source platform behavior should be transcoded 1:1 to the target. This section analyzes implementation constraints from the legacy .NET Framework 4.7 + jQuery/ES5 environment that were necessary in the original system but should be eliminated or redesigned for the modern platform.

Scope: Focused on the Finance — Gift Processing subsystem (multi-currency batch posting, recurring gifts, motivation administration, bank statement import, annual receipts).

Source: Curated from Section 6 (Technical Debt Analysis) with forward-platform-unlock framing. Every entry below explicitly cross-references the originating Section 6 concern (C1–C7), dependency row, anti-pattern note, or strength.

Note: For UI-specific per-screen transformation analysis (layout, navigation, individual component redesign), see Section 7: UI/UX Transformation Examples. Section 5.3 covers UI platform constraints; Section 7 covers UI transformation.

Every legacy platform imposes constraints on the software that runs on it. Some of those constraints encode genuine business requirements — a tax-deductibility percentage reflecting national charity law, a SEPA mandate window enforcing banking compliance, or a posted/unposted state machine protecting financial integrity. Others are pure platform artifacts: an EOL runtime that lacks types now in-box on newer frameworks, a hosting bridge that splits responsibility across three layers, or a UI toolchain whose bundler is no longer maintained. Getting the distinction right is critical to avoiding two failure modes — replicating unnecessary limitations in the modern system (wasting engineering effort reproducing constraints that serve no purpose), or accidentally breaking a business rule that was disguised as a platform constraint (producing software that silently violates business intent).

The Petra slice is unusual in that the language family is unchanged (C# → C#), but the runtime, hosting model, client framework, and toolchain all move. That makes every entry below highly concrete: each constraint either (a) goes away because the runtime is .NET 10 instead of .NET Framework 4.7, (b) goes away because the host is Azure App Service + Kestrel instead of Mono FastCGI, (c) goes away because the client is Angular 20 instead of jQuery 3.6, or (d) collapses because the storage abstraction reduces to PostgreSQL-only on the modern side. Entries are classified platform-driven (ELIMINATE), hybrid (eliminate the platform artifact, preserve the business intent), or business-driven (PRESERVE). Pure PRESERVE entries are uncommon in Section 5 by design — this section is fundamentally about unlocks; pure business preservation lives in Section 9 (Business Rules Analysis).

5.1 Capacity Constraints

5.1.1 .NET Framework 4.7 Runtime Ceiling

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Source: Runtime is not manifested in packages.config but is load-bearing for every package below it. The Gift Processing server-side codebase compiles against .NET Framework 4.7.
Root Cause: .NET Framework 4.7.0 is past Microsoft mainstream support. Every server-side dependency in csharp/ThirdParty/packages.config is pinned to a version that compiles against 4.7 — including Npgsql 4.1.10 with a source-tree comment that reads <!-- pinned: 5.x requires .NET 5+ --> and a SharpZipLib 1.3.3 pinned for the same reason; PDFsharp 1.50.5147 and NPOI 2.6.0 sit on the same constraint. The runtime ceiling cascades into a library-version ceiling, which cascades into a feature ceiling: the team cannot adopt async-streaming Npgsql APIs, modern JSON source generators, or in-box System.Memory primitives because the runtime lacks them. (See Section 6.7 rows .NET Framework 4.7, System.* facade packages, SharpZipLib 1.3.3, PDFsharp 1.50.5147, NPOI 2.6.0, and the “EOL-major / Replaced by target” classification on the runtime row.)
.NET 10 LTS provides active support through November 2026 with security extensions beyond. The polyfill packages (System.Buffers, System.Memory, System.ValueTuple, Microsoft.Bcl.AsyncInterfaces, System.Threading.Tasks.Extensions, System.Runtime.CompilerServices.Unsafe, System.Text.Encodings.Web) are in-box and disappear from the dependency manifest. Npgsql 8.x, SharpZipLib 1.4.x, PDFsharp 6.x, NPOI 2.7.x, and MimeKit/MailKit 4.x become available; the version-ceiling cascade dissolves. Azure App Service P1v3 Linux containers expose configurable per-request limits decoupled from any in-process memory cap.
Legacy Behavior Operators cannot adopt any Petra release that depends on .NET 5+ libraries. Security patches for 4.7.0 are no longer mainstream — CVEs require manual backporting or remain unpatched. The polyfill packages (roughly a third of the 19 server NuGet entries) exist solely because the runtime lacks types that live in mscorlib on modern .NET. Large bank-statement imports (CAMT v053, MT940, ZIP-bundled multi-month statements) hit AppDomain memory pressure under the FastCGI worker model and force the operator to split files manually. Bulk annual-receipt runs over 10,000+ donors are constrained by Mono GC pause behavior on long-running operations. Linux deployments rely on fastcgi-mono-server4 — itself effectively abandoned (see 5.2.2). The modern slice runs on .NET 10 with Kestrel + ASP.NET Core hosting on Azure App Service Linux. The polyfill packages are deleted at modernization time, shrinking the dependency surface by approximately 30%. Active-support security patches are delivered automatically through the Microsoft update channel and the package ecosystem. Bank-file parsing streams end-to-end via Stream + async IAsyncEnumerable<BankStatementLine>; P1v3 autoscale rules (2–4 instances baseline; 1–6 for Receipt Generation in Jan–Feb) absorb peak load without per-request file-size negotiation. Adoption of Npgsql 8’s async-streaming APIs is gated only by code authoring, not by runtime version.
Recommendation ✅ ELIMINATE CONSTRAINT — The .NET Framework 4.7 runtime ceiling is a pure platform limitation with no business justification.
Rationale: No business rule requires .NET Framework 4.7. The runtime was current when the legacy implementation was authored; it has since fallen out of mainstream support. The cascade of pinned dependencies is entirely a consequence of the framework version, not of business requirements. The pinning comments in packages.config are explicit acknowledgments that the team would prefer newer libraries if the runtime allowed it.
Implementation: Adopt .NET 10 LTS as the modernization’s anchoring decision (per Section 4). Delete the polyfill packages from the new .csproj. Upgrade Npgsql, SharpZipLib, PDFsharp, NPOI, MimeKit, MailKit, and Portable.BouncyCastle to their .NET 10-compatible major versions. Same-language modernization (C# → C#) means the upgrade is a build-system + framework version change, not a code rewrite.

5.2 Processing Model Constraints

5.2.1 ASP.NET Web Services (.asmx) Synchronous SOAP/RPC Pipeline

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Concho Entities: TGiftTransactionWebConnector and the family of *.asmx endpoints expose the gift-processing surface as [WebMethod]-decorated methods inside ASP.NET Web Services; the RpcEndpointCommunication Concho pattern (confidence 0.90) tags the wire shape. The wire format is a SOAP envelope wrapping JSON-RPC payloads with binary-serialized SortedList parameters — the framing is cosmetic, but the per-method ceremony is not.
Root Cause: ASMX uses the classic ASP.NET 1.1-era synchronous request pipeline (HttpHandler → method dispatch → response) with no native async, no streaming, and no graceful cancellation propagation. Per-endpoint policy attachment (auth, rate limit, output caching) is not available; discoverability is limited to WSDL; payload contracts are declared via attributes only on SerializableSortedList-style argument bags; and there is no Core successor — System.Web is a .NET-Framework-only namespace. (See Section 6.5 source-language anti-pattern entry “.asmx is not ‘hard SOAP’” and the C1-retirement framing.)
ASP.NET Core Minimal APIs (or Controllers) on .NET 10 with Kestrel hosting expose the same gift-processing methods over REST. Swashbuckle auto-generates the OpenAPI document from method signatures; per-endpoint policy attachment (auth, rate limit, output caching) composes through middleware. The async/await chain reaches into the DB driver and out to System.Text.Json end-to-end. No System.Web, no SOAP envelope, no SortedList argument bags.
Legacy Behavior A user starting a 30-second bulk SEPA recurring-gift posting cannot navigate away or cancel the request; the worker thread is held for the full duration. Two concurrent bulk runs from different ledgers contend for worker-thread capacity even though the operations are independent. Adding a new POST /batches/{batchId}/post-style operation requires authoring a new [WebMethod] + a SortedList parameter bag + a separate documentation source for partners; OpenAPI tooling does not apply. Clients (the jQuery front-end and any integration partners) must conform to the SOAP framing then introspect the response shape from a WSDL that does not survive payload evolution gracefully. Adding a new endpoint is a single app.MapPost("/api/gift-processing/v1/batches/{batchId}/post", …) call. The OpenAPI document updates automatically; clients regenerate typed contracts with NSwag / kiota. Long-running operations either run async with cancellation tokens (the user can navigate away and poll status) or move to Azure Service Bus when provisioned, freeing the API thread immediately. The same posted/unposted state machine (the strangler routing key from Section 4) is expressed as a routable REST surface, which is the precondition for the Azure API Management traffic shift.
Recommendation ✅ ELIMINATE CONSTRAINT — The .asmx RPC framing + synchronous pipeline is a pure platform limitation with no business justification.
Rationale: No business rule requires SOAP-style framing for gift-processing requests. The framing is an accident of the era when System.Web was the only ASP.NET; the wire format inside the envelope is already JSON. The modernization treats this as HTTP-RPC → REST per the Section 6.5 anti-pattern guidance, not a SOAP migration. The RPC contracts are business; the transport is platform.
Implementation: Recreate the TGiftTransactionWebConnector surface as ASP.NET Core Minimal API endpoints under /api/gift-processing/v1/ per the Appendix B catalog. Swashbuckle generates the OpenAPI document. Azure API Management routes traffic between the legacy ASMX endpoints and the new REST endpoints during the strangler-fig window. FluentValidation replaces the in-process TVerificationResult chain at the boundary; RFC 7807 ProblemDetails carries failure responses. Once the cutover is complete, the entire System.Web dependency disappears from the slice. Section 8 (Code Translation) demonstrates the LoadGiftBatchMapGet rewrite end-to-end.

5.2.2 Mono FastCGI Hosting Model and [ThreadStatic] Isolation

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Concho Entities: Hosting-runtime technology subject; fastcgi-mono-server4 process configuration on the legacy Linux deployment; the Concho ThreadStaticIsolationConstraint pattern (confidence 0.90) tags the per-thread state-isolation strategy that couples to the FastCGI worker shape.
Root Cause: Mono FastCGI is the historic way to run .NET-Framework-targeted ASP.NET on Linux. The hosting model splits responsibility across three layers (front-end nginx/Apache → FastCGI bridge → Mono runtime), each with its own lifecycle, process model, and configuration surface. Connection management between FastCGI and Mono is request-affinitized; the Mono project itself has deprioritized the FastCGI server — the component is community-maintained at best. Compounding the problem, server components annotated with [ThreadStatic] for per-thread isolation (workflow state, session-scoped lookups) couple the application’s state-isolation strategy to the FastCGI worker shape, prevent async/await across the affected fields, and break under any cooperative scheduling model. (See Section 6.7 row Mono FastCGI classified EOL-project / Removed by target and the Section 6.5 anti-pattern entry “Mono FastCGI hosting”.)
Kestrel is the ASP.NET Core in-process HTTP server, running directly inside the .NET 10 process under Azure App Service Linux. There is no separate FastCGI bridge, no Mono runtime, no Apache/nginx requirement on the App Service plan. The hosting model is one layer, one process, one configuration surface. Async-await-driven I/O has no thread affinity; per-request state lives in HttpContext.Items or scoped DI services (IServiceScope) where it stays correct across await points without the FastCGI worker shape leaking in.
Legacy Behavior Operators must keep three concerns aligned: the Apache/nginx FastCGI module configuration, the fastcgi-mono-server4 arguments and worker counts, and the Mono runtime version. Worker recycling, connection-pool resets, and graceful shutdown are managed across the boundary; an SLA-impacting deploy can require coordinated reloads in two places. The team has historically avoided async code paths on the server because [ThreadStatic] data does not flow across await — forcing synchronous-only handlers for any code path that touches per-request state, which is part of why long-running operations (annual receipts, bulk imports) appear single-threaded to operators. During year-end load peaks the team currently restarts the Mono process daily to clear accumulated session memory. Operators manage one App Service plan with slot-based blue/green deploys (Section 4). Health probes (/health/ready, /health/live, /health/startup) drive slot-swap behavior. No FastCGI bridge to coordinate; no Mono runtime to track. All Gift Processing API endpoints become async end-to-end; per-request state lives in scoped DI; background workflows (bulk receipts) become BackgroundService instances on the Receipt Generation Service with Service Bus session-ordered messaging. Daily process restarts are no longer needed. Kestrel’s connection pool, request scheduler, and graceful-shutdown semantics are part of the .NET 10 LTS support contract.
Recommendation ✅ ELIMINATE CONSTRAINT — Mono FastCGI plus the [ThreadStatic] isolation pattern is a pure platform artifact with no business justification.
Rationale: No business rule requires Mono, FastCGI, or any specific Linux web-server bridge. The state-isolation requirement (one request’s state must not bleed into another’s) is preserved — only the mechanism changes from thread-affine fields to scoped DI. The hosting model was chosen because .NET Framework had no in-process HTTP server on Linux at the time. .NET 10 with Kestrel is the modern equivalent and is a first-class supported platform on Azure App Service Linux.
Implementation: Build the Gift Processing API as a standard ASP.NET Core 10 application targeting linux-x64. Deploy as an Azure App Service plan (P1v3 Linux) per Section 4. Convert every [ThreadStatic] field to a scoped service registration. Make all gift-processing handlers async. Remove all FastCGI / Mono configuration from the modernization slice. Apache/nginx remain in front of the legacy stack during coexistence, but the modern slice does not need them — App Service’s front-end provides TLS and HTTP/2 termination.

5.2.3 Multi-Currency Engine Observability Gap

⚠️ SPECIAL CASE: This constraint is BOTH platform-driven AND business-driven

The legacy Finance Operations Engine combines (a) correct multi-currency / multi-country tax-deductibility math that must survive verbatim, and (b) a hosting model with zero telemetry that hides bottlenecks. The math is business; the opacity is platform. The modernization separates them.

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Concho Entity: Finance Operations Engine (Application Layer.Finance Management — 49,362 LOC, the largest single architectural element at 17.2% of the analyzed source); the MultiCurrencyAmountHandling pattern (confidence 0.90) tags the per-currency rounding and tax-deductibility code paths.
Root Cause: Concho’s architectural assessment flags the engine at medium severity not because the multi-currency or tax math is wrong, but because automated batch operations (annual receipts, recurring-gift processing) have no observability today. Latency, throughput, per-currency calculation timing, and per-batch posting cost are invisible until they show up as user-reported slowdowns. The legacy hosting stack (.NET Framework 4.7 + Mono FastCGI + custom session logging via TLogging writing to a single rolling file with no structured fields) provides no built-in tracing, no metrics surface, and no correlation IDs between front-end requests and back-end batch invocations. The 70% concentration of HIGH-complexity workflows in the engine means any latency regression is hard to localize. (See Section 6 C2 — Finance Operations Engine classified Mitigated by target.)
The same calculations cross the boundary verbatim (Section 8 walks through one per-currency rounding example bit-exact). What the target adds is observability: OpenTelemetry SDK on .NET 10 emits traces and metrics to Azure Application Insights via OTLP. Serilog with structured JSON logging propagates correlation IDs end-to-end. Per-batch posting latency, per-currency conversion timing, and per-motivation-detail throughput become first-class metrics with dashboards and alerts; auto-instrumentation covers ASP.NET Core, EF Core, and outbound HttpClient calls.
Legacy Behavior Platform Constraint: When a finance operator reports “the November multi-currency batch was slow” there is no per-stage attribution available; the only debugging path is to re-run the batch under a profiler attached to the Mono process. There is no built-in correlation between a user action on the Gift Batches screen and the downstream multi-currency calculations.
Business Requirement: The math itself — multi-currency conversion against per-period ledger rates, percentage-based tax-deductibility per motivation detail, recurring-gift schedule expansion, SEPA mandate validity windows — is correct domain logic. Compliance audits (charity law, banking regulators) depend on the math producing exactly the same numbers it does today.
Platform Unlock: OpenTelemetry instrumentation captures latency, throughput, and per-stage timing for every posting cycle. Application Insights dashboards show p50/p95 by batch size, by currency count, by donor count. Custom Activity sources wrap the per-currency math; custom metrics (gift_batch.calculation.duration_ms by currency code) ship with 100% sampling and 90-day retention. The bottleneck risk is not eliminated — the calculation is still O(batch × currencies) — it is made visible, which is the precondition for fixing it when it materializes.
Business Preserved: The C# code that computes per-currency amounts, tax-deductibility percentages, and recurring-gift expansions ports forward as-is. Same rounding rules, same conversion-rate lookups, same audit-trail fields. Section 8 includes a side-by-side that exercises the rounding contract.
Recommendation ⚖️ HYBRID APPROACH — Eliminate the platform-imposed opacity; preserve the multi-currency / tax-deductibility business math verbatim.
Rationale: The engine’s combination of business correctness and platform opacity is a textbook hybrid case. Section 6.4 lists the Business Rules Validation Engine and the multi-database abstraction as architectural strengths to preserve; this entry is consistent with that framing. Removing the math would break compliance; removing the opacity removes risk without changing behavior.
Implementation: Port the Finance Operations Engine to .NET 10. Wrap every public posting / receipt / recurring entry point with OpenTelemetry activity sources. Emit OpenTelemetry metrics for gifts-processed-per-second and posting-batch-latency. Serilog enrichers attach {LedgerId, BatchId, Currency} properties to every log entry inside the engine. Wire FluentValidation to replace the in-process TVerificationResult validation, returning RFC 7807 ProblemDetails (preserving validation semantics; replacing the framework). Section 8 examples include the per-currency rounding contract; Section 9 (Business Rules) catalogs the preserved math.

5.3 User Interface Constraints

5.3.1 jQuery / ES5 Web Client Ceiling

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Concho Entity: Web Client Interface (Presentation Layer.Web Client — 6,682 LOC, the single high-severity concern in Concho’s assessment, LLM confidence 0.85).
Source: js-client/package.jsonjquery ^3.6.0, browserify ^16.5.2, uglify-js ^3.16.3, browserify-css ^0.15.0; ES5 JavaScript throughout the client tree.
Root Cause: The front end is jQuery 3.6 + vanilla ES5 with browserify as the module bundler and uglify-js for minification. The stack was reasonable circa 2016–2018 but constrains the team on three axes in 2026: (1) no component model — reusable UI is copy/paste at the template level rather than encapsulated components; (2) no reactive change detection — screens that touch many fields (Annual Receipts, Gift Batch posting) hand-wire DOM updates; (3) developer recruitment friction — jQuery experience is increasingly rare in junior hires. The bundler family is collectively EOL-project (browserify, browserify-css, uglify-js); the HTTP client (axios 0.21.4) carries documented CVEs (CVE-2021-3749, CVE-2023-45857). (See Section 6 C1 — Web Client Interface jQuery / ES5; Section 6.7 rows jquery, browserify, browserify-css, uglify-js, axios.)
Angular 20 with standalone components, signals-based reactivity, reactive forms, and Angular Material accessibility primitives. The Angular CLI replaces the entire browserify-era toolchain (esbuild / Vite under the hood) with one integrated build, dev server with hot module replacement, source maps wired by default, and route-level code splitting available out of the box. Angular HttpClient replaces axios. The build emits a tree-shaken, code-split bundle; routing is via the Angular Router; state is via signals or services. Bundle delivery is via Azure App Service static files (or Azure Static Web Apps if the team chooses to split the deployment).
Legacy Behavior The five in-scope screens — Gift Batches, Recurring Gifts, Motivations, Bank Import, Annual Receipts — are jQuery + ES5 + Bootstrap 4.6 templates with manual DOM mutation and ad-hoc state management. The Annual Receipts screen, which touches many fields per donor, re-renders the entire donor list on every filter change because jQuery has no way to express “only the rows whose visibility flipped”. Bulk recurring-gift edits sometimes leave the screen in an inconsistent state if two AJAX calls return out of order; the user has to refresh to recover. Front-end developers rebuild the bundle on every change (no HMR); the rebuild takes ~10–20 seconds on a small change. The axios 0.21.4 HTTP client carries CVE-2021-3749 and CVE-2023-45857 — not exploitable in every deployment but a real audit-finding risk. The five screens are rebuilt as Angular standalone components consuming the new REST endpoints. Reactive forms handle validation declaratively; signals propagate state changes without manual subscription wiring. Filtering re-renders only the rows whose signal-derived visibility flipped. Multiple in-flight HTTP calls compose cleanly through RxJS operators (or signal-based async pipes), so out-of-order completion does not corrupt the visible state. Edit-save-see in <1 second via the Angular CLI dev server’s HMR. The HTTP client is Angular’s built-in HttpClient, eliminating the axios CVE surface entirely.
Recommendation ✅ ELIMINATE CONSTRAINT — The jQuery / ES5 + browserify bundler stack is a pure platform limitation with no business justification.
Rationale: No business rule requires jQuery, browserify, uglify-js, or pre-1.0 axios. The choice of stack was era-appropriate; in 2026 each component is either EOL or carries a known CVE. The modernization is a wholesale rewrite per the Section 6.5 framing (“AngularJS framing does not apply” — Petra is jQuery, not AngularJS, so the rewrite is years lighter than an ngUpgrade-based hybrid would be).
Implementation: Rebuild all five gift-processing screens in Angular 20 as standalone components per Section 7. Replace axios with Angular HttpClient. Replace browserify / uglify-js / browserify-css with the Angular CLI build pipeline (ng new gift-processing-spa generates the toolchain). The legacy jQuery client remains in production for non-Gift-Processing surfaces (Partner, GL, Reporting) during the strangler-fig window.

5.3.2 Bootstrap 4 + popper.js 1.x + FontAwesome 5 EOL UI Library Stack

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Source: js-client/package.jsonbootstrap ^4.6.1, popper.js ^1.16.1, @fortawesome/fontawesome-free ^5.15.4. Modal/tooltip/popover primitives are wired via Bootstrap data attributes + jQuery initializers; FontAwesome 5 icon set is referenced through CSS classes across templates.
Root Cause: Bootstrap 4 reached EOL January 2023; popper.js 1.x is no longer maintained (the project renamed to @popperjs/core at 2.x and rewrote the API); FontAwesome 5.15.4 is two majors behind 6.x. Layout grid, responsive breakpoints, modal management, and tooltip positioning all anchor to this stack. None of it is business intent — all of it is browser-era UI tooling that has since been superseded. Tooltip positioning misbehaves at narrow viewport widths because popper.js 1.x lacks the modern viewport-collision logic the 2.x rewrite introduced. (See Section 6.7 dependency rows for bootstrap 4.6, popper.js 1.16, @fortawesome/fontawesome-free 5.15 — all EOL-major / Replaced or Removed by target.)
Angular Material owns the layout grid, dialog (modal), tooltip, menu, snackbar, and form-field primitives with built-in focus management, ARIA wiring, and keyboard-trap behavior. Angular CDK provides lower-level overlay/portal/drag-drop primitives that replace popper.js; overlay positioning is correct across viewport sizes. Material icons are first-class and theme-aware; FontAwesome is not required. The whole UI inherits a consistent theming surface (Angular Material themes) instead of bolted-together Bootstrap component CSS.
Legacy Behavior Modal-based donor-batch editing relies on jQuery + Bootstrap data attributes; accessibility is hand-wired and incomplete (focus traps, ARIA labels). Icons referenced by name strings can drift silently between FontAwesome majors during upgrades; translation strings live in i18next 10 JSON files that diverge gradually from the server-side resource catalogs (covered in 5.3.3). None of the upgrade paths is in-place — each requires markup changes throughout the client codebase. Components are typed via Angular Material; tokens are validated at compile time; icons are typed enums. Drift between layers is eliminated mechanically rather than reviewed manually. Section 7 demonstrates the Bootstrap-modal → Material-dialog transformation on the Gift Batches screen.
Recommendation ✅ ELIMINATE CONSTRAINT — The Bootstrap 4 / popper.js 1.x / FontAwesome 5 cluster is a pure platform limitation with no business justification.
Rationale: No business workflow depends on a Bootstrap component class name or a FontAwesome icon family. The visual surface is reimplemented in Angular Material so the team gets accessibility, theming, and modern overlay positioning for free.
Implementation: Replace Bootstrap 4 with Angular Material (or Angular + ng-bootstrap if the team prefers the Bootstrap component look-and-feel — flagged as an open question in the tech-debt handoff). Angular CDK overlays replace popper.js wholesale. Material icons replace FontAwesome. The replacement is whole-stack: removing Bootstrap also removes the Bootstrap jQuery init code, which removes a chunk of the legacy js-client/ footprint.

5.3.3 i18n Toolchain Lock-In (i18next 10 + GNU Gettext PO)

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Source: js-client/package.jsoni18next ^10.6.0, i18next-browser-languagedetector ^2.2.4, i18next-xhr-backend ^1.5.1. Server-side uses GNU Gettext PO files via a custom GettextResourceManager (per source profile legacyIdiomInventory).
Root Cause: The translation toolchain bridges two unrelated ecosystems — i18next 10.x on the client (long EOL; current 23.x is a different framework, with substantial API rewrites between) and Gettext PO files on the server. Each ecosystem has its own message catalog format, its own key/namespace convention, and its own runtime loader. Translators maintain two catalogs; developers maintain two adapters; the build pipeline must keep the PO files and the JSON message bundles in sync. The i18next-xhr-backend 1.5.1 adapter loads JSON over XHR at runtime — a pattern newer i18next versions deprecated. (See Section 6.7 rows i18next and adapters classified EOL-major / Replaced by target; source profile legacyIdiomInventory entry “GNU Gettext PO files for i18n”.)
Angular i18n on the client (the official localization framework, with build-time compilation and signal-based locale switching) plus ASP.NET Core IStringLocalizer + .resx resources on the server. Both share Microsoft / Angular tooling. Translators maintain a single canonical source per locale; XLIFF interchange is the standard format on both sides.
Legacy Behavior The Annual Receipts screen, which renders donor-facing PDF templates with locale-sensitive currency formatting and salutations, has to look up messages in two catalogs depending on whether the message is rendered server-side (PO) or client-side (i18next JSON). Adding a new locale is a catalog clone on both sides plus an adapter update on each. Translation strings drift gradually between client and server because nothing enforces parity at build time. Angular i18n compiles translations into the build output; server-side .resx files are reloaded through the ASP.NET Core localization middleware. XLIFF interchange means translators receive one file per locale per side and round-trip through standard tooling. The Annual Receipts PDF template can use the same server-side message catalog as the rest of the receipt-generation flow.
Recommendation ✅ ELIMINATE CONSTRAINT — The dual i18next-PO toolchain is a pure platform limitation with no business justification.
Rationale: No business rule requires i18next 10.x, GNU Gettext, or any specific message-catalog format. The dual-catalog pattern exists because the legacy era picked the popular client and server tools independently; modernization is the opportunity to unify on standard Microsoft / Angular tooling.
Implementation: Replace i18next 10.x with Angular i18n. Replace GNU Gettext PO files with ASP.NET Core IStringLocalizer backed by .resx resources. Establish XLIFF as the translator interchange format on both sides. Existing translations are converted once during the slice cutover.

5.4 Data Type Constraints

5.4.1 Code-Generated Typed Dataset / SortedList Parameter Rigidity

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Source: The data access layer is built on *.Generated.cs files emitted by TDataDefinitionParser / TDataDefinitionStore from db/petra.xml (master schema, 24,925 LOC). Tables, columns, and FK relationships are generated as System.Data.DataSet + DataTable + DataRow classes with strongly-typed accessors. Binary-serialized SortedList parameters carry method inputs over the .asmx wire.
Root Cause: The typed-dataset pattern was the .NET Framework 1.1–2.0 era’s answer to ORM-style typed access. Each schema change requires re-running NAnt + the code generator, which overwrites the entire generated layer. Editing the generated files is futile (the next NAnt run wipes the edits); editing the XML schema requires a full regeneration cycle. The pattern leaks DataRow-style nullability into business logic (every column is nullable at the API level), and async/streaming access is impossible because DataSet is a fully-buffered in-memory structure. The binary-serialized SortedList parameter shape compounds this: every web-method input is a string-keyed map of object values, and type fidelity depends on convention rather than the type system. NAnt’s last release was 2012; it is a batch-only, file-driven build tool with no incremental support, no parallelism awareness, and no integration with modern CI/CD. (See Section 6.5 anti-pattern entries “Code-gen-from-XML is the source of truth” and “.asmx is not hard SOAP”; source profile legacyIdiomInventory entry “Generated typed datasets from XML schema”.)
Entity Framework Core 8 with Code-First entity classes and EF Core Migrations. The 8 gift tables (per Section 4.4) become POCO entity classes with C# 8+ nullable reference types — required columns are non-nullable in the entity surface, matching the database DDL. Schema evolution is a migration generated by dotnet ef migrations add; rollback is dotnet ef database update. Async access is the default (await dbContext.GiftBatches.ToListAsync()). System.Text.Json source generation provides typed DTOs for API request/response shapes; the contract is auto-discoverable via OpenAPI. The build itself uses dotnet build + MSBuild incremental compilation; GitHub Actions handles CI/CD with caching and parallelism.
Legacy Behavior Adding a column to a_gift_detail requires editing db/petra.xml, running the NAnt-driven code generator, recompiling, and updating every business-logic call site that touches the regenerated DataRow accessor. Required-vs-optional column semantics live in the XML metadata, not in the C# type system — every accessor returns nullable, so business logic is littered with row.IsXxxNull() checks. The fully-buffered DataSet shape means batch-posting operations can’t stream large gift batches; the entire batch loads into memory. A developer adding a field also has to find every SortedList call site that passes that field and add it by string-key — there is no compile-time guard against typos. Mismatches surface only as runtime exceptions. Adding a column is a property addition on the entity + a generated migration. Required-vs-optional is encoded in the C# nullable annotation, surfaced by the compiler. Streaming queries are first-class (IAsyncEnumerable<Gift>). Every call site that produces or consumes the entity gets a compile-time error if the field is missing or its type is wrong. OpenAPI re-publishes the new shape on the next build. The single-source-of-truth property from petra.xml is preserved during coexistence by a one-time generator that emits EF Core entities from a curated subset of the master schema (Section 6.4 architectural strength); once the cutover is complete, EF Core Migrations become the source of truth.
Recommendation ✅ ELIMINATE CONSTRAINT — The typed-dataset code-generation pipeline + SortedList parameter shapes is a pure platform limitation with no business justification.
Rationale: No business rule requires DataSet / DataTable / DataRow or NAnt-driven generation. The pattern is an artifact of pre-LINQ, pre-ORM .NET. The single-source-of-truth property (the architectural strength called out in Section 6.4) is preserved at a different level — EF Core Migrations are the new source of truth once the legacy XML schema is exported for the 8 gift tables.
Implementation: One-time generator emits EF Core entity classes for the 8 gift tables from a curated subset of db/petra.xml covering Gift Processing. After that baseline commit the generator is retired; further schema evolution is via dotnet ef migrations add. The legacy generation pipeline keeps running for the out-of-scope subsystems during strangler-fig coexistence. All gift-processing business logic moves to DbContext + LINQ async queries with non-nullable entity properties; binary SortedList parameter bags become typed DTOs with source-generated System.Text.Json serialization. NUnit fixtures port one-to-one to xUnit + WebApplicationFactory + Testcontainers PostgreSQL.

5.4.2 Newtonsoft.Json Reflection-Based Serialization Defaults

Aspect Legacy System (.NET Framework 4.7 + jQuery) Target System (.NET 10 + Angular 20 on Azure App Service)
Discovery Source: csharp/ThirdParty/packages.config<package id="Newtonsoft.Json" version="13.0.2" />. The ASMX-era serialization defaults to Newtonsoft for the JSON payload inside the SOAP envelope; SortedList-style parameter bags are serialized via Newtonsoft converters.
Root Cause: Newtonsoft.Json 13.x is still maintained but is no longer Microsoft’s recommended JSON library for new ASP.NET Core code. It serializes via runtime reflection over property descriptors — allocation-heavy and slow under JIT cold-start. The legacy serialization defaults (PascalCase property names, lenient type coercion, dictionary-key handling that differs from System.Text.Json) are baked into the wire contract between the jQuery client and the ASMX surface. Polymorphic serialization uses Newtonsoft’s $type convention, which has no direct equivalent in System.Text.Json defaults. Source generation is unavailable on .NET Framework 4.7. (See Section 6.7 row Newtonsoft.Json classified outdated (still maintained) / Replaced by target; Section 6 Open Question on System.Text.Json behavioral diffs.)
System.Text.Json with source-generated serializers (JsonSerializerContext) on .NET 10. CamelCase property naming policy by default (matching JSON-API conventions). Polymorphic deserialization handled via [JsonDerivedType] attributes with explicit discriminator values rather than implicit type tagging. Performance is materially better (no reflection at hot paths) and the dependency is in-box.
Legacy Behavior The jQuery client posts JSON payloads with PascalCase property names; Newtonsoft’s defaults accept them. Decimal precision in multi-currency amounts is preserved by Newtonsoft’s permissive number handling. Date fields are serialized as Microsoft’s legacy "/Date(1234567890)/" format in some endpoints — a Newtonsoft-era convention. Serialization cost is invisible to the team and surfaces as “the server is slow” rather than “serialization is slow” — aggregated across bulk operations (annual receipts, large bank-import dispatches) it becomes a measurable share of CPU. The Angular client and the ASP.NET Core endpoints communicate via standard ISO 8601 dates and camelCase property names. System.Text.Json source-generated converters provide compile-time validation of the serialization contract; runtime allocation and reflection both drop materially. The OpenAPI document (Swashbuckle) declares the contract explicitly; client regeneration via NSwag/kiota produces typed contracts. Decimal precision is preserved via the in-box JsonConverter<decimal> with explicit handling.
Recommendation ✅ ELIMINATE CONSTRAINT — The Newtonsoft.Json defaults + Microsoft date-tick format are platform artifacts with no business justification.
Rationale: No business rule requires Newtonsoft.Json, PascalCase JSON payloads, or the "/Date(.)/" format. The contract was era-driven. Microsoft’s explicit recommendation for new ASP.NET Core code is System.Text.Json; the slice is new code by definition. Behavioral compatibility is a per-call review, not a blocker.
Implementation: Configure System.Text.Json with source generation per Section 4.3.1. Standardize on camelCase, ISO 8601 dates, and explicit polymorphism via [JsonDerivedType]. Section 8 (Code Translation) includes a side-by-side that exercises the behavioral diff (Newtonsoft permissive parsing vs. System.Text.Json strict mode), flagged in Section 6 Open Question. The Angular client consumes the standardized contract from day one; the legacy jQuery client continues to use the legacy ASMX surface during coexistence.

Platform vs Business Driver Analysis

Constraint Driver Decision Rationale
.NET Framework 4.7 runtime ceiling (5.1.1) Platform (runtime EOL) ELIMINATE No business reason to remain on a runtime past mainstream support; polyfills + library pins all cascade from it
ASP.NET .asmx synchronous SOAP/RPC pipeline (5.2.1) Platform (framework era, no Core successor) ELIMINATE No business reason for SOAP framing or thread-blocking handler dispatch around JSON payloads
Mono FastCGI hosting + [ThreadStatic] isolation (5.2.2) Platform (legacy Linux .NET bridge + thread-affine state) ELIMINATE No business reason for a three-layer hosting bridge or thread-affine state isolation
Multi-currency engine observability gap (5.2.3) Hybrid (platform telemetry gap + business math) HYBRID Eliminate observability gap; preserve compliance-bound math verbatim
jQuery / ES5 web client + browserify toolchain (5.3.1) Platform (framework era + EOL components) ELIMINATE No business reason for jQuery, browserify, uglify-js, or pre-1.0 axios
Bootstrap 4 + popper.js 1.x + FontAwesome 5 (5.3.2) Platform (EOL UI library cluster) ELIMINATE No business reason for Bootstrap 4 / popper 1 / FontAwesome 5 specifically
i18n toolchain lock-in (5.3.3) Platform (dual-catalog adapter) ELIMINATE No business reason for the i18next 10.x + GNU Gettext split
Typed-dataset + SortedList code-gen rigidity (5.4.1) Platform (pre-LINQ .NET pattern + NAnt build cycle) ELIMINATE No business reason for DataSet/DataRow; single-source-of-truth preserved via EF Core Migrations
Newtonsoft.Json defaults (5.4.2) Platform (legacy serialization era) ELIMINATE No business reason for PascalCase JSON / /Date()/ ticks; reflection-based serializer is allocation-heavy

Summary: Platform Affinity Decisions

Constraint Category Driver Decision Modern Equivalent
.NET Framework 4.7 runtime ceiling 5.1 Capacity Platform ✅ ELIMINATE .NET 10 LTS with in-box System.* types and modern library versions
ASP.NET .asmx synchronous SOAP/RPC pipeline 5.2 Processing Platform ✅ ELIMINATE ASP.NET Core 10 Minimal APIs with Swashbuckle / OpenAPI, async end-to-end
Mono FastCGI + [ThreadStatic] isolation 5.2 Processing Platform ✅ ELIMINATE Kestrel + Azure App Service Linux (P1v3) with scoped DI, slot-based blue/green
Multi-currency engine observability gap 5.2 Processing Hybrid ⚖️ HYBRID OpenTelemetry + Application Insights + Serilog (preserved engine math)
jQuery / ES5 web client + browserify toolchain 5.3 UI Platform ✅ ELIMINATE Angular 20 standalone components + HttpClient + Angular CLI / esbuild
Bootstrap 4 + popper.js 1.x + FontAwesome 5 5.3 UI Platform ✅ ELIMINATE Angular Material + Angular CDK overlays + Material icons
i18n toolchain lock-in 5.3 UI Platform ✅ ELIMINATE Angular i18n + ASP.NET Core IStringLocalizer + .resx via XLIFF
Typed-dataset + SortedList code-gen rigidity 5.4 Data Type Platform ✅ ELIMINATE EF Core 10 Code-First entities + Migrations + typed DTOs (single-source-of-truth preserved)
Newtonsoft.Json defaults 5.4 Data Type Platform ✅ ELIMINATE System.Text.Json source-generated with camelCase + ISO 8601

Total constraints surveyed: 9 across 4 categories (1 Capacity, 3 Processing, 3 UI, 2 Data Type).
Decision distribution: 8 ELIMINATE, 1 HYBRID, 0 PRESERVE.
Out-of-scope concerns not curated here: Section 6 entries C3 (XML Report Template Definitions) and C4 (Partner Management Operations) are real architectural debt but live outside the Gift Processing modernization slice. They remain in Section 6.6 and do not surface in Section 5 per the agent contract.
What this means for the modernization: 100% of platform-driven limitations analyzed in this section are either fully retired (8 entries) or partially retired with business logic preserved (1 entry — the Finance Operations Engine multi-currency math) by the target .NET 10 + Angular 20 on Azure App Service architecture. The absence of any PRESERVE entries confirms that the constraints surfaced here are genuinely platform artifacts — none turned out to be business requirements in disguise. Business-driven concerns (multi-currency math, tax-deductibility percentages, SEPA mandate windows, posted/unposted state machine semantics) are cataloged separately in Section 9 (Business Rules Analysis) where they belong.

6. Technical Debt Analysis

TL;DR — Concho’s architectural assessment (LLM confidence 0.85) surfaced 7 architectural concerns and 5 architectural strengths across 12 significant elements, plus 28 third-party dependencies cataloged from packages.config and package.json. Highest severity: a single high-rated concern — the jQuery/ES5 web client (retired by the Angular 20 target). Of the 7 architectural concerns: 3 retired by the target, 2 mitigated, 0 inherited, 2 out-of-scope (outside the Gift Processing slice).

6.1 Methodology

This section presents a structured analysis of technical debt in the Petra (OpenPetra) codebase, drawing on two distinct evidence sources. Architectural concerns (Sections 6.2–6.6) are derived from Concho’s architectural assessment (get_architecture_insight), which evaluates the system’s 12 major and secondary architectural elements across 8 root layers and identifies severity-ranked areas of concern with element-level scoping. Outdated dependencies (Section 6.7) are derived from direct inspection of the dependency manifest files Concho catalogs in the source — the server-side csharp/ThirdParty/packages.config (NuGet, 19 packages) and the front-end js-client/package.json (npm, 13 packages, including dev/build tooling).

The architectural concerns in 6.2 are each classified against the proposed target architecture (.NET 10 + Angular 20 on Azure App Service with Azure Database for PostgreSQL — see Section 4) using four buckets: retired by target, mitigated by target, inherited, and out of scope. Section 6.7 uses a separate two-axis framework appropriate for library-level debt (lifecycle status × target action) because a per-version catalog is the right artifact for that category of debt. Both classification frameworks are intended to give stakeholders a defensible answer to the question “what specifically is broken today, and what does the modernization fix?”

This section is not a code-quality lint report, a comprehensive security audit, or a bug list. 6.2–6.6 are architectural-level debt — patterns and design choices — while 6.7 is library-level debt — specific package versions with known EOL dates, migration paths, or modern equivalents. The two categories deserve separate framings because they reach different stakeholders.

6.2 Severity-Ranked Architectural Concerns

Concho’s architectural assessment (LLM confidence: 0.85) identified 7 areas of concern across the Petra codebase. Each concern is classified against the target architecture defined in Section 4. Highest severity first.

Concern Architectural Element Severity Classification
jQuery / vanilla ES5 web client limits modern browser capabilities and developer productivity Web Client Interface (Presentation Layer.Web Client — 6,682 LOC) High Retired by target
Complex multi-currency calculations and automated workflows may create performance bottlenecks under high transaction volumes Finance Operations Engine (Application Layer.Finance Management — 49,362 LOC) Medium Mitigated by target
XML Report Template Definitions contain over 13% of the codebase in template files, creating maintenance overhead and potential consistency issues XML Report Template Definitions (Presentation Layer.Report Templates — 39,992 LOC) Medium Out of scope
Partner Management Operations coordinates extensive CRUD through TPartnerEditWebConnector, potentially creating transaction-management complexity Partner Management Operations (Application Layer.Partner Management — 28,298 LOC) Medium Out of scope
TPetraPrincipal session-based authentication lacks modern security patterns such as OAuth or JWT tokens Security Authorization Framework (Infrastructure.Security Framework — 6,685 LOC) Medium Retired by target
Common Utilities Framework may create tight coupling across modules through shared ArrayList and Utilities classes Common Utilities Framework (Cross-Cutting Concerns.Common Utilities — 8,083 LOC) Low Mitigated by target
Development Tools Suite (TinyWebServer + ad-hoc utilities) duplicates functionality available in modern development environments Development Tools Suite (Build & Deployment.Development Tools — 6,575 LOC) Low Retired by target

Severity is Concho’s assessment, not re-weighted by this analysis. Classification reflects how the resolved target architecture (Section 4) handles each concern.

6.3 Detailed Concern Narratives

jQuery / ES5 Web Client (High — Retired by Target)

The Web Client Interface is the single high-severity concern in Concho’s assessment. The current front-end (6,682 LOC, 2.3% of the project) is implemented in jQuery 3.6 and vanilla JavaScript targeting ES5, with browserify as the module bundler, popper.js for tooltip positioning, and Bootstrap 4.6 for layout. The stack was reasonable circa 2016–2018, but in 2026 it constrains the team on three axes: (1) no component model, so reusable UI is copy/paste at the template level rather than encapsulated components; (2) no reactive change detection, so screens that touch many fields (Annual Receipts, Gift Batch posting) hand-wire DOM updates; (3) developer recruitment friction — jQuery experience is increasingly rare in junior hires.

The Gift Processing modernization slice replaces this layer wholesale with Angular 20 (standalone components, signals, reactive forms, the official Angular Material accessibility surface). All five in-scope screens — Gift Batches, Recurring Gifts, Motivations, Bank Import, Annual Receipts — are rebuilt as Angular components consuming the new ASP.NET Core 10 REST endpoints. Section 7 walks through one example end-to-end. The legacy jQuery client remains in production for non-Gift-Processing surfaces (Partner, GL, Reporting) during the strangler-fig coexistence window, but the slice is fully retired from a UI-stack perspective.

Multi-Currency Performance in the Finance Operations Engine (Medium — Mitigated by Target)

The Finance Operations Engine is the largest architectural element (49,362 LOC, 17.2%) and houses the multi-currency, multi-country tax-deductibility, and ledger-posting math that defines Petra’s value. Concho flags it as medium severity not because the math is wrong but because (a) workflow complexity is concentrated — the engine carries 70% of the system’s HIGH-complexity workflows per the workflow-complexity artifact — and (b) automated batch operations (annual receipts, recurring-gift processing) have no observability today, making bottlenecks invisible until they show up as user-reported slowdowns.

The target architecture mitigates rather than retires this concern: the calculations themselves are correct domain logic that must be preserved verbatim, and they cross the modernization boundary unchanged (see Section 8 for the per-currency rounding example). What the target adds is observability — OpenTelemetry instrumentation through Serilog and Azure Application Insights gives per-batch latency, throughput, and per-currency calculation timing; the FluentValidation pipeline replaces hand-rolled TVerificationResult validation with consistent ProblemDetails responses; xUnit + WebApplicationFactory testing brings the engine under coverage that the legacy NUnit suite touches only at the integration boundary. The bottleneck risk is not eliminated — it is made visible, which is the precondition for fixing it when it materializes.

TPetraPrincipal Session Authentication (Medium — Retired by Target)

The Security Authorization Framework (6,685 LOC) is built on TPetraPrincipal + TOpenPetraOrgSessionManager, a custom session-based auth scheme rooted in .NET Remoting conventions. Sessions are stored server-side, sessions cookies are scoped to the ASP.NET host, and credential validation happens against a Petra-specific user store with custom permission tables. Concho flags this as medium severity for the absence of modern patterns: no OAuth flow, no JWT bearer tokens, no federation, no MFA support, no audit-trail of token issuance separate from request logs.

The target retires the framework entirely. Section 4 specifies Microsoft Entra ID (formerly Azure AD) for user authentication via the OIDC authorization-code-with-PKCE flow, JWT bearer tokens for API authorization, and Managed Identity for service-to-service authentication (Gift Processing API → Receipt Generation Service, Gift Processing API → Azure Key Vault, Gift Processing API → Azure Service Bus). Petra’s permission-table semantics are preserved as role claims on the JWT — the modernization keeps the authorization model while replacing the authentication mechanism. Annual receipt PDF access (today gated by the session cookie + a custom token table) is replaced by short-lived SAS URIs minted by the Gift Processing API after a JWT-authenticated request.

Medium-Severity Concerns Outside the Slice

XML Report Template Definitions (Out of Scope). The proprietary “NO-SQL” report template syntax (39,992 LOC, 14.0% of the analyzed source) is a significant maintenance surface but lives entirely outside the Gift Processing slice. Receipt PDF generation in the modernized service uses an HTML/CSS template processed by a headless renderer rather than the legacy XML templates. The legacy report templates continue to drive the GL, Partner, Conference, and Reporting subsystems during strangler-fig coexistence and are inventoried here as future-investment debt — not a debt the Gift Processing modernization addresses.

Partner Management Transaction Complexity (Out of Scope). TPartnerEditWebConnector coordinates many distinct CRUD operations within a single transaction boundary, and Concho flags this as a transaction-management risk. The Gift Processing slice consumes Partner data read-only through a REST bridge (validation of donor_partner_key and recipient_partner_key) — it does not write to the Partner aggregate. The Partner write surface remains on the legacy stack post-cutover; its modernization is a future slice, not this one.

Low-Severity Concerns

  • Common Utilities Framework — Concho flags shared ArrayList and Utilities classes as a tight-coupling risk. The target reduces this through dependency injection, scoped services, and typed collections (List<T>, IReadOnlyList<T>) replacing the non-generic legacy collections. Coupling is mitigated, not eliminated — some shared helpers (currency formatting, partner-key parsing) remain as a small Shared.Domain library on the .NET 10 side.
  • Development Tools SuiteTinyWebServer and the assorted in-tree utilities are retired by moving to Kestrel + the standard dotnet CLI + Azure DevOps / GitHub Actions tooling. No bespoke development server is needed; the Angular CLI handles the SPA dev server independently.

6.4 Architectural Strengths to Preserve

Concho’s assessment identifies 5 architectural strengths. Each is paired below with an explicit note on how the target architecture preserves the underlying property, because a section that catalogs only debt reads as a hatchet job and undermines stakeholder trust in the analysis.

  • XML-driven schema generation provides single-source-of-truth. (Data Layer.Database Schema) The db/petra.xml master schema is the canonical definition of every table, field, and relationship. The target preserves the single-source-of-truth property by keeping a code-generation step — the EF Core entities for the Gift Processing slice are themselves generated from a curated subset of petra.xml for the 8 gift tables, ensuring that schema drift between the legacy and modern stores is mechanically prevented during coexistence (and discoverable via migration diffing thereafter).
  • Code Generation Pipeline (TDataDefinitionParser / TDataDefinitionStore). (Build & Deployment.Code Generation) The pipeline that transforms XML schema into C# code, RPC interfaces, and database DDL is automated, repeatable, and well-isolated. The target preserves the idea of generated infrastructure but replaces the NAnt-driven implementation with EF Core migrations + a one-time generator. The generator is retired once the EF Core baseline is committed; the property of “schema changes propagate without manual edits” is retained.
  • Multi-database abstraction. (Data Layer.Data Access) The legacy TDBType enum supports PostgreSQL, MySQL, and SQLite through a unified connection-factory pattern. The target collapses to PostgreSQL only (Azure Database for PostgreSQL Flexible Server) but preserves the abstraction property at a different level: EF Core providers + repository interfaces let the team add a different store later without rewriting business code. The fact that Petra runs on PostgreSQL today made the modernization’s engine choice mechanical, not contested.
  • Business Rules Validation Engine (TVerificationResult). (Cross-Cutting Concerns.Validation Framework) The cross-layer validator pattern enforces business rules consistently across modules and is the single most reused infrastructure in the codebase. The target preserves the property — consistent validation across all API boundaries — through FluentValidation, with the same rules expressed as validator classes and the same domain-exception → RFC 7807 ProblemDetails translation in the middleware. The validation semantics are preserved verbatim; only the framework changes.
  • Integration Test Infrastructure. (Test.Integration Tests) The NUnit-based integration suite (9,461 LOC) provides specialized fixtures for partner editing, gift processing, and financial-period rollover. The target preserves this property — comprehensive integration coverage — through xUnit + WebApplicationFactory + Testcontainers (PostgreSQL), with the same fixture concepts ported (a GiftBatchFixture that seeds a posted/unposted batch pair is a direct port of the legacy NUnit setup). The 80% coverage target in Section 4 is set explicitly to honor the strength.

6.5 Source-Language Anti-Patterns Observed

The active dotnet-angularjs source profile carries an antiPatternsToCheck list — common framings that get applied incorrectly to .NET+AngularJS prospects. The following items from that list are observable in the Petra codebase and are called out here so the modernization plan handles them correctly:

  • .asmx is not “hard SOAP.” Petra’s TGiftTransactionWebConnector and related .asmx endpoints use the SOAP envelope shape but a JSON wire format with binary-serialized SortedList parameters — closer to JSON-RPC over SOAP framing. The modernization narrative in Section 8 treats this as “HTTP-RPC → REST”, not “SOAP → REST”, because the SOAP framing is cosmetic.
  • Code-gen-from-XML is the source of truth. The data access layer (*.Generated.cs files emitted by TDataDefinitionParser) is not hand-maintained — editing the generated files is futile. The target retires the generation pipeline first (one-time export to EF Core entities for the 8 gift tables) before the EF Core migration begins, so the generated layer cannot overwrite EF Core changes mid-modernization.
  • Multi-database assumption. The connection string and TDBType abstraction support PostgreSQL, MySQL, and SQLite. Petra production runs on PostgreSQL; the modernization commits to PostgreSQL only on the modern side, but the legacy stack continues to run against whichever backend it is configured for during coexistence.
  • AngularJS framing does not apply. Petra uses jQuery, not AngularJS — the source profile shares an ID with AngularJS prospects but Petra’s UI is older. The modernization plan treats this as a jQuery → Angular rewrite, not an AngularJS → Angular upgrade. The migration is years lighter than an ngUpgrade-based hybrid would be, because there is no Angular 1.x scope hierarchy to bridge.
  • Mono FastCGI hosting. Petra’s Linux deployments run under fastcgi-mono-server4 with Apache or nginx in front. This is incompatible with Azure App Service Linux. The target replaces it with Kestrel + the standard ASP.NET Core hosting model; no Mono runtime is involved in the modern slice.

6.6 Debt Outside Migration Scope

Two of the seven Concho-surfaced concerns are real architectural debt but live outside the Gift Processing modernization slice (Section 2):

  • XML Report Template Definitions. 39,992 LOC of proprietary “NO-SQL” report templates drive the GL, Partner, Conference, and Reporting subsystems. The modernization slice produces HTML/CSS receipt templates only; the legacy report templates continue to operate on the legacy stack. A future modernization slice would inventory these templates and decide whether to port them to a modern reporting engine (e.g., FastReport, JS Reports, or HTML/PDF) or rewrite the subsystem they belong to entirely.
  • Partner Management Operations. TPartnerEditWebConnector remains on the legacy stack. The Gift Processing slice consumes Partner data read-only through a REST bridge; the Partner write surface and its transaction-management complexity are inherited by the legacy system until a Partner-focused modernization slice is commissioned.

Calling these out as out-of-scope is deliberate: stakeholders should know they are real debts that the engagement does not address. A future investment case for Partner or Reporting modernization would inherit these line items as starting evidence.

6.7 Outdated Dependencies

Library-level debt is a different category from architectural debt and gets its own catalog. Concho’s file inventory confirms two dependency manifests in the Petra source tree:

  • csharp/ThirdParty/packages.config — NuGet packages for the .NET Framework 4.7 server (19 packages, 35 lines).
  • js-client/package.json — npm packages for the jQuery web client (13 dependencies including dev/build tooling, 37 lines).

Two other .csproj files exist in the source (inc/nanttasks/NanttasksForDevelopers.csproj and inc/template/vscode/template.csproj) but reference only NAnt build assemblies and a template skeleton respectively — no third-party application dependencies. They are excluded from the catalog.

The two manifests below give stakeholders the actual bill of materials, classified on lifecycle status (current / outdated / EOL-major / EOL-project / known-CVE) and target action (removed / replaced / upgraded / kept-with-risk / outside-slice).

Server-side — csharp/ThirdParty/packages.config (NuGet)

<?xml version="1.0" encoding="utf-8"?>
<packages>
  <package id="NUnit"                              version="3.13.3"/>
  <package id="NUnit.Console"                      version="3.15.2"/>
  <package id="NUnit.ConsoleRunner"                version="3.15.2"/>
  <package id="MySqlConnector"                     version="2.2.5" />
  <package id="Npgsql"                             version="4.1.10"/> <!-- pinned: 5.x requires .NET 5+ -->
  <package id="System.Buffers"                     version="4.5.1" />
  <package id="System.Diagnostics.DiagnosticSource" version="7.0.0"/>
  <package id="Microsoft.Bcl.AsyncInterfaces"      version="7.0.0" />
  <package id="System.Runtime.CompilerServices.Unsafe" version="6.0.0"/>
  <package id="System.Memory"                      version="4.5.5" />
  <package id="System.Text.Json"                   version="7.0.1" />
  <package id="System.Threading.Tasks.Extensions"  version="4.5.4" />
  <package id="System.ValueTuple"                  version="4.5.0" />
  <package id="System.Text.Encodings.Web"          version="7.0.0" />
  <package id="MimeKit"                            version="3.5.0" />
  <package id="MailKit"                            version="3.5.0" />
  <package id="System.Runtime"                     version="4.3.1" />
  <package id="Portable.BouncyCastle"              version="1.9.0" />
  <package id="HtmlAgilityPack"                    version="1.11.46"/>
  <package id="Newtonsoft.Json"                    version="13.0.2"/>
  <package id="libsodium-net"                      version="0.10.0"/>
  <package id="SharpZipLib"                        version="1.3.3" /> <!-- pinned: 1.4.x requires .NET 6+ -->
  <package id="PDFsharp"                           version="1.50.5147"/>
  <package id="NPOI"                               version="2.6.0" />
</packages>

Front-end — js-client/package.json (npm)

{
  "name": "openpetra-client-js",
  "version": "2018.2.0",
  "dependencies": {
    "@fortawesome/fontawesome-free":   "^5.15.4",
    "axios":                           "^0.21.4",
    "bootstrap":                       "^4.6.1",
    "browserify":                      "^16.5.2",
    "browserify-css":                  "^0.15.0",
    "cypress":                         "^5.6.0",
    "i18next":                         "^10.6.0",
    "i18next-browser-languagedetector":"^2.2.4",
    "i18next-xhr-backend":             "^1.5.1",
    "jquery":                          "^3.6.0",
    "popper.js":                       "^1.16.1",
    "uglify-js":                       "^3.16.0"
  }
}

Dependency catalog — lifecycle + target action

Dependency Manifest Current Lifecycle Target Action Notes
.NET Framework (runtime, not manifested) 4.7 EOL-major Replaced by target 4.7.0 is past Microsoft mainstream support; modern .NET 10 is LTS. Underlies every server package below.
jquery package.json 3.6.x outdated Removed by target Not used in Angular 20 client. Retired with the Web Client Interface.
bootstrap package.json 4.6.1 EOL-major Replaced by target Bootstrap 4 reached EOL January 2023; Bootstrap 5 still maintained. Target replaces with Angular Material.
browserify package.json 16.5.2 EOL-project Removed by target Largely abandoned; modern Angular uses the Angular CLI + esbuild/Vite. Replaced wholesale by the Angular toolchain.
popper.js package.json 1.16.1 EOL-major Removed by target Renamed to @popperjs/core at 2.x; 1.x no longer maintained. Replaced by Angular CDK overlay primitives.
axios package.json 0.21.4 known-CVE Removed by target Pre-1.0 axios versions carry several CVEs (e.g., SSRF in 0.x). Angular HttpClient replaces the HTTP layer entirely.
cypress package.json 5.6.0 outdated Replaced by target Cypress 13.x current. Target replaces with Playwright (component + e2e) per the standard concho.ai testing convention.
uglify-js / browserify-css package.json 3.16 / 0.15 EOL-project Removed by target Bundler-era tooling. Angular CLI handles minification (Terser) and CSS bundling natively.
i18next (+ adapters) package.json 10.x EOL-major Replaced by target i18next 23.x current (major rewrites). Target uses Angular i18n + ASP.NET Core IStringLocalizer + .resx resources.
@fortawesome/fontawesome-free package.json 5.15.4 outdated Replaced by target Font Awesome 6.x current; Angular Material uses its own icon set. Replaced as part of the SPA rebuild.
Newtonsoft.Json packages.config 13.0.2 outdated (still maintained) Replaced by target Not EOL but Microsoft recommends System.Text.Json for new code. Target uses source-generated System.Text.Json; behavioral diffs reviewed per call.
Npgsql packages.config 4.1.10 EOL-major Upgraded by target 4.x is past EOL on .NET Framework. Target uses Npgsql 8.x with EF Core 10 provider. (Comment in source notes pin: “5.x requires .NET 5+” — that constraint dissolves on .NET 10.)
MySqlConnector packages.config 2.2.5 outdated Removed by target MySQL is not a target backend (PostgreSQL only). Removed in the modern slice; remains in legacy.
NUnit (+ Console, ConsoleRunner) packages.config 3.13.3 / 3.15.2 outdated Replaced by target NUnit still maintained but the target standardizes on xUnit + WebApplicationFactory (Section 4.3.1). Existing NUnit fixtures port one-to-one.
SharpZipLib packages.config 1.3.3 outdated Upgraded by target Source comment notes pin: “1.4.x requires .NET 6+” — constraint dissolves on .NET 10. Used for ZIP-bundled bank statement imports.
libsodium-net packages.config 0.10.0 EOL-project Replaced by target Pre-1.0 binding, largely unmaintained. Target uses the modern NSec.Cryptography or built-in System.Security.Cryptography primitives on .NET 10.
PDFsharp packages.config 1.50.5147 outdated Upgraded by target PDFsharp 6.x current with .NET 10 support and modern font handling. Used by Receipt Generation Service for the annual-receipt PDF rendering.
NPOI packages.config 2.6.0 outdated Upgraded by target NPOI 2.7.x current with active maintenance; used for Excel-format bank statement parsing.
MimeKit / MailKit packages.config 3.5.0 outdated Upgraded by target 4.x current with .NET 10 support. Used for emailing donor receipts.
HtmlAgilityPack packages.config 1.11.46 outdated Outside slice Used by legacy report-template processing (Section 6.6 out-of-scope debt). Remains on legacy stack.
Portable.BouncyCastle packages.config 1.9.0 outdated Upgraded by target Modern BouncyCastle.Cryptography 2.x targets .NET Standard 2.0 + .NET 6+. Used for SEPA mandate signing.
System.* facade packages packages.config 4.3–7.0 outdated Removed by target The System.Buffers, System.Memory, System.ValueTuple, System.Threading.Tasks.Extensions, etc. polyfill packages are only required because .NET Framework 4.7 lacks those types in mscorlib. On .NET 10 they are in-box and the explicit references disappear.
NAnt (build, not in NuGet) 0.92 EOL-project Removed by target Last NAnt release: 2012. Target build system is dotnet CLI + MSBuild + GitHub Actions / Azure Pipelines.
Mono FastCGI (runtime, not manifested) 4.x EOL-project Removed by target Mono project effectively deprioritized FastCGI server. Target runs on Kestrel + Azure App Service Linux.

Specific high-priority items requiring narrative.

axios 0.21.4 is the single known-CVE entry. Pre-1.0 axios has documented SSRF and prototype-pollution issues (CVE-2021-3749, CVE-2023-45857) that affect any application that forwards user-supplied URLs or parses untrusted JSON without input bounds. The migration path is not “upgrade axios” — it is “replace with Angular HttpClient,” which removes the axios surface entirely. No additional defensive code is needed in the legacy client during coexistence because the affected screens are within the slice and are being rewritten in Angular.

.NET Framework 4.7 is the load-bearing runtime EOL. Although Microsoft still ships security patches for 4.8, version 4.7.0 specifically is past mainstream support. Every server-side dependency in packages.config is pinned to a version that compiles against 4.7; removing the runtime requires committing to the .NET 10 baseline across the slice. The Gift Processing modernization treats this as the modernization’s anchoring decision — the platform target (Section 4) is .NET 10 specifically because the runtime is past EOL.

browserify / Bower-style toolchain (browserify, browserify-css, uglify-js) is the front-end’s build pipeline and is collectively retired by adopting the Angular CLI. Bower itself is not in package.json (good — Petra already moved off Bower at some prior point) but the bundler choice is similarly stale. No upgrade path exists; the Angular CLI is the replacement.

Summary. Of 28 dependencies surveyed across the two manifests plus three runtime-level entries (.NET Framework, Mono FastCGI, NAnt):

  • 10 removed by target (jquery, browserify, browserify-css, uglify-js, popper.js, axios, Mono FastCGI, NAnt, MySqlConnector, System.* polyfills)
  • 10 replaced by target with a different package serving the same role (.NET Framework, bootstrap, cypress, i18next, FontAwesome, Newtonsoft.Json, NUnit, libsodium-net, plus stack-wide replacements)
  • 7 upgraded by target (Npgsql, SharpZipLib, PDFsharp, NPOI, MimeKit/MailKit, Portable.BouncyCastle)
  • 1 kept outside slice (HtmlAgilityPack — remains on legacy stack for report templates)
  • 0 kept with risk noted in the modern slice

The dependency burden of the modern Gift Processing slice is therefore dramatically smaller than the legacy footprint — most legacy packages exist to fill gaps that .NET 10 closes in-box (System.* polyfills) or to support a runtime + UI stack that is replaced entirely (jQuery + browserify era tooling).

7. UI/UX Transformation Examples

TL;DR — This section walks through the modernization of the Gift Batch Entry through Posted GL flow — the canonical multi-currency donation-processing journey that touches eight of the fourteen catalog business rules. Three legacy screens (GiftBatches.html, GiftDetailEntry.html, MotivationPicker.html) running on jQuery + AngularJS 1.x collapse into a single Angular 20 unified workspace with live multi-currency totals, live period validation, a tax-deductible-percentage clamp, and a pre-commit GL journal preview that legacy never offered. The section closes with a six-scene walkthrough rendered from the storyboard markdown that drove this section's generation.

The Finance — Gift Processing subsystem has a heavy UI surface — gift batch headers, detail rows, donor lookups, motivation pickers, and posting confirmations that the legacy implementation spreads across separate jQuery/AngularJS HTML pages. Modernization to Angular 20 + ASP.NET Core 10 is an opportunity not just to re-implement those pages, but to consolidate them around the actual user journey. The use-case-discovery phase identified Gift Batch Entry through Posted GL as the highest-density use case in the subsystem (8 of 14 catalog business rules fire along its path), so it is the journey featured here.

Section 7.1 through 7.6 present the 1:1 screen translations — legacy screen on the left, modern Angular 20 mockup in the middle, platform-affinity wins on the right. Section 7.7 is the "Beyond 1:1" consolidated walkthrough: the unified Gift Batch Workspace shown in six successive scenes from empty state through pre-commit preview, commit, downstream GL view, and side-by-side coexistence with the legacy reader. Section 7.8 enumerates the modern UI design tokens; Section 7.9 (a separate fragment, appended below this section in the assembled report) is the complete field-inventory reference table derived from the same storyboard JSON.

7.1 Legacy UI Inventory

Concho’s get_files_for_architecture_layer_with_metadata("Presentation Layer") returned 12 legacy UI artifacts (6,682 lines) for the Gift Processing subsystem. The three that drive the canonical use case are:

Legacy ArtifactLinesRoleState held in
js-client/src/forms/Finance/Gift/GiftEntry/GiftBatches.html~310Batch list + new-batch modaljQuery global $gpGiftBatches
js-client/src/forms/Finance/Gift/GiftEntry/GiftDetailEntry.html~480Per-row gift entry gridjQuery global $gpGiftDetail + localStorage
js-client/src/forms/Finance/Gift/GiftEntry/MotivationPicker.html~220Motivation group + detail lookup modalAngularJS $scope on a dedicated controller

Server-side, all three pages talk to TGiftTransactionWebConnector in csharp/ICT/Petra/Server/lib/MFinance/Gift/Gift.Batch.cs — specifically the CreateAGiftBatch and PostGiftBatch operations. The state machine across them is BATCH_CREATED → DETAILS_ENTERED → PERIOD_VALIDATED → AMOUNTS_CALCULATED → BATCH_POSTED → GL_JOURNAL_EMITTED, but no single legacy screen renders that whole pipeline; the clerk has to hop between three pages plus a server-side modal to walk it.

7.2 Gift Batches List (1:1 Translation)

The Gift Batches list page is the entry point for the use case. It enumerates today’s batches (unposted and posted), highlights which are eligible for posting, and offers a "New Gift Batch" action.

Legacy: GiftBatches.html (jQuery + AngularJS)

OpenPetra — Finance » Gift » Gift Entry » Gift Batches
# Date Status Description Total
4419/05/26PostedApril direct debits8,420.00
4520/05/26PostedAnonymous gifts1,250.00
4621/05/26UnpostedMay 2026 EUR donations2,460.00
Status: Ready · Legacy webform powered by jQuery 1.11 + AngularJS 1.5
  1. Status filter cannot be combined with date range without a server round-trip.
  2. Posting requires a separate confirmation page (server-side modal alert).
  3. Cross-screen state held in jQuery globals; refresh discards the workspace.

Modern: Gift Batches (Angular 20 + Tailwind)

Gift Batches — Ledger 43
Status: All Date: May 2026 1 unposted
# Date Status Description Total (EUR)
4419 May● PostedApril direct debits€8,420.00
4520 May● PostedAnonymous gifts€1,250.00
4621 May● UnpostedMay 2026 EUR donations€2,460.00

Platform Affinity Wins

  • BR-GIFT-007 status semantics visible — coloured status pills replace plain text. The unposted row is amber, posted is green.
  • Composable filter chips — status + date + count chips render client-side; no server round-trip per filter combination.
  • Single-page navigation — the "+ New Gift Batch" button opens the unified workspace inline; no modal-on-modal stacking.
  • Currency-aware totals — the Total column reads in the ledger’s base currency (BR-GIFT-003); legacy displayed raw decimals with no currency indicator.

7.3 Gift Detail Entry (1:1 Translation)

The legacy Gift Detail Entry page hosts the inline grid of donations within a batch. Donor selection requires an exact partner key at a blank prompt; motivation codes are entered by typing into a separate field; no live currency conversion is shown until save. The modern equivalent puts donor autocomplete, motivation auto-fill, and live multi-currency totals into a single grid.

Legacy: GiftDetailEntry.html

OpenPetra » Gift Detail Entry (Batch #46)
Effective:   Currency:  
Row Donor key Motivation Amount Cur Tax %
1
2
Status: 1 detail entered · no live FX, no donor preview, no GL preview
  1. Donor key entered as exact 8-digit numeric — no autocomplete (BR-GIFT-011 partner class is invisible).
  2. Motivation typed as GROUP/DETAIL string — no pick list, no auto-fill from partner class.
  3. Tax % accepted as raw integer — no live clamp; BR-GIFT-009 enforces 0–100 only on save.
  4. No live currency conversion to base or international (BR-GIFT-003 only triggers on Save).

Modern: Gift Detail Grid (Angular 20)

Batch #46 Unposted Period 5 (May 2026) · open Base: €2,460.00
Donor Motivation Amount Cur Tax % Base (EUR)
Müller, Heinrich [UNIT]GIFT › SUPPORT500.00USD87€454.55
Berger, Anneliese [FAMILY]GIFT › SUPPORT200.00EUR100€200.00
Kongo Mission Fund [UNIT/FIELD]GIFT › FIELD1,800.00EUR100€1,800.00

Platform Affinity Wins

  • BR-GIFT-011 motivation auto-fill — selecting a donor with class UNIT/FIELD auto-assigns motivation detail FIELD; UNIT/KEYMIN would assign KEY_MIN; everything else assigns SUPPORT. Side-panel preview shows the mapping before commit.
  • BR-GIFT-009 live clamp — the Tax % column is bound to a slider/number input that rejects values outside 0–100; legacy clamps only at save.
  • BR-GIFT-003 live FX — the Base column updates as you type the transaction amount; legacy showed base only after Save.
  • Period chip — the green "Period 5 open" pill from BR-GIFT-002 surfaces the validation at glance time, not save time.

7.4 Motivation Picker (1:1 Translation)

The legacy Motivation Picker is a modal triggered from the Detail Entry page; in the modern unified workspace it becomes a docked side-panel that auto-updates as the donor changes. Both surface the same data — motivation groups, details, and the partner-class-driven auto-assignment — but the modern variant is always visible and always reflects the row currently in focus.

Legacy: MotivationPicker.html (modal)

Motivation Picker (modal)
Group:
CodeDescription
SUPPORTGeneral Worker Support
FIELDField Project
KEY_MINKey Ministries
 
  1. Modal modal on top of the Detail Entry page — the underlying grid is blocked.
  2. No preview of how the choice maps from partner class (BR-GIFT-011 is hidden).
  3. Selection commits on OK click; closing without OK loses the change.

Modern: Motivation Side-Panel

Motivation — row 3 auto from partner class
Donor: Kongo Mission Fund
Partner class: UNIT » type FIELD
→ Motivation: GIFT › FIELD
GroupDetailReceipt?
GIFTFIELD
GIFTKEY_MIN
GIFTSUPPORT
MEMBERFEEANNUAL

Platform Affinity Wins

  • BR-GIFT-011 visible — partner class → motivation mapping rendered explicitly; user can override but sees what the auto-assignment would be.
  • Always-visible side-panel — no modal-on-modal stacking; the underlying grid stays active.
  • Receipt-eligibility column — the modern table shows the BR-GIFT-004 receipt flag inline so the clerk can see at a glance whether a motivation makes the gift receipt-eligible.

7.5 Period Validation Chip (1:1 Translation)

BR-GIFT-002’s Financial Period Validation is, in the legacy system, an invisible save-time check that throws a server error if the effective date is outside an open period. In the modern workspace it surfaces as a chip in the header that turns green/amber/red as the date is typed.

Legacy: save-time alert

Gift Detail Entry
Effective:  
Error: "GetLedgerDatePostingPeriod returned no period for 21/05/2027 in ledger 43.
AForceEffectiveDateToFit=false. Please choose an open period."
  1. Validation fires only on Save; the user might enter the entire batch before seeing the error.
  2. The force-fit toggle (AForceEffectiveDateToFit) is exposed only as a command-line argument to the server endpoint — not a UI control.

Modern: live period chip

Effective date:
Period 5 / May 2026 — open
Posts to GL effective 31 May 2026 (period close).

Platform Affinity Wins

  • Glance-time validation — the period chip resolves on every keystroke; the user sees the validity before continuing.
  • Force-fit surfaced as a checkbox — what was a hidden server parameter is now a UI control with a tooltip linking to the BR.
  • Same backend semantics — the chip queries the same a_accounting_period table; no new data is introduced.

7.6 Posted Confirmation (1:1 Translation)

The final 1:1 screen is the post-commit confirmation. Legacy Petra shows a plain modal alert ("Batch 46 posted successfully"); the modern workspace converts the entire page state from amber to green and surfaces the assigned GL journal number with a deep link to the GL view.

Legacy: modal alert

OpenPetra Alert
Batch 46 posted successfully.
  1. No journal number; the clerk has to navigate to GL051 to find it.
  2. No timestamp; audit-trail reconstruction requires a separate report.

Modern: Status bar + toast

Batch #46 posted. GL journal GL-2026-05-21-014 assigned. View GL Posting →
Posted by the finance clerk · 2026-05-21 14:32 UTC · Status: Posted · BR-GIFT-007 immutable

Platform Affinity Wins

  • Journal number surfaced — clerk no longer hops to GL051 to find the journal id; BR-GIFT-007 confirmation includes the assigned journal id.
  • Inline audit — posted-by user, timestamp, and immutability notice are part of the page state, not a separate report.
  • Deep link to GL view — the bridged-to-legacy GL posting is one click away (Scene 5 in the walkthrough).

7.7 Beyond 1:1 — The Unified Gift Batch Workspace (Multi-Scene Walkthrough)

While screen-for-screen conversions are useful, legacy modernization often offers the opportunity to combine multiple screens into a single cohesive experience. In Petra’s gift-processing slice, three legacy pages (GiftBatches.html, GiftDetailEntry.html, MotivationPicker.html) all participate in a single user journey — the daily gift-batch entry → post cycle. The modern Angular 20 unified workspace renders all three concerns at once, with responsive layout, modern form elements (autocomplete, sliders, chips), interaction patterns like live multi-currency conversion and pending-to-confirmed state transitions, and progressive disclosure of the GL posting payload.

The unified view below was auto-generated by the Concho modernization workflow. For the human-in-the-loop part, a companion storyboard in Markdown format is also auto-generated and can be edited by humans and fed back through the workflow to regenerate the screen mockups. "Scene" is a standard storyboarding term for a distinct moment in the user’s interaction; the walkthrough below has six scenes covering the empty state, donor lookup, pre-commit preview, commit, downstream GL view, and side-by-side legacy coexistence.

Show Storyboard Markdown
# Storyboard: Gift Batch Entry through Posted GL — Draft (Inferred from Code Analysis)

**Source:** Concho workflow entities + TGiftTransactionWebConnector operations (cycle 23), plus the use-case-discovery handoff (use case 1) and the behavioral-rules catalog (BR-GIFT-001/002/003/007/008/009/010/011).
**Programs Combined:** legacy GiftBatches.html, GiftDetailEntry.html, MotivationPicker.html, posting confirmation modal.
**Status:** DRAFT — inferred from TGiftTransactionWebConnector state machine + behavioral-rules catalog.
**Flow:** BATCH_CREATED → DETAILS_ENTERED → PERIOD_VALIDATED → AMOUNTS_CALCULATED → BATCH_POSTED → GL_JOURNAL_EMITTED
**Actor:** Finance Operations Clerk, processing the day’s incoming gift cheques and electronic transfers for a European non-profit ledger denominated in EUR.

---

## Scene 1: Three Screens Become One
- User: the finance clerk.
- User action: The finance clerk opens the modern unified route /forms/Finance/Gift/GiftEntry/UnifiedGiftBatch.
- Visible state: The workspace renders in empty state — header (ledger picker, effective date, batch number reserved as "next: 47"), an empty Gift Details grid, a collapsed Motivation Picker side-panel, Multi-Currency Summary showing zeros, GL Journal Preview saying "No posting payload yet".
- Business rule fired: BR-GIFT-001 has been *reserved* but no atomic increment occurs until the first save.
- Platform affinity win: legacy splits this workspace across three jQuery/AngularJS pages.

## Scene 2: Donor Search with Autocomplete
- User: the finance clerk.
- User action: The finance clerk types "Mül" into the Donor field on the first gift-detail row.
- Visible state: Autocomplete dropdown opens with three matches showing partner key, partner class, last gift date, YTD total in EUR, receipt cadence. Motivation Picker side-panel previews the motivation assignment for the highlighted match. Effective-date chip shows green "Period 5 (May 2026) — open".
- Business rule fired: BR-GIFT-002 lights up the period chip; BR-GIFT-011 drives the live motivation hint.
- Platform affinity win: legacy required exact 8-digit donor key at a blank prompt with no preview.

## Scene 3: Preview / Pending State — Multi-Currency Allocation
- User: the finance clerk.
- User action: The finance clerk selects Müller, Heinrich [UNIT · 43005001], types 500.00 into Amount, selects USD, types 87 into Tax %.
- Visible state: row in amber pending state; Multi-Currency Summary live (USD 500.00, EUR 454.55, USD 500.00 intl); Tax % shows 87.00 within bounds; GL Journal Preview populated with amber dry-run: Dr. 0100-Bank-EUR 454.55, Cr. 4500-Gift Income 395.46, Cr. 4501-Non-Deductible 59.09 — no journal number; Post button labelled "Preview only — click to post".
- Business rule fired: BR-GIFT-003, BR-GIFT-009 (clamp), BR-GIFT-010, BR-GIFT-008 (modified_detail=0 staged).
- Platform affinity win: legacy computed FX only on save; no GL preview ever possible.

## Scene 4: Commit / Confirmed State — Batch Posted
- User: the finance clerk.
- User action: The finance clerk clicks the Post Batch button. Confirmation toast slides in.
- Visible state: amber rows transition to green; "Est." prefix dropped; header shows "Batch #47 · Status: POSTED · Posted by the finance clerk · 2026-05-21 14:32 UTC"; GL Journal Preview now shows journal number GL-2026-05-21-014; Post button replaced by "Posted" chip + "View GL Posting →" link; lock icons appear on rows.
- Business rule fired: BR-GIFT-007 (Unposted → Posted + immutability); BR-GIFT-001 (atomic ++46 → 47); BR-GIFT-008 (modified_detail=0 baseline locked).
- Platform affinity win: legacy posting was a modal alert with no journal reference.

## Scene 5: Downstream Consequence — GL Posting Bridge
- User: the finance clerk.
- User action: The finance clerk clicks "View GL Posting →" in the status bar.
- Visible state: GL Posting panel expands inline; journal number GL-2026-05-21-014; three journal lines; bridged-status pill "Modern Gift Processing API → Azure API Management → Legacy GL (.asmx)"; Posting Receipt subsection with legacy GL ack id; chip "Bridged via classical-strangler-fig".
- Business rule fired: BR-GIFT-007 (Posted gate); BR-GIFT-003 (EUR base amounts with USD memo).
- Platform affinity win: legacy required navigating to a separate program to look up the GL effect.

## Scene 6 (optional): Side-by-Side Coexistence
- User: Tony, the finance manager auditing the day’s work.
- User action: Tony clicks toolbar toggle "Show legacy view".
- Visible state: page splits; legacy GiftBatches.html iframe renders read-only on the right; both halves show batch #47 with matching donor, amount, and Posted status; banner reads "Coexistence: Modern API owns writes for posted/unposted gift-batch state. Legacy reads continue against the same database."
- Business rule fired: coexistence-level; BR-GIFT-001/007/008 invariants verified across both readers.
- Platform affinity win: classical-strangler-fig commitment becomes auditable in the UI.

Scene 1: Three Screens Become One

The finance clerk opens the modernized unified route /forms/Finance/Gift/GiftEntry/UnifiedGiftBatch. The workspace renders in its empty initial state — one route, three panels, no navigation to other pages required.

Triggering user action: The finance clerk clicks the bookmark Gift Batch Workspace in their browser, which routes Angular to the unified view.

Gift Batch Workspace
Ledger 43 · EUR  ·  Effective: 2026-05-21  ·  Batch #47 (reserved)
Gift Details
DonorMotivationAmountCurTax %Base (EUR)
No detail rows yet — click the Donor field to begin.
Multi-Currency Summary — Transaction / Base / International
USD 0.00  ·  EUR 0.00  ·  USD 0.00
Motivation Picker
Collapsed — will preview the partner-class-driven assignment once you pick a donor.
GL Journal Preview
No posting payload yet — add gift details to preview.

Three legacy programs collapse into one route. The header panel takes the place of GiftBatches.html, the inline grid takes the place of GiftDetailEntry.html, and the side-panel takes the place of MotivationPicker.html. No business rule has fired yet — BR-GIFT-001 has reserved the next batch number but the atomic increment will not occur until the finance clerk posts. The first state transition happens when the finance clerk interacts with the donor field, which leads us to Scene 2.

Scene 2: Donor Search with Autocomplete

The finance clerk starts typing the donor’s name into the Donor field on the first detail row. The autocomplete dropdown opens with three matches, each carrying the financial context the legacy system never surfaced: partner key, partner class, last gift date, YTD total in the ledger base currency, and a receipt-cadence indicator. The Motivation Picker side-panel updates live to preview the motivation that BR-GIFT-011 will assign for the highlighted match.

Triggering user action: The finance clerk types Mül into the Donor input.

Gift Batch Workspace
Ledger 43 · EUR  ·  Effective: 2026-05-21  ·  Period 5 / May 2026 — open  ·  Batch #47 (reserved)
Gift Details
DonorMotivationAmountCurTax %Base (EUR)
Mül|
Müller, Heinrich [UNIT · 43005001]
YTD €3,200.00 · last gift 12 Apr 2026 · annual receipt
Müller-Schmidt, Petra [FAMILY · 43005104]
YTD €540.00 · last gift 02 Mar 2026 · per-gift receipt
Mülheim Field Project [UNIT/FIELD · 43009001]
YTD €1,840.00 · last gift 18 Apr 2026 · no receipt
Multi-Currency Summary — USD 0.00 · EUR 0.00 · USD 0.00
Motivation Picker
Highlighted: Müller, Heinrich
Partner class: UNIT
BR-GIFT-011 will assign → GIFT › SUPPORT
GL Journal Preview
No posting payload yet.

Two business rules light up at glance time. BR-GIFT-002 (Financial Period Validation) renders the green "Period 5 / May 2026 — open" pill in the header because the finance clerk’s effective date is bound to an open accounting period. BR-GIFT-011 (Motivation Detail Assignment by Partner Class) previews the motivation that will be auto-assigned once the finance clerk selects a match — their cursor is on Heinrich Müller (partner class UNIT, no specifically-linked motivation detail, no key-ministry mapping), so the side-panel previews GIFT › SUPPORT. In Scene 3, the finance clerk will pick the match and start typing amounts.

Scene 3: Preview / Pending State — Multi-Currency Allocation

The finance clerk selects Heinrich Müller from the dropdown and types the amount, transaction currency, and tax-deductible percentage. The detail row enters pending state (amber background), and three side-panels recompute in real time: the Multi-Currency Summary shows the three running totals; the Motivation Picker confirms the auto-assigned motivation; and the GL Journal Preview — a panel that legacy Petra could never offer — shows the dry-run posting payload that will hit the GL when the finance clerk commits.

Triggering user action: The finance clerk selects Müller, types Amount 500.00, Currency USD, Tax % 87, and adds two more rows (a EUR-denominated family gift and an EUR-denominated field-project gift). They have not yet clicked Post.

Gift Batch Workspace — Pending
Ledger 43 · EUR  ·  Effective: 2026-05-21  ·  Period 5 — open  ·  Batch #47 (reserved)
Gift Details — 3 rows pending
DonorMotivationAmountCurTax %Est. Base (EUR)
Müller, Heinrich [UNIT] GIFT › SUPPORT 500.00 USD 87 ~€454.55
Berger, Anneliese [FAMILY] GIFT › SUPPORT 200.00 EUR 100 €200.00
Kongo Mission Fund [UNIT/FIELD] GIFT › FIELD 1,800.00 EUR 100 €1,800.00
Multi-Currency Summary — est.
Transaction: USD 500.00 · EUR 2,000.00  ·  Base (EUR): €2,454.55  ·  International (USD): $2,700.00
Motivation Picker
Row 3 focus: Kongo Mission Fund
Partner class: UNIT/FIELD
BR-GIFT-011 assigned → GIFT › FIELD
GL Journal Preview (est.)
● Journal PENDING Source: GIFT-47 · 3 details
Account Dr Cr
0100 Bank-EUR2,454.55
4500 Gift Income2,395.46
4501 Non-Deductible Gift59.09
Net2,454.552,454.55
BR-GIFT-003 · 3-currency reconciled BR-GIFT-010 · tax-deductible split

Four business rules now run live. BR-GIFT-003 (Multi-Currency Gift Processing) drives the three-currency Summary — Müller’s USD 500.00 gift is converted to EUR 454.55 at the 1.10 USD/EUR rate, while the two EUR-denominated rows pass through. BR-GIFT-009 (Tax Deductible Percentage Bounds) clamped the finance clerk’s 87 % to within the [0, 100] range; had they typed 120, the input would have rejected back to 100. BR-GIFT-010 (Tax Deductibility Compliance Calculation) recomputed the deductible / non-deductible split across all three currencies and projected it into the GL Journal Preview. BR-GIFT-008 (Unmodified Detail Filter) is implicit but visible — every row is staged with a_modified_detail_l = 0, the baseline that makes future GiftAdjustment workflows idempotent. None of this preview was possible in legacy Petra because the GL journal was constructed only at posting time and held in memory.

Scene 4: Commit / Confirmed State — Batch Posted

The finance clerk clicks Post Batch. The workspace transitions from amber to green: rows lock, the batch status flips from Unposted to Posted, the GL journal number is assigned, and a confirmation toast slides in. The same data is on the page; only its colour and metadata have changed.

Triggering user action: The finance clerk clicks in the GL Journal Preview panel.

Batch #47 posted. GL journal GL-2026-05-21-014 assigned. View GL Posting →
Gift Batch Workspace — Posted
Ledger 43 · EUR  ·  Effective: 2026-05-21  ·  Batch #47 · Posted by the finance clerk · 14:32 UTC
Gift Details — 3 rows posted & locked
DonorMotivationAmountCurTax %Base (EUR)
Müller, Heinrich [UNIT] GIFT › SUPPORT 500.00 USD 87 €454.55 🔒
Berger, Anneliese [FAMILY] GIFT › SUPPORT 200.00 EUR 100 €200.00 🔒
Kongo Mission Fund [UNIT/FIELD] GIFT › FIELD 1,800.00 EUR 100 €1,800.00 🔒
Multi-Currency Summary — final
Transaction: USD 500.00 · EUR 2,000.00  ·  Base (EUR): €2,454.55  ·  International (USD): $2,700.00
Motivation Picker
All three rows posted with auto-assigned motivations.
✓ BR-GIFT-011 mapping locked.
GL Journal — posted
✓ Posted · GL-2026-05-21-014 GIFT-47 · 14:32 UTC
Account Dr Cr
0100 Bank-EUR2,454.55
4500 Gift Income2,395.46
4501 Non-Deductible Gift59.09
Net2,454.552,454.55
BR-GIFT-007 · Unposted → Posted BR-GIFT-001 · batch# 46→47 BR-GIFT-008 · modified_detail=0
✓ Posted · locked · View GL Posting →

The pending-to-confirmed transition is the visible representation of three database transactions that happened inside a single PostgreSQL BEGIN/COMMIT block: BR-GIFT-001 atomically incremented LastGiftBatchNumber from 46 to 47 (the next clerk to start a batch will see #48); BR-GIFT-007 flipped a_batch_status_c from Unposted to Posted and locked the rows against direct edit (subsequent changes require an explicit GiftAdjustment workflow); BR-GIFT-008 set a_modified_detail_l = 0 on every row to establish the idempotency baseline. The legacy posting confirmation was a one-line modal alert with no journal reference; in the modern workspace, the journal number, posted-by user, timestamp, and the immutability state are all part of the page state.

Scene 5: Downstream Consequence — GL Posting Bridge

The finance clerk clicks the View GL Posting → link in the status bar. Instead of navigating to a different program (as the legacy GL051 enquiry would have required), the modern workspace expands an inline panel that shows the GL journal as the legacy general ledger received it — including the strangler-fig bridge metadata. This makes the coexistence pattern visible to the user: the data lives in the modern Gift Processing API, but the canonical GL journal landed in the legacy GL (which remains the system of record for ledger balances until the GL itself is modernized in a later phase).

Triggering user action: The finance clerk clicks View GL Posting → in the confirmation toast or in the Status bar.

GL Posting — Journal GL-2026-05-21-014
Bridged via classical-strangler-fig
Journal Lines
AcctNameDebit (EUR)Credit (EUR)Memo
0100Bank — EUR2,454.55GIFT-47 net (USD 500 + EUR 2000)
4500Gift Income2,395.46Tax-deductible portion
4501Non-Deductible Gift59.09Residual from BR-GIFT-010 split
Net2,454.552,454.55✓ balanced
Posted at 2026-05-21 14:32:08 UTC · Source batch GIFT-47 · Ledger period 05/2026
Strangler-Fig Route
[Modern Gift Processing API]
            |
            v
[Azure API Management]
   route: gl-journal-bridge
            |
            v
[Legacy GL (.asmx SOAP)]
   endpoint: TGLBatchWebConnector
   ack id:   LGL-2026-014-ACK
Legacy GL Receipt
Ack id: LGL-2026-014-ACK
Received: 14:32:09 UTC (latency 1.1 s)
Legacy period: 05/2026
Status: accepted
The bridge is one-way. Reads of GL balances continue to go to the legacy GL until that subsystem is itself modernized in a later phase.

Two business rules quietly govern this view. BR-GIFT-007 is what permitted the panel to open at all — only Posted batches have an associated journal; unposted or cancelled batches return an empty panel. BR-GIFT-003 is visible in the journal lines themselves: the base-currency amount (€454.55) is the converted value of Müller’s USD 500 gift, while the memo retains the transaction-currency original. The platform-affinity win is "everything visible without leaving the page" — the legacy clerk would have had to navigate to GL051 (a separate program with its own login session) to see this; the modern clerk reads it inline.

Scene 6: Side-by-Side Coexistence (legacy mirror)

Tony, the finance manager, is auditing the day’s work. He wants to verify that the modern workspace and the legacy reader agree on the data — the contract that the classical-strangler-fig modernization plan promises. He clicks the toolbar toggle Show legacy view; the modern workspace stays where it is on the left, and the legacy GiftBatches.html page renders read-only on the right.

Triggering user action: Tony clicks the toolbar toggle .

Coexistence audit mode. Modern API owns writes for the posted/unposted gift-batch state machine. Legacy reads continue against the same PostgreSQL database until the legacy clients are retired.  See Section 11 →
Modern Gift Processing API — Batch #47
DonorAmountCurStatus
Müller, Heinrich500.00USDPosted
Berger, Anneliese200.00EURPosted
Kongo Mission Fund1,800.00EURPosted
Read from a_gift_batch via Angular 20 API client · latency 12 ms
Legacy mirror — GiftBatches.html (read-only iframe)
OpenPetra » Gift Batches
Donor keyAmountCurStatus
43005001500.00USDPosted
43005203200.00EURPosted
430090011,800.00EURPosted
Read from a_gift_batch via legacy jQuery client · latency 86 ms
✓ Convergence confirmed. Same three rows, same totals, same Posted status. BR-GIFT-001 (atomic numbering), BR-GIFT-007 (Posted status), and BR-GIFT-008 (unmodified-detail flag) all maintain their invariants across both readers.

No new business rule fires here — this is a coexistence-level check — but the scene is what makes the strangler-fig pattern auditable in the UI. Tony can verify in one screen that the modern endpoint and the legacy reader agree on the data. The platform-affinity win is the modernization plan’s commitment becoming first-class in the product: classical strangler-fig stops being plumbing and starts being a visible operational mode that the audit user can toggle.

7.8 Design Tokens and Modern UI Conventions

The Angular 20 mockups in this section use a small set of design tokens that recur across every screen and scene. Codifying them keeps the modernization deliberate — not ad-hoc — and makes them easy to enforce in the demo app the code-generation phase will produce.

TokenValueUsed for
Primary accent#1a4442 (Concho deep teal)Buttons, headings, focus borders
Pending state#fffbeb bg, #fde68a border, #92400e textAmber rows, "est." labels, pre-commit projections
Confirmed state#f0fdf4 bg, #bbf7d0 border, #166534 textPosted rows, journal-assigned chip, ✓ ticks
Period chip (open)#f0fdf4 bg, #166534 textBR-GIFT-002 live period validation
Status — Unposted#fffbeb bg, #92400e textBR-GIFT-007 status pill in list/header
Status — Posted#f0fdf4 bg, #166534 textBR-GIFT-007 status pill after commit
Strangler bridge label#fef3c7 bg, #92400e textCoexistence indicator in Scenes 5 and 6
Body font'Inter', sans-serifAll modern UI text
Code/journal blocks'Courier New', Courier, monospace · #1a1a1a bg · #ffffff fgGL journal previews, strangler-route diagrams, code snippets

The Section 7.9 fragment that follows enumerates every field on the unified workspace as a single inventory table. That table is the ground truth that the mockups above, the modern Angular components, and the Playwright drift-detection spec all derive from.

7.9 Field Inventory Reference (full table)

This table enumerates every user-facing field across the Gift Batch Entry through Posted GL flow as discovered in the legacy source — the ground truth that the mockups above, the modern components, and the Playwright drift-detection spec all derive from. Each row traces to a property on a real legacy ViewModel / typed-dataset row (see evidence column); the validate-storyboard.py gate re-checks every row against the cached legacy source before this section is assembled, so claimed fields cannot drift from what the legacy actually has.

Source profile: dotnet-angularjs · Target framework: Angular 20 + ASP.NET Core 10 (.NET 10) · Screens: 1 (unified workspace) · Top-level fields: 6 · Display-only fields: 3 · Detail-row fields: 10 · Total fields: 19 · Business rules referenced: 8

Step Screen Field id Label Kind Type Req? Validation Conditional Evidence (legacy)
1UnifiedGiftBatcha_ledger_number_iLedgerlookupint32must-existAGiftBatchRow.ALedgerNumber
1UnifiedGiftBatcha_batch_description_cBatch Descriptiontext-inputstringmax-length-80AGiftBatchRow.BatchDescription
1UnifiedGiftBatcha_gl_effective_date_dEffective Datedate-inputdatemust-fall-in-open-period (BR-GIFT-002)AGiftBatchRow.GlEffectiveDate
1UnifiedGiftBatcha_batch_status_cBatch Statusenumenumtransition-only-via-post-action (BR-GIFT-007)AGiftBatchRow.BatchStatus
1UnifiedGiftBatcha_batch_number_iBatch Numberdisplayint32atomic-increment-on-post (BR-GIFT-001)AGiftBatchRow.BatchNumber
1UnifiedGiftBatcha_currency_code_cBase Currencylookupstringmust-existAGiftBatchRow.CurrencyCode
1UnifiedGiftBatchsummary.transaction_totalTransaction Currency Totaldisplaydecimalderived: SUM(a_gift_transaction_amount_n) grouped by currency (BR-GIFT-003)AGiftDetailRow.GiftTransactionAmount
1UnifiedGiftBatchsummary.base_totalBase Currency Totaldisplaydecimalderived: SUM(a_gift_amount_n) in ledger base currency (BR-GIFT-003)AGiftDetailRow.GiftAmount
1UnifiedGiftBatchsummary.intl_totalInternational Currency Totaldisplaydecimalderived: SUM(a_gift_amount_intl_n) (BR-GIFT-003)AGiftDetailRow.GiftAmountIntl
1UnifiedGiftBatch › gift_detail_rowp_donor_key_nDonorautocompleteint64must-exist; partner-class drives motivation hint (BR-GIFT-011)AGiftRow.DonorKey
1UnifiedGiftBatch › gift_detail_rowa_motivation_group_code_cMotivation Grouplookupstringauto-assigned-from-partner-class (BR-GIFT-011); user can overrideAGiftDetailRow.MotivationGroupCode
1UnifiedGiftBatch › gift_detail_rowa_motivation_detail_code_cMotivation Detaillookupstringauto-assigned-from-partner-class (BR-GIFT-011); user can overrideAGiftDetailRow.MotivationDetailCode
1UnifiedGiftBatch › gift_detail_rowp_recipient_key_nRecipient (Partner)autocompleteint64optional; defaults to recipient_ledger_number if motivation is field-typevisible only when motivation group requires explicit recipient (UNIT/FIELD or UNIT/KEYMIN)AGiftDetailRow.RecipientKey
1UnifiedGiftBatch › gift_detail_rowa_gift_transaction_amount_nAmount (Transaction Currency)number-inputdecimalpositive-numeric; NUMERIC(12,2) precisionAGiftDetailRow.GiftTransactionAmount
1UnifiedGiftBatch › gift_detail_rowa_transaction_currency_cTransaction Currencylookupstringmust-exist (a_currency); drives BR-GIFT-003AGiftRow.CurrencyCode
1UnifiedGiftBatch › gift_detail_rowa_tax_deductible_pct_nTax Deductible %number-inputdecimal0-to-100-clamped (BR-GIFT-009)AGiftDetailRow.TaxDeductiblePct
1UnifiedGiftBatch › gift_detail_rowa_print_receipt_lPrint Receipt?checkboxbooleandefault-true; drives BR-GIFT-004 eligibilityAGiftRow.PrintReceipt
1UnifiedGiftBatch › gift_detail_rowa_confidential_gift_flag_lConfidential Giftcheckboxbooleandefault-false; drives BR-GIFT-006 authorization policy on readAGiftRow.ConfidentialGiftFlag
1UnifiedGiftBatch › gift_detail_rowa_modified_detail_lModified Detail Flagdisplaybooleansystem-managed; baseline = 0 on insert (BR-GIFT-008); set to 1 only by adjustment workflowAGiftDetailRow.ModifiedDetail

Business rules referenced across these fields: BR-GIFT-001 Gift Batch Sequential Numbering, BR-GIFT-002 Gift Batch Financial Period Validation, BR-GIFT-003 Multi-Currency Gift Processing, BR-GIFT-004 Donation Receipt Generation Workflow, BR-GIFT-006 Confidential Gift Privacy Handling, BR-GIFT-007 Gift Batch Posting Status Constraint, BR-GIFT-008 Unmodified Detail Filter, BR-GIFT-009 Tax Deductible Percentage Bounds, BR-GIFT-010 Tax Deductibility Compliance Calculation, BR-GIFT-011 Motivation Detail Assignment by Partner Class.

This table is target-framework-agnostic. The same inventory feeds the Angular 20 form schema, the ASP.NET Core 10 DTO validators, the EF Core entity column mappings, and the Playwright drift-detection spec. When the legacy a_gift_batch / a_gift / a_gift_detail typed-dataset definitions change — whether by an OpenPetra upstream upgrade or a local schema migration — regenerating the storyboard updates every downstream artifact in lockstep.

8. Code Translation Examples

TL;DR — This is a same-language modernization (C# .NET Framework 4.7 / Mono → C# .NET 10). The seven translation examples below show the same C# semantics expressed in a fundamentally different shape: ASMX SOAP web-service operations become Minimal-API endpoints, typed-DataSet table accessors become EF Core 8 entities backed by PostgreSQL, static utility classes become injectable services validated by FluentValidation, raw connection-string SQL becomes parameterized LINQ, and ad-hoc try/catch becomes Polly resilience pipelines plus RFC 7807 problem details. Every example cross-references the business rule from Section 9 that it implements.

The legacy Petra server is a .NET Framework 4.7 / Mono application that exposes Finance — Gift Processing through ASMX SOAP web services (notably TGiftTransactionWebConnector). State lives in petra-XML–generated typed DataSets; database access goes through a hand-rolled DBAccess facade that issues string-concatenated SQL against PostgreSQL, MySQL, or SQLite via the TDBType abstraction. Server-side validation is implicit in business-logic classes; logging is TLogging.Log() with severity levels; error handling is throw new EFinanceException("…") caught at the SOAP boundary; observability is essentially absent.

The target Petra server is an ASP.NET Core 10 Minimal API. Entities live in EF Core 10 (Npgsql), persisted to Azure Database for PostgreSQL Flexible Server. Validation is FluentValidation at every API boundary. Outbound calls to the legacy Partner and GL services are wrapped in Polly resilience pipelines (retry + circuit breaker). Logging is Serilog in JSON. Distributed tracing is OpenTelemetry .NET SDK, exporting OTLP to Azure Application Insights. Errors surface as RFC 7807 Problem Details. The same C# language flows through both ends — the modernization win is shape, not syntax.

8.1 Translation Principles

Seven concrete translation patterns appear in the examples below. Read together they describe the full transformation:

#Legacy ShapeModern ShapeRule fingerprint
1ASMX SOAP [WebMethod] with out parameters and side-effecting boolean returnsASP.NET Core Minimal-API app.MapPost(…) with a typed request DTO and a typed result recordBR-GIFT-001 / 002 (atomic ++)
2petra-XML–generated AGiftBatchTable typed DataSet, mutated in memoryEF Core 10 entity (GiftBatch) with concurrency token, persisted via DbContextBR-GIFT-001
3Static utility class TFinancialYear.GetLedgerDatePostingPeriodInjected IFinancialPeriodValidator with FluentValidation + DIBR-GIFT-002
4Static helper TaxDeductibility.UpdateTaxDeductibiltyAmounts(ref AGiftDetailRow) with Math.Max/Math.Min clampingDomain method on the GiftDetail aggregate, same clamping, with FluentValidation rule co-locatedBR-GIFT-009 / 010
5Switch-case on PartnerClass + UnitTypeCode with string constantsSwitch expression on strongly-typed PartnerClass and UnitType enums returning a MotivationAssignment recordBR-GIFT-011
6Raw concatenated SQL with three flags: a_batch_status_c = 'Posted' AND a_modified_detail_l = 0 AND a_print_receipt_l = 1EF Core IQueryable<GiftDetail> with global query filter + specification patternBR-GIFT-004 / 007 / 008
7Static HttpWebRequest calls to legacy services with hand-rolled retryHttpClient registered with Polly resilience pipeline (3 retries, exponential backoff, circuit breaker)BR-GIFT-007 (posting bridge)

Where the legacy code carries implicit behavior (the SOAP layer caught exceptions and translated them into SOAP faults; the typed DataSet held cross-screen state in memory; SQL strings encoded confidentiality via aliasing), the modernized code makes it explicit: FluentValidation owns input checks, RFC 7807 owns error shape, Polly owns retry, OpenTelemetry owns trace context. The eight catalog rules that fire in the canonical Gift-Batch-Entry use case map directly onto these seven patterns.

8.2 ASMX SOAP Endpoint → Minimal-API Post-Batch (Example 1)

The most architecturally important endpoint in the subsystem is PostGiftBatch — the modernized version of this single method is the strangler-fig routing key (per Section 11). Legacy clients invoke it as a SOAP operation on TGiftTransactionWebConnector; the modernized clients hit POST /api/gift-processing/v1/batches/{batchId}/post.

Legacy: ASMX SOAP (.NET 4.7 / Mono)

// csharp/ICT/Petra/Server/lib/MFinance/
//   Gift/Gift.Batch.cs

[WebMethod]
[RequireModulePermission("FINANCE-2")]
public bool PostGiftBatch(
    Int32 ALedgerNumber,
    Int32 ABatchNumber,
    out TVerificationResultCollection
        AVerifications)
{
    AVerifications = new
        TVerificationResultCollection();

    TDBTransaction Tx = null;
    bool ok = false;

    DBAccess.GetDBAccessObj(null)
        .WriteTransaction(
            ref Tx, ref ok,
            delegate
    {
        var batchTbl = AGiftBatchAccess
            .LoadByPrimaryKey(
                ALedgerNumber,
                ABatchNumber, Tx);

        if (batchTbl.Rows.Count == 0)
            throw new EFinanceException(
                "Batch not found");

        var row = (AGiftBatchRow)
            batchTbl.Rows[0];

        if (row.BatchStatus != "Unposted")
            throw new EFinanceException(
                "Batch not Unposted");

        row.BatchStatus = "Posted";
        AGiftBatchAccess.SubmitChanges(
            batchTbl, Tx, AVerifications);

        TGLPosting.PostBatch(
            ALedgerNumber, ABatchNumber,
            Tx);
        ok = true;
    });
    return ok;
}

Modern: Minimal API (.NET 10)

// src/Petra.GiftProcessing/Api/
//   GiftBatchEndpoints.cs

public static class GiftBatchEndpoints
{
  public static void MapGiftBatchPost(
    this IEndpointRouteBuilder app)
  {
    app.MapPost(
      "/api/gift-processing/v1/batches/" +
        "{batchId:int}/post",
      async (
        int batchId,
        PostBatchCommand cmd,
        IValidator<PostBatchCommand> v,
        IGiftBatchPoster poster,
        ILogger<Program> log,
        CancellationToken ct) =>
    {
      using var act = Tracing.Source
        .StartActivity("post_batch");
      act?.SetTag("batch_id", batchId);

      var vr = await v.ValidateAsync(
        cmd, ct);
      if (!vr.IsValid)
        return Results.ValidationProblem(
          vr.ToDictionary());

      var r = await poster
        .PostAsync(batchId, cmd, ct);

      log.LogInformation(
        "gift.batch.posted {BatchId} " +
        "{JournalRef}",
        batchId, r.GlJournalRef);

      return Results.Ok(r);
    })
    .RequireAuthorization(
      "Finance.WriteGifts")
    .WithName("PostGiftBatch")
    .WithOpenApi();
  }
}

Translation Notes

  • BR-GIFT-007 (Posting Status Constraint) preserved — the Unposted → Posted transition now lives in IGiftBatchPoster.PostAsync, enforced by a domain invariant on the GiftBatch aggregate rather than an inline string compare.
  • out TVerificationResultCollection disappears — FluentValidation results turn into RFC 7807 ValidationProblem responses at the HTTP boundary.
  • [RequireModulePermission("FINANCE-2")].RequireAuthorization("Finance.WriteGifts") (Entra ID JWT policy).
  • OpenTelemetry span ("post_batch") replaces the implicit "set status" log line; Serilog structured logging adds {BatchId} + {JournalRef} as queryable fields in Application Insights.
  • Polly & the GL strangler-bridge call live in GiftBatchPoster (see Example 7 below); this endpoint stays HTTP-shape only.

8.3 Typed DataSet → EF Core 10 Entity with Atomic Sequence (Example 2)

Petra's legacy data layer is generated from db/petra.xml into typed DataSets — e.g. AGiftBatchTable with strongly-typed columns and a hand-rolled accessor (AGiftBatchAccess). Modernization replaces both with one EF Core entity. The atomic increment behavior of BR-GIFT-001 survives the rewrite: legacy code did ++ALedgerTbl[0].LastGiftBatchNumber inside a serializable transaction; the modern code uses an EF Core concurrency token plus a row-level lock.

Legacy: typed DataSet (.NET 4.7)

// csharp/ICT/Petra/Server/lib/MFinance/
//   Gift/Gift.Batch.cs:111

ALedgerTable ALedgerTbl =
    ALedgerAccess.LoadByPrimaryKey(
        ALedgerNumber, Tx);

if (ALedgerTbl.Rows.Count == 0)
    throw new EFinanceException(
        "Ledger not found");

// Atomic ++ on the row in memory;
// safety relies on the SQL
// transaction's row lock.
Int32 newBatchNumber =
    ++ALedgerTbl[0]
        .LastGiftBatchNumber;

AGiftBatchRow newBatch =
    NewGiftBatchTbl.NewRowTyped();

newBatch.LedgerNumber = ALedgerNumber;
newBatch.BatchNumber  = newBatchNumber;
newBatch.BatchStatus  = "Unposted";
newBatch.GlEffectiveDate =
    ADateEffective;

NewGiftBatchTbl.Rows.Add(newBatch);

ALedgerAccess.SubmitChanges(
    ALedgerTbl, Tx, AVerifications);
AGiftBatchAccess.SubmitChanges(
    NewGiftBatchTbl, Tx, AVerifications);

Modern: EF Core 10 entity + domain method

// src/Petra.GiftProcessing/Domain/
//   GiftBatch.cs + Ledger.cs

public class Ledger
{
    public int Id { get; set; }
    public int LastGiftBatchNumber
        { get; private set; }

    [ConcurrencyCheck]
    public uint Xmin { get; private set; }

    public int AssignNextBatchNumber()
    {
        // BR-GIFT-001: monotonic, per-ledger.
        // EF Core writes the new value
        // back with WHERE xmin = @oldXmin;
        // PostgreSQL rejects concurrent
        // writers via OCC.
        return ++LastGiftBatchNumber;
    }
}

// Application service usage
await using var tx = await db.Database
    .BeginTransactionAsync(
        IsolationLevel.Serializable, ct);

var ledger = await db.Ledgers
    .SingleAsync(l => l.Id == ledgerId, ct);

var number = ledger
    .AssignNextBatchNumber();

var batch = new GiftBatch
{
    LedgerId       = ledgerId,
    BatchNumber    = number,
    BatchStatus    = BatchStatus.Unposted,
    GlEffectiveDate = effectiveDate
};

db.GiftBatches.Add(batch);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);

Translation Notes

  • BR-GIFT-001 (Gift Batch Sequential Numbering) preserved — per-ledger monotonic, no holes, no collisions. The atomic ++ stays in the domain method; persistence safety moves from "trust the transaction" to "concurrency token + explicit row update."
  • BatchStatus goes from string to a strongly-typed BatchStatus enum (Unposted / Posted / Cancelled), enforced at the EF Core value-conversion layer.
  • [ConcurrencyCheck] uint Xmin maps to PostgreSQL's system xmin column — EF Core 10's Npgsql provider supports this directly; conflicting writers get a DbUpdateConcurrencyException.
  • The legacy "two SubmitChanges in one transaction" idiom collapses into one SaveChangesAsync call.

8.4 Static Utility → Injectable Validator (Example 3)

Petra's legacy code uses static-utility classes pervasively — TFinancialYear.GetLedgerDatePostingPeriod is a representative case. The static design makes the helper untestable in isolation and forces every caller to depend on TDBTransaction being threaded through. The modernized version turns it into a DI-registered service backed by FluentValidation for the input rule (BR-GIFT-002: effective date must fall in an open accounting period).

Legacy: static utility + out params

// csharp/ICT/Petra/Server/lib/
//   MFinance/Common/Common.FinancialYear.cs

public static class TFinancialYear
{
  public static bool
    GetLedgerDatePostingPeriod(
      Int32 ALedgerNumber,
      ref DateTime ADateEffective,
      out Int32 ABatchYear,
      out Int32 ABatchPeriod,
      bool AForceEffectiveDateToFit,
      TDBTransaction ATransaction,
      bool AShowErrorMessages)
  {
    ABatchYear   = -1;
    ABatchPeriod = -1;

    var periods = AAccountingPeriodAccess
      .LoadViaALedger(
        ALedgerNumber, ATransaction);

    foreach (AAccountingPeriodRow p
        in periods.Rows)
    {
      if (ADateEffective >=
            p.PeriodStartDate &&
          ADateEffective <=
            p.PeriodEndDate)
      {
        ABatchYear   = p.AccountingYear;
        ABatchPeriod = p.AccountingPeriod;
        return true;
      }
    }

    if (AForceEffectiveDateToFit)
    {
      // … clamp to nearest boundary
      return true;
    }

    return false;
  }
}

Modern: injected service + FluentValidation

// src/Petra.GiftProcessing/Domain/
//   Validation/FinancialPeriodValidator.cs

public interface IFinancialPeriodValidator
{
  Task<PeriodResolution> ResolveAsync(
    int ledgerId, DateOnly effectiveDate,
    bool forceFit, CancellationToken ct);
}

public sealed record PeriodResolution(
  int Year, int Period, DateOnly FittedDate);

public class FinancialPeriodValidator
    : IFinancialPeriodValidator
{
  private readonly GiftDbContext _db;
  private readonly ILogger _log;

  public FinancialPeriodValidator(
    GiftDbContext db,
    ILogger<FinancialPeriodValidator> log)
  { _db = db; _log = log; }

  public async Task<PeriodResolution>
    ResolveAsync(int ledgerId,
      DateOnly date, bool forceFit,
      CancellationToken ct)
  {
    var p = await _db.AccountingPeriods
      .AsNoTracking()
      .Where(x => x.LedgerId == ledgerId)
      .Where(x => date >= x.PeriodStart
        && date <= x.PeriodEnd)
      .SingleOrDefaultAsync(ct);

    if (p is not null)
      return new(p.AccountingYear,
        p.AccountingPeriod, date);

    if (!forceFit)
      throw new PeriodOutOfRangeException(
        ledgerId, date);

    // … nearest-boundary fit
  }
}

// FluentValidation rule on the request DTO:
public class CreateBatchValidator
    : AbstractValidator<CreateBatchCommand>
{
  public CreateBatchValidator(
    IFinancialPeriodValidator periods)
  {
    RuleFor(c => c.EffectiveDate)
      .NotEmpty()
      .MustAsync(async (c, d, _) =>
      {
        try { await periods.ResolveAsync(
            c.LedgerId, d, c.ForceFit, _);
          return true; }
        catch (PeriodOutOfRangeException)
          { return false; }
      })
      .WithMessage(
        "Effective date is outside any " +
        "open accounting period.");
  }
}

Translation Notes

  • BR-GIFT-002 (Financial Period Validation) preserved — same open-period filter and same force-fit option, now expressible in a unit test that injects a fake GiftDbContext.
  • out parameters become a typed record (PeriodResolution); failure surfaces as PeriodOutOfRangeException, mapped to HTTP 422 by the RFC 7807 problem handler.
  • FluentValidation rule lives on CreateBatchCommand; the validator is wired to the endpoint via the Minimal-API pattern shown in 8.2.
  • Reference data (AccountingPeriods) is a normal EF Core DbSet — no separate ...Access accessor class needed.

8.5 Static Math.Max/Min Clamp → Domain Method (Example 4)

TaxDeductibility.UpdateTaxDeductibiltyAmounts is the focal point of BR-GIFT-009 (Tax Deductible Percentage Bounds) and BR-GIFT-010 (Tax Deductibility Compliance Calculation). The legacy code is a static helper that mutates a ref AGiftDetailRow; the modernized version is a domain method on the GiftDetail aggregate, with the input clamp expressed as a FluentValidation rule co-located with the entity.

Legacy: static helper + ref param

// csharp/ICT/Petra/Shared/lib/MFinance/
//   TaxDeductibility.cs:50

public static class TaxDeductibility
{
  public static void
    UpdateTaxDeductibiltyAmounts(
      ref AGiftDetailRow AGiftDetail)
  {
    if (AGiftDetail.IsTaxDeductiblePctNull())
      AGiftDetail.TaxDeductiblePct = 0.0m;

    AGiftDetail.TaxDeductiblePct =
      Math.Max(
        AGiftDetail.TaxDeductiblePct, 0m);
    AGiftDetail.TaxDeductiblePct =
      Math.Min(
        AGiftDetail.TaxDeductiblePct, 100m);

    decimal pct =
      AGiftDetail.TaxDeductiblePct / 100m;

    AGiftDetail.TaxDeductibleAmount =
      AGiftDetail.GiftAmount * pct;
    AGiftDetail.NonDeductibleAmount =
      AGiftDetail.GiftAmount * (1m - pct);

    AGiftDetail.TaxDeductibleAmountBase =
      AGiftDetail.GiftAmountBase * pct;
    AGiftDetail.NonDeductibleAmountBase =
      AGiftDetail.GiftAmountBase * (1m - pct);

    AGiftDetail.TaxDeductibleAmountIntl =
      AGiftDetail.GiftAmountIntl * pct;
    AGiftDetail.NonDeductibleAmountIntl =
      AGiftDetail.GiftAmountIntl * (1m - pct);
  }
}

Modern: domain method + FluentValidation

// src/Petra.GiftProcessing/Domain/
//   GiftDetail.cs

public class GiftDetail
{
  public decimal GiftAmount { get; set; }
  public decimal GiftAmountBase { get; set; }
  public decimal GiftAmountIntl { get; set; }
  public decimal TaxDeductiblePct
    { get; private set; }
  public decimal TaxDeductibleAmount
    { get; private set; }
  public decimal NonDeductibleAmount
    { get; private set; }
  public decimal TaxDeductibleAmountBase
    { get; private set; }
  public decimal NonDeductibleAmountBase
    { get; private set; }
  public decimal TaxDeductibleAmountIntl
    { get; private set; }
  public decimal NonDeductibleAmountIntl
    { get; private set; }

  // BR-GIFT-009 + BR-GIFT-010
  public void ApplyTaxDeductiblePct(
    decimal pct)
  {
    // Clamp 0..100 (legacy parity)
    var p = Math.Max(0m, Math.Min(100m, pct));
    TaxDeductiblePct = p;
    var f = p / 100m;

    TaxDeductibleAmount =
      decimal.Round(GiftAmount * f, 2);
    NonDeductibleAmount =
      GiftAmount - TaxDeductibleAmount;

    TaxDeductibleAmountBase =
      decimal.Round(GiftAmountBase * f, 2);
    NonDeductibleAmountBase =
      GiftAmountBase - TaxDeductibleAmountBase;

    TaxDeductibleAmountIntl =
      decimal.Round(GiftAmountIntl * f, 2);
    NonDeductibleAmountIntl =
      GiftAmountIntl - TaxDeductibleAmountIntl;
  }
}

// Co-located FluentValidation rule
// (input check; clamp still defensive)
public class GiftDetailValidator
    : AbstractValidator<GiftDetailDto>
{
  public GiftDetailValidator()
  {
    RuleFor(d => d.TaxDeductiblePct)
      .InclusiveBetween(0m, 100m)
      .WithMessage(
        "Tax deductible percentage " +
        "must be between 0 and 100.");
  }
}

Translation Notes

  • BR-GIFT-009 (Tax Deductible Percentage Bounds) preserved — same Math.Max/Math.Min clamp, now inside the aggregate so no caller can bypass it.
  • BR-GIFT-010 (Tax Deductibility Compliance Calculation) preserved — the three-currency split (transaction, base, intl) is identical; only the host changes.
  • Defense-in-depth: FluentValidation rejects out-of-range input at the boundary, AND the domain method clamps defensively — matches legacy belt-and-braces behavior.
  • decimal.Round(…, 2) is added explicitly so the EF Core NUMERIC(13,2) column writes a deterministic value (legacy relied on System.Data.DataRow's implicit rounding).

8.6 Switch-Case String Constants → Strongly-Typed Switch Expression (Example 5)

TGuiTools.GetMotivationGroupAndDetailForPartner drives BR-GIFT-011 (Motivation Detail Assignment by Partner Class). The legacy implementation is a string-on-string switch tracing p_partner.p_partner_class_c + p_unit.u_unit_type_code_c through hardcoded constants in MPartnerConstants + MFinanceConstants. The modernized version becomes a switch expression on strongly-typed enums returning a typed record.

Legacy: nested if + string compares

// csharp/ICT/Petra/Server/lib/MFinance/
//   Gift/Gift.gui.tools.cs:105-173

public static void
  GetMotivationGroupAndDetailForPartner(
    Int64 APartnerKey,
    ref string AMotivationGroup,
    ref string AMotivationDetail,
    TDBTransaction ATransaction)
{
  var pTable = PPartnerAccess
    .LoadByPrimaryKey(APartnerKey,
      ATransaction);
  if (pTable.Rows.Count == 0) return;

  var partnerClass =
    ((PPartnerRow)pTable.Rows[0])
      .PartnerClass;

  if (partnerClass !=
      MPartnerConstants.PARTNERCLASS_UNIT)
    return;

  // try the linked-detail lookup first
  var linked = AMotivationDetailAccess
    .LoadViaPPartner(APartnerKey,
      ATransaction);
  if (linked.Rows.Count > 0)
  {
    var r = (AMotivationDetailRow)
      linked.Rows[0];
    if (r.MotivationStatus)
    {
      AMotivationGroup  =
        r.MotivationGroupCode;
      AMotivationDetail =
        r.MotivationDetailCode;
      return;
    }
  }

  var unit = PUnitAccess
    .LoadByPrimaryKey(APartnerKey,
      ATransaction);
  string ut = ((PUnitRow)unit.Rows[0])
    .UnitTypeCode;

  if (ut == MPartnerConstants
        .UNIT_TYPE_AREA ||
      ut == MPartnerConstants
        .UNIT_TYPE_FUND ||
      ut == MPartnerConstants
        .UNIT_TYPE_FIELD)
  {
    AMotivationDetail =
      MFinanceConstants
        .GROUP_DETAIL_FIELD;
  }
  else if (ut == MPartnerConstants
        .UNIT_TYPE_KEYMIN)
  {
    AMotivationDetail =
      MFinanceConstants
        .GROUP_DETAIL_KEY_MIN;
  }
  else
  {
    AMotivationDetail =
      MFinanceConstants
        .GROUP_DETAIL_SUPPORT;
  }
}

Modern: switch expression + record

// src/Petra.GiftProcessing/Domain/
//   Motivation/MotivationResolver.cs

public enum PartnerClass
{ Person, Family, Organisation,
  Unit, Bank, Church, Venue }

public enum UnitType
{ Area, Fund, Field, KeyMin, Country,
  Conference, Team, WorkingGroup,
  Other, Root }

public sealed record MotivationAssignment(
  string Group, string Detail);

public interface IMotivationResolver
{
  Task<MotivationAssignment?> ResolveAsync(
    long partnerKey, CancellationToken ct);
}

public class MotivationResolver
    : IMotivationResolver
{
  private readonly GiftDbContext _db;

  public MotivationResolver(GiftDbContext db)
    => _db = db;

  public async Task<MotivationAssignment?>
    ResolveAsync(long partnerKey,
      CancellationToken ct)
  {
    var partner = await _db.Partners
      .AsNoTracking()
      .SingleOrDefaultAsync(
        p => p.PartnerKey == partnerKey,
        ct);
    if (partner is null) return null;

    if (partner.PartnerClass !=
        PartnerClass.Unit)
      return null; // caller-default preserved

    // Linked-detail wins if active.
    var linked = await _db.MotivationDetails
      .AsNoTracking()
      .Where(m =>
        m.LinkedPartnerKey == partnerKey
        && m.MotivationStatus)
      .FirstOrDefaultAsync(ct);
    if (linked is not null)
      return new(linked.MotivationGroupCode,
        linked.MotivationDetailCode);

    var unit = await _db.Units
      .AsNoTracking()
      .SingleAsync(
        u => u.PartnerKey == partnerKey, ct);

    return unit.UnitType switch
    {
      UnitType.Area
        or UnitType.Fund
        or UnitType.Field
          => new("GIFT", "FIELD"),
      UnitType.KeyMin
          => new("GIFT", "KEYMIN"),
      _   => new("GIFT", "SUPPORT"),
    };
  }
}

Translation Notes

  • BR-GIFT-011 (Motivation Detail Assignment by Partner Class) preserved — the test-corroborated nuance for non-UNIT partners (return null, caller keeps its defaults) is preserved by returning null instead of overwriting.
  • ref string output parameters disappear entirely; the typed record (MotivationAssignment) is value-equatable and unit-testable.
  • String constants (UNIT_TYPE_AREA, etc.) are replaced with a UnitType enum; EF Core's value converter handles the persistence boundary.
  • The switch expression is exhaustive at compile time — a new UnitType member triggers a compiler warning if a case is missed (legacy's silent fall-through to SUPPORT was a known pitfall).

8.7 Concatenated SQL → LINQ Specification (Example 6)

The receipt-eligibility query is the single most-cited SQL in the subsystem — csharp/ICT/Petra/Server/sql/Gift.ReceiptPrinting.GetDonationsOfDonor.sql encodes BR-GIFT-004 (dual-flag receipt eligibility), BR-GIFT-007 (posted only), BR-GIFT-008 (unmodified detail filter), and BR-GIFT-006 (confidential-gift dual partner alias) in one 60-line statement. The modernized version expresses each rule as a separate LINQ specification, so each can be unit-tested independently.

Legacy: hand-built SQL string

-- Gift.ReceiptPrinting
--   .GetDonationsOfDonor.sql

SELECT
  g.a_ledger_number_i,
  g.a_batch_number_i,
  g.a_gift_transaction_number_i,
  d.a_detail_number_i,
  d.a_gift_amount_n,
  d.a_gift_amount_intl_n,
  d.a_tax_deductible_pct_n,
  destn.p_partner_short_name_c
    AS gift_destination_name,
  rcp.p_partner_short_name_c
    AS recipient_name
FROM a_gift_batch b
INNER JOIN a_gift g
  ON g.a_ledger_number_i =
       b.a_ledger_number_i
 AND g.a_batch_number_i =
       b.a_batch_number_i
INNER JOIN a_gift_detail d
  ON d.a_ledger_number_i =
       g.a_ledger_number_i
 AND d.a_batch_number_i =
       g.a_batch_number_i
 AND d.a_gift_transaction_number_i =
       g.a_gift_transaction_number_i
INNER JOIN a_motivation_detail m
  ON m.a_ledger_number_i =
       d.a_ledger_number_i
 AND m.a_motivation_group_code_c =
       d.a_motivation_group_code_c
 AND m.a_motivation_detail_code_c =
       d.a_motivation_detail_code_c
INNER JOIN p_partner destn
  ON destn.p_partner_key_n =
       d.a_recipient_ledger_number_n
INNER JOIN p_partner rcp
  ON rcp.p_partner_key_n =
       d.p_recipient_key_n
WHERE b.a_batch_status_c = 'Posted'
  AND g.a_print_receipt_l = TRUE
  AND m.a_receipt_l = TRUE
  AND d.a_modified_detail_l = FALSE
  AND g.p_donor_key_n = {ADonorKey}
  AND g.a_date_entered_d BETWEEN
    '{ADateFrom}' AND '{ADateTo}'
ORDER BY g.a_date_entered_d ASC,
         d.a_detail_number_i DESC;

Modern: EF Core specifications

// src/Petra.GiftProcessing/Domain/
//   Specifications/GiftDetailSpecs.cs

public static class GiftDetailSpecs
{
  // BR-GIFT-007
  public static IQueryable<GiftDetail> Posted(
    this IQueryable<GiftDetail> q)
    => q.Where(d =>
      d.Gift.Batch.BatchStatus
        == BatchStatus.Posted);

  // BR-GIFT-008
  public static IQueryable<GiftDetail>
    Unmodified(this IQueryable<GiftDetail> q)
    => q.Where(d => !d.ModifiedDetail);

  // BR-GIFT-004 (dual flag)
  public static IQueryable<GiftDetail>
    ReceiptEligible(
      this IQueryable<GiftDetail> q)
    => q.Where(d =>
        d.Gift.PrintReceipt
        && d.MotivationDetail.ReceiptL);

  public static IQueryable<GiftDetail>
    ForDonor(this IQueryable<GiftDetail> q,
      long donorKey)
    => q.Where(d =>
      d.Gift.DonorKey == donorKey);

  public static IQueryable<GiftDetail>
    EnteredBetween(
      this IQueryable<GiftDetail> q,
      DateOnly from, DateOnly to)
    => q.Where(d =>
      d.Gift.DateEntered >= from
      && d.Gift.DateEntered <= to);
}

// Usage in the receipt service
var q = _db.GiftDetails.AsNoTracking()
  .Posted()           // BR-GIFT-007
  .Unmodified()       // BR-GIFT-008
  .ReceiptEligible()  // BR-GIFT-004
  .ForDonor(donorKey)
  .EnteredBetween(from, to)
  .Include(d => d.GiftDestination)
  .Include(d => d.Recipient) // BR-GIFT-006
  .OrderBy(d => d.Gift.DateEntered)
  .ThenByDescending(d => d.DetailNumber);

// BR-GIFT-006: authorization filter
// applied at the projection step
var dtos = await q
  .Select(d => new GiftDetailDto(
    d.Id, d.GiftAmount,
    d.GiftAmountIntl,
    d.TaxDeductiblePct,
    d.GiftDestination.ShortName,
    user.CanSeeConfidential
      || !d.Gift.ConfidentialGiftFlag
        ? d.Recipient.ShortName
        : "[Confidential]"))
  .ToListAsync(ct);

Translation Notes

  • BR-GIFT-004 (Donation Receipt Generation Workflow) preserved — dual-flag predicate stays exactly: PrintReceipt && ReceiptL.
  • BR-GIFT-007 (Posting Status Constraint) preserved — BatchStatus.Posted enum compare replaces the string.
  • BR-GIFT-008 (Unmodified Detail Filter) preserved as the !ModifiedDetail specification.
  • BR-GIFT-006 (Confidential Gift Privacy Handling) is the only rule whose semantics change — previously implicit in the SQL self-join, now an explicit projection-time authorization gate (the mitigation called out in the catalog). The recipient name is replaced with "[Confidential]" when the caller lacks the Gifts.ViewConfidential policy claim.
  • Each specification is a single-line extension method — unit-tested in isolation against an in-memory DbContext.
  • SQL injection vector eliminated — {ADonorKey} string interpolation is replaced with EF Core parameterization.

8.8 Hand-Rolled HttpWebRequest → Polly Resilience Pipeline (Example 7)

The strangler-fig bridge calls out to the legacy GL service (one-way posting) and the legacy Partner service (read-only validation) over REST. Legacy code wraps these calls in HttpWebRequest with hand-rolled try/catch and no retry; the modernized code uses HttpClient registered with a Polly resilience pipeline (retry + circuit breaker + timeout) and OpenTelemetry HTTP-client instrumentation.

Legacy: HttpWebRequest + try/catch

// csharp/ICT/Petra/Server/lib/
//   MFinance/GL/Gl.PostBatch.cs (sketch)

public static bool PostBatchToLegacyGL(
    Int32 ALedgerNumber,
    Int32 ABatchNumber,
    string APayload)
{
  try
  {
    var req = (HttpWebRequest)
      WebRequest.Create(
        TAppSettingsManager.GetValue(
          "Server.LegacyGlUrl"));

    req.Method = "POST";
    req.ContentType =
      "application/x-www-form-urlencoded";
    req.Timeout = 30000;

    using (var s = req.GetRequestStream())
    {
      var bytes = Encoding.UTF8
        .GetBytes(APayload);
      s.Write(bytes, 0, bytes.Length);
    }

    using (var resp = (HttpWebResponse)
      req.GetResponse())
    {
      return resp.StatusCode ==
        HttpStatusCode.OK;
    }
  }
  catch (WebException ex)
  {
    TLogging.Log(
      "GL POST failed: " + ex.Message);
    return false;
  }
  catch (Exception ex)
  {
    TLogging.Log(
      "GL POST unexpected: " + ex.Message);
    return false;
  }
}

Modern: HttpClient + Polly + OTEL

// src/Petra.GiftProcessing/Program.cs
// HttpClient registration with Polly v8

builder.Services
  .AddHttpClient<ILegacyGlClient,
    LegacyGlClient>(c =>
  {
    c.BaseAddress = new Uri(
      builder.Configuration[
        "LegacyGl:BaseUrl"]!);
    c.Timeout = TimeSpan.FromSeconds(15);
  })
  .AddStandardResilienceHandler(o =>
  {
    o.Retry.MaxRetryAttempts   = 3;
    o.Retry.BackoffType         =
      DelayBackoffType.Exponential;
    o.Retry.UseJitter           = true;
    o.CircuitBreaker
      .FailureRatio = 0.5;
    o.CircuitBreaker
      .SamplingDuration =
        TimeSpan.FromSeconds(30);
    o.AttemptTimeout.Timeout =
      TimeSpan.FromSeconds(10);
  });

// src/Petra.GiftProcessing/Bridges/
//   LegacyGlClient.cs

public class LegacyGlClient : ILegacyGlClient
{
  private readonly HttpClient _http;
  private readonly ILogger _log;
  private static readonly ActivitySource
    _act = new("Petra.LegacyGl");

  public LegacyGlClient(
    HttpClient http,
    ILogger<LegacyGlClient> log)
  { _http = http; _log = log; }

  public async Task<GlPostingResult>
    PostBatchAsync(
      GlPostingPayload payload,
      CancellationToken ct)
  {
    using var span = _act.StartActivity(
      "legacy_gl.post_batch");
    span?.SetTag("ledger_id",
      payload.LedgerId);
    span?.SetTag("batch_id",
      payload.BatchId);

    try
    {
      var resp = await _http
        .PostAsJsonAsync(
          "/api/legacy/gl/post",
          payload, ct);

      resp.EnsureSuccessStatusCode();

      var r = await resp.Content
        .ReadFromJsonAsync<
          GlPostingResult>(
            cancellationToken: ct);

      _log.LogInformation(
        "legacy_gl.posted {LedgerId} " +
        "{BatchId} {JournalRef}",
        payload.LedgerId,
        payload.BatchId,
        r!.JournalRef);

      return r;
    }
    catch (Exception ex)
    {
      span?.SetStatus(
        ActivityStatusCode.Error,
        ex.Message);
      throw new LegacyGlPostingException(
        payload.LedgerId,
        payload.BatchId, ex);
    }
  }
}

Translation Notes

  • BR-GIFT-007 (Posting Status Constraint) is what triggers this call — the modernized GL post path is the strangler-fig bridge described in Section 11.
  • Polly v8 AddStandardResilienceHandler registers retry + circuit breaker + per-attempt timeout in one block; legacy "swallow exception, return false" pattern goes away.
  • Failure surfaces as a domain exception (LegacyGlPostingException), which the RFC 7807 handler maps to HTTP 502 with a problem document.
  • OpenTelemetry HTTP-client instrumentation auto-propagates the trace context; the legacy GL service sees a traceparent header even though it itself does not emit spans.
  • Serilog structured logging adds {LedgerId}, {BatchId}, {JournalRef} as queryable fields in Application Insights — legacy strings-in-logs were unqueryable.

8.9 Error Handling: SOAP Fault → RFC 7807 Problem Details

The legacy SOAP layer caught EFinanceException at the boundary and serialized it as a SOAP fault with an arbitrary string. The modernized error pipeline turns domain exceptions into RFC 7807 Problem-Details JSON documents, with a stable type URI, a typed status code, and an Application-Insights-queryable correlation ID.

Domain exception & problem-details handler

// Domain exception (BR-GIFT-002 violation)
public sealed class PeriodOutOfRangeException
    : Exception
{
  public int LedgerId { get; }
  public DateOnly EffectiveDate { get; }

  public PeriodOutOfRangeException(
    int ledgerId, DateOnly date)
    : base($"Date {date} is outside any " +
           $"open period for ledger " +
           $"{ledgerId}.")
  { LedgerId = ledgerId;
    EffectiveDate = date; }
}

// Program.cs: ProblemDetails registration
builder.Services
  .AddProblemDetails(o =>
  {
    o.CustomizeProblemDetails = ctx =>
    {
      var act = Activity.Current;
      ctx.ProblemDetails.Extensions[
        "traceId"] = act?.TraceId.ToString();
    };
  });

builder.Services
  .AddExceptionHandler<GiftExceptionHandler>();

// GiftExceptionHandler.cs
public class GiftExceptionHandler
    : IExceptionHandler
{
  public async ValueTask<bool>
    TryHandleAsync(HttpContext ctx,
      Exception ex, CancellationToken ct)
  {
    var pd = ex switch
    {
      PeriodOutOfRangeException p =>
        new ProblemDetails
        {
          Type = "https://petra.openpetra" +
            ".org/problems/period-out-of-" +
            "range",
          Title = "Effective date outside " +
            "open period",
          Status =
            StatusCodes.Status422Unprocessable
            Entity,
          Detail = p.Message
        },
      LegacyGlPostingException g =>
        new ProblemDetails
        {
          Type = "https://petra.openpetra" +
            ".org/problems/legacy-gl-down",
          Title = "Legacy GL service " +
            "unavailable",
          Status =
            StatusCodes.Status502BadGateway,
          Detail = g.Message
        },
      _ => (ProblemDetails?)null
    };

    if (pd is null) return false;

    ctx.Response.StatusCode =
      pd.Status ?? 500;
    await ctx.Response.WriteAsJsonAsync(
      pd, ct);
    return true;
  }
}

Translation Notes

  • Every business rule that can fail at the API boundary has a dedicated domain exception (PeriodOutOfRangeExceptionBR-GIFT-002; BatchAlreadyPostedExceptionBR-GIFT-007; DetailAlreadyAdjustedExceptionBR-GIFT-008; LegacyGlPostingException ↔ bridge to legacy GL).
  • The handler maps each domain exception to a stable problem-type URI — clients (Angular SPA, Receipt Generation Service) can switch on type instead of parsing strings.
  • traceId from Activity.Current is added to every problem document — one click in Application Insights opens the failing request's full trace.
  • The legacy "return false on error" anti-pattern is structurally impossible — all failures surface as exceptions that the pipeline translates.

8.10 Summary

Same language. Different shape. The seven translation examples above describe the entire transformation: SOAP boundary becomes Minimal-API endpoint, typed DataSets become EF Core 10 entities, static utilities become DI services, hand-rolled SQL becomes LINQ specifications, untyped string-status filters become typed enums, raw HttpWebRequest becomes HttpClient + Polly, ad-hoc exceptions become RFC 7807 documents. Every business rule from the Section 9 catalog has at least one translation example anchoring it (BR-GIFT-001 in 8.3, BR-GIFT-002 in 8.4 + 8.9, BR-GIFT-003 in 8.5, BR-GIFT-004/006/007/008 in 8.7, BR-GIFT-007 in 8.2 + 8.8, BR-GIFT-009/010 in 8.5, BR-GIFT-011 in 8.6). The schemas these examples write to are detailed in Section 10; the runtime that hosts them is detailed in Appendix C.

9. Business Rules Analysis

TL;DR14 behavioral rules govern Petra gift processing (4 validation, 3 calculation, 2 state transition, 3 workflow, 2 authorization), each with a formal Given-When-Then specification and source-code evidence verified via Concho MCP. Confidence range 0.70–0.95; mean GWT confidence 0.78. 12 rules transfer verbatim to ASP.NET Core 10; 1 (BR-GIFT-002, period validation) is a deliberate improvement — the modern workspace warns live instead of silently force-fitting the date at save time; 1 (BR-GIFT-006, confidential gift privacy) requires explicit authorization mitigation because the legacy system enforces it implicitly via SQL self-joins. 5 rules are corroborated by surviving legacy NUnit tests; the remaining 9 carry `code-inferred` GWTs grounded in verified source.

This section documents the behavioral rules extracted from the Petra Finance — Gift Processing subsystem (279 files, 23-cycle Concho analysis), showing C# legacy source implementation alongside C# .NET 10 translations with formal Given-When-Then specifications. All 14 rules were discovered through Concho's Context Graph analysis and verified against source code; 5 are additionally corroborated by surviving OpenPetra NUnit test files (`csharp/ICT/Testing/lib/MFinance/server/Gift/*.test.cs`). A companion subsection — Section 9.8: Field-Level Business Rules — extends this catalog with per-field validation and formatting rules auto-derived from the UI field inventory; those field-level rules feed directly into Playwright test generation for the artifact workflow.

About these examples. The legacy code excerpts and target-language translations in this section are representative — they illustrate how each business rule is identified, translated, and formally specified. The complete rule implementation across all modules happens automatically as part of a companion artifact generation workflow that produces all application code, test suites, and behavioral equivalence validations. These GWT specifications are the direct input to both code generation and test generation in that workflow’s BDD/TDD iterate-till-green approach: it derives implementation code and automated tests from the same behavioral specs, runs the full test suite, and self-corrects any failures before presenting artifacts for human review. The intent of this section is to give stakeholders the ability to visually validate the rule translation approach and make course corrections before full code generation proceeds.

9.1 Business Rules Overview

Concho's codebase intelligence identified 14 behavioral rules governing the Finance — Gift Processing subsystem across 10 verified source files. These rules span five categories: validation (4 rules), calculation (3), state transition (2), workflow (3), and authorization (2). Confidence scores range from 0.70 to 0.95, with 9 rules at or above the 0.75 high-confidence threshold; the remaining 5 (confidence 0.70) are included because they represent important gift-processing behaviors verified directly against source. Project-level context: the Concho cycle-23 context graph catalogs 215 business rules across the full Petra codebase, of which these 14 are the subset tied to the Gift Processing bounded context.

Since the legacy and target platforms share the same language (C#), the majority of rules transfer with minimal behavioral change. The primary modernization impact is architectural: static utility classes become injectable services, typed datasets become EF Core entities, raw SQL becomes parameterized LINQ queries, runtime ALTER TABLE upgrades become EF Core migrations, and XML report templates become a typed .NET reporting service. One rule (BR-GIFT-002, period validation) is a deliberate improvement — the modern workspace warns live instead of silently force-fitting the date — and one rule (BR-GIFT-006) requires explicit mitigation because confidentiality was previously an emergent property of the SQL query layer.

Rule Distribution by Category

Category Count Description Example Rule
Validation 4 Input validation, data-integrity checks, format enforcement BR-GIFT-001: Gift Batch Sequential Numbering (0.95)
Calculation 3 Tax deductibility, currency conversion, motivation assignment BR-GIFT-003: Multi-Currency Gift Processing (0.83)
State Transition 2 Batch-posting status gates, modification-tracking flags BR-GIFT-007: Posting Status Constraint (0.90)
Workflow 3 Receipt generation, recurring-donation processing, confidential gift handling BR-GIFT-004: Donation Receipt Generation (0.87)
Authorization 2 Financial report filtering, SEPA mandate compliance BR-GIFT-013: SEPA Mandate Reference Format (0.89)

Rule Discovery Methodology

Rules were discovered through Concho's Context Graph, which analyzes code structure, control flow, data dependencies, and naming conventions to identify business-significant logic patterns. Concho provided rule descriptions, confidence scores, evidence classifications (all 14 are STRUCTURAL), and source-file references with line numbers. The workflow then assigned BR-GIFT-NNN identifiers, derived formal Given-When-Then specifications from Concho's descriptions, and verified each cited source file and code snippet via Concho's get_file_content_in_range API.

Seven rules originated as business_rule entities and seven as implementation_constraint entities in Concho's domain model. Both categories encode enforceable behavioral contracts that must be preserved during modernization, so they are treated uniformly in this analysis. After source verification, the workflow ran legacy-test discovery against the OpenPetra `Testing/lib/MFinance/server/Gift/` directory and found 7 surviving NUnit test files; 5 of the 14 rules carry test corroboration as a result, and those rules have their GWT badged accordingly.

GWT Origin Distribution

Of the 14 rules, 5 are code-inferred-test-corroborated (badged orange below) — the code-inferred GWT was within the ≥0.85 agreement threshold of the test-derived GWT, so the two sources are recorded as agreeing. The remaining 9 are code-inferred (badged grey) because no surviving test exercises the rule directly. No rule landed in legacy-test-derived (which would require a 0.5–0.85 agreement band) or conflict-halt (which would block emission). One nuance worth calling out: for a non-UNIT partner the rule leaves the operator's motivation unchanged — and in the source that behavior is the *absence* of an action (a fall-through that writes nothing), which reads ambiguously on its own. The surviving legacy test states the intent explicitly, asserting the unchanged outcome directly, so BR-GIFT-011 is sharpened to exactly the behavior the original developers pinned down. That is the value of reading the legacy tests alongside the code: they record the original team's intent on the edge and do-nothing cases that source alone leaves implicit.

9.2 Validation Rules

BR-GIFT-001: Gift Batch Sequential Numbering

Legacy C# (Gift.Batch.cs)

NewRow = AMainDS.AGiftBatch
    .NewRowTyped(true);

NewRow.LedgerNumber = ALedgerNumber;
NewRow.BatchNumber =
    ++ALedgerTbl[0].LastGiftBatchNumber;

Int32 BatchYear, BatchPeriod;
TFinancialYear.GetLedgerDatePostingPeriod(
    ALedgerNumber,
    ref ADateEffective,
    out BatchYear,
    out BatchPeriod,
    ATransaction,
    AForceEffectiveDateToFit);

NewRow.BatchYear = BatchYear;
NewRow.BatchPeriod = BatchPeriod;
NewRow.GlEffectiveDate = ADateEffective;
NewRow.ExchangeRateToBase = 1.0M;
NewRow.BatchDescription =
    "PLEASE ENTER A DESCRIPTION";
NewRow.BankAccountCode =
    info.GetDefaultBankAccount();
NewRow.BankCostCentre =
    info.GetStandardCostCentre();
NewRow.CurrencyCode =
    ALedgerTbl[0].BaseCurrency;

C# .NET 10 (GiftBatchService.cs)

public async Task<GiftBatch> CreateBatchAsync(
    int ledgerNumber,
    DateTime dateEffective,
    bool forceEffectiveDateToFit,
    CancellationToken ct = default)
{
    await using var tx = await
        _db.Database.BeginTransactionAsync(ct);

    var ledger = await _db.Ledgers
        .Where(l => l.LedgerNumber == ledgerNumber)
        .SingleAsync(ct);

    // Atomic increment — same sequential
    // numbering as legacy ++LastGiftBatchNumber
    ledger.LastGiftBatchNumber++;

    var (year, period) = await _periodValidator
        .GetPostingPeriodAsync(
            ledgerNumber, dateEffective,
            forceEffectiveDateToFit, ct);

    var batch = new GiftBatch
    {
        LedgerNumber = ledgerNumber,
        BatchNumber = ledger.LastGiftBatchNumber,
        BatchYear = year,
        BatchPeriod = period,
        GlEffectiveDate = dateEffective,
        ExchangeRateToBase = 1.0m,
        CurrencyCode = ledger.BaseCurrency
    };

    _db.GiftBatches.Add(batch);
    await _db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);

    return batch;
}

Business Rule Analysis

Code + test agree

Given: A ledger already has a last-used batch number
When: A clerk starts a new gift batch on that ledger
Then: The new batch takes the next number in sequence, so batch numbers always climb and no two batches in a ledger ever share one


Source: Gift.Batch.cs:111

Confidence: 0.95

Legacy Test Coverage:

  • PostGiftBatch.test.cs:75-130 — "TestPostGiftBatch" asserts BatchNumber != -1 after import

Behavioral Fidelity: The sequential numbering is preserved exactly. The one change is a safeguard: the next-number step now runs inside a database transaction, so two clerks starting a batch at the same instant can't accidentally grab the same number — protection the legacy in-memory increment didn't have.

BR-GIFT-002: Gift Batch Financial Period Validation

Legacy C# (Gift.Batch.cs)

Int32 BatchYear, BatchPeriod;
// if DateEffective is outside the range of
// open periods, use the most fitting date
TFinancialYear.GetLedgerDatePostingPeriod(
    ALedgerNumber,
    ref ADateEffective,
    out BatchYear,
    out BatchPeriod,
    ATransaction,
    AForceEffectiveDateToFit);

NewRow.BatchYear   = BatchYear;
NewRow.BatchPeriod = BatchPeriod;
NewRow.GlEffectiveDate = ADateEffective;

C# .NET 10 (FinancialPeriodValidator.cs)

public interface IFinancialPeriodValidator
{
    Task<(int year, int period)> GetPostingPeriodAsync(
        int ledgerNumber,
        DateTime dateEffective,
        bool forceEffectiveDateToFit,
        CancellationToken ct);
}

// Service impl uses EF Core query over
// a_accounting_period rows for the ledger
var period = await _db.AccountingPeriods
    .Where(p => p.LedgerNumber == ledgerNumber
        && p.PeriodStartDate <= dateEffective
        && p.PeriodEndDate   >= dateEffective)
    .SingleOrDefaultAsync(ct);

if (period is null && forceEffectiveDateToFit)
{
    period = await _db.AccountingPeriods
        .Where(p => p.LedgerNumber == ledgerNumber)
        .OrderBy(p => Math.Abs(
            (p.PeriodStartDate - dateEffective)
                .TotalDays))
        .FirstAsync(ct);
}
return (period.YearNumber, period.PeriodNumber);

Business Rule Analysis

Code + test agree

Given: A staff member is entering a gift batch and picks the date the gifts should post to
When: The system checks that date against the ledger's open accounting periods — in the modern workspace, live as the date is entered
Then: If the date falls in an open period, the batch is accepted for that period. If it falls outside one, the legacy system silently snapped the date to the nearest open period at save time; the modern workspace instead flags it immediately and asks the operator to choose a valid date themselves — a deliberate behavior improvement (see Fidelity)


Source: Gift.Batch.cs:114

Confidence: 0.90

Legacy Test Coverage:

  • PostGiftBatch.test.cs:85-130 — "TestPostGiftBatch" verifies current-year dates post successfully

Behavioral Fidelity: Deliberate behavior change — a documented improvement, not a verbatim carry-forward. The open-period check is preserved, but the legacy save-time silent force-fit — which could post a gift to a different period than the operator intended, with no signal — is replaced by live, in-line validation: the operator is warned the instant a date falls outside an open period and corrects it deliberately, rather than discovering a silent adjustment after posting. This is one example of the broader batch→real-time shift: work the legacy system deferred to save time now happens as the operator types.

BR-GIFT-009: Tax Deductible Percentage Bounds

Legacy C# (TaxDeductibility.cs)

if (AGiftDetail == null) return;
else if (AGiftDetail.IsTaxDeductiblePctNull())
{
    AGiftDetail.TaxDeductiblePct = 0.0m;
}

AGiftDetail.TaxDeductiblePct =
    Math.Max(AGiftDetail.TaxDeductiblePct, 0);
AGiftDetail.TaxDeductiblePct =
    Math.Min(AGiftDetail.TaxDeductiblePct, 100);

/* Update transaction amounts */
CalculateTaxDeductibilityAmounts(
    out TaxDeductAmount,
    out NonDeductAmount,
    AGiftDetail.GiftTransactionAmount,
    AGiftDetail.TaxDeductiblePct);

if (AGiftDetail.IsTaxDeductibleAmountNull()
    || AGiftDetail.IsNonDeductibleAmountNull()
    || (AGiftDetail.TaxDeductibleAmount
        != TaxDeductAmount)
    || (AGiftDetail.NonDeductibleAmount
        != NonDeductAmount))
{
    AGiftDetail.TaxDeductibleAmount =
        TaxDeductAmount;
    AGiftDetail.NonDeductibleAmount =
        NonDeductAmount;
}

C# .NET 10 (GiftDetail.cs / TaxDeductibilityService.cs)

public void UpdateTaxDeductibilityAmounts(
    GiftDetail detail)
{
    // Clamp percentage to [0, 100]
    detail.TaxDeductiblePct = Math.Clamp(
        detail.TaxDeductiblePct ?? 0m, 0m, 100m);

    var (deduct, nonDeduct) =
        CalculateAmounts(
            detail.GiftTransactionAmount,
            detail.TaxDeductiblePct.Value);

    if (detail.TaxDeductibleAmount != deduct
        || detail.NonDeductibleAmount != nonDeduct)
    {
        detail.TaxDeductibleAmount    = deduct;
        detail.NonDeductibleAmount    = nonDeduct;
    }

    // Repeat for base and intl currency amounts
    UpdateBaseAmounts(detail);
    UpdateIntlAmounts(detail);
}

Business Rule Analysis

Code-inferred

Given: A gift's tax-deductible percentage is entered, or left blank
When: The system records the deductible split for that gift
Then: A blank is treated as zero, and the value is held to the 0-to-100 range — anything below 0 becomes 0, anything above 100 becomes 100 — and the deductible and non-deductible amounts are recalculated across all three currencies


Source: TaxDeductibility.cs:50

Confidence: 0.89

Legacy Test Coverage: No direct legacy test coverage found (clamping is exercised transitively via RevertAdjustGiftBatch.test.cs).

Behavioral Fidelity: The same bounds apply, unchanged. The only difference is internal — the calculation moves from a shared utility onto the gift itself — and the exact-decimal precision is preserved, with no rounding introduced.

BR-GIFT-014: Barcode Character Validation for Receipts

Legacy C# (BarCode128.cs)

foreach (char c in AText)
{
    if (((c < 32) || (c > 126))
        && (c != 203))
    {
        // invalid character
        return "";
    }
}

bool tableB = true;
int ind = 0;
int length = AText.Length;
int checksum = 0;
// ... checksum computation follows ...

C# .NET 10 (BarcodeEncoderService.cs)

public sealed class BarcodeEncoderService
    : IBarcodeEncoder
{
    public string EncodeCode128(string text)
    {
        if (string.IsNullOrEmpty(text))
            return string.Empty;

        // Same ASCII range check as legacy:
        // 32..126 OR 203
        foreach (char c in text)
        {
            if ((c < 32 || c > 126) && c != 203)
                return string.Empty;
        }

        return ComputeChecksumAndEncode(text);
    }
}

Business Rule Analysis

Code-inferred

Given: Text is sent to be turned into a Code 128 barcode for a receipt
When: The barcode is generated
Then: Every character is checked against the range Code 128 allows; if any character is out of range, no barcode is produced; otherwise the barcode is generated with its proper checksum


Source: BarCode128.cs:51

Confidence: 0.80

Legacy Test Coverage: No legacy test coverage found (Common/Printing tests cover PDF assembly but not Code 128 character bounds).

Behavioral Fidelity: The same character-range check is preserved unchanged. Internally the barcode generator becomes a swappable service, leaving room to configure it later without touching the validation rule.

9.3 Calculation Rules

BR-GIFT-003: Multi-Currency Gift Processing

Legacy SQL (HOSAReportGiftSummary.sql)

-- Three-currency amount projection
SELECT
  a_gift_transaction_amount_n  AS TxAmount,
  a_gift_amount_n              AS BaseAmount,
  a_gift_amount_intl_n         AS IntlAmount,
  a_tax_deductible_amount_n,
  a_tax_deductible_amount_base_n,
  a_non_deductible_amount_n,
  a_non_deductible_amount_base_n,
  a_gift_batch.a_currency_code_c AS Currency
FROM a_gift_batch,
     a_gift,
     a_gift_detail,
     a_motivation_detail
WHERE GiftDetail.a_ledger_number_i = ?
  AND a_gift_batch.a_batch_status_c
      = 'Posted'
GROUP BY a_recipient_key_n,
         a_currency_code_c

C# .NET 10 (GiftReportService.cs)

// Entity preserves three currency amounts
public class GiftDetail
{
    public decimal GiftTransactionAmount { get; set; }
    public decimal GiftAmount            { get; set; } // base
    public decimal GiftAmountIntl        { get; set; }
    public decimal TaxDeductibleAmount   { get; set; }
    public decimal TaxDeductibleAmountBase { get; set; }
    public decimal NonDeductibleAmount   { get; set; }
    public decimal NonDeductibleAmountBase { get; set; }
}

// LINQ aggregation by recipient + currency
var hosaTotals = _db.GiftDetails
    .Include(d => d.GiftBatch)
    .Where(d => d.GiftBatch.LedgerNumber == ledgerId
        && d.GiftBatch.BatchStatus == BatchStatus.Posted)
    .GroupBy(d => new {
        d.RecipientKey,
        d.GiftBatch.CurrencyCode })
    .Select(g => new HosaSummary
    {
        RecipientKey = g.Key.RecipientKey,
        Currency     = g.Key.CurrencyCode,
        TotalTransaction = g.Sum(d =>
            d.GiftTransactionAmount),
        TotalBase    = g.Sum(d => d.GiftAmount),
        TotalIntl    = g.Sum(d => d.GiftAmountIntl)
    });

Business Rule Analysis

Code-inferred

Given: A gift comes in a currency other than the ledger's base currency (here, something other than EUR)
When: The gift is saved, or a receipt or donor-summary report is produced
Then: The system keeps three amounts side by side — the original transaction amount, the base-currency amount, and an international-clearing amount — and tracks the deductible / non-deductible split in both the transaction and base currencies; report totals are grouped by recipient and currency


Source: Gift.ReceiptPrinting.GetDonationsOfDonor.sql:7; ICH.HOSAReportGiftSummary.sql:26

Confidence: 0.83

Legacy Test Coverage: No direct legacy test coverage found (multi-currency totals exercised tangentially via MatchingGiftsFromBankstatement.test.cs CAMT/MT940 imports).

Behavioral Fidelity: The three-currency model carries over unchanged. The only change is internal — the totals are computed by the modern data layer instead of hand-written SQL — and every amount stays in exact decimal money, so nothing rounds differently than it did in legacy.

BR-GIFT-010: Tax Deductibility Compliance Calculation

Legacy C# (Gift.TaxDeductiblePct.cs)

[RequireModulePermission("FINANCE-1")]
public static bool GetGiftsForTaxDeductiblePct
    Adjustment(ref GiftBatchTDS AGiftDS,
        Int64 ARecipientKey,
        DateTime ADateFrom,
        decimal ANewPct,
        out TVerificationResultCollection AMessages)
{
    db.ReadTransaction( ref Transaction,
        delegate
        {
            string Query = "SELECT a_gift_detail.*"
              + " FROM a_gift_detail, a_gift_batch"
              + " WHERE p_recipient_key_n = "
              + ARecipientKey
              + " AND a_tax_deductible_pct_n <> "
              + ANewPct
              + " AND a_modified_detail_l <> true"
              + " AND a_tax_deductible_l = true"
              + " AND a_batch_status_c = 'Posted'"
              + " AND a_gl_effective_date_d >= '"
              + ADateFrom.ToString("yyyy-MM-dd")
              + "'";
            db.Select(MainDS, Query,
                MainDS.AGiftDetail.TableName,
                Transaction);
            foreach (Row in MainDS.AGiftDetail.Rows)
            {
                Row.TaxDeductiblePct = ANewPct;
                TaxDeductibility
                    .UpdateTaxDeductibiltyAmounts(
                        ref Row);
            }
            AGiftDetailAccess.SubmitChanges(
                Table, Transaction);
        });
}

C# .NET 10 (TaxDeductibilityAdjustmentService.cs)

[Authorize(Policy = "Finance.WriteGifts")]
public async Task<AdjustmentResult>
    AdjustTaxDeductiblePctAsync(
        long recipientKey,
        DateTime dateFrom,
        decimal newPct,
        CancellationToken ct = default)
{
    await using var tx = await
        _db.Database.BeginTransactionAsync(ct);

    // Parameterized query (no SQL injection risk)
    var details = await _db.GiftDetails
        .Where(d =>
            d.RecipientKey == recipientKey
            && d.TaxDeductiblePct != newPct
            && !d.ModifiedDetail
            && d.TaxDeductible
            && d.GiftBatch.BatchStatus
                == BatchStatus.Posted
            && d.GiftBatch.GlEffectiveDate
                >= dateFrom)
        .ToListAsync(ct);

    foreach (var detail in details)
    {
        detail.TaxDeductiblePct = newPct;
        _taxService.UpdateTaxDeductibilityAmounts(
            detail);
    }

    await _db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
    return new AdjustmentResult(details.Count);
}

Business Rule Analysis

Code-inferred

Given: A recipient's tax-deductible percentage has to change — say a regulation shifts
When: An authorized finance user runs the adjustment from a chosen date forward
Then: Every affected gift — already posted, not previously adjusted, tax-deductible, and currently at a different percentage — is pulled, its percentage and amounts are updated, and all the changes commit together as a single transaction


Source: Gift.TaxDeductiblePct.cs:138-197

Confidence: 0.88

Legacy Test Coverage: No direct test coverage found for the tax-pct adjustment pathway.

Behavioral Fidelity: The bulk-adjustment behavior carries over, with a security improvement: legacy built its database query by stitching text together (an injection risk); the modern version uses safe, parameterized queries instead. The finance-only restriction becomes an explicit authorization policy.

BR-GIFT-011: Motivation Detail Assignment by Partner Class

Legacy C# (Gift.gui.tools.cs)

// First check for linked motivation detail
AMotivationDetailTable MotivationDetailTable =
    AMotivationDetailAccess.LoadViaPPartner(
        APartnerKey, readTransaction);

if (MotivationDetailTable.Rows.Count > 0)
{
    foreach (Row in MotivationDetailTable.Rows)
    {
        if (Row.MotivationStatus)
        {
            motivationGroup = Row.MotivationGroupCode;
            motivationDetail = Row.MotivationDetailCode;
            KeyMinFound = true;
            break;
        }
    }
}

if (!KeyMinFound)
{
    // Map UnitTypeCode to motivation detail
    PUnitTable pUnitTable =
        PUnitAccess.LoadByPrimaryKey(
            APartnerKey, readTransaction);

    switch (pUnitTable[0].UnitTypeCode)
    {
        case UNIT_TYPE_AREA:
        case UNIT_TYPE_FUND:
        case UNIT_TYPE_FIELD:
            motivationDetail =
                GROUP_DETAIL_FIELD;
            break;
        case UNIT_TYPE_KEYMIN:
            motivationDetail =
                GROUP_DETAIL_KEY_MIN;
            break;
        default:
            motivationDetail =
                GROUP_DETAIL_SUPPORT;
            break;
    }
}

C# .NET 10 (MotivationResolver.cs)

public async Task<MotivationAssignment?>
    ResolveAsync(long partnerKey,
        CancellationToken ct = default)
{
    var partner = await _db.Partners
        .SingleOrDefaultAsync(
            p => p.PartnerKey == partnerKey, ct);

    if (partner is null) return null;

    if (partner.PartnerClass != "UNIT")
        // Non-UNIT: leave caller defaults
        // unchanged (test-corroborated)
        return MotivationAssignment.Unchanged;

    // Check for partner-linked motivation
    var linked = await _db.MotivationDetails
        .Where(m => m.PartnerKey == partnerKey
            && m.MotivationStatus)
        .FirstOrDefaultAsync(ct);

    if (linked is not null)
        return new MotivationAssignment(
            linked.GroupCode,
            linked.DetailCode);

    // Fall back to unit type mapping
    var unit = await _db.Units
        .SingleAsync(
            u => u.PartnerKey == partnerKey, ct);

    return unit.UnitTypeCode switch
    {
        "AREA" or "FUND" or "FIELD"
            => MotivationAssignment.Field,
        "KEYMIN"
            => MotivationAssignment.KeyMin,
        _ => MotivationAssignment.Support
    };
}

Business Rule Analysis

Code + test agree

Given: A gift is being entered for a recipient
When: The system looks at what kind of recipient it is
Then: For a "unit" recipient (a project, field, or ministry), it first uses any motivation specifically linked to that recipient; if there is none, it assigns one from the unit's type — area, fund, and field projects get the Field motivation, key-ministry gets Key Ministry, and everything else gets Support. For any other kind of recipient, it leaves the motivation exactly as the operator entered it, rather than overwriting it (confirmed by the legacy test that checks a person-type recipient comes back unchanged)


Source: Gift.gui.tools.cs:105-173

Confidence: 0.70 (code) / 0.90 (test corroboration)

Legacy Test Coverage:

  • SetMotivationGroupAndDetail.test.cs:55-180 — five-case suite: Test_NullPartner, Test_InvalidPartner, Test_Person, Test_Unit_WithoutKeyMin, Test_Unit_WithKeyMin

Behavioral Fidelity: The three-step resolution — linked motivation first, then the unit's type, otherwise leave it as entered — is preserved exactly. The important nuance the legacy tests confirmed (a non-unit recipient keeps whatever motivation was already entered, rather than being forced to "Support") carries over unchanged.

9.4 State Transition Rules

BR-GIFT-007: Gift Batch Posting Status Constraint

Legacy SQL (Gift.GetGiftsToAdjustField.sql)

SELECT PUB_a_gift_detail.*
FROM PUB_a_gift_batch,
     PUB_a_gift,
     PUB_a_gift_detail
WHERE PUB_a_gift_batch.a_ledger_number_i = ?
  AND PUB_a_gift_batch.a_gl_effective_date_d
      >= ?
  AND PUB_a_gift_batch.a_gl_effective_date_d
      <= ?
  AND PUB_a_gift_batch.a_batch_status_c
      = 'Posted'
  AND PUB_a_gift.a_ledger_number_i
      = PUB_a_gift_batch.a_ledger_number_i
  AND PUB_a_gift.a_batch_number_i
      = PUB_a_gift_batch.a_batch_number_i
  AND PUB_a_gift_detail.a_ledger_number_i
      = PUB_a_gift_batch.a_ledger_number_i
  AND PUB_a_gift_detail.a_batch_number_i
      = PUB_a_gift_batch.a_batch_number_i
  AND PUB_a_gift_detail.
      a_gift_transaction_number_i
      = PUB_a_gift.a_gift_transaction_number_i
  AND PUB_a_gift_detail.a_modified_detail_l = 0
  AND p_recipient_key_n = ?
  AND a_recipient_ledger_number_n = ?

C# .NET 10 (GiftQueryService.cs)

// Global query filter applied at model level
modelBuilder.Entity<GiftBatch>()
    .HasQueryFilter(b =>
        b.BatchStatus == BatchStatus.Posted);

// Caller can opt out with IgnoreQueryFilters()
// for admin scenarios that need draft batches.

public async Task<List<GiftDetail>>
    GetDetailsForAdjustmentAsync(
        int ledgerNumber,
        DateOnly from, DateOnly to,
        long recipientKey,
        long recipientLedgerNumber,
        CancellationToken ct = default)
{
    return await _db.GiftDetails
        .Include(d => d.Gift)
        .Include(d => d.GiftBatch)
        .Where(d =>
            d.GiftBatch.LedgerNumber == ledgerNumber
            && d.GiftBatch.GlEffectiveDate >= from
            && d.GiftBatch.GlEffectiveDate <= to
            && !d.ModifiedDetail
            && d.RecipientKey == recipientKey
            && d.RecipientLedgerNumber
                == recipientLedgerNumber)
        .ToListAsync(ct);
    // BatchStatus = Posted applied globally
}

Business Rule Analysis

Code + test agree

Given: An adjustment or a financial report runs over gift batches
When: The system selects which batches count
Then: Only posted batches are included — anything still unposted, draft, or cancelled is left out of every financial operation


Source: Gift.GetGiftsToAdjustField.sql:6

Confidence: 0.90

Legacy Test Coverage:

  • RevertAdjustGiftBatch.test.cs:50-260 — "TestAdjustGiftBatch" imports, posts, then adjusts a batch; only posted batches participate

Behavioral Fidelity: The posted-only rule is unchanged. The modern version enforces it in one central place rather than re-stating it in each query, so it can't be forgotten in a query written later; and batch status becomes a fixed set of allowed values instead of a free-text string.

BR-GIFT-008: Unmodified Detail Filter

Legacy SQL (Gift.GetGiftsToAdjustField.sql)

-- Line 11: idempotency guard
AND PUB_a_gift_detail.a_modified_detail_l = 0

C# .NET 10 (AdjustmentSpecification.cs)

public class AdjustmentSpecification
    : Specification<GiftDetail>
{
    public override Expression<
        Func<GiftDetail, bool>>
        ToExpression() =>
        detail => !detail.ModifiedDetail;
}

// Usage:
.Where(new AdjustmentSpecification()
    .ToExpression())

// After adjustment, the flag is set
// to prevent re-processing:
detail.ModifiedDetail = true;

Business Rule Analysis

Code-inferred

Given: An adjustment pulls gift lines to reallocate
When: The system selects which lines to act on
Then: Only lines that haven't already been adjusted are included; once a line is adjusted it's marked, so the same line can't be processed twice


Source: Gift.GetGiftsToAdjustField.sql:11

Confidence: 0.89

Legacy Test Coverage: No direct legacy test coverage found (idempotency exercised transitively via the adjustment cycle in RevertAdjustGiftBatch.test.cs).

Behavioral Fidelity: The same once-only guarantee is preserved. The "not yet adjusted" condition is packaged as a single reusable rule, so it's easy to find and apply consistently wherever it's needed.

9.5 Workflow Rules

BR-GIFT-004: Donation Receipt Generation Workflow

Legacy SQL (GetDonationsOfDonor.sql)

-- Dual-flag eligibility: print + receipt
WHERE a_gift_batch.a_ledger_number_i    = ?
  AND a_gift_batch.a_batch_status_c     = 'Posted'
  AND a_gift_batch.a_gl_effective_date_d
      BETWEEN ? AND ?
  AND a_gift.p_donor_key_n              = ?
  AND a_gift.a_print_receipt_l          = 1
  AND a_motivation_detail.a_receipt_l   = 1
  AND a_gift_detail.a_modified_detail_l = 0
ORDER BY a_gift.a_date_entered_d ASC,
    a_gift_detail.a_detail_number_i DESC

C# .NET 10 (ReceiptGenerationService.cs)

public class ReceiptEligibilitySpec
    : Specification<GiftDetail>
{
    public ReceiptEligibilitySpec(
        long donorKey,
        DateOnly from, DateOnly to)
    {
        AddCriteria(d =>
            d.Gift.DonorKey == donorKey
            && d.GiftBatch.GlEffectiveDate
                >= from
            && d.GiftBatch.GlEffectiveDate
                <= to
            && d.GiftBatch.BatchStatus
                == BatchStatus.Posted
            && d.Gift.PrintReceipt
            && d.MotivationDetail.Receipt
            && !d.ModifiedDetail);
        OrderBy(d => d.Gift.DateEntered);
        ThenByDescending(
            d => d.DetailNumber);
    }
}

// Razor template renders the receipt PDF
var details = await _db.GiftDetails
    .Specify(new ReceiptEligibilitySpec(
        donorKey, from, to))
    .ToListAsync(ct);
var html = await _razor.RenderAsync(
    "ReceiptTemplate", new ReceiptModel(donor, details));
var pdf = await _pdfRenderer.RenderAsync(html);

Business Rule Analysis

Code + test agree

Given: A donor's giving history is pulled to print receipts for a date range
When: The receipt run executes
Then: A gift appears on a receipt only when all four hold — it's flagged for a receipt, its motivation is receiptable, its batch is posted, and it hasn't been adjusted; results come back oldest-first


Source: GetDonationsOfDonor.sql:37; methodofgiving.xml:48

Confidence: 0.87

Legacy Test Coverage:

  • SingleGiftReceipt.test.cs:60-180 — "TestSingleReceipt" imports gifts, posts batch, asserts receipt content matches expected file
  • AnnualReceipts.test.cs:55-170 — "TestAnnualReceipt" verifies posted-batch + receipt-flag gating, multi-locale (German)

Behavioral Fidelity: The four-part eligibility test is preserved exactly. Internally the receipt itself moves from legacy HTML-template merging to a modern templating-and-PDF approach — with no change to who is and isn't eligible for a receipt.

BR-GIFT-005: Recurring Donation Frequency Scheduling

Legacy C# (Upgrade202206_202207.cs)

// Schema upgrade: add SEPA columns at runtime
if (!ColumnExists)
{
    sql = "ALTER TABLE PUB_a_recurring_gift "
        + "ADD COLUMN a_sepa_mandate_reference_c "
        + "varchar(70) DEFAULT NULL "
        + "AFTER p_banking_details_key_i";
    ADataBase.ExecuteNonQuery(
        sql, SubmitChangesTransaction);

    sql = "ALTER TABLE PUB_a_recurring_gift "
        + "ADD COLUMN a_sepa_mandate_given_d "
        + "date DEFAULT NULL "
        + "AFTER a_sepa_mandate_reference_c";
    ADataBase.ExecuteNonQuery(
        sql, SubmitChangesTransaction);
}

// Per-donor scheduling config:
// p_partner.a_receipt_letter_frequency_c
//   ("Annual"/"Monthly"/...)
// p_partner.a_receipt_each_gift_l
//   (true/false)

C# .NET 10 (EF Core Migration + RecurringGiftService.cs)

// EF Core migration replaces runtime ALTER
public partial class AddSepaMandate : Migration
{
    protected override void Up(MigrationBuilder mb)
    {
        mb.AddColumn<string>(
            name: "SepaMandateReference",
            table: "RecurringGift",
            type: "varchar(70)",
            nullable: true);
        mb.AddColumn<DateOnly?>(
            name: "SepaMandateGiven",
            table: "RecurringGift",
            nullable: true);
    }
}

// Background scheduler emits SEPA mandate refs
public class RecurringGiftScheduler
    : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken ct)
    {
        // ... process per-donor schedule,
        //     generate SEPA refs in YYYYMMDD
    }
}

Business Rule Analysis

Code + test agree

Given: A donor has a recurring gift set up, with their direct-debit (SEPA) mandate details on file
When: The recurring-gift cycle runs
Then: Each gift is processed on its own schedule, a SEPA mandate reference is generated for it, and donors set to annual receipting get one consolidated year-end receipt instead of a receipt per gift


Source: Upgrade202206_202207.cs:66; GenerateDonors.cs:97

Confidence: 0.70

Legacy Test Coverage:

  • RecurringGiftBatch.test.cs:1-200 — lifecycle tests for recurring batches with donor/recipient/motivation wiring

Behavioral Fidelity: The mandate format and the per-frequency scheduling carry over unchanged. Two internal cleanups: schema changes that legacy applied at runtime become proper versioned migrations, and the recurring cycle can run as a managed background job.

BR-GIFT-006: Confidential Gift Privacy Handling [Mitigation needed]

Legacy SQL (GetDonationsOfDonor.sql)

-- Dual partner alias: destination + recipient
FROM a_gift_batch,
     a_gift,
     a_gift_detail,
     a_motivation_detail,
     a_account,
     a_cost_centre,
     p_partner AS GiftDestination,
     p_partner AS Recipient
WHERE
     GiftDestination.p_partner_key_n
       = a_gift_detail.a_recipient_ledger_number_n
 AND Recipient.p_partner_key_n
       = a_gift_detail.p_recipient_key_n
-- (confidentiality is implicit:
--  callers must use the right alias
--  in their reports)

C# .NET 10 (GiftDetail.cs + AuthZ policy)

public class GiftDetail
{
    // Distinct navigation paths preserve
    // the legacy dual-alias intent
    public Partner GiftDestination { get; set; }
    public Partner Recipient       { get; set; }
    public bool    Confidential    { get; set; }
}

// Explicit authorization policy: required to
// view recipient details for confidential gifts
[Authorize(Policy = "Gifts.ViewConfidential")]
public async Task<GiftDetailDto>
    GetGiftDetailAsync(int id, ClaimsPrincipal user)
{
    var detail = await _db.GiftDetails
        .Include(d => d.GiftDestination)
        .Include(d => d.Recipient)
        .SingleAsync(d => d.Id == id, ct);

    // Redact recipient if confidential AND
    // caller lacks the policy:
    if (detail.Confidential &&
        !user.HasClaim("gifts.viewConfidential"))
    {
        return GiftDetailDto.WithRedactedRecipient(
            detail);
    }
    return GiftDetailDto.From(detail);
}

Business Rule Analysis

Code-inferred

Given: A receipt or report includes a gift to a confidential recipient
When: The system pulls who the gift went to
Then: It distinguishes the organizational destination from the individual recipient, and the confidential individual is shown only to users authorized to see it


Source: GetDonationsOfDonor.sql:27

Confidence: 0.70

Legacy Test Coverage: No legacy test coverage found for confidential-gift visibility.

Behavioral Fidelity: Mitigation needed. The split between destination and individual recipient is preserved, but legacy enforced confidentiality only as a side effect of how its database query happened to be shaped — too implicit to rely on. The modern system must make it an explicit permission check and actively hide the recipient from anyone who lacks that permission.

9.6 Authorization Rules

BR-GIFT-012: Posted-Batch Financial Integrity for Reports

Legacy XML (methodofgiving.xml)

<calculation id="GetGiftBatchCurrencies"
        returns="line_a_currency_code_c"
        returnsFormat="row">
    <query>
        <queryDetail><value>
        Select Distinct
            batch.a_currency_code_c
                as line_a_currency_code_c
        FROM PUB_a_gift_batch as batch
        WHERE batch.a_gl_effective_date_d
            BETWEEN {#param_start_date#}
            AND {#param_end_date#}
        AND batch.a_ledger_number_i
            = {{param_ledger_number_i}}
        AND batch.a_batch_status_c = 'Posted'
        ORDER BY batch.a_currency_code_c
        </value></queryDetail>
    </query>
</calculation>

C# .NET 10 (MethodOfGivingReportService.cs)

// Typed report parameters replace XML tokens
public record MethodOfGivingParams(
    int LedgerNumber,
    DateOnly StartDate,
    DateOnly EndDate);

public async Task<MethodOfGivingReport>
    GenerateAsync(
        MethodOfGivingParams p,
        CancellationToken ct = default)
{
    var batches = _db.GiftBatches
        .Where(b =>
            b.LedgerNumber == p.LedgerNumber
            && b.GlEffectiveDate >= p.StartDate
            && b.GlEffectiveDate <= p.EndDate);
    // BatchStatus=Posted via global filter

    var groupedGifts = await batches
        .SelectMany(b => b.Gifts)
        .SelectMany(g => g.Details)
        .Where(d => d.MotivationDetail.Receipt)
        .GroupBy(d => new {
            d.MotivationGroupCode,
            d.GiftBatch.CurrencyCode })
        .ToListAsync(ct);

    return MethodOfGivingReport.From(groupedGifts);
}

Business Rule Analysis

Code-inferred

Given: A Method-of-Giving financial report is run for a date range
When: The report gathers its data
Then: It counts only posted, receiptable gifts, and groups the totals by method of giving and by currency


Source: methodofgiving.xml:48 (and surrounding query template)

Confidence: 0.86

Legacy Test Coverage: No legacy test coverage found (XML report templates are covered by end-to-end smoke tests out of this slice's scope).

Behavioral Fidelity: The same filtering is preserved. Internally the report moves from an XML template with text-substituted tokens to a modern, typed reporting service — with no change to what's counted.

BR-GIFT-013: SEPA Mandate Reference Format

Legacy C# (Upgrade202206_202207.cs)

// Column DDL: enforces the storage shape
sql = "ALTER TABLE PUB_a_recurring_gift "
    + "ADD COLUMN a_sepa_mandate_reference_c "
    + "varchar(70) DEFAULT NULL "
    + "AFTER p_banking_details_key_i";
ADataBase.ExecuteNonQuery(
    sql, SubmitChangesTransaction);

// Format convention enforced by callers:
//   {donor_key}{YYYYMMDD}
// e.g. "27000000020220714"
//      where 2700000002 = donor partner key
//            20220714  = mandate-given date

C# .NET 10 (SepaMandateReference.cs)

public readonly record struct SepaMandateReference
{
    public string Value { get; }

    private SepaMandateReference(string value)
        => Value = value;

    public static SepaMandateReference Create(
        long donorKey,
        DateOnly mandateGiven)
    {
        var s =
            $"{donorKey}{mandateGiven:yyyyMMdd}";

        if (s.Length > 70)
            throw new ArgumentException(
                "SEPA mandate exceeds varchar(70)");

        return new SepaMandateReference(s);
    }

    public override string ToString() => Value;
}

// On the EF Core entity:
public class RecurringGift
{
    [MaxLength(70)]
    public string? SepaMandateReference { get; set; }
    public DateOnly? SepaMandateGiven   { get; set; }
}

Business Rule Analysis

Code-inferred

Given: A recurring gift is set up for SEPA direct-debit collection
When: Its mandate reference is created
Then: The reference is built from the donor's key plus the date, capped at 70 characters, and stored alongside the date the mandate was given; both are optional, since a gift needn't have a mandate


Source: Upgrade202206_202207.cs:66

Confidence: 0.89

Legacy Test Coverage: No legacy test coverage found for SEPA mandate reference generation specifically.

Behavioral Fidelity: The reference format and the 70-character limit are preserved. Internally the format gets its own validated type, and the schema change legacy made at runtime becomes a proper versioned migration.

9.7 Business Rule Traceability

The following matrix maps each rule to its Concho-verified source file and line range, evidence type, confidence score, GWT origin badge, and legacy-test corroboration:

GWT Origin — what the two values mean:
  • Code-inferred — the rule's Given-When-Then behavior was derived from the legacy source code alone. No surviving automated test exercises this rule, so the code is the single oracle for its intent.
  • Code + test agree — a surviving legacy NUnit test also exercises this rule, and the behavior asserted by that test matches the behavior derived from the code (within the ≥0.85 agreement threshold). The two independent sources corroborate each other, which is why these rules carry higher effective confidence — the original team's intent is pinned down by an executable test, not just inferred from implementation.
Rule ID Rule Name Source File Lines Confidence Category GWT Origin Test Evidence
BR-GIFT-001Gift Batch Sequential NumberingGift.Batch.cs1110.95ValidationCode+test agreePostGiftBatch.test.cs
BR-GIFT-002Financial Period ValidationGift.Batch.cs1140.90ValidationCode+test agreePostGiftBatch.test.cs
BR-GIFT-003Multi-Currency Gift ProcessingGetDonationsOfDonor.sql; HOSAReportGiftSummary.sql7; 260.83CalculationCode-inferred
BR-GIFT-004Donation Receipt GenerationGetDonationsOfDonor.sql370.87WorkflowCode+test agreeSingleGiftReceipt; AnnualReceipts
BR-GIFT-005Recurring Donation SchedulingUpgrade202206_202207.cs; GenerateDonors.cs66; 970.70WorkflowCode+test agreeRecurringGiftBatch.test.cs
BR-GIFT-006Confidential Gift PrivacyGetDonationsOfDonor.sql270.70WorkflowCode-inferred
BR-GIFT-007Batch Posting Status ConstraintGift.GetGiftsToAdjustField.sql60.90State TransitionCode+test agreeRevertAdjustGiftBatch.test.cs
BR-GIFT-008Unmodified Detail FilterGift.GetGiftsToAdjustField.sql110.89State TransitionCode-inferred
BR-GIFT-009Tax Deductible Percentage BoundsTaxDeductibility.cs500.89ValidationCode-inferred
BR-GIFT-010Tax Deductibility Compliance CalcGift.TaxDeductiblePct.cs138–1970.88CalculationCode-inferred
BR-GIFT-011Motivation Detail by Partner ClassGift.gui.tools.cs105–1730.70CalculationCode+test agreeSetMotivationGroupAndDetail.test.cs
BR-GIFT-012Posted Batch Report Integritymethodofgiving.xml480.86AuthorizationCode-inferred
BR-GIFT-013SEPA Mandate Reference FormatUpgrade202206_202207.cs660.89AuthorizationCode-inferred
BR-GIFT-014Barcode Character ValidationBarCode128.cs510.80ValidationCode-inferred
Behavioral Fidelity Summary. Of the 14 business rules extracted from the Finance — Gift Processing subsystem, 12 transfer with no behavioral change (same C# language, improved architectural patterns: injectable services, EF Core entities, parameterized queries, value objects, Specification pattern, global query filters). 1 rule (BR-GIFT-002: Financial Period Validation) is a deliberate improvement — the modern workspace warns live when a date falls outside an open period rather than silently force-fitting it at save time, a documented behavior change. 1 rule (BR-GIFT-006: Confidential Gift Privacy) requires explicit mitigation — the legacy system enforced confidentiality implicitly through SQL query structure, and the modernized system must add an explicit ASP.NET authorization policy plus a redaction step on the API response. 5 rules carry direct legacy-test corroboration; their Given-When-Then specifications were verified against surviving NUnit test cases, providing a higher fidelity-confidence floor for the test-generation step of the artifact workflow. Each rule above is specified with GWT criteria suitable for automated test generation; Concho's code-generation workflow produces corresponding test suites with boundary-value, integration, and behavioral-equivalence coverage derived from these specifications. Confidence scores range from 0.70 to 0.95, with a weighted average of 0.84 across the catalog and a mean GWT-derivation confidence of 0.78.

9.8 Field-Level Business Rules (auto-derived from field inventory)

Each row below is a Given/When/Then scenario derived from a single field's metadata on the storyboard JSON (required flag, validation regex, conditional collection, display masking, enum source). These complement the workflow-level BR-GIFT-NNN rules above by addressing the field level — the rules that drive BDD/TDD test generation — each scenario becomes a Playwright assertion against the running modern UI and the API contract. They are not authored by hand; they are generated by the field-level specification step of the modernization-planning workflow from the same storyboard JSON that drives Section 7.9's field inventory table. When the legacy source changes (required flag added, validation tightened, conditional flipped), regenerating the storyboard updates both the inventory table and these scenarios in lockstep.

Subsystem: Gift Processing — Annual Receipting Slice · Fields with derivable scenarios: 13 · Total scenarios: 16 · Scope coverage: rendering / validation / display / io

Test artifact: each row below corresponds to a Playwright test() block in petra-br.spec.ts (auto-generated by the test-generation step of the modernization-planning workflow). UI-testable rows (required, format, phone-mask) run live against the demo; rows that need backend support (conditional-hide, file upload, enum-validation) appear as test.skip() with the reason inline.

Step 1: Gift Batch Management

Rule idScopeGivenWhenThen
BR-F-GIFT-GiftBatchManagement-a-batch-description-c-required-empty
field: a_batch_description_c
validationthe Batch Description field is requiredthe finance officer submits the form with the field left emptyclient-side validation blocks submit with a required-field error
And: if the client check is bypassed, the API returns HTTP 422 with a field-level error
BR-F-GIFT-GiftBatchManagement-a-gl-effective-date-d-required-empty
field: a_gl_effective_date_d
validationthe Effective Date field is requiredthe finance officer submits the form with the field left emptyclient-side validation blocks submit with a required-field error
And: if the client check is bypassed, the API returns HTTP 422 with a field-level error
BR-F-GIFT-GiftBatchManagement-a-date-entered-d-required-empty
field: a_date_entered_d
validationthe Gift Date field is requiredthe finance officer submits the form with the field left emptyclient-side validation blocks submit with a required-field error
And: if the client check is bypassed, the API returns HTTP 422 with a field-level error
BR-F-GIFT-GiftBatchManagement-p-donor-key-n-required-empty
field: p_donor_key_n
validationthe Donor field is requiredthe finance officer submits the form with the field left emptyclient-side validation blocks submit with a required-field error
And: if the client check is bypassed, the API returns HTTP 422 with a field-level error
BR-F-GIFT-GiftBatchManagement-a-gift-transaction-amount-n-required-empty
field: a_gift_transaction_amount_n
validationthe Amount (Base Currency) field is requiredthe finance officer submits the form with the field left emptyclient-side validation blocks submit with a required-field error
And: if the client check is bypassed, the API returns HTTP 422 with a field-level error
BR-F-GIFT-GiftBatchManagement-a-motivation-detail-code-c-required-empty
field: a_motivation_detail_code_c
validationthe Motivation Detail field is requiredthe finance officer submits the form with the field left emptyclient-side validation blocks submit with a required-field error
And: if the client check is bypassed, the API returns HTTP 422 with a field-level error
BR-F-GIFT-GiftBatchManagement-p-recipient-key-n-conditional-hide
field: p_recipient_key_n
renderingthe storyboard condition {'visibleWhen': 'motivation group = MEMBERFEE'} evaluates to false (tenant config / form state)the finance officer renders Step 1 (Gift Batch Management)the Membership Recipient field is not rendered in the DOM
And: submitting the form succeeds without this field being present in the payload

Step 2: Annual Receipt Configuration

Rule idScopeGivenWhenThen
BR-F-GIFT-AnnualReceiptConfig-AStartDate-required-empty
field: AStartDate
validationthe Start Date field is requiredthe finance officer submits the form with the field left emptyclient-side validation blocks submit with a required-field error
And: if the client check is bypassed, the API returns HTTP 422 with a field-level error
BR-F-GIFT-AnnualReceiptConfig-AEndDate-required-empty
field: AEndDate
validationthe End Date field is requiredthe finance officer submits the form with the field left emptyclient-side validation blocks submit with a required-field error
And: if the client check is bypassed, the API returns HTTP 422 with a field-level error
BR-F-GIFT-AnnualReceiptConfig-AHTMLTemplate-upload-success
field: AHTMLTemplate
iothe finance officer selects a valid file for HTML Templatethe file is uploadedthe upload succeeds and the field stores the file reference
And: the UI confirms the upload (legacy field: AHTMLTemplate)
BR-F-GIFT-AnnualReceiptConfig-AHTMLTemplate-upload-failure
field: AHTMLTemplate
iothe finance officer selects an invalid or corrupt file for HTML Templatethe finance officer attempts the uploadno page navigation occurs
And: an error message is displayed; the form state is unchanged until a valid file is provided
BR-F-GIFT-AnnualReceiptConfig-ALogoImage-upload-success
field: ALogoImage
iothe finance officer selects a valid file for Logo Imagethe file is uploadedthe upload succeeds and the field stores the file reference
And: the UI confirms the upload (legacy field: ALogoImage)
BR-F-GIFT-AnnualReceiptConfig-ALogoImage-upload-failure
field: ALogoImage
iothe finance officer selects an invalid or corrupt file for Logo Imagethe finance officer attempts the uploadno page navigation occurs
And: an error message is displayed; the form state is unchanged until a valid file is provided
BR-F-GIFT-AnnualReceiptConfig-ASignatureImage-upload-success
field: ASignatureImage
iothe finance officer selects a valid file for Signature Imagethe file is uploadedthe upload succeeds and the field stores the file reference
And: the UI confirms the upload (legacy field: ASignatureImage)
BR-F-GIFT-AnnualReceiptConfig-ASignatureImage-upload-failure
field: ASignatureImage
iothe finance officer selects an invalid or corrupt file for Signature Imagethe finance officer attempts the uploadno page navigation occurs
And: an error message is displayed; the form state is unchanged until a valid file is provided
BR-F-GIFT-AnnualReceiptConfig-AEmailFrom-email-format
field: AEmailFrom
validationthe Email From Address field expects a valid email addressthe finance officer enters a value missing @, missing a TLD, or otherwise malformedvalidation fails with an email-format error

10. Data Mapping Strategy

TL;DR — The Gift Processing subsystem maps 8 legacy tables (petra-XML typed-DataSet definitions) onto 8 PostgreSQL entities backed by EF Core 10. Two new operational entities are added for the Receipt Generation Service (receipt_issuance, receipt_template). Across all 8 entities we tracked ~94 columns; 0 fidelity findings against the legacy db/petra.xml master schema for the three entities shown in detail (a_gift_batch, a_gift_detail, a_motivation_detail). One declared deviation: COBOL-style "_l" boolean suffix and "_n" numeric suffix are dropped in modernized column names (e.g. a_modified_detail_l → modified_detail). Approach: typed-DataSet XML → PostgreSQL DDL → EF Core 10 entity, with denormalised audit columns added throughout.

Petra's legacy data layer is unusual for a system its age: it is already SQL-based (PostgreSQL, MySQL, or SQLite via the TDBType abstraction in the legacy code), and the table definitions are generated from a single master XML schema (db/petra.xml) into typed C# DataSets. The modernization replaces both ends of that chain: the master schema becomes EF Core migrations targeting PostgreSQL exclusively, and the typed DataSets become EF Core 10 entities. Because the data store is already SQL-on-Postgres in the production deployment, this section focuses on shape change (typed DataSet → entity / VARCHAR boolean suffixes → bool properties / column-name normalisation) rather than the radical structural transform that a COBOL VSAM modernization would require.

For the complete target data model ER diagram showing all entity relationships, see Section 4.4: Target Data Model.

10.1 Legacy Data Analysis

The subsystem owns 8 tables in the legacy schema (all rooted at db/petra.xml):

Legacy TableLines in petra.xmlPurposeVolume Estimate
a_gift_batch~62Batch envelope: ledger, effective date, status, currency, journal reference, posting metadata~1,200 rows / year (typical mid-size non-profit ledger)
a_gift~38Per-donor gift envelope inside a batch: donor key, date entered, receipt flags, method of giving~15,000 rows / year
a_gift_detail~75Per-allocation line: recipient, motivation, three-currency amounts, tax-deductible split, modified-detail flag~25,000 rows / year
a_motivation_group~18Reference: motivation group codes per ledger~80 rows total (slow-changing)
a_motivation_detail~32Reference: motivation detail codes, GL account/cost-centre mappings, receipt-eligibility flag~450 rows total (slow-changing)
a_recurring_gift_batch~58Mirror of a_gift_batch for recurring (SEPA) gifts~12 rows / year
a_recurring_gift~46Mirror of a_gift plus SEPA mandate reference + date~600 active templates
a_recurring_gift_detail~72Mirror of a_gift_detail for recurring allocations~900 active rows

Three observations from the data analysis:

  1. COBOL-style column naming. Even though petra is a C# system, the SQL column names still carry COBOL-era suffixes — _l for boolean, _n for numeric, _c for character, _d for date, _i for integer. This is a vestige of the Pascal-language ancestor that petra modernized away from decades ago. The modernized schema drops these suffixes in favour of standard typed columns; the Section 4.4 ER diagram shows the target names.
  2. Composite primary keys. Every table uses a composite PK that includes the ledger ID and runs through batch → gift → detail numbers. This composite-key strategy is preserved in the modernization — it makes natural-key idempotency in the CDC bridge (Section 11) trivial and avoids the impedance mismatch of inventing surrogate UUIDs.
  3. Three-currency amount columns. Every monetary value appears three times: transaction currency, base currency, international (ICH clearing) currency. This is structural to BR-GIFT-003 (Multi-Currency Gift Processing) and is preserved column-for-column in the modernized schema.

10.2 Schema Mappings

Three representative entities are mapped in detail below: gift_batch (the batch envelope), gift_detail (the multi-currency line item), and motivation_detail (the reference data joined into every gift). The remaining five tables follow the same patterns and are mapped in the code-and-test-generation workflow's EF Core migrations.

10.2.1 a_gift_batch → gift_batch

Legacy: petra.xml typed-DataSet definition

<!-- db/petra.xml (excerpt) -->
<Table name="a_gift_batch">
 <Field name="a_ledger_number_i"
   type="integer" not-null="yes"/>
 <Field name="a_batch_number_i"
   type="integer" not-null="yes"/>
 <Field name="a_batch_description_c"
   type="varchar" length="80"/>
 <Field name="a_batch_status_c"
   type="varchar" length="16"
   default="Unposted"/>
 <Field name="a_currency_code_c"
   type="varchar" length="10"
   not-null="yes"/>
 <Field name="a_exchange_rate_to_base_n"
   type="number" precision="24"
   scale="10" default="1.0"/>
 <Field name="a_gl_effective_date_d"
   type="date" not-null="yes"/>
 <Field name="a_batch_year_i"
   type="integer"/>
 <Field name="a_batch_period_i"
   type="integer"/>
 <Field name="a_batch_total_n"
   type="number" precision="13"
   scale="2" default="0"/>
 <Field name="a_hash_total_n"
   type="number" precision="13"
   scale="2" default="0"/>
 <Field name="a_gift_type_c"
   type="varchar" length="16"
   default="Gift"/>
 <Field name="a_method_of_payment_code_c"
   type="varchar" length="20"/>
 <PrimaryKey>
  <Column name="a_ledger_number_i"/>
  <Column name="a_batch_number_i"/>
 </PrimaryKey>
</Table>

Database Schema: PostgreSQL DDL

-- migrations/001_create_gift_batch.sql
CREATE TABLE gift_processing.gift_batch (
  ledger_id            INTEGER NOT NULL,
  batch_number         INTEGER NOT NULL,
  batch_description    VARCHAR(80),
  batch_status         VARCHAR(16) NOT NULL
    DEFAULT 'Unposted'
    CHECK (batch_status IN
      ('Unposted','Posted','Cancelled')),
  currency_code        VARCHAR(10)
    NOT NULL,
  exchange_rate_to_base
    NUMERIC(24, 10) NOT NULL DEFAULT 1.0,
  gl_effective_date    DATE NOT NULL,
  batch_year           INTEGER,
  batch_period         INTEGER,
  batch_total          NUMERIC(13, 2)
    NOT NULL DEFAULT 0,
  hash_total           NUMERIC(13, 2)
    NOT NULL DEFAULT 0,
  gift_type            VARCHAR(16)
    NOT NULL DEFAULT 'Gift',
  method_of_payment_code VARCHAR(20),
  gl_journal_ref       VARCHAR(40),
  posted_at            TIMESTAMPTZ,
  posted_by            VARCHAR(64),
  created_at           TIMESTAMPTZ
    NOT NULL DEFAULT now(),
  updated_at           TIMESTAMPTZ
    NOT NULL DEFAULT now(),
  created_by           VARCHAR(64)
    NOT NULL,
  updated_by           VARCHAR(64)
    NOT NULL,
  PRIMARY KEY (ledger_id, batch_number)
);

CREATE INDEX
  ix_gift_batch_effective_date
  ON gift_processing.gift_batch
    (ledger_id, gl_effective_date);

CREATE INDEX
  ix_gift_batch_status
  ON gift_processing.gift_batch
    (batch_status)
  WHERE batch_status = 'Unposted';

Application Model: EF Core 10 Entity

// src/Petra.GiftProcessing/Domain/
//   GiftBatch.cs

public enum BatchStatus
{ Unposted, Posted, Cancelled }

public class GiftBatch
{
  public int LedgerId { get; set; }
  public int BatchNumber
    { get; private set; }
  public string? BatchDescription
    { get; set; }
  public BatchStatus BatchStatus
    { get; private set; }
    = BatchStatus.Unposted;
  public string CurrencyCode { get; set; }
    = "EUR";
  public decimal ExchangeRateToBase
    { get; set; } = 1.0m;
  public DateOnly GlEffectiveDate
    { get; set; }
  public int? BatchYear { get; set; }
  public int? BatchPeriod { get; set; }
  public decimal BatchTotal { get; set; }
  public decimal HashTotal { get; set; }
  public string GiftType { get; set; }
    = "Gift";
  public string? MethodOfPaymentCode
    { get; set; }
  public string? GlJournalRef
    { get; private set; }
  public DateTimeOffset? PostedAt
    { get; private set; }
  public string? PostedBy
    { get; private set; }

  // Audit
  public DateTimeOffset CreatedAt
    { get; set; } = DateTimeOffset.UtcNow;
  public DateTimeOffset UpdatedAt
    { get; set; } = DateTimeOffset.UtcNow;
  public string CreatedBy { get; set; } = "";
  public string UpdatedBy { get; set; } = "";

  // Children
  public List<Gift> Gifts { get; }
    = new();

  // Behaviour (BR-GIFT-007)
  public void MarkPosted(
    string user,
    string journalRef)
  {
    if (BatchStatus != BatchStatus.Unposted)
      throw new BatchAlreadyPostedException(
        LedgerId, BatchNumber);
    BatchStatus = BatchStatus.Posted;
    PostedAt    = DateTimeOffset.UtcNow;
    PostedBy    = user;
    GlJournalRef = journalRef;
  }
}

Schema Mapping Notes — a_gift_batch → gift_batch

Legacy Fieldpetra.xml TypeTarget ColumnTarget TypeTransformation
a_ledger_number_iintegerledger_idINTEGER NOT NULLDrop a_ prefix and _i suffix; rename to standard PK shape
a_batch_number_iintegerbatch_numberINTEGER NOT NULLDrop a_/_i suffixes; preserved as part of composite PK
a_batch_description_cvarchar(80)batch_descriptionVARCHAR(80)Width preserved (BR-GIFT compatibility)
a_batch_status_cvarchar(16) default 'Unposted'batch_statusVARCHAR(16) + CHECKCHECK constraint added; EF Core value converter maps to BatchStatus enum (BR-GIFT-007)
a_currency_code_cvarchar(10)currency_codeVARCHAR(10) NOT NULLDefault tightened to "EUR" in entity for European-non-profit context
a_exchange_rate_to_base_nnumber(24,10) default 1.0exchange_rate_to_baseNUMERIC(24,10) NOT NULLPrecision preserved exactly (BR-GIFT-003)
a_gl_effective_date_ddategl_effective_dateDATE NOT NULLEF Core maps to DateOnly
a_batch_total_nnumber(13,2)batch_totalNUMERIC(13,2)Precision preserved; decimal.Round(.., 2) in domain method
— (new)gl_journal_refVARCHAR(40)NEW: GL journal reference returned from strangler-bridge call
— (new)posted_at / posted_byTIMESTAMPTZ / VARCHAR(64)NEW: operational audit on the posting transition (BR-GIFT-007)
— (new)created_at / updated_at / created_by / updated_byTIMESTAMPTZ / VARCHAR(64)NEW: universal audit columns (Schema Migration Design Rules §10.8 / Audit Trail)

Indexing: the partial index on batch_status = 'Unposted' mirrors the legacy hot-path query "show me batches I still need to post." On a typical mid-size non-profit, this is at most 5–10 rows out of ~1,200 / year, so the partial index is one or two pages.

Volume: ~1,200 rows/year × ~7 years of retention ≈ ~8,500 rows total. Backfill from legacy is a single COPY ... FROM statement; CDC keeps the modern table in sync per Section 11.

10.2.2 a_gift_detail → gift_detail (the multi-currency line item)

Legacy: petra.xml typed-DataSet definition

<Table name="a_gift_detail">
 <Field name="a_ledger_number_i"
   type="integer" not-null="yes"/>
 <Field name="a_batch_number_i"
   type="integer" not-null="yes"/>
 <Field name="a_gift_transaction_number_i"
   type="integer" not-null="yes"/>
 <Field name="a_detail_number_i"
   type="integer" not-null="yes"/>
 <Field name="a_motivation_group_code_c"
   type="varchar" length="24"
   not-null="yes"/>
 <Field name="a_motivation_detail_code_c"
   type="varchar" length="24"
   not-null="yes"/>
 <Field name="p_recipient_key_n"
   type="bigint" default="0"/>
 <Field name="a_recipient_ledger_number_n"
   type="bigint" default="0"/>
 <Field name="a_gift_transaction_amount_n"
   type="number" precision="13"
   scale="2" not-null="yes"/>
 <Field name="a_gift_amount_n"
   type="number" precision="13"
   scale="2" not-null="yes"/>
 <Field name="a_gift_amount_intl_n"
   type="number" precision="13"
   scale="2" not-null="yes"/>
 <Field name="a_tax_deductible_pct_n"
   type="number" precision="5"
   scale="2" default="0"/>
 <Field name="a_tax_deductible_amount_n"
   type="number" precision="13"
   scale="2" default="0"/>
 <Field name="a_non_deductible_amount_n"
   type="number" precision="13"
   scale="2" default="0"/>
 <Field
   name="a_tax_deductible_amount_base_n"
   type="number" precision="13"
   scale="2" default="0"/>
 <Field
   name="a_non_deductible_amount_base_n"
   type="number" precision="13"
   scale="2" default="0"/>
 <Field name="a_modified_detail_l"
   type="bit" default="0"/>
 <Field name="a_confidential_gift_flag_l"
   type="bit" default="0"/>
 <PrimaryKey>
  <Column name="a_ledger_number_i"/>
  <Column name="a_batch_number_i"/>
  <Column
    name="a_gift_transaction_number_i"/>
  <Column name="a_detail_number_i"/>
 </PrimaryKey>
</Table>

Database Schema: PostgreSQL DDL

CREATE TABLE gift_processing.gift_detail (
  ledger_id              INTEGER NOT NULL,
  batch_number           INTEGER NOT NULL,
  gift_transaction_number INTEGER
    NOT NULL,
  detail_number          INTEGER NOT NULL,
  motivation_group_code  VARCHAR(24)
    NOT NULL,
  motivation_detail_code VARCHAR(24)
    NOT NULL,
  recipient_partner_key  BIGINT
    NOT NULL DEFAULT 0,
  recipient_ledger_number BIGINT
    NOT NULL DEFAULT 0,
  gift_transaction_amount
    NUMERIC(13, 2) NOT NULL,
  gift_amount            NUMERIC(13, 2)
    NOT NULL,
  gift_amount_intl       NUMERIC(13, 2)
    NOT NULL,
  tax_deductible_pct     NUMERIC(5, 2)
    NOT NULL DEFAULT 0
    CHECK (tax_deductible_pct
      BETWEEN 0 AND 100),
  tax_deductible_amount  NUMERIC(13, 2)
    NOT NULL DEFAULT 0,
  non_deductible_amount  NUMERIC(13, 2)
    NOT NULL DEFAULT 0,
  tax_deductible_amount_base
    NUMERIC(13, 2) NOT NULL DEFAULT 0,
  non_deductible_amount_base
    NUMERIC(13, 2) NOT NULL DEFAULT 0,
  tax_deductible_amount_intl
    NUMERIC(13, 2) NOT NULL DEFAULT 0,
  non_deductible_amount_intl
    NUMERIC(13, 2) NOT NULL DEFAULT 0,
  modified_detail        BOOLEAN
    NOT NULL DEFAULT FALSE,
  confidential_gift_flag BOOLEAN
    NOT NULL DEFAULT FALSE,
  created_at             TIMESTAMPTZ
    NOT NULL DEFAULT now(),
  updated_at             TIMESTAMPTZ
    NOT NULL DEFAULT now(),
  created_by             VARCHAR(64)
    NOT NULL,
  updated_by             VARCHAR(64)
    NOT NULL,
  PRIMARY KEY
    (ledger_id, batch_number,
     gift_transaction_number,
     detail_number),
  FOREIGN KEY
    (ledger_id, batch_number,
     gift_transaction_number)
    REFERENCES
    gift_processing.gift
      (ledger_id, batch_number,
       gift_transaction_number)
);

CREATE INDEX
  ix_gift_detail_recipient
  ON gift_processing.gift_detail
    (recipient_partner_key);

CREATE INDEX
  ix_gift_detail_motivation
  ON gift_processing.gift_detail
    (motivation_group_code,
     motivation_detail_code);

Application Model: EF Core 10 Entity

// src/Petra.GiftProcessing/Domain/
//   GiftDetail.cs

public class GiftDetail
{
  // Composite PK (parts mirror DDL)
  public int LedgerId { get; set; }
  public int BatchNumber { get; set; }
  public int GiftTransactionNumber
    { get; set; }
  public int DetailNumber { get; set; }

  public string MotivationGroupCode
    { get; set; } = "GIFT";
  public string MotivationDetailCode
    { get; set; } = "SUPPORT";

  public long RecipientPartnerKey
    { get; set; }
  public long RecipientLedgerNumber
    { get; set; }

  // BR-GIFT-003 three-currency
  public decimal GiftTransactionAmount
    { get; set; }
  public decimal GiftAmount { get; set; }
  public decimal GiftAmountIntl
    { get; set; }

  // BR-GIFT-009 + BR-GIFT-010 outputs
  public decimal TaxDeductiblePct
    { get; private set; }
  public decimal TaxDeductibleAmount
    { get; private set; }
  public decimal NonDeductibleAmount
    { get; private set; }
  public decimal TaxDeductibleAmountBase
    { get; private set; }
  public decimal NonDeductibleAmountBase
    { get; private set; }
  public decimal TaxDeductibleAmountIntl
    { get; private set; }
  public decimal NonDeductibleAmountIntl
    { get; private set; }

  // BR-GIFT-008
  public bool ModifiedDetail
    { get; private set; }
  // BR-GIFT-006
  public bool ConfidentialGiftFlag
    { get; set; }

  public DateTimeOffset CreatedAt
    { get; set; } = DateTimeOffset.UtcNow;
  public DateTimeOffset UpdatedAt
    { get; set; } = DateTimeOffset.UtcNow;
  public string CreatedBy { get; set; } = "";
  public string UpdatedBy { get; set; } = "";

  // Navigation
  public Gift Gift { get; set; } = null!;
  public MotivationDetail MotivationDetail
    { get; set; } = null!;
  public Partner Recipient { get; set; }
    = null!;
  public Partner GiftDestination
    { get; set; } = null!;
}

Schema Mapping Notes — a_gift_detail → gift_detail

Legacy Fieldpetra.xml TypeTarget ColumnTarget TypeTransformation / BR
a_modified_detail_lbit default 0modified_detailBOOLEAN NOT NULL DEFAULT FALSESuffix dropped; bit→bool. BR-GIFT-008 sets it on adjust
a_confidential_gift_flag_lbit default 0confidential_gift_flagBOOLEAN NOT NULL DEFAULT FALSEDrives BR-GIFT-006 projection-time authorization filter
a_tax_deductible_pct_nnumber(5,2)tax_deductible_pctNUMERIC(5,2) + CHECK 0–100BR-GIFT-009 bounds enforced at DB level too (belt-and-braces with domain method)
a_gift_transaction_amount_n / a_gift_amount_n / a_gift_amount_intl_n3× number(13,2)gift_transaction_amount / gift_amount / gift_amount_intl3× NUMERIC(13,2)Three-currency model preserved column-for-column (BR-GIFT-003)
p_recipient_key_nbigint default 0recipient_partner_keyBIGINT NOT NULL DEFAULT 0FK constraint dropped per sliceBoundaryFKPolicy: drop-constraints; partner validation happens at the application layer via the legacy-Partner strangler bridge
a_recipient_ledger_number_nbigint default 0recipient_ledger_numberBIGINT NOT NULL DEFAULT 0Same drop-constraints policy; used as the "GiftDestination" alias path for BR-GIFT-006

Indexing rationale: the recipient index supports the receipt-generation query (see Section 8.7); the motivation index supports the field-adjustment workflow (BR-GIFT-010GetGiftsForTaxDeductiblePctAdjustment).

Volume: ~25,000 rows/year. Year-end annual-receipt run reads ~5–15% of total rows (donors active in the past tax year). With the recipient index in place, that's a ~3,000-row index scan per donor pass.

10.2.3 a_motivation_detail → motivation_detail (reference data)

Legacy: petra.xml typed-DataSet definition

<Table name="a_motivation_detail">
 <Field name="a_ledger_number_i"
   type="integer" not-null="yes"/>
 <Field name="a_motivation_group_code_c"
   type="varchar" length="24"
   not-null="yes"/>
 <Field name="a_motivation_detail_code_c"
   type="varchar" length="24"
   not-null="yes"/>
 <Field name="a_motivation_detail_desc_c"
   type="varchar" length="80"/>
 <Field name="a_account_code_c"
   type="varchar" length="16"/>
 <Field name="a_cost_centre_code_c"
   type="varchar" length="20"/>
 <Field name="a_motivation_status_l"
   type="bit" default="1"/>
 <Field name="a_receipt_l"
   type="bit" default="1"/>
 <Field name="a_tax_deductible_l"
   type="bit" default="1"/>
 <Field name="p_linked_partner_key_n"
   type="bigint" default="0"/>
 <PrimaryKey>
  <Column name="a_ledger_number_i"/>
  <Column
    name="a_motivation_group_code_c"/>
  <Column
    name="a_motivation_detail_code_c"/>
 </PrimaryKey>
</Table>

Database Schema: PostgreSQL DDL

CREATE TABLE
  gift_processing.motivation_detail
(
  ledger_id              INTEGER NOT NULL,
  motivation_group_code  VARCHAR(24)
    NOT NULL,
  motivation_detail_code VARCHAR(24)
    NOT NULL,
  motivation_detail_desc VARCHAR(80),
  account_code           VARCHAR(16),
  cost_centre_code       VARCHAR(20),
  motivation_status      BOOLEAN
    NOT NULL DEFAULT TRUE,
  receipt_eligible       BOOLEAN
    NOT NULL DEFAULT TRUE,
  tax_deductible         BOOLEAN
    NOT NULL DEFAULT TRUE,
  linked_partner_key     BIGINT
    NOT NULL DEFAULT 0,
  created_at             TIMESTAMPTZ
    NOT NULL DEFAULT now(),
  updated_at             TIMESTAMPTZ
    NOT NULL DEFAULT now(),
  created_by             VARCHAR(64)
    NOT NULL,
  updated_by             VARCHAR(64)
    NOT NULL,
  PRIMARY KEY
    (ledger_id, motivation_group_code,
     motivation_detail_code),
  FOREIGN KEY
    (ledger_id, motivation_group_code)
    REFERENCES
    gift_processing.motivation_group
      (ledger_id, motivation_group_code)
);

Application Model: EF Core 10 Entity

// src/Petra.GiftProcessing/Domain/
//   MotivationDetail.cs

public class MotivationDetail
{
  // Composite PK
  public int LedgerId { get; set; }
  public string MotivationGroupCode
    { get; set; } = "";
  public string MotivationDetailCode
    { get; set; } = "";

  public string? MotivationDetailDesc
    { get; set; }
  public string? AccountCode { get; set; }
  public string? CostCentreCode
    { get; set; }

  public bool MotivationStatus
    { get; set; } = true;

  // BR-GIFT-004 (receipt eligibility flag)
  public bool ReceiptEligible
    { get; set; } = true;

  public bool TaxDeductible
    { get; set; } = true;

  // BR-GIFT-011 (linked partner lookup)
  public long LinkedPartnerKey
    { get; set; }

  public DateTimeOffset CreatedAt
    { get; set; } = DateTimeOffset.UtcNow;
  public DateTimeOffset UpdatedAt
    { get; set; } = DateTimeOffset.UtcNow;
  public string CreatedBy { get; set; } = "";
  public string UpdatedBy { get; set; } = "";

  // Navigation
  public MotivationGroup Group
    { get; set; } = null!;
  public List<GiftDetail> GiftDetails
    { get; } = new();
}

// Domain method for receipt eligibility
public static class MotivationSpecs
{
  // BR-GIFT-004 second flag
  public static IQueryable<GiftDetail>
    OnReceiptEligibleMotivation(
      this IQueryable<GiftDetail> q)
    => q.Where(d =>
      d.MotivationDetail.ReceiptEligible);
}

Schema Mapping Notes — a_motivation_detail → motivation_detail

Legacy Fieldpetra.xml TypeTarget ColumnTarget TypeTransformation / BR
a_motivation_status_lbit default 1motivation_statusBOOLEAN NOT NULL DEFAULT TRUEActive-flag preserved (soft-delete model)
a_receipt_lbit default 1receipt_eligibleBOOLEAN NOT NULL DEFAULT TRUERenamed for clarity; drives BR-GIFT-004 second flag in the receipt-eligibility dual-flag predicate
a_tax_deductible_lbit default 1tax_deductibleBOOLEAN NOT NULL DEFAULT TRUEDrives BR-GIFT-010 (only tax-deductible motivations get pct adjustments)
p_linked_partner_key_nbigint default 0linked_partner_keyBIGINT NOT NULL DEFAULT 0Drives BR-GIFT-011 UNIT-partner motivation lookup
a_account_code_c / a_cost_centre_code_cvarchar(16) / varchar(20)account_code / cost_centre_codeVARCHAR(16) / VARCHAR(20)GL routing keys for strangler-bridge posting payload

Volume: ~450 rows total, slow-changing. Eligible for full table-cache in EF Core's second-level cache (Microsoft.EntityFrameworkCore.Caching) at startup — eliminates the per-gift-detail JOIN cost on the receipt-generation hot path.

10.3 Data Type Mappings

Because petra is already a SQL-on-PostgreSQL system, the petra.xml → PostgreSQL DDL type map is small and mostly identity. The handful of consequential rules:

petra.xml TypeExamplePostgreSQL TypeEF Core / C# TypeNotes
integera_batch_number_iINTEGERintIdentity transform; suffix _i dropped
bigintp_recipient_key_nBIGINTlongPartner keys are 10-digit numeric — bigint required
number(p, s)a_gift_amount_n (13,2)NUMERIC(p, s)decimalPrecision/scale preserved exactly; never float/double for monetary fields (Universal Design Rules §10.8)
number(24, 10)a_exchange_rate_to_base_nNUMERIC(24, 10)decimalFX exchange rate needs the extra 10 decimal places for BR-GIFT-003 inverse-rate math
varchar(n)a_batch_description_cVARCHAR(n)stringWidth preserved; trailing-space trim handled in EF Core value converter; suffix _c dropped
datea_gl_effective_date_dDATEDateOnlyEF Core 10 supports DateOnly natively in Npgsql; legacy used DateTime with the time component truncated
bita_modified_detail_lBOOLEANboolSuffix _l dropped; legacy used 0/1 in INTEGER columns when targeting MySQL/SQLite, BOOLEAN in PostgreSQL — modernization standardises on BOOLEAN
(new)created_at, updated_atTIMESTAMPTZDateTimeOffsetUniversal audit columns added to every entity

10.4 Data Transfer Procedures

Petra is already SQL-on-Postgres in production, so the data-transfer story is two parts: an initial backfill from the legacy database into the modernized schema, and the ongoing CDC stream that keeps the modern schema in sync until the legacy database is decommissioned. The CDC stream is the strangler-fig bridge described in detail in Section 11; this subsection covers the backfill side.

10.4.1 Backfill Pipeline (One-Time Per Ledger)

The backfill is run once per ledger and once per pre-cutover validation rehearsal. It is a pure SQL pipeline — no application code involved — because the legacy and modernized schemas live on the same PostgreSQL engine family.

  1. Snapshot. pg_dump --schema-only --table='a_gift_*' --table='a_motivation_*' against the legacy DB to capture the schema; then a SELECT with FOR SHARE on the ledger's rows to lock the source while the snapshot is taken.
  2. Transform. A single SQL statement per target table renames columns, casts types, and adds audit columns:
    -- backfill: gift_batch
    INSERT INTO gift_processing.gift_batch (
      ledger_id, batch_number,
      batch_description, batch_status,
      currency_code, exchange_rate_to_base,
      gl_effective_date, batch_year,
      batch_period, batch_total, hash_total,
      gift_type, method_of_payment_code,
      gl_journal_ref, posted_at, posted_by,
      created_at, updated_at, created_by,
      updated_by
    )
    SELECT
      a_ledger_number_i,
      a_batch_number_i,
      NULLIF(a_batch_description_c, ''),
      a_batch_status_c,
      a_currency_code_c,
      a_exchange_rate_to_base_n,
      a_gl_effective_date_d,
      a_batch_year_i,
      a_batch_period_i,
      COALESCE(a_batch_total_n, 0),
      COALESCE(a_hash_total_n, 0),
      COALESCE(a_gift_type_c, 'Gift'),
      NULLIF(a_method_of_payment_code_c, ''),
      NULL,  -- gl_journal_ref backfilled
             -- from legacy GL via bridge
      CASE WHEN a_batch_status_c = 'Posted'
           THEN a_date_modified_d::TIMESTAMPTZ
           ELSE NULL END,
      CASE WHEN a_batch_status_c = 'Posted'
           THEN a_modified_by_c
           ELSE NULL END,
      COALESCE(a_date_created_d::TIMESTAMPTZ,
               now()),
      COALESCE(a_date_modified_d::TIMESTAMPTZ,
               now()),
      COALESCE(a_created_by_c, 'backfill'),
      COALESCE(a_modified_by_c, 'backfill')
    FROM legacy.a_gift_batch
    WHERE a_ledger_number_i = $1
    ON CONFLICT (ledger_id, batch_number)
    DO NOTHING;
  3. Load. INSERT … ON CONFLICT DO NOTHING on every table; idempotent so the backfill is re-runnable.
  4. Verify. See §10.5 below.

10.4.2 Schema Transformation Pipeline

Once backfill completes, the schema-transformation pipeline takes over for incremental sync:

StageMechanismCadenceReference
1. CaptureDebezium PostgreSQL connector (pgoutput plugin) reading the legacy DB's WAL via dedicated replication slot petra_gift_slotContinuous, ~50–200 ms lag steady-state§11 Cutover Choreography
2. TranslateIn-process translator inside Gift Processing API converts each captured row to the modern column shape using the same transform SQL as backfillPer-message§11 Translator
3. RouteAzure Service Bus session-ordered topic keyed by (ledger_id, batch_id); CloudEvents 1.0 envelope per the sage-domain-event-v1 schemaPer-message§11 Messaging
4. ApplyBackground-thread consumer in Gift Processing API performs natural-key upsert into the modern tablesContinuous§11 Target
5. ReconcileHourly job compares row-count + checksum of (ledger_id, batch_id) tuples between legacy and modern; mismatches surface to ops dashboardHourly§10.5

10.5 Data Validation Strategy

Four independent validation paths run during backfill rehearsals and after every cutover stage; all four must pass for the stage to be considered green.

PathMechanismPass Criterion
Row countsSELECT COUNT(*) FROM <legacy> vs SELECT COUNT(*) FROM <modern> per table per ledgerExact match
ChecksumsSELECT md5(string_agg(...)) over a stable column ordering, scoped to one ledger at a timeExact match
BR-rule samplingRandom sample of 1,000 gift_detail rows; recompute three-currency totals and tax-deductible split using the modernized domain code, compare against stored valuesZero discrepancies, ≤ 0.01 EUR rounding tolerance
Posted-batch invariantFor every batch_status = 'Posted' batch, verify SUM(gift_detail.gift_amount) = gift_batch.batch_totalExact equality (£0 / €0 / $0)

The BR-rule sampling pass is the only one that exercises the modernized domain code path — if FluentValidation, the EF Core entity, or the BR-GIFT-010 calculation diverges from legacy by even 0.01 EUR per detail, this is where it surfaces. Tolerance is set tight (≤ 0.01) because monetary fidelity is the load-bearing property of the subsystem.

10.6 Rollback Procedures

Three rollback affordances stack from cheapest to most expensive:

  1. APIM traffic flip. Reverse the per-endpoint traffic shift in Section 11: route 100% of traffic back to the legacy ASMX endpoint. Modern PostgreSQL is left intact (read-only); CDC stream continues running but no new writes hit the modern API. Reversible in < 1 minute.
  2. Per-ledger replay. Truncate the modern tables for a single ledger; re-run the backfill INSERT statements. The composite PK (ledger_id, batch_number, ...) scopes the truncate cleanly; other ledgers stay live. Reversible in 10–30 minutes per ledger.
  3. Point-in-time recovery. Azure Database for PostgreSQL Flexible Server is configured with 7-day backup retention; a full PITR restore takes 15–90 minutes depending on database size. Used only if the modern DB has been corrupted in a way that the targeted truncate cannot fix.

10.7 Mapping Summary

EntityRecords (est., 7-yr)ComplexityApproachBackfill Duration (est.)
gift_batch~8,500Low (envelope only)Direct INSERT … SELECT< 30s
gift~105,000LowDirect INSERT … SELECT~1 min
gift_detail~175,000Medium (three-currency split)Direct INSERT … SELECT + BR-rule sampling validation~3 min
motivation_group + motivation_detail~530LowDirect INSERT … SELECT + cache warm-up< 5s
recurring_gift_batch + recurring_gift + recurring_gift_detail~10,000Medium (SEPA mandates)Direct INSERT … SELECT + SEPA-format validation~1 min

10.8 Schema Migration Design Rules

These rules govern every design decision when translating legacy data structures to the modernized data model. They are split into universal rules (apply regardless of target database engine) and engine-specific rules (implementation details for PostgreSQL, DynamoDB, MongoDB). Together they ensure consistency between the schema artifact (DDL), the ORM/document model, and the seed data.

Why these rules? Without explicit design rules, independent code-generation steps will make ad-hoc decisions about field names, types, precision, and keys — producing artifacts that compile individually but fail when wired together. The rules below freeze those decisions up-front so the database-schema agent, the core-logic agent, and the seed-data agent all converge on the same shape.

10.8.1 Universal Rules (Engine-Agnostic)

DecisionRuleRationaleExample
Field Naming Drop legacy table-prefix (a_, p_) and type-suffix (_l, _n, _c, _d, _i). Expand opaque abbreviations to readable snake_case. Petra's COBOL-era suffixes don't survive contact with modern tooling; explicit names read better in code review and SQL traces. a_modified_detail_l → modified_detail; a_gift_amount_intl_n → gift_amount_intl
Numeric Precision Preserve NUMERIC(p, s) precision/scale exactly from petra.xml. Never use float/double for monetary or percentage fields. BR-GIFT-003, BR-GIFT-009, BR-GIFT-010 all encode monetary semantics; a sub-cent rounding error compounds to a regulatory issue. number(13, 2) → NUMERIC(13, 2) exactly; FX rate stays NUMERIC(24, 10)
OCCURS Elimination Always child entities with parent references, never fixed-size arrays. Petra's typed DataSet didn't carry OCCURS, but the same rule applies to the recurring-gift ↔ recurring-gift-detail relationship. Unlimited recurring detail rows; child-FK enables clean idempotent upserts in the CDC stream. recurring_gift 1:N recurring_gift_detail via composite FK
Audit Trail Every entity gets created_at, updated_at, created_by, updated_by columns. Application Insights queries pivot on these; legacy s_date_modified_d / s_modified_by_c are migrated forward as the new updated_* values. All 8 gift tables + the 2 new receipt tables
New Operational Entities Mark explicitly "NEW — no legacy equivalent" in mapping notes. Avoid confusion about what is migrated vs newly introduced. receipt_issuance, receipt_template (Receipt Generation Service)
Reference Data Read-only at the gift-processing service boundary; mastered by upstream subsystems (Partner, GL). FK constraints dropped per sliceBoundaryFKPolicy: drop-constraints. Strangler-fig bridge to legacy Partner/GL services remains the system of record until those subsystems modernize. recipient_partner_key, ledger_id, currency codes
String Field Widths Map to legacy width exactly; widen explicitly only when an affinity-win demands it. Backfill INSERT will fail loudly on width overflow instead of silently truncating — cheaper to detect. batch_description VARCHAR(80); SEPA mandate ref stays VARCHAR(70)

10.8.2 Engine-Specific Rules

DecisionPostgreSQL/Aurora (this project)DynamoDBMongoDB/DocumentDB
Primary Keys Composite (preserved from legacy): (ledger_id, batch_number, ...). Surrogate id BIGSERIAL added only for the two new receipt-service tables. PK + SK design: PK = LEDGER#<id>, SK = BATCH#<number>#DETAIL#<n> ObjectId or compound _id: { ledger, batch, ... }
Numeric Precision NUMERIC(p, s) exactly N type with application-level precision documentation Decimal128
Relationships FK constraints for in-service relationships (gift_batch ↔ gift ↔ gift_detail); dropped at service boundary Denormalize / GSIs Embed for high read-locality, reference otherwise
Computed Values GENERATED ALWAYS STORED for immutable expressions only (none in this project — tax-split is application-computed); CURRENT_DATE / volatile expressions go in views or domain properties Compute-on-write Aggregation pipeline
Audit Trail Columns + BEFORE UPDATE trigger or EF Core SaveChangesInterceptor (this project uses the interceptor) Attributes + DynamoDB Streams Fields + Change Streams
Schema Artifact schema.sql (EF Core migrations → generated SQL script) table-definitions.json + per-item schemas validation-schemas.json (JSON Schema)
OCCURS → Children Child table + composite FK (recurring_gift → recurring_gift_detail) Child items: same PK, different SK Embedded array OR separate collection (size-bounded decision)
String Widths VARCHAR(N) mapped to legacy width; CHECK constraint for short-enum strings (e.g. batch_status) S type with application-level width String with maxLength in JSON Schema
Workflow sequencing: The database-schema agent always runs first, producing the engine-appropriate schema artifact (here: schema.sql emitted from EF Core migrations). The core-logic agent then reads that artifact and generates ORM/document models that match it exactly. The orchestrator does not need to branch — the same phase order works for all engines (PostgreSQL, DynamoDB, MongoDB). For petra the database engine is fixed to PostgreSQL by report-plan.json, but the rule keeps the workflow portable.

For the overall modernization execution plan including phased cutover, dual-write implementation, and rollback procedures, see Section 11: Modernization Strategy.

11. Modernization Strategy

TL;DR — The legacy/modern coexistence pattern for Petra Gift Processing is classical strangler-fig, routed through Azure API Management. The 2-service .NET 10 stack (Gift Processing API + Receipt Generation Service) and the legacy Mono/FastCGI serverMFinance.asmx endpoints run side-by-side; APIM shifts traffic per endpoint group, reads first then writes. A Debezium-driven Azure Service Bus bridge keeps the modern PostgreSQL in sync during the overlap. Non-idempotent writes (atomic batch numbering, financial-period validation, reversal de-duplication) rule out symmetric dual-write; the cutover is a business decision, not a technical deadline.

This section defines the modernization execution strategy and the legacy/modern coexistence plan for the Finance — Gift Processing subsystem. The goal is to keep donation processing — the core revenue workflow for non-profit organizations — running uninterrupted on the existing Petra (OpenPetra) platform while the new Angular 20 + .NET 10 stack is incrementally validated and accepts production traffic.

11.1 Why the Classical Strangler-Fig Pattern Fits This System

The coexistence pattern is selected by applying a decision tree to the legacy system's interaction surface — the shape of the interface through which clients reach the system's business logic. The tree has one pivotal question:

Does the source system have a request-routing surface (load balancer, API gateway, reverse proxy)?

For Petra, the answer is unambiguously YES.

Concho analysis of Petra's Finance — Gift Processing entry points confirms a conventional HTTP-routable interaction surface. The legacy subsystem is reached through ASP.NET Web Services (.asmx) served by Mono / FastCGI behind an nginx reverse proxy, with four primary entry points:

  • TGiftTransactionWebConnector (confidence 0.90) — RPC endpoint exposing gift batch creation, posting, validation, transaction management, annual receipt generation, and financial-period operations.
  • AnnualReceiptGenerationInterface (confidence 0.70) — HTTP form interface for template upload, email configuration, and bulk annual tax-receipt generation.
  • GiftBatchManagementInterface (confidence 0.65) — HTTP interface for managing gift batches and transactions, with bookmarkable URL navigation.
  • BankImportManagementInterface (confidence 0.60) — HTTP interface for uploading bank statements (CAMT v053, MT940, CSV, ZIP).

These four entry points all sit behind the same nginx routing layer. That alone rules out SAROC, which is reserved for systems whose only interaction surface is a green-screen terminal, a batch-file drop, or a thick desktop client writing directly to a shared database. With a request-routing surface present, the decision tree advances to its second branch:

Are writes idempotent and is divergence acceptable for short windows?

For Petra Gift Processing, the answer is unambiguously NO. Three Concho-identified business rules establish non-idempotent, ordering-sensitive write semantics that make symmetric dual-write unsafe:

  1. GiftBatchSequentialNumbering (confidence 0.95) — Gift batch numbers are assigned by atomically incrementing LastGiftBatchNumber on the ledger table. The counter is non-idempotent; if both systems accept the same logical batch-creation in parallel, the two writes claim different batch numbers and the resulting gift → gift_detail → motivation hierarchy diverges between systems.
  2. GiftBatchFinancialPeriodValidation (confidence 0.90) — Gift batch effective dates must fall within an open accounting period, validated by TFinancialYear with a force-fit option. A batch posted in legacy that references a period later closed in the modern system (or vice versa) violates this constraint and silently corrupts the GL.
  3. MultiCurrencyGiftProcessingRule (confidence 0.83) — Multi-currency processing maintains separate tracking of transaction amounts, base amounts, and international amounts derived from exchange-rate snapshots. If the two systems pick the same gift up with different exchange-rate snapshots, the tax-deductible total and the GL credit diverge in opposite directions.

The middle branch of the decision tree therefore selects classical strangler-fig: an API gateway progressively shifts traffic from legacy endpoints to modern endpoints, reads first then writes, with a single source of truth at every moment for every endpoint. Symmetric dual-write — where both systems accept writes during the overlap — is explicitly off the table.

Decision: Classical Strangler-Fig

Reason: Petra exposes Gift Processing through HTTP-routable SOAP/RPC endpoints behind an nginx reverse proxy — a request-routing surface exists, so SAROC is not selected. Writes are non-idempotent in three Concho-identified ways (atomic batch numbering, financial-period validation, multi-currency ordering), so symmetric dual-write is also ruled out. Azure API Management sits in front of both stacks and shifts traffic per endpoint group, reads first then writes, with rollback by reverting the routing policy.

Implementation status: pending. In the v1 Concho code-and-test-generation workflow only SAROC has fully wired bridge code generation; the classical strangler-fig narrative, choreography, and operational guidance in this section are complete and implementable, but the bridge artifacts agent will emit a stub plus a clear “strangler-fig artifact generation not yet implemented” notice rather than guess at APIM policy code.

11.2 EIP Notation Diagram of the Bridge

Although the classical strangler-fig pattern operates primarily at the routing layer — Azure API Management shifts traffic per endpoint group — the data plane still requires a synchronization bridge during the overlap window. As long as some endpoints route to legacy, the writes those endpoints accept must reach the modern PostgreSQL before any modernized endpoint reads stale data. That synchronization bridge follows the canonical four-component Enterprise Integration Pattern (EIP) shape from Hohpe & Woolf (2003): Channel Adapter → Message Translator → Message Channel → Message Endpoint.

Reliable Message Queue — Enterprise Integration Pattern View Legacy Petra .NET 4.7 / Mono PostgreSQL Data Store (via typed datasets) CDC Adapter Channel Adapter (Debezium / WAL) Message Translator SQL row → JSON Ordered Message Channel (Azure Service Bus, session-ordered) Message Endpoint (background thread) Modern Petra .NET 10 API PostgreSQL Data Store (Azure Flex Server) Reads PostgreSQL WAL Legacy never modified Petra typed row → JSON domain events Session-ordered (per batch) Persistent + DLQ Consumer ACK required EIP Notation (Hohpe & Woolf, 2003): Channel Adapter Message Translator Message Channel Message Message Endpoint Dataflow

Figure 11.1 — Reliable Message Queue Pattern (EIP component view). During the strangler-fig coexistence window, mutations applied through legacy serverMFinance.asmx endpoints are captured by a Debezium connector on the legacy PostgreSQL WAL, translated into JSON domain events, and forwarded through Azure Service Bus (session-ordered per gift batch) to a background-thread consumer in the modern Gift Processing API.

Note on Figure 11.2 / 11.3. The SAROC topology diagram (two-paths bulk-ETL + queue-replay) is specific to the SAROC pattern and does not apply to classical strangler-fig — strangler-fig is driven by per-endpoint traffic shifting, not a one-shot bulk ETL. The temporal sequence for strangler-fig is shown in Section 11.3 as a Mermaid sequence diagram (Figure 11.2 below). Per-pattern Figure 11.2 / 11.3 templates for classical strangler-fig will land in the legacy-coexistence skill when the code-and-test-generation workflow's bridge generation for this pattern reaches implementationStatus: full.

11.3 Cutover Choreography

The classical strangler-fig cutover is driven by Azure API Management (APIM) traffic-shifting policies, not by a queue-replay event. APIM owns the public surface; behind it sit the legacy Mono / serverMFinance.asmx endpoints and the modern .NET 10 services. Each APIM policy decides, per request path and per HTTP method, which backend handles the call. The cutover advances through six phases.

Phase 1 — Deploy modern services in shadow mode

Gift Processing API and Receipt Generation Service are deployed to Azure App Service (P1v3 Linux) alongside the legacy Mono/FastCGI infrastructure. APIM routes 100% of gift-processing traffic to legacy. The modern services are reachable only through their staging-slot URLs and internal health probes (/health/ready, /health/live, /health/startup). Azure Database for PostgreSQL is provisioned and the EF Core migrations are applied, but production data has not yet been loaded.

Estimated duration: 2–3 weeks (provisioning + smoke testing). Risk: low — no production traffic touches the modern stack.

Phase 2 — Activate the data-synchronization bridge

A Debezium connector is configured against the legacy PostgreSQL write-ahead log to capture mutations on the eight watched tables (a_gift_batch, a_gift, a_gift_detail, a_motivation_group, a_motivation_detail, a_recurring_gift_batch, a_recurring_gift, a_recurring_gift_detail). Change events flow through Azure Service Bus — a session-ordered topic, persistent, with dead-lettering enabled. The session key is (ledger_id, batch_id), ensuring every event for a given batch arrives at the consumer in FIFO order. A bulk-ETL snapshot of existing data loads first; the background-thread consumer in Gift Processing API then drains the queue and applies events to the modern PostgreSQL schema via Entity Framework Core with natural-key idempotency.

The APIM policy that performs traffic shifting is parameterized on a sample APIM policy fragment; the canonical form is shown below in the dark BR theme.

<!-- APIM policy: per-endpoint traffic shift for Gift Processing -->
<policies>
  <inbound>
    <base />
    <choose>
      <!-- Read-only endpoints: serve from modern once Phase 3 lands -->
      <when condition="@(context.Request.Method == &quot;GET&quot; &amp;&amp; context.Request.Url.Path.StartsWith(&quot;/api/gift-processing/v1/batches&quot;))">
        <set-backend-service base-url="https://petra-gift-api.azurewebsites.net" />
      </when>
      <!-- Default: legacy Mono / .asmx until each write stage is migrated -->
      <otherwise>
        <set-backend-service base-url="https://petra-legacy.openpetra.org" />
        <rewrite-uri template="/serverMFinance.asmx" />
      </otherwise>
    </choose>
    <set-header name="X-Petra-Routing-Source" exists-action="override">
      <value>@(context.Request.Url.Path)</value>
    </set-header>
  </inbound>
  <backend><base /></backend>
</policies>
# Debezium connector config (legacy Petra PostgreSQL WAL -> Azure Service Bus)
name: petra-gift-cdc
connector.class: io.debezium.connector.postgresql.PostgresConnector
plugin.name: pgoutput
database.hostname: legacy-petra-db.example.org
database.dbname: petra_prod
slot.name: petra_gift_slot
publication.name: petra_gift_publication
table.include.list: public.a_gift_batch,public.a_gift,public.a_gift_detail,public.a_motivation_group,public.a_motivation_detail,public.a_recurring_gift_batch,public.a_recurring_gift,public.a_recurring_gift_detail
key.converter: org.apache.kafka.connect.json.JsonConverter
value.converter: org.apache.kafka.connect.json.JsonConverter
transforms: unwrap,routeBySession
transforms.unwrap.type: io.debezium.transforms.ExtractNewRecordState
transforms.routeBySession.type: org.apache.kafka.connect.transforms.RegexRouter
transforms.routeBySession.regex: .*
transforms.routeBySession.replacement: petra.gift.cdc.v1
# Azure Service Bus sink emits each event with sessionId = ${ledger_id}.${batch_id}
// Concho domain event envelope (sage-domain-event-v1) — emitted by the translator
{
  "eventId": "01J8K3Z9R7VYM2QH3F4XW2C6T8",
  "eventType": "petra.gift.gift_detail.upserted.v1",
  "eventTime": "2026-06-14T09:21:07.412Z",
  "source": "petra-legacy/serverMFinance.asmx",
  "sessionId": "43.1047",
  "naturalKey": {
    "ledger_id": 43,
    "batch_id": 1047,
    "gift_transaction_number": 1,
    "detail_number": 1
  },
  "payload": {
    "motivation_group_code": "SUPPORT",
    "motivation_detail_code": "FIELD",
    "gift_transaction_amount": 3000.00,
    "gift_amount_in_base_currency": 2580.00,
    "currency_code": "EUR",
    "exchange_rate_to_base": 0.86,
    "tax_deductible_pct": 100.00
  }
}

Estimated duration: 3–4 weeks (connector configuration, snapshot ETL, reconciliation tooling, alerting). Risk: medium — the bridge must demonstrably catch up to the live legacy database before any traffic is shifted.

Phase 3 — Migrate read-only endpoints

APIM policies begin routing read-only gift-processing requests to the modern API: gift-batch listings, gift detail queries, motivation group/detail lookups, donor gift history, recurring-gift batch queries, and exchange-rate lookups. Reads are safe to migrate first because they have no side effects on the source of truth (still legacy). APIM uses URL-path matching (e.g., GET /api/gift-processing/v1/batches → modern; everything else → legacy) with canary stepping 10% → 25% → 50% → 100%. Application Insights monitors error rate, p99 latency, and response-content diff (sampled) between legacy and modern; rollback is a single APIM policy revert.

Estimated duration: 2–3 weeks (canary progression with bake periods). Risk: low — reads are side-effect-free; rollback is < 1 minute.

Phase 4 — Migrate write endpoints in risk-ordered stages

Write endpoints migrate in five risk-ordered stages. Each stage has its own validation gate; the next stage cannot start until the previous gate has held green for a defined bake period.

StageOperationsRiskValidation GateBake
4a Motivation group / motivation detail CRUD (reference data) Low Row count + column-level checksum reconciliation legacy ↔ modern 1 week
4b Gift batch creation, gift entry, gift detail entry (unposted only) Medium Batch-total reconciliation, sequential-numbering audit (no gaps, no duplicates), 48-hour parallel-run sample comparison 2 weeks
4c Gift batch posting (GL bridge), financial-period operations High GL trial-balance reconciliation against legacy, period-end close verification, full regression test suite 3 weeks
4d Gift reversal / adjustment, bank statement import (CAMT/MT940/CSV/ZIP), recurring-gift processing (SEPA mandates) High Reversal audit-trail verification, bank-feed reconciliation, SEPA mandate validation against a sample of live mandates 3 weeks
4e Annual receipt generation (Receipt Generation Service) Medium Receipt PDF byte-for-byte comparison on a 100-donor sample, tax-compliance spot-check by Finance Ops 1 week

Once a write endpoint moves to modern, the data synchronization bridge for the affected tables reverses direction or is shut off, depending on whether any legacy paths still read those tables. The reversal pattern is documented per stage in Appendix B's implementation roadmap.

Estimated duration: 10–12 weeks (sum of stages and bake periods). Risk: rises from low to high across the stages; the bake periods are the controls.

Phase 5 — Verify full synchronization

After stages 4a–4e complete and the modern stack owns all gift-processing writes, the team runs a full reconciliation: row-count parity across all eight target tables, checksum comparison on gift-batch totals by period, sample-row diffs on 100 randomly selected gift detail records (transaction amount, base amount, motivation codes, tax-deductible percentage), Application Insights confirmation that consumer lag is ≤ 0 and error rate is < 0.1%, and a full Playwright regression suite against the modern Angular SPA.

Estimated duration: 1–2 weeks. Risk: low — the reconciliation is fact-finding, not state-changing.

Phase 6 — Decommission legacy Gift Processing endpoints

When Finance Ops confirms operational confidence (a business decision, not a technical deadline): APIM policies are updated to route 100% of gift-processing traffic to the modern services; legacy serverMFinance.asmx gift-processing operations are deprecated (HTTP 301 redirect, then HTTP 410 Gone after a defined sunset window); the CDC bridge is stopped and the queue drained and removed; the legacy Mono/FastCGI server stays online for unmigrated subsystems (Partner, GL, Reporting, System Management) until their own modernization cycles complete; the Angular 20 SPA becomes the sole gift-processing interface.

Estimated duration: 2 weeks (sunset window + telemetry confirmation). Risk: low if Phase 5 held green.

Temporal view — Figure 11.2

sequenceDiagram
    participant U as Users
    participant APIM as Azure API Management
    participant L as "Legacy Petra
(.asmx / Mono)" participant CDC as Debezium CDC participant SB as "Azure Service Bus
(session-ordered)" participant M as Modern .NET 10 API participant DB as Azure PostgreSQL Note over U,DB: Phase 1 — Deploy modern services (shadow mode) U->>APIM: All gift processing requests APIM->>L: 100% routed to legacy Note right of M: Modern services deployed
but receive no production traffic Note over U,DB: Phase 2 — Activate data-synchronization bridge L->>CDC: Debezium tails PostgreSQL WAL CDC->>SB: Domain events (sessionId = ledger.batch) Note right of SB: Snapshot ETL +
ongoing CDC capture SB->>M: Background-thread consumer drains queue M->>DB: Apply with natural-key idempotency Note over U,DB: Phase 3 — Migrate read-only endpoints U->>APIM: GET /api/gift-processing/v1/batches APIM->>M: Reads to modern (canary 10→25→50→100%) U->>APIM: POST /serverMFinance.asmx (writes) APIM->>L: Writes still to legacy Note over U,DB: Phase 4 — Migrate writes in stages (4a–4e) U->>APIM: POST /api/gift-processing/v1/batches APIM->>M: Stage 4b: batch creation to modern M->>DB: Modern is source of truth for this stage Note right of L: Bridge reverses direction
for migrated tables Note over U,DB: Phase 5 — Verify synchronization Note right of DB: Row counts, checksums,
batch totals reconciled
Consumer lag = 0 Note over U,DB: Phase 6 — Decommission legacy gift processing U->>APIM: All gift processing requests APIM->>M: 100% to modern Note over L: serverMFinance.asmx gift ops return 301/410 Note over CDC: CDC bridge stopped Note over SB: Queue drained and removed

Figure 11.2 — Classical strangler-fig cutover sequence for Petra Gift Processing. Phases 1–2 establish infrastructure; Phase 3 shifts reads; Phase 4 shifts writes in five risk-ordered stages (4a–4e); Phases 5–6 verify and decommission.

11.4 Why Ordering and Idempotency Matter for Gift Processing

Gift processing is a financially ordered domain: the sequence in which mutations arrive determines the correctness of downstream outcomes — GL postings, tax-deductible receipts, donor statements, and the regulator-facing audit trail. The data-synchronization bridge must deliver per-batch events in FIFO order, and the consumer must enforce idempotency. The worked example below shows why, using realistic figures from MultiCurrencyGiftProcessingRule (confidence 0.83) and GiftBatchSequentialNumbering (confidence 0.95).

Worked example — multi-currency gift with split tax-deductibility

A donor (partner key 43000127) makes a EUR 5,000 gift to a UK non-profit whose base currency is GBP. The gift is entered in legacy batch #1047 (ledger 43, period 2026/03), with exchange rate 0.86 GBP/EUR snapshotted at batch creation. The donor wants the gift split across two motivations with different tax-deductibility percentages.

EventOperationDetail
E1Create gift batch #1047Ledger 43, EUR batch, exchange rate 0.86 GBP/EUR, period 2026/03
E2Create gift in batch #1047Donor partner 43000127, gift_transaction_number = 1
E3Create gift detail 1 (SUPPORT motivation)EUR 3,000 (GBP 2,580), tax deductible 100%, account SUPPORT-01, cost centre 0300
E4Create gift detail 2 (KEYMIN motivation)EUR 2,000 (GBP 1,720), tax deductible 0%, account KEYMIN-01, cost centre 0300
E5Post batch #1047GL entries: debit Bank EUR 5,000; credit SUPPORT EUR 3,000; credit KEYMIN EUR 2,000

What breaks if events replay out of order:

  • E5 arrives before E3 / E4. The batch post finds the batch and the gift but no gift details. Per GiftBatchSequentialNumbering and the posting state machine, the post either fails (and is retried indefinitely until E3 / E4 arrive — head-of-line blocking) or, worse, posts a zero-amount batch to GL. If E3 and E4 then arrive after a zero-amount post, the modern GL trial balance permanently disagrees with the legacy GL trial balance by EUR 5,000 for period 2026/03 — a Finance-Ops-visible reconciliation break.
  • E4 arrives before E2. The gift detail insert references a gift (transaction number 1) that does not yet exist in the modern database. The insert fails on the natural-key constraint; if naively retried after E2 arrives, the detail's detail_number may be claimed by a parallel insert, producing detail-numbering drift. The annual receipt for partner 43000127 would then list the wrong detail order.
  • E1 replayed twice without idempotency. The second replay attempts to increment LastGiftBatchNumber a second time (per GiftBatchSequentialNumbering, confidence 0.95), creating a phantom batch #1048 in modern that does not exist in legacy. Every subsequent batch number is then off by one between systems — an unrecoverable cross-system identity drift that breaks reconciliation for every future batch.

What breaks for the annual tax receipt:

If E3 and E4 arrive out of order and the consumer naively writes whichever arrives first into detail_number = 1, the motivation-to-amount mapping is silently swapped: the SUPPORT motivation now carries GBP 1,720 instead of GBP 2,580, and the KEYMIN motivation carries GBP 2,580 instead of GBP 1,720. The annual tax-receipt PDF for donor 43000127 (issued by Receipt Generation Service) then reports GBP 1,720 as tax-deductible instead of GBP 2,580 — an £860 understatement on the donor's tax filing. Per MultiCurrencyGiftProcessingRule (confidence 0.83), this is exactly the dual-tracking failure mode the rule exists to prevent, and it carries regulatory-compliance consequences in jurisdictions that audit non-profit tax receipts (HMRC Gift Aid, US IRS Form 990, German § 50 EStDV).

How the bridge prevents this:

  • Ordering. Azure Service Bus sessions use (ledger_id, batch_id) as the session key. All events for batch #1047 are delivered in strict FIFO order to a single session-receiver; E1 always precedes E2, which always precedes E3 and E4, which precede E5. Cross-batch ordering is not enforced (and is not required — different batches don't share aggregate roots).
  • Idempotency. The consumer uses natural-key idempotency: the composite key (ledger_id, batch_id, gift_transaction_number, detail_number) uniquely identifies every gift detail. The consumer's upsert is an INSERT ... ON CONFLICT DO UPDATE on that natural key; a replay of E3 finds the existing row and either no-ops or updates the same row with the same payload. LastGiftBatchNumber is never re-incremented by the consumer — the modern stack treats it as a read-only watermark sourced from legacy until Phase 4b's bake completes.

11.5 Proof-of-Concept Results

PoC to be validated in Phase 2 of execution. The classical strangler-fig bridge for Petra has not yet been implemented as a working proof-of-concept on this run. The v1 Concho code-and-test-generation workflow supports SAROC bridge generation end-to-end (reference: ACAS / Docker / ROSA); classical strangler-fig artifact generation — APIM policies, Debezium connector wiring, Service Bus topic/subscription provisioning, the consumer's natural-key idempotency upsert — is planned for the next workflow cycle. The Section 11 narrative, choreography, and operational thresholds in this section are complete and implementable manually following the structure above.

The Phase 2 PoC will validate, at minimum:

  • APIM policy-based routing between a legacy .asmx endpoint and the modern .NET 10 Minimal API for a single read-only operation (gift-batch listing), including canary stepping and rollback.
  • Debezium CDC from the legacy PostgreSQL WAL to Azure Service Bus, with sessionId = (ledger_id, batch_id).
  • The background-thread consumer in Gift Processing API applying domain events to the modern PostgreSQL schema with natural-key idempotency — replay-safe and re-runnable.
  • Round-trip latency from legacy commit to modern DB consistency: target p99 ≤ 2 s for single-row mutations during steady state.
  • Reconciliation tooling that produces row-count, checksum, and sample-row-diff reports per gift table on demand.

11.6 Operational Considerations

Dead-letter behaviour

Azure Service Bus is configured with dead-lettering enabled (deadLetter: true in the persisted coexistencePattern declaration). Messages that fail processing after 5 delivery attempts move to the DLQ rather than head-of-line blocking the main queue. DLQ messages retain the original sessionId and sequence number, so manual replay after a fix is straightforward. A dedicated Azure Function monitors the DLQ depth and raises an Application Insights alert whenever depth exceeds zero — every DLQ message is treated as an incident, not a backlog.

Queue depth and consumer-lag thresholds

MetricWarningCriticalAction
Service Bus active message count > 1,000 > 10,000 Warning: investigate consumer lag and per-session imbalance. Critical: page on-call, verify consumer health and PostgreSQL connection pool.
DLQ depth > 0 > 50 Warning: any DLQ message investigated within 4 business hours. Critical: halt any in-flight stage migration, full root-cause review.
Consumer p99 processing latency > 500 ms > 2,000 ms Warning: review query plans and EF Core change tracking. Critical: check PostgreSQL connection pool, index health, and per-session contention.
Oldest message age > 5 minutes > 30 minutes Warning: consumer may be stalled on a specific session. Critical: restart consumer, capture per-session lag for forensics, check for a poison message.
Consumer lag SLO (steady state) > 30 s > 5 min SLO: modern DB ≤ 30 s behind legacy commit. Suspended during Phase 2 bulk ETL; re-armed once initial queue drains to zero.

These thresholds are calibrated to Petra Gift Processing's transaction volume: Finance Ops at a typical non-profit handles tens to low hundreds of gift batches per day, not thousands. A queue depth of 1,000 represents a significant backlog. Organizations running year-end campaigns or disaster-relief drives should scale thresholds proportionally and re-baseline before each Phase 4 stage.

Consumer lag SLO

Target: during steady-state coexistence (Phases 3–5), modern PostgreSQL is no more than 30 seconds behind the legacy database for any committed transaction, measured as the difference between the legacy WAL commit timestamp and the consumer's ack timestamp, exported via OpenTelemetry custom metrics to Application Insights. During Phase 2's initial bulk ETL the SLO is suspended; it re-arms once the consumer drains the initial backlog to zero.

Cutover rollback plan

Every phase has an independent rollback lever. The APIM routing policy and the App Service slot-swap are independent and can be used together or separately.

PhaseRollback MechanismData ImpactDuration
Phase 3 (reads) Revert APIM routing policy to send reads to legacy None — reads are side-effect-free < 1 minute (single policy swap)
Phase 4a–4e (writes) Revert APIM routing policy for the affected stage; re-enable CDC bridge legacy → modern for affected tables Writes accepted by modern during the rolled-back stage must reverse-sync to legacy or be discarded within the validation bake window < 5 minutes (policy swap + bridge reconfiguration)
Phase 6 (decommission) Re-enable legacy serverMFinance.asmx endpoints in APIM; restart CDC bridge Writes that occurred only in modern after decommission require reconciliation back to legacy if rollback extends past the sunset window < 15 minutes (service restart + bridge activation)

App Service slot-swap provides an additional safety net: if the modern API exhibits errors after any write-stage migration, the staging slot (running the previous version) can be swapped back into production in seconds — independent of the APIM policy state. Combined, the two levers let Finance Ops respond to a surprise within minutes regardless of which side of the bridge it appeared on.

12. How Concho Enabled This Analysis

TL;DR — Concho’s Concho Context Graph pre-computed the structural intelligence for 100% of Petra’s 572,757-line, 1,396-file .NET / jQuery codebase — 27 business-function subsystems, 48 aggregate roots across 34 bounded contexts, 215 project-wide business rules, 151 integration points, 87 tech-debt items, and 17 named diagnostic artifacts — before any planning agent opened a source file. This run-004 analysis consumed approximately ~135 Concho queries across all phases (vs an estimated 16–29 weeks of senior-architect effort to produce comparable depth manually). The deeper insight is not the time delta — it is that you don’t know what you don’t know: the three non-idempotent write rules that selected the strangler-fig pattern live in three different files and would never have been correlated by file-at-a-time inspection.

This analysis demonstrates three distinct approaches: (1) Concho-powered analysis where agents have deep insight into all subsystems via Concho MCP, (2) Claude Code alone — analyzing code with only Read/Grep/Glob, without Concho’s pre-computed knowledge — and (3) Traditional manual review by architects. Every comparison in this section includes all three so the value of Concho is clear relative to both AI-without-Concho and manual effort.

Section 12 sits at the end of the report deliberately. The earlier sections argued the modernization on its merits — the target architecture in Section 4, the platform unlocks in Section 5, the technical-debt classification in Section 6, the UI / code / data examples in Sections 7–8 and 10, the 14 behavioral rules in Section 9, and the classical strangler-fig coexistence design in Section 11. By the time a reader reaches this section, the analysis has already paid off; the question is no longer “is this credible?” but “how did anyone produce this depth on a 572,757-line .NET codebase in hours rather than weeks?” The answer is the rest of this section.

12.1 Analysis at a Glance

Petra (OpenPetra) comprises 572,757 lines of code across 1,396 cataloged files, 27 business-function subsystems, and 32 technology subjects — a layered, n-tier .NET Framework 4.7 application with a jQuery + Bootstrap 4 web client, ASP.NET Web Services (.asmx) over Mono FastCGI, an XML-driven code-generated data access layer, and a multi-database abstraction over PostgreSQL, MySQL, and SQLite. Concho’s Context Graph transformed this heterogeneous C# / JavaScript / SQL / XML surface into structured, queryable intelligence — enabling the planning workflow to evaluate all 27 subsystems across 3 independent scoring runs, select Finance – Gift Processing as the modernization pilot through a 2-of-3 consensus, validate a 2-service Azure App Service target through 3-perspective consensus (composite score 7.8/10), reconcile 9 platform-affinity entries across 3 independent runs (8 ELIMINATE / 1 HYBRID), audit 28 dependencies across 2 manifests with 7 architectural concerns classified, and extract 14 behavioral rules with confidence scores 0.70–0.95 and 5 corroborated by legacy NUnit tests — all from pre-computed analysis rather than file-by-file reading.

Dimension Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
Time to Insight Minutes to hours — structured queries return pre-analyzed intelligence instantly. Candidate selection scored all 27 subsystems across 3 independent runs in roughly 77 Concho queries (~25 minutes of agent wall-clock); behavioral-rule extraction consumed 24 Concho calls including 9 source-code verifications; the run-004 pipeline ran approximately ~135 Concho calls across all phases, dominated by LLM composition rather than data retrieval. Days to weeks — must read C# files, follow TWebConnector-style RPC method dispatch, parse the petra.xml code-generation chain, build a mental model from scratch. Multi-layer code generation makes naive file reads misleading: generated *.Generated.cs files outweigh hand-written code, XML report templates (43,521 LOC) inflate file counts without representing translation work. Weeks to months — module walkthroughs, OpenPetra contributor interviews, NAnt build inspection, sample-payload tracing through Mono FastCGI, multi-currency rule walkthroughs with the finance team, SEPA mandate-format verification with banking specialists.
Codebase Coverage 100% — all 572,757 cataloged lines analyzed across 1,396 files; full architecture tree with 8 root layers (Application 116,775 LOC, Presentation 64,648 LOC, Data 48,033 LOC, Cross-Cutting 40,804 LOC, Infrastructure 36,977 LOC, Build & Deployment 34,112 LOC, Test 26,849 LOC, Integration 12,885 LOC) and line counts per node. Every business function, technology subject, and integration point enumerated. ~5–15% — sampling constrained by context window and session time; roughly 50–200 files out of 1,396 before the analyst must stop and synthesize. When context windows are exceeded, LLMs tend to compensate by making things up and silently introducing hallucinations. ~1–5% — selective review of MFinance/Gift/, MPartner/, and a sampling of js-client/src/forms/Finance/Gift/. The 43.5K LOC of XML report templates and 21 SQL files in the gift slice are typically not reviewed.
Subsystems Evaluated All 27 — every business function scored on Risk / Feasibility / Strategic Value with quantitative inputs (file counts, entity inventories, cross-subject affinity scores, capability lists). The 3-run consensus protocol applied three different emphasis lenses (Standard, Technical Feasibility, Business Value) and reached 2-of-3 agreement on Finance – Gift Processing (+2.47 weighted average). Run 1 (risk-aversion) selected Finance – Banking and is preserved as a documented Plan B. 3–5 — limited by time to read each subsystem’s web connector classes, typed-dataset XML, and front-end JS controllers. Cannot compare all 27 within a practical session. 1–2 — typically the candidate the customer suggests plus one contrast (e.g., Finance – Accounting as the “too big” alternative).
Confidence Scoring Quantitative (0.0–1.0) per entity. Behavioral-rule extraction: 14 rules with confidence range 0.70–0.95, mean GWT confidence 0.78, 9 rules at ≥ 0.75. DonationManagement bounded context at 0.84 confidence. TGiftTransactionWebConnector entry point at 0.93. Concho architectural assessment LLM confidence 0.85. Three non-idempotent write rules (GiftBatchSequentialNumbering 0.95, GiftBatchFinancialPeriodValidation 0.90, MultiCurrencyGiftProcessingRule 0.83) are the auditable evidence chain behind the strangler-fig selection. None — assertions about which web connector matters most, or which typed dataset is load-bearing, without quantified support. Cannot distinguish a 0.95-confidence rule from a 0.63-confidence one (GiftAdjustmentProcessing workflow is the example — correctly downgraded to a secondary scenario by Concho). Qualitative (“high / medium / low”) based on reviewer experience with .NET Framework codebases. No reproducible scoring methodology.
Repeatability Deterministic — the same Concho queries produce the same results; an auditable query trail exists for every statistic in this report. The 3-run candidate-selection protocol specifically tests stability: 2 of 3 lenses converged on Finance – Gift Processing; the divergent run was preserved with full rationale. The 3-run platform-affinity protocol achieved 100% consensus (5 of 9 entries fully consensus, 4 of 9 majority, 0 split decisions). Variable — depends on which .cs / .js / .xml files are sampled and in what order. The multi-database abstraction is invisible unless multiple connection-factory files happen to be read together. Different sessions reach different conclusions. Low — different reviewers reach different conclusions, especially about coexistence strategy (strangler-fig vs lift-and-shift) and about which slice to modernize first. Knowledge walks out the door with the consultant.
Integration Discovery 151 integration points mapped across the full codebase with protocol classification — 83 bidirectional (55%), spanning .asmx RPC dispatch, web connector method invocation, multi-DB factory calls, file-based integrations, and database integrations. 148 entry points enumerated (47 REST, 25 SOAP). The integration data validated the 2-service seam: Gift Processing API owns interactive operations, Receipt Generation Service handles PDF generation, connected via a single one-way REST contract (GET /api/gift-processing/v1/donors/{partnerKey}/posted-gifts). Explicit method calls in sampled files only. Misses implicit integration through code generation (where petra.xml drives generated DAL files at NAnt build time) and through the typed-dataset abstraction (where GiftBatchTDS mediates between web connector and database). Cannot produce a project-wide integration map. Stakeholder interviews plus selective code review — depends on contributor knowledge of how THttpConnector.CallWebConnector dispatches across modules. Often incomplete because the implicit integration through code generation is not documented in architecture diagrams.
Behavioral Rules 14 distinct rules extracted for the Finance – Gift Processing slice: 7 business rules + 7 implementation constraints. Each rule has a confidence score, a Given-When-Then specification, a verified file:line reference, a category (Validation: 4, Calculation: 3, State Transition: 2, Workflow: 3, Authorization: 2), and a platform-behavior-change assessment. 12 of 14 rules transfer with no behavioral change; 1 (BR-GIFT-002 Financial Period Validation) is a deliberate improvement; 1 (BR-GIFT-006 Confidential Gift Privacy) requires explicit authorization-policy mitigation. 5 rules are corroborated by existing NUnit tests in csharp/ICT/Testing/lib/MFinance/server/Gift/*.test.cs. Could read individual Gift.*.cs files and identify some rules, but cannot produce a complete deduplicated catalog with confidence scoring. The cross-file manifestation pattern (e.g., GiftBatchSequentialNumbering enforced in Gift.Batch.cs but referenced by the migration-execution coexistence design) is invisible without the entity-level data. Behavioral rules surface piecemeal in code review or post-incident retrospectives. The discipline of enumerating the rules before modernization begins, with confidence scoring and platform-behavior-change assessment, is rarely applied; rules are rediscovered during modernization when something breaks.

12.2 Depth of Understanding

Beyond speed, the three approaches differ fundamentally in the depth and completeness of understanding they produce. This subsection compares what each approach actually achieves for six critical analysis capabilities.

12.2.1 Architecture Mapping

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
Complete layered-architecture tree with line counts per layer: Application Layer at 116,775 LOC (Finance Management 61,632 LOC of which Gift Processing is 17,103 LOC), Presentation Layer at 64,648 LOC (Report Templates 43,521 LOC), Data Layer at 48,033 LOC (Database Schema 24,925 LOC — the petra.xml master), and 5 more root layers. 48 aggregate roots across 34 bounded contexts enumerated with confidence scores. The DonationManagement bounded context (confidence 0.84) provided the domain evidence for the 2-service split. The Concho-vended architecture_layer_diagram artifact (confidence 0.95) was embedded verbatim in Section 3.1; the data_flow_diagram artifact (confidence 0.88) was embedded in Section 3.6. Can determine architecture of individual files by reading them, but cannot classify all 572K lines across 1,396 files. Would sample directory structure (csharp/ICT/Petra/Server/, csharp/ICT/Petra/Shared/, js-client/, XmlReports/) and infer the layered shape. Likely misses the multi-database abstraction pattern because it spans PetraServerAdmin, the connection factory, and three database-specific provider classes in different directories. Cannot produce line counts per layer without reading every file. Creates architecture diagrams from contributor knowledge and existing wiki pages. Often reflects the intended N-tier shape rather than the actual shape, where Presentation is dominated by 43.5K LOC of report templates that don’t fit cleanly into any tier. May not discover that the Application Layer alone contains 116,775 lines dominated by Finance Management.

12.2.2 Subsystem Classification

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
All 27 business functions identified and profiled: file count, entity inventory, domain concepts, capabilities, key implementation classes, and cross-subject affinity scores. Finance – Gift Processing profiled with 279 files, 7 business rules, 7 constraints, 3 workflows, 4 entry points, 4 integration boundaries, and a single bounded context (DonationManagement). Cross-subject affinity scores (Gift Processing ↔ Donations Processing at 0.8, Gift Processing ↔ Finance – Accounting at 0.6) materially informed the slice-shape decision and the recommended modernization sequence (Phase 1: Gift Processing, Phase 2: Banking, Phase 3: Accounting). Could identify subsystems from csharp/ICT/Petra/Server/lib/M* directory names, but quantifying each subsystem’s complexity (entity count, workflow count, integration boundaries) requires reading every web connector class. Likely identifies 5–8 major subsystems (MFinance, MPartner, MPersonnel, MConference, MSponsorship, MReporting, MSysMan) and stops there. Cannot produce the cross-subject affinity scores needed to recognize that Gift Processing and Donations Processing share 0.8 affinity — overlapping code surfaces under different cluster labels. Relies on contributor knowledge of the OpenPetra module map. Long-tenured contributors know the major modules but often disagree on boundaries (e.g., whether “Donations Processing” is part of Finance or its own subsystem). Quantified profiling of all 27 subsystems would require weeks of dedicated architect time.

12.2.3 Integration Analysis

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
151 integration points mapped across the full codebase with protocol classification (83 bidirectional). 148 entry points enumerated (47 REST, 25 SOAP). For the Gift Processing slice specifically, Concho returned 4 HTTP-routable entry points (TGiftTransactionWebConnector, AnnualReceiptGenerationInterface, GiftBatchManagementInterface, BankImportManagementInterface) — the evidence that a request-routing surface exists, which directly ruled out SAROC and selected the middle branch (classical strangler-fig) of the coexistence decision tree. The integration_map artifact (confidence 0.95) and entry_point_catalog artifact (confidence 0.88) were both consumed verbatim. Can trace explicit method calls in files it reads, but cannot detect implicit integration through code generation (where petra.xml drives generated DAL files at NAnt build time) or through the typed-dataset abstraction. Would need to read the NAnt build files, the XML schema, the parser (TDataDefinitionParser), and several web connectors together to understand the generation chain. The 148 entry points would take days to enumerate manually. Integration maps created from contributor interviews and architecture documents. Often incomplete because the SOAP-vs-JSON-RPC distinction is not documented — contributors who write web connectors know how to invoke them, but the wire-format detail (JSON content despite SOAP envelope) is rarely articulated. The implicit code-generation integration is the most commonly missed pattern.

12.2.4 Code Quality Assessment

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
7 architectural concerns surfaced with severity ranking (1 high, 4 medium, 2 low) and 0.85 LLM confidence. Each concern classified against the target architecture: 3 retired by target, 2 mitigated by target, 2 out of scope for this slice. 5 architectural strengths catalogued in parallel (single-source-of-truth schema, code-generation pipeline, multi-DB abstraction, cross-layer validation framework, NUnit integration test infrastructure). 28 dependencies audited across csharp/ThirdParty/packages.config (19 NuGet) and js-client/package.json (13 npm) with lifecycle status and target-action classification: 10 removed, 10 replaced, 7 upgraded, 1 outside slice. One known-CVE entry (axios 0.21.4: CVE-2021-3749, CVE-2023-45857) flagged for removal. The tech_debt_inventory artifact (87 debt items, confidence 0.92) is the source-of-truth. Can assess quality of files it reads, identifying patterns like deep nesting, long methods, or magic strings. But without full-codebase context, cannot determine whether a pattern is isolated or systemic. Would not discover that 5 of the 7 concerns share a common root cause — the .NET Framework 4.7 runtime ceiling that pins all NuGet packages to pre-.NET 5 versions. Could read packages.config but lacks lifecycle context for each package (which are EOL, which have CVEs, which polyfills are absorbed by .NET 10). Code-quality assessment typically done through targeted module reviews. Senior contributors may know which subsystems carry the worst debt, but rarely quantify strengths alongside weaknesses. The classification of “what the target retires vs mitigates vs leaves alone” is a structured analysis that manual reviews rarely produce.

12.2.5 Modernization Complexity Estimation

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
All 27 subsystems scored on Risk / Feasibility / Strategic Value across 3 independent runs with different emphasis lenses, producing a 2-of-3 consensus +2.47 weighted score for Finance – Gift Processing (Risk 5.8, Feasibility 7.0, Strategic Value 9.0). The divergent lens (Run 1, risk-aversion) selected Finance – Banking and is preserved as a documented Plan B — not hidden, not overruled silently. The obvious-but-wrong picks were quantitatively rejected: Finance – Accounting (370 files, too large for a pilot), Partner – Contacts Management (211 files, central hub — modernizing it disrupts everything), System Management – Users (390 files — infrastructure plumbing with no demo punch). The platform-affinity 3-run consensus reinforced this with 100% resolution on 9 entries. Can estimate complexity for subsystems it has analyzed, but without full-codebase profiling, cannot compare all 27. Likely evaluates the 3–5 subsystems whose directories look smallest by file count. Cannot produce quantified risk scores because it lacks the entity-level data (cross-subject affinity, capability inventory) needed for scoring. Would not recognize that the 279-file headline for Gift Processing includes report templates and SQL files that don’t represent hand-translation work. Estimation based on experience and analogy. Typically evaluates 1–2 candidates — often the one suggested by stakeholders. The discovery that Finance – Gift Processing occupies a “meaningful but manageable” sweet spot at ~17,103 LOC of Application-Layer logic within a 279-file footprint requires the kind of architectural decomposition that manual reviews rarely produce for all 27 subsystems.

12.2.6 Behavioral-Rule Catalog Construction

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
14 distinct rules extracted: 7 business rules and 7 implementation constraints, all with structural evidence and confidence scores 0.70–0.95 (mean GWT confidence 0.78). Concho’s entity model provided the complete set from a single subject query, with 9 source-code verifications confirming file:line references against actual code. Rules classified into 5 categories (Validation: 4, Calculation: 3, State Transition: 2, Workflow: 3, Authorization: 2) with platform-behavior-change assessment. The top rule — BR-GIFT-001 GiftBatchSequentialNumbering at 0.95 confidence — was flagged as the primary concurrency hotspot for the strangler-fig bridge, directly informing Section 11’s coexistence design. 12 of 14 rules transfer with no behavioral change; 1 (BR-GIFT-002 Financial Period Validation) is a deliberate improvement; 1 (BR-GIFT-006 Confidential Gift Privacy) requires explicit authorization middleware. 5 of 14 rules are corroborated by legacy NUnit tests — surfaced by a single search_files query that found the 7 test files in csharp/ICT/Testing/lib/MFinance/server/Gift/. Could read Gift.Batch.cs, Gift.Validation.cs, and TaxDeductibility.cs and identify some rules in those files. Would not find rules whose code lives in SQL files (Gift.ReceiptPrinting.GetDonationsOfDonor.sql), adjustment files (Gift.Adjustment.cs), or the schema migration (Upgrade202206_202207.cs) without explicitly searching for them. Confidence scoring is not available. The cross-file manifestation pattern — the same rule enforced in C# code, SQL queries, and report templates — is invisible without the entity-level data model. Would also miss the BR-GIFT-011 nuance that legacy tests reveal (non-UNIT partners leave caller defaults unchanged, contrary to what code-only inference suggests). Behavioral rules surface piecemeal in stakeholder interviews and code review, often incompletely. The discipline of producing a numbered catalog with confidence scoring and platform-behavior-change assessment before modernization begins is rarely applied. Rules are rediscovered during modernization when a regression breaks a test — or worse, when a stakeholder notices incorrect behavior in production.

12.3 Why the Concho Context Graph Matters

Could Claude Code alone read a source file and determine legacy behavior? Sure — if it knew which file to read. But the fundamental problem is: you don’t know what you don’t know. Even if Claude Code is pointed at the right file and correctly understands the logic, it still cannot know whether that file is the only part of the overall system that touches that capability. A constraint might originate in a data structure, propagate through a print program, and manifest as a screen layout limitation — three different files, three different architectural layers. A tool analyzing one file at a time cannot see this full picture, and worse, it doesn’t know to look for it.

Consider the most architecturally significant discovery from this analysis: the non-idempotent write pattern that selected the classical strangler-fig coexistence strategy. The migration-execution agent needed to decide between symmetric dual-write and classical strangler-fig for the legacy/modern coexistence period. This decision required knowing three specific facts about write behavior across the gift-processing surface: (1) BR-GIFT-001 GiftBatchSequentialNumbering (confidence 0.95, in Gift.Batch.cs) atomically increments LastGiftBatchNumber on the ledger — a non-idempotent write that cannot be safely duplicated; (2) BR-GIFT-002 GiftBatchFinancialPeriodValidation (confidence 0.90, in Gift.Batch.cs) constrains batch dates to open accounting periods — a time-dependent validation that must be authoritative in exactly one system; (3) BR-GIFT-003 MultiCurrencyGiftProcessingRule (confidence 0.83, in Gift.Currency.cs) computes dual base + transaction amounts from an exchange-rate snapshot that is not stable across systems.

These three facts live in three different C# source files. No single file contains all the evidence. A file-at-a-time analysis would need to (a) know to look at batch creation, period validation, and currency conversion as a coordinated set, (b) find the specific lines in each file, and (c) synthesize the pattern: “these writes are non-idempotent across three independent enforcement points, ruling out symmetric dual-write.” Concho’s Context Graph had already classified all three rules with confidence scores and source locations. The planning agent assembled the evidence in two Concho queries (get_entities_by_subject for entry points + get_entities_by_subject for business rules) from pre-computed intelligence, not by hoping to read the right files in the right order.

The advantage of Concho’s Context Graph is that the planning agent instantly has access to the full gamut of business flows, behavioral rules, architecture layers, and integration points — at both business and technology levels — across 100% of the codebase. It does not start by reading files; it starts by knowing what exists. It retrieves actual source code if and only if needed to verify an assertion the Concho analysis has already made. This inverts the workflow from “read code and hope you find what matters” to “know what matters and verify it against the code.”

The Inversion in Practice

Claude Code Alone = Start with 1,396 files across C#, JavaScript, SQL, and XML. Read some. Grep for patterns. Build a mental model. Hope you find the three non-idempotent write enforcement points before committing to a coexistence strategy. Rediscover from scratch on every project.

Concho + Claude Code = Start with 27 classified business-function subsystems, 34 bounded contexts, 48 aggregate roots, 215 project-wide business rules (14 specific to the gift slice with confidence scores), 151 integration points, 148 entry points (47 REST + 25 SOAP), and a 7-row architectural-concerns catalog — all with confidence scores and source references. Query what you need. Verify against code only when necessary. The coexistence-strategy decision draws on pre-computed rule evidence, not file sampling.

12.4 Specific Examples from This Analysis

12.4.1 Three-Run Subsystem Consensus Across 27 Candidates

Concho’s candidate-selection step evaluated all 27 business-function subsystems across 3 independent runs, each applying a different emphasis lens: Run 1 (Standard Weighted Scoring), Run 2 (Technical Feasibility emphasis), and Run 3 (Business Value emphasis). Two of three runs converged on Finance – Gift Processing as the optimal pilot — a 2/3 consensus. Run 1 selected Finance – Banking on risk-aversion grounds; that result is documented as Plan B in Appendix A rather than overruled silently. The 3-run protocol consumed approximately 77 Concho queries collectively (~25 minutes of agent wall-clock), each run independently profiling subsystems through domain briefings, entity inventories, and architectural-insight queries.

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
27 subsystems evaluated across 3 independent scoring runs with consensus averaging. Finance – Gift Processing selected at consensus score +2.47 (Risk 5.8, Feasibility 7.0, Strategic Value 9.0). The protocol exposed scoring stability and surfaced a legitimate minority opinion: Run 1 (risk-aversion lens) selected Banking and that result is preserved as documented Plan B. The runner-up Finance – Accounting (370 files, highest risk) was quantitatively rejected as too large for a pilot. Would evaluate 3–5 subsystems by reading directory contents and web-connector files. Cannot run the same subsystem set through multiple emphasis lenses because each profiling pass requires hours. Likely defaults to picking the customer-named candidate without quantitative comparison. The discovery that two reasonable lenses can disagree — and the discipline to preserve the minority position rather than hide it — is structurally outside what file-at-a-time analysis can produce. Evaluates 1–2 candidates, typically with qualitative pros-and-cons. Multiple reviewers may disagree on which candidate is best; a consensus protocol is not standard practice. The quantified scoring framework (Risk × -0.4 + Feasibility × 0.3 + Strategic Value × 0.3) with entity-level inputs is a capability that manual reviews rarely achieve.

12.4.2 Three-Perspective Service Architecture Validation

The service-architecture phase did not simply assert “2 services.” It submitted the Gift Processing slice to three independent analytical perspectives — Domain-Driven Design (bounded context analysis), Technical (load pattern and resource profile), and Business Capability (user roles and operational cadence) — then scored the options on a 4-dimension rubric. All three lenses unanimously proposed 2 services; the composite weighted score was 7.8/10, exceeding the 7.0 quality gate. This phase consumed only 2 Concho calls because it built on the already-profiled DonationManagement bounded context (confidence 0.84) from the candidate-assembly phase.

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
Three independent perspectives evaluated with pre-computed bounded-context data (DonationManagement at 0.84 confidence, exactly one context), workflow complexity data (DonationReceiptGeneration at 0.87, RecurringGiftProcessing at 0.84, GiftAdjustmentProcessing at 0.63), and integration-boundary data. The DDD perspective scored 7.9, Technical 7.7, Business 7.9 — convergence is genuine, not a procedural tiebreaker artifact. Five open questions from the prior target-architecture phase were resolved unanimously (service count = 2; Receipt Generation = API-only access; async = optional; Bank Import = internal; Motivation Admin = internal). Could read enough code to recognize that gift processing and receipt generation have different characteristics, but cannot produce three formal perspectives with quantified bounded-context, workflow, and integration data. Would likely propose a service count based on intuition or analogy to other microservice projects, without a quality-gate scoring rubric. Service decomposition decisions typically made via whiteboard sessions weighing “scalability,” “team boundaries,” and “deployment isolation” — valid factors, but without the structured 3-perspective protocol. The unanimous 2-service convergence across DDD, Technical, and Business lenses is the kind of structural agreement that is uniquely reproducible via pre-computed intelligence.

12.4.3 Platform Affinity Reconciliation from 3-Run Consensus

Section 5’s platform affinity analysis ran 3 independent passes and reconciled the results into 9 consensus entries (8 ELIMINATE, 1 HYBRID, 0 PRESERVE). Five of nine entries had full 3/3 inclusion-and-classification consensus; four of nine had 2/3 majority. Zero entries had a split decision requiring human review — 100% resolution rate. Five additional entries that appeared in only 1 of 3 runs were explicitly omitted with documented rationale. This consensus-then-reconcile pattern — the same approach used for candidate selection — ensures that the platform constraints surfaced for stakeholders are structurally stable, not artifacts of a single analytical perspective.

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
9 platform affinity entries reconciled from 3 independent runs: 5 with full consensus, 4 with majority agreement, 0 unresolved. Classification disputes resolved with documented rationale (e.g., Mono FastCGI: 3/3 ELIMINATE on classification, 2/3 on category — resolved to Processing not Capacity because the underlying constraint is a synchronous request-affinitized hosting bridge with [ThreadStatic] isolation that blocks async-await, not a capacity ceiling). Entries span 4 categories: Capacity (1), Processing (3), UI (3), Data Type (2). Concho data provided the architectural-concern evidence (7 concerns at 0.85 confidence) and implementation-constraint evidence (226 constraints) underlying each entry. Could identify some platform constraints by reading code (e.g., jQuery DOM manipulation in js-client/, SOAP envelope in .asmx files), but cannot produce a structured 3-run consensus. Would list constraints without the ELIMINATE / HYBRID / PRESERVE classification framework. The distinction between platform constraints and business rules — critical for knowing what to retire vs preserve — requires entity-level data that file reading cannot provide. Platform constraints surface during modernization planning as blockers or assumptions. The structured categorization (Capacity / Processing / UI / Data Type) with ELIMINATE / HYBRID / PRESERVE classification is a capability that manual reviews develop ad hoc rather than systematically. Disagreements between reviewers about what is “platform” vs “business” are common and often unresolved.

12.4.4 Evidence-Based Coexistence Pattern Selection

The migration-execution phase did not default to “strangler fig because everyone uses strangler fig.” It evaluated the pattern against specific evidence from just 2 Concho queries: (1) get_entities_by_subject(Finance - Gift Processing, entry_point) returned the 4 HTTP-routable entry points — confirming a request-routing surface exists, ruling out SAROC and selecting the middle branch of the decision tree; (2) get_entities_by_subject(Finance - Gift Processing, business_rule) returned the 7 rules including the three non-idempotent ones at 0.95 / 0.90 / 0.83 confidence — ruling out symmetric dual-write. The classical strangler-fig pattern was the only branch left, and the evidence chain is auditable.

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
Classical strangler-fig selected with evidence chain: 4 HTTP-routable entry points (TGiftTransactionWebConnector, AnnualReceiptGenerationInterface, GiftBatchManagementInterface, BankImportManagementInterface) confirm request-routing surface; 3 non-idempotent write rules (confidence 0.83–0.95) rule out dual-write; batch boundaries (posted/unposted) confirm incremental cutover feasibility. Azure API Management routing specified with posted/unposted state machine as the strangler key. Debezium CDC over PostgreSQL WAL selected for data synchronization with Azure Service Bus as the session-ordered durable substrate. Each fact traces to a specific Concho entity with confidence score. Would likely recommend strangler fig by default (it is the standard pattern for web-service modernizations), but without evidence that the writes are non-idempotent or that the routing surface is HTTP-routable. Cannot enumerate the entry points to confirm that traffic shifting is feasible. Might recommend dual-write without realizing that the sequential batch numbering makes it unsafe. Strangler-fig selection typically based on architectural intuition and industry precedent. The specific evidence chain (non-idempotent writes, batch boundaries, routing surface) is the kind of analysis a senior architect would perform if asked, but is rarely produced proactively. The Debezium-with-Azure-Service-Bus selection requires knowledge of both the legacy database configuration and the target deployment topology.

12.4.5 Use Case Discovery from Entity Evidence

The use-case-discovery phase discovered 3 primary use cases (Gift Batch Entry through Posted GL; Annual Tax-Deductible Receipt Generation; Recurring Donation Cycle with SEPA Export) plus a documented secondary scenario (Posted Gift Adjustment, downgraded because its workflow confidence is 0.63 — below the 0.70 primary-use-case threshold). Each primary use case was selected on multi-criterion fit: cross-service span, business visibility, modernization delta, demo-ability, and rule density. All 14 catalog rules fire across these three primary use cases (Use Case 1 fires 8 of 14; Use Case 2 fires 6 of 14; Use Case 3 fires 7 of 14). This discovery consumed 13 Concho calls: 4 get_entities_by_subject queries for workflows / entry points / bounded context / integration boundaries, then 9 targeted entity lookups for workflow steps, bounded-context responsibilities, and entry-point operations.

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
3 primary use cases discovered from Concho workflow entities, each scored on 5 criteria. The DonationReceiptGeneration workflow (9-step state machine, HIGH complexity, confidence 0.87) was correctly selected as the cross-service demonstration because it is the only use case exercising the REST contract between the two target services. The GiftAdjustmentProcessing workflow (5 steps, confidence 0.63) was correctly downgraded to a secondary scenario because its confidence falls below the 0.70 threshold — a quantitative decision that file-at-a-time analysis cannot replicate. Each use case was mapped to UI screens (5 per use case), behavioral rules in execution order, and service-boundary annotations. Could identify the gift receipt generation workflow by reading Gift.Receipting.cs and the associated SQL, but cannot score it against alternatives without reading all workflow-related files. Cannot determine that Use Case 1 fires 8 of 14 behavioral rules without first building the behavioral-rules catalog — a circular dependency that Concho resolves by providing both the workflow and rule inventories simultaneously. Would likely miss the GiftAdjustmentProcessing workflow entirely (or include it without realizing its confidence is too low). Use case selection typically based on stakeholder input (“the annual receipt run is our biggest pain point”). The quantified scoring against alternatives, with rule-density and cross-service-span metrics, is a structured analysis approach that manual reviews rarely produce.

12.4.6 Legacy-Test Discovery for High-Fidelity Rule Corroboration

A subtle but important capability of the Concho Context Graph is that it surfaces legacy test files alongside production source. The behavioral-rules phase ran a single search_files query against csharp/ICT/Testing/lib/MFinance/server/Gift/ and discovered 7 legacy NUnit test files covering the gift slice (PostGiftBatch.test.cs, RevertAdjustGiftBatch.test.cs, SingleGiftReceipt.test.cs, AnnualReceipts.test.cs, RecurringGiftBatch.test.cs, SetMotivationGroupAndDetail.test.cs, and one fixture). Cross-referencing these against the 14 catalog rules upgraded 5 rules from code-inferred to code-inferred-test-corroborated — raising their effective confidence and providing first-priority test-translation targets for the downstream code-and-test-generation workflow.

Critically, the legacy test suite for BR-GIFT-011 (Motivation Detail Assignment by Partner Class) captured a piece of original intent that code alone leaves implicit: for a non-UNIT partner the motivation is left unchanged — a "do nothing" branch that the production code expresses only as the absence of an overwrite. The original developers asserted that outcome explicitly in a test. Harvesting it gives a second, independent record of intent that nails down exactly these edge and do-nothing cases — which is precisely why Concho reads the surviving tests alongside the source.

Concho + Claude Code Claude Code Alone (no Concho MCP) Manual Review
One search_files query enumerated 7 legacy NUnit test files in scope. Cross-reference against the rule catalog upgraded 5 rules to code-inferred-test-corroborated, with mean confidence rising from 0.83 (code-only) to 0.87 in the test-corroborated subset. BR-GIFT-011 nuance (non-UNIT partner default behavior) discovered through test assertions rather than production code. Would need to know that legacy tests exist (often invisible from production paths), guess the test naming convention, and read each test file to extract its assertions. Cannot systematically cross-reference test assertions against a behavioral-rule catalog because no such catalog exists in this approach. Legacy tests are sometimes preserved in modernization projects, often discarded as “tied to the old platform.” The discipline of mining them as a fidelity-of-intent source for the behavioral-rule catalog is rare. Stakeholder interviews rarely surface the BR-GIFT-011 partner-class nuance because it is too low-level to make it into requirements documents.

12.5 Concho Query Snippets

For transparency, here are representative queries the planning agents executed against Concho’s Context Graph (project key petra, cycle 23). The Concho Context Graph exposes a small set of focused tools; the agents compose them to assemble evidence.

Project intelligence — one query returns the top-level shape:

get_project_metadata(project_key="petra", cycle=23)
→ { total_loc: 572757, file_count: 1396,
     primary_language: "C# / .NET Framework 4.7",
     business_function_subjects: 27,
     technology_subjects: 32,
     data_stores: ["PostgreSQL", "MySQL", "SQLite"] }

Subsystem profile — one query returns 31+ entities for a single slice:

get_subject_profile(project_key="petra", cycle=23,
                   subject="Finance - Gift Processing",
                   subject_kind="business_function")
→ { file_count: 279,
     bounded_contexts: [{ name: "DonationManagement", confidence: 0.84 }],
     business_rules: 7,
     implementation_constraints: 7,
     workflows: 3,
     entry_points: 4,
     integration_boundaries: 4,
     affinities: { "Donations Processing": 0.8,
                   "Finance - Accounting": 0.6 } }

Coexistence pattern evidence — two queries collapse weeks of file-reading:

get_entities_by_subject(subject="Finance - Gift Processing",
                       entity_type="entry_point")
→ 4 HTTP-routable entry points (TGiftTransactionWebConnector,
     AnnualReceiptGenerationInterface, GiftBatchManagementInterface,
     BankImportManagementInterface)
   ⇒ request-routing surface exists ⇒ SAROC ruled out

get_entities_by_subject(subject="Finance - Gift Processing",
                       entity_type="business_rule")
→ 7 rules including:
     BR-GIFT-001 GiftBatchSequentialNumbering (conf 0.95) - atomic ++
     BR-GIFT-002 GiftBatchFinancialPeriodValidation (conf 0.90) - period bind
     BR-GIFT-003 MultiCurrencyGiftProcessingRule (conf 0.83) - FX-snap
   ⇒ writes non-idempotent ⇒ symmetric dual-write ruled out

Result: classical-strangler-fig (middle branch of the decision tree)

Behavioral-rule extraction — one subject query returns the full catalog:

get_entities_by_subject(subject="Finance - Gift Processing",
                       entity_type="business_rule") +
get_entities_by_subject(subject="Finance - Gift Processing",
                       entity_type="implementation_constraint") +
get_entities_by_subject(subject="Donations Processing",
                       entity_type="business_rule")
→ 14 rules total (7 + 7 + 1 merged with GiftReceiptEligibility)
     confidence range 0.70-0.95, mean GWT confidence 0.78

search_files(file_name_regex=
    "csharp/ICT/Testing/lib/MFinance/server/Gift/.*\\.test\\.cs")
→ 7 legacy NUnit test files
   ⇒ 5 of 14 rules upgraded to code-inferred-test-corroborated

12.6 The Numbers

This run-004 analysis consumed approximately ~135 Concho Context Graph queries across all phases. Those queries provided structured intelligence covering 100% of the 572,757-line, 1,396-file codebase — every business function, every integration point, every behavioral rule, every architectural concern. The alternative would have been reading thousands of files and hoping to find what matters.

Phase Concho Queries Key Deliverables Estimated Manual Equivalent
Project Intelligence (Sections 1 + 3) ~11 572K LOC profile, 27 business functions, 48 aggregate roots, 34 bounded contexts, 151 integration points, 17 Concho-vended artifacts catalogued 2–3 weeks
Candidate Selection (3 runs + assembly) ~77 All 27 subsystems scored on Risk / Feasibility / Strategic Value with 2-of-3 consensus; Plan B documented 6–10 weeks
Target Architecture (Section 4) 1 2-service Azure App Service design, 8-table data model, resolved target patterns (reused validated facts from prior phases) 1–2 weeks
Service Architecture (Appendix B + 4.5) 2 3-perspective unanimous consensus (DDD / Technical / Business), composite 7.8/10 quality gate, REST contracts, 5 open questions resolved 1–2 weeks
Platform Affinity (3 runs + reconciliation) (*) 9 platform entries reconciled from 3-run consensus (8 ELIMINATE / 1 HYBRID / 0 PRESERVE), 100% resolution rate 1–2 weeks
Tech Debt Analysis (Section 6) 8 7 architectural concerns classified, 28 dependencies audited (1 known-CVE), 5 strengths catalogued 1–2 weeks
Use Case Discovery 13 3 primary use cases + 1 documented secondary scenario, all 14 rules mapped, UI storyboards per case 1–2 weeks
Behavioral Rules (Section 9) 24 14 rules with confidence 0.70–0.95, GWT specs, source verification, 5 corroborated by legacy NUnit tests 2–4 weeks
Migration Execution (Section 11) 2 Classical strangler-fig selected with evidence chain, Debezium CDC + Azure Service Bus, 6-phase cutover choreography 1–2 weeks
Total ~135 Complete modernization plan: 12 report sections + 3 appendices 16–29 weeks

(*) Platform-affinity runs consumed 0 direct Concho queries this cycle because they reused already-cataloged technology-subject and architectural-concern data from prior phases. This is a feature, not a gap: once a phase has surfaced the structural facts, downstream agents reason over them rather than re-query.

The estimated manual equivalent of 16–29 weeks represents the effort an experienced architect or modernization consultant would spend to produce an analysis of comparable depth and coverage — profiling all 27 subsystems, building the complete behavioral-rule catalog, mapping all integration points, auditing all dependencies, and designing the target architecture with evidence-based service decomposition. The Concho-assisted analysis achieved this in a fraction of the time, with higher coverage (100% vs sampling), quantified confidence scoring, and full auditability.

12.7 Conclusion: From Partial Analysis to Full Deep Insight

The Petra Finance – Gift Processing analysis demonstrates a fundamental transformation in legacy-modernization capability. Traditional approaches — whether manual or AI-assisted — are constrained to partial analysis: sampling representative files, inferring patterns from incomplete coverage, and accepting knowledge gaps as inevitable. Claude Code alone can accelerate this partial analysis but remains bound by context limits that force selective coverage (~5–15% of large codebases). When context windows are exceeded, LLMs tend to compensate by making things up and silently introducing hallucinations into the output.

Claude Code + Concho MCP eliminates partial analysis entirely. By providing pre-analyzed semantic intelligence across 100% of the codebase — business functions, architecture layers, behavioral rules, integration points, and confidence-scored relationships — Concho’s Context Graph transforms modernization from educated guesswork into systematic engineering. The difference isn’t just speed (~135 queries vs 16–29 weeks of architect time); it’s completeness, accuracy, and the discovery of critical patterns — like the three-file non-idempotent write evidence that selected the strangler-fig pattern — that partial analysis would miss entirely.

Every statistic in this report traces to a Concho query result. Every assertion about the legacy codebase is backed by structural evidence, not file sampling. Every comparison across subsystems covers all 27 candidates, not a convenient subset. This is what Concho’s Context Graph enables: not just faster analysis, but fundamentally more complete analysis — the kind that catches the non-idempotent write pattern before you commit to a coexistence strategy, finds all 14 behavioral rules before you start translating code, and evaluates all 27 subsystems before you pick the pilot.

Ready to Transform Your Legacy Modernization?

Experience the difference between partial sampling and full deep insight. Concho’ Concho Context Graph can analyze your legacy codebase — .NET Framework, Java, COBOL, C++, or other languages — and deliver comprehensive modernization intelligence in hours, not weeks.

Visit concho.ai and sign up for a pilot project. Discover what you’ve been missing with partial analysis.

Appendices

Advanced analysis and deployment artifacts

Appendix A: Multi-Agent Subsystem Selection

This appendix documents the multi-agent consensus methodology used to select the modernization pilot candidate from among 27 documented business-function subsystems in Petra (OpenPetra). Three independent evaluation runs were conducted, each applying the same weighted scoring formula but from a different analytical perspective, and the results were reconciled to produce a consensus recommendation. Unlike a unanimous outcome, the three runs of this analysis surfaced a real disagreement — Run 1 selected Finance — Banking on a lower-risk argument, while Runs 2 and 3 selected Finance — Gift Processing. This appendix shows both cases honestly and explains why the 2/3 majority prevails.

A.1 Methodology Overview

Subsystem selection for a modernization pilot is a multi-dimensional problem: technical feasibility, business impact, and migration risk pull in different directions. A single analyst’s evaluation inevitably reflects their professional bias — a developer may favor the technically cleanest subsystem, while a business stakeholder may push for the highest-revenue module regardless of migration complexity. Multi-agent consensus addresses this by running three independent evaluations with different emphasis lenses, then reconciling the results.

Each evaluation run uses the same weighted scoring formula but applies a different qualitative emphasis when assessing sub-factors within each dimension. The three runs are structurally independent: each agent queries Concho MCP tools separately, constructs its own subsystem profiles, and produces its own ranked list without knowledge of the other runs’ results. Consensus is determined after all three runs complete: if 2 or more runs agree on the top candidate, that is the recommendation; if all 3 differ, a weighted average across runs is used.

For this analysis, Runs 2 and 3 selected Finance — Gift Processing as the top candidate, while Run 1 selected Finance — Banking. The 2/3 majority makes Gift Processing the consensus recommendation. The divergence is informative rather than a defect of the methodology — it shows that the next-best candidate (Banking) is a credible alternative under a stronger risk-aversion lens, and it gives stakeholders a documented Plan B if pilot constraints later shift toward minimizing first-slice risk.

A.2 Scoring Formula

Score = (Risk × −0.4) + (Feasibility × 0.3) + (Strategic Value × 0.3)

Risk is negatively weighted because lower risk is better. The theoretical maximum score is +6.00 (Risk=0, Feasibility=10, Strategic Value=10). All three dimensions are scored on a 0–10 scale.

A.3 Scoring Dimensions

DimensionWeightDirectionSub-Factors
Risk 40% Lower is better Business criticality, integration fan-out, data migration risk, compliance/regulatory exposure
Feasibility 30% Higher is better File count and LOC, complexity, dependency isolation, test coverage, code-generation regularity
Strategic Value 30% Higher is better Business value, pattern reusability across the programme, stakeholder visibility, skill building

A.4 Agent Perspectives

Run 1: Standard Weighted Scoring

The first evaluation applies balanced weighting across all three dimensions without emphasizing any particular perspective. It queries Concho for project metadata, all 27 business function subjects, and detailed subject profiles for the top candidates. This run establishes the baseline ranking and stress-tests whether the highest-value candidate is also the safest first slice.

Key optimization: Finding the candidate that best balances all three dimensions simultaneously, with no single dimension dominating the assessment.

Run 2: Technical Feasibility Emphasis

The second evaluation applies heightened scrutiny to feasibility sub-factors: file count sweet spots, code-generation regularity, bounded-context confirmation, integration boundary count, and target-stack alignment. This run stress-tests whether the top candidate is genuinely tractable as a first slice or just strategically attractive.

Key optimization: Ensuring the recommended candidate is technically achievable within pilot constraints, with clean domain boundaries and manageable scope.

Run 3: Business Value Emphasis

The third evaluation weights the strategic-value sub-dimensions (business value, visibility, pattern reusability) more heavily in qualitative assessment. This run ensures the selected pilot delivers maximum organizational impact and establishes modernization patterns that transfer to the largest possible portion of the remaining codebase.

Key optimization: Maximizing the business return and pattern-reusability payoff of the pilot investment.

A.5 Agent Analysis Results

Run 1 Results: Standard Weighted Scoring — Selected Finance — Banking

Run 1 Recommendation (DIVERGENT): Finance — Banking

Risk: 4.0  |  Feasibility: 8.0  |  Strategic Value: 8.0  |  Weighted Score: +3.20

Run 1 ranked Finance — Banking first with a +3.20 weighted score, ahead of Gift Processing (+1.70). Run 1's argument centers on first-slice risk minimization:

  • Risk (4.0): Banking is well-bounded with low integration fan-out (only 1 high-affinity neighbor: Finance — Accounting at 0.7). Its failure mode is "manual reconciliation falls back to operator", not "donations break". Run 1 scored Gift Processing's risk at 7 because it is the revenue heart of the system — any bug breaks core donor stewardship and tax compliance.
  • Feasibility (8.0): 177 files — second-smallest Finance sub-domain. Clear bounded context (CAMT/MT940/CSV bank statement import and matching). Modern equivalents are well-understood (ISO 20022 parsing libraries exist for .NET 10). SEPA compliance logic is portable.
  • Strategic Value (8.0): Touches three modernization-pattern areas in one pilot: file-format parsing, transaction matching algorithms (Matching.cs is 47.8KB / 1126 LOC), and external standards integration (ISO 20022, SEPA, MT940). Builds reusable skills for harder slices later.

Run 1's argument against Gift Processing: Gift Processing is the revenue heart of OpenPetra. Migrating it first is high-risk — any bug breaks core donor stewardship and tax compliance. Gift Processing also has higher integration fan-out (0.8 affinity to Donations Processing, 0.6 to Accounting, plus deep MPartner coupling) making the strangler-fig boundary harder to draw cleanly. Banking sits downstream of Gift Processing (bank statements get matched into gift batches), so Banking can be modernized first with a synchronous API/file handoff back into the legacy gift module, giving the cleanest possible strangler boundary.

Top 5 from Run 1:

RankSubsystemFilesRiskFeasibilityStrategic ValueScore
1Finance — Banking177488+3.20
2Hospitality — Accommodation*5294+3.10
3Sponsorship — Child Management*12395+3.00
4Finance — Budgeting160385+2.70
5Finance — Gift Processing279769+1.70

* Run 1 rejected these as meaningful pilots due to insufficient scale (5–12 files won't exercise enough patterns).

Run 2 Results: Technical Feasibility Emphasis — Selected Finance — Gift Processing

Run 2 Recommendation: Finance — Gift Processing

Risk: 5.0  |  Feasibility: 7.5  |  Strategic Value: 8.5  |  Weighted Score: +2.80

Run 2 confirmed Gift Processing as the top candidate under its technical-feasibility lens. Key findings:

  • Risk (5.0): Moderate. Donation processing is mission-critical but the gift-batch workflow is well-bounded (posted/unposted states, batch-level posting). Integration with MFinance GL is direct but the contract is narrow (batch posting). Multi-currency and tax-receipt calculation adds regulatory exposure but is contained.
  • Feasibility (7.5): 279 files — large but tractable. The implementation surface is concentrated in TGiftTransactionWebConnector + TGiftBatchFunctions + the AGift/AGiftBatch/AGiftDetail tables — three well-named seams. Typed dataset ORM is mechanically translatable to EF Core. The web connector RPC pattern maps cleanly to ASP.NET Core controllers. The posted/unposted batch state machine gives the strangler a natural routing key.
  • Strategic Value (8.5): Patterns proven here (typed-dataset to EF Core, ASMX to ASP.NET Core, jQuery to Angular reactive forms, batch posting workflow) are directly reusable for Finance — Accounting, Finance — Budgeting, and Finance — Banking — the three highest-affinity neighbors. Tax-receipt HTML templating exercises the PdfSharp cross-platform migration risk early.

Run 2 explicitly considered four smaller alternatives (Sponsorship — Child Management, Reporting — Custom Reports, Conference — Registration, Personnel — Staff Management) and rejected them as too niche or too cross-cutting to anchor a modernization story. Gift Processing's 279-file size was acknowledged as the principal feasibility headwind, but the implementation is concentrated in a small set of named classes — the file count overstates the cognitive surface.

Top 5 from Run 2:

RankSubsystemFilesRiskFeasibilityStrategic ValueScore
1Finance — Gift Processing2795.07.58.5+2.80
2Conference — Registration703.58.06.0+2.80
3Finance — Banking1775.57.07.5+2.15
4Conference — Event Management774.07.56.0+2.05
5Sponsorship — Child Management123.09.05.5+2.05

Note: Conference — Registration ties Gift Processing on raw score (+2.80) but loses the tiebreaker; modernizing the donation engine is the headline story for a non-profit ERP, not an event-management form.

Run 3 Results: Business Value Emphasis — Selected Finance — Gift Processing

Run 3 Recommendation: Finance — Gift Processing

Risk: 5.5  |  Feasibility: 7.5  |  Strategic Value: 9.5  |  Weighted Score: +2.90

Run 3's business-value lens produced the strongest strategic case for Gift Processing. Key findings:

  • Donor-facing visibility: Of all 27 subsystems, gift batches and tax receipts are the operational heartbeat of a non-profit. Demoing this modernized first lets sponsors prove ROI to boards before broader rollout — the strongest possible strategic narrative.
  • Pattern reusability: Gift Processing exercises the four "spine" patterns the rest of the modernization needs — typed DataSet ORM, code-generated DAL, ASMX web service, AngularJS jQuery form. Once those are cracked for Gift, Donations Processing (0.8 affinity), Banking (0.5), Accounting (0.6), and Financial Statements all reuse the same pipeline.
  • Boundary clarity: The Gift module has crisp seams (TGift*WebConnector, AGift* tables, motivation_group codes) — exactly what a strangler-fig migration with the FK-constraint-drop policy needs.
  • Right-sized scope: 279 files is large enough to be meaningful but small enough to finish in a defensible timeline; not a 370-file Accounting or 390-file Users boil-the-ocean exercise.

Run 3 explicitly evaluated Donations Processing (raw score +2.95) and Sponsorship — Program Management (+3.20) as higher-scoring alternatives but rejected both on grounds of substantive overlap with Gift (Donations is effectively the same target via shared files and 0.8 affinity) and insufficient scale (Sponsorship at 12 files is niche and depends on Gift's recurring-gift infrastructure to be meaningful).

Top 5 from Run 3:

RankSubsystemFilesRiskFeasibilityStrategic ValueScore
1Finance — Gift Processing2795.57.59.5+2.90
2Donations Processing*2795.07.59.0+2.95
3Sponsorship — Program Management*124.08.57.5+3.20
4Reporting — Custom Reports*204.08.56.5+2.90
5Finance — Budgeting1605.07.57.5+2.50

* Disqualified as pilot: Donations Processing substantively overlaps Gift (sibling slice, not independent); Sponsorship and Custom Reports are too small (12–20 files) or too cross-cutting to validate the modernization framework.

A.6 Consensus Analysis

Where the Three Runs Agree

  • Finance — Gift Processing and Finance — Banking are the two real options. All three runs ranked both subsystems inside their top 5. Run 2 had Banking at #3 (+2.15); Run 3 had Banking at #6 in its broader table (+1.60). Every run agrees these are the two credible first slices.
  • Tiny modules are not pilots. All three runs noted that Sponsorship — Program Management (12 files), Sponsorship — Child Management (12 files), and Hospitality — Accommodation (5 files) achieve high raw scores via trivially low risk but would not validate a modernization approach at enterprise scale.
  • Finance — Accounting is wrong for a first slice. All three runs scored Accounting in the lower half (Run 1: +0.30; Run 2: +0.35; Run 3: +1.20) due to its 370 files and deep integration web. Right for Phase 3, wrong for a pilot.
  • System Management subsystems are last. All three runs scored Settings (352 files) and Users (390 files) below zero or barely positive due to security criticality and system-wide coupling.

Where the Three Runs Diverge

FactorRun 1 (Standard)Run 2 (Feasibility)Run 3 (Business Value)Impact
Top recommendation Finance — Banking (+3.20) Finance — Gift Processing (+2.80) Finance — Gift Processing (+2.90) 2 of 3 runs select Gift Processing; Run 1 selects Banking on a lower-risk argument.
Gift Processing risk score 7.0 5.0 5.5 Run 1's risk reading is 2 points higher than Runs 2 and 3. This single 2-point swing accounts for most of the Gift score difference (8.0 vs 2.80–2.90 weighted impact).
Banking risk score 4.0 5.5 6.5 Inverse pattern: Run 1 reads Banking as low-risk (well-bounded standards-based parsing); Runs 2 and 3 read it as moderate-risk (SEPA/MT940/ISO 20022 standards parsing adds complexity).
Gift Processing strategic value 9.0 8.5 9.5 All three runs agree Gift Processing carries the highest strategic value in the system. Run 1 does not dispute this — it argues the risk premium outweighs the value premium for a first slice.
Banking strategic value 8.0 7.5 7.5 All three runs put Banking strategic value clearly below Gift, but within reach. Banking demonstrates external-standards integration patterns that are reusable elsewhere.

How the Divergence Was Resolved

The divergence is qualitative, not arithmetic — it reflects a genuine difference in risk-aversion philosophy between Run 1 and Runs 2/3. Run 1 reads Gift Processing's revenue centrality as a reason not to migrate it first (failure blast radius is too large); Runs 2 and 3 read the same fact as a reason to migrate it first (highest demonstration value, patterns flow downstream to the rest of MFinance).

The consensus methodology resolves this by majority rule: 2 of 3 runs select Gift Processing, so Gift Processing is the recommendation. Three reinforcing reasons support the majority:

  1. Run 1's risk premium is large but not decisive. Run 1 scores Gift Processing's risk at 7.0, two points above Runs 2 and 3. Even taking Run 1's risk reading at face value, Gift Processing's strategic-value advantage (+1.0 to +1.5 over Banking) and pattern-reusability premium offset most of the risk gap. The strangler-fig migration strategy (selected separately in the architecture plan) is specifically designed to contain blast radius via the batch-state seam — Run 1's risk argument is partially mitigated by the chosen migration pattern.
  2. Banking is a tactical pilot; Gift is a strategic pilot. Run 1's argument is correct that Banking is the safer first slice in isolation. But Banking's value is mostly tactical (proves bank-format parsing works). Gift Processing's value is strategic (proves the entire modernization spine works on the system's marquee workflow). The latter is what a pilot needs to demonstrate.
  3. Banking remains the credible Phase 2 target. All three runs rank Banking inside their top 6. Selecting Gift first does not abandon Banking — it sequences Banking as the natural Phase 2 follow-on, where the patterns proven on Gift accelerate the Banking slice.

When would Run 1's recommendation prevail? If pilot constraints shift toward strict first-slice risk minimization — for example, if the customer's board requires a low-blast-radius proof before authorizing a Gift Processing migration — the orchestrator can defer to Run 1 and pilot Banking first. The Run 1 case is documented credibly in section A.5 above precisely so that decision can be made transparently if needed.

A.7 Combined Three-Run Score Matrix — Top Candidates

Average scores across the three independent runs for the candidates that appeared in at least one Top 5.

SubsystemFilesRun 1Run 2Run 3AverageVerdict
Finance — Gift Processing279+1.70+2.80+2.90+2.47RECOMMENDED (2/3 consensus)
Finance — Banking177+3.20+2.15+1.60+2.32Run 1 winner; Phase 2 target
Finance — Budgeting160+2.70+1.35+2.50+2.18Mid-pack; not a pilot
Conference — Registration70+2.00+2.80+1.95+2.25Niche domain; rejected
Donations Processing279+1.10+2.00+2.95+2.02Sibling of Gift; same target
Sponsorship — Child Management12+3.00+2.05+3.05+2.70Too small for pilot
Sponsorship — Program Management12+3.00+2.80+3.20+3.00Too small for pilot
Hospitality — Accommodation5+3.10+3.05+2.70+2.95Too small for pilot (5 files)

A.8 Bottom-Tier Subsystems (Aggregated Across Runs)

Subsystems that all three runs scored low and rejected as pilot candidates:

SubsystemFilesAverage ScoreWhy Rejected
Finance — Accounting370+0.62Largest subsystem; GL spine; deep integration web; right for Phase 3, wrong for pilot.
Partner — Contacts Management211+1.07Foundational entity; every other module joins on PPartner. Migrate after pilot patterns are proven.
Partner — Persons210+1.23Foundation table; high blast radius.
Partner — Organizations203+1.07Partner-merge complexity; high coupling.
Reporting — Financial Statements201+1.42Reads from GL; modernize after Finance core.
System Management — Settings352−0.30Cross-cutting infrastructure; security-sensitive; do not pilot.
System Management — Users390−0.18Auth/security; broad coupling; defer.
System Management — Access58+0.55libsodium/scrypt security surface; defer.

A.9 Final Recommendation

Consensus Recommendation: Finance — Gift Processing

Consensus: 2 of 3 runs agree (majority — Runs 2 and 3); Run 1 selected Finance — Banking as a lower-risk alternative

RiskFeasibilityStrategic ValueWeighted Score
Run 1 (Standard) — selected Banking7.06.09.0+1.70
Run 2 (Feasibility) — selected Gift5.07.58.5+2.80
Run 3 (Business Value) — selected Gift5.57.59.5+2.90
Three-Run Average (Gift Processing)5.87.09.0+2.47

279 files  |  concentrated in TGiftTransactionWebConnector, TGiftBatchFunctions, AGift/AGiftBatch/AGiftDetail  |  high-affinity neighbors: Donations Processing (0.8), Finance — Accounting (0.6)

Recommended Modernization Sequence

  1. Phase 1 (Pilot): Finance — Gift Processing — Establishes modernization patterns on the marquee donor workflow. Validates strangler-fig approach via the posted/unposted batch-state seam. Proves the typed-dataset to EF Core, ASMX to ASP.NET Core, jQuery to Angular 20 spine on the system's most visible capability.
  2. Phase 2: Finance — Banking — Natural extension; bank imports feed gift batches. Leverages all patterns from Phase 1, plus adds external-standards integration (ISO 20022, SEPA, MT940). All three runs ranked Banking in the top 6; Run 1 ranked it first. The 2/3 majority defers Banking to Phase 2 but acknowledges Run 1's case that Banking is the safer first slice in isolation — the orchestrator can swap Phases 1 and 2 if customer constraints later demand a lower-blast-radius pilot.
  3. Phase 3: Finance — Accounting — The largest and most complex subsystem (370 files). Tackled after Phases 1–2 have de-risked the modernization patterns and built team confidence.
  4. Subsequent phases: Partner subsystems, Reporting, then System Management (deferred to last due to security sensitivity and system-wide coupling).

Risk Mitigation Strategies for the Recommended Pilot

Run 1's risk-premium argument against Gift Processing is partially addressed by the specific mitigations baked into the architecture plan and target stack:

  • Strangler-fig boundary at the WebConnector layer: TGiftTransactionWebConnector already provides an API facade. The new ASP.NET Core 10 service fronts this interface while the legacy system continues to handle GL posting and Partner lookups, containing blast radius.
  • Batch-boundary rollback: Gift processing operates in discrete batches (posted/unposted). If the modernized system encounters issues, the legacy batch-processing path remains available at the batch boundary. Each posted batch is an atomic recovery point.
  • FK-constraint-drop slice policy: The architecture plan's drop-constraints policy on slice boundaries means the Gift slice can be carved out of the shared PostgreSQL schema without entangling the entire MFinance schema, addressing Run 1's "deep MPartner coupling" concern.
  • Run 1 documented as fallback: If pilot constraints shift to demand strict first-slice risk minimization, the architecture is set up to swap Phases 1 and 2 and pilot Banking instead. The Run 1 case is documented credibly above to make that pivot transparent.

A.10 Benefits of Multi-Agent Approach (Demonstrated by This Run)

  • Surfaces real disagreement. A unanimous outcome is comforting but uninformative. This analysis produced a real 2-vs-1 split with a substantive argument behind the minority position. The methodology forced that disagreement into the open rather than letting a single analyst's framing prevail unexamined.
  • Quantifies a defensible majority decision. The 2/3 consensus on Gift Processing is supported by two independent analytical lenses (feasibility, business value); Run 1's dissent is documented but does not change the recommendation.
  • Produces a credible Plan B. Run 1's analysis is high-quality and worth preserving. If pilot constraints shift, the orchestrator has a fully-scored alternative ready to deploy rather than restarting candidate selection from scratch.
  • Reduces single-analyst bias. The three runs disagreed precisely because they applied different lenses — that's the methodology working as designed, not a defect.
  • Reproducible methodology. The same three-run process applies to subsequent modernization phases or different codebases.

A.11 Concho Query Usage Across All Runs

Query CategoryRun 1Run 2Run 3Purpose
Project Metadata111Project-level scale, language, LOC, domain distribution
Business Function Enumeration111Enumerate all 27 subsystems
Subject Profile Deep-Dives182222File counts, capability summaries, affinity scores per subsystem
File Discovery (legacy approach)9Pre-switch to get_subject_profile for compact data
Totals29242477 Concho queries across 3 runs

Comparison to Manual Approach

Without Concho, this three-run candidate selection would require a senior architect to manually review 572,757 lines of C#/XML/JS across the OpenPetra codebase, identify subsystem boundaries via using-statement archaeology, count files per module, and assess integration affinities by reading source — estimated at 3–5 engineer-days per run for partial coverage, or 9–15 engineer-days total for all three runs. With Concho, all three runs completed in approximately 25 minutes total (77 MCP queries, each returning pre-computed intelligence from the Concho Context Graph), with richer signal (cross-subject affinity scores, capability summaries, integration boundaries) than manual code archaeology could produce in a planning timeframe.

Appendix B: Target Service Architecture

TL;DR — Three independent perspectives (DDD, Technical, Business) each scored a two-service decomposition above the 7.0 quality gate (DDD 7.9, Technical 7.7, Business 7.9; composite 7.8). The recommended target architecture is Gift Processing API (8 owned tables, year-round steady load) plus Receipt Generation Service (2 owned tables, year-end bursty load), integrated via REST with Managed Identity. Bank Import and Motivation Administration remain inside Gift Processing API as internal handlers. Receipt Generation reads gifts via REST only — no shared database access.

B.1 Methodology

Service granularity for the Finance — Gift Processing subsystem was determined by running three independent architecture analyses in parallel, each applying a different lens to the same source-of-truth inputs (Section 4 fragment, the target-architecture handoff, and Concho codebase intelligence for cycle 23). Each lens produced a service-count recommendation, a service decomposition, and a self-assessed score against the four-dimension service-architecture rubric defined in the modernization-scoring skill (Operational Complexity 20%, Business Alignment 30%, Technical Soundness 30%, Change Velocity 20%).

Lens Primary Concern Inputs Emphasized
Domain-Driven Design Bounded context boundaries, aggregate cohesion, invariant ownership Concho bounded-context query (DonationManagement, confidence 0.84), aggregate roots from the data model, consistency-boundary analysis
Technical Architecture Code coupling, scalability characteristics, deployment independence File clustering across 279 source files, peak-load profiles, blast-radius analysis for releases
Business Capability User workflows, organizational ownership, value streams Concho capability profile, the five Angular SPA screens, organizational-owner mapping (finance ops vs. compliance/tax)

B.2 Lens 1 — Domain-Driven Design Proposal

Concho cycle 23 confirms that the Gift Processing subsystem contains exactly one bounded context, DonationManagement, at confidence 0.84. A naive reading would conclude one bounded context implies one service. DDD as a discipline does not equate the two: within a single context there can be multiple aggregate clusters with different invariants, lifecycles, and consistency boundaries. The DDD analysis therefore looked one level deeper, at aggregate cohesion.

B.2.1 Aggregate Cluster Analysis

Aggregate Cluster Aggregate Roots Invariant Type Lifecycle
Gift Lifecycle GiftBatch (+ Gift, GiftDetail), RecurringGiftBatch (+ RecurringGift, RecurringGiftDetail), MotivationGroup / MotivationDetail Transactional ACID. GiftBatch state machine (Unposted → Posted → Cancelled) is the strangler-fig routing key. Posting is atomic. Mutable while unposted; immutable once posted. Daily activity.
Receipt Issuance AnnualReceipt, ReceiptTemplate Idempotent document generation. One receipt per donor per tax year. Sequence numbers are append-only and auditable. Immutable once issued; reprints carry an audit-trail self-reference. Episodic (Jan–Feb peak).

B.2.2 DDD Decision Rationale

  • Different consistency boundaries. Gift posting is transactional ACID; receipt issuance is idempotent document generation. Mixing them inflates the transactional surface.
  • Different change vectors. Receipt templates and tax-jurisdiction rules churn independently from the gift batch state machine.
  • Different read/write characteristics. Gift Processing is mixed read/write under user-driven load; Receipt Generation is read-heavy bursty workload concentrated at year-end.
  • Document artifact lifecycle. PDFs are external artifacts with retention obligations distinct from row-level gift data.

B.2.3 Rejected DDD Alternatives

Alternative Why Rejected (DDD)
1 cohesive service (one bounded context = one service) Conflates aggregate clusters with different invariants. Posted-batch immutability and receipt-PDF immutability are different invariants that should not share a transactional surface.
Bank Import as a third service Bank Import is a workflow that writes the GiftBatch aggregate root. A separate service forces cross-service writes to GiftBatch — a textbook DDD anti-pattern.
Motivation Administration as a third service Motivation codes are reference data with no independent invariants. Every GiftDetail references a MotivationDetail; splitting forces a cross-service join on the hottest read path.
Recurring Gifts as a third service Recurring gifts share the same GiftDetail aggregate structure and motivation references as one-off gifts. The "recurring" attribute is a frequency, not a boundary.

DDD lens recommendation: 2 services. Self-assessed score 7.9 (OpComplexity 7, BusinessAlign 8, TechSoundness 9, ChangeVelocity 7).

B.3 Lens 2 — Technical Architecture Proposal

The Technical lens evaluated three dimensions in parallel: code coupling across the 279 source files, peak-load profiles for each capability, and deployment-independence requirements.

B.3.1 File-Clustering Analysis

Cluster Approx Files Coupling to Gift Tables Verdict
TGiftTransactionWebConnector family ~85 High — every endpoint touches AGiftBatch/AGift/AGiftDetail Keep together (Gift Processing API)
TGiftBatchFunctions + date/period/posting logic ~40 High — tightly coupled to the GiftBatch state machine Keep with above (Gift Processing API)
Annual receipt HTML templating + PDF generation ~30 Read-only coupling to posted gifts Separable (Receipt Generation Service)
Typed-dataset boilerplate + shared utilities ~120 N/A — regenerated from petra.xml Becomes EF Core entities / shared NuGet packages; does not drive service boundaries

B.3.2 Load-Profile Analysis

Workload Peak Driver Pattern Sizing Implication
Gift batch entry & posting User-driven, business hours Mixed read/write; bursty around month-end Steady; autoscale 2–4 instances
Bank import parsing Daily/weekly file drops Bursty read+write; CPU-bound parse Same service as Gift Processing (same write path)
Recurring gift cycle Monthly/quarterly Batched write Same service
Receipt PDF generation Year-end spike (Jan–Feb) Read-heavy + CPU/memory for PDF rendering Different scaling profile — warrants its own App Service that scales 1–6 instances during the year-end window

B.3.3 Deployment Independence

Gift Processing changes carry regression risk against the posting state machine and require slot-swap validation. Receipt Generation changes are template-driven (HTML, jurisdiction-specific tax rules) with low blast radius and ship more frequently. Coupling them forces every receipt-template tweak to redeploy the transactional core — an unnecessary operational tax.

Technical lens recommendation: 2 services. Self-assessed score 7.7 (OpComplexity 7, BusinessAlign 7, TechSoundness 9, ChangeVelocity 8).

B.4 Lens 3 — Business Capability Proposal

The Business lens scored capabilities by user workflow boundaries and the organizational owner. Non-profit finance teams have two distinct jobs-to-be-done in this subsystem.

B.4.1 Jobs-to-be-Done

Job Cadence Organizational Owner SPA Screens
Job 1: Process incoming donations Year-round, daily Finance operations / donor services Gift Batches, Recurring Gifts, Motivations, Bank Import
Job 2: Issue annual tax receipts Year-end, episodic (Jan–Feb peak) Compliance / tax reporting (often different humans than Job 1) Annual Receipts

B.4.2 Business Decision Rationale

  • Different workflows, different cadences, different humans. Treating them as separate services aligns the technology to how the business actually operates.
  • Brand surface. The receipt PDF is the one place in this subsystem where the non-profit's brand touches the donor directly. Coupling its release cycle to the transactional core is risky — a template hotfix during peak season should not require regression-testing the posting state machine.
  • UI symmetry. The Angular UI naturally separates Annual Receipts into its own screen with its own information architecture. Backing it with its own service makes the UI/API symmetry clean and the audit story straightforward.

B.4.3 Rejected Business Alternatives

Alternative Why Rejected (Business)
1 cohesive service Possible but ties compliance team's release cadence to finance ops team's release cadence. Coupled blast radius for template changes.
Bank Import as a separate service Splits a single user workflow ("import the bank file, review the batch, post it") across services and screens. Bank Import flows directly into Gift Batches.
Motivation Administration as a separate service Adds a cross-service call to every gift entry save — poor UX. Administration is rare; the use of motivation codes is constant.

Business lens recommendation: 2 services. Self-assessed score 7.9 (OpComplexity 6, BusinessAlign 9, TechSoundness 8, ChangeVelocity 8).

B.5 Cross-Lens Scoring Matrix

Criterion Weight DDD Technical Business Average
Operational Complexity 20% 7 7 6 6.7
Business Alignment 30% 8 7 9 8.0
Technical Soundness 30% 9 9 8 8.7
Change Velocity 20% 7 8 8 7.7
Weighted Total 7.9 7.7 7.9 7.8

All three lenses score above the 7.0 quality gate. The DDD and Business proposals tied at 7.9; the Technical proposal sat 0.2 below them. Per the modernization-scoring skill tiebreaker rule (scores within 0.5 points prefer business alignment), the Business proposal wins by procedure, but the practical result is moot — all three lenses propose the same two services with the same boundaries and the same answers to the four open questions. The convergence is genuine, not procedural.

B.6 Open-Question Resolutions

Question DDD Technical Business Consensus
Final service count 2 2 2 2
Receipt data access API-only API-only API-only API-only
Async pathway Optional On-demand only Optional Optional / synchronous at pilot; Service Bus available for future bulk scaling
Bank Import as separate service? No No No No — internal handler inside Gift Processing API
Motivation Admin as separate service? No No No No — internal handler inside Gift Processing API

B.7 Target Service Specifications

B.7.1 Gift Processing API

Attribute Value
Purpose Process incoming donations end-to-end — from batch entry or bank-file import, through validation and tax-deductibility calculation, to posting against the GL via the strangler-fig bridge.
Organizational owner Finance Operations / Donor Services team.
Business capabilities Gift batch CRUD (single + bulk); bank statement import (CAMT v053, MT940, CSV, ZIP); recurring gift management with SEPA mandates; motivation administration; posting state machine (Unposted → Posted → Cancelled); multi-currency handling; tax-deductibility percentage calculation.
Owned tables gift_batch, gift, gift_detail, motivation_group, motivation_detail, recurring_gift_batch, recurring_gift, recurring_gift_detail (8 tables, per Section 4.4).
Upstream Angular 20 SPA (Gift Batches, Recurring Gifts, Motivations, Bank Import screens); bank file uploads via Blob + parser pipeline.
Downstream Legacy Partner Service (read-only validation via APIM strangler bridge); Legacy GL Service (one-way posting via APIM strangler bridge).
Azure resources Azure App Service P1v3 (autoscale 2–4); Azure Database for PostgreSQL (Flexible Server, General Purpose 2 vCores); Azure Cache for Redis (motivation lookups, exchange rates); Azure Blob Storage (bank-file archive); Azure Key Vault; Application Insights.
Auth (user) Microsoft Entra ID JWT Bearer.
Auth (service-to-service inbound) Managed Identity from Receipt Generation Service, scope GiftProcessing.Read.Posted.
Scaling profile Year-round steady with month-end bursts; autoscale rule: CPU > 65% sustained 5 min → scale out by 1, max 4 instances.
B.7.1.1 REST Contract (representative endpoints)
# Gift batches
GET    /api/gift-processing/v1/batches?ledgerId=&status=&year=&period=
POST   /api/gift-processing/v1/batches
GET    /api/gift-processing/v1/batches/{batchId}
PATCH  /api/gift-processing/v1/batches/{batchId}
DELETE /api/gift-processing/v1/batches/{batchId}                    (only when Unposted)

# Gifts and details within a batch
POST   /api/gift-processing/v1/batches/{batchId}/gifts
PATCH  /api/gift-processing/v1/batches/{batchId}/gifts/{giftId}
POST   /api/gift-processing/v1/batches/{batchId}/gifts/{giftId}/details

# Posting state machine (the strangler routing key)
POST   /api/gift-processing/v1/batches/{batchId}/post
POST   /api/gift-processing/v1/batches/{batchId}/cancel

# Bank statement import (CAMT v053, MT940, CSV, ZIP)
POST   /api/gift-processing/v1/bank-imports        (multipart upload)
GET    /api/gift-processing/v1/bank-imports/{importId}

# Recurring gifts & SEPA mandates
GET    /api/gift-processing/v1/recurring-gifts?donorPartnerKey=
POST   /api/gift-processing/v1/recurring-gifts
PATCH  /api/gift-processing/v1/recurring-gifts/{recurringGiftId}

# Motivation administration
GET    /api/gift-processing/v1/motivations
POST   /api/gift-processing/v1/motivations
PATCH  /api/gift-processing/v1/motivations/{groupCode}/{detailCode}

# Read path consumed by Receipt Generation Service
GET    /api/gift-processing/v1/donors/{partnerKey}/posted-gifts?year=&ledgerId=
  
B.7.1.2 Sample C# Endpoint (Minimal API)
// Program.cs — Gift Processing API endpoint group
var batches = app.MapGroup("/api/gift-processing/v1/batches")
                 .RequireAuthorization()
                 .WithOpenApi();

batches.MapPost("/{batchId:int}/post", async (
    int batchId,
    IGiftBatchPostingService posting,
    ClaimsPrincipal user,
    CancellationToken ct) =>
{
    var result = await posting.PostBatchAsync(batchId, user.GetUserId(), ct);
    return result switch
    {
        PostingResult.Success s        => Results.Ok(s.PostedBatch),
        PostingResult.AlreadyPosted    => Results.Conflict(new ProblemDetails {
            Title = "Batch already posted",
            Status = StatusCodes.Status409Conflict
        }),
        PostingResult.PeriodClosed pc  => Results.Problem(
            title:  "Posting period closed",
            detail: $"Period {pc.Year}/{pc.Period} is closed for posting.",
            statusCode: StatusCodes.Status422UnprocessableEntity),
        PostingResult.PartnerInvalid p => Results.Problem(
            title: "Partner validation failed",
            detail: string.Join("; ", p.InvalidDonorKeys),
            statusCode: StatusCodes.Status422UnprocessableEntity),
        _                              => Results.Problem("Unknown posting result")
    };
})
.WithName("PostGiftBatch")
.Produces<GiftBatchDto>(200)
.Produces<ProblemDetails>(409)
.Produces<ProblemDetails>(422);
  
B.7.1.3 OpenAPI Excerpt — POST a Gift Batch
paths:
  /api/gift-processing/v1/batches:
    post:
      summary: Create a new (unposted) gift batch
      operationId: createGiftBatch
      tags: [GiftBatches]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateGiftBatchRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GiftBatchDto'
        '422':
          description: Validation failure
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
components:
  schemas:
    CreateGiftBatchRequest:
      type: object
      required: [ledgerId, batchDescription, glEffectiveDate, currencyCode]
      properties:
        ledgerId:           { type: integer, example: 1 }
        batchDescription:   { type: string,  maxLength: 80 }
        glEffectiveDate:    { type: string,  format: date }
        currencyCode:       { type: string,  pattern: '^[A-Z]{3}$' }
        exchangeRateToBase: { type: number,  format: double }
  

B.7.2 Receipt Generation Service

Attribute Value
Purpose Generate annual tax-deductible receipt PDFs from posted gifts. Owns the HTML template registry, PDF rendering pipeline, and the receipt issuance audit trail.
Organizational owner Compliance / Tax Reporting team.
Business capabilities On-demand single-donor receipt generation; bulk annual receipt run for an entire ledger/year; HTML template management per tax jurisdiction; donor statement export (CSV/PDF); receipt sequence number assignment; reprint with audit trail.
Owned tables receipt_issuance, receipt_template (2 tables, added by this service).
Upstream Angular 20 SPA (Annual Receipts screen); optional cron trigger for year-end bulk runs.
Downstream Gift Processing API (GET posted-gifts via REST); Azure Blob Storage (PDF write, immutable retention policy).
Azure resources Azure App Service P1v3 (autoscale 1–6, scales to 6 only during Jan–Feb year-end window); Azure Database for PostgreSQL (small footprint, shares server with Gift Processing); Azure Blob Storage (PDF artifacts); Azure Service Bus (provisioned on demand, not by default).
Auth (user) Microsoft Entra ID JWT Bearer.
Auth (service-to-service outbound) Managed Identity to Gift Processing API.
Scaling profile Episodic: scales 1→6 during year-end window; idles at 1 instance the rest of the year. Autoscale rule: queue depth > 50 OR CPU > 70% → scale out.
B.7.2.1 REST Contract
# Single-donor receipt (synchronous)
POST /api/receipts/v1/receipts
  Body: { "donorPartnerKey": 12345, "taxYear": 2025, "ledgerId": 1, "jurisdictionCode": "DE" }
  201:  { "receiptId": "...", "sequenceNumber": "DE-2025-0042178",
          "blobUrl": "...", "issuedAt": "..." }

# Bulk annual run (asynchronous)
POST /api/receipts/v1/runs
  Body: { "ledgerId": 1, "taxYear": 2025, "jurisdictionCode": "DE" }
  202:  { "runId": "...", "statusUrl": "/api/receipts/v1/runs/{runId}" }

GET  /api/receipts/v1/runs/{runId}
  200:  { "runId": "...", "status": "InProgress|Completed|Failed",
          "totalDonors": 4821, "completed": 1840, "failed": 0,
          "startedAt": "...", "completedAt": null }

# Receipt retrieval
GET  /api/receipts/v1/receipts/{receiptId}
  200:  { "receiptId": "...", "donorPartnerKey": 12345,
          "taxYear": 2025, "sequenceNumber": "DE-2025-0042178",
          "blobUrl": "", "issuedAt": "...",
          "reissuedFrom": null }

# Reprint with audit trail
POST /api/receipts/v1/receipts/{receiptId}:reissue
  Body: { "reason": "Donor address corrected" }
  201:  { "receiptId": "", "sequenceNumber": "DE-2025-0042178-R1",
          "blobUrl": "...", "reissuedFrom": "" }

# Template registry
GET  /api/receipts/v1/templates?jurisdiction=&language=
PUT  /api/receipts/v1/templates/{templateId}
  
B.7.2.2 C# Interface for the Posted-Gifts Read Path
// Receipt Generation Service — outbound client to Gift Processing API
public interface IGiftProcessingClient
{
    Task<DonorPostedGifts> GetPostedGiftsAsync(
        long  partnerKey,
        int   taxYear,
        int   ledgerId,
        CancellationToken ct);
}

public sealed class GiftProcessingClient : IGiftProcessingClient
{
    private readonly HttpClient _http;        // Polly-decorated via AddHttpClient
    private readonly ILogger<GiftProcessingClient> _log;

    public GiftProcessingClient(HttpClient http, ILogger<GiftProcessingClient> log)
    {
        _http = http;
        _log  = log;
    }

    public async Task<DonorPostedGifts> GetPostedGiftsAsync(
        long partnerKey, int taxYear, int ledgerId, CancellationToken ct)
    {
        var url = $"/api/gift-processing/v1/donors/{partnerKey}/posted-gifts" +
                  $"?year={taxYear}&ledgerId={ledgerId}";

        var response = await _http.GetAsync(url, ct);
        response.EnsureSuccessStatusCode();

        var payload = await response.Content
            .ReadFromJsonAsync<DonorPostedGifts>(cancellationToken: ct);

        return payload
            ?? throw new InvalidOperationException("Empty posted-gifts payload");
    }
}

// DI registration with Polly resilience pipeline
builder.Services.AddHttpClient<IGiftProcessingClient, GiftProcessingClient>(client =>
    {
        client.BaseAddress = new Uri(builder.Configuration["GiftProcessing:BaseUrl"]!);
        client.DefaultRequestHeaders.Add("Accept", "application/json");
    })
    .AddPolicyHandler(GiftProcessingPolicies.RetryWithExponentialBackoff())
    .AddPolicyHandler(GiftProcessingPolicies.CircuitBreaker())
    .AddHttpMessageHandler<ManagedIdentityAuthHandler>();
  
B.7.2.3 Optional Async Envelope (Azure Service Bus, when provisioned)
# Azure Service Bus topic: receipts-bulk-run
# Subscription: receipt-generation-service
# Used only when bulk-run mode is > ~30 min synchronous or durable retry is required.

{
  "specversion": "1.0",
  "type":        "petra.receipts.donor-receipt-requested.v1",
  "source":      "/receipts/runs/r-2025-de-001",
  "id":          "msg-7c8b1a3e",
  "time":        "2026-01-15T03:42:11Z",
  "subject":     "donor/12345",
  "datacontenttype": "application/json",
  "data": {
    "runId":            "r-2025-de-001",
    "donorPartnerKey":  12345,
    "taxYear":          2025,
    "ledgerId":         1,
    "jurisdictionCode": "DE",
    "languageCode":     "de-DE"
  },
  "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
  

B.8 Target Integration Architecture

graph LR
    SPA["Angular 20 SPA
(5 screens)"] FD["Azure Front Door
(WAF + TLS)"] APIM["Azure API Management
(strangler-fig routing)"] subgraph "Gift Processing (year-round, autoscale 2-4)" GIFT["Gift Processing API
(.NET 10 Minimal API)"] end subgraph "Receipt Generation (episodic, autoscale 1-6)" RCPT["Receipt Generation Service
(.NET 10)"] end PG[("Azure Database
for PostgreSQL")] REDIS["Azure Cache
for Redis"] BLOB["Azure Blob Storage
(bank files +
receipt PDFs)"] SB["Azure Service Bus
(optional, on-demand)"] LEGACY_PARTNER["Legacy Partner Service
(read-only)"] LEGACY_GL["Legacy GL Service
(one-way posting)"] SPA --> FD FD --> APIM APIM -->|"/api/gift-processing/*"| GIFT APIM -->|"/api/receipts/*"| RCPT GIFT --> PG GIFT --> REDIS GIFT --> BLOB GIFT -.->|"REST + Polly"| LEGACY_PARTNER GIFT -.->|"REST + Polly"| LEGACY_GL RCPT --> PG RCPT --> BLOB RCPT -->|"REST + Managed Identity
GET posted-gifts"| GIFT RCPT -.->|"optional, on-demand"| SB SB -.->|"optional"| RCPT classDef modern fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px classDef azure fill:#e3f2fd,stroke:#1565c0,stroke-width:2px classDef legacy fill:#fff3e0,stroke:#e65100,stroke-width:1px,stroke-dasharray:5 classDef client fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px class SPA client class GIFT,RCPT modern class FD,APIM,PG,REDIS,BLOB,SB azure class LEGACY_PARTNER,LEGACY_GL legacy

Receipt Generation Service depends on Gift Processing API only via REST. Gift Processing API has zero awareness of receipt issuance — this is a strictly one-way dependency. Both services sit behind the same Azure API Management instance, share the same authentication surface (Microsoft Entra ID), and emit telemetry to the same Application Insights workspace. The optional Azure Service Bus path is dashed because it is provisioned only when bulk-run volume justifies it.

B.9 Implementation Roadmap

Iteration Weeks Deliverable Service
1 1–2 Repository skeleton: two .NET 10 solutions, Bicep templates for App Service + PostgreSQL + Blob + Key Vault + Redis, GitHub Actions baseline (build → test → deploy-staging → smoke → swap) Shared
2 3–5 gift_batch + gift + gift_detail CRUD, posting state machine, EF Core migrations, FluentValidation rules Gift Processing API
3 6–7 Motivation administration + recurring gifts + SEPA mandate handling Gift Processing API
4 8–9 Bank import: CAMT v053 / MT940 / CSV / ZIP parser ports + Blob upload pipeline Gift Processing API
5 10–11 APIM strangler bridge wiring: Legacy Partner read-only validation + Legacy GL one-way posting (Polly retry + circuit breaker) Gift Processing API
6 12–13 Template registry + single-donor receipt endpoint + HTML rendering pipeline (Razor / PuppeteerSharp container) Receipt Generation Service
7 14–15 Bulk annual receipt run with synchronous progress reporting (Service Bus deferred unless needed) Receipt Generation Service
8 16 End-to-end Playwright + integration tests; slot-swap rehearsal; observability dashboards; runbook Shared

B.10 Quality Gate Result

Three lenses; three independent scores; all three above the 7.0 quality gate; convergent answers to all five open questions raised by the target-architecture phase. Composite score 7.8 / 10. Quality gate: PASS.

Appendix C: Deployment and Infrastructure

TL;DR — Deployment target is Azure App Service (Premium V3, Linux) with slot-swap blue/green. Two App Services back the two-service decomposition: petra-giftprocessing-api (P1v3, autoscale 2–4) and petra-receipts-api (P1v3, autoscale 1–6, scales only during the Jan–Feb year-end window). Infrastructure-as-code is Bicep; CI/CD is GitHub Actions (build → test → deploy-staging → smoke → slot-swap-to-prod). Observability is OpenTelemetry .NET SDK → Application Insights; secrets live in Azure Key Vault, surfaced via App Service Key Vault references; service-to-service auth is Managed Identity.

C.1 Deployment Overview

The runtime topology for Petra Finance — Gift Processing modernization is intentionally lean: two App Service plans, one Azure Database for PostgreSQL Flexible Server (shared across both services with separate schemas), one Azure Service Bus namespace (provisioned on demand), one Azure API Management instance (the strangler-fig surface), one Azure Cache for Redis, two Azure Blob Storage containers (SEPA exports + receipt PDFs), and one Application Insights workspace. Everything is provisioned through Bicep modules; nothing is created by hand.

The two App Services map 1:1 to the services defined in Appendix B: Gift Processing API hosts the .NET 10 Minimal API for batch entry, posting, recurring gifts, bank import, and motivation administration; Receipt Generation Service hosts the .NET 10 Minimal API plus QuestPDF rendering for annual tax receipts. Both services share the PostgreSQL server but own separate schemas (gift_processing, receipts) and connect via separate Managed Identities — the receipt service has no gift_processing schema permissions and reaches gift data only over REST.

Topology Diagram

flowchart TD
    SPA["Angular 20 SPA
(static in App Service)"] AFD["Azure Front Door
+ WAF policies"] APIM["Azure APIM
(strangler-fig surface)"] GIFT["Gift Processing API
App Service P1v3
slots: staging / prod"] RECEIPT["Receipt Generation API
App Service P1v3
autoscale 1-6 (Jan-Feb)"] LEGACY["Legacy Petra
.asmx (Mono)"] PG[("Azure PostgreSQL
Flexible Server
gift_proc + receipts schemas")] SB["Azure Service Bus
(on-demand, optional)"] BLOB[("Blob Storage
receipts + SEPA exports")] AI["Application Insights
+ Log Analytics workspace"] SPA --> AFD --> APIM APIM -->|"per-endpoint traffic shift"| GIFT APIM -->|"strangler routing"| LEGACY GIFT -->|"HTTP (Managed Identity)"| LEGACY GIFT --> PG GIFT -->|"HTTP (MI)"| RECEIPT RECEIPT --> BLOB RECEIPT --> SB SPA -. OTLP .-> AI GIFT -. OTLP .-> AI RECEIPT -. OTLP .-> AI

C.2 Infrastructure as Code (Bicep)

Infrastructure is declared in modular Bicep at infra/main.bicep with per-resource modules under infra/modules/. Below is the orchestrating main.bicep followed by the App Service module for Gift Processing API; the equivalent Receipt Generation module differs only in the resource names, autoscale window, and an extra Blob role assignment.

C.2.1 main.bicep

// infra/main.bicep
targetScope = 'subscription'

@description('Environment short name: dev, stg, prd')
param environment string = 'stg'

@description('Azure region')
param location string = 'westeurope'

@description('Project tag applied to every resource')
param project string = 'petra-gift'

var resourceGroupName = 'rg-${project}-${environment}'
var tags = {
  project:     project
  environment: environment
  owner:       'finance-modernization'
  costCenter:  'cc-modernization-2026'
}

resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
  name:     resourceGroupName
  location: location
  tags:     tags
}

module logAnalytics './modules/log-analytics.bicep' = {
  name:  'log-analytics'
  scope: rg
  params: {
    workspaceName: 'log-${project}-${environment}'
    location:      location
    tags:          tags
    retentionDays: 90
  }
}

module appInsights './modules/app-insights.bicep' = {
  name:  'app-insights'
  scope: rg
  params: {
    name:                'appi-${project}-${environment}'
    location:            location
    tags:                tags
    workspaceResourceId: logAnalytics.outputs.workspaceId
    samplingPercentage:  100
  }
}

module keyVault './modules/key-vault.bicep' = {
  name:  'key-vault'
  scope: rg
  params: {
    name:              'kv-${project}-${environment}'
    location:          location
    tags:              tags
    enableRbac:        true
    purgeProtection:   true
    softDeleteDays:    90
  }
}

module postgres './modules/postgres-flexible.bicep' = {
  name:  'postgres-flexible'
  scope: rg
  params: {
    name:                'psql-${project}-${environment}'
    location:            location
    tags:                tags
    sku:                 'Standard_D2ds_v5'
    storageGb:           32
    backupRetentionDays: 7
    aadAdminLogin:       'finance-mod-admins'
    aadAdminObjectId:    aadAdminGroupObjectId
  }
}

module serviceBus './modules/service-bus.bicep' = if (provisionServiceBus) {
  name:  'service-bus'
  scope: rg
  params: {
    namespaceName: 'sb-${project}-${environment}'
    location:      location
    tags:          tags
    sku:           'Standard'
    topicName:     'petra-receipts'
  }
}

module redis './modules/redis-cache.bicep' = {
  name:  'redis-cache'
  scope: rg
  params: {
    name:     'redis-${project}-${environment}'
    location: location
    tags:     tags
    sku:      'Standard'
    family:   'C'
    capacity: 1
  }
}

module storage './modules/storage-account.bicep' = {
  name:  'storage'
  scope: rg
  params: {
    name:     'st${replace(project, '-', '')}${environment}'
    location: location
    tags:     tags
    containers: [
      'receipts'
      'sepa-exports'
      'bank-statements'
    ]
    immutableRetentionDays: 2555  // 7-yr tax receipt retention
  }
}

module appSvcPlan './modules/app-service-plan.bicep' = {
  name:  'app-svc-plan'
  scope: rg
  params: {
    name:     'asp-${project}-${environment}'
    location: location
    tags:     tags
    sku:      'P1v3'
    capacity: 2
  }
}

module giftApi './modules/app-service-dotnet.bicep' = {
  name:  'gift-processing-api'
  scope: rg
  params: {
    appServiceName:      'app-gift-${environment}'
    planResourceId:      appSvcPlan.outputs.planId
    location:            location
    tags:                tags
    keyVaultName:        keyVault.outputs.name
    appInsightsKey:      appInsights.outputs.connectionString
    pgServerFqdn:        postgres.outputs.fqdn
    pgSchema:            'gift_processing'
    redisHost:           redis.outputs.host
    storageAccount:      storage.outputs.name
    storageContainers:   [ 'sepa-exports', 'bank-statements' ]
    autoscaleMin:        2
    autoscaleMax:        4
    healthCheckPath:     '/health/ready'
  }
}

module receiptsApi './modules/app-service-dotnet.bicep' = {
  name:  'receipts-api'
  scope: rg
  params: {
    appServiceName:      'app-receipts-${environment}'
    planResourceId:      appSvcPlan.outputs.planId
    location:            location
    tags:                tags
    keyVaultName:        keyVault.outputs.name
    appInsightsKey:      appInsights.outputs.connectionString
    pgServerFqdn:        postgres.outputs.fqdn
    pgSchema:            'receipts'
    redisHost:           redis.outputs.host
    storageAccount:      storage.outputs.name
    storageContainers:   [ 'receipts' ]
    autoscaleMin:        1
    autoscaleMax:        6
    healthCheckPath:     '/health/ready'
    extraAppSettings: {
      'GiftProcessing__BaseUrl':
        'https://app-gift-${environment}.azurewebsites.net'
      'GiftProcessing__Scope':
        'api://gift-${environment}/.default'
    }
  }
}

module apim './modules/api-management.bicep' = {
  name:  'apim'
  scope: rg
  params: {
    name:                'apim-${project}-${environment}'
    location:            location
    tags:                tags
    publisherEmail:      'modernization@example.org'
    publisherName:       'Finance Modernization'
    sku:                 'Developer'
    legacyBackendUrl:    'https://legacy-petra.example.org'
    modernBackendUrl:    giftApi.outputs.defaultHostname
    stranglerRoutingKey: 'posting-status'
  }
}

param aadAdminGroupObjectId string
param provisionServiceBus bool = false

C.2.2 app-service-dotnet.bicep (the reusable App Service module)

// infra/modules/app-service-dotnet.bicep
param appServiceName string
param planResourceId string
param location string
param tags object
param keyVaultName string
param appInsightsKey string
param pgServerFqdn string
param pgSchema string
param redisHost string
param storageAccount string
param storageContainers array
param autoscaleMin int
param autoscaleMax int
param healthCheckPath string
param extraAppSettings object = {}

var baseAppSettings = {
  'ASPNETCORE_ENVIRONMENT':         'Production'
  'ASPNETCORE_FORWARDEDHEADERS_ENABLED': 'true'

  // OpenTelemetry
  'OTEL_SERVICE_NAME':              appServiceName
  'OTEL_EXPORTER_OTLP_ENDPOINT':
    'https://${location}.applicationinsights.azure.com/v2/track'
  'APPLICATIONINSIGHTS_CONNECTION_STRING': appInsightsKey
  'OTEL_RESOURCE_ATTRIBUTES':
    'deployment.environment=${tags.environment}'
  'LOG_LEVEL':                      'Information'

  // PostgreSQL (Managed Identity, no password)
  'ConnectionStrings__GiftDb':
    'Host=${pgServerFqdn};Database=petra;Username=${appServiceName};SslMode=Require;Trust Server Certificate=true'
  'Postgres__Schema':               pgSchema
  'Postgres__UseManagedIdentity':   'true'

  // Redis
  'Redis__Host':                    redisHost
  'Redis__UseManagedIdentity':      'true'

  // Storage
  'Storage__AccountName':           storageAccount
  'Storage__Containers':            join(storageContainers, ',')

  // Key Vault references (resolved at runtime by App Service)
  'Entra__TenantId':
    '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=EntraTenantId)'
  'Entra__ClientId':
    '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=${appServiceName}-ClientId)'
}

resource app 'Microsoft.Web/sites@2024-04-01' = {
  name:     appServiceName
  location: location
  tags:     tags
  kind:     'app,linux'
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId:                planResourceId
    httpsOnly:                   true
    clientAffinityEnabled:       false
    publicNetworkAccess:         'Enabled'
    keyVaultReferenceIdentity:   'SystemAssigned'
    siteConfig: {
      linuxFxVersion:            'DOTNETCORE|8.0'
      alwaysOn:                  true
      http20Enabled:             true
      ftpsState:                 'Disabled'
      minTlsVersion:             '1.2'
      healthCheckPath:           healthCheckPath
      appSettings: [for setting in items(union(baseAppSettings, extraAppSettings)): {
        name:  setting.key
        value: setting.value
      }]
    }
  }
}

resource stagingSlot 'Microsoft.Web/sites/slots@2024-04-01' = {
  parent:   app
  name:     'staging'
  location: location
  tags:     tags
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: planResourceId
    httpsOnly:    true
    siteConfig: {
      linuxFxVersion: 'DOTNETCORE|8.0'
      alwaysOn:       true
      healthCheckPath: healthCheckPath
      appSettings: [for setting in items(union(baseAppSettings, extraAppSettings)): {
        name:  setting.key
        value: setting.value
      }]
    }
  }
}

resource autoscale 'Microsoft.Insights/autoscalesettings@2022-10-01' = {
  name:     'autoscale-${appServiceName}'
  location: location
  tags:     tags
  properties: {
    enabled:          true
    targetResourceUri: planResourceId
    profiles: [
      {
        name: 'default'
        capacity: {
          minimum: string(autoscaleMin)
          maximum: string(autoscaleMax)
          default: string(autoscaleMin)
        }
        rules: [
          {
            metricTrigger: {
              metricName: 'CpuPercentage'
              metricResourceUri: planResourceId
              timeGrain: 'PT1M'
              statistic: 'Average'
              timeWindow: 'PT5M'
              timeAggregation: 'Average'
              operator: 'GreaterThan'
              threshold: 70
            }
            scaleAction: {
              direction: 'Increase'
              type: 'ChangeCount'
              value: '1'
              cooldown: 'PT5M'
            }
          }
          {
            metricTrigger: {
              metricName: 'CpuPercentage'
              metricResourceUri: planResourceId
              timeGrain: 'PT1M'
              statistic: 'Average'
              timeWindow: 'PT10M'
              timeAggregation: 'Average'
              operator: 'LessThan'
              threshold: 25
            }
            scaleAction: {
              direction: 'Decrease'
              type: 'ChangeCount'
              value: '1'
              cooldown: 'PT10M'
            }
          }
        ]
      }
    ]
  }
}

output principalId string = app.identity.principalId
output defaultHostname string = app.properties.defaultHostName

C.2.3 Key Vault references & Managed-Identity role assignments

// infra/modules/role-assignments.bicep
param keyVaultName string
param postgresServerName string
param storageAccountName string
param redisName string
param appPrincipalId string

// 1. Key Vault Secrets User
resource kvSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(keyVaultName, appPrincipalId, 'kv-secrets-user')
  scope: resourceId('Microsoft.KeyVault/vaults', keyVaultName)
  properties: {
    principalId:      appPrincipalId
    principalType:    'ServicePrincipal'
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '4633458b-17de-408a-b874-0445c86b69e6')  // Key Vault Secrets User
  }
}

// 2. PostgreSQL: AAD-authenticated app login
//    (the actual SQL GRANT statements run as a post-deploy step via az cli)

// 3. Storage Blob Data Contributor
resource storageContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(storageAccountName, appPrincipalId, 'storage-blob-contrib')
  scope: resourceId('Microsoft.Storage/storageAccounts', storageAccountName)
  properties: {
    principalId:      appPrincipalId
    principalType:    'ServicePrincipal'
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      'ba92f5b4-2d11-453d-a403-e96b0029c9fe')  // Storage Blob Data Contributor
  }
}

// 4. Redis Data Contributor
resource redisContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(redisName, appPrincipalId, 'redis-data-contrib')
  scope: resourceId('Microsoft.Cache/redis', redisName)
  properties: {
    principalId:      appPrincipalId
    principalType:    'ServicePrincipal'
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      'e0f68234-74aa-48ed-b826-c38b57376e17')  // Redis Cache Contributor
  }
}

C.3 CI/CD Pipeline (GitHub Actions)

The pipeline runs on every push to main and on PRs. Deploy steps use Azure OIDC federated credentials (no service-principal secrets); the production swap is gated by a deployment-environment approval.

# .github/workflows/deploy-gift-api.yml
name: Deploy Gift Processing API

on:
  push:
    branches: [ main ]
    paths:
      - 'src/Petra.GiftProcessing/**'
      - 'infra/**'
      - '.github/workflows/deploy-gift-api.yml'
  workflow_dispatch:

permissions:
  id-token: write   # OIDC federated credential
  contents: read

env:
  AZURE_RG:           rg-petra-gift-prd
  APP_NAME:           app-gift-prd
  DOTNET_VERSION:     '8.0.x'
  BICEP_FILE:         infra/main.bicep
  ARTIFACT_NAME:      petra-gift-api

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}
      - run: dotnet restore src/Petra.GiftProcessing.sln
      - run: dotnet build src/Petra.GiftProcessing.sln -c Release --no-restore
      - run: dotnet test  src/Petra.GiftProcessing.sln -c Release --no-build
                          --logger trx --collect:"XPlat Code Coverage"
      - run: dotnet publish src/Petra.GiftProcessing/Petra.GiftProcessing.csproj
                            -c Release -o ./publish --no-build
      - uses: actions/upload-artifact@v4
        with:
          name: ${{ env.ARTIFACT_NAME }}
          path: ./publish

  infra:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id:       ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id:       ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: What-if (preview)
        run: |
          az deployment sub what-if \
            --location westeurope \
            --template-file ${{ env.BICEP_FILE }} \
            --parameters environment=prd \
                         aadAdminGroupObjectId=${{ secrets.AAD_ADMIN_GROUP_ID }}
      - name: Deploy infra
        run: |
          az deployment sub create \
            --location westeurope \
            --template-file ${{ env.BICEP_FILE }} \
            --parameters environment=prd \
                         aadAdminGroupObjectId=${{ secrets.AAD_ADMIN_GROUP_ID }}

  deploy-staging:
    needs: [ build, infra ]
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: ${{ env.ARTIFACT_NAME }}
          path: ./publish
      - uses: azure/login@v2
        with:
          client-id:       ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id:       ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Deploy to staging slot
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ env.APP_NAME }}
          slot-name: staging
          package: ./publish

  smoke:
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - name: Wait for health probe
        run: |
          for i in $(seq 1 30); do
            code=$(curl -s -o /dev/null -w "%{http_code}" \
              https://${{ env.APP_NAME }}-staging.azurewebsites.net/health/ready)
            if [ "$code" = "200" ]; then echo "ready"; exit 0; fi
            sleep 10
          done
          echo "health probe failed"; exit 1
      - name: API smoke
        run: |
          curl -fsS https://${{ env.APP_NAME }}-staging.azurewebsites.net/health/ready
          curl -fsS https://${{ env.APP_NAME }}-staging.azurewebsites.net/health/live
          curl -fsS https://${{ env.APP_NAME }}-staging.azurewebsites.net/api/gift-processing/v1/ledgers \
              -H "Authorization: Bearer ${{ secrets.SMOKE_TEST_TOKEN }}" \
              | jq '.items | length >= 1'

  swap-to-prod:
    needs: smoke
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: azure/login@v2
        with:
          client-id:       ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id:       ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Slot swap
        run: |
          az webapp deployment slot swap \
            --resource-group ${{ env.AZURE_RG }} \
            --name ${{ env.APP_NAME }} \
            --slot staging --target-slot production

C.4 App Service Deployment Manifests

C.4.1 web.config (Linux In-Process / Kestrel front-loaded)

Even on Linux App Service the .NET 10 runtime expects a minimal web.config when long-running connections or custom forwarded-headers handling is needed. Kestrel runs in-process; nginx in front of it terminates TLS.

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <location path="." inheritInChildApplications="false">
    <system.webServer>
      <handlers>
        <add name="aspNetCore"
             path="*" verb="*"
             modules="AspNetCoreModuleV2"
             resourceType="Unspecified" />
      </handlers>
      <aspNetCore processPath="dotnet"
                  arguments=".\Petra.GiftProcessing.dll"
                  stdoutLogEnabled="false"
                  stdoutLogFile=".\logs\stdout"
                  hostingModel="inprocess">
        <environmentVariables>
          <environmentVariable name="ASPNETCORE_ENVIRONMENT"
                               value="Production" />
          <environmentVariable name="ASPNETCORE_FORWARDEDHEADERS_ENABLED"
                               value="true" />
        </environmentVariables>
      </aspNetCore>
    </system.webServer>
  </location>
</configuration>

C.4.2 Health probes (Minimal API)

// src/Petra.GiftProcessing/Program.cs (excerpt)
using Microsoft.Extensions.Diagnostics.HealthChecks;

builder.Services
  .AddHealthChecks()
  .AddNpgSql(
      builder.Configuration.GetConnectionString("GiftDb")!,
      name: "postgres",
      failureStatus: HealthStatus.Unhealthy,
      tags: new[] { "ready" })
  .AddRedis(
      builder.Configuration["Redis:Host"]!,
      name: "redis",
      failureStatus: HealthStatus.Degraded,
      tags: new[] { "ready" })
  .AddAzureBlobStorage(
      builder.Configuration["Storage:AccountName"]!,
      name: "blob",
      tags: new[] { "ready" })
  .AddCheck("self", () => HealthCheckResult.Healthy(),
      tags: new[] { "live" });

var app = builder.Build();

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
  Predicate = c => c.Tags.Contains("live")
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
  Predicate = c => c.Tags.Contains("ready")
});

app.MapHealthChecks("/health/startup", new HealthCheckOptions
{
  Predicate = c => c.Tags.Contains("ready"),
  ResultStatusCodes = new Dictionary<HealthStatus, int>
  {
    [HealthStatus.Healthy]   = 200,
    [HealthStatus.Degraded]  = 200,
    [HealthStatus.Unhealthy] = 503
  }
});

C.5 Networking and Routing

Three layers in front of the App Services:

  1. Azure Front Door — global TLS termination + WAF (OWASP Core Rule Set v3.2 + custom rule blocking known SOAP-injection patterns aimed at the legacy ASMX surface).
  2. Azure API Management (Developer/Standard tier, depending on environment) — the strangler-fig surface. Per-endpoint routing policies live here: POST /api/gift-processing/v1/batches/{id}/post is the first endpoint shifted from legacy to modern; reads precede writes per Section 11. APIM policies inject the Managed Identity token into the upstream call and propagate the W3C traceparent header.
  3. Private endpoints — PostgreSQL Flexible Server, Redis, Storage, Key Vault, and Service Bus all expose private endpoints into the App Service VNet integration subnet. No public network access on any data plane.

APIM policy fragment (strangler routing)

<policies>
  <inbound>
    <base />
    <!-- Strangler routing: send POST /batches/{id}/post
         to the modernized API; all other writes stay
         on legacy until their stage is reached -->
    <choose>
      <when condition="@(context.Request.Method == &quot;POST&quot; &amp;&amp;
                       context.Request.Url.Path.Contains(&quot;/post&quot;))">
        <set-backend-service base-url="https://app-gift-prd.azurewebsites.net" />
        <authentication-managed-identity resource="api://gift-prd" />
      </when>
      <otherwise>
        <set-backend-service base-url="https://legacy-petra.example.org" />
      </otherwise>
    </choose>
    <forward-request />
  </inbound>
  <outbound>
    <base />
    <set-header name="x-strangler-stage" exists-action="override">
      <value>posting-modernized</value>
    </set-header>
  </outbound>
</policies>

C.6 Observability

Observability runs on three concentric layers: in-process OpenTelemetry SDK, the App Service / platform metrics, and Application Insights as the unified backend. Logs, traces, and metrics all land in Application Insights via OTLP; Application Insights writes long-term storage to the shared Log Analytics workspace.

C.6.1 OpenTelemetry registration (Program.cs)

// src/Petra.GiftProcessing/Program.cs
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using OpenTelemetry.Metrics;
using OpenTelemetry.Logs;
using Azure.Monitor.OpenTelemetry.AspNetCore;

var otelResource = ResourceBuilder.CreateDefault()
  .AddService(
    serviceName:     builder.Configuration["OTEL_SERVICE_NAME"]!,
    serviceVersion:  ThisAssembly.AssemblyInformationalVersion)
  .AddAttributes(new Dictionary<string, object>
  {
    ["deployment.environment"] =
      builder.Configuration["ASPNETCORE_ENVIRONMENT"]!
  });

builder.Services
  .AddOpenTelemetry()
  .UseAzureMonitor(o =>
  {
    o.ConnectionString =
      builder.Configuration[
        "APPLICATIONINSIGHTS_CONNECTION_STRING"];
    o.SamplingRatio = 1.0f;
  })
  .ConfigureResource(r => r.AddService(
    builder.Configuration["OTEL_SERVICE_NAME"]!))
  .WithTracing(t => t
    .AddSource("Petra.*")
    .AddAspNetCoreInstrumentation()
    .AddHttpClientInstrumentation()
    .AddNpgsql())
  .WithMetrics(m => m
    .AddMeter("Petra.*")
    .AddAspNetCoreInstrumentation()
    .AddHttpClientInstrumentation()
    .AddRuntimeInstrumentation());

// Serilog -> structured JSON -> stdout -> App Insights
builder.Host.UseSerilog((ctx, lc) => lc
  .ReadFrom.Configuration(ctx.Configuration)
  .Enrich.FromLogContext()
  .Enrich.WithProperty("service",
    ctx.Configuration["OTEL_SERVICE_NAME"]!)
  .Enrich.WithCorrelationIdHeader("x-correlation-id")
  .WriteTo.Console(
    new Serilog.Formatting.Compact.RenderedCompactJsonFormatter()));

public static class Tracing
{
  public static readonly ActivitySource Source =
    new("Petra.GiftProcessing");
}

C.6.2 Application Insights queries (KQL)

// KQL — failed gift-batch posts last 24 hours, grouped by failure type
requests
| where timestamp > ago(24h)
| where name == "PostGiftBatch"
| where success == false
| extend problemType = tostring(customDimensions.["traceparent"])
| summarize count() by resultCode, problemType
| order by count_ desc

// KQL — receipt-generation throughput during year-end (Jan + Feb)
customEvents
| where name == "receipts.run.completed"
| extend year       = toint(customDimensions["taxYear"])
| extend donorCount = toint(customDimensions["donorCount"])
| extend durationMs = toint(customDimensions["durationMs"])
| project timestamp, year, donorCount, durationMs
| order by timestamp desc

// KQL — strangler-bridge end-to-end latency (P50/P95/P99)
dependencies
| where timestamp > ago(1h)
| where target == "legacy-petra.example.org"
| summarize
    p50 = percentile(duration, 50),
    p95 = percentile(duration, 95),
    p99 = percentile(duration, 99)
  by bin(timestamp, 5m)

C.6.3 OTEL Environment Variables (reference)

Environment VariableValuePurpose
OTEL_SERVICE_NAMEapp-gift-prd / app-receipts-prdService identity for traces and metrics
OTEL_EXPORTER_OTLP_ENDPOINThttps://westeurope.applicationinsights.azure.com/v2/trackOTLP endpoint for traces, metrics, logs
APPLICATIONINSIGHTS_CONNECTION_STRINGKey Vault referenceAzure Monitor auth + workspace routing
OTEL_RESOURCE_ATTRIBUTESdeployment.environment=prdPer-environment resource tagging
ENVIRONMENTprd / stg / devApp-level environment switch
LOG_LEVELInformation (prd), Debug (dev)Serilog minimum level

C.7 Secrets & Configuration (Key Vault references)

No secrets in appsettings.json, no secrets in environment variables, no secrets in pipeline output. Every secret is a Key Vault reference resolved by App Service at startup; rotation happens in Key Vault without redeploying the app.

SecretKey Vault ReferenceRotation Cadence
Entra ID Tenant ID@Microsoft.KeyVault(VaultName=kv-petra-gift-prd;SecretName=EntraTenantId)Effectively static
Entra ID Client ID (per service)@Microsoft.KeyVault(...;SecretName=app-gift-prd-ClientId)Annual
SendGrid API key (Receipt service)@Microsoft.KeyVault(...;SecretName=SendGridApiKey)90 days
Legacy Partner service shared secret (bridge)@Microsoft.KeyVault(...;SecretName=LegacyPartnerSecret)30 days (during cutover); decommissioned after Phase 6
Application Insights connection stringApp-Service setting (system-managed)Effectively static

All database connections (PostgreSQL, Redis) use Managed Identity — no passwords stored anywhere. The Bicep role-assignments module wires the System-Assigned Managed Identity of each App Service to the data services it needs.

C.8 Cost Estimates

ResourceSKU / SizeEstimated Monthly (EUR)Notes
App Service Plan (P1v3, 2 instances)P1v3 (2 vCPU, 8 GiB)~€230Shared between both App Services
Azure Database for PostgreSQL Flexible ServerStandard_D2ds_v5, 32 GB~€200Both schemas; ZRS backup, 7-day retention
Azure Cache for RedisStandard C1 (1 GB)~€55Reference-data + receipt-template cache
Azure Blob StorageHot, ZRS, ~50 GB~€10Includes immutable retention on receipts
API ManagementDeveloper tier (dev/stg) / Standard (prd)€45 / €500Standard required for prod SLA; Developer fine for staging
Application Insights + Log Analytics~5 GB / day ingest, 90-day retention~€130100% sampling enabled given low volume
Azure Service BusStandard (on-demand)€10 / €0 when un-provisionedCurrently optional — only Receipt Generation bulk path uses it
Front Door + WAFStandard~€30WAF rules included
Key VaultStandard, ~10K secret ops/month~€3Negligible
Total (Production)~€1,170 / month
Total (Staging)~€490 / monthSmaller SKUs throughout

Costs are illustrative for a single mid-size deployment in westeurope. Multi-region or sovereign-cloud deployments scale the App Service Plan and APIM lines proportionally; PostgreSQL replicas double the database cost. The bulk year-end receipt run scales the receipts App Service from 1 to 6 instances for ~6 weeks — ~€70 incremental.

C.9 Operational Runbook

ScenarioDetectionFirst ResponseEscalation
Gift batch posting failures spike KQL query (§C.6.2 #1) > 5 failures / hr Check problem-type breakdown; if period-out-of-range dominates → user-data issue (no action); if legacy-gl-down dominates → check legacy GL health page Pager to legacy-platform owner if legacy GL is down > 15 min
Receipt run stalled Custom event receipts.run.heartbeat not seen for > 10 min Check Application Insights live metrics; verify Blob writes proceeding; check QuestPDF memory pressure Restart the Receipt App Service instance handling the run; resume from last donor checkpoint
Strangler-bridge latency SLO breach KQL P95 > 1.5s for > 5 min Check legacy GL latency; if elevated, fail open APIM policy back to legacy temporarily Page legacy-platform owner
CDC consumer lag > 30s App Insights custom metric cdc.consumer.lag.seconds Check Service Bus queue depth; if growing, scale Gift Processing API up by 1; if shrinking, watch If lag > 5 min, pause new Phase-4 stage advancement
Slot-swap failed smoke test CI pipeline smoke step exits non-zero Inspect Application Insights for staging slot; do NOT swap Investigate; rollback by redeploying the previous commit to staging slot

Run-books live as Markdown in ops/runbooks/ alongside the code; each runbook ends with a "verify resolved" KQL snippet that confirms the metric returned to baseline.