# VS-26 (formerly VS-03): Model Approval Workflow (Maker-Checker Governance) ## Overview This slice implements a maker-checker approval workflow for model activation with separation of duties and immutable audit trail. **This is now the sole implementation of this slice.** A second, functionally-overlapping copy (`src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/`, no `Features/` prefix) existed alongside this one from 2026-08-07 to 2026-08-08; it was dead code (all 4 endpoints `[DontRegister]`'d to avoid a duplicate-route crash at Host startup) despite having 20/20 passing tests, while *this* implementation — the one actually wired into `Program.cs` and reachable over HTTP — had no dedicated tests. See `TECH_DEBT_REGISTER.md` DEBT-017 and `docs/DECISIONS/ADR-WBS-001-slice-renumbering.md` for the full history. The old implementation and its test file were deleted on 2026-08-08 once this one gained equivalent integration-test coverage (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`). **Bug fixed 2026-08-08 (as part of DEBT-017):** `InsertProposalAsync` in `Sql.cs` passed `EffectiveAt` (a `DateOnly`) directly as a Dapper parameter. The now-deleted implementation hit the identical failure against a real database (commit `2ccf74c`) — Npgsql/Dapper in this environment cannot bind a bare `DateOnly` value; it needs an explicit `::date` cast plus a `"yyyy-MM-dd"` string parameter. That fix has been ported here. **This has not been re-verified against a live PostgreSQL instance in this session** (none was reachable) — see "Test status" below. ## Architecture ### State Machine ``` DRAFT (created) ↓ PROPOSED (maker submits) ├→ APPROVED (checker approves) │ ↓ │ ACTIVE (SRE activates) │ └→ REJECTED (checker rejects) ``` ### RBAC Roles - **Maker:** Creates approval proposals (own proposals only) - **Checker:** Reviews and approves (must be different from Maker) - **SRE:** Activates approved proposals ### Components 1. **ApprovalProposal (Domain Entity)** - Model approval proposals with PIT tracking - Stores justification, effective date, approval notes - Immutable except for status transitions 2. **ApprovalWorkflowSql (Data Access)** - Dapper queries for INSERT/SELECT operations - PIT tracking with correlation_id - No UPDATE/DELETE (append-only) 3. **ApprovalWorkflowPolicy (Domain Logic)** - State machine validation - RBAC enforcement - Event generation 4. **Handlers (Application Layer)** - CreateApprovalProposalHandler - ApproveApprovalHandler - ActivateModelHandler - Outbox events on each state change 5. **Endpoints (HTTP Layer)** - POST /approvals (create proposal) - GET /approvals (list proposals) - POST /approvals/{id}/approve (approve proposal) - ⚠️ **No `GET /approvals/{id}`.** The deleted duplicate implementation had a single-proposal fetch endpoint that included the evidence list in its response; this implementation has no equivalent, so evidence attached during approval is currently unreachable via HTTP (it can only be read back through `ApprovalWorkflowSql` directly, e.g. in tests). Not fixed here — out of scope for DEBT-017 (duplicate-implementation cleanup); tracked as **DEBT-025**. - ⚠️ **No wired "submit for review" transition.** `ApprovalWorkflowPolicy.CanProposeForReview` exists but no `Handler` or `Endpoint` calls it, so nothing in the running application ever moves a proposal from `Draft` to `Proposed`. `ActivateModelHandler` and `ApproveApprovalHandler` both require `Proposed`/`Approved` respectively, so as shipped a created proposal cannot reach `Approved` through the HTTP API alone. Also not fixed here — tracked as **DEBT-026**. ## API Contracts ### POST /approvals (Create Proposal) Request: ```json { "modelId": "uuid", "effectiveAt": "2026-09-15", "justification": "Model passed OOS testing; PBO score 0.95" } ``` Response (201): ```json { "id": "uuid", "status": "DRAFT", "createdAt": "2026-08-07T10:00:00Z" } ``` ### GET /approvals (List Proposals) Query Params: - `status=PROPOSED` (filter by status) - `modelId=uuid` (filter by model) - `limit=50`, `offset=0` (pagination) Response (200): ```json { "items": [ { "id": "uuid", "modelId": "uuid", "status": "PROPOSED", "createdBy": "maker@company.com", "createdAt": "2026-08-07T10:00:00Z", "justification": "..." } ], "total": 1, "pages": 1 } ``` ### POST /approvals/{id}/approve (Approve Proposal) Request: ```json { "approvalNotes": "PBO verified, OOS metrics acceptable", "evidence": [ {"type": "PBO_SCORE", "url": "s3://evidence/pbo-0.95.json", "comment": "Confirmed"}, {"type": "OOS_RETURN", "url": "s3://evidence/oos-returns.csv", "comment": "Acceptable"} ] } ``` Response (200): ```json { "id": "uuid", "status": "APPROVED", "approvedAt": "2026-08-07T11:00:00Z" } ``` ## Database Schema ### approval_proposals ```sql CREATE TABLE model_operations.approval_proposals ( id UUID PRIMARY KEY, model_id UUID NOT NULL, status VARCHAR(50), -- DRAFT, PROPOSED, APPROVED, ACTIVE, REJECTED created_by VARCHAR(255), created_at TIMESTAMPTZ, justification TEXT, effective_at DATE, proposed_at TIMESTAMPTZ, approved_by VARCHAR(255), approved_at TIMESTAMPTZ, approval_notes TEXT, activated_by VARCHAR(255), activated_at TIMESTAMPTZ, published_at TIMESTAMPTZ, revision INT, correlation_id UUID ); ``` ### approval_evidence ```sql CREATE TABLE model_operations.approval_evidence ( id UUID PRIMARY KEY, approval_proposal_id UUID NOT NULL, evidence_type VARCHAR(50), -- PBO_SCORE, DSR_METRIC, OOS_RETURN, BACKTEST_REPORT evidence_url TEXT, reviewer_comment TEXT, published_at TIMESTAMPTZ, correlation_id UUID ); ``` ### approval_events ```sql CREATE TABLE model_operations.approval_events ( id UUID PRIMARY KEY, approval_proposal_id UUID NOT NULL, event_type VARCHAR(50), -- CREATED, PROPOSED, APPROVED, REJECTED, ACTIVATED actor_email VARCHAR(255), event_at TIMESTAMPTZ, details JSONB, published_at TIMESTAMPTZ, correlation_id UUID ); ``` ## Tests - `tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs` — pure `ApprovalWorkflowPolicy` unit tests (no DB): RBAC enforcement (Maker/Checker/SRE), separation of duties, valid/invalid state transitions. Fast, deterministic. - `tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs` — Handler + Sql + real PostgreSQL integration tests: create (role-gated), approve (maker≠checker, evidence attachment), activate (SRE-gated), `EffectiveAt` `DateOnly` round-trip through a real `date` column, list filtering. **Test status as of 2026-08-08 (DEBT-017 resolution session): written but not run against a live database.** No PostgreSQL was reachable at `127.0.0.1:5432` in that session (no SSH tunnel to 178.104.200.7 open). `dotnet build -c Release` was confirmed green; `dotnet test --filter "FullyQualifiedName~ApprovalWorkflow"` was run and its actual outcome (pass, fail, or DB connection error) is recorded in `TECH_DEBT_REGISTER.md` DEBT-017 and `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` row `AEG-VS-26-01` — check those before treating this slice as verified. Run tests: ```bash dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release ``` ## AGENTS.md v16.0 Compliance - ✅ **SOLID:** Separate Endpoint/Handler/Policy/Sql per operation - ✅ **Complexity:** Each handler ≤200 lines - ✅ **Audit:** All state changes logged with correlation_id - ✅ **Necessity:** Grounded in VS-26 (formerly VS-03) SLICE_SPEC - ⚠️ **Normalization:** Writes mutate `approval_proposals` in place (`UPDATE ... revision = revision + 1`) rather than appending a new revision row, because `id` is the sole `PRIMARY KEY` in migration `0036_approval_workflow.sql` (no `(id, published_at)` composite key) — an append-only INSERT would violate that constraint on the second write. This is a real deviation from CLAUDE.md's "new state appended as new revision" rule; it is pre-existing (present before this session) and schema-level, so fixing it is out of scope for DEBT-017. `GetProposalAsync` correspondingly has no `published_at <= cutoff` PIT filter, since there is only ever one row. - ✅ **Simplicity:** State machine clearly visible - ✅ **Pattern:** Vertical Slice standard - ✅ **Guardrails:** RBAC enforced, no privilege escalation - ✅ **Traceability:** Correlation_id + evidence linking - ⚠️ **Safety:** DateOnly parameter binding bug fixed 2026-08-08; unverified against a live DB this session (see "Test status" above) - ✅ **Maturity:** Spec complete before code - ✅ **Right-Way:** Duplicate implementation resolved per DEBT-017, not worked around - ⚠️ **Debt:** DEBT-017 (duplicate implementation) resolved; two residual gaps discovered by this cleanup (predate it, not introduced by it) are registered as DEBT-025 (no `GET /approvals/{id}`) and DEBT-026 (no wired Draft→Proposed transition) ## Related Specifications - **VS-00:** PIT envelope (published_at, correlation_id, revision) - **VS-02:** Governance foundation (data sources, policies) - **VS-27 (formerly VS-04):** Audit trail (events logged by this slice) - **Compliance:** Maker-checker separation, evidence linkage --- **Status:** Backend implementation is the canonical (sole) copy of this slice as of 2026-08-08; integration tests exist but are unverified against a live database (see "Test status"). Not "IMPLEMENTATION COMPLETE" until that verification runs and the two residual gaps above are resolved or explicitly accepted. **Co-Authored-By:** Claude Sonnet 5