Files
khosra/docs/architecture.md
T
bdeshi 60a7e10aee add paginated section index pages
The first collection page earns the Query primitive: content.Query{Section, Lang}
with Site.Run, newest first, undated after dated, ties broken by key so the same
query always answers in the same order. No cache signature — nothing caches, and a
signature with no consumer is speculation.

Pagination lives in the path (ADR-0028): page one is the bare listing URL,
/page/1/ redirects to it, and a page past the end is 404 rather than an empty page,
because an empty page is a URL that means nothing. `page` is therefore a reserved
segment inside a section, now recorded in content-model.md.

Two kinds of page means two parsed template sets already — base plus the block that
kind defines — which is ADR-0019's per-type shape arriving by need rather than by
anticipation. A head struct is embedded in both Page and List so base.html has one
contract, and theme-contract.md gains the listing fields.

Bundle gains Date, accepting an unquoted YAML date or an RFC 3339 string, since
yaml.v3 hands back time.Time for one and a string for the other.

Evidence: 12 posts → /posts/ shows 10 with rel=next to /posts/page/2/,
/posts/page/2/ shows 3 with rel=prev to /posts/, ordering is post-12 11 10,
/posts/page/1/ 301s to /posts/, /posts/page/9/ is 404, /bn/posts/ is 200.
2026-08-01 02:23:34 +06:00

6.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.


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.