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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user