The human asked for a demo extensive enough to review by hand, tracked as files, and kept current the way docs are. Generated filler cannot be reviewed — you cannot read a Go function and see what a reader sees — so the generator is deleted and `examples/demo-site/` is an ordinary site: 33 Markdown files, six pictures, site.yaml, a template override and static files. My reason for generating it was ADR-0011, and I had that rule wrong: it puts the *author's site root* outside this repository, not fixtures. conventions.md already keeps golden files in testdata/, and this is the same category one size up. Keeping both a generator and files would have been two sources of truth, so one had to go. Kept true by two gates rather than by good intentions. A table-driven test in internal/web serves the directory through the real handler with one case per feature — thirty-five of them, each naming what it proves — and verify.sh runs `khosra check` over it and fails on anything fatal. Adding a feature now means adding it to the demo and adding its case, and the build says so if you don't. Extensive on purpose: eleven dated posts so a section actually paginates, a four-chapter series so first/last are not the neighbours, a gallery with three JPEGs and an SVG so both the resampled and the untouched paths show, a Bengali-only bundle, a titleless status, a draft, a future date, an include, a nested extras tree, and a typography page that demonstrates what the engine will not do. Two expectations of mine were wrong and the demo corrected them: the site's own list template overrides *tag* listings too, so there are no group headings there — which turns out to be the better demonstration of ADR-0046, since the theme picking the flat shape is exactly the point. And template literal text is not escaped, so an apostrophe stays an apostrophe.
773 lines
63 KiB
Markdown
773 lines
63 KiB
Markdown
# Decisions (ADR log)
|
||
|
||
Append-only. Never rewrite history; supersede with a new entry. Six lines each.
|
||
|
||
Withdrawn: ADR-0013, ADR-0014, ADR-0017, ADR-0018, ADR-0025 — recorded pre-code, now intent in
|
||
`ideas/deferred-decisions.md`.
|
||
|
||
Numbers are never reused. A gap means an entry was withdrawn to `ideas/deferred-decisions.md` — recorded
|
||
before any code existed, so its shape was speculation rather than a decision. Do not add a pre-code ADR
|
||
for a mechanism nothing implements; write it as an idea and let the implementation decide the shape.
|
||
|
||
```
|
||
## ADR-NNNN — Title
|
||
Date · Status: accepted | proposed | superseded by ADR-NNNN
|
||
Decision: one sentence, imperative.
|
||
Why: the forcing reason, not the full debate.
|
||
Consequence: what this makes cheap, what it makes expensive.
|
||
Revisit if: the specific observation that would overturn it.
|
||
```
|
||
|
||
---
|
||
|
||
## ADR-0001 — Rendering is an ordered pipeline of stage functions
|
||
Date: front-loaded · Status: accepted (target shape; see counters before building)
|
||
Decision: rendering is a sequence of `(ctx, page) → page` transforms, not a monolith.
|
||
Why: every rendering feature must be addable and deletable without touching a renderer core.
|
||
Consequence: cheap to add typography, shortcodes, localisation, dithering; requires discipline about stage ordering.
|
||
Revisit if: ordering constraints between stages become a graph rather than a list.
|
||
|
||
## ADR-0002 — The page object is open
|
||
Date: front-loaded · Status: accepted
|
||
Decision: known fields as struct members plus a `Meta`/`Extra` bag; absence equals zero value.
|
||
Why: templates and extensions must survive fields they do not know about, in both directions.
|
||
Consequence: no schema migrations for new metadata; slightly weaker compile-time safety.
|
||
Revisit if: silent typos in frontmatter keys start costing real debugging time (then add a lint, not a schema).
|
||
|
||
## ADR-0003 — Pipeline has trusted and untrusted modes
|
||
Date: front-loaded · Status: accepted
|
||
Decision: untrusted content never receives shortcode or template evaluation.
|
||
Why: this is the RCE boundary; it is the one asymmetric risk in the whole engine.
|
||
Consequence: comments render through a strictly narrower path; some unification is permanently off the table.
|
||
Revisit if: never. Narrow the untrusted path, never widen it.
|
||
|
||
## ADR-0004 — Page identity is separate from language variant
|
||
Date: front-loaded · Status: accepted; the day-one-suffix instruction superseded by ADR-0021
|
||
Decision: `slug.bn.md` / `slug.en.md` with a fallback chain, adopted from day one at one language.
|
||
Why: retrofitting identity/variant separation touches routing, caching, feeds, and every URL.
|
||
Consequence: translations are free later; a tiny amount of ceremony now.
|
||
Revisit if: never.
|
||
|
||
## ADR-0005 — Render at request time behind a cache; static export is cache-warming
|
||
Date: front-loaded · Status: accepted
|
||
Decision: the server is the superset; export walks the same code path and writes files.
|
||
Why: two code paths for the same output diverge, and the divergence always shows up in production.
|
||
Consequence: dynamic features (comments, search, content negotiation) stay possible; needs a cache eventually.
|
||
Revisit if: hosting constraints make a running process impossible.
|
||
|
||
## ADR-0006 — No guest authors
|
||
Date: front-loaded · Status: accepted
|
||
Decision: whoever commits to the site repo is the single trusted author; no multi-author model.
|
||
Why: keeps the trust model a clean binary, which keeps the untrusted boundary auditable.
|
||
Consequence: collaboration happens via git; the comment boundary stays the only untrusted path.
|
||
Revisit if: a real co-author appears — and then reconsider from scratch, not by patching.
|
||
|
||
## ADR-0007 — Dependency budget with an allowlist
|
||
Date: harness · Status: accepted
|
||
Decision: non-stdlib dependencies live in `scripts/allowed-deps.txt`; additions need an ADR; `verify.sh` enforces it.
|
||
Why: sovereignty and comprehensibility are the point; dependencies are the usual way both die.
|
||
Consequence: some features cost more code; the whole engine stays readable in an afternoon.
|
||
Revisit if: a budget raise is justified in an ADR of its own.
|
||
|
||
## ADR-0008 — Permalink policy: `/{section}/{slug}/`
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: every bundle lives at `/{section}/{slug}/` with **no exceptions** — section is the content
|
||
type, the top-level directory under `content/` (`posts`, `comics`, `art`, `writing`, `status`,
|
||
`pages`), and slug comes from the bundle path or a `slug` override. `pages/about/` therefore serves at
|
||
`/pages/about/`. Trailing slash is canonical; the slashless form permanently redirects. Bengali
|
||
composes with the prefix outermost: `/bn/{section}/{slug}/` (ADR-0009).
|
||
Why: one rule with no exemption. Root-level pages were considered and rejected: they would give the
|
||
prettier `/about/`, but they permanently share the root namespace with the engine, so every future
|
||
root route (`/tags/`, `/search/`, `/feed.xml`) becomes a slug no page may ever use — a growing set of
|
||
reserved words discovered years after publishing. Uniformity also keeps the resolver at one URL shape.
|
||
Consequence: cheap — section feeds, indexes and queries map onto a URL prefix; the root stays entirely
|
||
engine-owned, so emitted files and future routes need no collision check. Expensive — `/pages/about/`
|
||
is a less handsome URL than `/about/`, and moving a bundle between sections changes its URL and needs
|
||
an `aliases` entry, so the section list is effectively permanent once anything is published.
|
||
Revisit if: never. This is the decision every shared URL depends on.
|
||
|
||
## ADR-0009 — Language routing: default at root, others prefixed
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: English (the default language) is served at the root; every other language is served
|
||
under a `/<lang>/` prefix on the **same path** — `/pages/about/` and `/bn/pages/about/`. `/en/…` is never a
|
||
live URL; it permanently redirects to the root form so it can never fork.
|
||
Why: English is the front door, and Bengali must still be linkable, cacheable, and shareable as
|
||
its own URL — which content negotiation on a single URL prevents.
|
||
Consequence: one redirect rule; `hreflang` plus `canonical` emitted per bundle from the variants
|
||
that exist; a third language costs nothing. Identity remains the slug (ADR-0004).
|
||
Revisit if: Bengali becomes the dominant language of the site — and then it is an alias and
|
||
default-language problem, not a routing rewrite.
|
||
|
||
## ADR-0010 — Deploy: self-hosted Docker, external infra permitted for derived state only
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: ship a single binary in a container on a self-hosted server. External infrastructure
|
||
services (Redis, object storage, a search index) are permitted where they earn their place.
|
||
Why: the full server shape keeps comments, search, and content negotiation possible, and the
|
||
container makes the host itself disposable.
|
||
Consequence: confirms ADR-0005 (server is the superset; export stays available as the same code
|
||
path, now optional insurance rather than the target). The path-traversal guard becomes a hard
|
||
pre-deploy blocker. Every infra client is a dependency and counts against `DEPS_MAX`.
|
||
Constraint: external services may hold **only derived or disposable state** — cache, index,
|
||
session, queue. Canonical content stays in the site repo. Test before adding one: *if this
|
||
service vanishes, does a rebuild restore it, or is something lost?*
|
||
Revisit if: the engine can no longer boot and serve correctly with every external service off.
|
||
|
||
## ADR-0011 — The site root is external to the engine repository
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: the engine is pointed at a **site root** — a directory outside this repository, versioned in
|
||
its own git repo — holding `content/`, `static/`, and optionally `templates/` overriding the defaults
|
||
the binary embeds. Selected by `-site <dir>` or `KHOSRA_SITE`. This repository contains engine source
|
||
only; no content, ever, not even an example.
|
||
Why: content is the database, and a database does not live in the application's repo. Mixing them
|
||
interleaves "fixed a typo in a poem" with "extracted the resolver" in one history, makes every typo a
|
||
code deploy, and bloats every clone of the engine with image history.
|
||
Consequence: cheap — content-only publishing without a rebuild, a second site is a second root rather
|
||
than a fork, fixture sites live in `testdata/`, and the gate needs no content-exclusion rules.
|
||
Expensive — two repos to track, and a disk-contract change can no longer migrate the author's files:
|
||
it must ship a documented migration step or a subcommand, because the engine does not own that data.
|
||
Revisit if: never usefully. The separation only pays better as content grows.
|
||
|
||
## ADR-0012 — Effect is the sixth primitive
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: work that happens outside the request path is an **Effect**, triggered on content change, on
|
||
a schedule, or on demand. It absorbs what the harness previously called an "emitted file" and the
|
||
`Emitter` slot in the extension contract — one name, not three. Definition and rules:
|
||
`architecture.md`.
|
||
Why: half the roadmap is not request-time — image derivatives, search index, sitemap and feed files,
|
||
webmention *sending*, POSSE, link archiving, EPUB, future-dated publication. None reduce to the other
|
||
five: `Interaction` is inbound by definition, and a Stage runs per render. Without this, those features
|
||
are not leaves, so invariant 7 fails and invariant 9's freeze cannot be checked.
|
||
Consequence: cheap — the Arc 3–4 networked layer becomes composition rather than core growth, and
|
||
scheduling is an in-process ticker in the one binary. Expensive — a second trigger kind (the clock)
|
||
means the engine has background work, so every Effect must be idempotent and its absence must degrade
|
||
rather than break. Anything requiring a separate scheduler process is a trunk under ADR-0010.
|
||
Revisit if: an Effect cannot be made idempotent, or scheduling genuinely needs a second process.
|
||
|
||
## ADR-0015 — Normalise to NFC everywhere; derive slugs by locale, override by hand
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: every string entering the engine as an identifier — filenames, bundle keys, taxonomy terms,
|
||
frontmatter slugs, request paths — is normalised to NFC at that boundary, unconditionally and with no
|
||
opt-out. Slug *derivation* is separate and locale-aware: default rules come from the site's default
|
||
locale, and any derived slug may be overridden by hand — `slug` on a bundle, and a term-to-slug mapping
|
||
for taxonomy and section segments.
|
||
Why: Bengali conjuncts have several byte encodings for identical-looking text, macOS hands back NFD, git
|
||
and editors pass through whatever they are given. Un-normalised, two visually identical files produce
|
||
different bundle keys and therefore different URLs, and a request never matches the page it names. This
|
||
is unfixable after publication except by accumulating aliases. Normalisation is correctness and cannot be
|
||
optional; romanisation and casing are taste and must be overridable.
|
||
Consequence: cheap — one chokepoint, and comparisons become byte comparisons again. Expensive — adds
|
||
`golang.org/x/text` (no transitive dependencies), the first entry on the allowlist, and every identifier
|
||
boundary must route through the normaliser rather than accepting a raw string.
|
||
Revisit if: never for normalisation. Slug derivation rules change with the default locale.
|
||
|
||
## ADR-0016 — Sequence position is metadata and never appears in a URL
|
||
Date: 2026-07-28 · Status: accepted; its membership clause is superseded by ADR-0033 (position rules stand)
|
||
Decision: a bundle's slug is its name, never its position — `comics/the-long-monsoon/the-flood/`, not
|
||
`.../02-the-flood/`. Position comes from a single declared source per type (`order` in the type
|
||
declaration: date, sequence, or manual), sparse by convention (10, 20, 30) so inserting between two
|
||
members is one edit. Sequence resolution — first, prev, next, last, index, count, honouring drafts and
|
||
language fallback — is defined once and shared by comics, serial fiction and multi-part essays. A slug
|
||
may therefore contain slashes, which clarifies ADR-0008's single-segment reading.
|
||
Why: with position in the path, inserting a chapter between 3 and 4 renumbers everything after it, which
|
||
renames directories, changes bundle keys, changes published URLs, and demands an `aliases` entry for
|
||
each — one editorial decision becoming a permalink event, against ADR-0008. Two sources of truth
|
||
(filename prefix and frontmatter field) also drift, so the archive and the prev/next links can disagree.
|
||
Consequence: cheap — insertion is local and free, and prev/next exists once rather than three times.
|
||
Expensive — ordering is invisible in a directory listing, so authors read it from frontmatter, and
|
||
`order` values want leaving gaps.
|
||
Revisit if: never. Position in a permalink is the mistake this exists to prevent.
|
||
|
||
## ADR-0019 — Templates: per-type sets, block-level override, cascade selection
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: one parsed template set per type, each set being base plus partials plus that type's
|
||
definitions. A site root override is parsed *after* the embedded defaults into the same set, so it may
|
||
redefine a single named block and inherit everything else. Which set renders a bundle resolves through the
|
||
cascade (`ideas/deferred-decisions.md`): type default, section override, then the bundle's own `view`.
|
||
Why: `html/template` has no `extends` — inheritance is "last definition of a name wins in a parsed set",
|
||
so a global set makes two types defining `main` collide, and per-type sets are the only clean answer.
|
||
File-level override would force copying a whole template to change one block, after which it stops
|
||
inheriting engine improvements; block-level costs a few lines of parse ordering and keeps site overrides
|
||
minimal.
|
||
Consequence: cheap — a site writes only what it changes, and item-level overrides need no new mechanism.
|
||
Expensive — parse order becomes load-bearing and must be asserted in a test, since a silently wrong order
|
||
means an override that quietly does nothing.
|
||
Revisit if: never cheaply — every template ever written assumes this convention.
|
||
|
||
## ADR-0020 — YAML is the author-facing format; allowlist `gopkg.in/yaml.v3`
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: frontmatter and the site declaration (`site.yaml` at the site root) are YAML, parsed by
|
||
`gopkg.in/yaml.v3` — one dependency, no transitive requirements, one parser for both.
|
||
Why: frontmatter is the author's primary interface with the engine and must behave exactly as they
|
||
expect. Go has no stdlib YAML, and the alternatives are worse: JSON is hostile to hand-write, TOML costs
|
||
a dependency anyway, and a hand-rolled subset would diverge from real YAML in ways only discovered while
|
||
writing a post. This is the case `conventions.md`'s "40 lines over a dependency" rule does not cover —
|
||
the 40 lines would be wrong in edge cases the author cannot predict.
|
||
Consequence: cheap — one parser for frontmatter, the site declaration and the cascade; anchors and
|
||
multi-line strings work as authors expect. Expensive — the third allowlist entry, and YAML's own traps
|
||
(the Norway problem, tabs) become the engine's to document rather than to invent.
|
||
Revisit if: the parser proves a maintenance burden, or the author-facing format changes — and the second
|
||
requires migrating every existing file.
|
||
|
||
## ADR-0021 — The default locale's suffix is optional, permanently
|
||
Date: 2026-07-28 · Status: accepted (supersedes ADR-0004's instruction to adopt `slug.en.md` from day one)
|
||
Decision: a missing language suffix means the default locale, always — not merely while one language
|
||
exists. `about.md` and `about.en.md` name the same variant and the parser accepts both. Identity still
|
||
excludes language (ADR-0004's actual point, unaffected).
|
||
Why: ADR-0004 told the *author* how to name files, which is not the engine's business — the site root
|
||
belongs to its owner (ADR-0011). The engine's job is to accept both spellings and derive the same bundle
|
||
key from either. Requiring a suffix bought nothing: identity is already language-free by construction.
|
||
Consequence: cheap — nothing in the harness prescribes a filename any more, and a single-language site
|
||
never types a suffix. Expensive — the parser must treat two spellings as one variant and reject a bundle
|
||
that supplies both, since that is ambiguous rather than harmless.
|
||
Revisit if: never. This removes a rule rather than adding one.
|
||
|
||
## ADR-0022 — Content change is detected by polling the site root
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: the engine detects change by walking the site root and comparing modification times, on an
|
||
interval set by one flag (`-poll`, zero to disable for immutable deployments), and acting only after a
|
||
settle window with no further changes. Stdlib only — no filesystem-watcher dependency. Fetching is not
|
||
the engine's job: something on the host updates the site root, and the engine notices. The site root is
|
||
therefore a mounted volume in the container, never baked into the image.
|
||
Why: the engine already runs against a directory, so the filesystem is the obvious signal, and it needs no
|
||
inbound endpoint — the untrusted surface stays empty until webmentions or Micropub actually arrive. A
|
||
watcher would cost a dependency for what a short walk does, and inotify is unreliable on exactly the
|
||
places this will run: bind mounts, overlay and network filesystems. A poll is boring and works everywhere.
|
||
Consequence: cheap — change detection is the `on change` trigger from ADR-0012 with nothing new, and a
|
||
template edit in the site root invalidates through the same path as content. Expensive — a checkout writes
|
||
many files, so the settle window is required rather than optional; editor droppings and `.DS_Store` must
|
||
be ignored or the engine re-renders continuously; and detection latency is bounded by the interval.
|
||
Revisit if: a site grows large enough that walking it costs real time — and then the fix is a cheaper
|
||
signal, not a watcher dependency.
|
||
|
||
## ADR-0023 — Three edit surfaces; this repo is bound to the theme contract, not the theme
|
||
Date: 2026-07-28 · Status: accepted
|
||
Decision: engine source, content, and theme are three separate surfaces with three separate owners. This
|
||
repository holds the engine and is bound to the **theme contract** — the data available to templates, the
|
||
template and block names it looks for, the helpers it provides, the URLs it emits — recorded in
|
||
`docs/theme-contract.md`. It is not bound to any theme's markup, layout, or styling. Where a theme comes
|
||
from (a directory in the site root, its own repo checked out into place) is deliberately outside this
|
||
repo's concern: the engine knows a path and a contract.
|
||
Why: a request that reads as one feature is often split — "supporting files listed in a sidebar" is a
|
||
contract change here and a layout decision there. Absorbing the theme half into the engine puts markup
|
||
and presentation choices into the core, which grows it against invariant 9 and makes the theme
|
||
unswappable. Keeping the boundary at the contract is what lets the theme change without an engine release
|
||
and the engine change without rewriting a theme.
|
||
Consequence: cheap — a split request produces a contract extension plus a written note of what the theme
|
||
must do, and the theme side is somebody's separate change. Expensive — the contract is now a published
|
||
interface: fields may be added, never renamed or removed, which is the View-layer freeze in
|
||
`architecture.md` arriving earlier than Arc 2. Embedded default templates are a reference implementation
|
||
of the contract, not the contract itself.
|
||
Revisit if: never usefully. Merging the surfaces is how a publishing engine becomes one site's code.
|
||
|
||
## ADR-0024 — Bundle-local assets inherit the bundle's publish status; `-dev` reveals
|
||
Date: 2026-07-29 · Status: accepted
|
||
Decision: every byte served from inside a bundle — body, cover image, any local asset, anything under the
|
||
extras directory — inherits that bundle's publish status, derived per request rather than stored. An
|
||
unpublished bundle and all its assets answer **404**, never 403. Reaching asset bytes without having
|
||
resolved the owning bundle is structurally impossible, not merely discouraged: one guard, every route.
|
||
`-dev` (default off) reveals drafts and future-dated bundles with their assets, and is the only thing that
|
||
changes the answer.
|
||
Why: without this, a draft's `cover.jpg` is world-readable while its page is not, and notes about
|
||
unfinished work leak through the asset path — which looks like static file serving, and static file serving
|
||
looks like it needs no context. That is the shape this bug always takes. 403 would confirm the work exists,
|
||
which for drafts is itself the thing worth not leaking.
|
||
Consequence: cheap — visibility is one derived predicate with no state to keep in sync, and a future-dated
|
||
bundle's 404 expires at its publish time, so it becomes visible exactly when it should
|
||
rather than needing a sweep. Expensive — no route may serve bundle bytes by path alone, so a fast static
|
||
path for assets is off the table; and `-dev` becomes security-relevant, so it must default off and be
|
||
obvious when on.
|
||
Revisit if: never. The generalisation from "extras are public" to "assets inherit visibility" is the whole
|
||
point of the entry.
|
||
|
||
## ADR-0026 — The engine ships a minimal reference theme
|
||
Date: 2026-07-29 · Status: accepted
|
||
Decision: the binary embeds a reference theme — templates plus one small stylesheet — sufficient to render
|
||
every declared type and the extras view, with semantic HTML and no JavaScript. It exists to make the theme
|
||
contract executable: a bare site root renders, and a golden-file test through it catches contract
|
||
regressions. It is deliberately not a design: legibility only, no branding, no visual opinions, and it
|
||
demonstrates every contract feature and nothing more.
|
||
Why: a contract nobody implements is a contract nobody has tested. Without a reference theme, the first
|
||
real theme discovers the contract's gaps, and `docs/theme-contract.md` stays aspirational. It also means
|
||
someone can point the binary at a folder of Markdown and see a site, which is the whole promise.
|
||
Consequence: cheap — templates and CSS are not Go, so they cost nothing against `CORE_LOC_MAX`, which is
|
||
the same incentive that pushes presentation out of the core. Expensive — the reference theme is a
|
||
maintenance obligation that grows with the contract, and if it ever becomes handsome it becomes the theme
|
||
nobody replaces, which is why "minimal" is a rule and not a preference.
|
||
Revisit if: it starts accumulating design decisions — then split it into a reference theme and a real one.
|
||
|
||
## ADR-0027 — Feature locality: one directory, no sibling imports, a mandatory `doc.go`
|
||
Date: 2026-07-30 · Status: accepted
|
||
Decision: a feature is one directory under `internal/ext/<name>/` from its first use, plus one line in
|
||
`cmd/khosra/wire.go` — the only file that knows every feature. No `internal/ext` package may import
|
||
another. Every one carries a `doc.go` stating, in four lines: what it contributes, which cascade keys it
|
||
reads, which theme-contract fields it adds, and what it deliberately does not do. A feature may import
|
||
`internal/content` and `internal/render`; never `internal/web`, never `cmd/`, never a sibling.
|
||
Why: the cost that matters when an agent adds a feature is how much of the codebase it must read first.
|
||
Sibling imports are what make a read set compound — understand one feature, then two, then four. A
|
||
`doc.go` in a fixed shape turns orientation into a fifteen-line read instead of a two-hundred-line one.
|
||
Routes reach `web` by being assembled in `wire.go`, so `web` never learns features exist and the
|
||
dependency arrows hold.
|
||
Consequence: cheap — adding a feature is a new directory and one wiring line, so the diff is local and
|
||
the read set is roughly the extension types plus `wire.go`. Shared logic between two features must move
|
||
inward (earning it under the counters) or stay duplicated until the third use, which is the existing rule
|
||
rather than a new one. Expensive — `wire.go` absorbs all the coupling on purpose and will look
|
||
repetitive; and this buys cheap *features*, not cheap spine changes, which still need the core read.
|
||
Revisit if: `wire.go` becomes hard to read, which means the registry from `extensions.md` is due.
|
||
|
||
## ADR-0028 — Pagination lives in the path: `/{section}/page/2/`
|
||
Date: 2026-07-30 · Status: accepted
|
||
Decision: page two of a listing is `/{section}/page/2/`; page one is the bare listing URL and never
|
||
`/page/1/`, which permanently redirects to it. Tag listings compose the same way:
|
||
`/tags/{tag}/page/2/`. `page` is therefore a reserved segment inside a section.
|
||
Why: a query parameter is not a permalink — it reads as ephemeral, is dropped by careless sharing, and
|
||
splits caching by URL shape rather than by content. Putting it in the path keeps every listing page
|
||
linkable and cacheable on the same terms as a bundle.
|
||
Consequence: cheap — one more resolver case, and every paginated listing is a first-class URL. Expensive
|
||
— no bundle may be slugged `page` inside a paginated section, and changing the page size renumbers pages,
|
||
so page URLs are stable only while the size is.
|
||
Revisit if: never for the shape. Page size is a separate setting and changing it is a URL event.
|
||
|
||
## ADR-0029 — A bundle that fails to parse is skipped loudly, not fatal
|
||
Date: 2026-07-30 · Status: accepted
|
||
Decision: a bundle whose frontmatter or filename cannot be parsed is logged at error level with its path
|
||
and line, excluded from the site, and does not stop startup or any request. The `check` command reports
|
||
the same failures as errors, and that is where a non-zero exit belongs.
|
||
Why: `conventions.md` says startup failure is fatal and loud, and separately that request-time failure
|
||
degrades. A single mistyped colon in one post is not a startup failure — treating it as one means one
|
||
typo takes the whole site down, which is the worst possible failure mode for a personal site published
|
||
from a text editor. Fatal is right for a broken site root or an unreadable directory, not for one file.
|
||
Consequence: cheap — the site always serves whatever is valid, and the author sees the error in the log
|
||
and in `check`. Expensive — a silently absent page is a real failure mode, so the log line must name the
|
||
file and the reason plainly, and `check` must exist early enough to be the place you look.
|
||
Revisit if: skipped bundles start going unnoticed in practice — then `check` runs in CI, rather than the
|
||
engine becoming fatal.
|
||
|
||
## ADR-0030 — The project is named khosra
|
||
Date: 2026-07-30 · Status: accepted
|
||
Decision: the engine is `khosra`. Module path `khosra`, binary `khosra`, command directory
|
||
`cmd/khosra/`, environment variable `KHOSRA_SITE`, feature-loop skill `khosra-feature-loop`. The former
|
||
name `atelier` appears nowhere, including in earlier ADRs, which describe this project under its old
|
||
name rather than a different project.
|
||
Why: naming is the author's, and it is free now — before a module is published, a URL is shared, or a
|
||
binary is deployed. Every day it waits, it costs more.
|
||
Consequence: cheap now, and permanent-ish once the module path is fetched by anything. Expensive later —
|
||
a module rename after publication needs a redirect or a major-version bump, and a deployed binary name
|
||
appears in service files and container tags.
|
||
Revisit if: never. Renaming again costs strictly more than this did.
|
||
|
||
## ADR-0031 — Path safety is `os.Root`, not a hand-rolled check
|
||
Date: 2026-07-30 · Status: accepted
|
||
Decision: every read of the site root goes through an `*os.Root` obtained by `os.OpenRoot` (Go 1.24+).
|
||
No code cleans, joins or validates a request path itself, and `os.DirFS` is not used for the site root.
|
||
Why: `os.Root` refuses any name resolving outside the root, including through a symlink; `os.DirFS`
|
||
documents that it does *not* prevent symlink escape. So the traversal guard that has sat on the latent
|
||
list becomes a property of the type rather than a check somebody has to remember at every call site —
|
||
which is the only version that survives twenty features.
|
||
Consequence: cheap — the guard cannot be forgotten, and the test is one symlink. Expensive — reads must
|
||
go through the root handle, so no helper may take a `string` path and open it directly, and the root is
|
||
held for the life of the process.
|
||
Revisit if: never. A hand-rolled cleaner is strictly worse.
|
||
|
||
## ADR-0032 — Tags: one global namespace, grouped in the listing
|
||
Date: 2026-07-30 · Status: accepted (the tag half of withdrawn ADR-0018, now built; the feed half is still
|
||
deferred in `ideas/deferred-decisions.md`)
|
||
Decision: `tags` is one global namespace across every section. `/tags/{term}/` lists everything carrying a
|
||
term, `/{section}/tags/{term}/` narrows it, and listings group by section. The URL form of a term is
|
||
lowercased with whitespace hyphenated, preserving script, so case is not a distinction and Bengali passes
|
||
through unchanged. Structural metadata with a fixed term set — `series` and its kin — is not a tag.
|
||
Why: cross-type discovery is the point of a single-author site; one term spanning a comic, a poem and a
|
||
photo essay is the feature, not the noise. Per-section pools would fragment that to solve a readability
|
||
problem the View solves by grouping. And a free-form cross-cutting tag behaves nothing like a fixed term
|
||
set that drives engine behaviour, so conflating them makes both worse.
|
||
Consequence: cheap — one Query with an optional section predicate serves both listings, and the second
|
||
caller is what earned `Query.Tag` rather than a guess. Expensive — tag hygiene is the author's discipline
|
||
since nothing scopes terms, so `check` owes a near-duplicate report; and `tags` is now reserved at the top
|
||
level and inside every section.
|
||
Revisit if: the pool becomes unusable in practice — and then the answer is curation, not namespacing.
|
||
|
||
## ADR-0033 — Series membership is structural; `order` is optional
|
||
Date: 2026-07-30 · Status: accepted (supersedes ADR-0016's membership clause, not its position rules)
|
||
Decision: a bundle is a member of a series because it is nested under the series' landing bundle —
|
||
`comics/the-long-monsoon/first-rain` under `comics/the-long-monsoon` — so the engine reads no `series`
|
||
field. Reading order is `order` ascending where it is set, then by key; a member without `order` sorts
|
||
after every member that has one. A bundle with bundles nested under it is a series landing page, and one
|
||
nested inside another series reports its own members rather than its siblings.
|
||
Why: the directory already states membership unambiguously, and a second statement of it in frontmatter
|
||
can only agree or be a bug — a typo in `series` silently orphans a chapter, which is precisely the class
|
||
of error a single source of truth removes. Making `order` optional keeps the cheap case cheap: a series
|
||
whose filenames already sort correctly needs no frontmatter at all, and `order` is for when they do not.
|
||
Consequence: cheap — nothing to declare, nothing to keep in sync, and moving a series moves its
|
||
membership with it. Expensive — a series can never span directories, so a cross-directory collection
|
||
needs a Query over some other field, not this; and membership now depends on the bundle key, which
|
||
ADR-0008 makes permanent, so re-parenting a chapter is a permalink event with an alias.
|
||
Revisit if: a real collection must span sections. Then it is a declared taxonomy (ADR-0032's second
|
||
half), not a change to how a series is joined.
|
||
|
||
## ADR-0034 — Chrome text is the engine's; body text is the author's
|
||
Date: 2026-07-30 · Status: accepted
|
||
Decision: every word the engine puts on a page that the author did not write — labels, page counts,
|
||
month names, digits — comes from a table in the engine keyed by (key, language), reached from templates
|
||
through `t`, `num` and `day`, so no template hardcodes English. Authored body text is never localised and
|
||
never rewritten beyond typographic smoothing, which is goldmark's typographer: quotes, dashes, ellipses,
|
||
code spans untouched. Machine-readable output — `datetime`, URLs, anything a parser reads — stays ASCII
|
||
whatever the locale.
|
||
Why: a Bengali page that says "Page 2 of 3" in English is broken, and a theme cannot fix it without
|
||
hardcoding the very strings a theme must not own (theme-contract.md). Meanwhile the opposite mistake is
|
||
worse: localising body text means the engine editing prose, and Bengali numerals inside an author's
|
||
sentence are the author's decision, not the renderer's. So the line is drawn by *who wrote the words*.
|
||
Consequence: cheap — a new label is one table row with both languages beside each other, and a missing
|
||
translation falls back to the default locale and then to the key, so it can never blank a page or fail a
|
||
render. Expensive — the engine now carries human-language strings, which is a small localisation surface
|
||
that grows with the theme; a site root cannot yet add or override a key, so a theme needing its own words
|
||
must write them in its own block.
|
||
Revisit if: a site root needs its own chrome strings. That is the settings cascade's problem
|
||
(`ideas/deferred-decisions.md`), not a second table.
|
||
|
||
## ADR-0035 — A slug belongs to the bundle, not to a variant
|
||
Date: 2026-07-30 · Status: accepted (confirms ADR-0009's "same path" clause against a per-language address)
|
||
Decision: `slug` renames the bundle in every language, so a variant is always reached at the bundle's one
|
||
path under its language prefix — `/posts/hello-world/` and `/bn/posts/hello-world/`, never a Bengali
|
||
spelling of the address. Where two variants declare different slugs the declaration is ambiguous: log both
|
||
and keep the derived path, the same way colliding keys and contested aliases are handled (ADR-0029). The
|
||
engine never derives a slug from a title in any language; a path changes only because an author wrote one.
|
||
Why: the alternative makes the path a function of (key, language) rather than of the key alone, which
|
||
costs a second lookup direction in the resolver and a permalink shape that differs per language — while
|
||
invariant 3 says identity is stable across translations and ADR-0009 already promised the same path under
|
||
a prefix. A Bengali-looking address is a real reader benefit, but not one worth a second identity rule
|
||
before anything is published.
|
||
Consequence: cheap — one path shape, one lookup direction, `/bn/` stays pure prefixing, and a translation
|
||
can be added or dropped without touching a URL. Expensive — a Bengali reader sees a Latin address, and
|
||
changing that later needs this decision reversed plus an alias for every path already published.
|
||
Revisit if: Bengali becomes the dominant language of the site — the trigger ADR-0009 already names. That
|
||
is a default-language change, not per-variant slugs.
|
||
|
||
## ADR-0036 — Shortcodes parse to engine-built nodes and render through theme templates
|
||
Date: 2026-07-30 · Status: accepted
|
||
Decision: a shortcode is `{{< name key="value" >}}` alone on a line, parsed into an AST node by a goldmark
|
||
block parser. It renders by executing a theme template of the same name with its arguments as data — the
|
||
engine supplies no markup — and raw HTML in Markdown stays disabled, so the only HTML on a page comes from
|
||
a template the site owns. A shortcode whose template is missing logs and renders nothing rather than
|
||
failing the request.
|
||
Why: two constraints meet here and both are already recorded. `theme-contract.md` says the engine decides
|
||
nothing about how something looks, "including how media is embedded", so a `<figure>` built in Go would be
|
||
the engine dressing content. And invariant 2 needs the trusted/untrusted split to be real code rather than
|
||
goldmark's default: with `html.WithUnsafe()` still off, an author's raw HTML is dropped while a
|
||
shortcode's output is trusted *because a template produced it*, and the author's bytes survive only as
|
||
arguments, which `html/template` escapes.
|
||
Consequence: cheap — markup lives where markup belongs, a theme restyles a shortcode by redefining one
|
||
template, and the escaping is the standard library's rather than ours. Expensive — a feature under
|
||
`internal/ext` cannot render itself, so it is handed a partial-rendering function at wiring time
|
||
(`cmd/khosra/wire.go`), and the shortcode's argument names become part of the theme contract, additive
|
||
only.
|
||
Revisit if: a shortcode needs to emit something no template can express. That is an argument for a new
|
||
contract field, not for the engine writing HTML.
|
||
|
||
## ADR-0037 — A theme fragment receives a Fragment, not a bare argument map
|
||
Date: 2026-07-30 · Status: accepted (widens ADR-0036's `Partial`, before any theme exists)
|
||
Decision: a fragment template receives `Fragment{Args, Items}` — `.Args` being the call's `key="value"`
|
||
pairs and `.Items` a list the feature gathered, such as the filenames a gallery found. So `figure` reads
|
||
`.Args.src` rather than `.src`. A feature that needs to hand over something neither string nor list of
|
||
strings is a reason to add a field here, never to reach for `any`.
|
||
Why: `gallery` is the second fragment and it needs a list, which a `map[string]string` cannot carry. The
|
||
shape had to widen either now or after a theme existed, and `theme-contract.md` says fields are added but
|
||
never renamed — so doing it before there is a theme costs one commit, and doing it later costs a contract
|
||
version. Naming the argument map keeps arguments and gathered data from colliding: a call with a `src`
|
||
argument and a feature-supplied `src` would otherwise silently pick one.
|
||
Consequence: cheap — one struct to widen when a third kind of data appears, and templates say which side of
|
||
the data they mean. Expensive — the embedded fragments and this document change together, and every future
|
||
fragment carries one extra hop (`.Args.`) for a clarity that only pays off from the second fragment on.
|
||
Revisit if: a feature needs structured items rather than strings — a gallery of images with captions and
|
||
dimensions, most likely at image derivatives. Then `Items` becomes a slice of a named struct, additively.
|
||
|
||
## ADR-0038 — An included file cannot itself include
|
||
Date: 2026-07-30 · Status: accepted (amends `extensions.md`'s phase table, which listed includes under
|
||
`PhaseLoad`)
|
||
Decision: `{{< include file="…" >}}` renders the named file as Markdown in place, resolved against the
|
||
including bundle's directory and read through the rooted filesystem. The included file is converted with the
|
||
same Markdown configuration, in a parse marked as nested, and an include *inside* an included file renders
|
||
nothing and logs. One level, always.
|
||
Why: the obvious implementation — parse the included file and splice its nodes into the page's tree — is not
|
||
merely buggy but invalid: goldmark nodes hold byte offsets into their own source, so a spliced node makes the
|
||
renderer read past the page's buffer and panic. That leaves converting the file separately, and once
|
||
conversion is separate, nesting stops being free: each level is another read and another parse triggered by
|
||
content, with a cycle costing a crash unless bounded. A depth limit would bound it; forbidding nesting
|
||
removes the failure mode instead, and no real page needs an include that includes.
|
||
Consequence: cheap — no depth counter, no cycle detection, and a self-including file is a logged line rather
|
||
than a bounded recursion. A `gallery` inside an included file still works, because the nested parse carries
|
||
the same Origin. Expensive — a page assembled from parts that themselves compose is not expressible, and
|
||
`PhaseLoad` loses its example: includes turned out to be parse-phase, not load-phase.
|
||
Revisit if: composing partials out of partials becomes a real need. That is textual splicing before parsing,
|
||
and it wants the Stage pipeline's load phase rather than a second mechanism here.
|
||
|
||
## ADR-0039 — `site.yaml` declares the site; the cascade below it stays deferred
|
||
Date: 2026-07-30 · Status: accepted (adopts the *site* level of the parked settings cascade in
|
||
`ideas/deferred-decisions.md`, and only that level)
|
||
Decision: a site root may hold `site.yaml` carrying declared site-level keys — `base` and `title` today. It
|
||
is read once at startup: absent is fine, since a bare site root still serves, but malformed is a fatal
|
||
startup error, because unlike one bad bundle it misconfigures every page. `-base` overrides the file when
|
||
given, so a staging host needs no edit to content. Section-level and bundle-level resolution are *not* built:
|
||
frontmatter already covers a bundle, and nothing reads a section override yet.
|
||
Why: absolute URLs are the forcing function — a canonical link, an `hreflang`, an OpenGraph tag and a sitemap
|
||
entry all require the site's own origin, which the engine cannot infer from a request it may be serving
|
||
behind any proxy. A flag alone would work, but the origin is a property of the site rather than of one
|
||
invocation, and it belongs in the site's own git history next to the content it describes. Building only the
|
||
site level keeps the earn-it rule honest: the cascade's cost is resolution per bundle, and nothing yet asks
|
||
for it.
|
||
Consequence: cheap — one file, two keys, read once, and a site that declares nothing keeps working with
|
||
relative links. Expensive — there is now a second place a setting can come from, so precedence has to be
|
||
stated wherever a key is documented; and the set of keys must stay declared or `site.yaml` becomes unbounded
|
||
config, which is the failure the parked cascade already warns about.
|
||
Revisit if: a section wants to set a policy for everything beneath it. That is the parked cascade arriving
|
||
for real, and its trigger is unchanged: the first section-level override with a reader.
|
||
|
||
## ADR-0040 — `golang.org/x/image` for resampling and WebP decoding
|
||
Date: 2026-07-30 · Status: accepted (human approval on the record, per hard rule 2)
|
||
Decision: add `golang.org/x/image` to the allowlist, for `draw` (CatmullRom resampling) and `webp` (decode).
|
||
Total modules go from three to four, against a cap of six.
|
||
Why: the standard library decodes and encodes JPEG, PNG and GIF but cannot resize — `image/draw` scales only
|
||
by nearest neighbour, which is visibly wrong on photographic downscales, and a site whose content *is*
|
||
pictures cannot ship that. A hand-rolled box filter is about fifty lines and still worse than CatmullRom on
|
||
exactly the material this site has. `x/image` is maintained by the Go team, sibling of the `x/text` already
|
||
allowed, and carries no transitive dependencies.
|
||
Consequence: cheap — good downscaling and WebP input for one module. Expensive — AVIF still has no decoder in
|
||
either the standard library or this module, so AVIF passes through untouched; and image code now has a
|
||
dependency that must be checked at each Go release like any other.
|
||
Revisit if: the standard library gains a resampler, or AVIF becomes something the site actually publishes.
|
||
|
||
## ADR-0041 — `CORE_LOC_MAX` 2000 → 2800
|
||
Date: 2026-07-30 · Status: accepted
|
||
Decision: raise `CORE_LOC_MAX` from **2000** to **2800**. `EXT_LOC_MAX` stays at 2000. The budget continues
|
||
to cover `cmd/` plus `internal/{content,render,web}`.
|
||
Why: the original figure was costed before any code existed, for "spine, bundles, queries, render, routing,
|
||
templates" — and it never budgeted the things `cmd/` will hold. Costing what remains that cannot be a leaf:
|
||
`check` ~250, `new` ~100, `-dev` ~100, change detection ~100, the page cache ~200. On top of 1870 that is
|
||
~2620 before anything optional, so the ceiling was going to fail on work nobody would call excess. This is a
|
||
costing error being corrected, not discipline being loosened.
|
||
Consequence: cheap — the gate stops blocking planned core work, and 2800 still binds at roughly one feature's
|
||
slack. Expensive — a raised ceiling is a weaker signal than the one it replaces, so the *second* raise should
|
||
be treated as evidence that something belongs in `internal/ext/` instead. Features that are leaves must keep
|
||
going there: if they land in core, `EXT_LOC` measures nothing and invariant 9 becomes unobservable, which is
|
||
the whole reason there are two ceilings.
|
||
Revisit if: core approaches 2800. That is the question "what here is not core?" and the answer is a leaf, not
|
||
a third raise.
|
||
|
||
## ADR-0042 — Derivatives are generated ahead of the request, into a cache outside the site root
|
||
Date: 2026-07-30 · Status: accepted (replaces `Fragment.Items` with `Fragment.Pictures`, ADR-0037's own
|
||
revisit trigger)
|
||
Decision: sized image derivatives are produced by a pass over the content at startup, not during a request,
|
||
and written to a cache directory outside the site root (`-cache`, defaulting under `os.UserCacheDir`). Each is
|
||
named by the source's content hash and the target width, so the pass is idempotent and a changed source yields
|
||
a different name. The engine never writes into the site root and never touches an original. Core serves that
|
||
cache as a directory of opaque names; every image decision — widths, naming, dimensions, which files are
|
||
images — lives in `internal/ext/shortcodes`, the package whose shortcodes need it. A fragment now receives
|
||
`Pictures`, each carrying `Src`, `Srcset`, `Width` and `Height`, replacing the bare `Items` list.
|
||
Why: resampling on the request path would make the first view of a page take seconds, and there is no page
|
||
cache yet to hide it. Writing derivatives into the site root would put generated files in somebody's content
|
||
git — the engine reads that directory and must not litter it, and derived state is disposable by definition
|
||
(ADR-0010). Content-addressed names mean a rebuild rewrites nothing, and a lost cache costs one startup pass
|
||
rather than any correctness. `Items` had exactly one consumer, so widening it in place beat adding a second
|
||
list beside it.
|
||
Consequence: cheap — no request pays for resampling, the cache can be deleted at any time, and `width`/`height`
|
||
in the markup end the layout-shift problem the output floor named. Expensive — a new image needs a restart
|
||
until change detection lands (queue 21), the cache is a second directory to think about when deploying, and
|
||
AVIF passes through unresized since nothing can decode it. A feature still cannot serve a route of its own, so
|
||
core carries a generic "serve this directory of derived files" — which is the seam to revisit when a second
|
||
feature wants output of its own.
|
||
Revisit if: startup time becomes noticeable on a large site — then the pass wants a manifest and a change
|
||
check rather than a stat per candidate.
|
||
|
||
## ADR-0043 — A feed carries dated bundles; membership needs no type declaration
|
||
Date: 2026-07-30 · Status: accepted (chooses a simpler rule than the parked feed shape in
|
||
`ideas/deferred-decisions.md`, which waited on declared types)
|
||
Decision: `/feed.xml` carries every **dated** bundle, newest first, capped at the most recent 20.
|
||
`/{section}/feed.xml` and `/tags/{term}/feed.xml` narrow it through the same Query, and a `/{lang}/` prefix
|
||
selects a language like anywhere else. The format is Atom, built with `encoding/xml` from typed structs rather
|
||
than a template. A feed needs the site's `base`, and answers 404 without one. Entries carry title, link,
|
||
identity and date — not the body.
|
||
Why: the parked direction was "every type declared `primary`", which would have made feeds wait for declared
|
||
types a third time. But the thing that actually distinguishes a feed item is already on disk: a publication
|
||
date. Pages, colophons and section landings have none and drop out for the right reason rather than by
|
||
declaration, and nothing new has to be invented or kept in sync. `encoding/xml` over a template because XML in
|
||
`html/template` is escaping for the wrong grammar — a correctness trap, not a style preference.
|
||
Consequence: cheap — one Query, one marshaller, and no new content concept; a `[spec]` idea stays parked
|
||
instead of being half-built. Expensive — "in the feed" and "has a date" cannot yet be separated, so a dated
|
||
bundle an author wants out of the feed has no way to say so; and entries without bodies mean a reader shows
|
||
titles only, until `summary` is parsed.
|
||
Revisit if: someone wants a dated bundle excluded, or one section kept out of the main feed. *That* is the
|
||
real trigger for declared types, and it is now a sharper one than "feeds exist".
|
||
|
||
## ADR-0044 — No page cache; the cost was repeated picture inspection
|
||
Date: 2026-07-30 · Status: accepted (the parked cache validity model stays parked)
|
||
Decision: do not build a page cache. Instead remember what each picture is — its size and its derivative names
|
||
— keyed by path, file size and modification time. Rendering stays request-time with no stored output, no
|
||
validity records and no invalidation graph.
|
||
Why: measured before deciding, as the entry required. A plain page rendered in 14µs and a twelve-picture
|
||
gallery in 1.23ms, of which ~102µs per picture was reading, hashing and decoding bytes already read on the
|
||
previous request. Remembering that one fact takes a map behind a mutex and brings the same gallery to 63µs —
|
||
19.5× faster, 21× fewer bytes allocated — after which nothing on the site is slow enough to justify caching
|
||
whole pages. A validity model with five axes, written before any code existed, would have been built to solve a
|
||
problem that turned out to be one repeated file read.
|
||
Consequence: cheap — twenty-odd lines, no stored HTML, and the only invalidation question is "did the file
|
||
change", answered by the filesystem. Expensive — one map grows with the number of pictures on the site and is
|
||
never evicted, which is correct for a single-author site and wrong for an unbounded one; and every future
|
||
"cache the page" instinct now has to beat 63µs rather than 1.23ms.
|
||
Revisit if: a page render exceeds a few milliseconds after this, or output stops being a pure function of
|
||
content — a comment stream, a per-visitor fragment. Then the parked validity model is the right shape, and its
|
||
five axes will have consumers instead of guesses.
|
||
|
||
## ADR-0045 — Widow prevention is the browser's job; the feature is deleted
|
||
Date: 2026-07-31 · Status: accepted (removes `internal/ext/widows`, shipped two commits earlier)
|
||
Decision: delete the widow-prevention feature. Line breaking belongs to whatever is laying out the text, so the
|
||
reference theme sets `text-wrap: pretty` on body copy and `text-wrap: balance` on headings, and the engine stops
|
||
touching the text. A layer test goes in `CLAUDE.md` so the question is asked before the next feature: content on
|
||
disk, data the browser needs, markup, or presentation — a presentation problem the browser can solve is not the
|
||
engine's.
|
||
Why: it was built at the wrong layer, and it contradicted two rules already recorded here. `theme-contract.md`
|
||
says the engine decides nothing about how something looks, and ADR-0034 says authored body text is the author's
|
||
— yet this inserted U+00A0 into that text. The practical harm follows from the layer error: the engine cannot
|
||
see the line box, so joining the last two words is a guess that can overflow a narrow viewport, and a reader
|
||
copying the paragraph gets a non-breaking space in their clipboard. CSS knows the line box and needs no bytes in
|
||
the content.
|
||
Consequence: cheap — 108 lines of engine deleted, one CSS declaration gained, and authored text is untouched
|
||
again. Expensive — `text-wrap: pretty` is unimplemented in some browsers, so those readers get ordinary wrapping;
|
||
that is a smaller cost than editing prose, and it improves on its own as browsers ship it. The typographer stays,
|
||
because turning `--` into an en dash is a text transformation no stylesheet can express — the distinction the
|
||
layer test is meant to draw.
|
||
Revisit if: nothing. If widows matter more than this, the answer is a better stylesheet.
|
||
|
||
## ADR-0046 — Layer audit of everything built, and the rule for everything next
|
||
Date: 2026-07-31 · Status: accepted (amends ADR-0032's grouping clause)
|
||
Decision: every feature was re-examined against the layer test in `architecture.md`, and the verdicts are
|
||
recorded below. One was wrong and is fixed here: a tag listing now receives **both** the flat list and the
|
||
section partition, so the theme decides whether it looks grouped. `Item` gains `.Section` so a flat listing can
|
||
still say where an entry came from. Everything else stays where it is, for the reasons given.
|
||
|
||
| Feature | Layer | Why it is right there |
|
||
|---|---|---|
|
||
| Scanning, keys, permalinks, aliases, redirects | engine | Only the engine reads the disk, and a URL is a promise it makes |
|
||
| Queries: sections, tags, sequences, pagination | engine | Needs the whole index. Page size sets URLs, so it cannot be presentation (ADR-0028) |
|
||
| Sequence neighbours, index, count | engine | Ordering needs frontmatter and the index; a template cannot sort |
|
||
| Grouping a tag listing | **engine offers, theme decides** | Templates cannot group, so the partition is data — but *whether to show it* is markup. Fixed here |
|
||
| Typographer: quotes, dashes, ellipses | engine | A *character* transformation no stylesheet can express: `--` cannot become an en dash in CSS |
|
||
| Chrome strings, digits, month names | engine | Translations are data. The alternative is every theme hardcoding Bengali months; where they appear is still the theme's |
|
||
| Widow and orphan control | **browser** | Deleted from the engine (ADR-0045). CSS knows the line box |
|
||
| Shortcode markup | theme | Engine parses the call and supplies data; every tag comes from a fragment (ADR-0036) |
|
||
| Image derivatives, `srcset`, `width`/`height` | engine | Resampling and file generation cannot happen in a browser, and `srcset` is markup the browser needs to be given |
|
||
| Gallery order, alt text | engine / theme | Order needs the directory; `alt=""` is written by the fragment, because only the theme knows the picture's role |
|
||
| Absolute canonical, `hreflang`, OpenGraph values | engine | Needs the declared origin. The *tags* are emitted by the theme's `base.html` |
|
||
| `robots.txt`, `sitemap.xml`, Atom | engine | Machine contracts with absolute URLs; no layer below can produce them |
|
||
| Draft and future-dated visibility | engine | A visibility rule enforced anywhere else is not enforced (ADR-0024) |
|
||
| Inlined reference stylesheet | engine, accepted | Costs bytes per page and forgoes caching; buys a site root that renders with no asset route. Revisit when the stylesheet is big enough for caching to beat the round trip |
|
||
Why: the widows mistake was not a coding error, it was a missing question, and one deleted feature is not
|
||
evidence the rest are sound. Auditing found the codebase otherwise clean — no Go file writes a tag, a class or
|
||
a style, which the ADR-0036 fragment rule already forced — but it did find one place where the engine had
|
||
quietly chosen how something looks.
|
||
Consequence: cheap — the verdicts are written down, so the next feature argues with a table instead of a
|
||
memory, and future entries carry a layer note before they are built. Expensive — "engine offers both shapes"
|
||
is a slightly larger contract than "engine decides", and a theme that ignores `.Groups` now has to know
|
||
`.Items` exists.
|
||
Revisit if: a verdict here is contradicted by a feature that cannot be built under it. Then the verdict was
|
||
wrong, and it is amended by name rather than worked around.
|
||
|
||
## ADR-0047 — Extras: a bundle's supporting files, enumerated and browsable
|
||
Date: 2026-07-31 · Status: accepted (re-adopts the parked extras entry in `ideas/deferred-decisions.md`, with two
|
||
deviations named below)
|
||
Decision: a bundle may hold `extras/`, which the scanner skips entirely — a `.md` in there is an asset, never a
|
||
bundle. The engine enumerates the tree, sorts it by path, classifies each entry by extension, renders markdown
|
||
and text, and serves anything else as bytes. One route with two behaviours: `…/extras/{path}` renders the
|
||
listing with that entry selected, and `?raw` returns the file. The bundle is looked up first, so an unpublished
|
||
bundle hides its extras exactly as it hides its body (ADR-0024). Entries are excluded from queries, feeds,
|
||
sitemaps and the derivative pass.
|
||
Two deviations from the parked shape. The directory name is fixed rather than a cascade key, because the
|
||
section-level cascade is still parked and nothing reads one. And a request resolves an entry against the
|
||
*enumeration* rather than against the filesystem, so a path that walks out of the tree is simply not found —
|
||
`os.Root` would refuse an escape anyway, but not being in the listing is a stronger and cheaper answer.
|
||
Why: drafts, notes, logs and scans are worth publishing as artefacts of the process, and they are not bundles —
|
||
no frontmatter, no identity, no language variants. Everything needed already existed: the scanner had an
|
||
exclusion rule for directories, `Assets()` knew which bundles own a directory, and `Lookup` already decided
|
||
visibility. `?raw` is a parameter rather than another path because it is a second *representation* of one entry,
|
||
not a second entry.
|
||
Consequence: cheap — no new primitive, no JavaScript, and selecting an entry is an ordinary link with a full
|
||
re-render, so a sidebar-and-pane layout is the theme's business and works without scripting. Expensive —
|
||
`extras` becomes a name no child of a bundle may use, one more template kind exists, and a large extras
|
||
directory is walked per request, which the render benchmark says costs nothing at this size but is the next
|
||
thing a cache would want.
|
||
Revisit if: extras need per-file metadata — a caption, an order, a date. Then they are bundles after all, and
|
||
this decision was wrong.
|
||
|
||
## ADR-0048 — Change detection lives in `internal/ext`, and a rebuild is an atomic swap
|
||
Date: 2026-07-31 · Status: accepted (implements the polling half of ADR-0022)
|
||
Decision: polling lives in `internal/ext/watch`, a feature `cmd` runs in a goroutine, and a settled change calls
|
||
one `rebuilder` function — the same one startup uses. The index is an `atomic.Pointer` swapped whole, so a
|
||
request reads the site that was current when it arrived. `content/`, `templates/` and `site.yaml` are watched;
|
||
only content takes effect without a restart, since templates are parsed once unless `-dev on`.
|
||
Why: it went to `ext` rather than core because the core ceiling had ~170 lines left and ADR-0041 said a second
|
||
raise should be read as evidence something belongs in `ext` — this did, being a poller that is deletable without
|
||
trace. Startup and reload share one function because a reload path that differs from the startup path is a
|
||
reload path that drifts. And the swap is atomic because the alternative — mutating the index in place — is a
|
||
data race with every in-flight request.
|
||
Consequence: cheap — an edit appears within a couple of seconds with no restart and no dependency, and
|
||
`Fingerprint` is testable without any timing. Expensive — a poll costs a stat per file, so a very large site
|
||
would want notifications after all; and the watcher never stops, because the process ending is what stops it,
|
||
which means no test can assert its shutdown.
|
||
Revisit if: a site grows big enough that polling shows up in a profile, or an operator wants a rebuild on
|
||
demand — a signal handler or an endpoint, not a shorter interval.
|
||
|
||
## ADR-0049 — Completing the reference theme found four holes in the contract
|
||
Date: 2026-07-31 · Status: accepted (extends `theme-contract.md`, additively)
|
||
Decision: a page now also receives `.Sections` (the site's sections, for navigation), `.Tags` (this bundle's own
|
||
terms with their listing URLs) and `.ExtrasURL` (empty when the bundle has none). Sections arrive through
|
||
`Renderer.Navigation`, a callback set at wiring time, because sections change when content does and a copy held
|
||
by the renderer would go stale. The reference theme uses all three, plus visible `.Alternates` links and the
|
||
sequence's `.First`/`.Last`, which existed and were never rendered.
|
||
Why: the theme was supposed to need no engine work, and the audit said that if it did, the gap was in the
|
||
contract rather than in the theme (ADR-0046). It did. Four things a reader could not reach from a page: any other
|
||
section, the tags on the page they were reading, the extras beside it, and the same page in the other language.
|
||
Each is a fact only the engine has, so each belongs in the contract — and none of them could be worked around in
|
||
a template, which is exactly the test.
|
||
Consequence: cheap — the contract grew by three fields, all additive and all zero-valued when absent, and the
|
||
reference theme is now a complete demonstration rather than a partial one. Expensive — `Navigation` is a second
|
||
set-once callback beside `Reload`, so the renderer has two pieces of state that are wired rather than passed;
|
||
`.Tags` and `.ExtrasURL` cost one Stat per bundle render.
|
||
Revisit if: a fourth set-once callback appears. Three would say the renderer wants a construction options
|
||
struct rather than a constructor plus setters.
|
||
|
||
## ADR-0050 — The root lists everything; `khosra demo` writes a site, not content in this repo
|
||
Date: 2026-07-31 · Status: accepted
|
||
Decision: `/` serves a listing of every bundle, newest first, paginated like any other — and 404s only when
|
||
nothing is published. And `khosra demo` writes a site root that exercises every feature, generating its filler
|
||
rather than copying stored files, into an empty directory the human names.
|
||
Why: serving the demo found that a visitor to the site's own address got a **404**. ADR-0008 keeps every bundle
|
||
under a section and leaves the root engine-owned, which is right, but "engine-owned" was never given an answer —
|
||
so the engine now gives the only one it can from content alone. A home page an author writes by hand is a
|
||
separate question and still open.
|
||
The demo generates rather than stores because nothing in this repository is content (ADR-0011): a directory of
|
||
demo Markdown here would be exactly that. Composing it in code keeps the rule intact, and it makes the demo a
|
||
test of the engine rather than a fixture — anything the engine can do that the generator cannot express is a
|
||
gap.
|
||
Consequence: cheap — a site has a front page with no configuration, the demo is one command, and `khosra check`
|
||
passing on generated output is a real end-to-end assertion. Expensive — the root listing mixes sections, which a
|
||
theme may not want (it can redefine `main`, and `.Items` carries `.Section`); and the demo's filler lives in Go,
|
||
so a feature added later must be added there too or the demo silently stops covering it.
|
||
Revisit if: someone wants a hand-written home page. That is a bundle at the root, which ADR-0008 currently
|
||
forbids, so it is a decision rather than a patch.
|
||
|
||
## ADR-0051 — The demo is a tracked site in `examples/`, kept true by gates
|
||
Date: 2026-07-31 · Status: accepted (replaces ADR-0050's generator: the Go generator is deleted)
|
||
Decision: the demonstration site lives in `examples/demo-site/` as ordinary Markdown, images, templates and
|
||
`site.yaml` — read, edited and served like any site. `make demo` serves it. Two gates keep it current: a test in
|
||
`internal/web` serves this directory through the real handler with one case per feature, and `verify.sh` runs
|
||
`khosra check` over it and fails on anything fatal. Adding a feature means adding it here and adding its case.
|
||
Why: the human asked for a demo extensive enough to review by hand, and generated filler cannot be reviewed —
|
||
you cannot read a Go function and see what a reader would see. My reason for generating it was ADR-0011, but that
|
||
rule is about the *author's site root* being external, not about fixtures: `conventions.md` already keeps golden
|
||
files in `testdata/`, and this is the same category one size up. Keeping both a generator and files would have
|
||
been two sources of truth for the same thing, so the generator went.
|
||
Consequence: cheap — the demo is reviewable, editable, and servable in one command; the coverage test turns "the
|
||
demo is out of date" from a thing nobody notices into a failing build; and 30 bundles across six sections
|
||
exercise pagination, fallback, sequences and galleries at realistic size. Expensive — about 200KB of committed
|
||
JPEGs, and a feature added without a case in the coverage test is still invisible, so the test is now part of
|
||
what "done" means.
|
||
Revisit if: the example grows big enough to slow the test suite, or someone wants several examples — then this
|
||
is `examples/<name>/` with the coverage test parameterised, not a second mechanism.
|