Skip to content
Building The Frank Papers — Research Infrastructure for a Third Series
Building The Frank Papers — Research Infrastructure for a Third Series

Building The Frank Papers — Research Infrastructure for a Third Series

The cluster has two voices already. The Building series answers how. The Operating series answers how to run. Neither answers why this and not the other twelve options.

The Frank Papers are research-grade landscape reviews. Each paper maps the vendor space for one capability, grades the options, then returns to Frank’s choice as a worked case study — honest about where that choice would not generalize. Every paper carries a ≤150-word TL;DR.

This post is about the infrastructure that makes a paper legal to ship. Phase 0 produces no published content — it produces the toolchain.

Why a Third Series

Building and Operating both center Frank. The narrative arc is “I tried this, here is what broke.”

That voice is wrong for a decision-maker weighing Authentik against Keycloak. They want the landscape and trade-offs that hold across orgs. Frank’s experience is one data point.

  • Building — first person, narrative. ~3000 words.
  • Operating — imperative, reference. ~1500 words.
  • Papers — third person, analytical, with a worked-example coda. ~5000 words plus TL;DR.

The Pipeline, and Who Actually Enforces It

Eight steps get a paper from nothing to published. Exactly two of them are enforced by a script, plus one consistency check that runs alongside. The rest are me — including one the rule file calls a “gate”. Doubled boxes are machine-checked in CI; plain boxes are a human deciding they are done.

    flowchart TD
  S["1. Scaffold<br/>scaffold-paper.sh"]
  F["2. Fill the dossier<br/>5 YAML sections"]
  G[["3. Dossier gate<br/>validate_dossier.py, 7 thresholds"]]
  R["4. Mark the dossier ready<br/>status: ready, unenforced"]
  D["5. Draft the paper<br/>TL;DR + sections 1 to 7"]
  M["6. Media<br/>cover image + diagrams"]
  P[["7. Frontmatter gate<br/>validate_papers.py"]]
  PUB["8. Publish<br/>draft: false"]
  SY[["Dossier to data sync<br/>sync_dossier_to_data.py --check"]]

  S --> F --> G --> R --> D --> M --> P --> PUB
  F -.-> SY
  SY -.-> D
  

Step 4 is the interesting one. agents/rules/repo-papers.md calls it a human gate, and it is human all the way down: blog/scripts/dossier_parser.py strips the frontmatter block before parsing, so no validator ever sees status: on a dossier. The scaffolded dossier does not even have a frontmatter block to put it in. It is a convention I keep, not a thing the repo can check — and calling it a gate in the rule file makes it sound like the second one.

The Dossier Gate

The load-bearing piece of Phase 0 is the one gate that is real. blog/scripts/validate_dossier.py reads the dossier’s ## sections as YAML and checks seven thresholds pulled from content_types.papers.gate in .blog-craft.yaml — nothing is hardcoded in the script. A freshly scaffolded dossier, one TODO entry per section, fails exactly as intended:

$ python3 blog/scripts/validate_dossier.py --config .blog-craft.yaml \
    docs/papers-dossiers/04-gpu-operators/dossier.md
DOSSIER GATE FAILED: docs/papers-dossiers/04-gpu-operators/dossier.md
  x vendors: need >=3, got 1
  x primary_sources: need >=5, got 1
  x primary_sources: need >=3 distinct types, got 1
  x artefacts: need >=3, got 1
  x artefacts: need >=2 distinct kinds, got 1

Filled in, it says DOSSIER GATE PASSED and exits 0. The thresholds:

ThresholdValueWhat it forces
min_vendors3No one-vendor review.
min_sources5A bibliography, not a link.
min_source_types3Not five pages of the same vendor’s docs.
min_artefacts3Claims tied to operational evidence.
min_artefact_kinds2Not three screenshots of one dashboard.
min_gaps1The question I could not answer.
min_counterargs1The opposing view, named.

The two “distinct” thresholds are the ones that bite. Five sources are easy if they can all be vendor docs; five sources across three of {vendor-docs, paper, postmortem, talk, benchmark} means going and finding a postmortem. Same for artefact kinds — a yaml and an incident are a different claim from two yamls.

The last two rows are why the gate exists at all. The structure forces naming the opposing view at dossier time, before the paper’s argument has hardened around a conclusion.

The dossier is a structured Markdown file whose five ## sections the validator locates by token, so ## Frank artefacts (>=3) and ## Artefacts both resolve. This is what the scaffold writes:

## Vendors in scope (>=3)
- {name: "TODO", positioning: "TODO one-line claim", primary_url: "https://TODO"}

## Primary sources (>=5, >=3 distinct type values)
- {title: "TODO", type: vendor-docs, url: "https://TODO", relevance: "one sentence"}

## Artefacts (>=3, >=2 distinct kind values)
- {kind: yaml, path_or_url: "TODO", date: "2026-08-03", demonstrates: "one sentence"}

## Named gaps (>=1)
- "TODO: gap description"

## Counter-arguments considered (>=1)
- "TODO: counter-argument"

What enforces it. Phase 0 built the gate as a .githooks/pre-commit hook. What enforces it today is CI: .github/workflows/blog-ci.yml syncs the dossier data, then runs the dossier gate over every docs/papers-dossiers/*/dossier.md and the frontmatter validator over every paper. The hook did not survive a later repo reorganisation — see Missteps. The two consequences the gate was built for survive either way: the dossier ships with the paper on the same SHA, and an agent dispatched to research has something to push against.

The Scaffold Script

$ blog/scripts/scaffold-paper.sh --config .blog-craft.yaml 12 gpu-operators
Scaffolded paper 12:
  bundle:  content/docs/papers/12-gpu-operators/
  dossier: docs/papers-dossiers/12-gpu-operators/dossier.md

--config is mandatory, not optional decoration: the script reads weight_offset, dossier_dir and the papers series key out of .blog-craft.yaml rather than hardcoding them. That is also where the weight = paper_number + 1 offset comes from — Paper 12 scaffolds with weight: 13, because Hugo sorts weight: 0 last and a Paper 00 would otherwise land at the bottom of the sidebar. Pick a number nothing has claimed. The script refuses to overwrite an existing NN-slug directory, but it compares the whole directory name — a second paper numbered 04 under a different slug sails through and lands a duplicate weight: 5 in the sidebar.

One sharp edge: the script resolves its output root from the directory holding the config, but only the dossier path goes through dossier_dir. The bundle is written to <root>/content/docs/papers/, and Frank keeps .blog-craft.yaml at the repo root with the Hugo site one level down in blog/ — so the bundle lands beside the site rather than inside it, and has to be moved into blog/content/docs/papers/. The printed bundle: line is honest about where it went; it just is not where a Frank paper lives.

Both files land with section skeletons. The Hugo index.md gets complete frontmatter, a TL;DR block, and the §1§7 outline with each section’s word budget and expected diagram type written in as an italic note. The dossier gets its five section headers with one TODO entry each — the exact input that produced the gate failure above.

Hugo Foundation

Three taxonomies, one nav entry:

[taxonomies]
  series = "series"
  capabilities = "capabilities"
  references = "references"

[[menu.main]]
  identifier = "papers"
  name = "Papers"
  pageRef = "/docs/papers"
  weight = 3

series is what the backlink partial queries. capabilities gives “show me all Papers tagged auth” navigation. references collects bibliography entries.

Visual System

Phase 0 gave Papers their own look by scoping everything to a single class. The Mermaid init it shipped keyed the diagram palette to Frank’s colours, on Papers pages only:

mermaid.initialize({
  theme: 'base',
  themeVariables: {
    primaryColor: '#1f2937',
    primaryTextColor: '#f3f4f6',
    primaryBorderColor: '#0d9488',
    lineColor: '#fb923c',
  }
});

The palette itself did not survive: a later Content Security PolicyA response header telling the browser which sources of script, style and images to trust. Frank's blog serves `script-src 'self'`, which is why inline scripts had to be replaced rather than tolerated. change replaced the site’s whole Mermaid bootstrap with an external, theme-following one that applies to every series. The scoping mechanism did, and it is the part worth copying. blog/layouts/docs/single.html appends a paper-post class to the article element when in .Params.series "papers", and every Papers-specific rule in custom.css hangs off .paper-post. Building and Operating posts inherit nothing, and no rule anywhere has to know which pages are Papers.

Six Shortcodes

ShortcodeWhere
pullquote§3 architecture comparison
scar§4 operational evidence
capability-matrix§2 vendor landscape — feature grid
landscape§2 vendor landscape — Mermaid quadrantChart
dossier-linksection header — dossier chip
references-index§8 bibliography — reads the synced dossier data

All six are registered in content_types.papers.shortcodes and live in blog/layouts/shortcodes/papers/. references-index is the one that closes the loop on the dossier: the sync script writes each dossier’s primary sources to blog/data/papers/<slug>.yaml, and the shortcode renders them as the paper’s bibliography. The sources you had to find to pass the gate become the sources the reader sees.

landscape wraps a Mermaid quadrantChart:

{{< papers/landscape
  title="Auth landscape — late 2025"
  axes="complexity:openness"
  q1="self-host friendly"
  q2="cloud-first incumbents"
  q3="walled gardens"
  q4="DIY territory"
  vendors="Authentik: [0.35, 0.85]\nKeycloak: [0.7, 0.8]\nAuth0: [0.85, 0.15]"
>}}

Cross-Series Linking — Zero Retrofit Writes

29 Building posts and 24 Operating posts already exist. Retrofitting links onto each would be a write-multiplier nightmare. The linking is single-sourced from the Paper’s frontmatter:

related_building: "docs/building/10-local-inference"
related_operating: "docs/operating/07-inference"

Two partials read these keys:

  • papers-forwardlinks.html — on Papers pages, renders chips to related Building/Operating posts.
  • papers-backlink.html — on non-Papers pages, iterates all Papers, matches by path, renders a chip.

Wired into single.html. Existing index.md files are untouched forever. Adding a new Paper is a one-line frontmatter add; Hugo picks up the backlink at render time.

Banner Images

Series banner needed three iterations: first pass Frank in green shirt (blended with skin), second pass fixed composition but still green-on-green, third pass explicit “Frank in white dress shirt” — that is the production version.

Per-series reference images live under .reference-pool/papers/ (refactored from a single shared reference in PR #380).

Agent Docs

The Papers workflow runs end-to-end through agents. Two files make that possible:

  • agents/rules/repo-papers.md — lifecycle, frontmatter schema, dossier format, diagram-types-by-section table. Always loaded, so an agent working in the repo has the conventions without being told.
  • The papers skill, invoked as /blog-craft:papers — scaffolds the bundle and dossier and walks the lifecycle. It is not a repo-local skill: it ships in the external blog-craft plugin alongside /blog-craft:blog-post and /blog-craft:media, which is why the same workflow works on blogs that are not Frank.

What Got Reverted

Paper 00 (prologue) landed three commits depending on phase-0 surface that had not merged yet — validate-dossier.py and cover-image tooling did not exist on main. 3cd2f78 reverts cleanly. Lesson: a paper cannot depend on infrastructure not yet on main.

Missteps

What HappenedWhy It Was WrongHow We Fixed ItCommit
Paper 00 landed before Phase 0 — pre-commit hook tried to run validator that did not existTwo branches open simultaneously; merge order followed writing energy, not dependency arrowReverted Paper 00 phases 1-3; re-landed after Phase 0 completed3cd2f78
Banner image Frank’s shirt matched his skin — the model left the shirt green, the same hue as Frank’s green skin, so the thin black necktie read as floating in front of his torsoPrompt did not pin the shirt colour; Gemini defaulted to green, and green-on-green erases the garment rather than the figurePinned it in the prompt: “WHITE BUTTON-DOWN DRESS SHIRT (clearly white, not green; the shirt fabric must contrast visibly with his green skin)”, then regenerateda6a83cb
Dossier link rendered twice — both inline shortcode and automatic footer injection firedsingle.html auto-injects dossier chip; shortcode in body adds a secondDocumented gotcha: use either the shortcode or the auto-injection, not both
Spec referenced old file paths.claude/rules/ and .claude/skills/ moved to agents/Blog refactored between spec (April) and Phase 0 (May)Translated paths during implementation
The pre-commit hook outlived its validators — the blog-craft cutover moved the papers validators to blog/scripts/ with underscored names, leaving .githooks/pre-commit calling scripts/validate-dossier.py, scripts/validate-papers.py and scripts/sync-dossier-to-data.py, none of which existThe gate had already moved to CI, so nothing failed and nothing noticed. A gate you believe in but that cannot run is worse than no gate — it buys the confidence without doing the workNot fixed here, recorded: CI is the enforcement of recordbd0415e6

Recovery Path

SymptomCauseFix
CI fails DOSSIER GATE FAILED on a paper with no dossierPaper committed without a matching docs/papers-dossiers/<NN-slug>/dossier.mdRun blog/scripts/scaffold-paper.sh --config .blog-craft.yaml <NN> <slug> and fill the five sections
x vendors: need >=3, got 1Only one or two vendors in scopeAdd entries to the dossier’s Vendors in scope section
x primary_sources: need >=3 distinct types, got 1Five sources, all vendor-docsFind a postmortem, a talk or a benchmark — the count is satisfied, the spread is not
CI fails weight on a new paperweight was set to paper_number, not paper_number + 1Hugo sorts weight: 0 last; the offset is content_types.papers.weight_offset
§8 bibliography renders emptyblog/data/papers/<slug>.yaml is missing or staleRun blog/scripts/sync_dossier_to_data.py --config .blog-craft.yaml; CI catches it with --check
Papers-only CSS not applyingPage is missing the paper-post classCheck blog/layouts/docs/single.html — the class is added when in .Params.series "papers", so series must contain papers
Backlink chips not appearing on Building postsPaper’s related_building path does not match post pathVerify path in Paper frontmatter matches the actual file path

References

Next: Edge Observability — Watching Frank’s Edge Without Watching Frank’s Edge Burn