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:
2026-08-09 00:32:02 +09:00
parent cbcc4849ec
commit 9ffb740f07
6 changed files with 75 additions and 5 deletions
@@ -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 = """