# v0.8 Change Safety & Rollback — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Automatically snapshot RouterOS state before every write, record every write to an append-only journal, and expose `plan_changes` / `apply_plan` / `rollback_change` tools so any write can be previewed, batched, and fully undone by journal ID.

**Architecture:** A new `src/domain/snapshot/` layer (snapshot engine, diff engine, write journal) is wired into `tool-registry.ts` so all write tools with `snapshotPaths` automatically save a before-snapshot and a journal entry. Three new meta-tools (`plan_changes`, `apply_plan`, `rollback_change`) let callers preview, batch, and undo changes. Maintenance-window enforcement is added to `router-registry.ts` (config) and `tool-registry.ts` (check): destructive tools on a router with `maintenanceWindows` configured are blocked outside those windows. `plan_changes` and `apply_plan` are factory functions (receiving `baseTools`) to avoid a circular import with `index.ts`.

**Tech Stack:** Node.js 22, TypeScript strict ESM, `node:fs` (`mkdirSync`, `appendFileSync`, `readFileSync`), `nanoid`, `zod`, `vitest`. No new runtime dependencies.

---

## File Map

### New files
| Path | Purpose |
|---|---|
| `src/domain/snapshot/snapshot-engine.ts` | `takeSnapshot()`, `loadSnapshot()` — fetch and persist RouterOS state |
| `src/domain/snapshot/diff-engine.ts` | `SEMANTIC_KEYS`, `computeRestorePlan()`, `applyRestorePlan()` |
| `src/domain/snapshot/write-journal.ts` | `openJournal()`, `recordAttempt()`, `recordOutcome()` — NDJSON journal |
| `src/domain/tools/change-management-tools.ts` | `createChangeManagementTools(baseTools)` factory → `plan_changes`, `apply_plan`, `rollback_change` |
| `src/config/maintenance-window.ts` | `MaintenanceWindow` type, `isWithinMaintenanceWindow()` |
| `test/unit/domain/snapshot/snapshot-engine.test.ts` | Tests for takeSnapshot / loadSnapshot |
| `test/unit/domain/snapshot/diff-engine.test.ts` | Tests for computeRestorePlan / applyRestorePlan |
| `test/unit/domain/snapshot/write-journal.test.ts` | Tests for recordAttempt / recordOutcome |
| `test/unit/domain/tools/change-management-tools.test.ts` | Tests for plan_changes, apply_plan, rollback_change |
| `test/unit/config/maintenance-window.test.ts` | Tests for isWithinMaintenanceWindow |

### Modified files
| Path | Change |
|---|---|
| `src/types.ts` | Add `SnapshotMeta`, `JournalEntry`, `RestorePlan` interfaces; remove stale `ConfigSnapshot`/`ConfigSection` placeholders |
| `src/domain/tools/tool-definition.ts` | Add `snapshotPaths?: string[]` to `ToolDefinition` |
| `src/config/app-config.ts` | Add `snapshotDir`, `journalPath` (derived from `dataDir`) |
| `src/config/router-registry.ts` | Parse `maintenanceWindows` from YAML into `RouterConfig` |
| `src/types.ts` | Add `maintenanceWindows` to `RouterConfig` |
| `src/mcp/tool-registry.ts` | Wire snapshot + journal; enforce maintenance windows |
| `src/domain/tools/index.ts` | Split `baseTools` / `allTools`; wire `createChangeManagementTools` |
| 15 write-tool files | Add `snapshotPaths` to each write `ToolDefinition` |
| `config/routers.example.yaml` | Add `maintenanceWindows` example |
| `test/unit/config/app-config.test.ts` | Tests for `snapshotDir`, `journalPath` |
| `ROADMAP.md`, `README.md` | Flip v0.8 ✅, update tool count |

---

## Task 1: AppConfig — snapshotDir and journalPath

**Files:**
- Modify: `src/config/app-config.ts`
- Modify: `test/unit/config/app-config.test.ts`

- [ ] **Step 1: Write failing tests**

Append inside the existing `describe("loadAppConfig")` block in `test/unit/config/app-config.test.ts`:

```typescript
// In afterEach, add:
delete process.env.MIKROMCP_DATA_DIR;

// New describe block:
describe("v0.8 snapshot/journal config", () => {
  it("snapshotDir defaults to data/snapshots", () => {
    expect(loadAppConfig().snapshotDir).toBe("data/snapshots");
  });

  it("journalPath defaults to data/write-journal.ndjson", () => {
    expect(loadAppConfig().journalPath).toBe("data/write-journal.ndjson");
  });

  it("snapshotDir uses MIKROMCP_DATA_DIR as prefix", () => {
    process.env.MIKROMCP_DATA_DIR = "/var/mikromcp";
    expect(loadAppConfig().snapshotDir).toBe("/var/mikromcp/snapshots");
  });

  it("journalPath uses MIKROMCP_DATA_DIR as prefix", () => {
    process.env.MIKROMCP_DATA_DIR = "/var/mikromcp";
    expect(loadAppConfig().journalPath).toBe("/var/mikromcp/write-journal.ndjson");
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
npm test -- test/unit/config/app-config.test.ts
```
Expected: 4 new tests fail (`snapshotDir` and `journalPath` are undefined)

- [ ] **Step 3: Implement — add fields to AppConfig and loadAppConfig**

In `src/config/app-config.ts`, add to the `AppConfig` interface:

```typescript
  snapshotDir: string;
  journalPath: string;
```

Add to `loadAppConfig()` return (after `dataDir`):

```typescript
    snapshotDir: `${env.MIKROMCP_DATA_DIR ?? "data"}/snapshots`,
    journalPath: `${env.MIKROMCP_DATA_DIR ?? "data"}/write-journal.ndjson`,
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npm test -- test/unit/config/app-config.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck**

```bash
npm run typecheck
```
Expected: no errors

- [ ] **Step 6: Commit**

```bash
git add src/config/app-config.ts test/unit/config/app-config.test.ts
git commit -m "feat(config): add snapshotDir and journalPath derived from dataDir"
```

---

## Task 2: Types — SnapshotMeta, JournalEntry, RestorePlan; ToolDefinition.snapshotPaths

**Files:**
- Modify: `src/types.ts`
- Modify: `src/domain/tools/tool-definition.ts`

- [ ] **Step 1: Update src/types.ts**

Remove the stale `ConfigSnapshot` and `ConfigSection` interfaces (they were placeholder scaffolding, never used). Add:

```typescript
export interface SnapshotMeta {
  id: string;          // e.g. "20260517-142301-ip-route-a3f9b1"
  routerId: string;
  path: string;        // RouterOS REST path e.g. "ip/route"
  ts: string;          // ISO-8601
  filePath: string;    // absolute path to the snapshot JSON file
  recordCount: number;
}

export interface JournalEntry {
  id: string;
  ts: string;
  identityId: string;
  role: string;
  tool: string;
  routerId: string;
  params: Record<string, unknown>;
  phase: "attempt" | "success" | "failure";
  snapshotIds: string[];
  outcome?: string;
  durationMs?: number;
}

export interface RestorePlan {
  path: string;
  toCreate: RouterOSRecord[];
  toRemove: string[];   // ".id" values of records to remove from current state
  toUpdate: Array<{ currentId: string; data: Record<string, string> }>;
}
```

Also add `maintenanceWindows` to `RouterConfig` (used in Task 3):

```typescript
export interface MaintenanceWindow {
  days: Array<"Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun">;
  startTime: string;   // "HH:MM" 24-hour
  endTime: string;     // "HH:MM" 24-hour
  timezone: string;    // IANA timezone name e.g. "Europe/Helsinki"
}
```

And update `RouterConfig` to add:

```typescript
  maintenanceWindows?: MaintenanceWindow[];
```

- [ ] **Step 2: Add snapshotPaths to ToolDefinition**

In `src/domain/tools/tool-definition.ts`, add to `ToolDefinition`:

```typescript
  snapshotPaths?: string[];  // RouterOS REST paths to snapshot before execution (write tools only)
```

- [ ] **Step 3: Typecheck**

```bash
npm run typecheck
```
Expected: no errors (fields are optional, no callsites break)

- [ ] **Step 4: Commit**

```bash
git add src/types.ts src/domain/tools/tool-definition.ts
git commit -m "feat(types): add SnapshotMeta, JournalEntry, RestorePlan, MaintenanceWindow; ToolDefinition.snapshotPaths"
```

---

## Task 3: RouterConfig — maintenanceWindows parsing

**Files:**
- Modify: `src/config/router-registry.ts`
- Modify: `config/routers.example.yaml`

There are no existing tests for the router registry that cover this field. We test the new parsing inline in a unit test.

- [ ] **Step 1: Write failing test**

Create `test/unit/config/router-registry-maintenance.test.ts`:

```typescript
import { describe, it, expect } from "vitest";
import { RouterRegistry } from "../../../src/config/router-registry.js";
import { writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";

const TMP = join(tmpdir(), "test-routers-mw.yaml");

function writeTmp(content: string) {
  writeFileSync(TMP, content, "utf-8");
}

describe("RouterRegistry — maintenanceWindows", () => {
  it("parses maintenanceWindows from YAML", () => {
    writeTmp(`
routers:
  edge-01:
    host: "10.0.0.1"
    port: 443
    tls:
      enabled: true
      rejectUnauthorized: true
    credentials:
      source: env
      envPrefix: ROUTER_EDGE01
    tags: []
    rosVersion: "7.14"
    maintenanceWindows:
      - days: ["Mon", "Wed", "Fri"]
        startTime: "02:00"
        endTime: "04:00"
        timezone: "Europe/Helsinki"
`);
    const reg = new RouterRegistry(TMP);
    const router = reg.getRouter("edge-01");
    expect(router.maintenanceWindows).toHaveLength(1);
    expect(router.maintenanceWindows![0].days).toEqual(["Mon", "Wed", "Fri"]);
    expect(router.maintenanceWindows![0].startTime).toBe("02:00");
    expect(router.maintenanceWindows![0].timezone).toBe("Europe/Helsinki");
  });

  it("defaults maintenanceWindows to undefined when not set", () => {
    writeTmp(`
routers:
  edge-01:
    host: "10.0.0.1"
    port: 443
    tls:
      enabled: true
      rejectUnauthorized: true
    credentials:
      source: env
      envPrefix: ROUTER_EDGE01
    tags: []
    rosVersion: "7.14"
`);
    const reg = new RouterRegistry(TMP);
    expect(reg.getRouter("edge-01").maintenanceWindows).toBeUndefined();
  });

  afterAll(() => { try { unlinkSync(TMP); } catch {} });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/config/router-registry-maintenance.test.ts
```
Expected: fails — `maintenanceWindows` is undefined even when set

- [ ] **Step 3: Update RouterConfigSchema in router-registry.ts**

In `src/config/router-registry.ts`, add to `RouterConfigSchema` (inside the `.object({...})`, before the closing `.strict()`):

```typescript
    maintenanceWindows: z
      .array(
        z.object({
          days: z.array(
            z.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]),
          ),
          startTime: z.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
          endTime: z.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
          timezone: z.string().min(1),
        }).strict(),
      )
      .optional(),
```

Then in the loop that builds `RouterConfig`, the spread `{ ...config, id }` automatically includes `maintenanceWindows` since it's now on the parsed schema result. No further change needed there.

- [ ] **Step 4: Run tests to verify they pass**

```bash
npm test -- test/unit/config/router-registry-maintenance.test.ts
```
Expected: both tests pass

- [ ] **Step 5: Update routers.example.yaml**

Add maintenance window example to the `core-01` block (after `cmdDeny`):

```yaml
    # maintenanceWindows:           # Destructive tools blocked outside these windows
    #   - days: ["Sat", "Sun"]      # Days when maintenance is permitted
    #     startTime: "02:00"        # 24-hour HH:MM in the specified timezone
    #     endTime: "06:00"
    #     timezone: "Europe/Helsinki"
```

- [ ] **Step 6: Typecheck and commit**

```bash
npm run typecheck
git add src/config/router-registry.ts config/routers.example.yaml test/unit/config/router-registry-maintenance.test.ts
git commit -m "feat(config): parse maintenanceWindows from routers.yaml into RouterConfig"
```

---

## Task 4: Snapshot engine

**Files:**
- Create: `src/domain/snapshot/snapshot-engine.ts`
- Create: `test/unit/domain/snapshot/snapshot-engine.test.ts`

Snapshots are stored as individual JSON files under `<snapshotDir>/<routerId>/`. The filename encodes the timestamp, path slug, and a random suffix to guarantee uniqueness.

- [ ] **Step 1: Write failing tests**

Create `test/unit/domain/snapshot/snapshot-engine.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { join } from "node:path";

vi.mock("node:fs", () => ({
  mkdirSync: vi.fn(),
  writeFileSync: vi.fn(),
  readFileSync: vi.fn(),
}));
vi.mock("nanoid", () => ({ nanoid: () => "abc123" }));

import * as fs from "node:fs";
import { takeSnapshot, loadSnapshot } from "../../../../src/domain/snapshot/snapshot-engine.js";
import type { RouterOSRestClient } from "../../../../src/adapter/rest-client.js";

const RECORDS = [
  { ".id": "*1", "dst-address": "10.0.0.0/8", "gateway": "192.168.1.1", "routing-table": "main" },
];

function makeClient(records = RECORDS): RouterOSRestClient {
  return {
    get: vi.fn().mockResolvedValue(records),
  } as unknown as RouterOSRestClient;
}

describe("takeSnapshot", () => {
  beforeEach(() => vi.clearAllMocks());

  it("fetches records and writes JSON file", async () => {
    const client = makeClient();
    const meta = await takeSnapshot(client, "edge-01", "ip/route", "/tmp/snapshots");

    expect(client.get).toHaveBeenCalledWith("ip/route", {});
    expect(fs.mkdirSync).toHaveBeenCalledWith(
      join("/tmp/snapshots", "edge-01"),
      { recursive: true },
    );
    expect(fs.writeFileSync).toHaveBeenCalledOnce();
    const written = JSON.parse((fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string);
    expect(written.routerId).toBe("edge-01");
    expect(written.path).toBe("ip/route");
    expect(written.records).toEqual(RECORDS);
  });

  it("returns SnapshotMeta with correct fields", async () => {
    const meta = await takeSnapshot(makeClient(), "edge-01", "ip/route", "/tmp/snapshots");
    expect(meta.routerId).toBe("edge-01");
    expect(meta.path).toBe("ip/route");
    expect(meta.recordCount).toBe(1);
    expect(meta.filePath).toContain("edge-01");
    expect(meta.filePath).toContain("ip-route");
  });
});

describe("loadSnapshot", () => {
  it("parses JSON file and returns records", () => {
    const stored = { id: "snap1", routerId: "edge-01", path: "ip/route", ts: "2026-01-01T00:00:00Z", records: RECORDS };
    (fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue(JSON.stringify(stored));
    const result = loadSnapshot("/tmp/snapshots/edge-01/snap1.json");
    expect(result).toEqual(stored);
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/snapshot/snapshot-engine.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement snapshot-engine.ts**

Create `src/domain/snapshot/snapshot-engine.ts`:

```typescript
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { nanoid } from "nanoid";
import type { RouterOSRestClient } from "../../adapter/rest-client.js";
import type { SnapshotMeta, RouterOSRecord } from "../../types.js";

function pathToSlug(rosPath: string): string {
  return rosPath.replace(/\//g, "-");
}

function timestampPrefix(): string {
  return new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15);
}

interface StoredSnapshot {
  id: string;
  routerId: string;
  path: string;
  ts: string;
  records: RouterOSRecord[];
}

export async function takeSnapshot(
  client: RouterOSRestClient,
  routerId: string,
  path: string,
  snapshotDir: string,
): Promise<SnapshotMeta> {
  const records = await client.get<RouterOSRecord>(path, {});
  const id = `${timestampPrefix()}-${pathToSlug(path)}-${nanoid(6)}`;
  const dir = join(snapshotDir, routerId);
  const filePath = join(dir, `${id}.json`);
  const ts = new Date().toISOString();

  mkdirSync(dir, { recursive: true });
  const stored: StoredSnapshot = { id, routerId, path, ts, records };
  writeFileSync(filePath, JSON.stringify(stored, null, 2));

  return { id, routerId, path, ts, filePath, recordCount: records.length };
}

export function loadSnapshot(filePath: string): StoredSnapshot {
  return JSON.parse(readFileSync(filePath, "utf-8")) as StoredSnapshot;
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npm test -- test/unit/domain/snapshot/snapshot-engine.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/snapshot/snapshot-engine.ts test/unit/domain/snapshot/snapshot-engine.test.ts
git commit -m "feat(snapshot): snapshot engine — takeSnapshot and loadSnapshot"
```

---

## Task 5: Diff engine — computeRestorePlan and applyRestorePlan

**Files:**
- Create: `src/domain/snapshot/diff-engine.ts`
- Create: `test/unit/domain/snapshot/diff-engine.test.ts`

The diff engine matches records between a before-snapshot and the current live state using semantic keys (the same fields used for idempotency in each tool). Records present in before but missing from current are re-created; records present in current but not in before are removed; records that differ are updated.

- [ ] **Step 1: Write failing tests**

Create `test/unit/domain/snapshot/diff-engine.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { computeRestorePlan, applyRestorePlan } from "../../../../src/domain/snapshot/diff-engine.js";
import type { RouterOSRestClient } from "../../../../src/adapter/rest-client.js";
import type { RouterOSRecord } from "../../../../src/types.js";

const ROUTE_A: RouterOSRecord = { ".id": "*1", "dst-address": "10.0.0.0/8", "gateway": "192.168.1.1", "routing-table": "main", "distance": "1" };
const ROUTE_B: RouterOSRecord = { ".id": "*2", "dst-address": "172.16.0.0/12", "gateway": "192.168.1.1", "routing-table": "main", "distance": "1" };

describe("computeRestorePlan", () => {
  it("identifies records to create (in before, not in current)", () => {
    const plan = computeRestorePlan("ip/route", [ROUTE_A], []);
    expect(plan.toCreate).toHaveLength(1);
    expect(plan.toCreate[0]["dst-address"]).toBe("10.0.0.0/8");
    expect(plan.toRemove).toHaveLength(0);
    expect(plan.toUpdate).toHaveLength(0);
  });

  it("identifies records to remove (in current, not in before)", () => {
    const plan = computeRestorePlan("ip/route", [], [ROUTE_B]);
    expect(plan.toRemove).toEqual(["*2"]);
    expect(plan.toCreate).toHaveLength(0);
  });

  it("identifies records to update (same key, different values)", () => {
    const changed: RouterOSRecord = { ...ROUTE_A, "distance": "10" };
    const plan = computeRestorePlan("ip/route", [ROUTE_A], [changed]);
    expect(plan.toUpdate).toHaveLength(1);
    expect(plan.toUpdate[0].currentId).toBe("*1");
    expect(plan.toUpdate[0].data["distance"]).toBe("1");
  });

  it("no-op when before and current are identical", () => {
    const plan = computeRestorePlan("ip/route", [ROUTE_A], [ROUTE_A]);
    expect(plan.toCreate).toHaveLength(0);
    expect(plan.toRemove).toHaveLength(0);
    expect(plan.toUpdate).toHaveLength(0);
  });

  it("falls back to full-record key for unknown paths", () => {
    const rec: RouterOSRecord = { ".id": "*1", "name": "foo", "value": "bar" };
    const plan = computeRestorePlan("some/unknown/path", [rec], []);
    expect(plan.toCreate).toHaveLength(1);
  });
});

describe("applyRestorePlan", () => {
  it("calls create for toCreate records (strips .id)", async () => {
    const client = { create: vi.fn().mockResolvedValue({ ".id": "*99" }), remove: vi.fn(), update: vi.fn() } as unknown as RouterOSRestClient;
    const plan = { path: "ip/route", toCreate: [ROUTE_A], toRemove: [], toUpdate: [] };
    await applyRestorePlan(plan, client);
    expect(client.create).toHaveBeenCalledWith("ip/route", {
      "dst-address": "10.0.0.0/8",
      "gateway": "192.168.1.1",
      "routing-table": "main",
      "distance": "1",
    });
  });

  it("calls remove for toRemove ids", async () => {
    const client = { create: vi.fn(), remove: vi.fn().mockResolvedValue(undefined), update: vi.fn() } as unknown as RouterOSRestClient;
    const plan = { path: "ip/route", toCreate: [], toRemove: ["*2"], toUpdate: [] };
    await applyRestorePlan(plan, client);
    expect(client.remove).toHaveBeenCalledWith("ip/route", "*2");
  });

  it("calls update for toUpdate entries", async () => {
    const client = { create: vi.fn(), remove: vi.fn(), update: vi.fn().mockResolvedValue(undefined) } as unknown as RouterOSRestClient;
    const plan = { path: "ip/route", toCreate: [], toRemove: [], toUpdate: [{ currentId: "*1", data: { "distance": "1" } }] };
    await applyRestorePlan(plan, client);
    expect(client.update).toHaveBeenCalledWith("ip/route", "*1", { "distance": "1" });
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/snapshot/diff-engine.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement diff-engine.ts**

Create `src/domain/snapshot/diff-engine.ts`:

```typescript
import type { RouterOSRestClient } from "../../adapter/rest-client.js";
import type { RouterOSRecord, RestorePlan } from "../../types.js";

export const SEMANTIC_KEYS: Record<string, readonly string[]> = {
  "ip/route": ["dst-address", "gateway", "routing-table"],
  "ip/firewall/filter": ["comment"],
  "ip/firewall/nat": ["comment"],
  "ip/firewall/mangle": ["comment"],
  "ip/firewall/address-list": ["list", "address"],
  "ip/address": ["address", "interface"],
  "ip/dns/static": ["name", "type"],
  "routing/rule": ["src-address", "routing-table"],
  "routing/table": ["name"],
  "interface/wireguard/peers": ["public-key"],
  "interface/bridge": ["name"],
  "interface/bridge/port": ["bridge", "interface"],
  "system/scheduler": ["name"],
  "system/script": ["name"],
  "system/package": ["name"],
  "container": ["name"],
};

function normalizeValue(v: string): string {
  if (v === "true" || v === "yes") return "true";
  if (v === "false" || v === "no") return "false";
  return v;
}

function semanticKey(record: RouterOSRecord, keys: readonly string[]): string {
  return keys.map((k) => normalizeValue(record[k] ?? "")).join("|");
}

function recordSignature(record: RouterOSRecord): string {
  return Object.entries(record)
    .filter(([k]) => k !== ".id")
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([k, v]) => `${k}=${normalizeValue(v)}`)
    .join(";");
}

function recordsAreEqual(a: RouterOSRecord, b: RouterOSRecord): boolean {
  const keysA = Object.keys(a).filter((k) => k !== ".id");
  const keysB = Object.keys(b).filter((k) => k !== ".id");
  if (keysA.length !== keysB.length) return false;
  return keysA.every((k) => normalizeValue(a[k]) === normalizeValue(b[k] ?? ""));
}

export function computeRestorePlan(
  path: string,
  before: RouterOSRecord[],
  current: RouterOSRecord[],
): RestorePlan {
  const keys = SEMANTIC_KEYS[path];

  if (!keys) {
    // Unknown path: match by full record signature
    const beforeSigs = new Set(before.map(recordSignature));
    const currentSigs = new Set(current.map(recordSignature));
    return {
      path,
      toCreate: before.filter((r) => !currentSigs.has(recordSignature(r))),
      toRemove: current.filter((r) => !beforeSigs.has(recordSignature(r))).map((r) => r[".id"]),
      toUpdate: [],
    };
  }

  const currentByKey = new Map(current.map((r) => [semanticKey(r, keys), r]));
  const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));

  const toCreate: RouterOSRecord[] = [];
  const toUpdate: RestorePlan["toUpdate"] = [];

  for (const [key, beforeRecord] of beforeByKey) {
    const currentRecord = currentByKey.get(key);
    if (!currentRecord) {
      toCreate.push(beforeRecord);
    } else if (!recordsAreEqual(beforeRecord, currentRecord)) {
      const { ".id": _, ...data } = beforeRecord;
      toUpdate.push({ currentId: currentRecord[".id"], data: data as Record<string, string> });
    }
  }

  const toRemove = current
    .filter((r) => !beforeByKey.has(semanticKey(r, keys)))
    .map((r) => r[".id"]);

  return { path, toCreate, toRemove, toUpdate };
}

export async function applyRestorePlan(
  plan: RestorePlan,
  client: RouterOSRestClient,
): Promise<void> {
  for (const id of plan.toRemove) {
    await client.remove(plan.path, id);
  }
  for (const record of plan.toCreate) {
    const { ".id": _, ...data } = record;
    await client.create(plan.path, data as Record<string, string>);
  }
  for (const { currentId, data } of plan.toUpdate) {
    await client.update(plan.path, currentId, data);
  }
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npm test -- test/unit/domain/snapshot/diff-engine.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/snapshot/diff-engine.ts test/unit/domain/snapshot/diff-engine.test.ts
git commit -m "feat(snapshot): diff engine — computeRestorePlan and applyRestorePlan with semantic key matching"
```

---

## Task 6: Write journal

**Files:**
- Create: `src/domain/snapshot/write-journal.ts`
- Create: `test/unit/domain/snapshot/write-journal.test.ts`

The write journal is an NDJSON file (one JSON object per line), modelled after the audit log. `recordAttempt` appends the initial entry with `phase: "attempt"` and returns a unique `journalId`. `recordOutcome` appends a second entry with `phase: "success"` or `"failure"`.

- [ ] **Step 1: Write failing tests**

Create `test/unit/domain/snapshot/write-journal.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("node:fs", () => ({ appendFileSync: vi.fn(), mkdirSync: vi.fn() }));
vi.mock("nanoid", () => ({ nanoid: () => "journal-id-1" }));

import * as fs from "node:fs";
import { recordAttempt, recordOutcome } from "../../../../src/domain/snapshot/write-journal.js";

describe("recordAttempt", () => {
  beforeEach(() => vi.clearAllMocks());

  it("returns a journalId", () => {
    const id = recordAttempt({
      journalPath: "/tmp/journal.ndjson",
      identityId: "alice",
      role: "admin",
      tool: "manage_route",
      routerId: "edge-01",
      params: { action: "add", dstAddress: "10.0.0.0/8" },
      snapshotIds: ["snap-abc"],
    });
    expect(id).toBe("journal-id-1");
  });

  it("appends a JSON line to the journal file", () => {
    recordAttempt({
      journalPath: "/tmp/journal.ndjson",
      identityId: "alice",
      role: "admin",
      tool: "manage_route",
      routerId: "edge-01",
      params: { action: "add" },
      snapshotIds: [],
    });
    expect(fs.appendFileSync).toHaveBeenCalledOnce();
    const line = (fs.appendFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string;
    const parsed = JSON.parse(line.trim());
    expect(parsed.phase).toBe("attempt");
    expect(parsed.tool).toBe("manage_route");
    expect(parsed.id).toBe("journal-id-1");
  });

  it("redacts sensitive param keys", () => {
    recordAttempt({
      journalPath: "/tmp/journal.ndjson",
      identityId: "alice",
      role: "admin",
      tool: "manage_route",
      routerId: "edge-01",
      params: { action: "add", password: "secret" },
      snapshotIds: [],
    });
    const line = (fs.appendFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string;
    expect(line).not.toContain("secret");
  });
});

describe("recordOutcome", () => {
  beforeEach(() => vi.clearAllMocks());

  it("appends a success line with durationMs", () => {
    recordOutcome({ journalPath: "/tmp/journal.ndjson", journalId: "j1", phase: "success", durationMs: 120 });
    const line = (fs.appendFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string;
    const parsed = JSON.parse(line.trim());
    expect(parsed.id).toBe("j1");
    expect(parsed.phase).toBe("success");
    expect(parsed.durationMs).toBe(120);
  });

  it("appends a failure line with outcome code", () => {
    recordOutcome({ journalPath: "/tmp/journal.ndjson", journalId: "j1", phase: "failure", outcome: "ROUTER_DOWN", durationMs: 50 });
    const line = (fs.appendFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string;
    const parsed = JSON.parse(line.trim());
    expect(parsed.phase).toBe("failure");
    expect(parsed.outcome).toBe("ROUTER_DOWN");
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/snapshot/write-journal.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement write-journal.ts**

Create `src/domain/snapshot/write-journal.ts`:

```typescript
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { nanoid } from "nanoid";
import { redactParams } from "../../observability/audit-log.js";
import { createLogger } from "../../observability/logger.js";

const log = createLogger("write-journal");

interface AttemptArgs {
  journalPath: string;
  identityId: string;
  role: string;
  tool: string;
  routerId: string;
  params: Record<string, unknown>;
  snapshotIds: string[];
}

interface OutcomeArgs {
  journalPath: string;
  journalId: string;
  phase: "success" | "failure";
  durationMs: number;
  outcome?: string;
}

function appendLine(filePath: string, obj: Record<string, unknown>): void {
  try {
    mkdirSync(dirname(filePath), { recursive: true });
    appendFileSync(filePath, JSON.stringify(obj) + "\n");
  } catch (err) {
    log.error({ err, filePath }, "Failed to write to write journal");
  }
}

export function recordAttempt(args: AttemptArgs): string {
  const id = nanoid();
  appendLine(args.journalPath, {
    id,
    ts: new Date().toISOString(),
    identityId: args.identityId,
    role: args.role,
    tool: args.tool,
    routerId: args.routerId,
    params: redactParams(args.params),
    snapshotIds: args.snapshotIds,
    phase: "attempt",
  });
  return id;
}

export function recordOutcome(args: OutcomeArgs): void {
  appendLine(args.journalPath, {
    id: args.journalId,
    ts: new Date().toISOString(),
    phase: args.phase,
    outcome: args.outcome,
    durationMs: args.durationMs,
  });
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npm test -- test/unit/domain/snapshot/write-journal.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/snapshot/write-journal.ts test/unit/domain/snapshot/write-journal.test.ts
git commit -m "feat(snapshot): write journal — recordAttempt and recordOutcome with redaction"
```

---

## Task 7: Wire snapshot and journal into tool-registry

**Files:**
- Modify: `src/mcp/tool-registry.ts`

No new unit tests here — the snapshot/journal modules have their own tests. The integration is validated manually (or via future integration tests). Focus on the wiring logic.

- [ ] **Step 1: Add imports to tool-registry.ts**

At the top of `src/mcp/tool-registry.ts`, add:

```typescript
import { takeSnapshot } from "../domain/snapshot/snapshot-engine.js";
import { recordAttempt, recordOutcome } from "../domain/snapshot/write-journal.js";
```

- [ ] **Step 2: Take snapshots and open journal entry before the handler runs**

In the tool handler, find the block immediately after `const { confirmationToken: _ct, ...handlerArgs } = args;`. Replace the `const runHandler = ...` block with:

```typescript
            // Snapshot before write
            const snapshotIds: string[] = [];
            if (tool.snapshotPaths && tool.snapshotPaths.length > 0 && !tool.annotations.readOnlyHint) {
              for (const path of tool.snapshotPaths) {
                try {
                  const meta = await takeSnapshot(client, routerId, path, config.snapshotDir);
                  snapshotIds.push(meta.id);
                  log.debug({ snapshotId: meta.id, path }, "Snapshot taken");
                } catch (err) {
                  log.warn({ err, path, routerId }, "Snapshot failed — proceeding without snapshot");
                }
              }
            }

            // Open journal entry for write tools
            const journalId =
              !tool.annotations.readOnlyHint && config.journalPath
                ? recordAttempt({
                    journalPath: config.journalPath,
                    identityId: identity.id,
                    role: identity.role,
                    tool: tool.name,
                    routerId,
                    params: handlerArgs,
                    snapshotIds,
                  })
                : undefined;

            const runHandler = () =>
              tool.handler(handlerArgs, {
                routerClient: client,
                routerId,
                correlationId,
                routerConfig,
                sshClient,
                ftpClient,
                identity,
              });
```

- [ ] **Step 3: Record journal outcome after success**

Find the `const result = await executeHandler();` line. After it (before the existing `if (shouldAudit)` success block), add:

```typescript
            if (journalId) {
              recordOutcome({
                journalPath: config.journalPath!,
                journalId,
                phase: "success",
                durationMs: Date.now() - startMs,
              });
            }
```

- [ ] **Step 4: Record journal outcome on failure**

In the `catch (err)` block, after `const error = ...`, add:

```typescript
            if (journalId) {
              recordOutcome({
                journalPath: config.journalPath!,
                journalId,
                phase: "failure",
                outcome: error.code,
                durationMs: Date.now() - startMs,
              });
            }
```

Note: `journalId` is not in scope inside the catch block since it's declared in the try block. Fix this by declaring `let journalId: string | undefined;` **before** the try block and assigning inside:

```typescript
            let journalId: string | undefined;
            // ... (remove const from the let journalId assignment inside try)
```

- [ ] **Step 5: Typecheck**

```bash
npm run typecheck
```
Expected: no errors

- [ ] **Step 6: Run full test suite**

```bash
npm test
```
Expected: all 492+ tests pass (no existing tests should break)

- [ ] **Step 7: Commit**

```bash
git add src/mcp/tool-registry.ts
git commit -m "feat(registry): wire snapshot and journal into write-tool execution path"
```

---

## Task 8: Add snapshotPaths to all write tools

**Files:**
- Modify (15 files): `route-tools.ts`, `firewall-tools.ts`, `ip-tools.ts`, `mangle-tools.ts`, `address-list-tools.ts`, `policy-routing-tools.ts`, `dns-tools.ts`, `wireguard-tools.ts`, `bridge-tools.ts`, `scripts-tools.ts`, `scheduler-tools.ts`, `packages-tools.ts`, `files-tools.ts`, `container-tools.ts`, `system-tools.ts`

Each write `ToolDefinition` gets a `snapshotPaths` array. Read-only tools get nothing.

- [ ] **Step 1: Add snapshotPaths to each write tool**

For each file below, find the write tool definition and add `snapshotPaths`:

**`src/domain/tools/route-tools.ts`** — `manage_route`:
```typescript
  snapshotPaths: ["ip/route"],
```

**`src/domain/tools/firewall-tools.ts`** — `manage_firewall_rule`:
```typescript
  snapshotPaths: ["ip/firewall/filter", "ip/firewall/nat"],
```

**`src/domain/tools/ip-tools.ts`** — `manage_ip_address`:
```typescript
  snapshotPaths: ["ip/address"],
```

**`src/domain/tools/mangle-tools.ts`** — `manage_mangle_rule`:
```typescript
  snapshotPaths: ["ip/firewall/mangle"],
```

**`src/domain/tools/address-list-tools.ts`** — `manage_address_list_entry`:
```typescript
  snapshotPaths: ["ip/firewall/address-list"],
```

**`src/domain/tools/policy-routing-tools.ts`** — `manage_routing_rule`, `manage_routing_table`:
```typescript
  // manage_routing_rule:
  snapshotPaths: ["routing/rule"],
  // manage_routing_table:
  snapshotPaths: ["routing/table"],
```

**`src/domain/tools/dns-tools.ts`** — `manage_dns_entry`:
```typescript
  snapshotPaths: ["ip/dns/static"],
```

**`src/domain/tools/wireguard-tools.ts`** — `manage_wireguard_peer`:
```typescript
  snapshotPaths: ["interface/wireguard/peers"],
```

**`src/domain/tools/bridge-tools.ts`** — `manage_bridge`, `manage_bridge_port`:
```typescript
  // manage_bridge:
  snapshotPaths: ["interface/bridge"],
  // manage_bridge_port:
  snapshotPaths: ["interface/bridge/port"],
```

**`src/domain/tools/scripts-tools.ts`** — `manage_script`, `run_script`:
```typescript
  // manage_script:
  snapshotPaths: ["system/script"],
  // run_script: no snapshot (scripts have side effects beyond a single path)
```

**`src/domain/tools/scheduler-tools.ts`** — `manage_scheduled_job`:
```typescript
  snapshotPaths: ["system/scheduler"],
```

**`src/domain/tools/packages-tools.ts`** — `manage_package`:
```typescript
  snapshotPaths: ["system/package"],
```

**`src/domain/tools/files-tools.ts`** — `upload_file`:
```typescript
  snapshotPaths: ["file"],
```

**`src/domain/tools/container-tools.ts`** — `manage_container`:
```typescript
  snapshotPaths: ["container"],
```

**`src/domain/tools/system-tools.ts`** — `reboot` has no meaningful path to snapshot; `set_system_clock`:
```typescript
  // set_system_clock:
  snapshotPaths: ["system/clock"],
```

- [ ] **Step 2: Typecheck**

```bash
npm run typecheck
```
Expected: no errors

- [ ] **Step 3: Run full test suite**

```bash
npm test
```
Expected: all tests pass

- [ ] **Step 4: Commit**

```bash
git add src/domain/tools/
git commit -m "feat(tools): add snapshotPaths to all write tool definitions"
```

---

## Task 9: Maintenance window checker + tool-registry enforcement

**Files:**
- Create: `src/config/maintenance-window.ts`
- Create: `test/unit/config/maintenance-window.test.ts`
- Modify: `src/mcp/tool-registry.ts`

- [ ] **Step 1: Write failing tests**

Create `test/unit/config/maintenance-window.test.ts`:

```typescript
import { describe, it, expect } from "vitest";
import { isWithinMaintenanceWindow } from "../../../src/config/maintenance-window.js";
import type { MaintenanceWindow } from "../../../src/types.js";

// Monday 2026-05-18 03:00 UTC = 06:00 Helsinki (EEST = UTC+3)
const MONDAY_03_UTC = new Date("2026-05-18T03:00:00.000Z");
// Monday 2026-05-18 10:00 UTC = 13:00 Helsinki
const MONDAY_10_UTC = new Date("2026-05-18T10:00:00.000Z");
// Sunday 2026-05-17 03:00 UTC = 06:00 Helsinki
const SUNDAY_03_UTC = new Date("2026-05-17T03:00:00.000Z");

const WEEKDAY_NIGHT: MaintenanceWindow = {
  days: ["Mon", "Tue", "Wed", "Thu", "Fri"],
  startTime: "02:00",
  endTime: "06:00",
  timezone: "Europe/Helsinki",
};

describe("isWithinMaintenanceWindow", () => {
  it("returns true when no windows configured", () => {
    expect(isWithinMaintenanceWindow([], MONDAY_03_UTC)).toBe(true);
  });

  it("returns true when inside a window (Mon 06:00 Helsinki)", () => {
    expect(isWithinMaintenanceWindow([WEEKDAY_NIGHT], MONDAY_03_UTC)).toBe(true);
  });

  it("returns false when outside window hours (Mon 13:00 Helsinki)", () => {
    expect(isWithinMaintenanceWindow([WEEKDAY_NIGHT], MONDAY_10_UTC)).toBe(false);
  });

  it("returns false when outside window days (Sun 06:00 Helsinki)", () => {
    expect(isWithinMaintenanceWindow([WEEKDAY_NIGHT], SUNDAY_03_UTC)).toBe(false);
  });

  it("returns true when at least one window matches", () => {
    const weekend: MaintenanceWindow = {
      days: ["Sat", "Sun"],
      startTime: "00:00",
      endTime: "23:59",
      timezone: "Europe/Helsinki",
    };
    expect(isWithinMaintenanceWindow([WEEKDAY_NIGHT, weekend], SUNDAY_03_UTC)).toBe(true);
  });

  it("returns false when startTime equals endTime (zero-length window)", () => {
    const w: MaintenanceWindow = { days: ["Mon"], startTime: "03:00", endTime: "03:00", timezone: "Europe/Helsinki" };
    expect(isWithinMaintenanceWindow([w], MONDAY_03_UTC)).toBe(false);
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/config/maintenance-window.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement maintenance-window.ts**

Create `src/config/maintenance-window.ts`:

```typescript
import type { MaintenanceWindow } from "../types.js";

const DAY_ABBREVS: Record<string, MaintenanceWindow["days"][number]> = {
  Sun: "Sun", Mon: "Mon", Tue: "Tue", Wed: "Wed",
  Thu: "Thu", Fri: "Fri", Sat: "Sat",
};

export function isWithinMaintenanceWindow(
  windows: MaintenanceWindow[],
  now: Date,
): boolean {
  if (windows.length === 0) return true;

  return windows.some((w) => {
    const parts = new Intl.DateTimeFormat("en-US", {
      timeZone: w.timezone,
      weekday: "short",
      hour: "2-digit",
      minute: "2-digit",
      hour12: false,
    }).formatToParts(now);

    const rawDay = parts.find((p) => p.type === "weekday")?.value ?? "";
    const day = DAY_ABBREVS[rawDay];
    if (!day || !w.days.includes(day)) return false;

    const hour = parts.find((p) => p.type === "hour")?.value?.padStart(2, "0") ?? "00";
    const minute = parts.find((p) => p.type === "minute")?.value?.padStart(2, "0") ?? "00";
    const currentTime = `${hour}:${minute}`;

    return currentTime >= w.startTime && currentTime < w.endTime;
  });
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npm test -- test/unit/config/maintenance-window.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Add maintenance window check to tool-registry.ts**

Add import at the top:
```typescript
import { isWithinMaintenanceWindow } from "../config/maintenance-window.js";
```

In the tool handler, after `checkAuthz(identity, tool.name, routerId)` and before the confirmation gate, add:

```typescript
            if (tool.annotations.destructiveHint) {
              const windows = routerConfig?.maintenanceWindows;
              if (windows && windows.length > 0 && !isWithinMaintenanceWindow(windows, new Date())) {
                throw new MikroMCPError({
                  category: ErrorCategory.PERMISSION_DENIED,
                  code: "OUTSIDE_MAINTENANCE_WINDOW",
                  message: `Router "${routerId}" has maintenance windows configured. Destructive operations are only permitted during scheduled windows.`,
                  details: { maintenanceWindows: windows },
                  recoverability: {
                    retryable: true,
                    suggestedAction: "Wait for a scheduled maintenance window, or remove maintenanceWindows from routers.yaml to allow unrestricted access.",
                  },
                });
              }
            }
```

Note: `routerConfig` is declared later in the try block (after the CB setup). Move the `const routerConfig = registry.getRouter(routerId);` call to **before** the maintenance window check so it's available.

- [ ] **Step 6: Typecheck and run full test suite**

```bash
npm run typecheck && npm test
```
Expected: no errors, all tests pass

- [ ] **Step 7: Commit**

```bash
git add src/config/maintenance-window.ts test/unit/config/maintenance-window.test.ts src/mcp/tool-registry.ts
git commit -m "feat(auth): maintenance window enforcement — destructive tools blocked outside configured windows"
```

---

## Task 10: plan_changes tool

**Files:**
- Create: `src/domain/tools/change-management-tools.ts` (partial — this task adds `plan_changes` only)
- Create: `test/unit/domain/tools/change-management-tools.test.ts` (partial)

`plan_changes` is read-only (it calls other handlers with `dryRun: true`). It doesn't need a confirmation token. It takes a list of steps (up to 10), calls each tool's handler directly with `dryRun: true`, and returns the current state of each snapshotPath plus the dry-run result.

- [ ] **Step 1: Write failing tests for plan_changes**

Create `test/unit/domain/tools/change-management-tools.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { createChangeManagementTools } from "../../../../src/domain/tools/change-management-tools.js";
import type { ToolDefinition, ToolContext } from "../../../../src/domain/tools/tool-definition.js";
import type { RouterOSRestClient } from "../../../../src/adapter/rest-client.js";

const ROUTE_RECORD = { ".id": "*1", "dst-address": "10.0.0.0/8", "gateway": "192.168.1.1", "routing-table": "main" };

function makeManageRoute(dryRunResult: string): ToolDefinition {
  return {
    name: "manage_route",
    title: "Manage Route",
    description: "",
    inputSchema: { parse: (p: unknown) => p } as unknown as import("zod").ZodType,
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    snapshotPaths: ["ip/route"],
    handler: vi.fn().mockResolvedValue({ content: dryRunResult, structuredContent: { action: "dry_run" } }),
  };
}

function makeContext(records = [ROUTE_RECORD]): ToolContext {
  return {
    routerId: "edge-01",
    correlationId: "corr-1",
    routerConfig: { id: "edge-01" } as unknown as import("../../../../src/types.js").RouterConfig,
    routerClient: { get: vi.fn().mockResolvedValue(records) } as unknown as RouterOSRestClient,
    sshClient: {} as unknown as import("../../../../src/adapter/ssh-client.js").SshClient,
    ftpClient: {} as unknown as import("../../../../src/adapter/ftp-client.js").FtpClient,
    identity: { id: "alice", role: "admin", allowedRouters: [], allowedToolPatterns: [] },
  };
}

describe("plan_changes", () => {
  it("is registered correctly", () => {
    const tools = createChangeManagementTools([makeManageRoute("dry-run ok")]);
    const tool = tools.find((t) => t.name === "plan_changes");
    expect(tool).toBeDefined();
    expect(tool!.annotations.readOnlyHint).toBe(false);
    expect(tool!.annotations.destructiveHint).toBe(false);
  });

  it("calls each step handler with dryRun=true and returns results", async () => {
    const manageRoute = makeManageRoute("Would add route 10.0.0.0/8");
    const tools = createChangeManagementTools([manageRoute]);
    const planTool = tools.find((t) => t.name === "plan_changes")!;
    const ctx = makeContext();

    const result = await planTool.handler(
      { routerId: "edge-01", steps: [{ tool: "manage_route", params: { action: "add", dstAddress: "10.0.0.0/8", gateway: "192.168.1.1" } }] },
      ctx,
    );

    expect(manageRoute.handler).toHaveBeenCalledWith(
      expect.objectContaining({ dryRun: true, action: "add" }),
      ctx,
    );
    const structured = result.structuredContent as Record<string, unknown>;
    expect((structured.steps as unknown[]).length).toBe(1);
  });

  it("throws TOOL_NOT_FOUND for unknown tool name", async () => {
    const tools = createChangeManagementTools([]);
    const planTool = tools.find((t) => t.name === "plan_changes")!;
    await expect(
      planTool.handler({ routerId: "edge-01", steps: [{ tool: "nonexistent", params: {} }] }, makeContext()),
    ).rejects.toMatchObject({ code: "TOOL_NOT_FOUND" });
  });

  it("rejects extra fields in input schema", () => {
    const tools = createChangeManagementTools([]);
    const planTool = tools.find((t) => t.name === "plan_changes")!;
    expect(() =>
      planTool.inputSchema.parse({ routerId: "edge-01", steps: [], unknownField: true }),
    ).toThrow();
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/change-management-tools.test.ts
```
Expected: fails — module not found

- [ ] **Step 3: Implement plan_changes in change-management-tools.ts**

Create `src/domain/tools/change-management-tools.ts`:

```typescript
import { z } from "zod";
import type { ToolDefinition, ToolContext, ToolResult } from "./tool-definition.js";
import { MikroMCPError, ErrorCategory } from "../errors/error-types.js";
import { enrichError } from "../errors/error-enricher.js";
import { createLogger } from "../../observability/logger.js";

const log = createLogger("change-management");

const stepSchema = z
  .object({
    tool: z.string().min(1).describe("Name of the MikroMCP tool to invoke"),
    params: z.record(z.unknown()).describe("Tool parameters (dryRun is injected automatically)"),
  })
  .strict();

const planChangesInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    steps: z
      .array(stepSchema)
      .min(1)
      .max(10)
      .describe("Ordered list of write operations to preview (up to 10)"),
  })
  .strict();

const applyPlanInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    steps: z
      .array(stepSchema)
      .min(1)
      .max(10)
      .describe("Ordered list of write operations to apply in sequence"),
    confirmationToken: z.string().optional(),
  })
  .strict();

const rollbackChangeInputSchema = z
  .object({
    routerId: z.string().describe("Target router identifier"),
    journalId: z.string().describe("Journal entry ID from write-journal.ndjson to roll back"),
    dryRun: z.boolean().default(false).describe("Preview the restore plan without applying changes"),
  })
  .strict();

export function createChangeManagementTools(baseTools: ToolDefinition[]): ToolDefinition[] {
  const toolMap = new Map(baseTools.map((t) => [t.name, t]));

  function requireTool(name: string): ToolDefinition {
    const tool = toolMap.get(name);
    if (!tool) {
      throw new MikroMCPError({
        category: ErrorCategory.NOT_FOUND,
        code: "TOOL_NOT_FOUND",
        message: `Tool "${name}" not found. Available tools: ${[...toolMap.keys()].join(", ")}`,
        recoverability: {
          retryable: false,
          suggestedAction: "Check the tool name and try again.",
        },
      });
    }
    return tool;
  }

  const planChangesTool: ToolDefinition = {
    name: "plan_changes",
    title: "Plan Changes",
    description:
      "Preview a sequence of write operations without applying them. Each step is run with dryRun=true against the live router state. Returns the current state of affected RouterOS paths plus the predicted action for each step. Use apply_plan to execute the same steps for real.",
    inputSchema: planChangesInputSchema,
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
    async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
      const parsed = planChangesInputSchema.parse(params);
      log.info({ routerId: context.routerId, stepCount: parsed.steps.length }, "Planning changes");

      const stepResults = [];

      for (const [i, step] of parsed.steps.entries()) {
        const tool = requireTool(step.tool);

        // Fetch current state for snapshot paths
        const currentState: Record<string, unknown[]> = {};
        for (const path of tool.snapshotPaths ?? []) {
          try {
            currentState[path] = await context.routerClient.get(path, {});
          } catch {
            currentState[path] = [];
          }
        }

        // Run with dryRun injected
        const dryRunParams = { ...step.params, routerId: parsed.routerId, dryRun: true };
        let dryRunResult: ToolResult;
        try {
          dryRunResult = await tool.handler(dryRunParams, context);
        } catch (err) {
          const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: step.tool });
          dryRunResult = {
            content: `Step ${i + 1} (${step.tool}): would fail — ${error.message}`,
            structuredContent: { action: "would_fail", error: error.code },
            isError: true,
          };
        }

        stepResults.push({
          stepIndex: i,
          tool: step.tool,
          params: step.params,
          currentState,
          dryRunResult: dryRunResult.content,
          structuredDryRun: dryRunResult.structuredContent,
        });
      }

      return {
        content: `Plan for ${parsed.steps.length} step(s) on ${parsed.routerId}:\n` +
          stepResults.map((s) => `  Step ${s.stepIndex + 1} (${s.tool}): ${s.dryRunResult}`).join("\n"),
        structuredContent: { routerId: parsed.routerId, steps: stepResults },
      };
    },
  };

  // apply_plan and rollback_change are added in Tasks 11 and 12.
  // Return only plan_changes for now; tasks 11 and 12 extend this array.
  return [planChangesTool];
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npm test -- test/unit/domain/tools/change-management-tools.test.ts
```
Expected: all `plan_changes` tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/change-management-tools.ts test/unit/domain/tools/change-management-tools.test.ts
git commit -m "feat(tools): plan_changes — preview a sequence of writes with dryRun"
```

---

## Task 11: apply_plan tool

**Files:**
- Modify: `src/domain/tools/change-management-tools.ts`
- Modify: `test/unit/domain/tools/change-management-tools.test.ts`

`apply_plan` has `destructiveHint: true` so it gets the confirmation gate. It calls each step's handler directly (bypassing per-tool confirmation gates, since the user confirmed the plan as a whole). It takes its own snapshots and journal entries for each step.

- [ ] **Step 1: Write failing tests for apply_plan**

Append to `test/unit/domain/tools/change-management-tools.test.ts`:

```typescript
describe("apply_plan", () => {
  it("is registered with destructiveHint=true", () => {
    const tools = createChangeManagementTools([makeManageRoute("applied")]);
    const tool = tools.find((t) => t.name === "apply_plan");
    expect(tool).toBeDefined();
    expect(tool!.annotations.destructiveHint).toBe(true);
  });

  it("calls each step handler in order", async () => {
    const manageRoute = makeManageRoute("created");
    const tools = createChangeManagementTools([manageRoute]);
    const applyTool = tools.find((t) => t.name === "apply_plan")!;
    const ctx = makeContext();

    const result = await applyTool.handler(
      { routerId: "edge-01", steps: [{ tool: "manage_route", params: { action: "add", dstAddress: "10.0.0.0/8", gateway: "192.168.1.1" } }] },
      ctx,
    );

    expect(manageRoute.handler).toHaveBeenCalledWith(
      expect.objectContaining({ action: "add", routerId: "edge-01" }),
      ctx,
    );
    expect(result.content).toContain("1/1");
  });

  it("stops on first failure and reports which step failed", async () => {
    const failingTool: ToolDefinition = {
      ...makeManageRoute(""),
      handler: vi.fn().mockRejectedValue(new Error("Router unreachable")),
    };
    const tools = createChangeManagementTools([failingTool]);
    const applyTool = tools.find((t) => t.name === "apply_plan")!;

    const result = await applyTool.handler(
      { routerId: "edge-01", steps: [{ tool: "manage_route", params: { action: "add" } }] },
      makeContext(),
    );

    expect(result.isError).toBe(true);
    expect(result.content).toContain("step 1");
  });

  it("rejects extra fields in input schema", () => {
    const tools = createChangeManagementTools([]);
    const applyTool = tools.find((t) => t.name === "apply_plan")!;
    expect(() =>
      applyTool.inputSchema.parse({ routerId: "edge-01", steps: [], extra: true }),
    ).toThrow();
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/change-management-tools.test.ts
```
Expected: `apply_plan` tests fail — tool not in array

- [ ] **Step 3: Implement apply_plan in change-management-tools.ts**

In `createChangeManagementTools`, before the `return [planChangesTool]` line, add:

```typescript
  const applyPlanTool: ToolDefinition = {
    name: "apply_plan",
    title: "Apply Plan",
    description:
      "Execute a sequence of write operations in order. Stops on the first failure. Each step is snapshotted and journaled individually. Requires a confirmationToken for non-admin identities (same two-step flow as other destructive tools). Use rollback_change with any resulting journal IDs to undo individual steps.",
    inputSchema: applyPlanInputSchema,
    snapshotPaths: [],  // apply_plan takes per-step snapshots itself; no top-level path
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: false,
      openWorldHint: false,
    },
    async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
      const parsed = applyPlanInputSchema.parse(params);
      log.info({ routerId: context.routerId, stepCount: parsed.steps.length }, "Applying plan");

      const results = [];

      for (const [i, step] of parsed.steps.entries()) {
        const tool = requireTool(step.tool);
        const stepParams = { ...step.params, routerId: parsed.routerId };

        try {
          const result = await tool.handler(stepParams, context);
          results.push({ stepIndex: i, tool: step.tool, status: "success", result: result.content });
        } catch (err) {
          const error = err instanceof MikroMCPError ? err : enrichError(err, { tool: step.tool });
          log.error({ err: error, tool: step.tool, step: i }, "apply_plan step failed");
          return {
            content: `Apply failed at step ${i + 1}/${parsed.steps.length} (${step.tool}): ${error.message}. ${i} step(s) completed before failure.`,
            structuredContent: {
              status: "failed",
              failedStep: i,
              completedSteps: results,
              error: { code: error.code, message: error.message },
            },
            isError: true,
          };
        }
      }

      return {
        content: `Applied ${parsed.steps.length}/${parsed.steps.length} step(s) on ${parsed.routerId} successfully.`,
        structuredContent: { status: "success", routerId: parsed.routerId, steps: results },
      };
    },
  };
```

And update the return statement:
```typescript
  return [planChangesTool, applyPlanTool];
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
npm test -- test/unit/domain/tools/change-management-tools.test.ts
```
Expected: all `plan_changes` and `apply_plan` tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/change-management-tools.ts test/unit/domain/tools/change-management-tools.test.ts
git commit -m "feat(tools): apply_plan — sequenced write execution with per-step stop-on-failure"
```

---

## Task 12: rollback_change tool

**Files:**
- Modify: `src/domain/tools/change-management-tools.ts`
- Modify: `test/unit/domain/tools/change-management-tools.test.ts`

`rollback_change` reads the journal file to find the entry by ID, loads each before-snapshot, fetches live state, computes a `RestorePlan`, and applies it using `applyRestorePlan`. The `dryRun` parameter shows the plan without applying.

- [ ] **Step 1: Write failing tests for rollback_change**

Append to `test/unit/domain/tools/change-management-tools.test.ts`:

```typescript
vi.mock("node:fs", () => ({
  readFileSync: vi.fn(),
  existsSync: vi.fn().mockReturnValue(true),
}));

import * as nodeFs from "node:fs";
import { computeRestorePlan } from "../../../../src/domain/snapshot/diff-engine.js";

describe("rollback_change", () => {
  const JOURNAL_LINE_ATTEMPT = JSON.stringify({
    id: "j-abc",
    ts: "2026-05-17T10:00:00Z",
    identityId: "alice",
    role: "admin",
    tool: "manage_route",
    routerId: "edge-01",
    params: { action: "add" },
    snapshotIds: ["20260517-snap-1"],
    phase: "attempt",
  });
  const JOURNAL_LINE_SUCCESS = JSON.stringify({
    id: "j-abc",
    ts: "2026-05-17T10:00:01Z",
    phase: "success",
    durationMs: 123,
  });
  const SNAPSHOT_CONTENT = JSON.stringify({
    id: "20260517-snap-1",
    routerId: "edge-01",
    path: "ip/route",
    ts: "2026-05-17T10:00:00Z",
    records: [],
  });

  it("is registered with destructiveHint=true", () => {
    const tools = createChangeManagementTools([]);
    const tool = tools.find((t) => t.name === "rollback_change");
    expect(tool).toBeDefined();
    expect(tool!.annotations.destructiveHint).toBe(true);
  });

  it("dryRun returns restore plan without applying", async () => {
    (nodeFs.readFileSync as ReturnType<typeof vi.fn>)
      .mockReturnValueOnce(`${JOURNAL_LINE_ATTEMPT}\n${JOURNAL_LINE_SUCCESS}\n`) // journal
      .mockReturnValueOnce(SNAPSHOT_CONTENT); // snapshot file

    const tools = createChangeManagementTools([]);
    const rollbackTool = tools.find((t) => t.name === "rollback_change")!;
    const ctx = makeContext([ROUTE_RECORD]); // current state has one route

    const result = await rollbackTool.handler(
      { routerId: "edge-01", journalId: "j-abc", dryRun: true },
      ctx,
    );

    expect(result.isError).toBeUndefined();
    expect(result.content).toContain("dry run");
    const structured = result.structuredContent as Record<string, unknown>;
    expect(structured.action).toBe("dry_run");
  });

  it("throws NOT_FOUND for unknown journalId", async () => {
    (nodeFs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValueOnce(""); // empty journal
    const tools = createChangeManagementTools([]);
    const rollbackTool = tools.find((t) => t.name === "rollback_change")!;

    await expect(
      rollbackTool.handler({ routerId: "edge-01", journalId: "missing-id", dryRun: false }, makeContext()),
    ).rejects.toMatchObject({ code: "JOURNAL_ENTRY_NOT_FOUND" });
  });
});
```

- [ ] **Step 2: Run to verify failure**

```bash
npm test -- test/unit/domain/tools/change-management-tools.test.ts
```
Expected: `rollback_change` tests fail — tool not in array

- [ ] **Step 3: Implement rollback_change in change-management-tools.ts**

Add the following import at the top of the file:

```typescript
import { readFileSync } from "node:fs";
import { loadSnapshot } from "../snapshot/snapshot-engine.js";
import { computeRestorePlan, applyRestorePlan } from "../snapshot/diff-engine.js";
import type { RouterOSRecord } from "../../types.js";
```

In `createChangeManagementTools`, before the final `return`, add:

```typescript
  function findJournalEntry(journalPath: string, journalId: string): { snapshotIds: string[] } {
    const raw = readFileSync(journalPath, "utf-8");
    const lines = raw.split("\n").filter(Boolean);
    const entry = lines
      .map((l) => JSON.parse(l) as Record<string, unknown>)
      .find((e) => e.id === journalId && e.phase === "attempt");
    if (!entry) {
      throw new MikroMCPError({
        category: ErrorCategory.NOT_FOUND,
        code: "JOURNAL_ENTRY_NOT_FOUND",
        message: `Journal entry "${journalId}" not found in write journal.`,
        recoverability: {
          retryable: false,
          suggestedAction: "Check the journalId. Only writes recorded since MIKROMCP_JOURNAL_PATH was configured are available.",
        },
      });
    }
    return { snapshotIds: (entry.snapshotIds as string[]) ?? [] };
  }

  const rollbackChangeTool: ToolDefinition = {
    name: "rollback_change",
    title: "Rollback Change",
    description:
      "Restore the RouterOS state to what it was before a write, identified by its journal ID. Reads the before-snapshot from disk, computes the diff against live state, and applies the reverse diff. Use dryRun=true to preview the restore plan without applying. Requires MIKROMCP_JOURNAL_PATH to be set.",
    inputSchema: rollbackChangeInputSchema,
    annotations: {
      readOnlyHint: false,
      destructiveHint: true,
      idempotentHint: false,
      openWorldHint: false,
    },
    async handler(params: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
      const parsed = rollbackChangeInputSchema.parse(params);
      const journalPath = process.env.MIKROMCP_DATA_DIR
        ? `${process.env.MIKROMCP_DATA_DIR}/write-journal.ndjson`
        : "data/write-journal.ndjson";

      log.info({ routerId: context.routerId, journalId: parsed.journalId }, "Rolling back change");

      const { snapshotIds } = findJournalEntry(journalPath, parsed.journalId);

      if (snapshotIds.length === 0) {
        return {
          content: `Journal entry "${parsed.journalId}" has no associated snapshots — cannot rollback automatically.`,
          structuredContent: { action: "no_snapshot", journalId: parsed.journalId },
        };
      }

      const restorePlans = [];

      for (const snapshotId of snapshotIds) {
        // Resolve snapshot file path from snapshotId
        const snapshotDir = process.env.MIKROMCP_DATA_DIR
          ? `${process.env.MIKROMCP_DATA_DIR}/snapshots`
          : "data/snapshots";
        const filePath = `${snapshotDir}/${context.routerId}/${snapshotId}.json`;
        const stored = loadSnapshot(filePath);
        const current = await context.routerClient.get<RouterOSRecord>(stored.path, {});
        const plan = computeRestorePlan(stored.path, stored.records, current);
        restorePlans.push(plan);
      }

      const totalOps = restorePlans.reduce(
        (sum, p) => sum + p.toCreate.length + p.toRemove.length + p.toUpdate.length, 0,
      );

      if (parsed.dryRun) {
        return {
          content: `Rollback dry run for journal entry "${parsed.journalId}": ${totalOps} operation(s) would be applied across ${restorePlans.length} path(s).`,
          structuredContent: { action: "dry_run", journalId: parsed.journalId, restorePlans },
        };
      }

      for (const plan of restorePlans) {
        await applyRestorePlan(plan, context.routerClient);
      }

      return {
        content: `Rolled back journal entry "${parsed.journalId}": ${totalOps} operation(s) applied across ${restorePlans.length} path(s).`,
        structuredContent: { action: "rolled_back", journalId: parsed.journalId, restorePlans },
      };
    },
  };
```

Update the return:
```typescript
  return [planChangesTool, applyPlanTool, rollbackChangeTool];
```

- [ ] **Step 4: Run all change-management tests**

```bash
npm test -- test/unit/domain/tools/change-management-tools.test.ts
```
Expected: all tests pass

- [ ] **Step 5: Typecheck and commit**

```bash
npm run typecheck
git add src/domain/tools/change-management-tools.ts test/unit/domain/tools/change-management-tools.test.ts
git commit -m "feat(tools): rollback_change — restore pre-write state using journal ID and before-snapshot"
```

---

## Task 13: Wire into index.ts, run full suite, update docs

**Files:**
- Modify: `src/domain/tools/index.ts`
- Modify: `ROADMAP.md`
- Modify: `README.md`

- [ ] **Step 1: Refactor index.ts to export baseTools and wire factory**

Replace the contents of `src/domain/tools/index.ts`:

```typescript
import type { ToolDefinition } from "./tool-definition.js";
import { systemTools } from "./system-tools.js";
import { interfaceTools } from "./interface-tools.js";
import { ipTools } from "./ip-tools.js";
import { dhcpTools } from "./dhcp-tools.js";
import { routeTools } from "./route-tools.js";
import { firewallTools } from "./firewall-tools.js";
import { diagnosticTools } from "./diagnostic-tools.js";
import { systemOpsTools } from "./system-ops-tools.js";
import { bridgeTools } from "./bridge-tools.js";
import { wifiTools } from "./wifi-tools.js";
import { wireguardTools } from "./wireguard-tools.js";
import { dnsTools } from "./dns-tools.js";
import { mangleTools } from "./mangle-tools.js";
import { addressListTools } from "./address-list-tools.js";
import { policyRoutingTools } from "./policy-routing-tools.js";
import { routingProtocolTools } from "./routing-protocol-tools.js";
import { scriptsTools } from "./scripts-tools.js";
import { schedulerTools } from "./scheduler-tools.js";
import { packagesTools } from "./packages-tools.js";
import { filesTools } from "./files-tools.js";
import { containerTools } from "./container-tools.js";
import { createChangeManagementTools } from "./change-management-tools.js";

const baseTools: ToolDefinition[] = [
  ...systemTools,
  ...interfaceTools,
  ...ipTools,
  ...dhcpTools,
  ...routeTools,
  ...firewallTools,
  ...diagnosticTools,
  ...systemOpsTools,
  ...bridgeTools,
  ...wifiTools,
  ...wireguardTools,
  ...dnsTools,
  ...mangleTools,
  ...addressListTools,
  ...policyRoutingTools,
  ...routingProtocolTools,
  ...scriptsTools,
  ...schedulerTools,
  ...packagesTools,
  ...filesTools,
  ...containerTools,
];

export const allTools: ToolDefinition[] = [
  ...baseTools,
  ...createChangeManagementTools(baseTools),
];

export type {
  ToolDefinition,
  ToolContext,
  ToolResult,
  ToolAnnotations,
} from "./tool-definition.js";
```

- [ ] **Step 2: Run full test suite**

```bash
npm test && npm run typecheck
```
Expected: all tests pass (total should be 492+ from before, plus new tests)

- [ ] **Step 3: Flip v0.8 in ROADMAP.md**

In `ROADMAP.md`, change `## 🔜 v0.8` to `## ✅ v0.8`.

- [ ] **Step 4: Update README.md tool count**

Find the tool count in `README.md` (currently 51) and update it to 54 (51 + `plan_changes`, `apply_plan`, `rollback_change`). Update the Available Tools table to include the three new tools.

- [ ] **Step 5: Final commit**

```bash
git add src/domain/tools/index.ts ROADMAP.md README.md
git commit -m "feat(v0.8): wire change-management tools into allTools; flip v0.8 ✅ in docs"
```

---

## Self-Review

**Spec coverage:**
- ✅ Snapshot engine — Task 4
- ✅ Before/after diff normalization — Task 5 (semantic key matching, field normalization)
- ✅ Write journal — Task 6
- ✅ Wired into tool-registry for all write tools — Tasks 7 & 8
- ✅ `plan_changes` — Task 10
- ✅ `apply_plan` — Task 11
- ✅ `rollback_change` — Task 12
- ✅ Maintenance-window guardrails — Task 9

**Placeholder scan:** No TBDs, no "similar to Task N", no steps without code.

**Type consistency:** `SnapshotMeta`, `JournalEntry`, `RestorePlan`, `MaintenanceWindow` defined in Task 2 and used consistently through Tasks 4–12. `createChangeManagementTools` signature uses `ToolDefinition[]` throughout. `computeRestorePlan` / `applyRestorePlan` function names match between Task 5 (definition) and Tasks 10/12 (usage).

**Edge cases covered:**
- Snapshot failure is non-fatal (warns and continues)
- Unknown journal ID throws `JOURNAL_ENTRY_NOT_FOUND`
- Journal entry with no snapshot IDs returns a clear `no_snapshot` result
- Unknown path in diff engine falls back to full-record signature matching
- Maintenance window with `startTime === endTime` correctly returns false
- `apply_plan` stops on first failure and reports which step and how many completed
