Gate 4: Manual Activation Workflow (Approval Queue & Maker-Checker)
Implements validation gate 4: Model activation workflow with approval queue, maker-checker pattern
Backend implementation (3 vertical slices):
1. GetApprovalQueue endpoint - List pending/approved/rejected approvals (GET /api/v1/approval-queue)
2. ApproveModel endpoint - Maker-checker approval with reason (POST /api/v1/approval-queue/{id}/approve)
3. RejectModel endpoint - Rejection with reason (POST /api/v1/approval-queue/{id}/reject)
Features:
- Approval status transitions (Pending → Approved/Rejected)
- Timestamp tracking (requested_at, approved_at, rejected_at)
- Maker-checker pattern (approved_by user tracking)
- UNIQUE constraint on run_id (prevents duplicate approvals)
- PL/pgSQL triggers enforce data integrity (approved_at/rejection_reason validation)
- Role-based access (Risk, Compliance roles)
Test coverage (6 scenarios):
1. Approval queue listing by status
2. Approval status update with approver tracking
3. Constraint validation (prevent re-approval)
4. Rejection workflow with reason tracking
5. Audit trail timestamps (end-to-end traceability)
6. Unique constraint on run_id (idempotency)
AGENTS.md v16.0 compliance:
✓ Vertical slice pattern (endpoint→handler→query)
✓ Constraint-enforced workflow (DB triggers)
✓ Audit trails (timestamps, approver tracking)
✓ Maker-checker authorization checks
✓ Role-based access control
Test status: 6 integration tests + existing 47 tests passing
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using Dapper;
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
using KArtSell.BuildingBlocks.Time;
|
||||
|
||||
namespace KArtSell.Modules.ModelOperations.Features.ApproveModel;
|
||||
|
||||
public sealed class Handler(IDbConnectionFactory connectionFactory, IClock clock)
|
||||
{
|
||||
public async Task<Response> HandleAsync(Request request, Guid approverUserId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(ct);
|
||||
|
||||
// Verify approval exists and is Pending
|
||||
var existing = await connection.QuerySingleOrDefaultAsync<(Guid Id, string Status)?>("""
|
||||
SELECT id, status FROM model_operations.approval_queue WHERE run_id = @RunId
|
||||
""",
|
||||
new { request.RunId });
|
||||
|
||||
if (!existing.HasValue)
|
||||
throw new InvalidOperationException($"Approval not found for run {request.RunId}");
|
||||
|
||||
if (existing.Value.Status != "Pending")
|
||||
throw new InvalidOperationException($"Approval status is {existing.Value.Status}, not Pending");
|
||||
|
||||
// Update approval status (trigger will set approved_at)
|
||||
var now = clock.UtcNow.DateTime;
|
||||
await connection.ExecuteAsync("""
|
||||
UPDATE model_operations.approval_queue
|
||||
SET
|
||||
status = 'Approved',
|
||||
approved_by = @ApprovedBy,
|
||||
approval_reason = @ApprovalReason
|
||||
WHERE run_id = @RunId
|
||||
""",
|
||||
new
|
||||
{
|
||||
request.RunId,
|
||||
ApprovedBy = approverUserId,
|
||||
request.ApprovalReason
|
||||
});
|
||||
|
||||
return new Response(
|
||||
existing.Value.Id,
|
||||
request.RunId,
|
||||
"Approved",
|
||||
now);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user