Skip to content
Settings
Theme
Mode
Language

The build story, part 2: specs, standards and the review loop

8 min read
  • build-story
  • process
  • ai-agents

Part 1 was the bets: what to build, and what each choice displaced. This part is the method — how the code actually gets written, reviewed and merged. That matters more than usual here, because a large share of this code is written by AI agents, and the honest version of that arrangement is not “the AI writes it and it works”. It is a set of standards rigid enough that I can trust a change I did not type, and a review loop built on the assumption that I can’t.

Code that explains itself

The comment standard is the shortest spec in the repo: names carry the meaning, and a comment exists only for a “why” the code cannot express. The banned anti-pattern is the doc block that restates the signature — if the types already say it, the block is noise that will one day disagree with the code next to it. The default number of comments is zero, and what survives review is the non-obvious rationale: the sentence that prevents a future regression.

When a module genuinely needs explaining, it gets explained once, at the top of the file. The prose model that renders this very post opens with a paragraph on its one load-bearing decision — inline marks are structure, not markdown left inside strings, because the future editor would otherwise round-trip every save through a parser, and a parser bug would silently rewrite what the author wrote. The diagram validator carries two lines on why it reads only the key side of each source line: everything after the colon is label text, where the word “icon” is prose, not a property. Neither comment narrates what the code does; both record why it is not the other way.

Commits are tasks, and the gate is not decorative

Commits land at task level — one commit per completed feature, fix or doc-set, never one per file tweak. The history should read as a list of finished things, not a feed of save points. The format is conventional commits, checked by commitlint, and AI-authored commits are marked:

<type>(<scope>): 🤖 <subject>        # AI-authored
<type>(<scope>): <subject>           # human-authored

That emoji is the entire attribution policy — no trailers, no “generated with” footers, just an honest marker in the subject. And every commit goes through the real pipeline: husky runs lint-staged on the staged files, commitlint rejects a bad message, and before a batch lands, pnpm turbo lint typecheck test build runs across the workspace. A failing hook means fix and retry. Bypassing with --no-verify is not an option, because a gate you can skip under pressure is a suggestion, not a gate.

The pipeline is also where honesty about status lives. Green is what runs on every commit today; grey is designed, written down, and not yet live. The deploy half of this picture is still a plan, and the diagram says so instead of pretending.

The pipeline — what runs today, what is still planned
source
On every commit todayGitHub Actions · plannedGHCR · plannedThe server · plannedhusky · lint-staged · commitlintpnpm turbo lint · typecheck · test · buildtoken contrast check · WCAG AAcheck-propagations · spec consequencesthe same four tasks, redefined nowheredocker build · four imagesCaddy · TLScompose · web · api · dashboard · showcasePostgres push to masterpushcompose pullssh restart
# title: The pipeline — what runs today, what is still planned
direction: down

local: On every commit today {
  hooks: husky · lint-staged · commitlint
  gate: pnpm turbo lint · typecheck · test · build
  contrast: token contrast check · WCAG AA
  props: check-propagations · spec consequences
  hooks -> gate
  gate -> contrast
  gate -> props
}

ci: GitHub Actions · planned {
  tasks: the same four tasks, redefined nowhere
  image: docker build · four images
  tasks -> image
}

registry: GHCR · planned
server: The server · planned {
  caddy: Caddy · TLS
  stack: compose · web · api · dashboard · showcase
  db: Postgres
  caddy -> stack
  stack -> db
}

local -> ci: push to master
ci.image -> registry: push
registry -> server: compose pull
ci -> server: ssh restart

local.hooks.class: success
local.gate.class: success
local.contrast.class: success
local.props.class: success
ci.tasks.class: muted
ci.image.class: muted
registry.class: muted
server.caddy.class: muted
server.stack.class: muted
server.db.class: muted

Spec-driven development, with a ledger

exec-b1618656-7220-4324-8a79-f0414d634681.png

Spec-driven development, in this repo, means every question has exactly one file that answers it:

  • how a feature works lives in its spec — written before and while the feature is built;
  • what comes next lives in the task queue;
  • what is open right now lives in PROGRESS.md, as checkboxes ticked in the same commit that completes them.

The rule that earned its place the hard way: a consequence created by a spec change is filed as a checkbox with an id, never as prose. When the Move domain was dropped from the backend in July, the backend spec had frozen days earlier, and the fallout — tables to replace, dashboard screens to update — was written down as prose notes. The prose got ignored. Three times, on that one pivot, before the rule existed. Now a spec files each consequence under a Propagations heading as a PROP-id, mirrors it into PROGRESS.md, and a script fails the commit whenever a consequence exists only as words.

A list in a spec is a cache with no invalidation.

That is the other spec rule: a spec may define a rule, a criterion or a shape, but it never enumerates content values. An invented list of slugs sat in the web spec for a month, rotted, and got treated as truth. Content lives in the content source; the spec only points at it.

The review loop

Nothing the agents write gets merged unread. I read the diffs, every time — not because the code is usually wrong, but because the failure mode of agent code is plausibility: it looks like what a careful person would have written, which is exactly why it deserves a more careful read, not a lighter one. A dedicated reviewer agent runs before merges as a second pass with different incentives — it files findings, it does not fix them. Findings land as checkboxes in the logs that own them, not as chat that gets scrolled away.

The loop pays for itself in specifics. On July 22 a review pass before the commit caught two latent injection defects in freshly written code: a JSON-LD block serialized with a bare JSON.stringify, through which a closing script sequence could break out of its tag, and an RSS feed emitting raw titles — one ampersand away from invalid XML. Both sat on paths that would later carry database-sourced content. Cheap to fix before the commit; an incident once the CMS starts feeding those paths.

One design system, three frameworks

exec-77318f54-f773-42d6-add4-f5f2cd2ca84c.png

The design system goes through the same discipline, and it lives in React, Vue and Astro at once. @eakz/tokens is the single colour source: eight palettes, each in dark and light, and the token package’s own tests run an automated WCAG AA contrast check across all of them. A palette that fails cannot ship — not “should not”, cannot, because the failure is a red test. The first casualty was the planned default: obsidian’s light mode failed the check, the palette was scrapped, and on July 17 dracula took over as the identity palette.

Every primitive exists twice, React and Vue, verified against one behavioural contract down to keyboard interaction and ARIA roles. When a fix lands on one side it is back-ported to the other in the same task, because a divergence deferred is a divergence shipped.

And there is a bar no test can automate: the first version of the landing page was rejected outright for reading as AI slop — competent, generic, interchangeable with a thousand other sites. The work went back to the boards. Design passes through the same review loop as code, and “it renders” has never been the acceptance criterion.

What SDD actually taught me

Freeform prompting produces plausible code fast — and it drifts. Every session starts from zero: an agent opens some files, re-derives the architecture from whatever it happens to see, and the tenth session quietly disagrees with the second. For a demo that is fine. For a platform where four apps have to keep agreeing with each other about tokens, contracts and schemas, it fails slowly and then suddenly.

The spec plus the checkbox ledger costs more up front — noticeably more. Writing down how a feature works before building it feels slow, and maintaining PROGRESS.md feels like bureaucracy for a team of one. But it is the only thing that made this platform survive my own context resets. I am the bottleneck: I stop, sleep, switch tasks, and every new session — mine or an agent’s — starts by reading state instead of reconstructing it from chat history. The spec is the memory; the ledger is what keeps it current.

The honest cost: specs rot. A spec is not truth, it is a bet with a maintenance bill, and nothing invalidates it automatically — which is exactly why consequences became enforced checkboxes, and why lists of content values are banned from specs entirely. SDD did not remove the discipline problem; it moved it somewhere a script can see it.

Part 3 is what the method is for: the core is a finished product, and everything after it arrives as a module that plugs into the dashboard, runs on its own clock, and can be switched off without a release.

917dfce