Twenty-seven ADRs existed before a line of Go. Five specified the shape of unbuilt mechanisms — cache validity, declared types, the settings cascade, taxonomies, extras — which breaks the rule against abstraction before a second concrete use, in prose where the counters cannot see it. They move to ideas/deferred-decisions.md as recorded intent and return shaped by whatever implements them. Citations retargeted throughout; where one was decoration the rule now stands on its own reasoning. Type declarations and the cascade drop to [spec] with the MVP behaviour stated instead, so the first prompts have less to build. conventions.md names http.ServeMux as the router, closing a hole that invited hand-rolling a path splitter. The ADR gate now checks a number is registered in the log rather than headed by an entry, so withdrawals resolve and invented numbers still fail. Two architecture invariants corrected: identity no longer implies a required language suffix, and the duplicated permalink clause is gone.
6.7 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: deferred until the first collection page. One-off directory reads are fine before then.
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: buildable, inline. Keep cases inline;
extract the resolver when the routing counter in state.md is due, not before.
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. Anything wanting a permanent service or a core-model
change is a trunk and waits for a human decision (
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.