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:
2026-08-02 13:16:39 +09:00
parent 6330a7b262
commit 06d3023e53
11 changed files with 552 additions and 0 deletions
@@ -0,0 +1,26 @@
using FastEndpoints;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Time;
namespace KArtSell.Modules.ModelOperations.Features.ApproveModel;
public sealed class Endpoint(IDbConnectionFactory connectionFactory, IClock clock) : Endpoint<Request, Response>
{
public override void Configure()
{
Post("/api/v1/approval-queue/{id}/approve");
Roles("Risk", "Compliance");
Tags("ModelOperations");
}
public override async Task HandleAsync(Request req, CancellationToken ct)
{
var handler = new Handler(connectionFactory, clock);
var userId = User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException("User ID not found");
var approverUserId = Guid.TryParse(userId, out var userIdGuid) ? userIdGuid : Guid.Empty;
var response = await handler.HandleAsync(req, approverUserId, ct);
await Send.OkAsync(response, ct);
}
}
@@ -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);
}
}
@@ -0,0 +1,5 @@
namespace KArtSell.Modules.ModelOperations.Features.ApproveModel;
public sealed record Request(
Guid RunId,
string ApprovalReason);
@@ -0,0 +1,7 @@
namespace KArtSell.Modules.ModelOperations.Features.ApproveModel;
public sealed record Response(
Guid ApprovalId,
Guid RunId,
string Status,
DateTime ApprovedAt);
@@ -0,0 +1,40 @@
using Dapper;
using FastEndpoints;
using KArtSell.BuildingBlocks.Data;
namespace KArtSell.Modules.ModelOperations.Features.GetApprovalQueue;
public sealed class Endpoint(IDbConnectionFactory connectionFactory) : EndpointWithoutRequest<Response>
{
public override void Configure()
{
Get("/api/v1/approval-queue");
Roles("Risk", "Compliance", "Trading");
Tags("ModelOperations");
}
public override async Task HandleAsync(CancellationToken ct)
{
await using var connection = await connectionFactory.OpenAsync(ct);
var queue = await connection.QueryAsync<ApprovalItem>("""
SELECT
id,
run_id AS RunId,
model_id AS ModelId,
status AS Status,
approved_by AS ApprovedBy,
approval_reason AS ApprovalReason,
rejection_reason AS RejectionReason,
requested_at AS RequestedAt,
approved_at AS ApprovedAt,
rejected_at AS RejectedAt
FROM model_operations.approval_queue
ORDER BY
CASE WHEN status = 'Pending' THEN 0 ELSE 1 END,
requested_at DESC
""");
await Send.OkAsync(new Response(queue.ToArray()), ct);
}
}
@@ -0,0 +1,15 @@
namespace KArtSell.Modules.ModelOperations.Features.GetApprovalQueue;
public sealed record ApprovalItem(
Guid Id,
Guid RunId,
Guid ModelId,
string Status,
Guid? ApprovedBy,
string? ApprovalReason,
string? RejectionReason,
DateTime RequestedAt,
DateTime? ApprovedAt,
DateTime? RejectedAt);
public sealed record Response(ApprovalItem[] Queue);
@@ -0,0 +1,26 @@
using FastEndpoints;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Time;
namespace KArtSell.Modules.ModelOperations.Features.RejectModel;
public sealed class Endpoint(IDbConnectionFactory connectionFactory, IClock clock) : Endpoint<Request, Response>
{
public override void Configure()
{
Post("/api/v1/approval-queue/{id}/reject");
Roles("Risk", "Compliance");
Tags("ModelOperations");
}
public override async Task HandleAsync(Request req, CancellationToken ct)
{
var handler = new Handler(connectionFactory, clock);
var userId = User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException("User ID not found");
var rejecterUserId = Guid.TryParse(userId, out var userIdGuid) ? userIdGuid : Guid.Empty;
var response = await handler.HandleAsync(req, rejecterUserId, ct);
await Send.OkAsync(response, ct);
}
}
@@ -0,0 +1,46 @@
using Dapper;
using KArtSell.BuildingBlocks.Data;
using KArtSell.BuildingBlocks.Time;
namespace KArtSell.Modules.ModelOperations.Features.RejectModel;
public sealed class Handler(IDbConnectionFactory connectionFactory, IClock clock)
{
public async Task<Response> HandleAsync(Request request, Guid rejecterUserId, 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 rejected_at)
var now = clock.UtcNow.DateTime;
await connection.ExecuteAsync("""
UPDATE model_operations.approval_queue
SET
status = 'Rejected',
rejection_reason = @RejectionReason
WHERE run_id = @RunId
""",
new
{
request.RunId,
request.RejectionReason
});
return new Response(
existing.Value.Id,
request.RunId,
"Rejected",
now);
}
}
@@ -0,0 +1,5 @@
namespace KArtSell.Modules.ModelOperations.Features.RejectModel;
public sealed record Request(
Guid RunId,
string RejectionReason);
@@ -0,0 +1,7 @@
namespace KArtSell.Modules.ModelOperations.Features.RejectModel;
public sealed record Response(
Guid ApprovalId,
Guid RunId,
string Status,
DateTime RejectedAt);