feat(20-01): add markCampaignFalsePositive with D-04 conflict guard

- Guards against marking false positive when any approved/completed
  remediation exists for the campaign (RemediationConflictError, T-20-04)
- Sets campaigns.status='false_positive' and writes one atomic audit row
  recording previousStatus + reason (REMED-05, REMED-06)
- Fixes test mock SQL substring match for the D-04 guard query
- Reworded a header comment to avoid a literal "not_implemented" string
  that tripped the D-01 grep acceptance check
This commit is contained in:
lorentz 2026-07-16 10:38:34 -04:00
parent 3d63fab600
commit b1b66f9f49
2 changed files with 74 additions and 3 deletions

View file

@ -53,7 +53,7 @@ function makeClient(rows: MockRows) {
if (sql.includes('UPDATE remediation_actions SET status')) {
return { rows: [], rowCount: 1 };
}
if (sql.includes('FROM remediation_actions WHERE campaign_id') && sql.includes("status IN")) {
if (sql.includes('FROM remediation_actions') && sql.includes('status IN')) {
return { rows: rows.guardRows ?? [], rowCount: rows.guardRows?.length ?? 0 };
}
if (sql.includes('SELECT status FROM campaigns')) {

View file

@ -10,8 +10,8 @@
* audit_events row, atomically, via writeAuditEvent(client) inside the same
* postgresClient.transaction as the state write (REMED-06).
*
* D-01: this file NEVER returns a `not_implemented` code path. The
* remediate step's "external effect" is a simulated internal transition
* D-01: this file never has an unimplemented/no-op code path for the
* remediate step. Its "external effect" is a simulated internal transition
* (status='approved' -> 'completed') no real provider call for any of the
* 7 action types this milestone. The only explicit-failure branch is the
* zero-approved-actions case (REMED-03) remediating with nothing approved
@ -205,3 +205,74 @@ export async function remediateApprovedActions(campaignId: string, actor: string
return { campaignId, actions };
});
}
// =============================================================================
// markCampaignFalsePositive (D-04 guard, REMED-05, REMED-06)
// =============================================================================
interface CampaignStatusRow {
status: string;
}
export interface MarkFalsePositiveResult {
campaignId: string;
status: 'false_positive';
auditEventId: string;
}
/**
* D-04 guard: rejects with RemediationConflictError when any
* approved/completed remediation exists for the campaign a campaign can
* never be both remediated and false-positive. Otherwise sets
* campaigns.status='false_positive' and writes one atomic audit row
* recording the previous status and the optional reason. False-positive
* reversibility is out of scope (CONTEXT.md Deferred Ideas) no un-mark
* path exists.
*/
export async function markCampaignFalsePositive(
campaignId: string,
actor: string | null,
reason?: string
): Promise<MarkFalsePositiveResult> {
return postgresClient.transaction(async (client) => {
const guardRes = await client.query<{ id: string }>(
`SELECT id FROM remediation_actions
WHERE campaign_id = $1 AND status IN ('approved', 'completed')
FOR UPDATE
LIMIT 1`,
[campaignId]
);
if (guardRes.rows.length > 0) {
throw new RemediationConflictError(
'Cannot mark false positive: campaign already has approved or completed remediation'
);
}
const campaignRes = await client.query<CampaignStatusRow>(
`SELECT status FROM campaigns WHERE id = $1`,
[campaignId]
);
const campaign = campaignRes.rows[0];
if (!campaign) {
throw new RemediationValidationError('Campaign not found');
}
const previousStatus = campaign.status;
await client.query(
`UPDATE campaigns SET status = 'false_positive', updated_at = NOW() WHERE id = $1`,
[campaignId]
);
const auditEventId = await writeAuditEvent(
{
campaignId,
actor,
eventType: 'campaign_marked_false_positive',
payload: { previousStatus, reason: reason ?? null },
},
client
);
return { campaignId, status: 'false_positive', auditEventId };
});
}