// tests/kb-update/test-decisions-actions.test.mjs // Spor B / B3 (Sesjon 16): the ADDITIVE skill-level action layer on the lag-2 // decision ledger. The URL-keyed `decisions` map (Spor A) is untouched — its // contract is covered by test-decisions-io.test.mjs, which stays green // unmodified, proving the extension is non-regressive. Here we cover only the // new `actions` collection: actionKey dedup, recordAction (pure), isActionDecided, // listActions, and backward-compatible normalization of pre-action ledgers. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createLedger, loadDecisions, saveDecisions, recordDecision, isDecided, actionKey, isActionDecided, recordAction, listActions, setActionStatus, } from '../../scripts/kb-update/lib/decisions-io.mjs'; function withTmp(fn) { const dir = mkdtempSync(join(tmpdir(), 'dec-act-test-')); try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } const MERGE = { operation_type: 'merge_skills', status: 'pending', decided_at: '2026-06-20', targets: { absorber: 'ms-ai-infrastructure', absorbed: 'ms-ai-engineering' }, guardrail: { ok: true }, note: 'dry-run', }; // --- scaffold now carries an empty actions collection (additive) --- test('createLedger — additive actions:{} alongside untouched decisions:{}', () => { const led = createLedger(); assert.equal(led.version, 1, 'version stays 1 — no migration needed'); assert.deepEqual(led.decisions, {}, 'URL-keyed map unchanged'); assert.deepEqual(led.actions, {}, 'new skill-op collection present and empty'); }); // --- actionKey — deterministic dedup key --- test('actionKey — merge_skills key is symmetric on the pair (direction-agnostic dedup)', () => { const k1 = actionKey(MERGE); const k2 = actionKey({ ...MERGE, targets: { absorber: 'ms-ai-engineering', absorbed: 'ms-ai-infrastructure' }, }); assert.equal(k1, k2, 'merging A<-B and B<-A must dedup to the same key'); assert.equal(k1, 'merge_skills:ms-ai-engineering+ms-ai-infrastructure'); }); test('actionKey — single-skill ops keyed by their one target', () => { assert.equal( actionKey({ operation_type: 'sanitize_skill', targets: { skill: 'ms-ai-governance' } }), 'sanitize_skill:ms-ai-governance', ); assert.equal( actionKey({ operation_type: 'create_skill', targets: { name: 'ms-power-platform' } }), 'create_skill:ms-power-platform', ); }); // --- recordAction — pure, mirrors recordDecision --- test('recordAction — records under actionKey, returns new ledger, does not mutate input', () => { const led = createLedger(); const next = recordAction(led, MERGE); assert.deepEqual(led.actions, {}, 'input untouched'); assert.notEqual(next, led); assert.equal(next.actions['merge_skills:ms-ai-engineering+ms-ai-infrastructure'].status, 'pending'); }); test('recordAction — advances updated_at to the action date', () => { const next = recordAction(createLedger(), MERGE); assert.equal(next.updated_at, '2026-06-20'); }); test('recordAction — does NOT touch the URL-keyed decisions map', () => { let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', { status: 'approved', decided_at: '2026-06-19', }); const next = recordAction(led, MERGE); assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives'); assert.equal(isActionDecided(next, actionKey(MERGE)), true, 'action recorded alongside'); }); // --- isActionDecided — dedup gate (policy A: any status counts) --- test('isActionDecided — true for ANY recorded status, false when absent', () => { let led = createLedger(); assert.equal(isActionDecided(led, actionKey(MERGE)), false); led = recordAction(led, { ...MERGE, status: 'rejected' }); assert.equal(isActionDecided(led, actionKey(MERGE)), true, 'rejected merge still dedups (policy A)'); }); // --- listActions — flat list, optional status filter --- test('listActions — returns all entries, filters by status when given', () => { let led = createLedger(); led = recordAction(led, MERGE); // pending led = recordAction(led, { operation_type: 'sanitize_skill', status: 'approved', decided_at: '2026-06-20', targets: { skill: 'ms-ai-governance' }, }); assert.equal(listActions(led).length, 2); assert.deepEqual( listActions(led, 'pending').map((a) => a.operation_type), ['merge_skills'], ); assert.deepEqual( listActions(led, 'approved').map((a) => a.operation_type), ['sanitize_skill'], ); }); // --- backward compatibility: a pre-action ledger on disk loads cleanly --- test('loadDecisions — normalizes a pre-action (no actions key) ledger to actions:{}', () => { withTmp((dir) => { // Simulate an old on-disk ledger written before the action layer existed. writeFileSync( join(dir, 'decisions.json'), JSON.stringify({ version: 1, updated_at: null, decisions: {} }, null, 2), ); const led = loadDecisions(dir); assert.deepEqual(led.actions, {}, 'missing actions key is backfilled, not undefined'); // and recording an action on the loaded ledger works const next = recordAction(led, MERGE); assert.equal(isActionDecided(next, actionKey(MERGE)), true); }); }); test('saveDecisions + loadDecisions — actions round-trip through disk', () => { withTmp((dir) => { const led = recordAction(createLedger(), MERGE); saveDecisions(led, dir); const back = loadDecisions(dir); assert.deepEqual(back, led); }); }); // --- setActionStatus — pure status transition (Sesjon 18: gates the apply-path) --- // The apply-path flips an operator-approved entry approved -> applied AFTER a // successful skills/ mutation. setActionStatus is the one pure primitive that // records that transition; it preserves every other field (targets, guardrail). test('setActionStatus — transitions status, preserves targets+guardrail, is pure', () => { const led = recordAction(createLedger(), { ...MERGE, status: 'approved' }); const key = actionKey(MERGE); const next = setActionStatus(led, key, 'applied', '2026-06-21'); assert.equal(led.actions[key].status, 'approved', 'input ledger untouched (pure)'); assert.notEqual(next, led); assert.equal(next.actions[key].status, 'applied'); assert.deepEqual(next.actions[key].targets, MERGE.targets, 'targets preserved'); assert.deepEqual(next.actions[key].guardrail, MERGE.guardrail, 'guardrail preserved'); }); test('setActionStatus — advances updated_at to the transition date', () => { const led = recordAction(createLedger(), { ...MERGE, status: 'approved' }); const next = setActionStatus(led, actionKey(MERGE), 'applied', '2026-06-21'); assert.equal(next.actions[actionKey(MERGE)].decided_at, '2026-06-21', 'entry date advances'); assert.equal(next.updated_at, '2026-06-21'); }); test('setActionStatus — without a date, keeps the entry decided_at and ledger updated_at', () => { let led = recordAction(createLedger(), { ...MERGE, status: 'approved' }); // decided_at 2026-06-20 const next = setActionStatus(led, actionKey(MERGE), 'applied'); assert.equal(next.actions[actionKey(MERGE)].decided_at, '2026-06-20', 'no date -> keep original'); }); test('setActionStatus — throws on an unknown action key (cannot transition a non-entry)', () => { const led = recordAction(createLedger(), { ...MERGE, status: 'approved' }); assert.throws(() => setActionStatus(led, 'sanitize_skill:ghost', 'applied'), /no action|ghost/i); }); test('setActionStatus — leaves the URL-keyed decisions map and sibling actions untouched', () => { let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', { status: 'approved', decided_at: '2026-06-19', }); led = recordAction(led, { ...MERGE, status: 'approved' }); led = recordAction(led, { operation_type: 'sanitize_skill', status: 'approved', decided_at: '2026-06-20', targets: { skill: 'ms-ai-governance' }, guardrail: { ok: true }, }); const next = setActionStatus(led, actionKey(MERGE), 'applied', '2026-06-21'); assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives'); assert.equal( next.actions['sanitize_skill:ms-ai-governance'].status, 'approved', 'sibling action untouched', ); });