ADR-021: PowerFill Port Strategy
Status
Proposed (2026-04-16) — Amended 2026-04-19 with §Narrow Bug-Fix Carve-Out (see below). The verbatim-port discipline remains the default; the amendment formalises a tightly-scoped exception for empirically-demonstrated legacy bugs that prevent end-to-end PoC progress against tenant data.
Context
The Desktop App's PowerFill plugin is approximately 19,000 lines of T-SQL embedded in PowerBuilder string literals (deployed at runtime as stored procedures), plus approximately 5,000 lines of PowerBuilder code for the window shell, menu, DataWindows, and tab user objects.
The engine has been in production for 15+ years. It contains extensive domain knowledge — constraint logic, securitization rule interactions, tolerance bands, carry cost scoring, multi-pass allocation — none of which is separately documented. The source code IS the specification.
Porting this to PSSaaS requires a strategy for the T-SQL. Three options exist:
Option A: Lift-and-Shift T-SQL
Deploy the existing stored procedures (or functionally-identical versions) to the tenant databases. The PSSaaS .NET API calls these procedures via EF Core FromSqlRaw or raw ADO.NET.
Pros:
- Lowest risk — we run the same code that has 15 years of tuning
- Fastest to implement — no algorithm rewrite
- Performance — set-based T-SQL is fast for this workload
- Tenant DBs already have the infrastructure (SQL MI supports stored procedures)
Cons:
- Two execution environments to debug (.NET API + T-SQL procedures)
- Cross-tenant procedure deployment is operationally painful
- Business logic lives in T-SQL, not in C# — harder to unit test
- T-SQL is not the PSSaaS technology stack
Option B: Full C# Port
Rewrite everything in C#. Load data into in-memory collections, execute allocation logic in managed code, persist results to the database.
Pros:
- Single execution environment
- All business logic in C# — unit testable with standard tooling
- No stored procedure deployment across tenant databases
- Full type safety
Cons:
- Highest risk — 19K lines of T-SQL that "just works" rewritten from scratch
- Performance unknown — set-based T-SQL may outperform row-by-row C# for a 10K-loan × 50-trade × constraint-priority iteration
- Longest timeline — rewrite + verification could take months
- High probability of behavioral drift from the Desktop App
Option C: Hybrid
Keep the heavy set-based SQL in stored procedures (allocation engine, candidacy scoring, tolerance enforcement) because that's where T-SQL excels. Move orchestration, validation, constraint editing, API contracts, and business rule evaluation to C#.
Pros:
- Best-of-both: T-SQL does what T-SQL is good at, C# does what C# is good at
- Matches the Desktop App's pattern (PB shell + T-SQL engine) which is proven at scale
- Unit-testable business rules in C#, performance-critical set operations in T-SQL
- Lower rewrite risk than Option B
- Less operational complexity than Option A (fewer deployed procedures)
Cons:
- Two languages in the module
- Contract between C# and T-SQL must be carefully maintained
- Still requires stored procedure deployment per tenant (though fewer than Option A)
- Mix of testing strategies
Decision
Adopt Option C: Hybrid.
Specifically
-
In T-SQL (deployed as stored procedures per tenant):
psp_powerfill_consetport — core allocation engine with constraint iteration, candidacy scoring, multi-pass allocationpsp_powerfillUEport — post-processing and recap finalizationpsp_pfill_bx_cash_gridsport — BestEx cash grid refreshpsp_pfill_bx_settle_and_priceport — settlement/price computation- Helper procedures for set-based operations (candidacy builds, lockdown applications, kickout detection)
-
In C# (PSSaaS .NET API):
- API endpoint controllers and routing
- Authentication and tenant resolution
- Input validation (preflight)
- Options parsing and parameter construction for stored procedure calls
- Async run orchestration (spawn job, track status, handle cancellation, handle failure)
- Constraint CRUD editing and validation
- Carry cost curve CRUD and validation
- Lockdown CRUD and validation
- Business rule evaluation for non-set operations (e.g., "is this constraint editable?")
- Run history and audit trail
- Result marshalling from
pfill_*tables to API response shapes
Stored Procedure Deployment
- PSSaaS deploys PowerFill stored procedures via EF Core migrations during tenant provisioning
- Updated procedures versioned with ADR-006-compatible migration scripts
- Stored procedure source lives in the PSSaaS repo under
src/backend/PowerSeller.SaaS.Modules.PowerFill/Sql/ - Per ADR-006 schema preservation, the
pfill_*table schemas match the Desktop App's schema exactly for coexistence period
Interface Contract
The C# layer invokes stored procedures via a typed service (IPowerFillEngineService) that:
- Validates inputs
- Opens a SQL transaction
- Calls procedures in order with structured parameters
- Checks for procedure return codes / output parameters
- Commits or rolls back
- Reads
pfill_*run-output tables into C# DTOs - Returns typed results to the controller
Consequences
Positive
- Minimal risk — the allocation engine is a port of battle-tested T-SQL, not a rewrite
- API-first — modern REST endpoints, authentication, tenant isolation, audit trail
- Testable — C# orchestration is unit-testable; T-SQL procedures are integration-testable against a seeded DB
- Coexistence-friendly — shared schema with Desktop App per ADR-015; PSSaaS and Desktop App can run PowerFill against the same tenant database during transition
- Performance preserved — set-based T-SQL continues to outperform row-by-row for the allocation workload
- Matches Desktop App behavior — faithful port = easier Tom/Greg review and customer parallel validation
Negative
- Two languages in the module — contributors must be comfortable with both
- Stored procedure versioning complexity — updating procedures across tenants requires migration discipline
- Contract drift risk — if the T-SQL signature changes and C# isn't updated, runtime failures
- T-SQL unit testability is weaker — integration tests against a seeded DB are needed, not pure unit tests
- Harder to move to another DB engine — PostgreSQL support (if ever desired) would require procedure rewrites
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Procedure-C# contract drift | Generate C# DTOs from procedure signatures via code generation (future); for now, maintain integration tests that fail on mismatch |
| Procedure deployment inconsistency across tenants | Single-source procedure definitions in repo, deployed via EF migrations; tenant provisioning includes PowerFill schema as standard |
| Debugging across two environments | Structured logging with correlation IDs; stored procedures emit diagnostic info to a pfill_run_log table |
| Performance regression vs. legacy | Benchmark suite runs each release against PS_DemoData baseline; alert on >10% regression |
Narrow Bug-Fix Carve-Out (added 2026-04-19)
The verbatim-port discipline above is the default. It is not absolute. Operating against PS_DemoData (and prospectively against any tenant DB whose snapshot data shape exposes latent legacy-proc bugs) revealed an empirical class of cases where strict verbatim port produces an unrunnable engine — the legacy procedure body itself contains a bug that fires deterministically on real tenant data, blocking end-to-end PoC progress.
This carve-out formalises a tightly-scoped exception.
Principle
When a legacy proc body contains an empirically-demonstrated bug that prevents end-to-end PoC progress against tenant data, PSSaaS may apply a narrow surgical fix (typically ≤5 LOC per fix; ≤15 LOC across multiple fixes addressing one root cause) provided all four acceptance criteria are met:
- (a) Empirically reproducible — the bug is reproducible against tenant data with line-level NVO citations + a captured
SqlException(or equivalent diagnostic) + post-state SELECT confirmation. Plan-time hypotheses count for nothing; only empirical PoC evidence opens the carve-out. - (b) Mirrors legacy author intent — the fix shape is justified by adjacent code in the same proc body (e.g., the
_1300PK extension to 3 cols matches the immediately-following_1400's 3-col PK; thept13JOIN qualifier extension matches the column the inner subquery already selected with no other use site). Pure speculation about author intent is insufficient; the evidence must come from the code itself. - (c) Documented in the assumption log with a Phase 9 parallel-validation hook — the assumption-log entry carries the empirical evidence + the fix justification + an explicit Phase 9 parallel-validation expectation (e.g., "Phase 9 must compare PSSaaS allocations to Desktop App allocations on a customer DB to confirm the fix doesn't introduce divergence").
- (d) Tom/Greg consultation is recorded — either:
- PO + recipient signature — the PO escalates to Tom/Greg, captures their input, and records it in the assumption-log entry; OR
- Demo-as-consultation note — when the fix is necessary to demo to Tom/Greg/another domain expert who would otherwise be the consulted authority, the demo IS the consultation. The PO records this disposition explicitly. The empirical evidence + the ADR-021 carve-out reasoning constitute the case the consulted expert reviews live.
What this is NOT
- Not a rewrite license. Carve-out fixes are surgical (≤5 LOC each; ≤15 LOC per root cause). Anything larger is a structural change requiring a new ADR.
- Not a behavior change. The carve-out is reserved for cases where the fix is strictly more permissive than the legacy behavior (the post-fix proc accepts every input shape the pre-fix proc accepted, plus additional shapes the pre-fix proc rejected). Fixes that change which row wins, change ordering semantics, or alter aggregation results are out of scope.
- Not a substitute for Phase 9. Phase 9 parallel-validation against the Desktop App on a customer DB remains the canonical correctness gate. The carve-out unblocks PoC progress; it does not bless the fix as customer-deployable.
- Not a precedent for skipping ADR-021's discipline elsewhere. Every carve-out invocation requires an explicit assumption-log entry that satisfies all four acceptance criteria. Pattern repetition over time may justify a new ADR; one-off invocations stay under this carve-out.
Canonical first instance: A54 (PowerFill psp_powerfill_pool_guide — ##cte_posting_set_1300 PK + pt13 JOIN qualifier)
Empirical evidence (a): On every PSSaaS run against PS_DemoData since Phase 6c (2026-04-19), Step 5 (pool_guide) terminated as Failed with SqlException 2627: Violation of PRIMARY KEY constraint 'PK__##cte_po__...' on ##cte_posting_set_1300. Reproducible across 5 runs (run IDs 5859cd70, fd5b63cc, 8c9e46dd, a8e5bcfd, plus 6c/6d/6e/7's prior runs). NVO source: n_cst_powerfill.sru lines 9889-9919 (_1300 CREATE), 10001-10033 (_1400 CREATE for shape comparison), 9990-9999 (pt13 INNER JOIN definition).
Mirrors legacy author intent (b): Two surgical changes:
- PK extension —
_1300's PK extended from(trade_id, loan_id)(2 cols) to(trade_id, loan_id, pa_key)(3 cols), withpa_key NUMERIC(1, 0) NOT NULLadded to the table CREATE and the SELECT (using the identical CASE expressionLEFT(pool_action, 1) IN ('L'/'R'/'J'/else)that_1400's SELECT already uses ~80 lines later). This mirrors_1400's author-intended shape byte-for-byte. pt13JOIN qualifier extension — addedAND ps1200_13.settlement_date = pt13.settlement_dateto the INNER JOIN. Thept13inline subquery already selected(trade_id, settlement_date)and GROUPed by both — the legacy JOIN qualified ontrade_idalone, which produces a fan-out when a trade has multiple distinct settlement_dates across its loans (empirically: trade36177868had 4 rows with 3 distinct settlement_dates inpfill_powerfill_guidemid-run, exploding each_1200row 3x). The author selectedsettlement_dateforpt13with no other downstream use of that column — the JOIN qualifier omission is a clear oversight.
Documented in assumption log + Phase 9 hook (c): A54 marked RESOLVED 2026-04-19 with the full empirical-resolution arc (5 pre-fix run citations + post-fix run 43e8f148 + 7c9dfe50 Complete-status confirmations). A65 documents the broader observation (multi-pa_key Switching is one of two latent triggers; settlement-date variance is the other; PS_DemoData's snapshot exposes both). Phase 9 parallel-validation is explicitly identified as the canonical gate to confirm the fix doesn't introduce per-loan divergence vs the Desktop App.
Tom/Greg consultation recorded (d): Demo-as-consultation disposition. The PO has decided that the immediate next consumer of PSSaaS PowerFill output is Greg himself, in a Greg-demo context. The empirical evidence + this ADR amendment + the Greg-demo-readiness completion report (docs-site/docs/handoffs/powerfill-a54-fix-greg-demo-readiness.md) constitute the consultation material Greg reviews live. If Greg's review reveals a deeper concern (the legacy author's 2-col PK was intentional; the JOIN qualifier omission preserved by design), the fix is reverted and the PoC reverts to its pre-fix Failed state — i.e., this is a reversible, documented carve-out, not a permanent platform change.
Forward-only
The fix is forward-only. PSSaaS-deployed 009_*.sql includes the amended proc body. Legacy Desktop App customers running their own copy of psp_powerfill_pool_guide are unaffected (the carve-out lives only inside PSSaaS's port). When Phase 9 runs PSSaaS against a real customer DB, the post-fix proc body will accept every (trade, loan, pa_key, settlement_date) tuple shape that the pre-fix proc accepted, plus additional tuples the pre-fix proc rejected — strictly more permissive.
Alternatives Considered and Rejected
Option A (Lift-and-Shift T-SQL)
Rejected because: business rule evaluation and constraint editing genuinely belong in C# for testability and API ergonomics. Pure T-SQL would force us to expose stored procedures for CRUD operations, which is awkward.
Option B (Full C# Port)
Rejected because: the risk/timeline trade-off is bad. A 19K-line rewrite is not the right use of engineering time when Option C delivers behavioral parity with half the risk. An LP/CSP rewrite remains a Phase 2 modernization candidate (tracked as a future ADR).
Relationship to Other ADRs
- ADR-004 (Modular Monolith) — PowerFill is a module within the monolith; T-SQL stored procedures live in the tenant DB, not a separate service
- ADR-005 (Database-Per-Tenant) — each tenant gets PowerFill procedures and
pfill_*tables; no cross-tenant data - ADR-006 (Schema Preservation) —
pfill_*tables preserve the legacy schema; Desktop App and PSSaaS coexist against the same tables - ADR-014 (Backend Language Divergence) — this ADR adds T-SQL as a third language (alongside C# and Python in PSX), scoped to PowerFill stored procedures
- ADR-022 (Allocation Algorithm) — this ADR picks the port approach; ADR-022 picks the algorithm (port iterative passes vs. LP rewrite)
- ADR-023 (Constraint Model) — this ADR picks execution split; ADR-023 picks the constraint data model
Revision Triggers
This ADR should be revisited if:
- Tom or Greg critique reveals the legacy T-SQL has bugs we inadvertently ported
- Performance measurements show the stored procedure approach doesn't scale
- A full C# port becomes tractable (e.g., via a constraint solver library that matches the domain)
- PostgreSQL support becomes a product requirement (forcing a reduction in stored procedure reliance)
- A second carve-out instance accumulates against a different proc — pattern repetition justifies promoting carve-outs to a sibling ADR (e.g., ADR-026 "PSSaaS Bug-Fix Catalog for Legacy PowerFill Procs") with a per-fix register
- Greg or Tom rejects the A54 carve-out during their review of the Greg-demo-readiness report — the fix gets reverted and the carve-out clause itself is reconsidered