fix: DEBT-018 - make outbox writes co-transactional with entity writes

TradeExecution: TradeOutboxPublisher.PublishAsync replaced with
UpdateAndPublishAsync, which opens one connection/transaction, updates
trade status and writes the outbox message on it, then commits once.
Used by the 3 call sites that publish an event after a status update
(SubmitTradeHandler, PollTradeStatusHandler's FullyFilled branch,
ConfirmSettlementHandler).

PortfolioReconciliation: ReconcileTradeHandler now injects the
request-scoped IDbConnection (the same instance ReconciliationSql
already uses) instead of opening a second separate connection via
IDbConnectionFactory, begins one transaction shared by
ReconciliationEngine.ReconcileTradeAsync and the outbox writes, and
commits once. Required adding IDbTransaction-aware overloads of
GetHoldingAsync/UpsertHoldingAsync/InsertReconciliationLogAsync - the
read needed one too, since Npgsql throws if a command on a connection
with a pending transaction doesn't have it attached.

dotnet build KArtSell.sln -c Release: clean. Integration tests: 17
pure-logic tests pass, 13 DB-backed tests fail with the pre-existing
connection-refused error (no SSH tunnel in this environment) - the
transactional changes themselves are not yet verified against a live
database. Also corrected two WBS_PROGRESS_TRACKER.csv rows that
inaccurately said frontend UI work was "in progress" when the
background agents building VS-28/VS-29 UI had actually failed
(hit the session's spend limit) before committing anything.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 23:04:44 +09:00
parent 3c56c0926a
commit 8ed232e224
7 changed files with 164 additions and 88 deletions
+1 -1
View File
@@ -50,7 +50,7 @@
| DEBT-008 | Namespace consistency | Medium (2) | Low (1) | Accepted | All projects use RootNamespace=KArtSell.Aegis; AssemblyName retained per-project for DLL clarity. Trade-off accepted: DLL clarity > namespace alignment. No action. | @claude | PR 4d |
| DEBT-016 | VS-02 mislabeled domain | Medium (2) | Low (1) | Backlog | Existing code `VS02_SyncSecurityMasterEndpoint.cs`, `VS02_SecurityMasterJobs.cs`, `VS02_SecurityMasterPolicy.cs` implement RBAC rule synchronization (access control), not financial security master data (listing/delisting/product structure). Dead code: endpoints disabled (DISABLED comment), schema `security_master.rules` table never migrated, never deployed. Correct domain documented in `docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md` (financial PIT). Removal decision deferred pending architect review (PR recommended). | @claude | docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md |
| DEBT-017 | Duplicate VS-26 (formerly VS-03) Approval Workflow implementation | High (3) | Medium (2) | Completed (DB verification pending) | **Decision (2026-08-08):** `Features/ApprovalWorkflow/` (Workstream G) kept as canonical — it is the implementation actually wired into `Program.cs`/`FastEndpoints`. `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` (Workstream H, `[DontRegister]`'d dead code) and its dedicated test file (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`, the old 20/20-passing suite that exercised only the dead code) were **deleted**. `ApprovalWorkflowPolicyTests.cs` already tested the kept implementation's pure `Policy` class and was extended (5→10 cases) rather than replaced. New Handler+Sql+real-Postgres integration tests were written at the same path the old dead-code tests occupied (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`), covering create (Maker-role-gated), approve (Maker≠Checker separation of duties, Checker-role-gated, evidence attachment), activate (SRE-role-gated), list filtering, and an explicit `DateOnly EffectiveAt` round-trip. **Bug found and fixed while porting:** the kept implementation's `Sql.cs InsertProposalAsync` had the *exact same* Dapper-cannot-bind-`DateOnly` bug that was found and fixed in the deleted implementation's `ApprovalSql.cs` (commit `2ccf74c`) — i.e. the "tested" dead code had already been fixed for this, but the "live" code had not; it would have failed 100% of proposal-creation calls against a real database. Fixed identically (`::date` cast + `"yyyy-MM-dd"` string parameter). **Not fixed (out of scope, flagged as residual gaps in the slice's README):** no `GET /approvals/{id}` endpoint (evidence becomes unreachable via HTTP after approval), and no wired Draft→Proposed transition anywhere in the running app (`ApprovalWorkflowPolicy.CanProposeForReview` exists but no Handler/Endpoint calls it), and `approval_proposals` rows are mutated in place via `UPDATE` rather than appended as new PIT revisions (the table's schema only has `id` as `PRIMARY KEY`, so the deleted implementation's append-only INSERT approach would itself have violated that constraint on the second write — this is pre-existing, schema-level, and not a regression from this cleanup). **Verification status: `dotnet build -c Release` is clean (0 errors/warnings). `dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release` was run 2026-08-08: 10/10 pure-`Policy` tests passed; all 8 new DB-backed integration tests failed with `Npgsql.NpgsqlException: Failed to connect to 127.0.0.1:5432` (connection refused) because no PostgreSQL was reachable in that session (no SSH tunnel to 178.104.200.7 open). None of the 8 have been confirmed to pass against a real database.** Do not mark this row fully verified until that run happens; see `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` row `AEG-VS-26-01`, kept `BLOCKED` for the same reason. | @claude | commit a2e742c (original dup.), this session's commit (resolution), `docs/DECISIONS/ADR-WBS-001-slice-renumbering.md` |
| DEBT-018 | Outbox write not co-transactional with entity write | Medium (2) | Medium (2) | Backlog | `TradeExecution/TradeHandlers.cs` (`TradeOutboxPublisher`) and `PortfolioReconciliation/ReconcileTradeHandler.cs` open a second, separate connection/transaction to write the outbox message after the trade/holding write already committed on its own connection. A crash between the two leaves the entity updated but no outbox event emitted (silent, non-atomic). Proper fix: thread a shared `NpgsqlTransaction` through `TradeSql`/`ReconciliationSql` mutation methods so entity insert + outbox insert commit together, matching `DapperModelOperationRequestRepository`'s pattern. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening) |
| DEBT-018 | Outbox write not co-transactional with entity write | Medium (2) | Medium (2) | Completed (DB verification pending) | Fixed 2026-08-08, matching `DapperModelOperationRequestRepository`'s pattern. **TradeExecution:** added a `DbConnection`/`DbTransaction`-taking overload of `ITradeSql.UpdateTradeStatusAsync`; `TradeOutboxPublisher.PublishAsync` replaced with `UpdateAndPublishAsync`, which opens one connection/transaction, updates trade status and writes the outbox message on it, then commits once — used by all 3 call sites that publish an event (`SubmitTradeHandler`, the `FullyFilled` branch of `PollTradeStatusHandler`, `ConfirmSettlementHandler`); paths with no outbox event still use the plain non-transactional update. **PortfolioReconciliation:** `ReconcileTradeHandler` now injects the request-scoped `IDbConnection` (the same instance `ReconciliationSql` already uses within one HTTP request, replacing its own separate `IDbConnectionFactory`-opened connection) and begins one `IDbTransaction` shared by `ReconciliationEngine.ReconcileTradeAsync(..., transaction)` (which threads it into new `IDbTransaction`-aware overloads of `GetHoldingAsync`/`UpsertHoldingAsync`/`InsertReconciliationLogAsync` — the read needed a transaction-aware overload too, since Npgsql throws if a command on a connection with a pending transaction doesn't have it attached) and the outbox `TradeReconciled`/`ReconciliationMismatchAlert` writes; the handler commits once at the end (or rolls back on `!result.Success`). `dotnet build KArtSell.sln -c Release`: 0 warnings/0 errors. `dotnet test --filter "FullyQualifiedName~TradeExecution\|FullyQualifiedName~PortfolioReconciliation" -c Release`: 17 pure-logic tests passed, 13 DB-backed tests failed with the same pre-existing 127.0.0.1:5432 connection-refused error (no SSH tunnel in this session) — none of the transactional changes have been confirmed against a live database yet. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening, discovery), Session 2026-08-08 (fix) |
| DEBT-019 | Multiple duplicate cross-cutting abstractions (`IClock`, `IOutboxWriter`, `IKrxDataService`) | Medium (2) | Low (1) | Completed (partial) | Found and collapsed 3 separate cases where a slice reinvented an abstraction that already existed in `KArtSell.BuildingBlocks`: a second `IKrxDataService` (deleted, `ShadowRun.Services`), a second `IOutboxWriter`/`WriteAsync<T>` in `ReconcileTradeHandler.cs` (removed, switched to `BuildingBlocks.Reliability.IOutboxWriter`), and a second `IClock`/`SystemClock` in `ApprovalWorkflow/ApprovalPolicy.cs` (removed, switched to `BuildingBlocks.Time.IClock`). Root cause: successive sessions implementing a slice without searching `BuildingBlocks` first. Recommend a pre-implementation checklist step ("does this abstraction already exist in BuildingBlocks?") for future slices. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening) |
| DEBT-020 | `model_operations.models` and `compliance` schema never created by any migration | High (3) | Low (1) | Completed | `0036`/`0038` reference `model_operations.models(id)` via FK and `OpenDartDailyBatchJob.cs` queries it directly, but no migration ever ran `CREATE TABLE model_operations.models`; `0037` wrote to `compliance.*` tables without `CREATE SCHEMA compliance`. Any fresh database — including the actual deploy target (178.104.200.7), confirmed via a live failed SCP/DbMigrator deploy on 2026-08-07 — failed at migration `0036`/`0037`. Fixed via new `0035_model_operations_models.sql` (minimal: id/ticker/published_at/correlation_id/revision only — full Model Card schema is separate future work) and `CREATE SCHEMA IF NOT EXISTS compliance;` added to `0037`. Full chain 0000→0040 now verified fresh-install + idempotent re-run clean. | @claude | Session 2026-08-07 (deploy failure triage) |
| DEBT-021 | Dapper never configured for snake_case↔PascalCase column mapping | High (3) | Low (1) | Completed | `Dapper.DefaultTypeMap.MatchNamesWithUnderscores` was never set anywhere in the codebase, so every `QueryAsync<T>`/`QuerySingleOrDefaultAsync<T>` result-mapping onto a snake_case DB column (e.g. `event_type``EventType`) silently returned null/default for that property instead of throwing — masking the bug in every Sql class across every module. Confirmed via `ApprovalWorkflowTests.InsertAndRetrieveProposal_RoundTrips` and `AuditTrailTests.InsertAuditEvent_CreatesImmutableRecord` both getting real rows back with null fields. Fixed centrally via a `[ModuleInitializer]` in `KArtSell.BuildingBlocks/Data/DapperBootstrap.cs` (runs once per process regardless of entry point — Host/DbMigrator/tests). | @claude | Session 2026-08-07 (deploy failure triage) |