@hanna84/mcp-writing 3.20.0 → 3.21.1

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/CHANGELOG.md CHANGED
@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v3.21.1](https://github.com/hannasdev/mcp-writing/compare/v3.21.0...v3.21.1)
8
+
9
+ - fix(sync): preserve managed canonical structure [`#222`](https://github.com/hannasdev/mcp-writing/pull/222)
10
+
11
+ #### [v3.21.0](https://github.com/hannasdev/mcp-writing/compare/v3.20.0...v3.21.0)
12
+
13
+ > 26 May 2026
14
+
15
+ - feat(backup): apply project restores transactionally [`#221`](https://github.com/hannasdev/mcp-writing/pull/221)
16
+ - Release 3.21.0 [`4097165`](https://github.com/hannasdev/mcp-writing/commit/40971653b4332af83c15a9337fa1089b61aa0973)
17
+
7
18
  #### [v3.20.0](https://github.com/hannasdev/mcp-writing/compare/v3.19.0...v3.20.0)
8
19
 
20
+ > 24 May 2026
21
+
9
22
  - feat(backup): add project restore dry-run planning [`#220`](https://github.com/hannasdev/mcp-writing/pull/220)
23
+ - Release 3.20.0 [`0237cae`](https://github.com/hannasdev/mcp-writing/commit/0237cae3743e46cd19433e3e47cf5582d37dfe7c)
10
24
 
11
25
  #### [v3.19.0](https://github.com/hannasdev/mcp-writing/compare/v3.18.1...v3.19.0)
12
26
 
package/README.md CHANGED
@@ -28,9 +28,9 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
28
28
 
29
29
  **Current status:**
30
30
  - **Core platform complete:** Metadata-first analysis, sidecar-backed metadata maintenance, AI-assisted prose editing with confirmation + git history, review bundles, and Scrivener Direct extraction are all implemented.
31
- - **Recently completed:** Docker, CI, and Deployment Workflow made Docker a supported way to build, run, smoke-test, and deploy Writing MCP.
32
- - **Previous milestone:** Filesystem Boundary Hardening centralized local file mutation through application-aware helpers and lint guardrails.
33
- - **Active development:** Database Backup and Recovery now has project backup export, freshness diagnostics, advisory operation history, and automatic backup refresh after sanctioned project-scoped canonical mutations; the next slice focuses on dry-run restore planning.
31
+ - **Recently completed:** Database Backup and Recovery added project backup export, freshness diagnostics, advisory operation history, automatic backup refresh after sanctioned project-scoped canonical mutations, dry-run restore planning, transactional restore application, and backup/restore operations guidance.
32
+ - **Previous milestone:** Docker, CI, and Deployment Workflow made Docker a supported way to build, run, smoke-test, and deploy Writing MCP.
33
+ - **Active development:** Post-initiative stabilization and backlog selection.
34
34
  - **Deferred backlog:** OpenClaw integration, client-agnostic setup, divisions, and embeddings search.
35
35
  - **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
36
36
 
@@ -47,6 +47,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
47
47
  | [docs/guides/setup.md](docs/guides/setup.md) | Prerequisites, first-time setup, Scrivener import, native sync format |
48
48
  | [mcp-writing-vscode](https://github.com/hannasdev/mcp-writing-vscode) | VS Code extension for client-native setup flows |
49
49
  | [docs/guides/docker.md](docs/guides/docker.md) | Docker Compose, deployment operations, MCP gateway notes |
50
+ | [docs/guides/backup-recovery.md](docs/guides/backup-recovery.md) | Project backup artifacts, diagnostics, and explicit restore workflow |
50
51
  | [docs/foundations/managed-structure-contract.md](docs/foundations/managed-structure-contract.md) | Design boundaries for structural mutation, generated views, import, and maintenance workflows |
51
52
  | [docs/agents/tools.md](docs/agents/tools.md) | Full tool reference — auto-generated from source |
52
53
  | [docs/agents/README.md](docs/agents/README.md) | Index of agent-focused guidance, examples, and boot files |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.20.0",
3
+ "version": "3.21.1",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import matter from "gray-matter";
3
4
  import {
4
5
  buildProjectBackup,
5
6
  computeProjectBackupBundleChecksum,
@@ -65,6 +66,30 @@ const PROJECT_SCOPE_FIELDS = new Map([
65
66
  ["reference_links", ["source_project_id"]],
66
67
  ]);
67
68
 
69
+ const SNAPSHOT_DOMAIN_COLUMNS = new Map([
70
+ ["project", ["project_id", "universe_id", "name"]],
71
+ ["universe", ["universe_id", "name"]],
72
+ ["external_references", ["character_ids", "place_ids", "reference_doc_ids"]],
73
+ ["operation_history", ["supported", "authority", "advisory", "artifact"]],
74
+ ["chapters", ["chapter_id", "project_id", "title", "sort_index", "logline", "source_path", "source_checksum", "metadata_stale", "updated_at"]],
75
+ ["scenes", ["scene_id", "project_id", "chapter_id", "scene_role", "title", "part", "chapter", "chapter_title", "pov", "logline", "scene_change", "causality", "stakes", "scene_functions", "save_the_cat_beat", "timeline_position", "story_time", "word_count", "file_path", "prose_checksum", "metadata_stale", "updated_at"]],
76
+ ["epigraphs", ["epigraph_id", "project_id", "chapter_id", "file_path", "prose_checksum", "metadata_stale", "updated_at"]],
77
+ ["epigraph_characters", ["epigraph_id", "project_id", "character_id"]],
78
+ ["epigraph_tags", ["epigraph_id", "project_id", "tag"]],
79
+ ["scene_characters", ["scene_id", "project_id", "character_id"]],
80
+ ["scene_places", ["scene_id", "project_id", "place_id"]],
81
+ ["scene_tags", ["scene_id", "project_id", "tag"]],
82
+ ["scene_threads", ["scene_id", "project_id", "thread_id", "beat"]],
83
+ ["characters", ["character_id", "project_id", "universe_id", "name", "role", "arc_summary", "first_appearance", "file_path"]],
84
+ ["character_traits", ["character_id", "trait"]],
85
+ ["character_relationships", ["from_character", "to_character", "relationship_type", "strength", "scene_id", "note"]],
86
+ ["places", ["place_id", "project_id", "universe_id", "name", "file_path"]],
87
+ ["threads", ["thread_id", "project_id", "name", "status"]],
88
+ ["reference_docs", ["doc_id", "project_id", "universe_id", "type", "title", "summary", "file_path"]],
89
+ ["reference_doc_tags", ["doc_id", "tag"]],
90
+ ["reference_links", ["source_kind", "source_project_id", "source_id", "target_doc_id", "relation", "origin"]],
91
+ ]);
92
+
68
93
  function stableStringify(value) {
69
94
  if (value === null || typeof value !== "object") return JSON.stringify(value);
70
95
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
@@ -81,6 +106,10 @@ function jsonType(value) {
81
106
  return typeof value;
82
107
  }
83
108
 
109
+ function sortedUnique(values) {
110
+ return [...new Set(values.filter(value => value !== null && value !== undefined && value !== ""))].sort();
111
+ }
112
+
84
113
  function fileReferenceBoundaryFailure(error, fallbackResolvedPath) {
85
114
  const errorDetails = isRecord(error?.details) ? error.details : {};
86
115
  const message = error instanceof Error ? error.message : String(error);
@@ -127,7 +156,7 @@ function identityFieldError(row, field, nullableFields, emptyStringFields) {
127
156
  actual_type: jsonType(value),
128
157
  };
129
158
  }
130
- if (value === "" && !nullableFields.has(field) && !emptyStringFields.has(field)) {
159
+ if (value === "" && !emptyStringFields.has(field)) {
131
160
  return { reason: "empty_identity" };
132
161
  }
133
162
  return null;
@@ -256,6 +285,75 @@ function compareRows(currentRows = [], backupRows = [], keyFields) {
256
285
  return changes;
257
286
  }
258
287
 
288
+ function rowIsCrossScope(domain, row, projectId) {
289
+ if (!row) return false;
290
+ if (domain === "universes") return true;
291
+ if (["characters", "places", "reference_docs"].includes(domain)) {
292
+ return row.project_id == null || row.project_id === "";
293
+ }
294
+ if (domain === "character_traits") {
295
+ return false;
296
+ }
297
+ if (domain === "reference_links") {
298
+ return row.source_project_id == null || row.source_project_id === "";
299
+ }
300
+ if (domain === "reference_doc_tags") {
301
+ return false;
302
+ }
303
+ if (domain === "character_relationships") {
304
+ return row.scene_id == null || row.scene_id === "";
305
+ }
306
+ return Object.hasOwn(row, "project_id") && row.project_id !== projectId;
307
+ }
308
+
309
+ function mergeScopedIds(map, rows, idField, projectId) {
310
+ for (const row of rows ?? []) {
311
+ const id = row?.[idField];
312
+ if (!id) continue;
313
+ const crossScope = row.project_id == null || row.project_id === "" || row.project_id !== projectId;
314
+ map.set(id, (map.get(id) ?? false) || crossScope);
315
+ }
316
+ }
317
+
318
+ function buildRestoreScopeContext(currentSnapshot, backupSnapshot, projectId) {
319
+ const projectSceneIds = new Set();
320
+ for (const snapshot of [currentSnapshot, backupSnapshot]) {
321
+ for (const row of snapshot.scenes ?? []) {
322
+ if (row.project_id === projectId) projectSceneIds.add(row.scene_id);
323
+ }
324
+ }
325
+
326
+ const characterIds = new Map();
327
+ mergeScopedIds(characterIds, currentSnapshot.characters, "character_id", projectId);
328
+ mergeScopedIds(characterIds, backupSnapshot.characters, "character_id", projectId);
329
+
330
+ const referenceDocIds = new Map();
331
+ mergeScopedIds(referenceDocIds, currentSnapshot.reference_docs, "doc_id", projectId);
332
+ mergeScopedIds(referenceDocIds, backupSnapshot.reference_docs, "doc_id", projectId);
333
+
334
+ return { characterIds, projectSceneIds, referenceDocIds };
335
+ }
336
+
337
+ function rowIsContextCrossScope(domain, row, projectId, scopeContext) {
338
+ if (domain === "character_traits") {
339
+ return scopeContext.characterIds.get(row?.character_id) === true;
340
+ }
341
+ if (domain === "reference_doc_tags") {
342
+ return scopeContext.referenceDocIds.get(row?.doc_id) === true;
343
+ }
344
+ if (domain === "character_relationships") {
345
+ if (!row || row.scene_id == null || row.scene_id === "") return true;
346
+ return !scopeContext.projectSceneIds.has(row.scene_id);
347
+ }
348
+ return rowIsCrossScope(domain, row, projectId);
349
+ }
350
+
351
+ function markChangeScope(change, projectId, scopeContext) {
352
+ const row = change.backup ?? change.current ?? null;
353
+ const crossScope = rowIsContextCrossScope(change.domain, row, projectId, scopeContext);
354
+ return crossScope ? { ...change, cross_scope: true } : change;
355
+ }
356
+
259
357
  function compareSingleton(domain, current, backup, keyFields) {
260
358
  return compareRows(
261
359
  current ? [current] : [],
@@ -323,7 +421,7 @@ function collectCurrentSnapshot(db, {
323
421
  if (built.ok) return { ok: true, snapshot: built.snapshot, checksum: built.manifest.checksums.canonical_snapshot_sha256 };
324
422
  if (built.error?.code === "NOT_FOUND") {
325
423
  const snapshot = buildEmptyCurrentSnapshot(db, backupSnapshot);
326
- return { ok: true, snapshot, checksum: null };
424
+ return { ok: true, snapshot, checksum: computeProjectBackupSnapshotChecksum(snapshot) };
327
425
  }
328
426
  return built;
329
427
  }
@@ -527,6 +625,21 @@ function validateBundleShape({ manifest, snapshot, backupDir, projectId }) {
527
625
  },
528
626
  { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
529
627
  ));
628
+ } else if (value !== null) {
629
+ const allowedColumns = SNAPSHOT_DOMAIN_COLUMNS.get(domain) ?? [];
630
+ const unexpectedColumns = Object.keys(value).filter(column => !allowedColumns.includes(column));
631
+ if (unexpectedColumns.length) {
632
+ diagnostics.push(createDiagnostic(
633
+ "project_restore_invalid_snapshot",
634
+ `Backup canonical snapshot field "${domain}" contains unsupported columns.`,
635
+ {
636
+ backup_dir: backupDir,
637
+ domain,
638
+ unsupported_columns: unexpectedColumns,
639
+ },
640
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
641
+ ));
642
+ }
530
643
  }
531
644
  }
532
645
 
@@ -570,6 +683,22 @@ function validateBundleShape({ manifest, snapshot, backupDir, projectId }) {
570
683
  return;
571
684
  }
572
685
 
686
+ const allowedColumns = SNAPSHOT_DOMAIN_COLUMNS.get(domain) ?? [];
687
+ const unexpectedColumns = Object.keys(row).filter(column => !allowedColumns.includes(column));
688
+ if (unexpectedColumns.length) {
689
+ diagnostics.push(createDiagnostic(
690
+ "project_restore_invalid_snapshot",
691
+ `Backup canonical snapshot row ${index} in domain "${domain}" contains unsupported columns.`,
692
+ {
693
+ backup_dir: backupDir,
694
+ domain,
695
+ index,
696
+ unsupported_columns: unexpectedColumns,
697
+ },
698
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
699
+ ));
700
+ }
701
+
573
702
  let hasValidIdentity = true;
574
703
  for (const field of keyFields) {
575
704
  const identityError = identityFieldError(row, field, nullableFields, emptyStringFields);
@@ -648,7 +777,21 @@ function validateBundleShape({ manifest, snapshot, backupDir, projectId }) {
648
777
  continue;
649
778
  }
650
779
  const value = row[field];
651
- if (value !== null && value !== undefined && value !== "" && value !== projectId) {
780
+ const emptyStringFields = EMPTY_STRING_IDENTITY_FIELDS.get(domain) ?? new Set();
781
+ if (value === "" && !emptyStringFields.has(field)) {
782
+ diagnostics.push(createDiagnostic(
783
+ "project_restore_invalid_snapshot",
784
+ `Backup canonical snapshot row ${index} in domain "${domain}" has empty project scope field "${field}".`,
785
+ {
786
+ backup_dir: backupDir,
787
+ domain,
788
+ index,
789
+ field,
790
+ reason: "empty_project_scope",
791
+ },
792
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
793
+ ));
794
+ } else if (value !== null && value !== undefined && value !== "" && value !== projectId) {
652
795
  diagnostics.push(createDiagnostic(
653
796
  "project_restore_wrong_project",
654
797
  `Backup canonical snapshot row ${index} in domain "${domain}" has ${field} "${value}", not "${projectId}".`,
@@ -700,7 +843,7 @@ function validateCurrentSnapshotForPlanning(snapshot, { backupDir }) {
700
843
  return diagnostics;
701
844
  }
702
845
 
703
- function buildRestorePlan(currentSnapshot, backupSnapshot) {
846
+ function buildRestorePlan(currentSnapshot, backupSnapshot, { projectId }) {
704
847
  const changes = [
705
848
  ...compareSingleton("projects", currentSnapshot.project, backupSnapshot.project, ["project_id"]),
706
849
  ...compareSingleton("universes", currentSnapshot.universe, backupSnapshot.universe, ["universe_id"]),
@@ -712,25 +855,210 @@ function buildRestorePlan(currentSnapshot, backupSnapshot) {
712
855
  }
713
856
  }
714
857
 
858
+ const scopeContext = buildRestoreScopeContext(currentSnapshot, backupSnapshot, projectId);
859
+ const scopedChanges = changes.map(change => markChangeScope(change, projectId, scopeContext));
860
+
715
861
  const byDomain = {};
716
- for (const change of changes) {
862
+ for (const change of scopedChanges) {
717
863
  byDomain[change.domain] ??= { create: 0, update: 0, delete: 0, unchanged: 0, refused: 0, conflict: 0 };
718
864
  byDomain[change.domain][change.action] = (byDomain[change.domain][change.action] ?? 0) + 1;
719
865
  }
720
866
 
721
867
  return {
722
- totals: countActions(changes),
868
+ totals: countActions(scopedChanges),
723
869
  by_domain: byDomain,
724
- destructive_change_count: changes.filter(change => change.action === "delete").length,
725
- changes,
870
+ destructive_change_count: scopedChanges.filter(change => change.action === "delete").length,
871
+ cross_scope_change_count: scopedChanges.filter(change => change.cross_scope && change.action !== "unchanged").length,
872
+ changes: scopedChanges,
726
873
  };
727
874
  }
728
875
 
876
+ function placeholders(values) {
877
+ return values.map(() => "?").join(",") || "NULL";
878
+ }
879
+
880
+ function allSnapshotIds(snapshot, domain, field) {
881
+ return sortedUnique((snapshot[domain] ?? []).map(row => row[field]));
882
+ }
883
+
884
+ function deleteCharacterRelationship(db, row) {
885
+ db.prepare(`
886
+ DELETE FROM character_relationships
887
+ WHERE from_character = ?
888
+ AND to_character = ?
889
+ AND relationship_type = ?
890
+ AND (strength IS ? OR strength = ?)
891
+ AND (scene_id IS ? OR scene_id = ?)
892
+ AND (note IS ? OR note = ?)
893
+ `).run(
894
+ row.from_character,
895
+ row.to_character,
896
+ row.relationship_type,
897
+ row.strength ?? null,
898
+ row.strength ?? null,
899
+ row.scene_id ?? null,
900
+ row.scene_id ?? null,
901
+ row.note ?? null,
902
+ row.note ?? null
903
+ );
904
+ }
905
+
906
+ function upsertRow(db, table, row, conflictColumns) {
907
+ const columns = Object.keys(row);
908
+ if (!conflictColumns.length) {
909
+ db.prepare(`
910
+ INSERT INTO ${table} (${columns.join(", ")})
911
+ VALUES (${columns.map(() => "?").join(", ")})
912
+ `).run(...columns.map(column => row[column]));
913
+ return;
914
+ }
915
+ const updateColumns = columns.filter(column => !conflictColumns.includes(column));
916
+ const conflictSql = conflictColumns.join(", ");
917
+ const updateSql = updateColumns.length
918
+ ? `DO UPDATE SET ${updateColumns.map(column => `${column} = excluded.${column}`).join(", ")}`
919
+ : "DO NOTHING";
920
+ db.prepare(`
921
+ INSERT INTO ${table} (${columns.join(", ")})
922
+ VALUES (${columns.map(() => "?").join(", ")})
923
+ ON CONFLICT (${conflictSql}) ${updateSql}
924
+ `).run(...columns.map(column => row[column]));
925
+ }
926
+
927
+ function insertRows(db, table, rows, conflictColumns) {
928
+ for (const row of rows) {
929
+ upsertRow(db, table, row, conflictColumns);
930
+ }
931
+ }
932
+
933
+ function restoreEpigraphRows(db, snapshot, { syncDir }) {
934
+ for (const row of snapshot.epigraphs) {
935
+ const filePath = path.isAbsolute(row.file_path)
936
+ ? path.resolve(row.file_path)
937
+ : path.resolve(syncDir, row.file_path);
938
+ const body = matter(fs.readFileSync(filePath, "utf8")).content;
939
+ upsertRow(db, "epigraphs", {
940
+ ...row,
941
+ body,
942
+ file_path: row.file_path,
943
+ }, ["epigraph_id", "project_id"]);
944
+ }
945
+ }
946
+
947
+ function clearDerivedRestoreRows(db, { projectId, currentSnapshot, backupSnapshot }) {
948
+ db.prepare(`DELETE FROM scenes_fts WHERE project_id = ?`).run(projectId);
949
+
950
+ const referenceDocIds = sortedUnique([
951
+ ...allSnapshotIds(currentSnapshot, "reference_docs", "doc_id"),
952
+ ...allSnapshotIds(backupSnapshot, "reference_docs", "doc_id"),
953
+ ]);
954
+ if (referenceDocIds.length) {
955
+ db.prepare(`
956
+ DELETE FROM reference_docs_fts
957
+ WHERE doc_id IN (${placeholders(referenceDocIds)})
958
+ `).run(...referenceDocIds);
959
+ }
960
+ }
961
+
962
+ function applyProjectRestore(db, {
963
+ projectId,
964
+ syncDir,
965
+ currentSnapshot,
966
+ backupSnapshot,
967
+ }) {
968
+ const currentCharacterIds = allSnapshotIds(currentSnapshot, "characters", "character_id");
969
+ const currentPlaceIds = allSnapshotIds(currentSnapshot, "places", "place_id");
970
+ const currentReferenceDocIds = allSnapshotIds(currentSnapshot, "reference_docs", "doc_id");
971
+
972
+ clearDerivedRestoreRows(db, { projectId, currentSnapshot, backupSnapshot });
973
+
974
+ for (const domain of [
975
+ "epigraph_characters",
976
+ "epigraph_tags",
977
+ "scene_characters",
978
+ "scene_places",
979
+ "scene_tags",
980
+ "scene_threads",
981
+ ]) {
982
+ db.prepare(`DELETE FROM ${domain} WHERE project_id = ?`).run(projectId);
983
+ }
984
+
985
+ db.prepare(`DELETE FROM chapters WHERE project_id = ?`).run(projectId);
986
+ db.prepare(`DELETE FROM epigraphs WHERE project_id = ?`).run(projectId);
987
+ db.prepare(`DELETE FROM scenes WHERE project_id = ?`).run(projectId);
988
+ db.prepare(`DELETE FROM threads WHERE project_id = ?`).run(projectId);
989
+
990
+ if (currentCharacterIds.length) {
991
+ db.prepare(`DELETE FROM character_traits WHERE character_id IN (${placeholders(currentCharacterIds)})`).run(...currentCharacterIds);
992
+ }
993
+ for (const row of currentSnapshot.character_relationships ?? []) {
994
+ deleteCharacterRelationship(db, row);
995
+ }
996
+ if (currentCharacterIds.length) {
997
+ db.prepare(`DELETE FROM characters WHERE character_id IN (${placeholders(currentCharacterIds)})`).run(...currentCharacterIds);
998
+ }
999
+
1000
+ if (currentPlaceIds.length) {
1001
+ db.prepare(`DELETE FROM places WHERE place_id IN (${placeholders(currentPlaceIds)})`).run(...currentPlaceIds);
1002
+ }
1003
+
1004
+ if (currentReferenceDocIds.length) {
1005
+ db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id IN (${placeholders(currentReferenceDocIds)})`).run(...currentReferenceDocIds);
1006
+ db.prepare(`DELETE FROM reference_docs WHERE doc_id IN (${placeholders(currentReferenceDocIds)})`).run(...currentReferenceDocIds);
1007
+ }
1008
+ for (const row of currentSnapshot.reference_links ?? []) {
1009
+ db.prepare(`
1010
+ DELETE FROM reference_links
1011
+ WHERE source_kind = ?
1012
+ AND source_project_id = ?
1013
+ AND source_id = ?
1014
+ AND target_doc_id = ?
1015
+ AND relation = ?
1016
+ `).run(row.source_kind, row.source_project_id, row.source_id, row.target_doc_id, row.relation);
1017
+ }
1018
+
1019
+ if (currentSnapshot.universe && !backupSnapshot.universe) {
1020
+ db.prepare(`DELETE FROM universes WHERE universe_id = ?`).run(currentSnapshot.universe.universe_id);
1021
+ }
1022
+
1023
+ if (backupSnapshot.universe) {
1024
+ upsertRow(db, "universes", backupSnapshot.universe, ["universe_id"]);
1025
+ }
1026
+ if (backupSnapshot.project) {
1027
+ upsertRow(db, "projects", backupSnapshot.project, ["project_id"]);
1028
+ }
1029
+
1030
+ insertRows(db, "chapters", backupSnapshot.chapters, ["chapter_id", "project_id"]);
1031
+ insertRows(db, "scenes", backupSnapshot.scenes, ["scene_id", "project_id"]);
1032
+ restoreEpigraphRows(db, backupSnapshot, { syncDir });
1033
+ insertRows(db, "characters", backupSnapshot.characters, ["character_id"]);
1034
+ insertRows(db, "character_traits", backupSnapshot.character_traits, ["character_id", "trait"]);
1035
+ insertRows(db, "character_relationships", backupSnapshot.character_relationships, []);
1036
+ insertRows(db, "places", backupSnapshot.places, ["place_id"]);
1037
+ insertRows(db, "threads", backupSnapshot.threads, ["thread_id"]);
1038
+ insertRows(db, "reference_docs", backupSnapshot.reference_docs, ["doc_id"]);
1039
+ insertRows(db, "reference_doc_tags", backupSnapshot.reference_doc_tags, ["doc_id", "tag"]);
1040
+ insertRows(db, "reference_links", backupSnapshot.reference_links, ["source_kind", "source_project_id", "source_id", "target_doc_id", "relation"]);
1041
+
1042
+ for (const domain of [
1043
+ ["epigraph_characters", ["epigraph_id", "project_id", "character_id"]],
1044
+ ["epigraph_tags", ["epigraph_id", "project_id", "tag"]],
1045
+ ["scene_characters", ["scene_id", "project_id", "character_id"]],
1046
+ ["scene_places", ["scene_id", "project_id", "place_id"]],
1047
+ ["scene_tags", ["scene_id", "project_id", "tag"]],
1048
+ ["scene_threads", ["scene_id", "project_id", "thread_id"]],
1049
+ ]) {
1050
+ insertRows(db, domain[0], backupSnapshot[domain[0]], domain[1]);
1051
+ }
1052
+ }
1053
+
729
1054
  export function restoreProjectFromBackup(db, {
730
1055
  syncDir,
731
1056
  projectId,
732
1057
  backupPath = null,
733
1058
  dryRun = true,
1059
+ confirmDestructive = false,
1060
+ confirmCrossScope = false,
1061
+ expectedCurrentSnapshotChecksum = null,
734
1062
  applicationVersion = "0.0.0",
735
1063
  } = {}) {
736
1064
  const resolvedBackupDir = resolveBackupDir(backupPath ?? path.join(syncDir, "project-backups", projectId));
@@ -740,15 +1068,6 @@ export function restoreProjectFromBackup(db, {
740
1068
  const snapshotRead = readJsonFile(snapshotPath, "canonical snapshot");
741
1069
  const diagnostics = [manifestRead.diagnostic, snapshotRead.diagnostic].filter(Boolean);
742
1070
 
743
- if (dryRun === false) {
744
- diagnostics.push(createDiagnostic(
745
- "project_restore_apply_not_implemented",
746
- "Project backup restore apply is not implemented in this milestone.",
747
- { project_id: projectId },
748
- { severity: "error", nextStep: "Run with dry_run=true to inspect the restore plan. Apply support is planned for M7." }
749
- ));
750
- }
751
-
752
1071
  const manifest = manifestRead.ok ? manifestRead.value : null;
753
1072
  const snapshot = snapshotRead.ok ? snapshotRead.value : null;
754
1073
  if (manifestRead.ok && snapshotRead.ok) {
@@ -830,10 +1149,125 @@ export function restoreProjectFromBackup(db, {
830
1149
  };
831
1150
  }
832
1151
 
833
- const plan = buildRestorePlan(current.snapshot, snapshot);
1152
+ const plan = buildRestorePlan(current.snapshot, snapshot, { projectId });
1153
+ const applyDiagnostics = [];
1154
+ if (dryRun === false) {
1155
+ if (typeof expectedCurrentSnapshotChecksum !== "string" || expectedCurrentSnapshotChecksum === "") {
1156
+ applyDiagnostics.push(createDiagnostic(
1157
+ "project_restore_current_snapshot_confirmation_required",
1158
+ "Project backup restore requires the current_snapshot_checksum from the reviewed dry-run plan.",
1159
+ {
1160
+ project_id: projectId,
1161
+ current_snapshot_checksum: current.checksum,
1162
+ },
1163
+ {
1164
+ severity: "error",
1165
+ nextStep: "Run a dry-run restore, review the plan, then retry with expected_current_snapshot_checksum set to that dry-run current_snapshot_checksum.",
1166
+ }
1167
+ ));
1168
+ } else if (expectedCurrentSnapshotChecksum !== current.checksum) {
1169
+ applyDiagnostics.push(createDiagnostic(
1170
+ "project_restore_current_snapshot_changed",
1171
+ "Current SQLite canonical state changed after the reviewed dry-run plan.",
1172
+ {
1173
+ project_id: projectId,
1174
+ expected_current_snapshot_checksum: expectedCurrentSnapshotChecksum,
1175
+ current_snapshot_checksum: current.checksum,
1176
+ },
1177
+ {
1178
+ severity: "error",
1179
+ nextStep: "Run a new dry-run restore, review the updated plan, then retry with the new current_snapshot_checksum.",
1180
+ }
1181
+ ));
1182
+ }
1183
+ }
1184
+ if (dryRun === false && plan.destructive_change_count > 0 && confirmDestructive !== true) {
1185
+ applyDiagnostics.push(createDiagnostic(
1186
+ "project_restore_destructive_confirmation_required",
1187
+ "Project backup restore would delete canonical SQLite records and requires explicit confirmation.",
1188
+ {
1189
+ project_id: projectId,
1190
+ destructive_change_count: plan.destructive_change_count,
1191
+ },
1192
+ {
1193
+ severity: "error",
1194
+ nextStep: "Review the dry-run delete candidates, then retry with confirm_destructive=true if those deletes are intended.",
1195
+ }
1196
+ ));
1197
+ }
1198
+ if (dryRun === false && plan.cross_scope_change_count > 0 && confirmCrossScope !== true) {
1199
+ applyDiagnostics.push(createDiagnostic(
1200
+ "project_restore_cross_scope_confirmation_required",
1201
+ "Project backup restore would create, update, or delete universe-scoped records and requires explicit confirmation.",
1202
+ {
1203
+ project_id: projectId,
1204
+ cross_scope_change_count: plan.cross_scope_change_count,
1205
+ },
1206
+ {
1207
+ severity: "error",
1208
+ nextStep: "Review the dry-run cross_scope changes, then retry with confirm_cross_scope=true if those changes are intended.",
1209
+ }
1210
+ ));
1211
+ }
1212
+
1213
+ if (applyDiagnostics.length) {
1214
+ applyDiagnostics.sort((a, b) => {
1215
+ const typeCompare = a.type.localeCompare(b.type);
1216
+ if (typeCompare) return typeCompare;
1217
+ return a.message.localeCompare(b.message);
1218
+ });
1219
+ return {
1220
+ ok: false,
1221
+ action: "restore_refused",
1222
+ dry_run: Boolean(dryRun),
1223
+ project_id: projectId,
1224
+ backup_dir: resolvedBackupDir,
1225
+ diagnostics: applyDiagnostics,
1226
+ plan,
1227
+ next_step: "Resolve confirmation requirements before applying this trusted backup.",
1228
+ };
1229
+ }
1230
+
1231
+ if (dryRun === false) {
1232
+ try {
1233
+ db.exec("BEGIN");
1234
+ applyProjectRestore(db, {
1235
+ projectId,
1236
+ syncDir,
1237
+ currentSnapshot: current.snapshot,
1238
+ backupSnapshot: snapshot,
1239
+ });
1240
+ db.exec("COMMIT");
1241
+ } catch (error) {
1242
+ try {
1243
+ db.exec("ROLLBACK");
1244
+ } catch (rollbackError) {
1245
+ void rollbackError;
1246
+ }
1247
+ return {
1248
+ ok: false,
1249
+ action: "restore_refused",
1250
+ dry_run: false,
1251
+ project_id: projectId,
1252
+ backup_dir: resolvedBackupDir,
1253
+ diagnostics: [createDiagnostic(
1254
+ "project_restore_write_failed",
1255
+ "Failed to apply project backup restore transaction.",
1256
+ {
1257
+ project_id: projectId,
1258
+ error: error instanceof Error ? error.message : String(error),
1259
+ },
1260
+ { severity: "error", nextStep: "Review the database error and retry after resolving conflicts." }
1261
+ )],
1262
+ plan,
1263
+ next_step: "Resolve restore write diagnostics before retrying.",
1264
+ };
1265
+ }
1266
+ }
1267
+
834
1268
  return {
835
1269
  ok: true,
836
- action: "planned",
1270
+ action: dryRun ? "planned" : "restored",
837
1271
  dry_run: Boolean(dryRun),
838
1272
  project_id: projectId,
839
1273
  backup_dir: resolvedBackupDir,
@@ -846,9 +1280,20 @@ export function restoreProjectFromBackup(db, {
846
1280
  current_snapshot_checksum: current.checksum,
847
1281
  backup_snapshot_checksum: manifest.checksums.canonical_snapshot_sha256,
848
1282
  plan,
1283
+ applied: dryRun ? null : {
1284
+ restored: true,
1285
+ destructive_confirmed: Boolean(confirmDestructive),
1286
+ cross_scope_confirmed: Boolean(confirmCrossScope),
1287
+ derived_indexes: {
1288
+ scenes_fts: "cleared_for_restored_project",
1289
+ reference_docs_fts: "cleared_for_restored_reference_docs",
1290
+ },
1291
+ },
849
1292
  diagnostics: [],
850
- next_step: plan.destructive_change_count > 0
851
- ? "Review destructive delete candidates carefully. Apply support will require explicit confirmation in a later milestone."
852
- : "Review the dry-run plan. Apply support is intentionally not available until the transactional restore milestone.",
1293
+ next_step: dryRun
1294
+ ? (plan.destructive_change_count > 0 || plan.cross_scope_change_count > 0
1295
+ ? "Review destructive delete and cross_scope candidates carefully before applying with explicit confirmation."
1296
+ : "Review the dry-run plan, then rerun with dry_run=false to apply the restore transactionally.")
1297
+ : "Run sync, diagnose_project_backups, and export_project_backup to rebuild derived indexes and refresh generated backup transparency.",
853
1298
  };
854
1299
  }
package/src/sync/sync.js CHANGED
@@ -957,15 +957,24 @@ function canPruneScenes(syncDir) {
957
957
  return hasBroadRootChild;
958
958
  }
959
959
 
960
- function pruneMissingScenes(db, seenSceneKeys, syncDir) {
960
+ function pruneMissingScenes(db, seenSceneKeys, syncDir, { managedProjectIds = new Set() } = {}) {
961
961
  const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
962
962
  const rows = projectScope
963
- ? db.prepare(`SELECT scene_id, project_id FROM scenes WHERE project_id = ?`).all(projectScope)
964
- : db.prepare(`SELECT scene_id, project_id FROM scenes`).all();
963
+ ? db.prepare(`SELECT scene_id, project_id, file_path FROM scenes WHERE project_id = ?`).all(projectScope)
964
+ : db.prepare(`SELECT scene_id, project_id, file_path FROM scenes`).all();
965
965
 
966
+ const result = { deleted: 0, preservedManagedScenes: [] };
966
967
  for (const row of rows) {
967
968
  const key = `${row.scene_id}::${row.project_id}`;
968
969
  if (seenSceneKeys.has(key)) continue;
970
+ if (managedProjectIds.has(row.project_id)) {
971
+ result.preservedManagedScenes.push({
972
+ scene_id: row.scene_id,
973
+ project_id: row.project_id,
974
+ file_path: row.file_path,
975
+ });
976
+ continue;
977
+ }
969
978
 
970
979
  db.prepare(`DELETE FROM scenes_fts WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
971
980
  db.prepare(`
@@ -977,7 +986,9 @@ function pruneMissingScenes(db, seenSceneKeys, syncDir) {
977
986
  db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
978
987
  db.prepare(`DELETE FROM scene_tags WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
979
988
  db.prepare(`DELETE FROM scene_threads WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
989
+ result.deleted++;
980
990
  }
991
+ return result;
981
992
  }
982
993
 
983
994
  function pruneMissingChapters(db, seenChapterKeys, syncDir) {
@@ -1013,19 +1024,99 @@ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
1013
1024
  }
1014
1025
  }
1015
1026
 
1027
+ function formatSyncRelativePath(syncDir, filePath) {
1028
+ if (!filePath) return null;
1029
+ if (!path.isAbsolute(filePath)) return filePath.split(/[\\/]+/).join("/");
1030
+ const resolvedSyncDir = path.resolve(syncDir);
1031
+ const resolvedFilePath = path.resolve(filePath);
1032
+ const relativePath = path.relative(resolvedSyncDir, resolvedFilePath);
1033
+ if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
1034
+ return relativePath.split(path.sep).join("/");
1035
+ }
1036
+ return resolvedFilePath;
1037
+ }
1038
+
1039
+ function storedSyncPathCandidates(syncDir, filePath) {
1040
+ if (!filePath) return [];
1041
+ if (path.isAbsolute(filePath)) return [path.resolve(filePath)];
1042
+
1043
+ const candidates = new Set([path.resolve(syncDir, filePath)]);
1044
+ const normalizedParts = filePath.split(/[\\/]+/).filter(Boolean);
1045
+ const anchor = normalizedParts[0];
1046
+ if (["projects", "universes", "scenes"].includes(anchor)) {
1047
+ const syncParts = path.resolve(syncDir).split(path.sep);
1048
+ for (let index = syncParts.length - 1; index >= 0; index--) {
1049
+ if (syncParts[index] !== anchor) continue;
1050
+ const rootParts = syncParts.slice(0, index);
1051
+ const root = rootParts.length ? rootParts.join(path.sep) : path.sep;
1052
+ candidates.add(path.resolve(root, filePath));
1053
+ }
1054
+ }
1055
+
1056
+ return [...candidates];
1057
+ }
1058
+
1059
+ function collectMissingManagedRepresentationWarnings(db, syncDir, {
1060
+ observedChapterKeys,
1061
+ observedEpigraphKeys,
1062
+ }) {
1063
+ const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
1064
+ const chapterRows = projectScope
1065
+ ? db.prepare(`SELECT chapter_id, project_id, source_path FROM chapters WHERE project_id = ?`).all(projectScope)
1066
+ : db.prepare(`SELECT chapter_id, project_id, source_path FROM chapters`).all();
1067
+ const epigraphRows = projectScope
1068
+ ? db.prepare(`SELECT epigraph_id, project_id, file_path FROM epigraphs WHERE project_id = ?`).all(projectScope)
1069
+ : db.prepare(`SELECT epigraph_id, project_id, file_path FROM epigraphs`).all();
1070
+ const warnings = [];
1071
+
1072
+ for (const row of chapterRows) {
1073
+ const key = `${row.chapter_id}::${row.project_id}`;
1074
+ if (observedChapterKeys.has(key)) continue;
1075
+ const sourcePaths = storedSyncPathCandidates(syncDir, row.source_path);
1076
+ if (!sourcePaths.length || sourcePaths.some(candidate => fs.existsSync(candidate))) continue;
1077
+ warnings.push(`Managed sync preserved canonical chapter missing from filesystem: ${row.project_id}/${row.chapter_id} (${formatSyncRelativePath(syncDir, row.source_path)})`);
1078
+ }
1079
+
1080
+ for (const row of epigraphRows) {
1081
+ const key = `${row.epigraph_id}::${row.project_id}`;
1082
+ if (observedEpigraphKeys.has(key)) continue;
1083
+ const filePaths = storedSyncPathCandidates(syncDir, row.file_path);
1084
+ if (!filePaths.length || filePaths.some(candidate => fs.existsSync(candidate))) continue;
1085
+ warnings.push(`Managed sync preserved canonical epigraph missing from filesystem: ${row.project_id}/${row.epigraph_id} (${formatSyncRelativePath(syncDir, row.file_path)})`);
1086
+ }
1087
+
1088
+ return warnings;
1089
+ }
1090
+
1091
+ function formatStoredPathSuffix(syncDir, filePath) {
1092
+ const displayPath = formatSyncRelativePath(syncDir, filePath);
1093
+ return displayPath ? ` (${displayPath})` : "";
1094
+ }
1095
+
1096
+ function formatPreservedManagedSceneWarning(syncDir, scene) {
1097
+ const pathSuffix = formatStoredPathSuffix(syncDir, scene.file_path);
1098
+ const sceneLabel = `${scene.project_id}/${scene.scene_id}`;
1099
+ const filePaths = storedSyncPathCandidates(syncDir, scene.file_path);
1100
+ if (filePaths.some(candidate => fs.existsSync(candidate))) {
1101
+ return `Managed sync preserved canonical scene not observed during sync scan: ${sceneLabel}${pathSuffix}`;
1102
+ }
1103
+ return `Managed sync preserved canonical scene missing from filesystem: ${sceneLabel}${pathSuffix}`;
1104
+ }
1105
+
1016
1106
  export function pruneSyncDerivedIndexes(db, syncDir, {
1017
1107
  seenSceneKeys,
1018
1108
  seenEpigraphKeys,
1019
1109
  seenChapterKeys,
1020
1110
  sceneIndexFailures,
1111
+ managedProjectIds = new Set(),
1021
1112
  }) {
1022
1113
  if (!canPruneScenes(syncDir)) return { pruned: false, reason: "scope_not_prunable" };
1023
1114
  if (sceneIndexFailures !== 0) return { pruned: false, reason: "scene_index_failures" };
1024
1115
 
1025
- pruneMissingScenes(db, seenSceneKeys, syncDir);
1116
+ const scenePrune = pruneMissingScenes(db, seenSceneKeys, syncDir, { managedProjectIds });
1026
1117
  pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
1027
1118
  pruneMissingChapters(db, seenChapterKeys, syncDir);
1028
- return { pruned: true, reason: null };
1119
+ return { pruned: true, reason: null, scenePrune };
1029
1120
  }
1030
1121
 
1031
1122
  export function regenerateReferenceAndWorldIndexes(db, syncDir, files, {
@@ -1449,6 +1540,10 @@ const WARNING_TYPE_LABELS = {
1449
1540
  chapter_structure: "Chapter structure",
1450
1541
  orphaned_sidecar: "Orphaned sidecar",
1451
1542
  moved_scene: "Moved scene",
1543
+ missing_canonical_scene: "Missing canonical scene",
1544
+ unobserved_canonical_scene: "Unobserved canonical scene",
1545
+ missing_canonical_chapter: "Missing canonical chapter",
1546
+ missing_canonical_epigraph: "Missing canonical epigraph",
1452
1547
  nested_mirror: "Ignored nested mirror path",
1453
1548
  };
1454
1549
 
@@ -1459,6 +1554,10 @@ const WARNING_PATTERNS = [
1459
1554
  { type: "chapter_structure", re: /^(Chapter structure warning|Epigraph requires explicit chapter linkage|Epigraph references unknown chapter_id|Scene references unknown chapter_id|Ambiguous chapter linkage|Epigraph identity conflict|Managed structure sync ignored|Managed structure sync preserved canonical epigraph)/ },
1460
1555
  { type: "moved_scene", re: /^Moved scene detected:/ },
1461
1556
  { type: "orphaned_sidecar", re: /^Orphaned sidecar/ },
1557
+ { type: "missing_canonical_scene", re: /^Managed sync preserved canonical scene missing from filesystem:/ },
1558
+ { type: "unobserved_canonical_scene", re: /^Managed sync preserved canonical scene not observed during sync scan:/ },
1559
+ { type: "missing_canonical_chapter", re: /^Managed sync preserved canonical chapter missing from filesystem:/ },
1560
+ { type: "missing_canonical_epigraph", re: /^Managed sync preserved canonical epigraph missing from filesystem:/ },
1462
1561
  { type: "nested_mirror", re: /^Ignored nested mirror path:/ },
1463
1562
  ];
1464
1563
 
@@ -1511,6 +1610,8 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1511
1610
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
1512
1611
  const seenChapterKeys = new Set(managedChapterKeys);
1513
1612
  const seenEpigraphKeys = new Set(managedEpigraphKeys);
1613
+ const observedChapterKeys = new Set();
1614
+ const observedEpigraphKeys = new Set();
1514
1615
  let sceneIndexFailures = 0;
1515
1616
  const warnings = [];
1516
1617
  const chapterFoldersByProject = new Map();
@@ -1588,7 +1689,9 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1588
1689
  warnings.push(result.warning);
1589
1690
  }
1590
1691
  if (result.chapterId) {
1591
- seenChapterKeys.add(`${result.chapterId}::${project_id}`);
1692
+ const chapterKey = `${result.chapterId}::${project_id}`;
1693
+ seenChapterKeys.add(chapterKey);
1694
+ observedChapterKeys.add(chapterKey);
1592
1695
  }
1593
1696
  if (chapterStructure.chapter && result.chapterId) {
1594
1697
  const chapterMapKey = `${project_id}::${chapterStructure.chapter.sort_index}`;
@@ -1607,7 +1710,9 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1607
1710
  if (result.skippedAsEpigraph) {
1608
1711
  if (result.epigraphIndexed && result.epigraphId) {
1609
1712
  const epigraphId = result.epigraphId;
1610
- seenEpigraphKeys.add(`${epigraphId}::${project_id}`);
1713
+ const epigraphKey = `${epigraphId}::${project_id}`;
1714
+ seenEpigraphKeys.add(epigraphKey);
1715
+ observedEpigraphKeys.add(epigraphKey);
1611
1716
  }
1612
1717
  if (result.epigraphIndexed) {
1613
1718
  epigraphsIndexed++;
@@ -1626,12 +1731,22 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
1626
1731
  }
1627
1732
  }
1628
1733
 
1629
- pruneSyncDerivedIndexes(db, syncDir, {
1734
+ const pruneResult = pruneSyncDerivedIndexes(db, syncDir, {
1630
1735
  seenSceneKeys,
1631
1736
  seenEpigraphKeys,
1632
1737
  seenChapterKeys,
1633
1738
  sceneIndexFailures,
1739
+ managedProjectIds,
1634
1740
  });
1741
+ for (const scene of pruneResult.scenePrune?.preservedManagedScenes ?? []) {
1742
+ warnings.push(formatPreservedManagedSceneWarning(syncDir, scene));
1743
+ }
1744
+ for (const warning of collectMissingManagedRepresentationWarnings(db, syncDir, {
1745
+ observedChapterKeys,
1746
+ observedEpigraphKeys,
1747
+ })) {
1748
+ warnings.push(warning);
1749
+ }
1635
1750
 
1636
1751
  // --- Orphaned sidecar detection ---
1637
1752
  for (const diagnostic of observeOrphanedSidecars(syncDir, { indexedSceneIds })) {
package/src/tools/sync.js CHANGED
@@ -338,13 +338,16 @@ export function registerSyncTools(s, {
338
338
 
339
339
  s.tool(
340
340
  "restore_project_from_backup",
341
- "Dry-run an explicit project restore from a trusted generated project backup bundle. Validates manifest, schema, checksums, project identity, and file references, then returns a deterministic create/update/delete/unchanged plan without mutating SQLite or generated files.",
341
+ "Explicitly restore a project from a trusted generated project backup bundle. Defaults to dry-run planning; dry_run=false applies canonical SQLite changes transactionally after the reviewed current snapshot checksum and required destructive or cross-scope confirmations are provided.",
342
342
  {
343
343
  project_id: z.string().describe("Project ID to restore (e.g. 'test-novel' or 'universe-1/book-1-the-lamb')."),
344
344
  backup_path: z.string().optional().describe("Path under WRITING_SYNC_DIR to a project backup directory, manifest.json, or canonical.snapshot.json. Defaults to project-backups/<project_id>."),
345
- dry_run: z.boolean().optional().describe("If true (default), validate and summarize the restore plan without writing SQLite state. dry_run=false is reserved for a later transactional restore milestone."),
345
+ dry_run: z.boolean().optional().describe("If true (default), validate and summarize the restore plan without writing SQLite state."),
346
+ confirm_destructive: z.boolean().optional().describe("Required with dry_run=false when the restore plan includes delete candidates."),
347
+ confirm_cross_scope: z.boolean().optional().describe("Required with dry_run=false when the restore plan changes universe-scoped records."),
348
+ expected_current_snapshot_checksum: z.string().optional().describe("Required with dry_run=false; pass the current_snapshot_checksum returned by the reviewed dry-run plan to guard against state changes before apply."),
346
349
  },
347
- async ({ project_id, backup_path, dry_run = true } = {}) => {
350
+ async ({ project_id, backup_path, dry_run = true, confirm_destructive = false, confirm_cross_scope = false, expected_current_snapshot_checksum = null } = {}) => {
348
351
  if (!SYNC_DIR_WRITABLE && dry_run === false) {
349
352
  return errorResponse("READ_ONLY", "Cannot restore project from backup: server is in read-only mode for canonical structure mutations.");
350
353
  }
@@ -368,6 +371,9 @@ export function registerSyncTools(s, {
368
371
  projectId: project_id,
369
372
  backupPath: requestedBackupPath,
370
373
  dryRun: dry_run,
374
+ confirmDestructive: confirm_destructive,
375
+ confirmCrossScope: confirm_cross_scope,
376
+ expectedCurrentSnapshotChecksum: expected_current_snapshot_checksum,
371
377
  applicationVersion: MCP_SERVER_VERSION,
372
378
  }));
373
379
  } catch (error) {
@@ -95,6 +95,19 @@ export const WORKFLOW_CATALOGUE = [
95
95
  { tool: "export_project_backup", note: "Generate or repair the broader deterministic project backup bundle for Git review and future recovery input; editing it does not mutate canonical state." },
96
96
  ],
97
97
  },
98
+ {
99
+ id: "backup_recovery",
100
+ label: "Verify or restore project backups",
101
+ use_when: "Use when the user asks about backup freshness, generated project-backups files, database loss, recovery readiness, or restoring SQLite-canonical project state.",
102
+ steps: [
103
+ { tool: "diagnose_project_backups", note: "Start here to verify whether the bundle is missing, stale, tampered, incompatible, partial, or current. This never mutates SQLite or generated files." },
104
+ { tool: "export_project_backup", note: "Generate or refresh the deterministic project backup bundle when diagnostics report missing or stale backup artifacts." },
105
+ { tool: "restore_project_from_backup", note: "Use dry_run=true first to validate the trusted bundle and inspect create/update/delete/unchanged and cross_scope changes before applying anything." },
106
+ { tool: "restore_project_from_backup", note: "Apply only after the dry-run plan is reviewed. Pass dry_run=false with expected_current_snapshot_checksum from that dry run, confirm_destructive=true for deletes, and confirm_cross_scope=true for universe-scoped changes." },
107
+ { tool: "sync", note: "Run after a successful restore to regenerate derived indexes and confirm ordinary read workflows see the restored state." },
108
+ { tool: "diagnose_project_backups", note: "Re-check backup health after restore, then run export_project_backup if generated transparency needs refreshing." },
109
+ ],
110
+ },
98
111
  {
99
112
  id: "review_preparation",
100
113
  label: "Prepare material for human review",