ADR-12 should always mean the same decision. That’s the kind of statement that sounds obvious until you watch it break.
The decision-log template in timbers generates Architecture Decision Records — numbered, appended to a single file, meant to be referenced from other documents, commit messages, design reviews. “See ADR-12” only works if ADR-12 is still ADR-12 the next time you regenerate. And with the old approach — hardcoded restart-at-1 — every fresh render cheerfully renumbered everything from scratch. External references rotted silently. Nobody gets an error. They just get the wrong decision.
The fix seems straightforward: let the caller say “start numbering at N.” But the interesting part was figuring out where that knowledge should live.
The tempting move was baking a --continue-from <file> flag directly into draft. Scan the target file, find the highest ADR-N, increment, done. Except that’s decision-log-specific parsing logic jammed into a generic rendering command. It’s the kind of convenience that turns a tool into an opinion. The next template with a numbering need would want a different scanner, and suddenly draft is in the business of understanding every template’s domain. No thanks.
The other temptation: store the counter in .timbers/state.json. Persistent, automatic, no user effort. Also: two sources of truth. The output file is already version-controlled markdown — it is the durable record. A separate counter can drift, and regenerating the file orphans the state. One source of truth beats two, even if the one requires a grep.
So the architecture landed here: a new --var key=value flag on draft, repeatable, parsed into a map[string]string, namespaced under {{vars.*}} in templates. The decision-log template declares starting_number with a default of 1 in its own frontmatter. The just decision-log recipe does the domain-specific work — greps the max ADR-N from the target file, increments, passes --var starting_number=N. The generic tool stays generic. The recipe owns the sugar.
Namespacing under {{vars.*}} was deliberate. Built-in template tokens like date are sacred — callers shouldn’t be able to accidentally shadow them with a --var date=oops. Unknown keys remain as literal text, matching existing token behavior, so a typo in --var produces a visible {{vars.typo}} in output rather than silent swallowing. Fail loud or don’t fail at all.
Per-template defaults in frontmatter was a small decision with outsized leverage. Rather than hardcoding fallback values in render.go (coupling the render package to specific template variables), each template declares its own defaults. Caller-supplied --var values win; template defaults fill the gaps. This means new templates can introduce new variables without touching the render engine. Configuration lives with the thing being configured — a principle that’s easy to state and surprisingly easy to violate when you’re in a hurry.
With the variable plumbing in place, hardening came next. The original parseVars had a silent last-wins policy on duplicate keys — fine for interactive use, a footgun in scripts where two --var starting_number= flags might sneak in through variable expansion. A duplicate-key guard with table-driven tests closed that gap. Separately, the just decision-log pipeline had a || true outside the $() that interacted poorly with pipefail — empty-file and no-match cases were ambiguous. Moving the fallback inside the pipeline as || echo "" made both cases explicit.
The interesting pattern here: the right place for domain logic is the outermost layer that understands the domain.
draftunderstands rendering. The justfile recipe understands decision logs. Mixing those up produces a tool that’s convenient for one workflow and hostile to every other.
This all shipped as v0.17.0 — a minor bump reflecting new user-visible capability rather than a breaking change. The --var flag exists for any template now, not just decision logs. ADR numbering is just the first consumer, and probably the most satisfying one, because stable identifiers are the kind of thing you only appreciate after they’ve already broken.
Written with AI assistance.