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
@@ -144,6 +144,128 @@ describe('POST /api/blueprints/:id/apply confirm binding', () => {
expect(stored.updated_at).toBe(created.body.updated_at);
});
it('returns PREVIEW_STALE when compose drifts between preview and approval persist', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
const db = DatabaseService.getInstance();
const originalSet = db.setBlueprintApproval.bind(db);
vi.spyOn(db, 'setBlueprintApproval').mockImplementation((id, input) => {
// Concurrent edit landed compose v2 before approval was written; Apply
// still persists the v1 fingerprint onto that row.
db.getDb().prepare('UPDATE blueprints SET compose_content = ? WHERE id = ?').run(
'services:\n app:\n image: nginx:evil\n',
id,
);
return originalSet(id, input);
});
const reconcileSpy = vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan);
const res = await request(app)
.post(`/api/blueprints/${created.body.id}/apply`)
.set('Cookie', adminCookie)
.send({
planFingerprint: preview.body.planFingerprint,
actions: preview.body.confirmableActions,
});
expect(res.status).toBe(409);
expect(res.body.code).toBe('PREVIEW_STALE');
expect(reconcileSpy).not.toHaveBeenCalled();
const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
expect(stored.approval_status).toBe('pending');
expect(stored.approved_intent_fingerprint).toBeNull();
});
it('returns PREVIEW_STALE when reconcileConfirmedPlan refuses after approval', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockResolvedValueOnce({
outcomes: [],
refused: true,
});
const res = await request(app)
.post(`/api/blueprints/${created.body.id}/apply`)
.set('Cookie', adminCookie)
.send({
planFingerprint: preview.body.planFingerprint,
actions: preview.body.confirmableActions,
});
expect(res.status).toBe(409);
expect(res.body.code).toBe('PREVIEW_STALE');
const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
expect(stored.approval_status).toBe('pending');
expect(stored.approved_intent_fingerprint).toBeNull();
});
it('reports live pending approval when approval is cleared during reconcile', async () => {
const node = seedNode();
counter += 1;
const created = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validBlueprintBody(node.id));
expect(created.status).toBe(201);
const preview = await request(app)
.get(`/api/blueprints/${created.body.id}/preview`)
.set('Cookie', adminCookie);
expect(preview.status).toBe(200);
vi.mocked(BlueprintReconciler.getInstance().reconcileConfirmedPlan).mockImplementationOnce(async (id) => {
// Concurrent edit invalidated approval while the confirmed snapshot ran.
DatabaseService.getInstance().clearBlueprintApproval(id);
return {
outcomes: [{
nodeId: node.id,
nodeName: node.name,
action: 'create',
status: 'ok',
}],
};
});
const res = await request(app)
.post(`/api/blueprints/${created.body.id}/apply`)
.set('Cookie', adminCookie)
.send({
planFingerprint: preview.body.planFingerprint,
actions: preview.body.confirmableActions,
});
expect(res.status).toBe(200);
expect(res.body.effectiveApproval).toBe('pending');
expect(res.body.message).toMatch(/approval is no longer current/i);
expect(res.body.outcomes).toHaveLength(1);
expect(res.body.outcomes[0].status).toBe('ok');
expect(res.body.outcomeSummary.ok).toBe(1);
const stored = DatabaseService.getInstance().getBlueprint(created.body.id)!;
expect(stored.approval_status).toBe('pending');
});
it('returns failed outcomes without pretending the rollout was clean', async () => {
const node = seedNode();
counter += 1;
@@ -246,6 +246,71 @@ describe('reconcileOne approval gate (real path)', () => {
});
});
describe('reconcileConfirmedPlan fingerprint gate', () => {
it('does not deploy when approval fingerprint no longer matches live compose', async () => {
const node = seedNode();
const bp = createBp({ nodeIds: [node.id] });
// Simulate an approved row whose fingerprint lags a concurrent compose edit.
const staleFp = intentFingerprint(bp);
DatabaseService.getInstance().getDb().prepare(
`UPDATE blueprints SET compose_content = ?,
approval_status = 'approved',
approved_intent_fingerprint = ?,
approved_blast_json = ?,
approved_at = ?,
approved_by = 'admin'
WHERE id = ?`,
).run(
'services:\n app:\n image: nginx:evil\n',
staleFp,
serializeApprovedBlast([{ nodeId: node.id, outcome: 'place' }]),
Date.now(),
bp.id,
);
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
const result = await BlueprintReconciler.getInstance().reconcileConfirmedPlan(bp.id, [
{ nodeId: node.id, action: 'create' },
]);
expect(result.outcomes).toEqual([]);
expect(result.refused).toBe(true);
expect(deploySpy).not.toHaveBeenCalled();
expect(withdrawSpy).not.toHaveBeenCalled();
});
it('deploys authorized actions when approval fingerprint matches live intent', async () => {
const authorized = seedNode();
const unauthorized = seedNode();
const bp = createBp({ nodeIds: [authorized.id, unauthorized.id] });
approvePlace(bp.id, [authorized.id]);
const deploySpy = vi.spyOn(BlueprintService.getInstance(), 'deployToNode').mockResolvedValue({ status: 'active' });
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({ status: 'withdrawn' });
const result = await BlueprintReconciler.getInstance().reconcileConfirmedPlan(bp.id, [
{ nodeId: authorized.id, action: 'create' },
{ nodeId: unauthorized.id, action: 'create' },
]);
expect(result.refused).toBeFalsy();
expect(deploySpy).toHaveBeenCalledTimes(1);
expect(deploySpy).toHaveBeenCalledWith(
expect.objectContaining({ id: bp.id, compose_content: bp.compose_content }),
expect.objectContaining({ id: authorized.id }),
);
expect(withdrawSpy).not.toHaveBeenCalled();
expect(result.outcomes).toEqual([{
nodeId: authorized.id,
nodeName: authorized.name,
action: 'create',
status: 'ok',
}]);
});
});
describe('Accept/Evict STALE_GUARD', () => {
it('refuses Accept without a valid place approval', async () => {
const node = seedNode();