Deleting the widows feature answered one question; the human asked the general one. So every feature built so far is now audited, with the verdicts as a table in ADR-0046, and each remaining queue entry carries a layer note before anyone writes code for it. The codebase turned out to be otherwise clean, and I checked rather than remembered: no Go file writes a tag, a class or a style. ADR-0036's rule that all markup comes from a fragment had already forced that. One real finding. A tag listing received only `.Groups`, so the engine had decided that a tag listing *looks* grouped — ADR-0032's own reasoning was "so a busy term stays readable", which is a readability judgement. It now receives both shapes: the partition, because a template cannot group for itself, and the flat list, because choosing between them is markup. `Item` gained `.Section` so a flat listing can still say where an entry came from. Two judgement calls recorded rather than left implicit. The typographer stays: turning `--` into an en dash is a character transformation no stylesheet can express. Chrome strings stay: translations are data, and the alternative is every theme hardcoding Bengali month names. The inlined stylesheet is accepted with its cost written down — bytes per page, no caching — and a trigger for revisiting it. Two patterns worth reusing came out of this: offer the shape rather than choosing it, and a split feature is normal — search will be an engine-built index queried by the browser, not one or the other.
671 lines
54 KiB
Markdown
671 lines
54 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.
|