schema stamps: lazy v1 for chats + the graph family, honest refusal

Chat records gain version (CURRENT_CHAT_VERSION), stamped by a single
writeChat choke point in the store; absence reads as v1 — existing files
migrate on their next write, no mass rewrite. The graph family (four JSONL
ledgers, sources.json, render-settings.json) shares one lazy sidecar,
graph/format.json; positions stays unstamped (rebuildable) and jobs stay
unstamped (transient).

Downgrade scenario ruled honest-refusal: records stamped newer than the
running build read as absent (chats, logged) or throw (graph family) — a
read-modify-write store never rewrites what it doesn't understand.

Golden fixtures stay the unstamped legacy shape on purpose (lazy v1 is
current-valid by design); the stamped write path is pinned by
tests/schema-stamps.test.ts. Reads never write — the golden byte-identical
pin holds.
This commit is contained in:
amosblomqvist
2026-10-10 13:54:17 +03:00
parent bc09ec25e1
commit eda5491010
10 changed files with 245 additions and 10 deletions
+9
View File
@@ -52,8 +52,17 @@ export interface ChatRecord {
extractions?: ExtractionRunUsage[];
/** Per-chat model/thinking overrides for the conversation agents. */
settings?: ChatSettings;
/** SCHEMA STAMP (schema-versioning wave, owner 2026-10-10): LAZY v1 —
* existing records stay unstamped (absence reads as v1); every write
* stamps the current version. A record stamped NEWER than this build
* (the downgrade scenario) is REFUSED on read: a read-modify-write
* store must never rewrite what it doesn't understand. */
version?: number;
}
/** The chat record format this build writes and understands. */
export const CURRENT_CHAT_VERSION = 1;
/** The conversation agents' runtime model choice (model-control wave,
* 2026-09-12): a per-chat override of config.ts's pinned prober/teacher
* spec. Absent fields fall back to config; written by the model picker
+4
View File
@@ -120,6 +120,10 @@ export interface NamingRunStats {
appended: number;
}
/** The graph-family format version this build writes and understands
* (the format.json sidecar — src/store/graph-format.ts). */
export const CURRENT_GRAPH_FORMAT_VERSION = 1;
export interface GraphNode {
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
+28 -9
View File
@@ -13,6 +13,7 @@ import { PATHS } from "../../config.ts";
import { emit } from "../core/events.ts";
import type { ChatStore } from "../core/ports.ts";
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
import { CURRENT_CHAT_VERSION } from "../core/contracts.ts";
import type { LhMessage } from "../core/native-messages.ts";
import { activeProblemOf, danglingQuestionOf } from "../core/native-messages.ts";
import { phaseAt3Lh } from "../core/phases.ts";
@@ -44,17 +45,35 @@ function createChat(title: string, kind?: "practice"): ChatRecord {
...(kind === "practice" ? { kind: "practice" as const } : {}),
};
fs.mkdirSync(PATHS.chats, { recursive: true });
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
writeChat(record);
emit("chats-changed", {});
return record;
return { ...record, version: CURRENT_CHAT_VERSION }; // the in-memory twin of what landed on disk
}
/** THE WRITE CHOKE POINT (schema-versioning wave, owner 2026-10-10): every
* record persist goes through here and carries the CURRENT version stamp —
* lazy migration means existing files only get stamped on their next write.
* A future-version guard lives on the read side (getChat). */
function writeChat(record: ChatRecord): void {
fs.writeFileSync(chatFile(record.id), JSON.stringify({ ...record, version: CURRENT_CHAT_VERSION }, null, "\t"), "utf-8");
}
function getChat(id: string): ChatRecord | null {
let parsed: unknown;
try {
return JSON.parse(fs.readFileSync(chatFile(id), "utf-8")) as ChatRecord;
parsed = JSON.parse(fs.readFileSync(chatFile(id), "utf-8"));
} catch {
return null;
}
// Honest refusal (owner ruling 2026-10-10): a record stamped NEWER than
// this build (the downgrade scenario) reads as absent, never misparsed —
// the next read-modify-write must not rewrite what it doesn't understand.
const v = (parsed as { version?: unknown }).version;
if (typeof v === "number" && v > CURRENT_CHAT_VERSION) {
console.error(`[chat ${id}] record stamped with schema version ${v} > ${CURRENT_CHAT_VERSION} — this app build is older; refusing to read it`);
return null;
}
return parsed as ChatRecord;
}
function listChats(): ChatSummary[] {
@@ -109,7 +128,7 @@ function saveMessages(id: string, messages: LhMessage[]): void {
if (!record) throw new Error(`Chat not found: ${id}`);
record.messages = messages;
record.updatedAt = new Date().toISOString();
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
writeChat(record);
}
/** Append one usage entry to the record's turn log — idempotent per
@@ -122,7 +141,7 @@ function appendTurnUsage(id: string, entry: TurnUsage): void {
record.turns = record.turns?.some((t) => t.id === entry.id)
? record.turns
: [...(record.turns ?? []), entry];
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
writeChat(record);
}
/** Append one extraction-run usage entry to the record's extractions
@@ -134,7 +153,7 @@ function appendExtractionUsage(id: string, entry: ExtractionRunUsage): void {
record.extractions = record.extractions?.some((r) => r.startedAt === entry.startedAt)
? record.extractions
: [...(record.extractions ?? []), entry];
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
writeChat(record);
}
/** Persist the per-chat model/thinking settings — replace-whole (the
@@ -145,7 +164,7 @@ function saveSettings(id: string, settings: ChatSettings): void {
// Like saveTree: a settings tweak is not conversation activity, so it
// deliberately does NOT bump updatedAt (sidebar order must not churn).
record.settings = settings;
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
writeChat(record);
}
/** Rename a session (the header's click-to-edit title, 2026-09-17). Like
@@ -157,7 +176,7 @@ function renameChat(id: string, title: string): ChatRecord {
const record = getChat(id);
if (!record) throw new Error(`Chat not found: ${id}`);
record.title = title.trim() || "Untitled session";
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
writeChat(record);
emit("chats-changed", {});
return record;
}
@@ -183,7 +202,7 @@ function saveTree(id: string, tree: PlanTree): void {
record.tree = tree;
// Deliberately does NOT bump updatedAt: tree mutations piggyback on the
// turn's saveMessages, and sidebar ordering shouldn't churn on tree edits.
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
writeChat(record);
}
export const chatStore: ChatStore = {
+60
View File
@@ -0,0 +1,60 @@
/**
* The graph-family FORMAT STAMP (schema-versioning wave, owner 2026-10-10):
* data/graph/format.json — one sidecar versioning the whole graph dir
* family (the four JSONL ledgers, sources.json, render-settings.json).
* JSONL ledgers can't carry inline stamps without per-line bloat, and the
* JSON files share the family's fate, so the stamp lives beside them.
* positions.json is rebuildable (owner ruling) and needs no protection.
*
* The contract:
* - LAZY v1: NO sidecar = v1 (the pre-stamp world stays valid forever);
* the first graph-family WRITE creates it. Reads never write.
* - Honest refusal (owner: refuse-honestly ruling): a sidecar stamped
* NEWER than this build (the downgrade scenario — user reverted the
* app) makes graph-family reads THROW. Read-modify-write stores must
* never rewrite what they don't understand; a ledger store must never
* silently skip lines it can't interpret as current-format data.
*
* Version constants: contracts/graph.ts CURRENT_GRAPH_FORMAT_VERSION.
*/
import fs from "node:fs";
import path from "node:path";
import { GRAPH_FORMAT_FILE } from "../../config.ts";
import { CURRENT_GRAPH_FORMAT_VERSION } from "../core/contracts/graph.ts";
function stampedVersion(): number | null {
let raw: string;
try {
raw = fs.readFileSync(GRAPH_FORMAT_FILE, "utf-8");
} catch {
return null; // no sidecar — the lazy-v1 pre-stamp world
}
try {
const parsed = JSON.parse(raw) as { version?: unknown };
return typeof parsed.version === "number" && Number.isInteger(parsed.version) ? parsed.version : null;
} catch {
return null; // corrupt sidecar reads as unstamped (v1), not fatal
}
}
/** READ-SIDE GUARD: throws when the data dir was written by a NEWER build.
* Called by every graph-family read — never writes (the golden fixtures
* pin reads as byte-identical). */
export function assertGraphFormat(): void {
const v = stampedVersion();
if (v !== null && v > CURRENT_GRAPH_FORMAT_VERSION) {
throw new Error(
`Graph data is stamped with format version ${v}, but this build understands only ${CURRENT_GRAPH_FORMAT_VERSION}. ` +
"The app is older than its data — upgrade the app before touching the knowledge map.",
);
}
}
/** WRITE-SIDE STAMP: creates the sidecar on the first graph-family write
* (lazy); afterwards a no-op existence-wise. Callers keep their own mkdir. */
export function ensureGraphFormat(): void {
if (stampedVersion() !== null) return;
fs.mkdirSync(path.dirname(GRAPH_FORMAT_FILE), { recursive: true });
fs.writeFileSync(GRAPH_FORMAT_FILE, JSON.stringify({ version: CURRENT_GRAPH_FORMAT_VERSION }, null, "\t") + "\n", "utf-8");
}
+5
View File
@@ -35,6 +35,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
import { assertGraphFormat, ensureGraphFormat } from "./graph-format.ts";
import type {
GraphChain,
GraphNode,
@@ -51,6 +52,7 @@ import type { GraphStore } from "../core/ports.ts";
function appendLine<T>(file: string, record: T): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
ensureGraphFormat(); // the lazy format stamp (graph-format.ts)
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, "utf-8");
}
@@ -83,6 +85,7 @@ const isString = (v: unknown): v is string => typeof v === "string";
/** Strict shape guards — anything not matching a W4 ledger shape is skipped. */
function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
assertGraphFormat(); // honest refusal on a newer-stamped data dir
return readRawLines(file).filter(isValid);
}
@@ -280,6 +283,7 @@ const isPosition = (v: unknown): v is Position =>
/** sources.json — { id: NotesSource }. Read-modify-write, NOT append-only:
* the usage log lives inside the record (like a chat's JSON file). */
function readSourceMap(): Record<string, NotesSource> {
assertGraphFormat(); // honest refusal on a newer-stamped data dir
let raw: string;
try {
raw = fs.readFileSync(SOURCES_FILE, "utf-8");
@@ -301,6 +305,7 @@ function readSourceMap(): Record<string, NotesSource> {
function writeSourceMap(map: Record<string, NotesSource>): void {
fs.mkdirSync(path.dirname(SOURCES_FILE), { recursive: true });
ensureGraphFormat(); // the lazy format stamp (graph-format.ts)
fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8");
}
+3
View File
@@ -13,9 +13,11 @@ import fs from "node:fs";
import path from "node:path";
import { RENDER_SETTINGS_FILE } from "../../config.ts";
import { DEFAULT_RENDER_SETTINGS, sanitizeRenderSettings, type GraphRenderSettings } from "../core/render-settings.ts";
import { assertGraphFormat, ensureGraphFormat } from "./graph-format.ts";
/** The persisted settings, or null when no file exists yet (first boot). */
export function readRenderSettings(): GraphRenderSettings | null {
assertGraphFormat(); // honest refusal on a newer-stamped data dir
let raw: string;
try {
raw = fs.readFileSync(RENDER_SETTINGS_FILE, "utf-8");
@@ -33,6 +35,7 @@ export function readRenderSettings(): GraphRenderSettings | null {
export function writeRenderSettings(raw: unknown): GraphRenderSettings {
const settings = sanitizeRenderSettings(raw);
fs.mkdirSync(path.dirname(RENDER_SETTINGS_FILE), { recursive: true });
ensureGraphFormat(); // the lazy format stamp (graph-format.ts)
fs.writeFileSync(RENDER_SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8");
return settings;
}