fix: DEBT-028 - wire ActivateModelHandler, fix data-corrupting activation
Systematic sweep of every *Handler registered in Program.cs (same
method that found DEBT-026/027) found ActivateModelHandler was the
last orphan in Features/ApprovalWorkflow/: no POST /approvals/{id}/activate
endpoint existed, so an Approved proposal could never reach Active -
the entire point of this maker-checker slice.
While wiring it up, found the handler's original call would have
overwritten the checker's approved_by/approval_notes with the
activating SRE's identity (it passed userEmail through
UpdateProposalStatusAsync's approvedBy parameter), and never set
activated_by/activated_at at all despite those columns existing since
migration 0036. Added a dedicated ApprovalWorkflowSql.ActivateProposalAsync
that only touches activation-specific columns, and a regression test
asserting the checker's approval record survives activation unchanged.
Also documents DEBT-029 (discovered, not fixed - genuine cross-cutting
scope): LogAuditEventCommandHandler is never called by any other
slice, so VS-27's audit trail is empty in production regardless of
activity even though its own tests pass. Downgraded AEG-VS-27-01 from
COMPLETED to BLOCKED in the tracker to reflect that honestly.
dotnet build KArtSell.sln -c Release: clean. Not run against a live
database this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,43 @@ public class ProposeForReviewEndpoint : EndpointWithoutRequest<ProposeForReviewR
|
||||
}
|
||||
}
|
||||
|
||||
public record ActivateApprovalResponse(Guid Id, string Status, DateTime? ActivatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// DEBT-028: before this endpoint existed, ActivateModelHandler was registered in DI and fully
|
||||
/// implemented but had no HTTP entry point — an Approved proposal could never reach Active, the
|
||||
/// final step of the maker-checker gate this whole slice exists for.
|
||||
/// </summary>
|
||||
public class ActivateApprovalEndpoint : EndpointWithoutRequest<ActivateApprovalResponse>
|
||||
{
|
||||
private readonly ActivateModelHandler _handler;
|
||||
private readonly ApprovalWorkflowSql _sql;
|
||||
|
||||
public ActivateApprovalEndpoint(ActivateModelHandler handler, ApprovalWorkflowSql sql)
|
||||
{
|
||||
_handler = handler;
|
||||
_sql = sql;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/approvals/{id}/activate");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var proposalId = Route<Guid>("id");
|
||||
var userEmail = HttpContext.User.FindFirst("email")?.Value ?? "anonymous";
|
||||
var userRole = HttpContext.User.FindFirst("role")?.Value ?? "Guest";
|
||||
|
||||
await _handler.Handle(proposalId, userEmail, userRole, Guid.NewGuid(), ct);
|
||||
|
||||
var proposal = await _sql.GetProposalAsync(proposalId, ct);
|
||||
await Send.OkAsync(new ActivateApprovalResponse(proposalId, "ACTIVE", proposal!.ActivatedAt), ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApprovalDetailResponse(Guid Id, Guid ModelId, string Status, string CreatedBy, DateTime CreatedAt,
|
||||
string Justification, DateOnly EffectiveAt, string? ApprovedBy, DateTime? ApprovedAt, string? ApprovalNotes,
|
||||
string? ActivatedBy, DateTime? ActivatedAt, List<EvidenceDto> Evidence);
|
||||
|
||||
@@ -139,7 +139,10 @@ public class ActivateModelHandler
|
||||
|
||||
ApprovalWorkflowPolicy.ValidateProposalState(proposal.Status, ApprovalStatus.Active);
|
||||
|
||||
await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Active, userEmail, "Model activated by SRE", ct);
|
||||
// DEBT-028: UpdateProposalStatusAsync would overwrite approved_by/approval_notes with
|
||||
// the activating SRE's identity and never set activated_by/activated_at. Use the
|
||||
// dedicated activation method instead.
|
||||
await _sql.ActivateProposalAsync(proposalId, userEmail, ct);
|
||||
|
||||
var activateEvent = ApprovalWorkflowPolicy.CreateStateChangeEvent(proposalId, ApprovalStatus.Active, userEmail, correlationId, _clock.UtcNow.UtcDateTime,
|
||||
new Dictionary<string, object> { { "effectiveAt", proposal.EffectiveAt.ToString("O") } });
|
||||
|
||||
@@ -99,6 +99,26 @@ public class ApprovalWorkflowSql
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DEBT-028: activation must NOT go through UpdateProposalStatusAsync — that method's
|
||||
/// approvedBy/approvalNotes parameters would overwrite the checker's approved_by/
|
||||
/// approval_notes with the activating SRE's identity, and it never touches
|
||||
/// activated_by/activated_at at all (those columns exist in the schema but nothing wrote
|
||||
/// them). This is a dedicated method so activation only touches activation-specific columns.
|
||||
/// </summary>
|
||||
public async Task ActivateProposalAsync(Guid proposalId, string activatedBy, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE model_operations.approval_proposals
|
||||
SET status = 'ACTIVE', activated_by = @activatedBy, activated_at = NOW(),
|
||||
published_at = NOW(), revision = revision + 1
|
||||
WHERE id = @proposalId
|
||||
""";
|
||||
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.ExecuteAsync(sql, new { proposalId, activatedBy });
|
||||
}
|
||||
|
||||
public async Task<List<ApprovalEvidence>> GetEvidenceForProposalAsync(Guid proposalId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
|
||||
Reference in New Issue
Block a user