/* Reader-authored inspection notes, never a source of semantic approval. */ (() => { "use strict"; const format = "physicslogic-inspection-notebook", version = 1; const limit = 2 * 1024 * 1024, maximum = 1000; const prefix = "physicslogic-inspection-note-v1:"; const projectScope = "project-meaning-with-listed-external-boundaries"; const boundaryScope = "external-type-in-extraction"; const kinds = Object.freeze({ observation: "Inspection note", question: "Open question", concern: "Suspected mismatch" }); const fields = ["id", "declaration", "argument", "scope", "fingerprint", "sourceSnapshot", "kind", "text", "createdAt"]; const ensure = (condition, message) => { if (!condition) throw new Error(message); }; const hash = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value); const uuid = value => typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value); const string = (value, length) => typeof value === "string" && value.trim() && value.length <= length; function record(value) { ensure(value && Object.keys(value).length === fields.length && fields.every(key => Object.hasOwn(value, key)), "Unexpected notebook entry fields; review approvals are not notebook entries."); ensure(uuid(value.id) && string(value.declaration, 4096) && string(value.argument, 4096), "Invalid note identity."); ensure([projectScope, boundaryScope].includes(value.scope) && hash(value.fingerprint) && hash(value.sourceSnapshot), "Missing or unsupported note version and scope."); ensure(value.scope !== boundaryScope || value.fingerprint === value.sourceSnapshot, "Invalid external-type snapshot."); ensure(Object.hasOwn(kinds, value.kind) && string(value.text, 8000), "Choose a note kind and enter 1-8000 characters."); ensure(typeof value.createdAt === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value.createdAt) && Number.isFinite(Date.parse(value.createdAt)) && new Date(value.createdAt).toISOString() === value.createdAt, "Invalid note timestamp."); return Object.fromEntries(fields.map(key => [key, value[key]])); } function merge(...collections) { const entries = new Map(); for (const collection of collections) { ensure(Array.isArray(collection) && collection.length <= maximum, "Notebook exceeds 1000 entries."); for (const value of collection) { const item = record(value), previous = entries.get(item.id); ensure(!previous || JSON.stringify(previous) === JSON.stringify(item), "Conflicting entries have the same ID; existing notes were not replaced."); entries.set(item.id, item); ensure(entries.size <= maximum, "Notebook exceeds 1000 entries."); } } return [...entries.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); } function encode(entries) { const output = JSON.stringify({ format, version, semanticApproval: false, entries: merge(entries) }); ensure(new TextEncoder().encode(output).length <= limit, "Notebook exceeds the 2 MiB limit."); return output; } function decode(text) { ensure(new TextEncoder().encode(text).length <= limit, "Notebook exceeds the 2 MiB limit."); const value = JSON.parse(text); ensure(value && JSON.stringify(value) === text && Object.keys(value).length === 4 && value.format === format && value.version === version && value.semanticApproval === false, "Use the compact notebook JSON export; duplicate keys, HTML, and review ledgers are not accepted."); const entries = merge(value.entries); ensure(entries.length === value.entries.length, "Duplicate notebook entry ID."); return entries; } function anchor(report, declaration) { const node = report.nodes.find(item => item.name === declaration); if (node) { ensure(node.review?.scope === projectScope && hash(node.review.fingerprint), "Missing project review fingerprint."); return { scope: projectScope, fingerprint: node.review.fingerprint }; } ensure(report.externalNodes.some(item => item.name === declaration) && hash(report.sourceSnapshot), "Declaration is not in this extraction."); return { scope: boundaryScope, fingerprint: report.sourceSnapshot }; } function create(report, declaration, argument, kind, text) { ensure(report.roots.some(item => item.root === argument), "Missing selected argument."); return record({ id: crypto.randomUUID(), declaration, argument, ...anchor(report, declaration), sourceSnapshot: report.sourceSnapshot, kind, text, createdAt: new Date().toISOString() }); } function attachment(value, report) { const item = record(value); if (![...report.nodes, ...report.externalNodes].some(node => node.name === item.declaration)) return "outside"; const current = anchor(report, item.declaration); return current.scope === item.scope && current.fingerprint === item.fingerprint ? "matching" : "stale"; } // One immutable entry per storage key avoids whole-notebook overwrites by other tabs. function load(storage) { const entries = [], errors = []; try { for (let index = 0; index < storage.length; index++) { const key = storage.key(index); if (!key?.startsWith(prefix)) continue; try { const parsed = decode(storage.getItem(key)); ensure(parsed.length === 1 && key === prefix + parsed[0].id, "Stored note ID does not match its key."); entries.push(parsed[0]); } catch (error) { errors.push(error.message); } } } catch (error) { errors.push(error.message); } return { entries: merge(entries), errors }; } function persist(storage, entries) { const prepared = merge(entries), errors = []; // Validate all conflicts before making any writes. Never replace an existing entry. const pending = prepared.filter(item => { const previous = storage.getItem(prefix + item.id); if (previous === null) return true; const stored = decode(previous); ensure(stored.length === 1 && stored[0].id === item.id, "Stored note ID conflict."); merge(stored, [item]); return false; }); for (const item of pending) { try { storage.setItem(prefix + item.id, encode([item])); } catch (error) { errors.push(error.message); } } return errors; } globalThis.PhysicsLogicNotebook = Object.freeze({ limit, maximum, prefix, kinds, encode, decode, merge, create, attachment, load, persist }); })();