diff --git a/README.md b/README.md
index c55242b9b..382501667 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7516+ tests / 602 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7538+ tests / 602 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7516+ tests, 602 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7538+ tests, 602 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -713,8 +713,8 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**.
-**Current test metrics (2026-09-06, source-synchronized; CI remains authoritative for pass/fail):**
-- **7516+ unit tests** across **602 test files** — CI is authoritative for pass/fail
+**Current test metrics (2026-09-07, source-synchronized; CI remains authoritative for pass/fail):**
+- **7538+ unit tests** across **602 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/components/StorageErrorScreen.tsx b/components/StorageErrorScreen.tsx
index 182ef787c..95103bab5 100644
--- a/components/StorageErrorScreen.tsx
+++ b/components/StorageErrorScreen.tsx
@@ -9,6 +9,8 @@ export interface StorageErrorCopy {
storageUnavailable: string;
projectUnavailable: string;
projectIoUnavailable: string;
+ projectUnsupported: string;
+ projectMigrationGap: string;
reload: string;
retry: string;
recover: string;
@@ -27,6 +29,10 @@ export const STARTUP_COPY_FALLBACKS: StorageErrorCopy = {
projectUnavailable: 'A local project could not be opened. Reload and try again.',
projectIoUnavailable:
'The project could not currently be read because of a storage or file-access problem. It has not been classified as corrupt or changed. Check the storage device and file permissions, then retry.',
+ projectUnsupported:
+ 'This project uses a schema version this build cannot edit. The original file has not been changed. Upgrade WorldScript Studio or open it with a compatible build.',
+ projectMigrationGap:
+ 'This project uses an older schema version that this build cannot migrate. The original file has not been changed. Open it with a compatible build that supports its migration path.',
reload: 'Reload',
retry: 'Retry',
recover: 'Quarantine project and reload',
@@ -54,6 +60,8 @@ export async function loadStorageErrorCopy(): Promise {
storageUnavailable,
projectUnavailable,
projectIoUnavailable,
+ projectUnsupported,
+ projectMigrationGap,
reload,
retry,
recover,
@@ -78,6 +86,14 @@ export async function loadStorageErrorCopy(): Promise {
'error.startup.projectIoUnavailable',
STARTUP_COPY_FALLBACKS.projectIoUnavailable,
),
+ startupTranslation(
+ 'error.startup.projectUnsupported',
+ STARTUP_COPY_FALLBACKS.projectUnsupported,
+ ),
+ startupTranslation(
+ 'error.startup.projectMigrationGap',
+ STARTUP_COPY_FALLBACKS.projectMigrationGap,
+ ),
startupTranslation('error.startup.reload', STARTUP_COPY_FALLBACKS.reload),
startupTranslation('error.startup.retry', STARTUP_COPY_FALLBACKS.retry),
startupTranslation('error.startup.recover', STARTUP_COPY_FALLBACKS.recover),
@@ -97,6 +113,8 @@ export async function loadStorageErrorCopy(): Promise {
storageUnavailable,
projectUnavailable,
projectIoUnavailable,
+ projectUnsupported,
+ projectMigrationGap,
reload,
retry,
recover,
@@ -117,6 +135,8 @@ function getFailureMessage(
const messages: Record = {
'project-corrupt': copy.projectUnavailable,
'project-io': copy.projectIoUnavailable,
+ 'project-unsupported': copy.projectUnsupported,
+ 'project-migration-gap': copy.projectMigrationGap,
storage: copy.storageUnavailable,
};
return messages[failureKind];
@@ -366,7 +386,15 @@ export function StorageErrorScreen({
{getFailureMessage(failureKind, copy)}
-
+
{
@@ -129,8 +129,12 @@ export class FsAssetStore extends FsSnapshotStore {
async deleteBinderAsset(projectId: string, assetId: string): Promise {
try {
- await this.withLegacyRoutingOperation(() => this.deleteBinderAssetStrict(projectId, assetId));
+ await this.withLegacyRoutingOperation(
+ () => this.deleteBinderAssetStrict(projectId, assetId),
+ projectId,
+ );
} catch (error) {
+ if (this.isProjectWriteAuthorityError(error)) throw error;
logger.warn('deleteBinderAsset failed:', error);
}
}
@@ -208,6 +212,6 @@ export class FsAssetStore extends FsSnapshotStore {
}
}),
);
- });
+ }, projectId);
}
}
diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts
index 9190c6b20..e03db8b85 100644
--- a/services/fs/codexFsStore.ts
+++ b/services/fs/codexFsStore.ts
@@ -30,7 +30,7 @@ export class FsCodexStore extends FsSettingsStore {
if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
const codexFile = await apis.join(codexDir, 'codex.snap');
await writeTextFileAtomic(apis, codexFile, compressData(codex));
- });
+ }, codex.projectId);
}
async getStoryCodex(projectId: string): Promise {
@@ -55,8 +55,12 @@ export class FsCodexStore extends FsSettingsStore {
async deleteStoryCodex(projectId: string): Promise {
try {
- await this.withLegacyRoutingOperation(() => this.deleteStoryCodexStrict(projectId));
+ await this.withLegacyRoutingOperation(
+ () => this.deleteStoryCodexStrict(projectId),
+ projectId,
+ );
} catch (error) {
+ if (this.isProjectWriteAuthorityError(error)) throw error;
logger.error('Failed to delete story codex:', error);
}
}
@@ -84,7 +88,7 @@ export class FsCodexStore extends FsSettingsStore {
if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
const vectorsFile = await apis.join(codexDir, 'vectors.snap');
await writeTextFileAtomic(apis, vectorsFile, compressData(vectors));
- });
+ }, projectId);
}
async getRagVectors(projectId: string): Promise {
@@ -124,8 +128,9 @@ export class FsCodexStore extends FsSettingsStore {
'vectors.snap',
);
if (await apis.exists(vectorsFile)) await retryFs(() => apis.remove(vectorsFile));
- });
+ }, projectId);
} catch (error) {
+ if (this.isProjectWriteAuthorityError(error)) throw error;
logger.error('Failed to delete RAG vectors:', error);
}
}
diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts
index 7186cf86d..3eecf0e18 100644
--- a/services/fs/fsCore.ts
+++ b/services/fs/fsCore.ts
@@ -154,25 +154,32 @@ export class DecompressionError extends Error {
}
}
-// QNBS-v3 (Amazon Q): JSON.parse also wrapped — a bare SyntaxError would break the DecompressionError-only contract callers rely on.
-export function decompressData(raw: string): T {
+/**
+ * Decodes a stored payload while retaining the decompressed JSON text as the lossless admission
+ * input. The typed helper below remains the compatibility path for non-project stores.
+ */
+export function decompressJsonText(raw: string): string {
+ let json = raw;
if (raw.startsWith(LZ_PREFIX)) {
const decompressed = LZString.decompressFromUTF16(raw.slice(LZ_PREFIX.length));
if (decompressed === null) {
throw new DecompressionError();
}
- try {
- return JSON.parse(decompressed) as T;
- } catch {
- throw new DecompressionError(
- 'Failed to parse decompressed data as JSON — the payload is corrupt.',
- );
- }
+ json = decompressed;
}
+ return json;
+}
+
+// QNBS-v3: JSON.parse also wrapped — a bare SyntaxError would break the DecompressionError-only contract callers rely on.
+export function decompressData(raw: string): T {
try {
- return JSON.parse(raw) as T;
+ return JSON.parse(decompressJsonText(raw)) as T;
} catch {
- throw new DecompressionError('Failed to parse stored data as JSON — the payload is corrupt.');
+ throw new DecompressionError(
+ raw.startsWith(LZ_PREFIX)
+ ? 'Failed to parse decompressed data as JSON — the payload is corrupt.'
+ : 'Failed to parse stored data as JSON — the payload is corrupt.',
+ );
}
}
@@ -336,8 +343,18 @@ export class FsCore {
return loadTauriApis();
}
+ // QNBS-v3: subclasses re-evaluate project authority only after the serialized operation begins.
+ protected async assertProjectWriteAuthority(_projectId: string): Promise {}
+
+ protected isProjectWriteAuthorityError(_error: unknown): boolean {
+ return false;
+ }
+
// QNBS-v3: serialize complete filesystem operations so legacy route ownership cannot change between awaited mutations.
- protected async withLegacyRoutingOperation(operation: () => Promise): Promise {
+ protected async withLegacyRoutingOperation(
+ operation: () => Promise,
+ projectId?: string,
+ ): Promise {
let release!: () => void;
const gate = new Promise((resolve) => {
release = resolve;
@@ -347,6 +364,9 @@ export class FsCore {
this.legacyRoutingOperationTail = current;
await previous;
try {
+ if (projectId !== undefined) {
+ await this.assertProjectWriteAuthority(projectId);
+ }
return await operation();
} finally {
if (this.legacyRoutingOperationTail === current) {
diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts
index 27264af05..3e10a751e 100644
--- a/services/fs/projectFsStore.ts
+++ b/services/fs/projectFsStore.ts
@@ -6,10 +6,18 @@
import type { EntityState } from '@reduxjs/toolkit';
import { scheduleCoreProjectValidation } from '../../features/project/coreValidationShadow';
+import {
+ CURRENT_PROJECT_SCHEMA_VERSION,
+ type ProjectVersionClassification,
+} from '../../features/project/projectSchemaVersion';
import type { Character, StoryProject, World } from '../../types';
import { getStaticTranslation } from '../i18n/staticTranslate';
import { logger } from '../logger';
-import { parseImportedProjectJson } from '../projectImportSchema';
+import {
+ admitCanonicalProjectDocument,
+ type CanonicalProjectSchemaResult,
+} from '../projectDocument';
+import { importedProjectJsonSchema, parseImportedProjectJson } from '../projectImportSchema';
import {
normalizeSaveProjectInputToStoryProject,
type ProjectQuarantineResult,
@@ -20,6 +28,7 @@ import { FsAssetStore } from './assetFsStore';
import {
compressData,
decompressData,
+ decompressJsonText,
retryFs,
sanitizePathSegment,
type TauriApis,
@@ -27,7 +36,6 @@ import {
} from './fsCore';
import {
evidenceFromPersistedMetadata,
- hasLegacyMissingProjectId,
isLegacyInvalidProjectId,
LEGACY_AUXILIARY_METADATA_KEY,
LEGACY_PROJECT_DIRECTORY_METADATA_KEY,
@@ -49,15 +57,26 @@ import {
// QNBS-v3 (DA-01): distinguishes corrupt/unreadable saved data from genuine absence — callers must never treat this the same as "no project exists yet".
export class ProjectLoadError extends Error {
constructor(
- public readonly reason: 'corrupt' | 'io-error',
+ public readonly reason: 'corrupt' | 'io-error' | 'unsupported-version',
message: string,
public readonly projectId: string,
+ public readonly classification?: ProjectVersionClassification,
) {
super(message);
this.name = 'ProjectLoadError';
}
}
+// QNBS-v3: legacy admissions stay readable but cannot enter the ordinary writer until migration fencing exists.
+export class ProjectWritebackError extends Error {
+ constructor(public readonly projectId: string) {
+ super(
+ `Project "${projectId}" was loaded from an unversioned legacy source and cannot be saved until durable migration fencing is available.`,
+ );
+ this.name = 'ProjectWritebackError';
+ }
+}
+
// QNBS-v3: a stable deletion outcome keeps incomplete legacy cleanup retryable without exposing filesystem details.
export class ProjectDeleteError extends Error {
constructor(
@@ -143,9 +162,200 @@ function looksLikeStoryProject(value: unknown): value is StoryProject {
);
}
-// QNBS-v3: one sanitizer and empty-ID policy keeps every filesystem project operation on the same path identity.
+// QNBS-v3: reuse nested import validators while returning the original object so opaque fields remain present until raw-carrier writeback.
+const storedProjectSchema = {
+ safeParse(value: unknown): CanonicalProjectSchemaResult {
+ const result = importedProjectJsonSchema.safeParse(value);
+ if (!result.success) {
+ return {
+ success: false,
+ error: {
+ issues: result.error.issues.map((issue) => ({
+ path: issue.path,
+ message: issue.message,
+ })),
+ },
+ };
+ }
+ if (looksLikeStoryProject(value)) {
+ return { success: true, data: value };
+ }
+ return {
+ success: false,
+ error: {
+ issues: [
+ {
+ path: [],
+ message: 'Stored project is missing the required project-owned fields.',
+ },
+ ],
+ },
+ };
+ },
+};
+
+// QNBS-v3: keep the synthetic legacy-to-V1 marker out of editable state until fenced durable migration exists.
+function withoutSyntheticLegacySchemaVersion(project: StoryProject): StoryProject {
+ const projection = { ...(project as unknown as Record) };
+ delete projection['schemaVersion'];
+ return projection as unknown as StoryProject;
+}
+
+// QNBS-v3: fresh writer output is explicitly CURRENT while legacy admissions remain fenced before this boundary.
+function withCurrentSchemaVersion(project: StoryProject): StoryProject {
+ const projection = { ...(project as unknown as Record) };
+ if (!Object.hasOwn(projection, 'schemaVersion')) {
+ projection['schemaVersion'] = CURRENT_PROJECT_SCHEMA_VERSION;
+ }
+ return projection as unknown as StoryProject;
+}
+
+type LegacyAdmissionRecord = {
+ sourceDirectoryId: string;
+ persistedProjectId: string | null;
+ sourceOwnedWriterIdentities: ReadonlySet;
+ sharedFallbackWriterIdentities: ReadonlySet;
+ writerIdentities: ReadonlySet;
+ editable: boolean;
+};
+
+const LEGACY_FALLBACK_WRITER_IDENTITIES = ['browser-project', 'default', 'project'] as const;
+
+// QNBS-v3: one source-owned record keeps the canonical directory, embedded identity, aliases, and write verdict together.
export class FsProjectStore extends FsAssetStore {
private readonly verifiedLegacyProjectDirectories = new Set();
+ private readonly legacyAdmissionRecords = new Map();
+
+ private writerIdentityAliases(projectId: string): Set {
+ const identities = new Set([projectId]);
+ const safeProjectId = projectPathSegment(projectId);
+ if (safeProjectId) identities.add(safeProjectId);
+ return identities;
+ }
+
+ private registerLegacyAdmission(sourceDirectoryId: string, project: StoryProject): void {
+ const persistedId = persistedProjectId(project);
+ const sourceOwnedWriterIdentities = new Set([sourceDirectoryId]);
+ const sharedFallbackWriterIdentities = new Set();
+ if (typeof persistedId === 'string') {
+ sourceOwnedWriterIdentities.add(persistedId);
+ const safePersistedId = projectPathSegment(persistedId);
+ if (safePersistedId) sourceOwnedWriterIdentities.add(safePersistedId);
+ if (!safePersistedId) {
+ for (const fallback of LEGACY_FALLBACK_WRITER_IDENTITIES) {
+ sourceOwnedWriterIdentities.add(fallback);
+ }
+ }
+ } else {
+ // QNBS-v3: an ID-less legacy inspection is read-only; shared fallback names must not become global write claims that can block an unrelated CURRENT source.
+ }
+ const writerIdentities = new Set([
+ ...sourceOwnedWriterIdentities,
+ ...sharedFallbackWriterIdentities,
+ ]);
+ this.legacyAdmissionRecords.set(sourceDirectoryId, {
+ sourceDirectoryId,
+ persistedProjectId: typeof persistedId === 'string' ? persistedId : null,
+ sourceOwnedWriterIdentities,
+ sharedFallbackWriterIdentities,
+ writerIdentities,
+ editable: false,
+ });
+ }
+
+ private clearLegacyAdmissionForSource(sourceDirectoryId: string): void {
+ this.legacyAdmissionRecords.delete(sourceDirectoryId);
+ for (const [recordSource, record] of this.legacyAdmissionRecords) {
+ const sourceOwnedWriterIdentities = new Set(
+ [...record.sourceOwnedWriterIdentities].filter(
+ (identity) => projectPathSegment(identity) !== sourceDirectoryId,
+ ),
+ );
+ if (sourceOwnedWriterIdentities.size === record.sourceOwnedWriterIdentities.size) {
+ continue;
+ }
+ const writerIdentities = new Set([
+ ...sourceOwnedWriterIdentities,
+ ...record.sharedFallbackWriterIdentities,
+ ]);
+ if (writerIdentities.size === 0) {
+ this.legacyAdmissionRecords.delete(recordSource);
+ } else {
+ this.legacyAdmissionRecords.set(recordSource, {
+ ...record,
+ sourceOwnedWriterIdentities,
+ writerIdentities,
+ });
+ }
+ }
+ }
+
+ private legacyAdmissionsForWriter(projectId: string): LegacyAdmissionRecord[] {
+ const aliases = this.writerIdentityAliases(projectId);
+ return [...this.legacyAdmissionRecords.values()].filter((record) =>
+ [...aliases].some((identity) => record.writerIdentities.has(identity)),
+ );
+ }
+
+ private async currentProjectSourceIsAdmitted(sourceDirectoryId: string): Promise {
+ const projectFileId = projectPathSegment(sourceDirectoryId);
+ if (!projectFileId) return false;
+ try {
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ const projectFile = await apis.join(appDataPath, 'projects', projectFileId, 'project.json');
+ if (!(await apis.exists(projectFile))) return false;
+ const admission = admitCanonicalProjectDocument(
+ decompressJsonText(await retryFs(() => apis.readTextFile(projectFile))),
+ storedProjectSchema,
+ );
+ return admission.status === 'CURRENT' && admission.canonical?.projection !== null;
+ } catch {
+ return false;
+ }
+ }
+
+ // QNBS-v3: current-source validation may release only aliases proven to belong to that current source.
+ protected override async assertProjectWriteAuthority(projectId: string): Promise {
+ const admissions = this.legacyAdmissionsForWriter(projectId);
+ if (admissions.length === 0) return;
+
+ const aliases = this.writerIdentityAliases(projectId);
+ const hasSharedFallbackConflict = admissions.some((record) =>
+ [...aliases].some((identity) => record.sharedFallbackWriterIdentities.has(identity)),
+ );
+ if (hasSharedFallbackConflict) {
+ throw new ProjectWritebackError(projectId);
+ }
+
+ const sourceDirectoryId = projectPathSegment(projectId);
+ if (sourceDirectoryId && (await this.currentProjectSourceIsAdmitted(sourceDirectoryId))) {
+ this.clearLegacyAdmissionForSource(sourceDirectoryId);
+ return;
+ }
+ throw new ProjectWritebackError(projectId);
+ }
+
+ protected override isProjectWriteAuthorityError(error: unknown): boolean {
+ return error instanceof ProjectWritebackError;
+ }
+
+ private canonicalLegacyProjection(
+ project: StoryProject,
+ sourceDirectoryId: string,
+ ): StoryProject {
+ const persistedId = persistedProjectId(project);
+ const safePersistedId =
+ typeof persistedId === 'string' ? projectPathSegment(persistedId) : null;
+ if (safePersistedId && safePersistedId !== sourceDirectoryId) {
+ return {
+ ...project,
+ id: sourceDirectoryId,
+ [LEGACY_PROJECT_DIRECTORY_METADATA_KEY]: sourceDirectoryId,
+ } as StoryProject;
+ }
+ return project;
+ }
private async inspectLegacyAuxiliaryEvidence(
project: StoryProject,
@@ -282,8 +492,8 @@ export class FsProjectStore extends FsAssetStore {
this.verifiedLegacyProjectDirectories.add(safeProjectId);
return project;
}
- if (!hasLegacyMissingProjectId(project, safeProjectId)) return project;
this.verifiedLegacyProjectDirectories.add(safeProjectId);
+ // QNBS-v3: retain the source directory for every missing-ID load so a later save cannot drift to a title-derived path.
return legacyProjectWithDirectory(project, safeProjectId);
}
@@ -396,15 +606,34 @@ export class FsProjectStore extends FsAssetStore {
if (!validatedTarget) {
throw new ProjectSnapshotRestoreError('target-unavailable');
}
+ try {
+ await this.assertProjectWriteAuthority(targetDirectory);
+ } catch {
+ throw new ProjectSnapshotRestoreError('target-unavailable');
+ }
- const snapshot = await super.getSnapshotData(snapshotId);
- if (!looksLikeStoryProject(snapshot)) {
- throw new ProjectSnapshotRestoreError(
- snapshot === null ? 'snapshot-unavailable' : 'snapshot-invalid',
- );
+ let snapshotJson: string | null;
+ try {
+ snapshotJson = await this.getSnapshotJsonText(snapshotId);
+ } catch (error) {
+ logger.error('Failed to read snapshot for restore', {
+ snapshotId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new ProjectSnapshotRestoreError('snapshot-unavailable');
+ }
+ if (snapshotJson === null) {
+ throw new ProjectSnapshotRestoreError('snapshot-unavailable');
+ }
+
+ // QNBS-v3: admit decompressed snapshot text before parsing so duplicate and unsafe version tokens remain visible to the canonical gate.
+ const snapshotAdmission = admitCanonicalProjectDocument(snapshotJson, storedProjectSchema);
+ const admittedSnapshot = snapshotAdmission.canonical?.projection;
+ if (snapshotAdmission.status === 'REFUSED' || !admittedSnapshot) {
+ throw new ProjectSnapshotRestoreError('snapshot-invalid');
}
- const snapshotProjectId = persistedProjectId(snapshot);
+ const snapshotProjectId = persistedProjectId(admittedSnapshot);
if (typeof snapshotProjectId !== 'string') {
throw new ProjectSnapshotRestoreError('snapshot-owner-unverifiable');
}
@@ -416,7 +645,7 @@ export class FsProjectStore extends FsAssetStore {
throw new ProjectSnapshotRestoreError('snapshot-owner-mismatch');
}
- const restored = { ...(snapshot as unknown as Record) };
+ const restored = { ...(admittedSnapshot as unknown as Record) };
delete restored['id'];
delete restored[LEGACY_PROJECT_DIRECTORY_METADATA_KEY];
delete restored[LEGACY_AUXILIARY_METADATA_KEY];
@@ -430,6 +659,8 @@ export class FsProjectStore extends FsAssetStore {
restored['id'] = safeTargetId;
}
+ restored['schemaVersion'] = CURRENT_PROJECT_SCHEMA_VERSION;
+
const validatedTargetDirectory = legacyProjectDirectory(validatedTarget);
if (validatedTargetDirectory) {
restored[LEGACY_PROJECT_DIRECTORY_METADATA_KEY] = validatedTargetDirectory;
@@ -488,7 +719,6 @@ export class FsProjectStore extends FsAssetStore {
}
} else {
projectId = safeProjectId;
- this.verifiedLegacyProjectDirectories.delete(projectId);
}
} else {
const legacyDirectory = legacyProjectDirectory(flat);
@@ -498,6 +728,13 @@ export class FsProjectStore extends FsAssetStore {
: (projectPathSegment(flat.title || '') ?? 'project');
}
+ await this.assertProjectWriteAuthority(projectId);
+ if (suppliedProjectId) {
+ this.verifiedLegacyProjectDirectories.delete(projectId);
+ }
+
+ projectToPersist = withCurrentSchemaVersion(projectToPersist);
+
// Auto-snapshot: fire-and-forget, mirrors dbService behaviour
if (Date.now() - this.lastAutoSnapshotTime > this.AUTO_SNAPSHOT_INTERVAL) {
this.lastAutoSnapshotTime = Date.now();
@@ -569,6 +806,23 @@ export class FsProjectStore extends FsAssetStore {
return this.withLegacyRoutingOperation(() => this.loadProjectUnlocked(projectId));
}
+ // QNBS-v3: desktop bootstrap uses a distinct admission boundary so a readable legacy projection cannot enter the ordinary editable Redux store.
+ async loadProjectForEditing(projectId: string): Promise {
+ return this.withLegacyRoutingOperation(async () => {
+ const project = await this.loadProjectUnlocked(projectId);
+ const safeProjectId = projectPathSegment(projectId);
+ if (project && safeProjectId && this.legacyAdmissionRecords.has(safeProjectId)) {
+ throw new ProjectLoadError(
+ 'unsupported-version',
+ `The legacy project file for "${projectId}" is readable but cannot enter the editable application state. The file has not been changed.`,
+ projectId,
+ 'LEGACY_UNVERSIONED',
+ );
+ }
+ return project;
+ });
+ }
+
private async loadProjectUnlocked(projectId: string): Promise {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
@@ -581,6 +835,7 @@ export class FsProjectStore extends FsAssetStore {
try {
if (!(await apis.exists(projectFile))) {
this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ this.clearLegacyAdmissionForSource(safeProjectId);
return null;
}
content = await retryFs(() => apis.readTextFile(projectFile));
@@ -594,30 +849,59 @@ export class FsProjectStore extends FsAssetStore {
}
let project: StoryProject;
+ let classification: ProjectVersionClassification | undefined;
+ let legacyAdmission = false;
try {
- const parsed = decompressData(content);
- if (!looksLikeStoryProject(parsed)) {
- throw new Error('Parsed content is not project-shaped (missing title/manuscript).');
+ const admission = admitCanonicalProjectDocument(
+ decompressJsonText(content),
+ storedProjectSchema,
+ );
+ legacyAdmission = admission.status === 'LEGACY_TO_V1';
+ classification = admission.source.classification;
+ if (admission.canonical?.projection === null || admission.canonical === null) {
+ throw new Error(
+ admission.source.error ??
+ `Project admission refused for ${admission.source.classification} input.`,
+ );
}
- project = parsed;
+ project =
+ admission.status === 'LEGACY_TO_V1'
+ ? withoutSyntheticLegacySchemaVersion(admission.canonical.projection)
+ : admission.canonical.projection;
} catch (error) {
logger.error('Failed to parse project file (corrupt data):', error);
throw new ProjectLoadError(
- 'corrupt',
- `The saved project file for "${projectId}" appears to be corrupted and could not be read. The file has not been deleted.`,
+ classification && classification !== 'MALFORMED' ? 'unsupported-version' : 'corrupt',
+ classification && classification !== 'MALFORMED'
+ ? 'The saved project file for "' +
+ projectId +
+ '" was refused as ' +
+ classification +
+ '. The file has not been changed.'
+ : 'The saved project file for "' +
+ projectId +
+ '" appears to be corrupted and could not be read. The file has not been deleted.',
projectId,
+ classification,
);
}
- // QNBS-v3: schedule observation after this async load resolves so validation cannot delay or alter the load result.
+ // QNBS-v3: admission remains non-destructive until durable migration and raw-carrier writeback are fenced.
const migratedProject = await this.migrateLegacyProjectIdentity(
project,
safeProjectId,
apis,
appDataPath,
);
+ if (legacyAdmission) {
+ this.registerLegacyAdmission(safeProjectId, project);
+ } else {
+ this.clearLegacyAdmissionForSource(safeProjectId);
+ }
scheduleCoreProjectValidation(migratedProject);
- return migratedProject;
+ return legacyAdmission
+ ? this.canonicalLegacyProjection(migratedProject, safeProjectId)
+ : migratedProject;
}
async listProjects(): Promise {
@@ -740,6 +1024,7 @@ export class FsProjectStore extends FsAssetStore {
}
await retryFs(() => apis.rename(projectPath, preservedPath));
this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ this.clearLegacyAdmissionForSource(safeProjectId);
return { projectId: safeProjectId, path: preservedPath };
} catch (error) {
let sourceExists: boolean;
@@ -756,11 +1041,13 @@ export class FsProjectStore extends FsAssetStore {
}
if (!sourceExists && preservedExists) {
this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ this.clearLegacyAdmissionForSource(safeProjectId);
return { projectId: safeProjectId, path: preservedPath };
}
if (!sourceExists) {
await releaseReservation();
this.clearLegacyAuxiliaryPolicy(safeProjectId);
+ this.clearLegacyAdmissionForSource(safeProjectId);
throw new ProjectQuarantineError('source-missing');
}
await releaseReservation();
@@ -828,6 +1115,7 @@ export class FsProjectStore extends FsAssetStore {
throw new ProjectDeleteError();
}
this.verifiedLegacyProjectDirectories.delete(safeProjectId);
+ this.clearLegacyAdmissionForSource(safeProjectId);
this.clearLegacyAuxiliaryPolicy(safeProjectId);
}
diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts
index c689fc5cb..3f5d6c666 100644
--- a/services/fs/snapshotFsStore.ts
+++ b/services/fs/snapshotFsStore.ts
@@ -9,7 +9,7 @@ import { FsCodexStore } from './codexFsStore';
import {
compressData,
countProjectWords,
- decompressData,
+ decompressJsonText,
retryFs,
writeTextFileAtomic,
} from './fsCore';
@@ -46,24 +46,37 @@ export class FsSnapshotStore extends FsCodexStore {
return id;
}
- async getSnapshotData(snapshotId: number): Promise {
- try {
- const apis = await this.getApis();
- const appDataPath = await this.ensureAppDataPath();
- const snapshotFile = await apis.join(appDataPath, 'snapshots', `${snapshotId}.json`);
+ protected async getSnapshotJsonText(snapshotId: number): Promise {
+ const apis = await this.getApis();
+ const appDataPath = await this.ensureAppDataPath();
+ const snapshotFile = await apis.join(appDataPath, 'snapshots', `${snapshotId}.json`);
- if (!(await apis.exists(snapshotFile))) {
- return null;
- }
+ if (!(await apis.exists(snapshotFile))) {
+ return null;
+ }
- const content = await retryFs(() => apis.readTextFile(snapshotFile));
- const envelope = JSON.parse(content) as SnapshotEnvelope;
- // New format: envelope with compressed data field
- if (envelope && typeof envelope.data === 'string') {
- return decompressData(envelope.data);
- }
- // Legacy format: raw project data stored directly
- return envelope;
+ const content = await retryFs(() => apis.readTextFile(snapshotFile));
+ let envelope: unknown;
+ try {
+ envelope = JSON.parse(content);
+ } catch {
+ // QNBS-v3: preserve malformed raw snapshot text so canonical admission can classify it without a lossy parse/re-serialize step.
+ return content;
+ }
+ if (
+ typeof envelope === 'object' &&
+ envelope !== null &&
+ typeof (envelope as { data?: unknown }).data === 'string'
+ ) {
+ return decompressJsonText((envelope as { data: string }).data);
+ }
+ return content;
+ }
+
+ async getSnapshotData(snapshotId: number): Promise {
+ try {
+ const raw = await this.getSnapshotJsonText(snapshotId);
+ return raw === null ? null : JSON.parse(raw);
} catch (error) {
logger.error('Failed to load snapshot:', error);
return null;
diff --git a/services/libraryBackupService.ts b/services/libraryBackupService.ts
index 52b89b8fc..e12f6bb19 100644
--- a/services/libraryBackupService.ts
+++ b/services/libraryBackupService.ts
@@ -134,6 +134,8 @@ export async function collectLibraryBackupPayload(
} catch (error) {
// QNBS-v3 (codex P1): only the expected corruption/I-O case is swallowed — an unexpected bug must still surface, not be silently absorbed as "skip this project".
if (!(error instanceof ProjectLoadError)) throw error;
+ // QNBS-v3: an unsupported project must make the backup fail visibly instead of producing an incomplete archive.
+ if (error.reason === 'unsupported-version') throw error;
logger.warn('collectLibraryBackupPayload: skipping unreadable project', {
projectId,
reason: error.reason,
diff --git a/services/startupRecovery.tsx b/services/startupRecovery.tsx
index ef6ce9d26..c8a99595d 100644
--- a/services/startupRecovery.tsx
+++ b/services/startupRecovery.tsx
@@ -51,7 +51,11 @@ export async function renderProjectInitializationFailure(
},
}
: {})}
- {...(failureKind === 'project-io' ? { onRetry: () => window.location.reload() } : {})}
+ {...(failureKind === 'project-io' ||
+ failureKind === 'project-unsupported' ||
+ failureKind === 'project-migration-gap'
+ ? { onRetry: () => window.location.reload() }
+ : {})}
{...(canReset
? {
onReset: async () => {
diff --git a/services/startupRecoveryPolicy.ts b/services/startupRecoveryPolicy.ts
index cfb4b013c..11999926a 100644
--- a/services/startupRecoveryPolicy.ts
+++ b/services/startupRecoveryPolicy.ts
@@ -1,7 +1,12 @@
import { ProjectLoadError } from './fs/projectFsStore';
export type StartupStorageBackend = 'indexeddb' | 'filesystem';
-export type StartupRecoveryFailureKind = 'storage' | 'project-corrupt' | 'project-io';
+export type StartupRecoveryFailureKind =
+ | 'storage'
+ | 'project-corrupt'
+ | 'project-io'
+ | 'project-unsupported'
+ | 'project-migration-gap';
export interface StartupRecoveryActions {
failureKind: StartupRecoveryFailureKind;
@@ -16,11 +21,15 @@ export function getStartupRecoveryActions(
): StartupRecoveryActions {
const projectLoadError = error instanceof ProjectLoadError ? error : null;
const failureKind: StartupRecoveryFailureKind =
- projectLoadError?.reason === 'corrupt'
- ? 'project-corrupt'
- : projectLoadError || backend === 'filesystem'
- ? 'project-io'
- : 'storage';
+ projectLoadError?.classification === 'UNSUPPORTED_OLDER'
+ ? 'project-migration-gap'
+ : projectLoadError?.reason === 'unsupported-version'
+ ? 'project-unsupported'
+ : projectLoadError?.reason === 'corrupt'
+ ? 'project-corrupt'
+ : projectLoadError || backend === 'filesystem'
+ ? 'project-io'
+ : 'storage';
return {
failureKind,
canQuarantine: failureKind === 'project-corrupt' && backend === 'filesystem',
diff --git a/services/storageBackend.ts b/services/storageBackend.ts
index fa6754023..264dcbf2a 100644
--- a/services/storageBackend.ts
+++ b/services/storageBackend.ts
@@ -75,6 +75,8 @@ export function normalizeSaveProjectInputToStoryProject(project: SaveProjectInpu
export interface StorageBackend {
saveProject(project: SaveProjectInput): Promise;
loadProject(projectId: string): Promise;
+ /** QNBS-v3: editable desktop admission must reject readable legacy projections before Redux hydration. */
+ loadProjectForEditing?(projectId: string): Promise;
listProjects(): Promise;
deleteProject(projectId: string): Promise;
/** QNBS-v3 (#332): optional — only the multi-project Tauri filesystem backend implements this; IndexedDB's single-project contract has no "which one" ambiguity to resolve. */
diff --git a/services/storageService.ts b/services/storageService.ts
index 37e52eff9..90b730b9e 100644
--- a/services/storageService.ts
+++ b/services/storageService.ts
@@ -84,6 +84,13 @@ class StorageManager {
return backend.loadProject(projectId);
}
+ // QNBS-v3: bootstrap asks for an editable admission explicitly; backends without a distinct boundary retain their existing single-project behavior.
+ async loadProjectForEditing(projectId: string): Promise {
+ const backend = await this.getBackend();
+ if (backend.loadProjectForEditing) return backend.loadProjectForEditing(projectId);
+ return backend.loadProject(projectId);
+ }
+
async listProjects(): Promise {
const backend = await this.getBackend();
return backend.listProjects();
diff --git a/tests/unit/libraryBackupService.test.ts b/tests/unit/libraryBackupService.test.ts
index cdbfb3c88..3a9615b45 100644
--- a/tests/unit/libraryBackupService.test.ts
+++ b/tests/unit/libraryBackupService.test.ts
@@ -115,6 +115,26 @@ describe('libraryBackupService — partial corruption (DA-01)', () => {
expect(corrupt?.project).toBeNull();
});
+ // QNBS-v3: unsupported projects must never be represented as a successful backup with an omitted payload.
+ it('fails visibly when a project uses an unsupported schema version', async () => {
+ const { storageService } = await import('../../services/storageService');
+ const { ProjectLoadError } = await import('../../services/fs/projectFsStore');
+ vi.mocked(storageService.listProjects).mockResolvedValue(['future']);
+ vi.mocked(storageService.loadProject).mockRejectedValue(
+ new ProjectLoadError(
+ 'unsupported-version',
+ 'The saved project uses a schema version this build cannot edit.',
+ 'future',
+ 'FUTURE',
+ ),
+ );
+ const { collectLibraryBackupPayload } = await import('../../services/libraryBackupService');
+ await expect(collectLibraryBackupPayload()).rejects.toMatchObject({
+ reason: 'unsupported-version',
+ projectId: 'future',
+ });
+ });
+
// QNBS-v3 (codex P1): an unexpected (non-ProjectLoadError) failure must still surface, not be silently swallowed as if it were an ordinary corrupt project.
it('rethrows an unexpected (non-ProjectLoadError) failure instead of silently swallowing it', async () => {
const { storageService } = await import('../../services/storageService');
diff --git a/tests/unit/services/appBootstrap.test.ts b/tests/unit/services/appBootstrap.test.ts
index b6bfec500..675460e12 100644
--- a/tests/unit/services/appBootstrap.test.ts
+++ b/tests/unit/services/appBootstrap.test.ts
@@ -37,6 +37,7 @@ const h = vi.hoisted(() => ({
loadSettings: vi.fn(),
listProjects: vi.fn(),
loadProject: vi.fn(),
+ loadProjectForEditing: vi.fn(),
getActiveProjectId: vi.fn(),
}));
@@ -53,6 +54,7 @@ vi.mock('../../../services/storageService', () => ({
loadSettings: h.loadSettings,
listProjects: h.listProjects,
loadProject: h.loadProject,
+ loadProjectForEditing: h.loadProjectForEditing,
getActiveProjectId: h.getActiveProjectId,
},
}));
@@ -72,6 +74,7 @@ describe('loadPersistedRootState', () => {
h.loadSettings.mockResolvedValue(null);
h.listProjects.mockResolvedValue([]);
h.loadProject.mockResolvedValue(null);
+ h.loadProjectForEditing.mockResolvedValue(null);
h.getActiveProjectId.mockResolvedValue(null);
});
@@ -89,14 +92,14 @@ describe('loadPersistedRootState', () => {
h.isTauri.value = true;
h.loadSettings.mockResolvedValue({ theme: 'sepia' });
h.listProjects.mockResolvedValue(['proj-1']);
- h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'My Novel' });
+ h.loadProjectForEditing.mockResolvedValue({ id: 'proj-1', title: 'My Novel' });
const result = await loadPersistedRootState();
expect(h.dbLoadState).not.toHaveBeenCalled();
expect(h.loadSettings).toHaveBeenCalledTimes(1);
expect(h.listProjects).toHaveBeenCalledTimes(1);
- expect(h.loadProject).toHaveBeenCalledWith('proj-1');
+ expect(h.loadProjectForEditing).toHaveBeenCalledWith('proj-1');
expect(result?.settings).toEqual({ theme: 'sepia' });
// Flat shape — index.tsx's existing hydration logic reconstructs the redux-undo envelope.
expect(result?.project).toEqual({ data: { id: 'proj-1', title: 'My Novel' } });
@@ -122,30 +125,30 @@ describe('loadPersistedRootState', () => {
h.isTauri.value = true;
h.listProjects.mockResolvedValue(['proj-1', 'proj-2']);
h.getActiveProjectId.mockResolvedValue(null);
- h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'First' });
+ h.loadProjectForEditing.mockResolvedValue({ id: 'proj-1', title: 'First' });
await loadPersistedRootState();
- expect(h.loadProject).toHaveBeenCalledTimes(1);
- expect(h.loadProject).toHaveBeenCalledWith('proj-1');
+ expect(h.loadProjectForEditing).toHaveBeenCalledTimes(1);
+ expect(h.loadProjectForEditing).toHaveBeenCalledWith('proj-1');
});
it('on desktop, prefers the active-project marker over the first listed project id', async () => {
h.isTauri.value = true;
h.listProjects.mockResolvedValue(['proj-1', 'proj-2']);
h.getActiveProjectId.mockResolvedValue('proj-2');
- h.loadProject.mockResolvedValue({ id: 'proj-2', title: 'Second' });
+ h.loadProjectForEditing.mockResolvedValue({ id: 'proj-2', title: 'Second' });
await loadPersistedRootState();
- expect(h.loadProject).toHaveBeenCalledTimes(1);
- expect(h.loadProject).toHaveBeenCalledWith('proj-2');
+ expect(h.loadProjectForEditing).toHaveBeenCalledTimes(1);
+ expect(h.loadProjectForEditing).toHaveBeenCalledWith('proj-2');
});
it('on desktop, falls back to the first listed project id when the marker points to a deleted project', async () => {
h.isTauri.value = true;
h.listProjects.mockResolvedValue(['proj-1', 'proj-2']);
h.getActiveProjectId.mockResolvedValue('proj-deleted');
- h.loadProject.mockResolvedValue({ id: 'proj-1', title: 'First' });
+ h.loadProjectForEditing.mockResolvedValue({ id: 'proj-1', title: 'First' });
await loadPersistedRootState();
- expect(h.loadProject).toHaveBeenCalledTimes(1);
- expect(h.loadProject).toHaveBeenCalledWith('proj-1');
+ expect(h.loadProjectForEditing).toHaveBeenCalledTimes(1);
+ expect(h.loadProjectForEditing).toHaveBeenCalledWith('proj-1');
});
});
diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts
index 0e8f5a88b..68bd95b32 100644
--- a/tests/unit/services/fs/fsCore.test.ts
+++ b/tests/unit/services/fs/fsCore.test.ts
@@ -10,8 +10,9 @@ import type { TauriApis } from '../../../../services/fs/fsCore';
import {
compressData,
countProjectWords,
- decompressData,
DecompressionError,
+ decompressData,
+ decompressJsonText,
decryptText,
encryptText,
retryFs,
@@ -152,6 +153,14 @@ describe('compressData / decompressData', () => {
it('throws DecompressionError (not a bare SyntaxError) for malformed uncompressed JSON', () => {
expect(() => decompressData('this is not json {{{')).toThrow(DecompressionError);
});
+
+ it('returns decompressed JSON text without normalizing numeric literals', () => {
+ const source = '{"opaque":9007199254740993.0000000000001}';
+ const compressed = `\x00lz1\x00${LZString.compressToUTF16(source)}`;
+
+ expect(decompressJsonText(source)).toBe(source);
+ expect(decompressJsonText(compressed)).toBe(source);
+ });
});
describe('encryptText / decryptText', () => {
diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts
index 431ab524f..d806316e0 100644
--- a/tests/unit/services/fs/fsStores.test.ts
+++ b/tests/unit/services/fs/fsStores.test.ts
@@ -62,6 +62,16 @@ import { compressData, decompressData } from '../../../../services/fs/fsCore';
import { FsProjectStore } from '../../../../services/fs/projectFsStore';
import { logger } from '../../../../services/logger';
+// QNBS-v3: shared Binder fixture keeps schema-complete asset nodes consistent across filesystem cleanup and routing tests.
+const legacyBinderNode = (binderAssetId: string) => ({
+ id: `binder-${binderAssetId}`,
+ parentId: null,
+ type: 'pdf' as const,
+ title: `Legacy ${binderAssetId}`,
+ sortIndex: 0,
+ binderAssetId,
+});
+
interface FakeFs {
apis: TauriApis;
text: Map;
@@ -166,6 +176,7 @@ afterEach(() => {
describe('FsProjectStore — projects', () => {
const project = {
id: 'p1',
+ schemaVersion: 1,
title: 'My Novel',
logline: 'A tale',
manuscript: [{ id: 's1', title: 'Ch1', content: 'hello world foo' }],
@@ -186,6 +197,18 @@ describe('FsProjectStore — projects', () => {
expect(await store.listProjects()).not.toContain('p1');
});
+ // QNBS-v3: fresh filesystem writes carry an explicit current marker so this build does not reclassify its own output as legacy.
+ it('stamps a current schema version on newly created projects', async () => {
+ const { schemaVersion: _schemaVersion, ...unversionedProject } = project;
+
+ await store.saveProject(unversionedProject as never);
+
+ const persisted = decompressData>(
+ fake.text.get('/app/projects/p1/project.json') as string,
+ );
+ expect(persisted['schemaVersion']).toBe(1);
+ });
+
it('returns null for a missing project and [] when no projects dir', async () => {
expect(await store.loadProject('nope')).toBeNull();
expect(await store.listProjects()).toEqual([]);
@@ -322,12 +345,376 @@ describe('FsProjectStore — projects', () => {
expect(await store.loadProject('item')).toEqual(expect.objectContaining({ title: 'My Novel' }));
});
+ // QNBS-v3: conflicting embedded identity is kept read-only under the canonical source directory.
+ it('fences legacy writeback by both directory and embedded project identity', async () => {
+ const legacyProject = {
+ id: 'embedded-id',
+ title: 'Legacy Novel',
+ logline: 'A legacy tale',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ };
+ await fake.apis.mkdir('/app/projects/directory-id', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/directory-id/project.json',
+ compressData(legacyProject),
+ );
+
+ const loaded = await store.loadProject('directory-id');
+
+ expect((loaded as unknown as Record)['id']).toBe('directory-id');
+ await expect(
+ store.saveProject({ ...loaded, title: 'Edited legacy novel' } as never),
+ ).rejects.toMatchObject({
+ name: 'ProjectWritebackError',
+ projectId: 'directory-id',
+ });
+ expect(fake.text.has('/app/projects/embedded-id/project.json')).toBe(false);
+ });
+
+ // QNBS-v3: a valid current source can reclaim its own identity without inheriting a legacy alias fence.
+ it('does not let a conflicting legacy alias fence a valid current project', async () => {
+ await store.saveProject({ ...project, id: 'embedded-id' } as never);
+ await fake.apis.mkdir('/app/projects/directory-id', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/directory-id/project.json',
+ compressData({
+ id: 'embedded-id',
+ title: 'Legacy Novel',
+ logline: 'A legacy tale',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ }),
+ );
+
+ await store.loadProject('directory-id');
+ await expect(
+ store.saveProject({ ...project, id: 'embedded-id', title: 'Current Novel' } as never),
+ ).resolves.toBeUndefined();
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/embedded-id/project.json') as string,
+ )['title'],
+ ).toBe('Current Novel');
+ });
+
+ // QNBS-v3: snapshot restore cannot reintroduce mutable state for a project whose source lacks write authority.
+ it('refuses snapshot restore for a fenced legacy project', async () => {
+ const legacyProject = {
+ title: 'Legacy Novel',
+ logline: 'A legacy tale',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ };
+ await fake.apis.mkdir('/app/projects/legacy-snapshot', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/legacy-snapshot/project.json',
+ compressData(legacyProject),
+ );
+ const loaded = await store.loadProject('legacy-snapshot');
+ const snapshotId = await store.saveSnapshot('legacy', loaded);
+
+ await expect(store.restoreSnapshot(snapshotId, loaded as never)).rejects.toMatchObject({
+ name: 'ProjectSnapshotRestoreError',
+ reason: 'target-unavailable',
+ });
+ });
+
+ // QNBS-v3: absence clears the whole directory-owned fence so a later replacement can be saved safely.
+ it('clears stale legacy writeback fences after the source disappears', async () => {
+ const legacyProject = {
+ id: 'replacement-id',
+ title: 'Legacy Novel',
+ logline: 'A legacy tale',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ };
+ await fake.apis.mkdir('/app/projects/old-directory', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/old-directory/project.json',
+ compressData(legacyProject),
+ );
+ const loaded = await store.loadProject('old-directory');
+ await expect(store.saveProject(loaded as never)).rejects.toMatchObject({
+ name: 'ProjectWritebackError',
+ });
+
+ await fake.apis.remove('/app/projects/old-directory', { recursive: true });
+ await expect(store.loadProject('old-directory')).resolves.toBeNull();
+ await expect(
+ store.saveProject({ ...project, id: 'replacement-id' } as never),
+ ).resolves.toBeUndefined();
+ });
+
+ // QNBS-v3: auxiliary project files cannot mutate while the owning legacy source is fenced.
+ it('fences project-owned auxiliary asset writes and deletes for legacy projects', async () => {
+ const asset = new Uint8Array([1, 2, 3]).buffer;
+ await store.saveBinderAsset('legacy-assets', 'asset-1', asset, {
+ mimeType: 'application/pdf',
+ originalFileName: 'legacy.pdf',
+ byteSize: 3,
+ });
+ await store.saveStoryCodex({
+ projectId: 'legacy-assets',
+ extractedAt: '2026-01-01T00:00:00.000Z',
+ entities: [],
+ summary: 'legacy',
+ });
+ await store.saveRagVectors('legacy-assets', [{ id: 'vector-1' }]);
+ await fake.apis.mkdir('/app/projects/legacy-assets', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/legacy-assets/project.json',
+ compressData({
+ title: 'Legacy Assets',
+ logline: 'L',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ }),
+ );
+ await store.loadProject('legacy-assets');
+
+ await expect(
+ store.saveBinderAsset('legacy-assets', 'asset-2', asset, {
+ mimeType: 'application/pdf',
+ originalFileName: 'new.pdf',
+ byteSize: 3,
+ }),
+ ).rejects.toMatchObject({ name: 'ProjectWritebackError' });
+ await expect(store.deleteBinderAsset('legacy-assets', 'asset-1')).rejects.toMatchObject({
+ name: 'ProjectWritebackError',
+ });
+ await expect(store.deleteStoryCodex('legacy-assets')).rejects.toMatchObject({
+ name: 'ProjectWritebackError',
+ });
+ await expect(store.saveRagVectors('legacy-assets', [{ id: 'vector-2' }])).rejects.toMatchObject(
+ {
+ name: 'ProjectWritebackError',
+ },
+ );
+ await expect(store.deleteRagVectors('legacy-assets')).rejects.toMatchObject({
+ name: 'ProjectWritebackError',
+ });
+ await expect(store.getBinderAsset('legacy-assets', 'asset-1')).resolves.not.toBeNull();
+ await expect(store.getStoryCodex('legacy-assets')).resolves.not.toBeNull();
+ await expect(store.getRagVectors('legacy-assets')).resolves.toEqual([{ id: 'vector-1' }]);
+ });
+
+ // QNBS-v3: ID-less legacy callers can only address the retained source directory, never an ambiguous fallback route.
+ it('fails closed for ID-less legacy Binder, Codex, and RAG writes', async () => {
+ const asset = new Uint8Array([1, 2, 3]).buffer;
+ const legacyProject = {
+ title: 'ID-less Legacy',
+ logline: 'L',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ };
+ await fake.apis.mkdir('/app/projects/idless-legacy', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/idless-legacy/project.json',
+ JSON.stringify(legacyProject),
+ );
+ await fake.apis.mkdir('/app/projects/browser-project/binder', { recursive: true });
+ await fake.apis.writeFile(
+ '/app/projects/browser-project/binder/legacy.bin',
+ new Uint8Array([9]),
+ );
+ await fake.apis.mkdir('/app/projects/default/codex', { recursive: true });
+ const originalCodex = { projectId: 'default', entries: [{ name: 'legacy' }] };
+ await fake.apis.writeTextFile(
+ '/app/projects/default/codex/codex.snap',
+ compressData(originalCodex),
+ );
+ await fake.apis.mkdir('/app/projects/project/codex', { recursive: true });
+ const originalVectors = [{ id: 'legacy-vector' }];
+ await fake.apis.writeTextFile(
+ '/app/projects/project/codex/vectors.snap',
+ compressData(originalVectors),
+ );
+
+ await store.loadProject('idless-legacy');
+ await expect(
+ store.saveBinderAsset('idless-legacy', 'new', asset, {
+ mimeType: 'application/pdf',
+ originalFileName: 'new.pdf',
+ byteSize: 3,
+ }),
+ ).rejects.toMatchObject({ name: 'ProjectWritebackError' });
+ await expect(
+ store.saveStoryCodex({ projectId: 'idless-legacy', entries: [{ name: 'new' }] } as never),
+ ).rejects.toMatchObject({ name: 'ProjectWritebackError' });
+ await expect(
+ store.saveRagVectors('idless-legacy', [{ id: 'new-vector' }]),
+ ).rejects.toMatchObject({ name: 'ProjectWritebackError' });
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/default/codex/codex.snap') as string,
+ ),
+ ).toEqual(originalCodex);
+ expect(
+ decompressData(
+ fake.text.get('/app/projects/project/codex/vectors.snap') as string,
+ ),
+ ).toEqual(originalVectors);
+ expect(fake.bin.has('/app/projects/browser-project/binder/new.bin')).toBe(false);
+ });
+
+ // QNBS-v3: background ID-less inspection cannot install a global fallback fence over a valid CURRENT source.
+ it('keeps CURRENT fallback writes authoritative after ID-less legacy inspection', async () => {
+ await store.saveProject({ ...project, id: 'project' } as never);
+ const legacyProject = {
+ title: 'ID-less Legacy',
+ logline: 'L',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ };
+ await fake.apis.mkdir('/app/projects/idless-legacy', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/idless-legacy/project.json',
+ JSON.stringify(legacyProject),
+ );
+
+ await store.loadProject('idless-legacy');
+ await store.loadProject('project');
+
+ await expect(
+ store.saveBinderAsset('project', 'ambiguous', new Uint8Array([1]).buffer, {
+ mimeType: 'application/octet-stream',
+ originalFileName: 'ambiguous.bin',
+ byteSize: 1,
+ }),
+ ).resolves.toBeUndefined();
+ await expect(
+ store.saveStoryCodex({ projectId: 'project', entries: [{ name: 'ambiguous' }] } as never),
+ ).resolves.toBeUndefined();
+ await expect(store.saveRagVectors('project', [{ id: 'ambiguous' }])).resolves.toBeUndefined();
+ expect(fake.bin.has('/app/projects/project/binder/ambiguous.bin')).toBe(true);
+ expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(true);
+ expect(fake.text.has('/app/projects/project/codex/vectors.snap')).toBe(true);
+ });
+
+ // QNBS-v3: readable legacy bytes remain available to inspection but cannot cross the editable bootstrap boundary.
+ it('refuses ID-less legacy admission for the editable application state without rewriting the source', async () => {
+ const legacyProject = {
+ title: 'ID-less Legacy',
+ logline: 'L',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ };
+ const source = '/app/projects/idless-legacy/project.json';
+ await fake.apis.mkdir('/app/projects/idless-legacy', { recursive: true });
+ await fake.apis.writeTextFile(source, JSON.stringify(legacyProject));
+ const before = fake.text.get(source);
+
+ await expect(store.loadProjectForEditing('idless-legacy')).rejects.toMatchObject({
+ name: 'ProjectLoadError',
+ reason: 'unsupported-version',
+ classification: 'LEGACY_UNVERSIONED',
+ projectId: 'idless-legacy',
+ });
+ expect(fake.text.get(source)).toBe(before);
+ });
+
+ // QNBS-v3: authority is checked after queued work completes so a load cannot race a later mutation.
+ it('rechecks legacy authority after a queued admission change', async () => {
+ const legacyProject = {
+ title: 'Racing Legacy',
+ logline: 'L',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ };
+ await fake.apis.mkdir('/app/projects/racing-legacy', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/racing-legacy/project.json',
+ JSON.stringify(legacyProject),
+ );
+
+ const originalWriteTextFile = fake.apis.writeTextFile;
+ let releaseWrite!: () => void;
+ let writeStarted!: () => void;
+ const writeStartedPromise = new Promise((resolve) => {
+ writeStarted = resolve;
+ });
+ fake.apis.writeTextFile = (path: string, content: string) => {
+ if (path.startsWith('/app/projects/queue-holder/codex/codex.snap.tmp-')) {
+ writeStarted();
+ return new Promise((resolve, reject) => {
+ releaseWrite = () => originalWriteTextFile(path, content).then(resolve, reject);
+ });
+ }
+ return originalWriteTextFile(path, content);
+ };
+
+ const holder = store.saveStoryCodex({ projectId: 'queue-holder', entries: [] } as never);
+ await writeStartedPromise;
+ const load = store.loadProject('racing-legacy');
+ const queuedWrite = store.saveStoryCodex({ projectId: 'racing-legacy', entries: [] } as never);
+
+ releaseWrite();
+ await holder;
+ await load;
+ await expect(queuedWrite).rejects.toMatchObject({ name: 'ProjectWritebackError' });
+ expect(fake.text.has('/app/projects/racing-legacy/codex/codex.snap')).toBe(false);
+ });
+
+ // QNBS-v3: auxiliary fence checks use the writers' sanitized identity and fallback so invalid IDs cannot bypass legacy writeback policy.
+ it('normalizes legacy auxiliary fence identities before mutation', async () => {
+ const asset = new Uint8Array([1, 2, 3]).buffer;
+ await fake.apis.mkdir('/app/projects/foo-bar', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/foo-bar/project.json',
+ compressData({
+ id: 'foo/bar',
+ title: 'Legacy Path ID',
+ logline: 'L',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ }),
+ );
+ await store.loadProject('foo-bar');
+
+ await expect(
+ store.saveBinderAsset('foo/bar', 'asset-1', asset, {
+ mimeType: 'application/pdf',
+ originalFileName: 'new.pdf',
+ byteSize: 3,
+ }),
+ ).rejects.toMatchObject({ name: 'ProjectWritebackError' });
+
+ await fake.apis.mkdir('/app/projects/project', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/project/project.json',
+ compressData({
+ id: '***',
+ title: 'Legacy Fallback ID',
+ logline: 'L',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ }),
+ );
+ await store.loadProject('project');
+
+ await expect(store.saveRagVectors('***', [])).rejects.toMatchObject({
+ name: 'ProjectWritebackError',
+ });
+ });
+
// QNBS-v3: verified Codex and Binder evidence remains addressable while provenance-free vectors stay unassigned.
it('keeps verified legacy Binder and Codex data addressable without assigning ambiguous RAG data', async () => {
const legacyProject = {
...project,
id: '***',
- binderNodes: [{ binderAssetId: 'legacy-asset' }],
+ binderNodes: [legacyBinderNode('legacy-asset')],
};
const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
const legacyVectors = [{ id: 'legacy-vector' }];
@@ -514,7 +901,7 @@ describe('FsProjectStore — projects', () => {
const legacyProject = {
...project,
id: '***',
- binderNodes: [{ binderAssetId: 'legacy-asset' }],
+ binderNodes: [legacyBinderNode('legacy-asset')],
};
await fake.apis.mkdir('/app/projects/item', { recursive: true });
await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
@@ -551,7 +938,7 @@ describe('FsProjectStore — projects', () => {
const legacyProject = {
...project,
id: '***',
- binderNodes: [{ binderAssetId: 'legacy-asset' }],
+ binderNodes: [legacyBinderNode('legacy-asset')],
};
await fake.apis.mkdir('/app/projects/item', { recursive: true });
await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
@@ -596,7 +983,7 @@ describe('FsProjectStore — projects', () => {
const legacyProject = {
...project,
id: '***',
- binderNodes: [{ binderAssetId: 'legacy-asset' }],
+ binderNodes: [legacyBinderNode('legacy-asset')],
};
const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] };
await store.saveStoryCodex(legacyCodex as never);
@@ -764,7 +1151,7 @@ describe('FsProjectStore — projects', () => {
const legacyProject = {
...project,
id: '***',
- binderNodes: [{ binderAssetId: '.' }, { binderAssetId: '..' }],
+ binderNodes: [legacyBinderNode('.'), legacyBinderNode('..')],
};
await fake.apis.mkdir('/app/projects/item', { recursive: true });
await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
@@ -995,6 +1382,79 @@ describe('FsProjectStore — projects', () => {
expect(fake.text.has('/app/projects/p2/project.json')).toBe(false);
});
+ // QNBS-v3: a future snapshot cannot overwrite a current target with an unadmitted schema marker.
+ it('refuses a future snapshot before it can turn a current target into future state', async () => {
+ await store.saveProject(project as never);
+ const current = await store.loadProject('p1');
+ const snapshotId = await store.saveSnapshot('future', {
+ ...project,
+ schemaVersion: 99,
+ title: 'Future snapshot content',
+ });
+
+ await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({
+ name: 'ProjectSnapshotRestoreError',
+ reason: 'snapshot-invalid',
+ });
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/p1/project.json') as string,
+ ),
+ ).toMatchObject({ schemaVersion: 1, title: 'My Novel' });
+ });
+
+ // QNBS-v3: raw snapshot version tokens must be admitted before JSON parsing can normalize them.
+ it.each([
+ [
+ 'duplicate schemaVersion',
+ '{"schemaVersion":1,"schemaVersion":2,"id":"p1","title":"Raw","logline":"L","manuscript":[],"characters":[],"worlds":[]}',
+ ],
+ [
+ 'unsafe numeric schemaVersion',
+ '{"schemaVersion":9007199254740993,"id":"p1","title":"Raw","logline":"L","manuscript":[],"characters":[],"worlds":[]}',
+ ],
+ ])('refuses %s snapshot tokens before normalization', async (_label, rawSnapshot) => {
+ await store.saveProject(project as never);
+ const current = await store.loadProject('p1');
+ const snapshotId = 9101;
+ fake.text.set(
+ `/app/snapshots/${snapshotId}.json`,
+ JSON.stringify({
+ id: snapshotId,
+ name: 'raw',
+ date: new Date().toISOString(),
+ wordCount: 0,
+ data: rawSnapshot,
+ }),
+ );
+
+ await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({
+ name: 'ProjectSnapshotRestoreError',
+ reason: 'snapshot-invalid',
+ });
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/p1/project.json') as string,
+ ),
+ ).toMatchObject({ schemaVersion: 1, title: 'My Novel' });
+ });
+
+ // QNBS-v3: pre-marker snapshots remain recoverable through the non-destructive legacy projection.
+ it('restores a legacy snapshot through canonical in-memory admission', async () => {
+ await store.saveProject(project as never);
+ const current = await store.loadProject('p1');
+ const { schemaVersion: _schemaVersion, ...legacySnapshot } = project;
+ const snapshotId = await store.saveSnapshot('legacy-snapshot', {
+ ...legacySnapshot,
+ title: 'Legacy snapshot content',
+ });
+
+ const restored = await store.restoreSnapshot(snapshotId, current as never);
+
+ expect(restored.title).toBe('Legacy snapshot content');
+ expect((restored as unknown as Record)['schemaVersion']).toBe(1);
+ });
+
// QNBS-v3: target-owned metadata survives a matching restore without trusting snapshot metadata.
it('restores older normalized content and preserves target auxiliary metadata', async () => {
const legacyProject = { ...project, id: '***', title: 'Current legacy content' };
@@ -1119,6 +1579,36 @@ describe('FsProjectStore — projects', () => {
expect(fake.text.has('/app/projects/Renamed-Again/project.json')).toBe(false);
});
+ // QNBS-v3: a CURRENT ID-less source remains authoritative across repeated title-changing saves.
+ it('retains a CURRENT ID-less source binding across repeated saves', async () => {
+ const currentProject = {
+ schemaVersion: 1,
+ title: 'Current Novel',
+ logline: 'L',
+ manuscript: [],
+ characters: [],
+ worlds: [],
+ };
+ await fake.apis.mkdir('/app/projects/current-source', { recursive: true });
+ await fake.apis.writeTextFile(
+ '/app/projects/current-source/project.json',
+ compressData(currentProject),
+ );
+
+ const loaded = await store.loadProject('current-source');
+ await store.saveProject({ ...loaded, title: 'First Rename' } as never);
+ await store.saveProject({ ...loaded, title: 'Second Rename' } as never);
+
+ expect(
+ decompressData>(
+ fake.text.get('/app/projects/current-source/project.json') as string,
+ ),
+ ).toMatchObject({ schemaVersion: 1, title: 'Second Rename' });
+ expect(fake.text.has('/app/projects/First-Rename/project.json')).toBe(false);
+ expect(fake.text.has('/app/projects/Second-Rename/project.json')).toBe(false);
+ expect(fake.text.get('/app/config/active-project-id.txt')).toBe('current-source');
+ });
+
// QNBS-v3: legacy missing-ID saves preserve historical Binder/Codex fallbacks without inventing cross-project ownership.
it('keeps missing-ID legacy auxiliary data on its historical fallback paths', async () => {
const legacyProject = { ...project, id: undefined, title: 'Legacy Novel' };
@@ -1174,7 +1664,7 @@ describe('FsProjectStore — projects', () => {
const legacyProject = {
...project,
id: '***',
- binderNodes: [{ binderAssetId: 'legitimate-asset' }],
+ binderNodes: [legacyBinderNode('legitimate-asset')],
};
await fake.apis.mkdir('/app/projects/item', { recursive: true });
await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject));
diff --git a/tests/unit/services/fs/projectFsStore.test.ts b/tests/unit/services/fs/projectFsStore.test.ts
index 2c47459d8..5a5977739 100644
--- a/tests/unit/services/fs/projectFsStore.test.ts
+++ b/tests/unit/services/fs/projectFsStore.test.ts
@@ -121,9 +121,11 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => {
await expect(promise).rejects.toMatchObject({ reason: 'io-error' });
});
+ // QNBS-v3: explicit V1 admission keeps this source writable while legacy sources remain fenced.
it('resolves the real project on a valid save with array-shaped characters/worlds', async () => {
const { FsProjectStore } = await import('../../../../services/fs/projectFsStore');
const validProject = {
+ schemaVersion: 1,
title: 'My Book',
logline: 'L',
characters: [],
@@ -133,12 +135,17 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => {
mockDesktopPlatform.filesystem.exists.mockResolvedValue(true);
mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(compressData(validProject));
const store = new FsProjectStore();
- await expect(store.loadProject('good-id')).resolves.toEqual(validProject);
+ await expect(store.loadProject('good-id')).resolves.toMatchObject({
+ ...validProject,
+ schemaVersion: 1,
+ });
});
+ // QNBS-v3: EntityState-shaped V1 data remains editable after canonical admission.
it('resolves the real project on a valid save with EntityState-shaped characters/worlds', async () => {
const { FsProjectStore } = await import('../../../../services/fs/projectFsStore');
const validProject = {
+ schemaVersion: 1,
title: 'My Book',
logline: 'L',
characters: { ids: [], entities: {} },
@@ -148,6 +155,99 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => {
mockDesktopPlatform.filesystem.exists.mockResolvedValue(true);
mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(compressData(validProject));
const store = new FsProjectStore();
- await expect(store.loadProject('good-entity-state-id')).resolves.toEqual(validProject);
+ await expect(store.loadProject('good-entity-state-id')).resolves.toMatchObject({
+ ...validProject,
+ schemaVersion: 1,
+ });
});
+
+ // QNBS-v3: current filesystem admission must reject malformed owned children before editable authority.
+ it.each([
+ ['entity', { characters: [{ id: 'c1', name: 42 }] }],
+ ['manuscript entry', { manuscript: [{ id: 's1', title: 42, content: 'text' }] }],
+ ])('rejects malformed nested %s content', async (_label, fragment) => {
+ const { FsProjectStore } = await import('../../../../services/fs/projectFsStore');
+ mockDesktopPlatform.filesystem.exists.mockResolvedValue(true);
+ mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(
+ JSON.stringify({
+ title: 'Malformed nested',
+ logline: 'L',
+ characters: [],
+ worlds: [],
+ manuscript: [],
+ ...fragment,
+ }),
+ );
+ const store = new FsProjectStore();
+ await expect(store.loadProject('malformed-nested-id')).rejects.toMatchObject({
+ name: 'ProjectLoadError',
+ reason: 'corrupt',
+ classification: 'MALFORMED',
+ });
+ });
+
+ // QNBS-v3: legacy admission must not stamp or rewrite a source before durable migration fencing exists.
+ it('admits a legacy project in memory without rewriting its source', async () => {
+ const { FsProjectStore } = await import('../../../../services/fs/projectFsStore');
+ const source =
+ '{"title":"Legacy book","logline":"L","characters":[],"worlds":[],"manuscript":[],"opaque":{"exact":9007199254740993}}';
+ mockDesktopPlatform.filesystem.exists.mockResolvedValue(true);
+ mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(source);
+ const store = new FsProjectStore();
+
+ const loaded = await store.loadProject('legacy-id');
+ expect(loaded).toMatchObject({
+ title: 'Legacy book',
+ logline: 'L',
+ characters: [],
+ worlds: [],
+ manuscript: [],
+ });
+ expect(loaded).not.toHaveProperty('schemaVersion');
+ expect(mockDesktopPlatform.filesystem.writeTextFile).not.toHaveBeenCalled();
+ expect(mockDesktopPlatform.filesystem.rename).not.toHaveBeenCalled();
+ });
+
+ // QNBS-v3: legacy admission cannot let ordinary autosave normalize or rewrite its source before fencing.
+ it('rejects ordinary writeback after admitting a legacy project', async () => {
+ const { FsProjectStore } = await import('../../../../services/fs/projectFsStore');
+ const source =
+ '{"title":"Legacy book","logline":"L","characters":[],"worlds":[],"manuscript":[]}';
+ mockDesktopPlatform.filesystem.exists.mockResolvedValue(true);
+ mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(source);
+ const store = new FsProjectStore();
+
+ const loaded = await store.loadProject('legacy-id');
+ await expect(
+ store.saveProject({ ...loaded, title: 'Edited legacy book' } as never),
+ ).rejects.toMatchObject({
+ name: 'ProjectWritebackError',
+ projectId: 'legacy-id',
+ });
+ expect(mockDesktopPlatform.filesystem.writeTextFile).not.toHaveBeenCalled();
+ });
+
+ // QNBS-v3: unsupported versions must refuse editable filesystem authority without touching source.
+ it.each([
+ ['future', { schemaVersion: 99, title: 'Future' }, 'FUTURE'],
+ ['migration gap', { schemaVersion: 0, title: 'Gap' }, 'UNSUPPORTED_OLDER'],
+ ])(
+ 'refuses %s filesystem input without changing its source',
+ async (_label, value, classification) => {
+ const { FsProjectStore } = await import('../../../../services/fs/projectFsStore');
+ const source = JSON.stringify(value);
+ mockDesktopPlatform.filesystem.exists.mockResolvedValue(true);
+ mockDesktopPlatform.filesystem.readTextFile.mockResolvedValue(source);
+ const store = new FsProjectStore();
+
+ await expect(store.loadProject('refused-id')).rejects.toMatchObject({
+ name: 'ProjectLoadError',
+ reason: 'unsupported-version',
+ projectId: 'refused-id',
+ classification,
+ });
+ expect(mockDesktopPlatform.filesystem.writeTextFile).not.toHaveBeenCalled();
+ expect(mockDesktopPlatform.filesystem.rename).not.toHaveBeenCalled();
+ },
+ );
});
diff --git a/tests/unit/startupRecovery.test.tsx b/tests/unit/startupRecovery.test.tsx
index 340aef92e..4f41d65d8 100644
--- a/tests/unit/startupRecovery.test.tsx
+++ b/tests/unit/startupRecovery.test.tsx
@@ -12,6 +12,8 @@ const { mockRoot, mockReset, mockBackendKind, mockQuarantine, mockCopy, loggerEr
storageUnavailable: 'storage unavailable',
projectUnavailable: 'project unavailable',
projectIoUnavailable: 'project io unavailable',
+ projectUnsupported: 'project unsupported',
+ projectMigrationGap: 'project migration gap',
reload: 'reload',
retry: 'retry',
recover: 'recover',
@@ -47,9 +49,10 @@ vi.mock('../../components/StorageErrorScreen', () => ({
vi.mock('../../services/fs/projectFsStore', () => {
class ProjectLoadError extends Error {
constructor(
- public readonly reason: 'corrupt' | 'io-error',
+ public readonly reason: 'corrupt' | 'io-error' | 'unsupported-version',
message: string,
public readonly projectId: string,
+ public readonly classification?: 'FUTURE' | 'UNSUPPORTED_OLDER',
) {
super(message);
this.name = 'ProjectLoadError';
@@ -72,7 +75,12 @@ import {
type RecoveryScreenProps = {
copy: typeof mockCopy;
- failureKind: 'storage' | 'project-corrupt' | 'project-io';
+ failureKind:
+ | 'storage'
+ | 'project-corrupt'
+ | 'project-io'
+ | 'project-unsupported'
+ | 'project-migration-gap';
onReset?: () => Promise;
onRecover?: () => Promise;
onRetry?: () => void;
@@ -142,4 +150,34 @@ describe('startup recovery rendering', () => {
expect(renderedScreenProps().failureKind).toBe('storage');
expect(renderedScreenProps().onReset).toEqual(expect.any(Function));
});
+
+ // QNBS-v3: unsupported versions expose retry-only recovery so no destructive authority is offered.
+ it('renders unsupported project versions without quarantine or reset authority', async () => {
+ mockBackendKind.mockResolvedValue('filesystem');
+ await renderProjectInitializationFailure(
+ mockRoot as never,
+ new ProjectLoadError('unsupported-version', 'future', 'p1'),
+ );
+
+ const props = renderedScreenProps();
+ expect(props.failureKind).toBe('project-unsupported');
+ expect(props.onRecover).toBeUndefined();
+ expect(props.onReset).toBeUndefined();
+ expect(props.onRetry).toEqual(expect.any(Function));
+ });
+
+ // QNBS-v3: migration-gap recovery keeps the older-version diagnostic while remaining retry-only.
+ it('renders migration-gap projects with distinct retry-only recovery', async () => {
+ mockBackendKind.mockResolvedValue('filesystem');
+ await renderProjectInitializationFailure(
+ mockRoot as never,
+ new ProjectLoadError('unsupported-version', 'older', 'p1', 'UNSUPPORTED_OLDER'),
+ );
+
+ const props = renderedScreenProps();
+ expect(props.failureKind).toBe('project-migration-gap');
+ expect(props.onRecover).toBeUndefined();
+ expect(props.onReset).toBeUndefined();
+ expect(props.onRetry).toEqual(expect.any(Function));
+ });
});
diff --git a/tests/unit/startupRecoveryPolicy.test.ts b/tests/unit/startupRecoveryPolicy.test.ts
index e16517dc2..353e9017f 100644
--- a/tests/unit/startupRecoveryPolicy.test.ts
+++ b/tests/unit/startupRecoveryPolicy.test.ts
@@ -34,6 +34,33 @@ describe('startup recovery action policy', () => {
});
});
+ it('keeps unsupported project versions non-quarantinable and retryable', () => {
+ expect(
+ getStartupRecoveryActions(
+ new ProjectLoadError('unsupported-version', 'future', 'project-1', 'FUTURE'),
+ 'filesystem',
+ ),
+ ).toEqual({
+ failureKind: 'project-unsupported',
+ canQuarantine: false,
+ canReset: false,
+ });
+ });
+
+ // QNBS-v3: preserve migration-gap provenance so recovery copy explains why this build cannot migrate the project.
+ it('distinguishes unsupported older projects from future projects', () => {
+ expect(
+ getStartupRecoveryActions(
+ new ProjectLoadError('unsupported-version', 'older', 'project-1', 'UNSUPPORTED_OLDER'),
+ 'filesystem',
+ ),
+ ).toEqual({
+ failureKind: 'project-migration-gap',
+ canQuarantine: false,
+ canReset: false,
+ });
+ });
+
it('retains database reset for non-project failures from IndexedDB', () => {
expect(getStartupRecoveryActions(new Error('QuotaExceededError'), 'indexeddb')).toEqual({
failureKind: 'storage',