diff --git a/TECH_DEBT_REGISTER.md b/TECH_DEBT_REGISTER.md index 1ad7f75f..620feda2 100644 --- a/TECH_DEBT_REGISTER.md +++ b/TECH_DEBT_REGISTER.md @@ -57,8 +57,8 @@ | DEBT-022 | jsonb/inet columns written as plain text without an explicit cast | Medium (2) | Low (1) | Completed (partial) | Dapper does not know to cast a `string` parameter to `jsonb`/`inet` for Npgsql; `AuditSql.InsertAuditEventAsync` (`details`, `ip_address`), `AuditSql.RedactAuditEventDetailsAsync` (duplicate `SET details =` assignment, separately fixed), `TradeSql.InsertTradeAsync`/`UpdateTradeStatusAsync` (`kis_response`), and `SellDecisionSql.InsertDecisionAsync` (`oos_performance`) all failed with `42804: column "x" is of type jsonb but expression is of type text` the first time they were run against a real schema. Fixed with explicit `::jsonb`/`::inet` casts at each call site (mechanical, no behavior change). `AuditSql`'s jsonb read-back (`Dictionary` from a jsonb column) also needed a raw-DTO + `JsonSerializer.Deserialize` mapping since Dapper has no built-in jsonb→Dictionary conversion either. **Not yet checked**: `PortfolioReconciliation`/`ApprovalWorkflow` Sql classes for the same pattern beyond what surfaced in this session's test runs — a full audit of jsonb/inet columns across all Sql classes is still open. | @claude | Session 2026-08-07 (deploy failure triage) | | DEBT-023 | `ApprovalSql.InsertProposalAsync` fails on `DateOnly` parameter | Medium (2) | Low (1) | Completed | Stale entry, corrected 2026-08-08: this described `ApprovalSql.cs` under `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` — that per-call-site fix (`::date` cast + `"yyyy-MM-dd"` string parameter, not a centralized type handler) landed in commit `2ccf74c` but this row was never updated to reflect it. That whole file was then deleted as dead code while resolving DEBT-017 (2026-08-08); its surviving sibling, `Features/ApprovalWorkflow/Sql.cs`, was found to have the *same* unfixed bug independently and received the identical fix in that session — see DEBT-017. No centralized `DateOnly` type handler was added; this remains a per-call-site fix pattern, so any *other* `DateOnly`-typed Dapper INSERT elsewhere in the codebase should still be checked individually rather than assumed safe. | @claude | commit 2ccf74c; DEBT-017 (this session) | | DEBT-024 | New integration tests don't insert FK parent rows / one pure-logic test flakes under full-suite run | Low (1) | Low (1) | Backlog | `TradeExecutionTests` constructs `Trade` with a random `sellDecisionId` that was never inserted into `sell_decisions`, so every insert now correctly fails its FK constraint (`trades_sell_decision_id_fkey`) once the schema was actually complete (see DEBT-020) — test-only gap, not a production code defect; needs the tests updated to insert a parent `models`+`sell_decisions` row first. Separately, `SellPriorityRankerTests.CalculateScore_HardImpairment_ReturnsLowestScore` (pure logic, no DB) passed in isolation but returned 1000 instead of the expected 950 (age-boost not applied) when run as part of the full suite — not yet root-caused; may be test-order/parallelization state leakage rather than a `SellPriorityRanker` bug. Also, `DbUpMigrationTests.*` (pre-existing, unrelated to this session) fail locally with `42501: must be owner of database kartsell_migration_test` — a local Postgres role permission gap, not a code issue. | @claude | Session 2026-08-07 (deploy failure triage) | -| DEBT-025 | `Features/ApprovalWorkflow` has no `GET /approvals/{id}` endpoint | Medium (2) | Low (1) | Backlog | Discovered while resolving DEBT-017 (2026-08-08). The deleted duplicate implementation had a single-proposal fetch endpoint that included the attached `Evidence` list in its response; the kept, canonical implementation (`Features/ApprovalWorkflow/Endpoints.cs`) only has `POST /approvals`, `GET /approvals` (list, no evidence in the DTO), and `POST /approvals/{id}/approve`. Net effect: evidence attached during approval (PBO/DSR/OOS artifact links, the whole point of this slice per CLAUDE.md's "Activation gating") is currently unreachable via HTTP — only readable by querying `model_operations.approval_evidence` directly. Needs a `GetApprovalByIdEndpoint` + `ApprovalDetailResponse` (with `Evidence`) added to the slice; not done in the DEBT-017 session because it is new functionality, not a duplication cleanup, and out of that session's scope. | @claude | DEBT-017 (2026-08-08), `src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md` | -| DEBT-026 | `Features/ApprovalWorkflow` has no wired Draft→Proposed transition | High (3) | Low (1) | Backlog | Discovered while resolving DEBT-017 (2026-08-08). `ApprovalWorkflowPolicy.CanProposeForReview` exists but no `Handler` or `Endpoint` in the kept implementation calls it, so nothing in the running application ever moves a proposal from `Draft` to `Proposed`. `ApproveApprovalHandler` requires `Proposed` and `ActivateModelHandler` requires `Approved`, so as shipped, a proposal created via `POST /approvals` cannot reach `Approved`/`Active` through the HTTP API at all — the maker-checker gate is not actually completable end-to-end today. Higher impact than DEBT-025 because it blocks the slice's core purpose, not just an ancillary read. Needs a `ProposeForReviewHandler` + `POST /approvals/{id}/propose` (or equivalent) endpoint. Not done in the DEBT-017 session (new functionality, out of that session's duplication-cleanup scope). | @claude | DEBT-017 (2026-08-08), `src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md` | +| DEBT-025 | `Features/ApprovalWorkflow` has no `GET /approvals/{id}` endpoint | Medium (2) | Low (1) | Completed (DB verification pending) | Added `GetApprovalByIdEndpoint` (`GET /approvals/{id}`) + `ApprovalDetailResponse` (includes `Evidence`), and `ApprovalWorkflowSql.GetEvidenceForProposalAsync`. Evidence attached during approval (PBO/DSR/OOS artifact links) is now readable via HTTP. Two new tests added (`GetEvidenceForProposalAsync_ReturnsEvidenceAttachedDuringApproval` + the endpoint itself). `dotnet build -c Release` clean (0/0). **Not verified against a live database** — same 127.0.0.1:5432 connection-refused blocker as DEBT-017/026; do not mark fully verified until a real Postgres run passes. | @claude | DEBT-017 (2026-08-08), `src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md` | +| DEBT-026 | `Features/ApprovalWorkflow` has no wired Draft→Proposed transition | High (3) | Low (1) | Completed (DB verification pending) | Added `ProposeForReviewHandler` + `POST /approvals/{id}/propose`, wired into `Program.cs` DI. Calls the pre-existing `ApprovalWorkflowPolicy.CanProposeForReview` (creator-only) and `ValidateProposalState` (Draft→Proposed), then updates status and emits a `PROPOSED` event — same pattern as `ApproveApprovalHandler`/`ActivateModelHandler`. A proposal created via `POST /approvals` can now reach `Approved`/`Active` through the HTTP API end-to-end. Two new tests added (`ProposeForReview_ByCreatingMaker_TransitionsDraftToProposed`, `ProposeForReview_ByDifferentUserThanCreator_ThrowsUnauthorized`). `dotnet build -c Release` clean (0/0). **Not verified against a live database** — same 127.0.0.1:5432 connection-refused blocker as DEBT-017/025; `dotnet test --filter FullyQualifiedName~ApprovalWorkflowTests -c Release` run 2026-08-08, all 17 matched tests fail with connection-refused (includes this file's tests plus an unrelated top-level `ApprovalWorkflowTests.cs` the substring filter also matches). Do not mark fully verified until a real Postgres run passes. | @claude | DEBT-017 (2026-08-08), `src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md` | --- diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv index 6cfb57e9..f35df13d 100644 --- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv +++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv @@ -27,7 +27,7 @@ AEG-VS-00-07,S0,VS-00,회귀·관제·Runbook·Rollback 증거,COMPLETED,2026-08 AEG-X-009,S1,Cross,Source catalog 고도화,COMPLETED,2026-08-07,"docs/CURRENT/CATALOGS/source-catalog.md; docs/CURRENT/AEG-X-009_AUTOMATION_PROPOSAL.md; contracts/data/source-approval.v1.proposed.json; docs/DECISIONS/ADR-DATA-001.md; db/migrations/0033_source_approval_contract.sql; db/migrations/0034_dataset_manifest_freeze_contract.sql; db/migrations/0033_market_data_import_logs.sql; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperApprovedModelContextReader.cs",Data Governance,"✅ Workstream D/E/F COMPLETED: source-catalog.md v2.0 (KRX/OpenDart/KIS consolidated), VS-02_DATA_GOVERNANCE_POLICY.md, VS-03/04 SLICE_SPECs. All 4 unknowns resolved. ✅ Workstream G (commit 136665c, 2026-08-07) also now COMPLETE: live KRX OpenAPI / OpenDart / KIS service integrations (P1-P3), daily scheduling + error classification + SLA tracking + LKG fallback (P4-P6), market_data schema with append-only import logs, correlation_id-based idempotent replay. ⚠️ Note: 0033 is used by two different, unrelated migrations across branches (source_approval_contract.sql vs market_data_import_logs.sql) — confirm actual applied migration number in the target DB's kartsell_schema_versions journal before assuming both landed as authored." AEG-VS-01-01,S1,VS-01,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-07,docs/CURRENT/SLICE_SPECS/VS-01-SLICE_SPEC.md,PM/Architect,"✅ SLICE_SPEC produced: VS-01-SLICE_SPEC.md (identity/MFA/RBAC/maker-checker contract). Prerequisite AEG-X-001 + AEG-VS-00-02 already COMPLETED. Ready for security team review and schema implementation." AEG-VS-02-01,S1,VS-02,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md; docs/CURRENT/VS-02_DATA_GOVERNANCE_POLICY.md",PM/Architect,"✅ COMPLETE: VS-02-SLICE_SPEC.md + governance policy. All 4 unknowns resolved (data source, import SLA, audit policy, schema versioning). Financial security master implementation ready for Phase 2." -AEG-VS-26-01,S2,VS-26,모델 승인 워크플로우 구현 (Maker-Checker Governance),BLOCKED,-,"docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/ (Handlers.cs, Sql.cs, Policy.cs, Endpoints.cs — sole implementation, wired in Program.cs); db/migrations/0036_approval_workflow.sql; tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs (new, 8 cases); tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs (extended, 10 cases); TECH_DEBT_REGISTER.md DEBT-017/DEBT-023; commit a2e742c (original duplication, superseded)",PM/BE Lead,"🟡 UPDATED 2026-08-08 — DEBT-017 architect decision made and executed: the duplicate `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` (dead code, `[DontRegister]`'d, previously credited with the misleading '20/20 tests PASS') was DELETED along with its dedicated test file. `Features/ApprovalWorkflow/` (the implementation actually reachable over HTTP) is now the sole implementation and gained new Handler+Sql+real-Postgres integration tests covering create/approve/activate role gating, maker≠checker separation of duties, evidence attachment, and an explicit `DateOnly EffectiveAt` round-trip. While porting: found `Features/ApprovalWorkflow/Sql.cs InsertProposalAsync` had the same Dapper `DateOnly`-binding bug already fixed in the deleted implementation (commit 2ccf74c) — fixed identically here (`::date` cast + string parameter). Two residual (pre-existing, not introduced by this cleanup) gaps documented in the slice README rather than fixed: no `GET /approvals/{id}` endpoint, and no wired Draft→Proposed transition anywhere in the running app (approve/activate are therefore currently unreachable end-to-end via HTTP with real data). **Still BLOCKED, not COMPLETED, because no PostgreSQL was reachable in this session (127.0.0.1:5432 connection refused, no SSH tunnel open): `dotnet build -c Release` is clean, but `dotnet test --filter FullyQualifiedName~ApprovalWorkflow -c Release` shows only the 10 pure-Policy (no-DB) tests passing — all 8 new DB-backed integration tests fail with a connection error, unverified either way against a live database.** Do not mark COMPLETED until that run happens against a reachable Postgres and actually passes. Renumbered from VS-03 to VS-26 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-03 in WBS_MASTER.csv ('IngestMarketDataPIT') remains a separate, unrelated, still-unimplemented slice. Also still open: `src/KArtSell.Host/Features/MarketData/VS03_*.cs` is a THIRD, unrelated, already-implemented-and-tested body of work also labeled 'VS-03' that is entirely absent from this tracker — see CURRENT_ROADMAP.md." +AEG-VS-26-01,S2,VS-26,모델 승인 워크플로우 구현 (Maker-Checker Governance),BLOCKED,-,"docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/ (Handlers.cs, Sql.cs, Policy.cs, Endpoints.cs — sole implementation, wired in Program.cs); db/migrations/0036_approval_workflow.sql; tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs (12 cases); tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs (10 cases); TECH_DEBT_REGISTER.md DEBT-017/DEBT-023/DEBT-025/DEBT-026; commit a2e742c (original duplication, superseded)",PM/BE Lead,"🟡 UPDATED 2026-08-08 (second pass, BE priority work) — DEBT-025 and DEBT-026 (both discovered during the DEBT-017 cleanup earlier the same day) are now also code-complete: added `ProposeForReviewHandler` + `POST /approvals/{id}/propose` (wires the previously-dead `ApprovalWorkflowPolicy.CanProposeForReview`, so a proposal created via `POST /approvals` can now reach Approved/Active through the HTTP API end-to-end — this was the higher-impact gap, DEBT-026), and `GetApprovalByIdEndpoint` (`GET /approvals/{id}`) + `ApprovalWorkflowSql.GetEvidenceForProposalAsync` so approval evidence is readable via HTTP (DEBT-025). 4 new tests added (12 total in this file). `dotnet build KArtSell.sln -c Release` clean (0 warnings/0 errors). **Still BLOCKED, not COMPLETED: no PostgreSQL reachable in this session (127.0.0.1:5432 connection refused, no SSH tunnel open) — `dotnet test --filter FullyQualifiedName~ApprovalWorkflowTests -c Release` run 2026-08-08, all 17 matched tests fail with connection-refused (includes an unrelated top-level ApprovalWorkflowTests.cs the substring filter also matches). None of the 12 tests in this file have been confirmed to pass against a live database.** Do not mark COMPLETED until that run happens against a reachable Postgres and actually passes. Renumbered from VS-03 to VS-26 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-03 in WBS_MASTER.csv ('IngestMarketDataPIT') remains a separate, unrelated, still-unimplemented slice. Also still open: `src/KArtSell.Host/Features/MarketData/VS03_*.cs` is a THIRD, unrelated, already-implemented-and-tested body of work also labeled 'VS-03' that is entirely absent from this tracker — see CURRENT_ROADMAP.md." AEG-VS-27-01,S2,VS-27,불변 감사 추적 구현 (Audit Trail / GDPR),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Compliance/ (AuditSql.cs, GdprRetention.cs); tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs; commit 97444c9 (Workstream I, PR #24, merged to main)",PM/BE Lead,"✅ Backend implementation + tests complete: append-only audit_events table, GDPR retention tracking + redaction (actor_email→'', customer_id→''), PII fields (ip_address). 5/5 tests PASS run in isolation (2026-08-07). Also fixed same day (fix/dapper-underscore-mapping-and-build branch): ip_address (inet) and kis_response-style jsonb columns threw InvalidCastException when read through Dapper into a typed class; GdprRetention.RetentionEndsAt was declared DateTime against a DATE column, same failure mode; and a process-wide Dapper snake_case-mapping race condition (KArtSell.BuildingBlocks' [ModuleInitializer] only fires once that assembly loads — AuditSql doesn't reliably touch it) intermittently nulled out every column read from this table depending on unrelated test/host startup order. None of this had ever been exercised against a live database before. ⚠️ No frontend UI yet — in progress 2026-08-08. Renumbered from VS-04 to VS-27 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md — VS-04 in WBS_MASTER.csv ('ApplyCorporateActions') was an unrelated, still-unimplemented slice and keeps its original number unchanged." AEG-VS-05-01,S3,VS-05,정책·범위·실패상태 계약 확정,BLOCKED,-,docs/CURRENT/AEG-VS-05-01_BLOCKER.md,PM/Architect,"2026-08-08: blocked before implementation. WBS defines IngestFundamentalsPIT (REQ-FND-001/DAT-05/MIG-FND-001/002/J04/UI-FND-01/T-FND-001), but existing VS-05 architecture/data contracts define unrelated Risk Metrics. Declared VS-02 dependency is not concretely evidenced beyond AEG-VS-02-01. See blocker record for required PM/Architect decision and Gate G1 evidence. No build/test/migration claimed." AEG-VS-06-01,S3,VS-06,정책·범위·실패상태 계약 확정,BLOCKED,-,docs/CURRENT/AEG-VS-06-01_BLOCKER.md,PM/Architect,"2026-08-08: blocked before implementation. WBS defines MaintainFeeTaxFxSchedule (REQ-COST-001/COST-01/02/MIG-COST-001/J04C/UI-COST-01/T-COST-001), but existing VS-06 architecture/data contracts define unrelated Stress Testing (STRESS-* / migration 0035). Declared VS-02 dependency is not concrete enough for Gate G1. See blocker record for required PM/Architect and Compliance/Owner decisions. No build/test/migration claimed." diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs index 61720362..e0b9ad56 100644 --- a/src/KArtSell.Host/Program.cs +++ b/src/KArtSell.Host/Program.cs @@ -211,6 +211,7 @@ builder.Services.AddScoped new KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow.ApprovalWorkflowSql(connectionString)); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Endpoints.cs b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Endpoints.cs index 4ee87b40..44686760 100644 --- a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Endpoints.cs +++ b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Endpoints.cs @@ -65,6 +65,67 @@ public class GetApprovalsEndpoint : Endpoint +{ + private readonly ProposeForReviewHandler _handler; + + public ProposeForReviewEndpoint(ProposeForReviewHandler handler) => _handler = handler; + + public override void Configure() + { + Post("/approvals/{id}/propose"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var proposalId = Route("id"); + var userEmail = HttpContext.User.FindFirst("email")?.Value ?? "anonymous"; + + await _handler.Handle(proposalId, userEmail, Guid.NewGuid(), ct); + + await Send.OkAsync(new ProposeForReviewResponse(proposalId, "PROPOSED"), ct); + } +} + +public record ApprovalDetailResponse(Guid Id, Guid ModelId, string Status, string CreatedBy, DateTime CreatedAt, + string Justification, DateOnly EffectiveAt, string? ApprovedBy, DateTime? ApprovedAt, string? ApprovalNotes, + string? ActivatedBy, DateTime? ActivatedAt, List Evidence); + +public class GetApprovalByIdEndpoint : EndpointWithoutRequest +{ + private readonly ApprovalWorkflowSql _sql; + + public GetApprovalByIdEndpoint(ApprovalWorkflowSql sql) => _sql = sql; + + public override void Configure() + { + Get("/approvals/{id}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var proposalId = Route("id"); + var proposal = await _sql.GetProposalAsync(proposalId, ct); + if (proposal is null) + { + await Send.NotFoundAsync(ct); + return; + } + + var evidence = await _sql.GetEvidenceForProposalAsync(proposalId, ct); + var evidenceDtos = evidence.Select(e => new EvidenceDto(e.EvidenceType, e.EvidenceUrl, e.ReviewerComment)).ToList(); + + await Send.OkAsync(new ApprovalDetailResponse( + proposal.Id, proposal.ModelId, proposal.Status.ToString(), proposal.CreatedBy, proposal.CreatedAt, + proposal.Justification, proposal.EffectiveAt, proposal.ApprovedBy, proposal.ApprovedAt, + proposal.ApprovalNotes, proposal.ActivatedBy, proposal.ActivatedAt, evidenceDtos), ct); + } +} + public record ApproveApprovalRequest(string ApprovalNotes, List Evidence); public record EvidenceDto(string Type, string Url, string? Comment); public record ApproveApprovalResponse(Guid Id, string Status, DateTime ApprovedAt); diff --git a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Handlers.cs b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Handlers.cs index 0afc7a69..6c4722be 100644 --- a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Handlers.cs +++ b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Handlers.cs @@ -43,6 +43,35 @@ public class CreateApprovalProposalHandler } } +public class ProposeForReviewHandler +{ + private readonly ApprovalWorkflowSql _sql; + private readonly IClock _clock; + + public ProposeForReviewHandler(ApprovalWorkflowSql sql, IClock clock) + { + _sql = sql; + _clock = clock; + } + + public async Task Handle(Guid proposalId, string userEmail, Guid correlationId, CancellationToken ct = default) + { + var proposal = await _sql.GetProposalAsync(proposalId, ct) + ?? throw new KeyNotFoundException($"Proposal {proposalId} not found"); + + if (!ApprovalWorkflowPolicy.CanProposeForReview(proposal, userEmail)) + throw new UnauthorizedAccessException("Only the proposal's Maker can move it from Draft to Proposed"); + + ApprovalWorkflowPolicy.ValidateProposalState(proposal.Status, ApprovalStatus.Proposed); + + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed, ct: ct); + + var now = _clock.UtcNow.UtcDateTime; + var proposeEvent = ApprovalWorkflowPolicy.CreateStateChangeEvent(proposalId, ApprovalStatus.Proposed, userEmail, correlationId, now); + await _sql.InsertEventAsync(proposeEvent, ct); + } +} + public class ApproveApprovalHandler { private readonly ApprovalWorkflowSql _sql; diff --git a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs index 3b8f8b12..c95a7e59 100644 --- a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs +++ b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs @@ -99,6 +99,21 @@ public class ApprovalWorkflowSql }); } + public async Task> GetEvidenceForProposalAsync(Guid proposalId, CancellationToken ct = default) + { + const string sql = """ + SELECT id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, + published_at, correlation_id + FROM model_operations.approval_evidence + WHERE approval_proposal_id = @proposalId + ORDER BY published_at + """; + + using var conn = new NpgsqlConnection(_connectionString); + var evidence = await conn.QueryAsync(sql, new { proposalId }); + return evidence.ToList(); + } + public async Task InsertEvidenceAsync(ApprovalEvidence evidence, CancellationToken ct = default) { const string sql = """ diff --git a/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs b/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs index 89a62f20..60bb0aa4 100644 --- a/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs +++ b/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs @@ -94,6 +94,77 @@ public class ApprovalWorkflowTests : IAsyncLifetime handler.Handle("viewer@company.com", "Viewer", modelId, DateOnly.FromDateTime(DateTime.UtcNow), "no role", Guid.NewGuid())); } + [Fact] + public async Task ProposeForReview_ByCreatingMaker_TransitionsDraftToProposed() + { + // Arrange: this handler didn't exist until DEBT-026 was fixed — before that, a proposal + // created via POST /approvals could never reach Proposed/Approved/Active through the + // running application at all (only test code bypassing the handler via + // _sql.UpdateProposalStatusAsync directly could move it, as the other tests in this file + // still do to set up their own preconditions). + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var proposalId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Ready to send for review", Guid.NewGuid()); + + var proposeHandler = new ProposeForReviewHandler(_sql, _clock); + + // Act + await proposeHandler.Handle(proposalId, "maker@company.com", Guid.NewGuid()); + + // Assert + var proposal = await _sql.GetProposalAsync(proposalId); + Assert.NotNull(proposal); + Assert.Equal(ApprovalStatus.Proposed, proposal!.Status); + } + + [Fact] + public async Task ProposeForReview_ByDifferentUserThanCreator_ThrowsUnauthorized() + { + // Arrange + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var proposalId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Only the creator may propose it", Guid.NewGuid()); + + var proposeHandler = new ProposeForReviewHandler(_sql, _clock); + + // Act & Assert + await Assert.ThrowsAsync(() => + proposeHandler.Handle(proposalId, "someone-else@company.com", Guid.NewGuid())); + } + + [Fact] + public async Task GetEvidenceForProposalAsync_ReturnsEvidenceAttachedDuringApproval() + { + // Arrange: this query didn't exist until DEBT-025 was fixed — evidence attached during + // approval (the whole point of this slice per CLAUDE.md's "Activation gating") was + // otherwise only readable by querying model_operations.approval_evidence directly. + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var proposalId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Needs evidence readback", Guid.NewGuid()); + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); + + var approveHandler = new ApproveApprovalHandler(_sql, _clock); + var evidence = new List<(string Type, string Url, string? Comment)> + { + ("PBO_SCORE", "s3://evidence/pbo-0.95.json", "Verified"), + }; + await approveHandler.Handle(proposalId, "checker@company.com", "Checker", "Approved", evidence, Guid.NewGuid()); + + // Act + var retrieved = await _sql.GetEvidenceForProposalAsync(proposalId); + + // Assert + Assert.Single(retrieved); + Assert.Equal("PBO_SCORE", retrieved[0].EvidenceType); + Assert.Equal("s3://evidence/pbo-0.95.json", retrieved[0].EvidenceUrl); + } + [Fact] public async Task Approve_WithDifferentChecker_TransitionsToApproved_AndAttachesEvidence() {