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
+23 -4
View File
@@ -37,6 +37,8 @@ export interface ConfirmedActionOutcome {
export interface ConfirmedPlanResult {
outcomes: ConfirmedActionOutcome[];
/** True when the approval gate refused execution (Apply must not claim success). */
refused?: boolean;
}
export interface ConfirmedOutcomeSummary {
@@ -78,6 +80,17 @@ export function messageForConfirmedOutcomes(summary: ConfirmedOutcomeSummary): s
return 'Rollout confirmed';
}
/** Apply finished a confirmed snapshot, but live approval is no longer current. */
export function messageForSnapshotFinishedWithStaleApproval(summary: ConfirmedOutcomeSummary): string {
if (summary.failed > 0) {
return 'Confirmed snapshot finished with node failures; approval is no longer current';
}
if (summary.pending > 0) {
return 'Confirmed snapshot finished with actions still in progress; approval is no longer current';
}
return 'Confirmed snapshot finished; approval is no longer current';
}
function mapDeployOutcome(
base: { nodeId: number; nodeName: string; action: PreviewAction },
result: DeployOutcome,
@@ -195,12 +208,18 @@ export class BlueprintReconciler {
): Promise<ConfirmedPlanResult> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
if (!blueprint || !blueprint.enabled) {
return { outcomes: [] };
return { outcomes: [], refused: true };
}
const parsed = parseApprovedBlastJson(blueprint.approved_blast_json);
if (!parsed.ok || blueprint.approval_status !== 'approved') {
diagnosticLog('reconcileConfirmedPlan skipped: approval missing or invalid', { blueprintId });
return { outcomes: [] };
// Same fail-closed gate as tick reconcile: never execute when approval is
// missing, invalid, or the stored fingerprint no longer matches live intent.
if (
!parsed.ok
|| blueprint.approval_status !== 'approved'
|| blueprint.approved_intent_fingerprint !== intentFingerprint(blueprint)
) {
diagnosticLog('reconcileConfirmedPlan skipped: approval missing, invalid, or drifted', { blueprintId });
return { outcomes: [], refused: true };
}
const authorized = filterAuthorizedExecutorActions(parsed.entries, executorActions);
const nodes = DatabaseService.getInstance().getNodes();