SPEC.md (Source of Truth)
A PRD is written for a person. It says what the product is for, who it serves, and why anyone should care, and a human reader quietly fills in everything it leaves out, because that is what human readers do. SPEC.md is written for the machine. It says what gets built, in numbers, and it is the file that wins when the code disagrees with it.
That last clause is the whole point and it is the part people skip. A spec you wrote once at the start and never opened again decays into fiction by the third session. A spec that is the source of truth means a disagreement between code and spec is a bug in the code until you decide otherwise, and when you do decide otherwise, you write the decision back into the file with a date on it.
The failure this prevents is a quiet one. An agent fills every gap you leave with a plausible guess, and plausible guesses compound: a guess about how timing works shapes a guess about where state lives, which shapes the export path, and four sessions later you are debugging a decision nobody ever made. Numbers convert guesses into lookups. 8BEETY's spec ran to about 630 lines before any meaningful code existed, and when a question came up mid-build the answer was read SPEC.md §7.3, not a negotiation. That is what the file buys you: arguments you only have to have once.
# SPEC.md
Source of truth. When this file and the code disagree, this file is right
until someone changes this file first.
Last updated: [YYYY-MM-DD]
## 1. Product statement
[One paragraph. What the thing is, who runs it, what comes out of it.
Written so a stranger could repeat it back to you correctly. No marketing.]
Example (8BEETY): a browser tool that turns any MIDI file into music that
sounds like it came out of an NES, a Game Boy, a Super Nintendo, or a Sega
Genesis. Drop in a MIDI, set a tempo, assign tracks to chip channels, export
a WAV or a 9:16 video. No login, no server, hosted as a static site.
**Guiding principle:** [the one sentence that settles architecture
arguments. The useful form is a correction: name the obvious wrong mental
model, then the right one.]
Example (8BEETY): this is not a synthesizer that receives notes, it is a
compiler that produces a register script, plus a player that renders that
script. Everything downstream of the compiler is deterministic, which is
what makes offline export and testing easy.
## 2. Non-goals
[What this will never do, stated flatly. No "for now" unless you mean it.
Every non-goal here is an argument you have already won.]
Example (8BEETY): no user accounts. No server-side anything. No full tracker
or macro editor. No cycle-accurate emulation (musical models of the chips,
not emulators). No Nintendo or Sega logos, characters, or trade dress
anywhere in the UI.
## 3. Core rules
[The architectural invariants. These are the rules where breaking one means
a rewrite rather than a fix. Keep the list short enough to remember, and
copy it into CLAUDE.md so the agent reads it every session.]
Example (8BEETY):
- All timing is 60 fps frames. Parameter changes happen only on frame
boundaries. Never schedule audio events in seconds from the main thread.
- The compiler is a pure function: `compile(song, project, profile)` returns
a FrameScript, deterministically, and never reads global state.
- The audio worklet imports nothing from the rest of the app.
- TypeScript strict, no `any`. The dependency list is closed; adding to it
is an explicit decision, not a side effect of a task.
## 4. Domain model
[The real numbers and formulas. Rates, ranges, limits, units, rounding
rules, what happens at the edges. This is the section that turns "make it
sound right" into something a machine can implement.]
Example (8BEETY): NES pitch round-trips through the real hardware timer
math, `1789773 / (16 * (t + 1))`, so high notes pick up the chip's genuine
detune instead of a clean pitch nobody's console ever produced. Timers below
8 go silent on the real hardware, and that is preserved rather than fixed.
The Game Boy uses `131072 / (2048 - x)`.
## 5. Data model
[The load-bearing decision. Exact shapes, exact field names, exact types,
and a diagram of what turns into what. Every milestone after the first is
downstream of this section, so a mistake here gets paid for repeatedly.]
```
MIDI file --import--> Song (normalized notes and tempo)
Song + Project (the user's arrangement) --compile--> FrameScript
FrameScript --ApuCore--> audio samples (realtime worklet OR offline render)
```
Example (8BEETY): FrameScript is per-channel parallel typed arrays, one
entry per frame: `period` (the pitch register), `volume`, `duty` (duty
index, wave preset, FM patch, or sample index depending on the chip), `pan`,
and `trig` (the note-on marker). A three-minute song is roughly 10,800
frames across a handful of channels, small enough to hand to the worklet in
one message, and identical for playback, WAV export, and video export.
## 6. Behavior
[What the system does with that data. The rules, the edge cases, and the
priority order for when two things want the same resource.]
Example (8BEETY): polyphony is resolved the 8-bit way, with top and bottom
note extraction, arp (chord tones cycled at 1 to 3 frames per step), and
split (one chord spread across several channels). A GM drum map routes kick,
snare, toms, hats, and crash onto the noise channel, with priority
resolution when two of them land on the same frame.
## 7. UI
[Screens, density, and what is on one screen versus behind a click. Name
the feel in words a stranger could actually apply to a layout.]
Example (8BEETY): dense, utilitarian, dark. One screen: a header with chip
select, BPM, transport, and export; a chip rack of drag-target channel
cards; a track list with per-track piano rolls; a bar ruler with click-seek
and drag-loop.
## 8. Export, persistence, sharing
[Formats in and out, where state lives between sessions, and exactly what a
shared link contains.]
Example (8BEETY): WAV export renders offline through the exact same audio
core as playback, which is why the two match. Video export is a 9:16
720x1280 MP4 of the visualization. Project files are zod-validated JSON with
the MIDI embedded as base64, and share links compress the whole project into
the URL fragment with lz-string, so there is still no server.
## 9. Acceptance criteria
[Per milestone. Each one has to be settled by running a command or by a
named comparison someone else could repeat without asking you what you
meant. "Works well" is not a criterion. "Sounds good" is not a criterion.]
- M[n]: [the criterion, with the command or the comparison named]
- Standing gate for every milestone: [e.g. `npm test` green and
`npm run build` green, both actually run, output pasted]
Example (8BEETY): the NES fixture is compared by ear against a FamiTracker
export of the same pattern, and separately, chunked realtime rendering and
whole-file offline rendering must produce sample-exact identical output,
which is a runnable proof that the player is deterministic.
## 10. Deviations log
[When you deliberately depart from this spec, write it here with a date and
a reason, then go fix the section above. An undocumented deviation is a bug
with a good story attached.]
- [YYYY-MM-DD] [what changed, why, and which section above was updated]Adaptation notes:
- The test for whether a section is specific enough is to put a number in it. If you cannot, you do not know what you want yet, and neither will the agent, which will pick a number for you and never mention it.
- The spec wins over the code. That rule costs nothing to write and everything to keep, and the first time you keep it will be a time when keeping it is inconvenient.
- Write deviations back into the file the day you make them. A spec that stopped matching the build three weeks ago is worse than no spec at all, because your agent still reads it and still believes it.
- Copy section 3 into CLAUDE.md close to verbatim. Core rules are the part that needs repeating every session; the rest of the spec is a lookup the agent does when it needs to.
- 630 lines sounds like a lot right up until you notice it is mostly numbers you were going to have to decide anyway. You are choosing when to decide them, not whether.