← All work

Production win

A diagram that is expensive to update is a diagram that lies

7 drift signals · 0 model calls to detect · 1 headless run to regenerate · 61 commits of drift caught

AI Workflows

Before and after

Thirteen days apart

The launch-day architecture diagram of the AI Shorts pipeline, hand-drawn: a 06:57 launchd trigger, a runbook-driven Claude Opus run, and three uploaders.

17 August. Drawn by hand, 700 lines, unchanged through 61 commits.

The generated architecture diagram: two launchd jobs, the twelve steps of the morning run with their subagents, Postgres as the source of truth, the on-demand model tools, the posting hub, and the three publishes that leave the machine.

30 August. Generated from tables, checked against the repo, versioned with a reason.

The architecture diagram for the AI Shorts pipeline was drawn on launch day, 17 August, as 700 lines of hand-placed SVG. It was a good drawing. It was also the last time anyone touched it. Between 17 and 30 August the repo took 61 commits: the file-based state moved into Postgres, the dashboard grew a posting hub, a visual director and a reel decision loop, and the morning run learned to delegate its mechanical steps to six tiered subagents. The picture kept describing three uploaders and a 170-line runbook. The copy of it on this site, on the AI Shorts Autopilot page, was stale from the morning it went up.

Nobody updated it for the usual reason. Adding one box meant an afternoon of nudging rectangles so the arrows still met, so every time the pipeline changed, the diagram was the thing that could wait. That is not a discipline problem. It is a tooling problem: the diagram made truth expensive, and expensive truth loses to cheap silence every time.

The first fix was to stop drawing and start generating. One script holds the content in tables at the top of the file: the twelve steps of the morning run and which subagent each delegates to, the dashboard views, the Postgres tables grouped by owner, the user-triggered model tools, the outbound calls, the principles. Everything below the tables is layout, and it reflows around whatever the tables contain. A section's height derives from its line count, a chip row wraps, a long note wraps to a pixel width. The rule written into the file is that if you find yourself typing a magic y-coordinate into a content table, you add a layout helper instead. A new pipeline step is now one line. The generated SVG is committed, and nobody edits it.

The drawing also follows conventions a reader should never have to guess at. Colour and pattern carry meaning together, never colour alone. Every arrow is single-ended and points from caller to dependency. Every box and every edge is labelled. The trust boundary is explicit, and only the things that genuinely cross it are drawn crossing it. The version and the command that regenerates it are stamped in the header.

A generator on its own only makes updating cheap. It does not make anyone remember to update. So the second fix is a checker, and the whole design question was what it should be allowed to know. It compares seven things the repo contains against the content export of the generator: the step headings in the daily runbook, the files in the agents directory, the views in the dashboard, the tables in information_schema, every prompt file, every uploader, and the two launchd plists. It is a set difference. It reads the repo directly, spends nothing, and follows the Terraform convention on exit codes: 0 in sync, 2 drifted, 1 a real error, so a caller can branch without parsing anything.

There is deliberately no model in the detection path. A set difference is not a judgement call. A model there would make the answer slow, expensive and different each time, which would make the cheap "is this still true?" button in the dashboard impossible. The model belongs in the one place the work genuinely is writing English: regeneration hands the drift report to a headless Opus run that may edit the content tables and nothing else, then re-runs the generator and re-runs the check rather than taking the model's word for it. A dry-run flag writes the assembled prompt and stops, and the raw output is kept either way, so a bad parse leaves something to read.

The part I got wrong the first time was noise. The first cut of the checker mapped each prompt file to its owning script by grepping whole files for the prompt's name, and it confidently reported three stale claims that were fine, because the checker's own comments mention the other tools' prompts. A checker that produces false positives is worse than none: people stop reading it, and then it hides exactly the drift it exists to find. So stale was narrowed to one meaning. A claim is stale only when the file or table it names is genuinely gone. Everything else, including deliberate omissions like the migration bookkeeping table, goes into a baseline file that requires a written reason and a date per entry, prints how many entries it is ignoring on every run so the list can never go invisible, and is meant to shrink. Only the ignore subcommand may append to it. Regenerating it in bulk to make a check go green is the one thing the file's own header forbids.

The first end-to-end regeneration found a mistake I had not seen. The Postgres band still said 41 tables after the versions table had been added, and the run fixed it. It also correctly refused to fix a second one, a "ten views" label sitting over eleven chips, because that string lived in layout code and layout was out of scope. Flagging it instead of editing it pointed at the actual defect: both numbers were hand-typed prose, so both could go stale in silence. The view count now derives from the length of the views table. The table count cannot derive, because the generator never queries the database (that is what makes a render reproducible offline), so it became a named constant in the content tables that the checker compares against the schema. I verified it by putting 41 back and watching the check go red.

The third fix is history. The SVG is committed, so git is already the archive and every past version renders from git show. What git cannot say is why. A diff of a generated SVG is 400 lines of moved coordinates, because adding one box reflows the layout and moves everything a little. So a versions table holds one row per regeneration: the drift that prompted it, a one-line summary, the git sha once it is committed, and no SVG bytes. The dashboard's Diagram view joins the two, pictures from git and reasons from Postgres, and lets you pin any two revisions to compare side by side, with a swipe, as an onion-skin cross-fade, or as a difference blend. Onion is the default, because a cross-fade is exactly what shows a re-flowed layout and two separate pictures do not.

Two bugs came from running it rather than reading it. The generator module was cached, so a long-lived dashboard answered every check after a regeneration from the content tables as they were at boot. And the history rail labelled uncommitted bytes with HEAD's sha and subject, which is a small lie of exactly the kind the whole feature exists to prevent.

What I would claim is narrow. Documentation drifts because the tooling makes truth expensive, and the fix is not a reminder or a review checklist. It is making the true version cheaper to produce than the stale one, and making the gap visible without anyone having to go looking. As I write this the checker reports one thing missing: the nightly engagement brief, added last week. It is right, and folding it in is one line and one run.

What I would not claim: the seven signals are the ones that were cheap to make unambiguous, not the ones that matter most. The check knows that a table exists, not what the drawing says about it. Prose claims inside the diagram, like "retry once, never double-post", are only as true as the last regeneration, and nothing checks them. And the checker runs when someone opens the Diagram view, not on every commit; wiring it into the morning run as a report, never as a gate, is the obvious next step.

The decision

Generate the drawing from content tables, detect drift with a deterministic set difference that has no model in it, and spend the one model call only on regeneration, where the work is writing English. Keep the pictures in git and the reasons in Postgres.

Alternatives considered

  • ·Keep hand-drawing and add a reminder. The reminder would have fired into the same afternoon of rectangle-nudging that stopped the updates in the first place.
  • ·Derive the diagram straight from the code with a diagrams-as-code tool. The pipeline's shape lives in a runbook prompt, two launchd plists, a schema and a dashboard, not in one dependency graph a tool can walk, and half of what the drawing says is editorial.
  • ·Let a model read the repo and redraw the whole thing on demand. Slow, expensive and different every time, which rules out a cheap 'is this still true?' button, and a model will happily explain away a stale box rather than report it.

Tech stack

TypeScriptSVGPostgreSQLClaude OpusRemotionReactHonogit

The challenge

An architecture diagram drawn by hand on launch day, unchanged through 61 commits and a move from files to Postgres, still shipped on this site as the picture of the system. Make the diagram cheap to update, make it say when it has gone stale without anyone remembering to look, and keep the reason each version changed.

Architecture approach

  • ·Moved every fact in the drawing into tables at the top of one generator (steps, subagents, views, tables by owner, on-demand tools, outbound calls, principles) and made every height, wrap and chip row derive from them. The rule in the file: a magic y-coordinate in a content table means you needed a layout helper.
  • ·Built the checker as a set difference over seven signals: runbook step headings, agent files, dashboard views, information_schema tables, prompt files, uploaders and launchd plists. It reads the repo directly, spends nothing, and uses Terraform's exit codes (0 in sync, 2 drifted, 1 error) so a caller can branch without parsing.
  • ·Narrowed 'stale' to one meaning after the first cut reported three false positives: a claim is stale only when the file or table it names is genuinely gone. Deliberate omissions go in a baseline that needs a written reason per entry, prints its count on every run, and is meant to shrink.
  • ·Kept the model out of detection and inside regeneration only: a headless Opus run may edit the content tables and nothing else, and the generator and checker are re-run afterwards instead of trusting the model's word. Dry-run writes the assembled prompt and stops; raw output is kept either way.
  • ·Made the two hand-typed counts impossible to drift: the view count derives from the table, and the table count is a named constant the checker compares against the schema, because the generator deliberately never queries the database.
  • ·Put the whole loop in the dashboard: check, ignore, regenerate, and an evolution rail that joins git's pictures with Postgres's reasons, with side-by-side, swipe, onion-skin and difference compare.

Results

  • 700 hand-placed lines became a 580-line generator whose output nobody edits; a new pipeline step is one line
  • Seven drift signals, zero model calls to detect, exit 2 on drift; the check is a button in the dashboard
  • The first regeneration found a wrong table count (41 against 42) that two humans had read past
  • Three false positives in the first checker replaced with a definition of stale that cannot be ambiguous
  • Four committed revisions and two recorded regenerations, browsable with onion-skin compare

What I'd do differently at production scale

  • ·The check runs when someone opens the Diagram view, not on every commit. The next step is running it in the morning run as a report, never as a gate, because an unattended run must degrade rather than stop.
  • ·The seven signals are the ones that were cheap to make unambiguous, not the ones that matter most. The checker knows a table exists; it does not check what the drawing says about it. Prose claims like 'retry once, never double-post' are only as true as the last regeneration.
  • ·The ignore baseline is meant to shrink and nothing forces it to. A periodic prune, or an age on each entry, would stop it becoming a second place for drift to hide.
  • ·The generator module was cached, so a long-lived dashboard answered every check after a regeneration from the tables as they were at boot. Found by running it, not reading it; the fix is a cache-busting import.

References

AboutEMpathWritingProductionConnect