docs/content-model.md opens with "Engine specification". It is also where the rule lives that a leading underscore makes a file unaddressable — and the human who owns this site did not know that rule, because nothing in this repository is addressed to an author. Twelve documents named docs/ while being exclusively about building the parser is a signpost pointing at the wrong room. Naming the directory for its audience makes the gap visible instead of hiding it. docs/ is now reserved and deliberately absent: an empty docs/ is an honest statement that end-user documentation does not exist, where docs/ full of parser specs was a claim that it did. HARNESS.md stays at the root. Root holds the three entry points — README.md for a human, CLAUDE.md for an agent, HARNESS.md for whoever maintains the machine — and harness/README.md is the map of the directory, so moving the guide inside would have collided with it for nothing. Mechanical and wide: 100 path references across 24 files. Every verify.sh gate that names a doc by path, the directory lists the dangling-path and ADR-number gates scan, surface.sh's output target, the Makefile, CLAUDE.md's read order, the skill, four commands, and two Go package comments. A first pass with a shell loop silently edited only four files and the rest still said docs/; the fix was to write the file list out and check the remaining count was zero rather than trust the loop's exit status. No rule, threshold, gate or obligation moved — this is a rename, and the gates demonstrated it twice: they stayed green on the new paths, and the ADR-number gate caught ADR-0082 before the entry existed. Deferred, both on the human's call: the end-user documentation site itself, which wants its own decision about where it lives and whether its claims are gated; and moving examples/ under docs/, since demo-site is a live site root that verify.sh, the coverage test and make demo all point at, and moving it would couple a rename to a design nobody has made. 31 files, +146/-106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.9 KiB
Architecture
Six primitives. Every feature is a composition of them — never a special case, never a plugin slot bolted to the side. This file is the target the code follows.
STATUS says what is legal to build now — a permission, not a report. What actually exists is
state.md's job alone, so a STATUS line never needs editing as code lands.
request ──► resolver ──► Bundle ──► [Stage…] ──► View ──► bytes
│ ▲ │
Query ─────────┘ cache
Interaction ──► (own path, off the content graph)
change / clock / command ──► Effect ──► derived artifacts, outbound calls
Bundle — the atom
A content folder (or single file): metadata + one or more language bodies + local assets + attached interactions. Identity is the bundle; language is a variant of it. STATUS: buildable. Arc 1's spine: single-language, single-file bundles first, then language fallback.
Stage — a transform during rendering
func(ctx, *Page) error, in fixed order. Every rendering feature is a Stage: typography, Bengali
numerals, shortcodes, image sizing, dithering. A Stage runs on everything by default and is switched off
by a settings cascade key (ideas/deferred-decisions.md), never by a type predicate compiled into it; a Stage with nothing to
do returns early on content shape.
STATUS: deferred. Called inline in the render path until the transform counter in state.md
reaches its threshold. Check it; do not guess.
Query — filter/sort bundles into a list
Returns a list plus a signature doubling as a cache key. All grouping is a Query: sections, tags,
series, latest, related, pagination, feeds.
STATUS: live. Earned at the first collection page. content.Query{Section, Lang} with Site.Run;
it carries no cache signature yet, because nothing caches and a signature with no consumer is
speculation.
View — bundle-or-query → output
Overridable per bundle. All presentation. Emits HTML, gemtext, PDF, a program, JSON.
STATUS: deferred until Arc 2. Until then, templates chosen by a small explicit lookup. Its data
contract is frozen from the moment a theme exists, not at Arc 2 — see theme-contract.md (ADR-0023).
Interaction — external input attached to a bundle
Comments, webmentions, likes, via adapters. Off the content dependency graph: own fragment cache, one-page blast radius, hydrated client-side or spliced late. STATUS: not buildable yet. Arc 3. Nothing may assume it exists.
Effect — work outside the request path
Derived artifacts and outbound calls: image derivatives, search index, sitemap and feed files, webmention sending, POSSE, outbound-link archiving, EPUB builds, future-dated publication.
Three triggers, and one Effect may accept more than one: on change (a bundle changed), on schedule (a clock), on demand (a CLI subcommand). Every Effect is idempotent and re-runnable from scratch, never writes into the site root — that is the author's data (ADR-0011) — logs and retries on failure rather than dying, and leaves the engine correct if it has never run at all: a missing derivative serves the original, a missing index disables search (invariant 8).
Not an Effect: anything computable at render time from the page plus the clock. An "this article is
old" banner is a Stage reading date — right on every request, no job, no staleness, nothing
written. Inbound reactions are Interactions. If an Effect would need to mutate content to be
visible, it is a Stage wearing a disguise.
STATUS: not buildable yet. Earned at the first derived artifact; check the Effects counter in
state.md. Scheduling is an in-process ticker inside the single binary — no cron container and no
queue until ADR-0010's second-service test is actually met.
Routing = URL → (Bundle, View), via a resolver. STATUS: live. The resolver was earned at the second routing case — the default locale at the root, every other language under a prefix — and now owns URL shape so the mux has one entry.
Layers — build at the outermost one that can do the job
A primitive says what a feature is. This says where it belongs, and it is the question that catches a feature nothing else does (ADR-0045).
| Layer | Owns | Test |
|---|---|---|
| Content | bytes an author wrote | Would a reader's own words change? Then only the author may touch it |
| Engine | facts only the engine can produce — which bundles exist, an image's size, a derivative's name, a permalink | Could anything downstream work this out for itself? If yes, it does not belong here |
| Theme | markup and which fields go where | Is the answer "how it looks"? Then it is a template, not Go |
| Browser | line breaking, spacing, colour, motion — CSS, and only then JS | Does the browser already know something the engine cannot, like the line box? Then leave it there |
Two rules fall out. The engine never edits authored text to change how it looks: it may transform text
(-- into an en dash is a transformation no stylesheet can express) but never lay it out. And a feature
that needs no engine fact is not an engine feature — it is a stylesheet, a template, or nothing.
The failure this exists to prevent: widow prevention was built as a Markdown transform that inserted a
non-breaking space into an author's prose. It worked, it had tests, and it was wrong at the layer —
text-wrap: pretty does it better with no bytes in the content, and the engine could never see the line box
it was guessing about.
Every feature built so far has been audited against this, with the verdicts in ADR-0046. Two patterns came out of it worth reusing. Offer the shape, do not choose it: templates cannot group, so the engine supplies a partitioned listing — but it supplies the flat one too, because which to show is markup. And a split feature is normal: search will be an engine-built index and a browser-side query, not one or the other.
Invariants — violating one is a stop condition
- Open page object. Known fields as struct members plus a
Meta/Extrabag. Absence equals zero value; templates read what exists and never crash on a missing field. Never add a required field. Never make a template failure fatal at request time. - Trusted / untrusted pipeline modes. Content from the site root is trusted; everything else is not, and never reaches shortcode or template evaluation. This is the RCE boundary — the one place where an extra check beats an elegant unification.
- Identity is not language. The bundle key excludes the language suffix, which is optional for the default locale (ADR-0021). Identity stays stable across translations.
- Request-time render behind a cache. The server shape is the superset; static export is cache-warming, never a separate code path.
- Permalinks are permanent. Renames add aliases and redirects. URLs are not reused.
- Interactions are off the content graph. A comment invalidates one fragment, never a build.
- Every feature is a leaf — a View, Stage, Query, Interaction adapter, Effect, or
bundle-as-program, deletable without trauma. Mechanical since ADR-0069:
verify.shallowsinternal/ext/…to be imported fromcmd/and nowhere else, siblings included. Anything wanting a permanent service or a core-model change is a trunk and waits for a human decision (ideas/exploration.md). - The engine serves correctly with every external service off. Redis, an index, object storage — each is a shortcut around work the engine can still do itself, slowly. Anything that cannot degrade that way holds canonical state and does not belong there (ADR-0010).
- Core stops growing after Arc 2. Post-freeze, features arrive by composition only; core growth
is a bug in the plan. Measured, not asserted:
CORE_LOC_MAXcoverscmd/+internal/{content,render,web},EXT_LOC_MAXcoversinternal/ext/, reported separately byverify.sh. An invariant nothing measures is decoration.
Re-render and invalidation (target shape, not built)
A cached entry needs more than a content-dependency key: time, an engine/template epoch, and an opt-out
all invalidate too. The shape is recorded as intent in ideas/deferred-decisions.md; the cache that implements it decides the fields.
Two things hold regardless. Stages reach the clock only through an injected accessor, so a time-dependent render can never silently outlive its truth. And asset derivatives are content-addressed and therefore immutable — never invalidated, far-future cacheable, a different lifetime from page renders. Defer the inverted index: v1 re-renders everything, correct until it is slow.
Dropped on purpose
Guest authors. The trust model is a clean binary — site-root content trusted, everything else not — and multi-author would smear it. Comments remain the only untrusted path.