The constitution asks which primitive a feature is. It never asked *where* the feature belongs, and that let a whole feature get built at the wrong layer. Four layers, outermost wins: content on disk, engine (facts only the engine can produce), theme (markup), browser (CSS, then JS). Two rules fall out — the engine never edits authored text to change how it looks, and a feature needing no engine fact is not an engine feature. architecture.md carries the table and the test; CLAUDE.md carries the one-line version, since it is the file always loaded. The example is named in the docs on purpose. A rule with a scar attached is one an agent can apply; a rule stated in the abstract gets reasoned around.
133 lines
8.4 KiB
Markdown
133 lines
8.4 KiB
Markdown
# 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.
|
|
|
|
## Invariants — violating one is a stop condition
|
|
|
|
1. **Open page object.** Known fields as struct members plus a `Meta`/`Extra` bag. 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.
|
|
2. **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.
|
|
3. **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.
|
|
4. **Request-time render behind a cache.** The server shape is the superset; static export is
|
|
cache-warming, never a separate code path.
|
|
5. **Permalinks are permanent.** Renames add aliases and redirects. URLs are not reused.
|
|
6. **Interactions are off the content graph.** A comment invalidates one fragment, never a build.
|
|
7. **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`).
|
|
8. **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).
|
|
9. **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_MAX` covers `cmd/` +
|
|
`internal/{content,render,web}`, `EXT_LOC_MAX` covers `internal/ext/`, reported separately by
|
|
`verify.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.
|