The .mpcweb snapshot schema in src/core/project/mpcweb.ts declares every id as a bare z.string() and every numeric as a bare z.number(). Both assumptions are load-bearing further down, and neither is enforced. §9.6 requires the import to "Zod-validate manifest + snapshot", and §1.3 #11 makes Zod the guard for exactly this kind of external data.
1. Unconstrained ids corrupt the whole snapshot
remapSnapshot rewrites ids by global string replacement:
for (const [oldId, newId] of idMap) json = json.split(oldId).join(newId);
Its own doc comment states the precondition — "Ids are globally-unique 36-char strings" — but every id field is id: z.string().
From a hand-edited project.json:
midiEvents[0].id = "e" → every letter e in the serialised snapshot (project name, track names, the JSON keys "sequences", "velocity") is replaced with a UUID. The parse then throws, or worse succeeds with silently mangled names and payloads.
id = "" → "".split("") explodes the document into individual characters, each rejoined with a UUID.
id = "1" → every digit 1 in every tick, velocity and timestamp is rewritten, producing a structurally valid project with corrupted musical data.
Fix: z.uuid() (or z.string().length(36)) on every id field, plus a non-empty guard before the split/join.
2. Unconstrained numerics reach tick arithmetic
songEntry.repeats is z.number(), and buildSongMap in src/core/sequencer/songMap.ts loops on it:
for (let repeat = 0; repeat < entry.repeats; repeat++) {
JSON.parse('{"repeats":1e999}') yields Infinity, which z.number() accepts — it rejects only NaN. The loop never terminates and pushes segments until the tab dies. repeats: 1e9 reaches the same end more slowly.
The same gap applies to tick_start, duration_ticks, note, velocity, length_bars and time_sig_denominator, all of which feed tick arithmetic that assumes finite in-range integers. sequence.tempo is z.number().nullable() and permits 0, which makes secondsPerTick in src/core/sequencer/ppqn.ts return Infinity and poisons every when in the scheduled batch — there is no bpm = 0 guard.
Fix: .int(), .min(), .max() and .finite() bounds matching the §9.3 DDL CHECK constraints and the §2.6 ranges, so the schema and the database agree.
Note the runtime store paths already clamp these (useTransportStore, ranges.ts); it is specifically the import path that bypasses those clamps.
The
.mpcwebsnapshot schema insrc/core/project/mpcweb.tsdeclares every id as a barez.string()and every numeric as a barez.number(). Both assumptions are load-bearing further down, and neither is enforced. §9.6 requires the import to "Zod-validate manifest + snapshot", and §1.3 #11 makes Zod the guard for exactly this kind of external data.1. Unconstrained ids corrupt the whole snapshot
remapSnapshotrewrites ids by global string replacement:Its own doc comment states the precondition — "Ids are globally-unique 36-char strings" — but every id field is
id: z.string().From a hand-edited
project.json:midiEvents[0].id = "e"→ every letterein the serialised snapshot (project name, track names, the JSON keys"sequences","velocity") is replaced with a UUID. The parse then throws, or worse succeeds with silently mangled names and payloads.id = ""→"".split("")explodes the document into individual characters, each rejoined with a UUID.id = "1"→ every digit1in every tick, velocity and timestamp is rewritten, producing a structurally valid project with corrupted musical data.Fix:
z.uuid()(orz.string().length(36)) on every id field, plus a non-empty guard before the split/join.2. Unconstrained numerics reach tick arithmetic
songEntry.repeatsisz.number(), andbuildSongMapinsrc/core/sequencer/songMap.tsloops on it:JSON.parse('{"repeats":1e999}')yieldsInfinity, whichz.number()accepts — it rejects onlyNaN. The loop never terminates and pushes segments until the tab dies.repeats: 1e9reaches the same end more slowly.The same gap applies to
tick_start,duration_ticks,note,velocity,length_barsandtime_sig_denominator, all of which feed tick arithmetic that assumes finite in-range integers.sequence.tempoisz.number().nullable()and permits0, which makessecondsPerTickinsrc/core/sequencer/ppqn.tsreturnInfinityand poisons everywhenin the scheduled batch — there is nobpm = 0guard.Fix:
.int(),.min(),.max()and.finite()bounds matching the §9.3 DDL CHECK constraints and the §2.6 ranges, so the schema and the database agree.Note the runtime store paths already clamp these (
useTransportStore,ranges.ts); it is specifically the import path that bypasses those clamps.