@cleocode/playbooks 2026.10.11 → 2026.10.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/state.js CHANGED
@@ -272,6 +272,16 @@ export function updatePlaybookApproval(db, approvalId, patch) {
272
272
  values.push(patch.reason ?? null);
273
273
  }
274
274
  if ('status' in patch && patch.status !== undefined) {
275
+ // A decided gate never changes its decision (playbook.approval.once, the
276
+ // same guard transitionGate applies): a peer would refuse the move, so
277
+ // the origin refuses it too (T12944).
278
+ const current = db
279
+ .prepare('SELECT status FROM playbook_approvals WHERE approval_id = ?')
280
+ .get(approvalId);
281
+ if (current && current.status !== 'pending' && current.status !== patch.status) {
282
+ // @sync-invariant playbook.approval.once a decided gate keeps its decision locally, as every peer's merge keeps it
283
+ throw new Error(`E_APPROVAL_ALREADY_DECIDED: gate ${approvalId} is already ${current.status}`);
284
+ }
275
285
  sets.push('status = ?');
276
286
  values.push(patch.status);
277
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/playbooks",
3
- "version": "2026.10.11",
3
+ "version": "2026.10.12",
4
4
  "description": "Playbook DSL + runtime for CLEO — T889 Orchestration Coherence v3",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,8 +19,8 @@
19
19
  "dependencies": {
20
20
  "drizzle-orm": "1.0.0-rc.4",
21
21
  "js-yaml": "^4.1.0",
22
- "@cleocode/core": "2026.10.11",
23
- "@cleocode/contracts": "2026.10.11"
22
+ "@cleocode/contracts": "2026.10.12",
23
+ "@cleocode/core": "2026.10.12"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/js-yaml": "^4.0.9",
@@ -218,6 +218,29 @@ describe('W4-8: playbook state CRUD', () => {
218
218
  expect(updated.reason).toBe('explicit human approval');
219
219
  });
220
220
 
221
+ it('updatePlaybookApproval refuses to change a decided gate, as transitionGate does (T12944)', () => {
222
+ const run = createPlaybookRun(db, { playbookName: 'p', playbookHash: 'h' });
223
+ const approval = createPlaybookApproval(db, {
224
+ runId: run.runId,
225
+ nodeId: 'approval-decided',
226
+ token: 'tok-decided-guard-00000000000000',
227
+ });
228
+ updatePlaybookApproval(db, approval.approvalId, { status: 'approved', approver: 'a' });
229
+ for (const status of ['pending', 'rejected'] as const) {
230
+ expect(() => updatePlaybookApproval(db, approval.approvalId, { status })).toThrow(
231
+ /E_APPROVAL_ALREADY_DECIDED/,
232
+ );
233
+ }
234
+ // The same decision and non-status fields still patch.
235
+ expect(
236
+ updatePlaybookApproval(db, approval.approvalId, { status: 'approved', reason: 'noted' })
237
+ .reason,
238
+ ).toBe('noted');
239
+ expect(getPlaybookApprovalByToken(db, 'tok-decided-guard-00000000000000')?.status).toBe(
240
+ 'approved',
241
+ );
242
+ });
243
+
221
244
  it('listPlaybookApprovals returns rows in requestedAt order with stable secondary key', () => {
222
245
  const run = createPlaybookRun(db, {
223
246
  playbookName: 'p',
package/src/state.ts CHANGED
@@ -414,6 +414,18 @@ export function updatePlaybookApproval(
414
414
  values.push(patch.reason ?? null);
415
415
  }
416
416
  if ('status' in patch && patch.status !== undefined) {
417
+ // A decided gate never changes its decision (playbook.approval.once, the
418
+ // same guard transitionGate applies): a peer would refuse the move, so
419
+ // the origin refuses it too (T12944).
420
+ const current = db
421
+ .prepare('SELECT status FROM playbook_approvals WHERE approval_id = ?')
422
+ .get(approvalId) as { status: string } | undefined;
423
+ if (current && current.status !== 'pending' && current.status !== patch.status) {
424
+ // @sync-invariant playbook.approval.once a decided gate keeps its decision locally, as every peer's merge keeps it
425
+ throw new Error(
426
+ `E_APPROVAL_ALREADY_DECIDED: gate ${approvalId} is already ${current.status}`,
427
+ );
428
+ }
417
429
  sets.push('status = ?');
418
430
  values.push(patch.status);
419
431
  }