fix(blueprints): gate confirmed apply on live intent fingerprint (#1663)

* fix(blueprints): gate confirmed apply on live intent fingerprint

reconcileConfirmedPlan only checked approval_status, so a concurrent compose
edit during Apply could deploy unapproved content under a stale fingerprint.
Match the tick-path fingerprint gate, refuse Apply when live intent drifts,
and surface reconciler refusal as PREVIEW_STALE instead of a false success.

* test(blueprints): cover matching-fingerprint reconcileConfirmedPlan path

Prove the production allow branch still deploys authorized actions when the
approval fingerprint matches, and does not execute unauthorized blast nodes.

* fix(blueprints): report live approval after confirmed snapshot apply

A concurrent edit can clear approval while multi-node snapshot deploy is
still running. Keep the in-flight snapshot contract, but re-read live
effectiveApproval before responding and warn in the rollout dialog when
approval is no longer current.
This commit is contained in:
Anso
2026-07-21 20:31:13 -04:00
committed by GitHub
parent e079a5baf3
commit 155db30554
5 changed files with 244 additions and 8 deletions
+32 -4
View File
@@ -11,6 +11,7 @@ import { BlueprintService } from '../services/BlueprintService';
import {
BlueprintReconciler,
messageForConfirmedOutcomes,
messageForSnapshotFinishedWithStaleApproval,
summarizeConfirmedOutcomes,
} from '../services/BlueprintReconciler';
import { BlueprintAnalyzer } from '../services/BlueprintAnalyzer';
@@ -18,6 +19,8 @@ import { buildBlueprintPreview, evaluateLightweightEffectiveApproval } from '../
import {
confirmableActionsEqual,
deriveBlastFromConfirmableActions,
evaluateEffectiveApproval,
intentFingerprint,
parseConfirmableActionsBody,
serializeApprovedBlast,
} from '../services/blueprintApproval';
@@ -427,17 +430,42 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise
}
const blast = deriveBlastFromConfirmableActions(preview.confirmableActions);
DatabaseService.getInstance().setBlueprintApproval(id, {
const approved = DatabaseService.getInstance().setBlueprintApproval(id, {
intentFingerprint: preview.planFingerprint,
blastJson: serializeApprovedBlast(blast),
approvedBy: req.user?.username ?? null,
});
const plan = await BlueprintReconciler.getInstance().reconcileConfirmedPlan(id, preview.executorActions);
// planFingerprint is from an earlier preview. Concurrent compose/selector
// edits can invalidate it before approval persists, or the reconciler can
// refuse if the live gate no longer matches. Both paths clear and 409.
const approvalMatches = !!approved && intentFingerprint(approved) === preview.planFingerprint;
const plan = approvalMatches
? await BlueprintReconciler.getInstance().reconcileConfirmedPlan(id, preview.executorActions)
: null;
if (!plan || plan.refused) {
DatabaseService.getInstance().clearBlueprintApproval(id);
const fresh = await buildBlueprintPreview(id);
res.status(409).json({
error: 'Preview is stale; refresh and confirm again',
code: 'PREVIEW_STALE',
preview: fresh,
});
return;
}
// Snapshot deploy may finish after a concurrent edit cleared approval.
// Report live effectiveApproval; do not hardcode "approved".
const live = DatabaseService.getInstance().getBlueprint(id);
const { effectiveApproval } = live
? evaluateEffectiveApproval(live, preview.executorActions)
: { effectiveApproval: 'pending' as const };
const outcomeSummary = summarizeConfirmedOutcomes(plan.outcomes);
const message = effectiveApproval === 'approved'
? messageForConfirmedOutcomes(outcomeSummary)
: messageForSnapshotFinishedWithStaleApproval(outcomeSummary);
res.json({
message: messageForConfirmedOutcomes(outcomeSummary),
message,
blueprintId: id,
effectiveApproval: 'approved',
effectiveApproval,
outcomes: plan.outcomes,
outcomeSummary,
});