fix: resolve DEBT-017 duplicate ApprovalWorkflow implementation

Adopt Features/ApprovalWorkflow/ (wired into Program.cs, reachable over
HTTP) as the sole VS-26 (formerly VS-03) maker-checker approval slice.
Delete the dead, [DontRegister]'d duplicate under
ApprovalWorkflow/ (Workstream H) and its dedicated test file, which had
been misleadingly credited with "20/20 tests PASS" while being
unreachable at runtime.

- Sql.cs: fix the same Dapper DateOnly-parameter-binding bug that was
  already found and fixed in the now-deleted implementation
  (commit 2ccf74c) but had not been ported to this one; InsertProposalAsync
  would have failed 100% of the time against a real database.
- tests/.../ApprovalWorkflow/ApprovalWorkflowTests.cs: new Handler+Sql+
  real-Postgres integration coverage (create/approve/activate role
  gating, maker!=checker separation of duties, evidence attachment,
  DateOnly round-trip, list filtering) replacing the deleted dead-code
  suite at the same path.
- ApprovalWorkflowPolicyTests.cs: extended (5->10 cases) rather than
  replaced, since it already tested the kept implementation's Policy.
- Program.cs: drop the reference comment to the deleted namespace.
- TECH_DEBT_REGISTER.md: DEBT-017 marked Completed (DB verification
  pending); corrected stale DEBT-023 to point at this resolution;
  registered two residual gaps discovered (not introduced) by this
  cleanup as DEBT-025 (no GET /approvals/{id}, evidence unreachable via
  HTTP) and DEBT-026 (no wired Draft->Proposed transition, so the
  approve/activate path is currently unreachable end-to-end via HTTP).
- WBS_PROGRESS_TRACKER.csv / CURRENT_ROADMAP.md: AEG-VS-26-01 kept
  BLOCKED, not COMPLETED — no PostgreSQL was reachable in this session
  (127.0.0.1:5432 connection refused), so the 8 new integration tests
  are unverified; only the 10 pure-Policy tests were confirmed passing.

Cherry-picked cedc8d7/8c777df from docs/wbs-tracker-current-state onto
this worktree branch first, to bring in the VS-26 renumbering and
ADR-WBS-001 that this task's brief assumed already existed.

dotnet build -c Release: 0 errors/0 warnings.
dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release:
10 passed (Policy, no DB), 15 failed (DB connection refused - includes
6 unrelated pre-existing tests matched by the filter substring).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 13:05:03 +09:00
parent df7d41df7d
commit 14e2cedc4f
14 changed files with 324 additions and 1284 deletions
@@ -1,177 +0,0 @@
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FastEndpoints;
using KArtSell.BuildingBlocks.Time;
/// <summary>
/// Superseded by Features.ApprovalWorkflow.CreateApprovalEndpoint (same route). Kept for
/// ApprovalWorkflowTests.cs coverage of ApprovalSql/ApprovalPolicy; excluded from route
/// registration to avoid a duplicate-route conflict at Host startup. See TECH_DEBT_REGISTER.md.
/// </summary>
[DontRegister]
public class CreateApprovalEndpoint : Endpoint<CreateApprovalProposalRequest, ApprovalProposalResponse>
{
private readonly CreateApprovalProposalHandler _handler;
public CreateApprovalEndpoint(CreateApprovalProposalHandler handler)
{
_handler = handler;
}
public override void Configure()
{
Post("/approvals");
AllowAnonymous();
}
public override async Task HandleAsync(CreateApprovalProposalRequest req, CancellationToken ct)
{
var userEmail = User?.FindFirst("email")?.Value ?? "system@kartsell.local";
var userRole = User?.FindFirst("role")?.Value;
var response = await _handler.Handle(req, userEmail, userRole);
await Send.CreatedAtAsync<GetApprovalEndpoint>(new { id = response.Id }, response, cancellation: ct);
}
}
/// <summary>Superseded by Features.ApprovalWorkflow (same route). See CreateApprovalEndpoint remarks.</summary>
[DontRegister]
public class ListApprovalsEndpoint : Endpoint<EmptyRequest, List<ApprovalProposalResponse>>
{
private readonly ApprovalSql _sql;
private readonly IClock _clock;
public ListApprovalsEndpoint(ApprovalSql sql, IClock clock)
{
_sql = sql;
_clock = clock;
}
public override void Configure()
{
Get("/approvals");
AllowAnonymous();
}
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
{
var status = Query<string?>("status");
var cutoff = _clock.UtcNow;
List<ApprovalProposal> proposals;
if (!string.IsNullOrEmpty(status))
{
proposals = await _sql.GetProposalsByStatusAsync(status, cutoff);
}
else
{
proposals = await _sql.GetProposalsByStatusAsync("Proposed", cutoff);
}
var responses = proposals.ConvertAll(p => new ApprovalProposalResponse
{
Id = p.Id,
ModelId = p.ModelId,
Status = p.Status.ToString(),
CreatedBy = p.CreatedBy,
CreatedAt = p.CreatedAt,
Justification = p.Justification,
EffectiveAt = p.EffectiveAt,
ApprovedBy = p.ApprovedBy,
ApprovedAt = p.ApprovedAt,
ApprovalNotes = p.ApprovalNotes
});
await Send.OkAsync(responses, ct);
}
}
/// <summary>Superseded by Features.ApprovalWorkflow (same route). See CreateApprovalEndpoint remarks.</summary>
[DontRegister]
public class GetApprovalEndpoint : Endpoint<EmptyRequest, ApprovalProposalResponse>
{
private readonly ApprovalSql _sql;
private readonly IClock _clock;
public GetApprovalEndpoint(ApprovalSql sql, IClock clock)
{
_sql = sql;
_clock = clock;
}
public override void Configure()
{
Get("/approvals/{id}");
AllowAnonymous();
}
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
{
var id = Route<Guid>("id");
var cutoff = _clock.UtcNow;
var proposal = await _sql.GetProposalByIdAsync(id, cutoff);
if (proposal == null)
{
await Send.NotFoundAsync(ct);
return;
}
var response = new ApprovalProposalResponse
{
Id = proposal.Id,
ModelId = proposal.ModelId,
Status = proposal.Status.ToString(),
CreatedBy = proposal.CreatedBy,
CreatedAt = proposal.CreatedAt,
Justification = proposal.Justification,
EffectiveAt = proposal.EffectiveAt,
ApprovedBy = proposal.ApprovedBy,
ApprovedAt = proposal.ApprovedAt,
ApprovalNotes = proposal.ApprovalNotes,
Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse
{
Id = e.Id,
EvidenceType = e.EvidenceType,
EvidenceUrl = e.EvidenceUrl,
ReviewerComment = e.ReviewerComment
})
};
await Send.OkAsync(response, ct);
}
}
/// <summary>Superseded by Features.ApprovalWorkflow (same route). See CreateApprovalEndpoint remarks.</summary>
[DontRegister]
public class ApproveApprovalEndpoint : Endpoint<ApproveApprovalRequest, ApprovalProposalResponse>
{
private readonly ApproveApprovalHandler _handler;
private readonly IClock _clock;
public ApproveApprovalEndpoint(ApproveApprovalHandler handler, IClock clock)
{
_handler = handler;
_clock = clock;
}
public override void Configure()
{
Post("/approvals/{id}/approve");
AllowAnonymous();
}
public override async Task HandleAsync(ApproveApprovalRequest req, CancellationToken ct)
{
var id = Route<Guid>("id");
var checkerEmail = User?.FindFirst("email")?.Value ?? "system@kartsell.local";
var cutoff = _clock.UtcNow;
var response = await _handler.Handle(id, req, checkerEmail, cutoff);
await Send.OkAsync(response, ct);
}
}
@@ -1,205 +0,0 @@
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using KArtSell.BuildingBlocks;
public class CreateApprovalProposalHandler
{
private readonly ApprovalSql _sql;
private readonly ApprovalPolicy _policy;
private readonly IOutbox _outbox;
public CreateApprovalProposalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox)
{
_sql = sql;
_policy = policy;
_outbox = outbox;
}
public async Task<ApprovalProposalResponse> Handle(
CreateApprovalProposalRequest request,
string userEmail,
string? userRole)
{
if (!_policy.CanCreateProposal(userEmail, userRole))
throw new UnauthorizedAccessException("Only Makers can create approval proposals");
var proposal = _policy.CreateProposal(
request.ModelId,
userEmail,
request.Justification,
request.EffectiveAt);
await _sql.InsertProposalAsync(
proposal.Id,
proposal.ModelId,
proposal.Status.ToString(),
proposal.CreatedBy,
proposal.Justification,
proposal.EffectiveAt,
proposal.PublishedAt,
proposal.CorrelationId);
// Log event
var evt = _policy.CreateProposalEvent(proposal, "CREATED", userEmail);
await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId);
// Emit Outbox event
await _outbox.PublishAsync("ApprovalProposalCreated", proposal.CorrelationId, new { proposal.Id, proposal.ModelId });
return MapToResponse(proposal);
}
private ApprovalProposalResponse MapToResponse(ApprovalProposal proposal)
{
return new ApprovalProposalResponse
{
Id = proposal.Id,
ModelId = proposal.ModelId,
Status = proposal.Status.ToString(),
CreatedBy = proposal.CreatedBy,
CreatedAt = proposal.CreatedAt,
Justification = proposal.Justification,
EffectiveAt = proposal.EffectiveAt,
ApprovedBy = proposal.ApprovedBy,
ApprovedAt = proposal.ApprovedAt,
ApprovalNotes = proposal.ApprovalNotes,
Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse
{
Id = e.Id,
EvidenceType = e.EvidenceType,
EvidenceUrl = e.EvidenceUrl,
ReviewerComment = e.ReviewerComment
})
};
}
}
public class ApproveApprovalHandler
{
private readonly ApprovalSql _sql;
private readonly ApprovalPolicy _policy;
private readonly IOutbox _outbox;
public ApproveApprovalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox)
{
_sql = sql;
_policy = policy;
_outbox = outbox;
}
public async Task<ApprovalProposalResponse> Handle(
Guid proposalId,
ApproveApprovalRequest request,
string checkerEmail,
DateTimeOffset cutoff)
{
var proposal = await _sql.GetProposalByIdAsync(proposalId, cutoff)
?? throw new KeyNotFoundException("Approval proposal not found");
if (!_policy.CanApproveApproval(proposal, checkerEmail, proposal.CreatedBy))
throw new UnauthorizedAccessException("Cannot approve: separation of duties violation or wrong status");
proposal = _policy.ApproveApproval(proposal, checkerEmail, request.ApprovalNotes, request.Evidence);
// Update proposal
await _sql.UpdateProposalStatusAsync(
proposal.Id,
proposal.Status.ToString(),
checkerEmail,
request.ApprovalNotes,
proposal.PublishedAt);
// Add evidence
foreach (var evidence in request.Evidence)
{
await _sql.InsertEvidenceAsync(
Guid.NewGuid(),
proposal.Id,
evidence.Type,
evidence.Url,
evidence.Comment,
proposal.CorrelationId);
}
// Log event
var evt = _policy.CreateProposalEvent(proposal, "APPROVED", checkerEmail);
await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId);
// Emit Outbox event
await _outbox.PublishAsync("ApprovalProposalApproved", proposal.CorrelationId, new { proposal.Id, checkerEmail });
return MapToResponse(proposal);
}
private ApprovalProposalResponse MapToResponse(ApprovalProposal proposal)
{
return new ApprovalProposalResponse
{
Id = proposal.Id,
ModelId = proposal.ModelId,
Status = proposal.Status.ToString(),
CreatedBy = proposal.CreatedBy,
CreatedAt = proposal.CreatedAt,
Justification = proposal.Justification,
EffectiveAt = proposal.EffectiveAt,
ApprovedBy = proposal.ApprovedBy,
ApprovedAt = proposal.ApprovedAt,
ApprovalNotes = proposal.ApprovalNotes,
Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse
{
Id = e.Id,
EvidenceType = e.EvidenceType,
EvidenceUrl = e.EvidenceUrl,
ReviewerComment = e.ReviewerComment
})
};
}
}
public class ActivateApprovalHandler
{
private readonly ApprovalSql _sql;
private readonly ApprovalPolicy _policy;
private readonly IOutbox _outbox;
public ActivateApprovalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox)
{
_sql = sql;
_policy = policy;
_outbox = outbox;
}
public async Task Handle(Guid proposalId, string sreEmail, string? userRole, DateTimeOffset cutoff)
{
if (!_policy.CanActivateApproval(new ApprovalProposal { CreatedBy = string.Empty, Justification = string.Empty }, userRole ?? string.Empty))
throw new UnauthorizedAccessException("Only SRE can activate approvals");
var proposal = await _sql.GetProposalByIdAsync(proposalId, cutoff)
?? throw new KeyNotFoundException("Approval proposal not found");
proposal = _policy.ActivateApproval(proposal, sreEmail);
// Update proposal status to ACTIVE
await _sql.UpdateProposalStatusAsync(
proposal.Id,
proposal.Status.ToString(),
sreEmail,
null,
proposal.PublishedAt);
// Log event
var evt = _policy.CreateProposalEvent(proposal, "ACTIVATED", sreEmail);
await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId);
// Emit Outbox event for model activation
await _outbox.PublishAsync("ApprovalProposalActivated", proposal.CorrelationId, new { proposal.Id, proposal.ModelId });
}
}
public interface IOutbox
{
Task PublishAsync(string eventType, Guid correlationId, object data);
}
@@ -1,162 +0,0 @@
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
using System;
using System.Collections.Generic;
using System.Linq;
using KArtSell.BuildingBlocks.Time;
public class ApprovalPolicy
{
private readonly IClock _clock;
public ApprovalPolicy(IClock clock)
{
_clock = clock;
}
public bool CanCreateProposal(string userEmail, string? userRole)
{
return userRole is "Maker" or "Admin";
}
public bool CanProposeApproval(ApprovalProposal proposal, string userEmail)
{
if (proposal.Status != ApprovalStatus.Draft)
return false;
return proposal.CreatedBy == userEmail;
}
public bool CanApproveApproval(ApprovalProposal proposal, string checkerEmail, string makerEmail)
{
if (proposal.Status != ApprovalStatus.Proposed)
return false;
if (checkerEmail == makerEmail)
return false; // Separation of duties: Maker cannot approve own proposal
return true;
}
public bool CanActivateApproval(ApprovalProposal proposal, string userRole)
{
if (proposal.Status != ApprovalStatus.Approved)
return false;
return userRole is "SRE" or "Admin";
}
public ApprovalProposal CreateProposal(
Guid modelId,
string createdBy,
string justification,
DateOnly effectiveAt)
{
return new ApprovalProposal
{
Id = Guid.NewGuid(),
ModelId = modelId,
Status = ApprovalStatus.Draft,
CreatedBy = createdBy,
CreatedAt = _clock.UtcNow,
Justification = justification,
EffectiveAt = effectiveAt,
PublishedAt = _clock.UtcNow,
Revision = 1,
CorrelationId = Guid.NewGuid()
};
}
public ApprovalProposal ProposeApproval(ApprovalProposal proposal, string makerEmail)
{
if (!CanProposeApproval(proposal, makerEmail))
throw new InvalidOperationException("Only the creator can propose their own approval");
proposal.Status = ApprovalStatus.Proposed;
proposal.ProposedAt = _clock.UtcNow;
proposal.Revision++;
proposal.PublishedAt = _clock.UtcNow;
return proposal;
}
public ApprovalProposal ApproveApproval(
ApprovalProposal proposal,
string checkerEmail,
string approvalNotes,
List<EvidenceItem> evidence)
{
if (!CanApproveApproval(proposal, checkerEmail, proposal.CreatedBy))
throw new InvalidOperationException("Checker cannot approve their own proposals");
proposal.Status = ApprovalStatus.Approved;
proposal.ApprovedBy = checkerEmail;
proposal.ApprovedAt = _clock.UtcNow;
proposal.ApprovalNotes = approvalNotes;
proposal.Revision++;
proposal.PublishedAt = _clock.UtcNow;
// Add evidence
foreach (var evt in evidence)
{
proposal.Evidence.Add(new ApprovalEvidence
{
Id = Guid.NewGuid(),
ApprovalProposalId = proposal.Id,
EvidenceType = evt.Type,
EvidenceUrl = evt.Url,
ReviewerComment = evt.Comment,
PublishedAt = _clock.UtcNow,
CorrelationId = proposal.CorrelationId
});
}
return proposal;
}
public ApprovalProposal ActivateApproval(ApprovalProposal proposal, string sreEmail)
{
if (!CanActivateApproval(proposal, "SRE"))
throw new InvalidOperationException("Only SRE can activate approved proposals");
proposal.Status = ApprovalStatus.Active;
proposal.ActivatedBy = sreEmail;
proposal.ActivatedAt = _clock.UtcNow;
proposal.Revision++;
proposal.PublishedAt = _clock.UtcNow;
return proposal;
}
public ApprovalProposal RejectApproval(ApprovalProposal proposal, string checkerEmail, string rejectionReason)
{
if (proposal.Status != ApprovalStatus.Proposed)
throw new InvalidOperationException("Only proposed approvals can be rejected");
proposal.Status = ApprovalStatus.Rejected;
proposal.ApprovalNotes = $"Rejected: {rejectionReason}";
proposal.Revision++;
proposal.PublishedAt = _clock.UtcNow;
return proposal;
}
public ApprovalEvent CreateProposalEvent(
ApprovalProposal proposal,
string eventType,
string actorEmail,
Dictionary<string, object>? details = null)
{
return new ApprovalEvent
{
Id = Guid.NewGuid(),
ApprovalProposalId = proposal.Id,
EventType = eventType,
ActorEmail = actorEmail,
EventAt = _clock.UtcNow,
Details = details,
PublishedAt = _clock.UtcNow,
CorrelationId = proposal.CorrelationId
};
}
}
@@ -1,109 +0,0 @@
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
using System;
using System.Collections.Generic;
public class ApprovalProposal
{
public Guid Id { get; set; }
public Guid ModelId { get; set; }
public ApprovalStatus Status { get; set; }
public string CreatedBy { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
public string Justification { get; set; } = null!;
public DateOnly EffectiveAt { get; set; }
public DateTimeOffset? ProposedAt { get; set; }
public string? ApprovedBy { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public string? ApprovalNotes { get; set; }
public string? ActivatedBy { get; set; }
public DateTimeOffset? ActivatedAt { get; set; }
public DateTimeOffset PublishedAt { get; set; }
public int Revision { get; set; }
public Guid CorrelationId { get; set; }
public List<ApprovalEvidence> Evidence { get; set; } = [];
public List<ApprovalEvent> Events { get; set; } = [];
public bool CanBeProposed => Status == ApprovalStatus.Draft && CreatedBy is not null;
public bool CanBeApproved => Status == ApprovalStatus.Proposed;
public bool CanBeActivated => Status == ApprovalStatus.Approved;
}
public enum ApprovalStatus
{
Draft,
Proposed,
Approved,
Active,
Rejected
}
public class ApprovalEvidence
{
public Guid Id { get; set; }
public Guid ApprovalProposalId { get; set; }
public string EvidenceType { get; set; } = null!;
public string EvidenceUrl { get; set; } = null!;
public string? ReviewerComment { get; set; }
public DateTimeOffset PublishedAt { get; set; }
public Guid CorrelationId { get; set; }
}
public class ApprovalEvent
{
public Guid Id { get; set; }
public Guid ApprovalProposalId { get; set; }
public string EventType { get; set; } = null!;
public string ActorEmail { get; set; } = null!;
public DateTimeOffset EventAt { get; set; }
public Dictionary<string, object>? Details { get; set; }
public DateTimeOffset PublishedAt { get; set; }
public Guid CorrelationId { get; set; }
}
public class CreateApprovalProposalRequest
{
public Guid ModelId { get; set; }
public DateOnly EffectiveAt { get; set; }
public string Justification { get; set; } = null!;
}
public class ApproveApprovalRequest
{
public string ApprovalNotes { get; set; } = null!;
public List<EvidenceItem> Evidence { get; set; } = [];
}
public class EvidenceItem
{
public string Type { get; set; } = null!;
public string Url { get; set; } = null!;
public string? Comment { get; set; }
}
public class ApprovalProposalResponse
{
public Guid Id { get; set; }
public Guid ModelId { get; set; }
public string Status { get; set; } = null!;
public string CreatedBy { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
public string Justification { get; set; } = null!;
public DateOnly EffectiveAt { get; set; }
public string? ApprovedBy { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public string? ApprovalNotes { get; set; }
public List<ApprovalEvidenceResponse> Evidence { get; set; } = [];
}
public class ApprovalEvidenceResponse
{
public Guid Id { get; set; }
public string EvidenceType { get; set; } = null!;
public string EvidenceUrl { get; set; } = null!;
public string? ReviewerComment { get; set; }
}
@@ -1,216 +0,0 @@
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Dapper;
using KArtSell.BuildingBlocks.Time;
using Npgsql;
public class ApprovalSql
{
private readonly string _connectionString;
private readonly IClock _clock;
public ApprovalSql(string connectionString, IClock clock)
{
_connectionString = connectionString;
_clock = clock;
}
public async Task<ApprovalProposal?> GetProposalByIdAsync(Guid id, DateTimeOffset cutoff)
{
using var conn = new NpgsqlConnection(_connectionString);
const string sql = """
SELECT
id, model_id, status, created_by, created_at, justification, effective_at,
proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at,
published_at, revision, correlation_id
FROM model_operations.approval_proposals
WHERE id = @id
AND published_at <= @cutoff
ORDER BY published_at DESC
LIMIT 1
""";
var proposal = await conn.QueryFirstOrDefaultAsync<ApprovalProposalRaw>(sql, new { id, cutoff });
if (proposal == null) return null;
return MapFromRaw(proposal);
}
public async Task<List<ApprovalProposal>> GetProposalsByStatusAsync(string status, DateTimeOffset cutoff, int pageSize = 100)
{
using var conn = new NpgsqlConnection(_connectionString);
const string sql = """
SELECT
id, model_id, status, created_by, created_at, justification, effective_at,
proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at,
published_at, revision, correlation_id
FROM model_operations.approval_proposals
WHERE status = @status
AND published_at <= @cutoff
ORDER BY created_at DESC
LIMIT @pageSize
""";
var proposals = await conn.QueryAsync<ApprovalProposalRaw>(sql, new { status, cutoff, pageSize });
return proposals.Select(MapFromRaw).ToList();
}
public async Task InsertProposalAsync(
Guid id, Guid modelId, string status, string createdBy, string justification,
DateOnly effectiveAt, DateTimeOffset publishedAt, Guid correlationId)
{
using var conn = new NpgsqlConnection(_connectionString);
const string sql = """
INSERT INTO model_operations.approval_proposals
(id, model_id, status, created_by, created_at, justification, effective_at, published_at, revision, correlation_id)
VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt::date, @publishedAt, 1, @correlationId)
""";
await conn.ExecuteAsync(sql, new
{
id,
modelId,
status,
createdBy,
createdAt = _clock.UtcNow,
justification,
effectiveAt = effectiveAt.ToString("yyyy-MM-dd"), // Dapper: DateOnly cannot be used as a parameter value directly
publishedAt,
correlationId
});
}
public async Task UpdateProposalStatusAsync(Guid id, string newStatus, string approvedBy, string? approvalNotes, DateTimeOffset publishedAt)
{
using var conn = new NpgsqlConnection(_connectionString);
const string sql = """
INSERT INTO model_operations.approval_proposals
(id, model_id, status, created_by, created_at, justification, effective_at,
approved_by, approved_at, approval_notes, published_at, revision, correlation_id)
SELECT id, model_id, @newStatus, created_by, created_at, justification, effective_at,
@approvedBy, @approvedAt, @approvalNotes, @publishedAt, revision + 1, correlation_id
FROM model_operations.approval_proposals
WHERE id = @id
ORDER BY published_at DESC LIMIT 1
""";
await conn.ExecuteAsync(sql, new
{
id,
newStatus,
approvedBy,
approvedAt = _clock.UtcNow,
approvalNotes,
publishedAt
});
}
public async Task InsertEvidenceAsync(Guid id, Guid proposalId, string evidenceType, string evidenceUrl, string? comment, Guid correlationId)
{
using var conn = new NpgsqlConnection(_connectionString);
const string sql = """
INSERT INTO model_operations.approval_evidence
(id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, published_at, correlation_id)
VALUES (@id, @proposalId, @evidenceType, @evidenceUrl, @comment, @publishedAt, @correlationId)
""";
await conn.ExecuteAsync(sql, new
{
id,
proposalId,
evidenceType,
evidenceUrl,
comment,
publishedAt = _clock.UtcNow,
correlationId
});
}
public async Task InsertEventAsync(Guid id, Guid proposalId, string eventType, string actorEmail, Dictionary<string, object>? details, Guid correlationId)
{
using var conn = new NpgsqlConnection(_connectionString);
const string sql = """
INSERT INTO model_operations.approval_events
(id, approval_proposal_id, event_type, actor_email, event_at, details, published_at, correlation_id)
VALUES (@id, @proposalId, @eventType, @actorEmail, @eventAt, @details::jsonb, @publishedAt, @correlationId)
""";
var detailsJson = details != null ? JsonSerializer.Serialize(details) : null;
await conn.ExecuteAsync(sql, new
{
id,
proposalId,
eventType,
actorEmail,
eventAt = _clock.UtcNow,
details = detailsJson,
publishedAt = _clock.UtcNow,
correlationId
});
}
public async Task<List<ApprovalEvidence>> GetEvidenceByProposalAsync(Guid proposalId, DateTimeOffset cutoff)
{
using var conn = new NpgsqlConnection(_connectionString);
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
AND published_at <= @cutoff
ORDER BY published_at DESC
""";
var results = await conn.QueryAsync<ApprovalEvidence>(sql, new { proposalId, cutoff });
return results.ToList();
}
private ApprovalProposal MapFromRaw(ApprovalProposalRaw raw)
{
return new ApprovalProposal
{
Id = raw.Id,
ModelId = raw.ModelId,
Status = Enum.Parse<ApprovalStatus>(raw.Status),
CreatedBy = raw.CreatedBy,
CreatedAt = raw.CreatedAt,
Justification = raw.Justification,
EffectiveAt = raw.EffectiveAt,
ProposedAt = raw.ProposedAt,
ApprovedBy = raw.ApprovedBy,
ApprovedAt = raw.ApprovedAt,
ApprovalNotes = raw.ApprovalNotes,
ActivatedBy = raw.ActivatedBy,
ActivatedAt = raw.ActivatedAt,
PublishedAt = raw.PublishedAt,
Revision = raw.Revision,
CorrelationId = raw.CorrelationId
};
}
private sealed class ApprovalProposalRaw
{
public Guid Id { get; set; }
public Guid ModelId { get; set; }
public string Status { get; set; } = null!;
public string CreatedBy { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
public string Justification { get; set; } = null!;
public DateOnly EffectiveAt { get; set; }
public DateTimeOffset? ProposedAt { get; set; }
public string? ApprovedBy { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public string? ApprovalNotes { get; set; }
public string? ActivatedBy { get; set; }
public DateTimeOffset? ActivatedAt { get; set; }
public DateTimeOffset PublishedAt { get; set; }
public int Revision { get; set; }
public Guid CorrelationId { get; set; }
}
}
@@ -1,208 +0,0 @@
# VS-26: Model Approval Workflow
## Overview
This vertical slice implements a maker-checker approval workflow for model activation. It enforces separation of duties, state machine transitions, and evidence linkage for regulatory compliance.
**Status:** ✅ Ready for implementation
**Specification:** `docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md`
---
## User Story
As a platform lead/compliance officer, I want to enforce maker-checker approval workflow for model activation so that only reviewed, authorized models reach production (governance compliance).
---
## Key Features
### 1. Approval State Machine
```
DRAFT (Maker creates)
PROPOSED (Maker submits to Checker)
├→ APPROVED (Checker signs off with evidence)
│ ↓
│ ACTIVE (SRE activates)
└→ REJECTED (Checker rejects, revise to DRAFT)
```
### 2. Maker-Checker Separation of Duties
- **Maker:** Can create and propose approval proposals (own proposals only)
- **Checker:** Can approve any proposal (must be different from Maker)
- **SRE:** Can activate approved proposals
- **System:** Logs all actions with actor identity and correlation_id
### 3. Evidence Linkage
- Store PBO/DSR/OOS artifact URLs during approval
- Checker annotates evidence interpretation
- Traceability: approval_id → evidence_links → S3 artifacts
### 4. Immutable Audit Trail
- All state transitions logged in `approval_events` table
- Correlation_id links related events
- PIT tracking via `published_at` + `revision`
---
## Database Schema
### approval_proposals
```sql
id, model_id, status, created_by, created_at, justification, effective_at,
proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at,
published_at, revision, correlation_id
```
### approval_evidence
```sql
id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment,
published_at, correlation_id
```
### approval_events
```sql
id, approval_proposal_id, event_type, actor_email, event_at, details,
published_at, correlation_id
```
---
## API Endpoints
### POST /approvals (Create Proposal)
**Role:** Maker
**Request:**
```json
{
"modelId": "uuid",
"effectiveAt": "2026-09-15",
"justification": "Model passed OOS testing; PBO score 0.95"
}
```
**Response (201):**
```json
{
"id": "approval-uuid",
"modelId": "uuid",
"status": "Draft",
"createdBy": "maker@company.com",
"createdAt": "2026-08-07T10:00:00Z"
}
```
### GET /approvals (List Proposals)
**Query Params:** `status=Proposed&modelId=uuid`
**Response (200):**
```json
{
"items": [
{
"id": "approval-uuid",
"modelId": "uuid",
"status": "Proposed",
"createdBy": "maker@company.com",
"approvalNotes": null
}
]
}
```
### GET /approvals/{id} (Get Single)
**Response (200):**
```json
{
"id": "approval-uuid",
"modelId": "uuid",
"status": "Proposed",
"evidence": [
{
"id": "evidence-uuid",
"evidenceType": "PBO_SCORE",
"evidenceUrl": "s3://evidence/pbo-0.95.json",
"reviewerComment": "Verified"
}
]
}
```
### POST /approvals/{id}/approve (Checker Approval)
**Role:** Checker
**Request:**
```json
{
"approvalNotes": "PBO verified, OOS metrics acceptable",
"evidence": [
{"type": "PBO_SCORE", "url": "s3://evidence/pbo-0.95.json", "comment": "Verified"},
{"type": "OOS_RETURN", "url": "s3://evidence/oos-returns.csv", "comment": "Acceptable"}
]
}
```
**Response (200):**
```json
{
"id": "approval-uuid",
"status": "Approved",
"approvedBy": "checker@company.com",
"approvedAt": "2026-08-07T11:00:00Z"
}
```
---
## RBAC Enforcement
| Role | Can Create | Can Approve | Can Activate |
|------|-----------|-----------|------------|
| Maker | ✅ (own) | ❌ | ❌ |
| Checker | ❌ | ✅ (others) | ❌ |
| SRE | ❌ | ❌ | ✅ |
| Admin | ✅ | ✅ | ✅ |
**Separation of Duties:** Maker ≠ Checker (same user cannot approve own proposal)
---
## Compliance & Governance
-**Separation of Duties:** Enforced at Endpoint level
-**Evidence Linkage:** All evidence URLs traceable to artifacts
-**Immutable Audit Trail:** INSERT-only events table
-**Correlation Tracking:** CorrelationId links related events across slices
-**PIT Queries:** All reads include `WHERE published_at <= cutoff`
---
## Related Specifications
- **VS-00:** PIT envelope (published_at, correlation_id, revision)
- **VS-02:** Financial security master (governance foundation)
- **VS-27:** Audit trail (logs all approval events)
- **VS-10:** Sell decision (uses approved models)
---
## Next Steps
1. ✅ Schema migration (0036_approval_workflow.sql)
2. ✅ Domain entities (ApprovalProposal, ApprovalEvidence, ApprovalEvent)
3. ✅ Dapper queries (Sql.cs)
4. ✅ Business logic (ApprovalPolicy with state machine)
5. ✅ HTTP handlers (ApprovalHandlers.cs)
6. ✅ FastEndpoints (ApprovalEndpoints.cs)
7. ✅ Unit/Integration tests
8. ⏳ Merge to main (awaiting PR review)
9. ⏳ Integration with VS-27 (audit trail subscribers)
10. ⏳ Phase 2 implementation (after Phase 1 data available)
---
**Co-Authored-By:** Claude Haiku 4.5 <noreply@anthropic.com>
**AGENTS.md v16.0:** 13/13 ✅
**Compliance:** Spec-before-code, no new tech debt
@@ -1,9 +1,27 @@
# VS-03: Model Approval Workflow (Maker-Checker Governance)
# 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
@@ -52,6 +70,16 @@ PROPOSED (maker submits)
- 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
@@ -178,15 +206,25 @@ CREATE TABLE model_operations.approval_events (
## Tests
Unit tests cover:
- RBAC enforcement (Maker, Checker, SRE roles)
- Separation of duties (Checker ≠ Maker)
- State machine transitions
- RBAC violations
- `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 "ApprovalWorkflowPolicyTests"
dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release
```
## AGENTS.md v16.0 Compliance
@@ -194,25 +232,37 @@ dotnet test --filter "ApprovalWorkflowPolicyTests"
-**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-03 SLICE_SPEC
- **Normalization:** 3NF schema, append-only events
-**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:** Idempotent, rollback-safe
- ⚠️ **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:** No shortcuts, formal approval workflow
-**Debt:** No new tech debt
- ✅ **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-04:** Audit trail (events logged by this slice)
- **VS-27 (formerly VS-04):** Audit trail (events logged by this slice)
- **Compliance:** Maker-checker separation, evidence linkage
---
**Status:** ✅ IMPLEMENTATION COMPLETE
**Co-Authored-By:** Claude Haiku 4.5 <noreply@anthropic.com>
**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 <noreply@anthropic.com>
@@ -55,7 +55,7 @@ public class ApprovalWorkflowSql
INSERT INTO model_operations.approval_proposals
(id, model_id, status, created_by, created_at, justification, effective_at,
published_at, revision, correlation_id)
VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt,
VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt::date,
@publishedAt, @revision, @correlationId)
RETURNING id
""";
@@ -69,7 +69,11 @@ public class ApprovalWorkflowSql
proposal.CreatedBy,
proposal.CreatedAt,
proposal.Justification,
proposal.EffectiveAt,
// Dapper cannot bind DateOnly directly as an Npgsql parameter value (see TECH_DEBT_REGISTER.md
// DEBT-017); the identical failure was found and fixed the same way in the now-deleted
// ApprovalSql.cs (commit 2ccf74c) after it crashed 100% of proposal-creation calls against a
// real database.
effectiveAt = proposal.EffectiveAt.ToString("yyyy-MM-dd"),
proposal.PublishedAt,
proposal.Revision,
proposal.CorrelationId