docs/content-model.md opens with "Engine specification". It is also where the rule lives that a leading underscore makes a file unaddressable — and the human who owns this site did not know that rule, because nothing in this repository is addressed to an author. Twelve documents named docs/ while being exclusively about building the parser is a signpost pointing at the wrong room. Naming the directory for its audience makes the gap visible instead of hiding it. docs/ is now reserved and deliberately absent: an empty docs/ is an honest statement that end-user documentation does not exist, where docs/ full of parser specs was a claim that it did. HARNESS.md stays at the root. Root holds the three entry points — README.md for a human, CLAUDE.md for an agent, HARNESS.md for whoever maintains the machine — and harness/README.md is the map of the directory, so moving the guide inside would have collided with it for nothing. Mechanical and wide: 100 path references across 24 files. Every verify.sh gate that names a doc by path, the directory lists the dangling-path and ADR-number gates scan, surface.sh's output target, the Makefile, CLAUDE.md's read order, the skill, four commands, and two Go package comments. A first pass with a shell loop silently edited only four files and the rest still said docs/; the fix was to write the file list out and check the remaining count was zero rather than trust the loop's exit status. No rule, threshold, gate or obligation moved — this is a rename, and the gates demonstrated it twice: they stayed green on the new paths, and the ADR-number gate caught ADR-0082 before the entry existed. Deferred, both on the human's call: the end-user documentation site itself, which wants its own decision about where it lives and whether its claims are gated; and moving examples/ under docs/, since demo-site is a live site root that verify.sh, the coverage test and make demo all point at, and moving it would couple a rename to a design nobody has made. 31 files, +146/-106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1444 lines
122 KiB
Markdown
1444 lines
122 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
|
||
`harness/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 `harness/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); its last clause — what a
|
||
rebuild applies — is superseded by ADR-0055
|
||
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 for the root listing; the `khosra demo` half is superseded by ADR-0051,
|
||
which deleted the generator in favour of the tracked site in `examples/`. The Decision text below stands as
|
||
written — read it as history, and never as a subcommand that exists.
|
||
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.
|
||
|
||
## ADR-0052 — Committing is part of the loop, and unasked-for automation is announced once
|
||
Date: 2026-08-01 · Status: accepted
|
||
Decision: the loop gains a seventh step — the agent commits its own work before reporting, one revertible
|
||
unit per commit, never pushing. In a session where the human has not asked for it, the first commit is
|
||
preceded by a prominent line naming what is happening and how to stop it; after that, silence is consent.
|
||
Why: the human's reason, and it is the right one — a commit is trivially reverted, while work that only ever
|
||
existed in the working tree cannot be recovered with certainty. This session lost uncommitted work once
|
||
already, to a `git checkout` while signing was broken. The notice exists because the safety argument covers
|
||
the *loss*, not the *surprise*: committing into someone's repository without having said you would is a
|
||
different failure from committing too much.
|
||
Consequence: cheap — every step of a long autonomous run is bisectable, and a bad turn costs one revert
|
||
instead of a reconstruction. Expensive — `git log` now carries the agent's pacing, so a sloppy unit boundary
|
||
is permanent noise; and this is behaviour no gate can check, since no script can see whether a sentence was
|
||
said. `conventions.md` "Git" owns what one commit contains; `CLAUDE.md` §4 owns when one happens.
|
||
Revisit if: the log fills with commits nobody would revert separately — then the unit is wrong, not the
|
||
automation; or the human wants a review gate before anything lands, which is a different default, not a
|
||
tweak to this one.
|
||
|
||
## ADR-0053 — Context economy is a doc with a floor, plus three gates
|
||
Date: 2026-08-01 · Status: accepted (adopts `ideas/token-conservation.md`)
|
||
Decision: `harness/context-economy.md` owns how the agent spends context — read the compressed form first,
|
||
fewer turns before fewer bytes, script anything repeatable, shrink output at the source, never pay twice
|
||
for the same bytes. Three parts are mechanical rather than remembered: `scripts/surface.sh` generates
|
||
`harness/surface.md`, the pre-commit hook regenerates and stages it while `verify.sh` compares it
|
||
independently, `CLAUDE_LOC_MAX=150` bounds the file re-sent every turn, and `verify.sh --quiet` prints
|
||
only what needs acting on (the hook uses it). Generated artifacts are produced by the hook, never by
|
||
memory; the hook refuses a commit with unstaged `.go` changes, since what it generated describes the
|
||
working tree rather than the commit.
|
||
Why: the binding constraint is context per session, not typing speed, so wasted bytes are features not
|
||
built. The measurements that decided the shape: `go doc` on `internal/content` is 38 lines against 1,579
|
||
of source, and `surface.md` is 261 against 3,757. The doc leads with a floor because every cheap failure
|
||
mode is also a saving — skipping the owning doc, guessing a signature, reporting from a diff, thinning a
|
||
test — and each has already cost this repo a defect. Generated-and-gated rather than hand-written,
|
||
because an index nobody regenerates is worse than no index: it is confidently wrong.
|
||
Consequence: cheap — a green gate run is 2 lines instead of 43, "where does X live" is one grep of a
|
||
tracked file, and the disciplines are auditable in one place. Expensive — a fourth generated artifact to
|
||
keep honest, one more budget to raise deliberately, and 261 lines of committed noise in every diff that
|
||
adds a function. Deliberately *not* mechanical: nothing can detect a redundant read, so most of the doc
|
||
is discipline, and it says so instead of implying enforcement (rule 8).
|
||
Revisit if: the surface grows past a few hundred lines, in which case it is per-package files rather than
|
||
one; or `surface.md` churn starts drowning real diffs, which is the argument for generating it on demand
|
||
instead of tracking it.
|
||
|
||
## ADR-0054 — The feature-loop skill drops its `khosra-` prefix
|
||
Date: 2026-08-01 · Status: accepted (amends ADR-0030's skill-name clause only; the rest of that naming
|
||
decision stands)
|
||
Decision: the skill directory is `.claude/skills/feature-loop/`, not `khosra-feature-loop`.
|
||
Why: the human asked, and the prefix was redundant — every file in this repository is khosra's, so a
|
||
`khosra-` prefix inside it distinguishes nothing. ADR-0030 named the skill as part of settling the
|
||
project's name, which made sense while `atelier` was still being erased; it does not survive contact
|
||
with the fact that the skill is only ever loaded from this repo.
|
||
Consequence: cheap — a shorter name in the one place it is written (`CLAUDE.md` §4) and in the skill
|
||
listing. ADR-0030 keeps its original text because `decisions.md` is append-only, so a reader who lands
|
||
there sees the old name; this entry is the pointer that makes it resolvable.
|
||
Revisit if: the skill is ever published or shared outside this repo, where an unprefixed `feature-loop`
|
||
would collide with everyone else's.
|
||
|
||
## ADR-0055 — A rebuild swaps the theme too; `site.yaml` is restart-only
|
||
Date: 2026-08-01 · Status: accepted (supersedes ADR-0048's last clause — "only content takes effect
|
||
without a restart" — and delivers the template half of ADR-0022's stated consequence); its `-dev`
|
||
clause, that a per-request reparse is a trade worth keeping, is superseded by ADR-0056
|
||
Decision: a rebuild reparses the theme and swaps it in whole, next to the index swap, so an edited
|
||
template takes effect within a poll interval and without a restart. The parsed sets and the stylesheet
|
||
become one immutable `parsedTheme` behind an `atomic.Pointer`, replaced rather than mutated. A parse
|
||
failure keeps the theme that was working and logs. `site.yaml` goes the other way: it is dropped from
|
||
`watch.Fingerprint` and applies only at startup.
|
||
Why: ADR-0022 already promised that "a template edit in the site root invalidates through the same path
|
||
as content"; the code never did it, so the watcher fired a rebuild that changed nothing — a defect
|
||
against a recorded decision rather than a missing feature. The theme is swapped rather than reparsed per
|
||
request because `-dev` already owns the per-request trade and a serving build should not pay it. Settings
|
||
are the opposite case: they are copied by value into the renderer, the handler, the feeds and the
|
||
sitemap, so a live read would have to thread through all four, and applying it to some of them is worse
|
||
than applying it to none — a title that changes on a page but not in its feed is a bug that looks like a
|
||
feature. Removing it from the fingerprint makes the honest behaviour visible instead of hiding it behind
|
||
a rebuild that no-ops.
|
||
Consequence: cheap — theme edits need no restart, the reparse is off the request path, and the atomic
|
||
swap retires the in-place field mutation `-dev` used, which was a data race with every in-flight render.
|
||
Startup parses the theme twice, once in `New` and once in the first rebuild, because startup and change
|
||
share one path and that is worth more than the microseconds. Expensive — `site.yaml` now needs a restart
|
||
with nothing in the logs to say so, and the renderer must reach through `theme.Load()` at every use.
|
||
Revisit if: anything else wants a live `site.yaml` — then thread a live read through all four readers at
|
||
once, or give settings the same atomic treatment the theme just got.
|
||
|
||
## ADR-0056 — One theme snapshot for the whole site; the poll interval is a parameter
|
||
Date: 2026-08-01 · Status: accepted (supersedes ADR-0055's clause that `-dev` owns a per-request reparse;
|
||
implements the `-poll` flag ADR-0022 specified and never got)
|
||
Decision: `Refresh` is the only thing that ever replaces the theme, so every page served after a swap was
|
||
rendered from the same snapshot. The per-render reparse is deleted — `Renderer.reload`, `Reload()` and
|
||
`fresh()` are gone — and `-dev on` gets its promptness from polling every 250ms instead, unless `-poll`
|
||
was passed, in which case the operator's interval wins. `Watch` takes the interval and the settle window
|
||
as arguments; the `Interval` and `Settle` package variables are deleted. `-poll` sets the interval and
|
||
`-poll 0` disables watching entirely, as ADR-0022 said it would. The theme is reparsed in the watcher's
|
||
callback rather than inside `rebuilder`, so startup parses it exactly once, in `New`.
|
||
Why: per-render reparsing could not keep the site coherent, and did not. Only two of the four render
|
||
methods called `fresh()` — `Bundle` and `Tag` never did — so under `-dev on` a listing served an edited
|
||
template while a bundle served the old one, verified on the pre-G1 binary: `/posts/` answered V2 while
|
||
`/posts/hello/` answered V1, permanently. Adding the missing calls would have made four places that must
|
||
each remember, and `Partial` runs *during* a page's Markdown conversion, so a single page could still mix
|
||
two themes. A whole-site swap makes coherence structural instead of a discipline, and it costs a poll
|
||
interval of latency in dev, which is the cheaper half of that trade. The interval had to become a
|
||
parameter for dev and `-poll` to want different ones — the second and third callers, so no anticipation.
|
||
Consequence: cheap — one less exported method, one less field, two fewer package variables, and a test
|
||
that pins every render path to one snapshot at once. `-poll 0` gives immutable deployments a way to stop
|
||
polling, and no test mutates package state to control timing any more. Expensive — an author's template
|
||
edit now appears on the next poll rather than the next request (~375ms in dev, not instant); and the
|
||
theme and index are still two `Store` calls, so a request landing between them sees a new theme with the
|
||
previous index. Both halves are internally coherent and the gap is microseconds, but it is not a snapshot
|
||
of the disk, and closing it means one pointer holding both, which is `web.Handler`'s signature.
|
||
Revisit if: that microsecond gap ever matters — then the index and the theme become one snapshot behind
|
||
one pointer, and `web.Handler` takes an accessor for it instead of two arguments.
|
||
|
||
## ADR-0057 — `state.md`'s currency is compared, not declared
|
||
Date: 2026-08-01 · Status: accepted (retires the `verified against` sha the harness carried from day one)
|
||
Decision: `verify.sh` decides whether `harness/state.md` is current by comparing the last commit that touched
|
||
it against the last commit that touched a `.go` file — current when the doc's commit is the same or newer.
|
||
The hand-written `**Verified against:** <sha>` line is deleted, and no commit exists solely to write one.
|
||
Why: the sha could only ever be wrong. A commit cannot name itself, so the line had to be written *after*
|
||
the commit it described, which forced a trailing `state:` commit every time — and `conventions.md` has
|
||
always said code, test, `state.md` row and ADR belong in one commit, so the gate was pushing against the
|
||
convention it was meant to protect. Folding the trailing commits away then left the sha naming a commit
|
||
that no longer existed, which is how this session reproduced the same mess the last history rewrite
|
||
cleaned up. Two commits in `backup/pre-fold` per feature, and `8905686` needed afterwards to name the
|
||
survivor, are the evidence. Git already knows when each file last changed; asking it is free and cannot
|
||
drift.
|
||
Consequence: cheap — one fewer thing to write, one fewer commit per feature, and a rewrite of history no
|
||
longer invalidates a doc. The gate's intent is unchanged, so `state.md` still cannot lag the code silently.
|
||
Expensive — the doc no longer records *which* commit a human last reconciled it against, so a deliberate
|
||
`/refresh-docs` pass leaves no mark beyond its own commit; if that turns out to matter, it is a line in the
|
||
commit body, not a value in the file. The check is also weaker on a squashed or rebased history, where both
|
||
files move in the same commit by construction.
|
||
Revisit if: someone wants the reconciliation *moment* recorded rather than the currency, which is a
|
||
different fact and belongs in the arc retro log.
|
||
|
||
## ADR-0058 — The Markdown dialect is CommonMark plus five, named once
|
||
Date: 2026-08-01 · Status: accepted, except the task-list exclusion, which ADR-0078 supersedes. The refusal
|
||
of the GFM bundle and of linkify stands and is the load-bearing half. The Decision text below stands as
|
||
written — read the task-list sentence as the position that was held, not the one that holds.
|
||
Decision: tables, footnotes, definition lists, strikethrough and automatic heading ids are enabled; task
|
||
lists, linkify, CJK line breaking and the GFM bundle are refused. The list lives in `cmd/khosra/wire.go`
|
||
beside the features, and `content-model.md` carries the authored form. Footnote ids inside an included file
|
||
are namespaced by that file, through `shortcodes.FootnotePrefix`.
|
||
Why: "which Markdown dialect" is permanent — content written against it cannot be un-written — so it is
|
||
settled in one decision rather than admitted an extension per feature. The five chosen serve what this
|
||
engine is for: footnotes carry citations in technical writing and asides in fiction, tables carry
|
||
specifications, definition lists carry glossaries, heading ids are the half of a table of contents only the
|
||
engine can supply. The four refused each fail a test rather than a taste: task lists publish nothing,
|
||
linkify rewrites an author's plain text into markup that ADR-0034 says the engine may not touch, CJK is the
|
||
wrong script family for a Bengali site, and the bundle is a package deal for two of them. All five are
|
||
parse-phase, composing in goldmark's extender list, so the render-transform counter stays where it is.
|
||
Consequence: cheap — five lines where features are enabled, and a test beside the list that renders the
|
||
whole dialect through the shipped `extenders`, which nothing tested before. Expensive — the demo's example
|
||
test rebuilds that list by hand, because a package cannot import a `main`, so the two can drift; the
|
||
reference theme now has to style markup it never emitted before; and enabling tables changes how existing
|
||
content renders, since a `|` line that used to come out as prose is now parsed.
|
||
Revisit if: a sixth extension is wanted — which is a change to this decision and gets an ADR of its own,
|
||
not a quiet line in the list.
|
||
|
||
## ADR-0059 — Shortcodes are generic directives: `::name{key=value}`
|
||
Date: 2026-08-01 · Status: accepted (replaces the call syntax of ADR-0036 and ADR-0038; everything else
|
||
those decide — that a call renders through a theme fragment, that an include is content and stays inside
|
||
its bundle — is untouched)
|
||
Decision: a call is `::name{key=value}` alone on a line, quotes only where a value contains spaces, braces
|
||
omitted when there are none. `:::name{…}` … `:::` is reserved for container directives and is deliberately
|
||
not parsed until the first feature needs a body. The old `{{< name key="value" >}}` is retired outright:
|
||
`khosra check` reports it as fatal and names the replacement, and the demo is migrated in this change.
|
||
Why: the old form cost eleven characters of punctuation per call and could not carry a body, which
|
||
admonitions need. Generic directives are an existing convention — remark-directive, MyST, Docusaurus — so
|
||
authors and other tools already know the shape, and the inline form `:name[text]{…}` is there when the
|
||
dynamic shortcodes arrive. Retiring rather than aliasing, because two syntaxes for one thing is two parsers
|
||
and two sets of tests forever, and `check` makes the migration mechanical: run it until it exits zero.
|
||
Consequence: cheap — shorter calls, a body form reserved without building it, and unquoted values, which is
|
||
most of the saving. Expensive — a disk-contract change, so every site root written against the old form
|
||
must be migrated by hand; the trigger byte moves from `{` to `:`, which prose uses far more often, so the
|
||
parser must refuse `:::`, `: definition` and `3::4` and does; and the alt-text check's regex had to move
|
||
with the syntax, which is exactly the kind of silent breakage a syntax change causes.
|
||
Revisit if: the container or inline forms need to differ from the directive convention — which would be a
|
||
change to this decision, not a quiet addition.
|
||
|
||
## ADR-0060 — Raw HTML renders for site-root content, and the gate narrows to one call site
|
||
Date: 2026-08-01 · Status: accepted (replaces ADR-0036's "raw HTML stays dropped" clause; the rest of that
|
||
decision — a shortcode renders through a theme fragment and writes no markup itself — is untouched)
|
||
Decision: `html.WithUnsafe()` is enabled in `internal/render/render.go`, so HTML an author writes in a
|
||
bundle body reaches the page. `verify.sh` no longer forbids the option; it requires it in exactly that one
|
||
file, and fails when it appears anywhere else. An untrusted source — a comment, a webmention — gets its own
|
||
goldmark without it, and building that renderer waits until such a source exists.
|
||
Why: dropping it was silently destructive. `H<sub>2</sub>O` rendered as "H2O", `10<sup>6</sup>` as "106",
|
||
and `check` reported nothing, so an author lost meaning with no signal anywhere — measured on the real
|
||
binary before this change. Invariant 2 already says content from the site root is *trusted*; the old gate
|
||
was defending the trusted half of a boundary that was never in question, while the untrusted half has no
|
||
code yet to defend. Chemistry, units, exponents and keystrokes are exactly what a hard-science site needs
|
||
and what no Markdown dialect expresses. Keeping the gate but narrowing it costs nothing and keeps the
|
||
boundary mechanical: the thing worth checking was never "is raw HTML on" but "how many pipelines trust
|
||
their input".
|
||
Consequence: cheap — authors write HTML where the dialect has no syntax, one gate instead of none, and the
|
||
silent-loss failure disappears. Expensive — a `<script>` an author writes now runs, which is their site and
|
||
their choice, but it means an author who pastes something they did not read has no net; and the day
|
||
comments arrive, the untrusted renderer must be built rather than assumed, with the gate as the reminder.
|
||
Revisit if: an untrusted source appears — which is when the second renderer is built and this gate proves
|
||
whether the split was ever real.
|
||
|
||
## ADR-0061 — Inline notation is khosra's, and it owns the tilde
|
||
Date: 2026-08-01 · Status: accepted (amends ADR-0058: strikethrough is unchanged as an authored syntax, but
|
||
`internal/ext/notation` provides it instead of `extension.Strikethrough`)
|
||
Decision: `internal/ext/notation` adds `~sub~`, `^sup^` and `==mark==`, and takes over `~~strike~~`. A single
|
||
run is scanned to its closing byte and may not cross whitespace; a doubled run goes through goldmark's
|
||
delimiter machinery and may. Extensions counter 3 → 4.
|
||
Why: goldmark's strikethrough claims a *single* tilde as well as a double, so with it enabled `H~2~O`
|
||
rendered `H<del>2</del>O` — measured before this change. Two features cannot share a byte and both be
|
||
correct, so one of them has to own it; taking strikethrough is cheaper than inventing a subscript syntax
|
||
nobody else uses, and it leaves the authored form exactly as ADR-0058 documented it. The whitespace rule is
|
||
the second half: under delimiter rules `x^2 + y^2 = z^2` pairs its carets across the expression and turns
|
||
prose into markup, which is the same class of silent damage raw HTML dropping caused. Pandoc draws the line
|
||
in the same place, so a subscript holds a formula and never a phrase.
|
||
Consequence: cheap — four marks from one table, and the two failure modes are now tests rather than
|
||
surprises. Expensive — a single-run mark takes its content as text, so `~*a*~` is not emphasised inside a
|
||
subscript, which is a limit worth stating rather than discovering; and khosra now maintains a strikethrough
|
||
implementation it used to get from upstream.
|
||
Revisit if: a mark wants markup inside a single run — which needs recursive inline parsing and is a
|
||
different mechanism, not a wider table.
|
||
|
||
## ADR-0062 — Abbreviations are a definition line plus a replacement pass
|
||
Date: 2026-08-01 · Status: accepted (extends ADR-0061's package with a second mechanism)
|
||
Decision: `*[TERM]: expansion` on its own line defines an abbreviation, renders nothing itself, and every
|
||
whole-word occurrence of TERM in that document becomes `<abbr title="expansion">`. A block parser claims the
|
||
line; an AST transformer does the replacing. Code spans, autolinks, raw HTML and an already-expanded term
|
||
are skipped. The longest defined term wins where two could match.
|
||
Why: PHP Markdown Extra's form, so it is a syntax authors already know. It is a transformer rather than an
|
||
inline parser because a definition may appear *after* the use it explains — a parser only ever sees what it
|
||
has already read — and a block parser rather than a pattern found later because the line has to stop being
|
||
content, which is the part an author would notice going wrong. Whole-word matching is what keeps `HTMLish`
|
||
and `xHTML` intact; without it a definition quietly rewrites every substring on the page.
|
||
Consequence: cheap — one more mark from the same package, and the definition can sit anywhere in the file.
|
||
Expensive — the replacement walks every text node in the document, which is work proportional to the page
|
||
rather than to the number of definitions; and definitions are document-scoped, so a term defined in a page
|
||
does not reach an included fragment, which is parsed on its own bytes (ADR-0038) exactly as footnotes are.
|
||
Revisit if: definitions want to be site-wide, which is a settings-cascade question and not this mechanism.
|
||
|
||
## ADR-0063 — `:name:` is an icon, and the theme owns what one is
|
||
Date: 2026-08-01 · Status: accepted
|
||
Decision: `:name:` inline parses to a call on one theme fragment, `icon`, receiving `.Args.name` and nothing
|
||
else. The engine holds no list of icon names. A name must start with a letter and hold only letters, digits,
|
||
hyphens and underscores, and neither colon may touch an alphanumeric. When the fragment renders nothing, the
|
||
engine writes the author's original `:name:` text back. The reference theme maps six names to Unicode
|
||
characters and ships no sprite, font or asset.
|
||
Why: the surveyed engines split three ways — Unicode (Hugo, Pandoc), a remote image per icon (Jekyll's
|
||
jemoji, which fails the sovereignty test outright), and inlined SVG from a bundled set (MkDocs Material,
|
||
which is the one worth copying). All three are decisions about *markup*, which ADR-0036 puts in the theme, so
|
||
the engine's half is identical whichever a theme picks: parse the name, hand it over. A theme wanting Font
|
||
Awesome or Material ships a sprite in its own `base.html` and redefines one fragment; no webfont, no request,
|
||
no script. The boundary rules are the hard part, because the colon is the commonest punctuation in technical
|
||
prose: without them `10:30:15`, `key:value:pair` and `Note: this` all become icon calls, which is a silent
|
||
edit to someone's sentence. The literal fallback closes the same hole from the other side — an unrecognised
|
||
name is left on the page rather than deleted from the middle of a paragraph.
|
||
Consequence: cheap — no icon table in Go, ever; the set is swapped by editing one template; and the two ways
|
||
this could damage prose are tests. Expensive — a theme cannot tell the engine which names it knows, so an
|
||
unknown name costs a fragment execution before falling back; and the reference theme's Unicode set is a
|
||
`{{if}}` chain, which is fine at six names and would not be at sixty.
|
||
Revisit if: a theme wants to declare its set to the engine — which is the settings cascade, not this.
|
||
|
||
## ADR-0064 — Container directives, and a fragment may receive a body
|
||
Date: 2026-08-01 · Status: accepted (spends the `:::` form reserved by ADR-0059; extends the theme contract
|
||
additively with `Fragment.Body`)
|
||
Decision: `:::name{…}`, a body of Markdown, then `:::` on its own line. The body is rendered first and handed
|
||
to the theme fragment of that name as `.Body`, already HTML. One level: a `:::` inside a container closes it.
|
||
When the theme has no template for the kind, the engine writes the body out unwrapped. The reference theme
|
||
defines `note`, `warn` and `tip`, each an `<aside>` with an optional title.
|
||
Why: admonitions are the first call that wraps content rather than replacing a line, which is what the
|
||
reserved form was for — building it in ADR-0059 would have been a mechanism with no user. The body is
|
||
rendered by a transformer rather than the node renderer for the same reason an include is: rendering a
|
||
subtree needs the document, and a node renderer has no way to get one. Falling back to the bare body is the
|
||
same principle as an unknown icon keeping its text — a theme not knowing one name must never cost an author
|
||
paragraphs, and an unstyled aside is a far smaller failure than a missing one.
|
||
Consequence: cheap — `epigraph`, `spec` or anything else that wraps content is now a template, not a code
|
||
change; `.Body` is two lines of contract. Expensive — containers do not nest, which is a real limit for a
|
||
warning inside a note; every kind needs its own template, so a theme with twelve admonition styles writes
|
||
twelve; and the body is rendered before the fragment sees it, so a fragment cannot choose *not* to render it.
|
||
Revisit if: nesting is genuinely wanted — which needs a fence length rule like CommonMark's code fences, not
|
||
a deeper parser.
|
||
|
||
## ADR-0065 — `::toc` hands headings to the theme, and the core ceiling moves to 2850
|
||
Date: 2026-08-01 · Status: accepted (second raise of `CORE_LOC_MAX`, 2800 → 2850; the first was ADR-0041)
|
||
Decision: `::toc` renders through a `toc` fragment receiving `.Headings` — level, text and the id goldmark
|
||
assigned (ADR-0058). The engine collects; the theme decides whether that is a list, a sidebar, or nothing.
|
||
The reference theme emits a flat `<ol>` with a level class per entry. `CORE_LOC_MAX` becomes 2850 to fit the
|
||
eight lines of contract this needs.
|
||
Why: a theme cannot enumerate headings — templates cannot parse HTML — so the engine is the only thing that
|
||
can supply them, and `Fragment` is the seam it already has. Collection lives in `internal/ext` and only the
|
||
type and the field are core. ADR-0041 said to read a second raise as evidence something belongs in `ext`, and
|
||
it does: `feed.go` and `discover.go` are features by any reading. They cannot move, because an `ext` feature
|
||
cannot own a **route** until the extension registry exists, which the counter in `state.md` says buys nothing
|
||
yet. So the thing that should leave core is blocked on a different decision, and pretending eight lines of
|
||
contract fit would have been the dishonest fix.
|
||
Consequence: cheap — a contents list is a template, entries carry depth so indentation is CSS, and an entry
|
||
whose heading has no id is skipped rather than linked nowhere. Expensive — the author places the call, so a
|
||
theme cannot put a contents list in a sidebar on every page; that wants `Page.Headings`, which is more core
|
||
and can be argued for on its own. Entry text is the heading's words with markup stripped, because a link
|
||
inside a link is not markup a browser accepts.
|
||
Revisit if: the registry arrives and routes become ownable — at which point feeds and discovery leave core
|
||
and this ceiling should come back down rather than stay as headroom.
|
||
|
||
## ADR-0066 — Three author controls: `include: merge`, an explicit heading id, and `::toc{depth}`
|
||
Date: 2026-08-01 · Status: accepted (adds a second include model beside ADR-0038's; that decision's
|
||
mechanism is unchanged and remains the default)
|
||
Decision: `include: merge` in frontmatter splices a bundle's `::include` lines with the files they name
|
||
**before** the parse, so the page is one document: footnotes collect at its end, an abbreviation defined
|
||
anywhere reaches everywhere, and every heading is in its contents list. Without the flag, nothing changes —
|
||
each fragment is still its own document with its ids namespaced. A heading may declare its own anchor,
|
||
`## Title {#stable-anchor}`. `::toc{depth=N}` lists headings no deeper than N. `render.Renderer.Compose` is
|
||
the seam the splice arrives through, set at wiring time like `Navigation`.
|
||
Why: composing a page from several files is one of the reasons to have includes at all, and under ADR-0038's
|
||
model the notes of each fragment render where it sits — an `<hr>` and a numbered list halfway down the
|
||
article. Moving the rendered block afterwards would mean editing goldmark's own markup; splicing the source
|
||
instead gets the right answer from the parser rather than around it. A flag rather than a change of default,
|
||
because the embedded model contains the damage a malformed fragment does, and existing content must not
|
||
re-render differently. The heading id is the control that matters most: an id derived from the text changes
|
||
when the text does, so rewording a heading silently breaks every link to that anchor — unacceptable in an
|
||
engine whose first value is that published addresses are permanent. `depth` exists because a theme cannot
|
||
know per page how deep a contents list should go, and the author can.
|
||
Consequence: cheap — one endnote list where the author asks for it, permanent anchors, and shorter contents
|
||
lists, none of which changes a site that says nothing. Expensive — two include models to keep working, and
|
||
under `merge` a fragment's Markdown is no longer contained: an unclosed code fence in a part now affects the
|
||
whole page, which is what textual inclusion means everywhere it exists.
|
||
Revisit if: a third include model is wanted, which would be evidence the flag should have been an enum of
|
||
composition strategies rather than two paths.
|
||
|
||
## ADR-0067 — A fragment receives the language, because it supplies words of its own
|
||
Date: 2026-08-01 · Status: accepted (extends the theme contract additively; `Fragment` gains `Lang` and
|
||
`Origin` carries it)
|
||
Decision: `Fragment.Lang` is the language being served, captured on each call at parse time. The reference
|
||
theme uses it to label an untitled admonition (`note`, `warn`, `tip`), an untitled panel (`details`) and a
|
||
contents list (`contents`), all from the engine's phrase table. Words the author wrote are still never
|
||
touched.
|
||
Why: three fragments added this session need a word the author did not write — a `:::warn` with no title gave
|
||
the reader nothing to say it was a warning, and the contents list had no label at all, which is an
|
||
accessibility gap as well as an untranslated one. `t` needs a language and `Fragment` had none, so those
|
||
words could only ever have been hardcoded English on a site that serves Bengali. Captured at parse time
|
||
rather than passed at render time because a node renderer never receives the parse context, which is the
|
||
same constraint that put pictures and headings on the node.
|
||
Consequence: cheap — five phrase keys, and a fragment can now say anything the chrome table can. Expensive —
|
||
every call node carries a language it mostly does not use, and a theme adding a *new* word of its own still
|
||
cannot add a phrase key, which waits for the settings cascade exactly as it did before.
|
||
Revisit if: a site wants to override a phrase, which is the cascade and not this.
|
||
|
||
## ADR-0068 — `sizes` belongs to the theme, beside the layout it describes
|
||
Date: 2026-08-01 · Status: accepted
|
||
Decision: the reference theme's `figure` and `gallery` fragments emit `sizes` alongside `srcset`, and a
|
||
gallery entry also carries `loading="lazy"` and `decoding="async"`. The values describe the reference
|
||
stylesheet — a 32rem measure, two gallery columns above 36rem — and a theme that changes the layout changes
|
||
them with it. The engine supplies widths and dimensions and says nothing about `sizes`.
|
||
Why: without it a browser assumes `100vw`, so a gallery thumbnail in a 16rem column fetched the 1600px
|
||
original — the derivative pass was costing bandwidth on exactly the page it exists to save it on. `sizes` is
|
||
the one part of responsive images that cannot be computed from the picture: it is a statement about where
|
||
the picture sits, which only the layout knows, which is the theme (ADR-0046). Lazy loading goes on gallery
|
||
entries and not on a figure, because a figure is often the first thing on the page and deferring it delays
|
||
what the reader came for.
|
||
Consequence: cheap — five attributes, no engine change, and the derivative pass finally pays off. Expensive
|
||
— a theme that redefines these fragments and forgets `sizes` silently returns to the old behaviour, and
|
||
nothing can check that, since only the theme knows its own measure.
|
||
Revisit if: the engine ever learns a layout, which it should not.
|
||
|
||
## ADR-0069 — Invariant 7 is a rule in the gate, not a list
|
||
Date: 2026-08-01 · Status: accepted (queue entry G5)
|
||
Decision: `verify.sh` enforces "only `cmd/` may import `internal/ext/…`" as one positive rule, replacing the
|
||
three enumerated pairs — `content → ext`, `render → ext`, `web → ext` — and the separate sibling check. Test
|
||
files are excluded, because `go list .Imports` omits them and the demo's own test exercises features on
|
||
purpose.
|
||
Why: the enumeration only forbade what already existed. A core package added tomorrow could import a feature
|
||
and pass, which is exactly how an invariant rots — the list stays true and stops being the rule. Stated
|
||
positively it also catches a sibling import in the same breath, since a feature importing another feature is
|
||
the other way one stops being deletable (ADR-0027). "Every feature is a leaf" is the property the whole
|
||
`internal/ext` split exists to protect, and it was the last architecture invariant held only by hand.
|
||
Consequence: cheap — one rule where there were four, and it covers packages that do not exist yet. Watched
|
||
rejecting both kinds: a core package importing a feature, and a feature importing its sibling. Expensive —
|
||
`cmd/` is now the only place a feature may be named, so a future test that wants to wire features must
|
||
either live in `cmd/` or duplicate the list, which is the latent item already recorded against
|
||
`example_test.go`.
|
||
Revisit if: features need to be composed somewhere a test can import, which is that latent item and not this
|
||
gate.
|
||
|
||
## ADR-0070 — A counter row must say what does not count
|
||
Date: 2026-08-01 · Status: accepted (queue entry G6)
|
||
Decision: the counters table in `state.md` gains a required fifth column, **Does not count**, and
|
||
`verify.sh` fails on a row that leaves it empty. Every existing row now fills it.
|
||
Why: four of these counters — transforms, views, effects, extensions — had to be re-scoped the first time
|
||
something tested them, and every re-scoping was a sentence about what had been wrongly included. Transforms
|
||
were counting parse-phase work that goldmark's extender list already orders; views were counting output
|
||
formats; effects nearly counted an in-memory swap; extensions counts packages and not goldmark's own. The fix
|
||
is not another counter but a required shape, because writing the exclusion up front is the cheapest way to
|
||
find out whether a counter measures a mechanism or a symptom — and a counter that cannot name one is not
|
||
measuring anything. Checked by shape rather than wording, since a gate that demands a phrase gets the phrase
|
||
and not the thinking.
|
||
Consequence: cheap — one column, one gate, and the four re-scopings are now written down where the next
|
||
person meets the counter rather than in an ADR they would have to find. Expensive — a new counter costs a
|
||
sentence that may be genuinely hard to write, which will still feel like friction.
|
||
Revisit if: a counter is added whose exclusion is honestly "nothing" — then the column is wrong, or the
|
||
counter is.
|
||
|
||
## ADR-0071 — Shortcode fragments may be a file or a directory; the directory wins
|
||
Date: 2026-08-01 · Status: accepted (extends ADR-0019's parse-order mechanism to a glob)
|
||
Decision: fragments are parsed from `templates/shortcodes.html` and then `templates/shortcodes/*.html`,
|
||
embedded first and the site's after, so within one source the directory overrides the file and a site
|
||
overrides the binary. The embedded reference theme now ships the directory only — seven files, no
|
||
`shortcodes.html` — so nothing is defined twice. `parseSet` takes globs, and a set that matches nothing
|
||
anywhere is a startup failure.
|
||
Why: one file held every fragment, and it grew a fragment per feature all session — figure, gallery, icon,
|
||
three admonitions, details, aside, contents. A theme author overriding one of them had to copy the file or
|
||
redefine into it, and a diff of the theme became a diff of everything. Both forms stay supported because a
|
||
small theme is happier with one file and the contract should not force a directory on it; the directory wins
|
||
because it is the more specific statement, the same way a site override beats the embedded set.
|
||
Consequence: cheap — the embedded theme is now seven readable files, `parseSet` grew a glob and lost a
|
||
branch, and a site may use either form or both at once, which is tested. Expensive — fragments now live in
|
||
two possible places, so "where is `figure` defined" has two answers; and the old guard, that a literal
|
||
embedded name must exist, had to go, since `shortcodes.html` is deliberately absent. Its replacement is
|
||
stronger: a set that matches nothing at all fails at startup, which also catches a renamed `base.html`.
|
||
Revisit if: the page and listing sets want the same treatment, which they do not yet — each is one file with
|
||
one block.
|
||
|
||
## ADR-0072 — The demo's coverage test lives in `package main`, beside the wiring it proves
|
||
Date: 2026-08-01 · Status: accepted (clears the latent item against `internal/web/example_test.go`)
|
||
Decision: `TestTheExampleSiteExercisesEveryFeature` moves from `internal/web` to `cmd/khosra`, and the
|
||
renderer both it and `runServe` use is built by one function, `theme`. No package rebuilds the feature list.
|
||
Why: the test rebuilt the list by hand because a package cannot import a `main`, and it drifted three times
|
||
in one session — the dialect, `notation`, `Compose` — each caught by a failing case rather than by the copy.
|
||
The obvious fix was a composition package under `internal/ext`, and it is the wrong one: ADR-0069 forbids a
|
||
feature importing its sibling, so that package would fail the gate on its first build and the invariant would
|
||
have to be weakened one commit after it became mechanical. Moving the test is smaller and points the other
|
||
way — `cmd` stays the only place a feature is named, which is what `extensions.md` asks for. The test was
|
||
arguably misplaced anyway: it exercises the whole binary's wiring, not the web package, which is why
|
||
`wire_test.go` already lived here.
|
||
Consequence: cheap — one list, no copy, no gate exemption, and the latent row is gone. `internal/web` keeps
|
||
its own tests and loses only the one that was never about `web`. Expensive — `cmd/khosra` now holds the
|
||
largest test in the repo, and anything else wanting the shipped wiring must live here too. The benchmark in
|
||
`internal/web` still names `shortcodes`, deliberately: it measures the render path with one extension and
|
||
never claimed to be the shipped list, so there is nothing for it to drift from.
|
||
Revisit if: something outside `cmd` genuinely needs the composed renderer — which is the registry question,
|
||
and this decision deliberately does not answer it.
|
||
|
||
## ADR-0073 — The picture memo is bounded, least-recently-used
|
||
Date: 2026-08-01 · Status: accepted (bounds the cache ADR-0044 measured into existence)
|
||
Decision: `inspected` becomes a `memo` — a map into a recency-ordered list, holding at most 1024 pictures and
|
||
evicting the least recently used. Reads promote; both reads and evictions are constant time.
|
||
Why: the memo was unbounded, one entry per picture ever rendered, for the life of the process. Correct for
|
||
one author's laptop and wrong for the thing this engine is: a server that runs for months and serves a whole
|
||
site to many readers. Least-recently-used rather than clearing when full, because a site has pages nobody
|
||
opens for months and a front page opened every minute — discarding wholesale throws away exactly the entries
|
||
about to be asked for again. 1024 entries is a few hundred kilobytes, generous enough that a normal site
|
||
never evicts and bounded enough that no site can grow the process without limit.
|
||
Consequence: cheap — the benchmark that justified the cache (ADR-0044) is unaffected below the bound, and
|
||
above it the cost of a miss is the same ~102µs inspection the memo was built to avoid. Eviction and recency
|
||
are tested, and the store is exercised under `-race`, since requests are concurrent. Expensive — a list and a
|
||
map where there was one map, and the bound is a constant rather than a setting, so a site large enough to
|
||
thrash it has no knob. That is deliberate: a knob with one user is a knob nobody has asked for.
|
||
Revisit if: a real site evicts often enough to matter, which is a measurement and not a guess — and then the
|
||
question is the number, not the policy.
|
||
|
||
## ADR-0074 — The ceilings move for a list of features, not for one
|
||
Date: 2026-08-02 · Status: accepted (third raise of `CORE_LOC_MAX`, first of `EXT_LOC_MAX` and `DEPS_MAX`)
|
||
Decision: `CORE_LOC_MAX` 2850 → 3000, `EXT_LOC_MAX` 2000 → 3500, `DEPS_MAX` 6 → 9. `FILE_LOC_WARN` and
|
||
`FUNC_LOC_WARN` are unchanged, because they are about one file being readable and nothing about the plan
|
||
changes that.
|
||
Why: `ext` was the binding one at 1975 of 2000, with syntax highlighting still to write, so this is not
|
||
speculative for that ceiling — it is the difference between building the next feature and not. The other two
|
||
are anticipatory, and the human signed for them knowing a larger list of features is coming: `DEPS_MAX` 9
|
||
leaves room for chroma and its `regexp2` (which fills 6 exactly) plus two more, and core gets 150 lines for
|
||
the contract fields features keep needing. This is worth naming honestly: a ceiling raised on evidence is a
|
||
measurement, and a ceiling raised on intent is a budget. The first two raises were the first kind. This is the
|
||
second, which is weaker, and the mitigation is that ADR-0041's test still stands — if core approaches 3000,
|
||
the question is again what belongs in `ext`, and the answer is still `feed.go` and `discover.go` waiting on a
|
||
feature being able to own a route.
|
||
Consequence: cheap — the next several features fit without another conversation, which is the point of
|
||
budgeting ahead. Expensive — "core stops growing after Arc 2" (invariant 9) is measured by a number that has
|
||
now moved three times, and each move makes it a weaker claim. The two ceilings still say something only
|
||
because `ext` rose far more than core did: 75% against 7%.
|
||
Revisit if: core reaches 3000 — and then the answer is a route seam, not a fourth raise.
|
||
|
||
## ADR-0075 — Syntax highlighting is chroma's, colour is the theme's, content may come from a file
|
||
Date: 2026-08-02 · Status: accepted (queue entry 8; first dependency since ADR-0040)
|
||
Decision: fenced blocks are highlighted at render time by `chroma`, emitted as CSS classes rather than inline
|
||
colour, and handed to a `code` theme fragment with the language, any title, and the highlighted body. A
|
||
fence's info string carries `title=`, `numbers=yes`, `start=N`, `hl=3,7-9` and `file=name lines=A-B`, which
|
||
reads the snippet out of a file beside the bundle and numbers it by that file's own lines. The feature lives
|
||
in `internal/ext/shortcodes` rather than a package of its own, and adds `chroma` and its `regexp2` — 6 of 9
|
||
modules, and ~5MB of binary.
|
||
Why: colour is not decoration on a site that shows code constantly, and no lighter pure-Go option exists —
|
||
every "alternative to chroma" is JavaScript, which the reference theme is gated against. Classes rather than
|
||
inline colour because a palette is presentation and belongs where dark mode already lives (ADR-0036). Reading
|
||
from a file is what lets a post quote several parts of one program without the copies drifting from it, and
|
||
the line numbers stay the file's, so a reader can find what they are looking at. It is not a new package
|
||
because a new one could not import the `key=value` parser this repo already has — ADR-0069 forbids a feature
|
||
importing its sibling — and writing a second parser for the same syntax is what CLAUDE.md §6 stops.
|
||
Consequence: cheap — highlighting works with scripting off, in a feed reader, in a browser that never runs
|
||
JavaScript; a title, real line numbers and highlighted ranges cost the author one info string; and a copy
|
||
button, which does need a script, is a theme's own business rather than the engine's. Expensive — the binary
|
||
roughly doubles, from ~15MB to ~20MB, for a project whose sovereignty story is one small binary; chroma is
|
||
larger than khosra; and the reference theme now carries a token palette, which is the first thing in it that
|
||
is a taste rather than a demonstration, kept to eight classes for that reason.
|
||
Revisit if: the binary size becomes the thing people notice, in which case chroma can build with fewer
|
||
lexers, or the feature can be dropped for `class="language-x"` and nothing else — the fragment would not
|
||
change.
|
||
|
||
## ADR-0076 — Merging is the default include model
|
||
Date: 2026-08-02 · Status: accepted (reverses ADR-0066's default; both models remain)
|
||
Decision: `::include` splices before the parse unless the bundle says `include: embed`. A fragment's own
|
||
include line is dropped during the splice, which is the one-level rule the embedded model already keeps.
|
||
Why: composing a page from several files is the reason includes exist, and one endnote list at the end is
|
||
what that page wants. ADR-0066 kept the old default only so existing content would not re-render; the human
|
||
has now looked at both and chosen. Embedding stays for the case it is better at — containing a malformed
|
||
fragment.
|
||
Consequence: cheap — the common case needs no frontmatter, and the mid-article footnote block is now
|
||
something you opt into. Expensive — an unclosed fence in a fragment affects the whole page by default, and
|
||
existing content that relied on per-fragment footnote lists must say `include: embed`.
|
||
Revisit if: containment turns out to matter more often than composition, which would be evidence the
|
||
defaults are the wrong way round again.
|
||
|
||
## ADR-0077 — The index and the theme are one snapshot
|
||
Date: 2026-08-02 · Status: accepted (completes ADR-0055 and ADR-0056; retires `Renderer.Refresh`)
|
||
Decision: `web.Snapshot` holds the site and the renderer that were current together, behind one
|
||
`atomic.Pointer` that a rebuild stores once. A `Renderer` never changes after `New` — a rebuild builds a new
|
||
one — so `Refresh` and the atomic inside the renderer are gone. `web.Handler` takes the accessor and no
|
||
separate renderer.
|
||
Why: two stores meant a request landing between them saw a new theme with the previous index — each half
|
||
coherent, the pair a state that never existed on disk. The gap was microseconds, which is why it waited; it
|
||
closes now because the fix also removes machinery rather than adding it. An immutable renderer is the simpler
|
||
object: nothing to swap inside it, and the one place a change is applied is the one place a change is
|
||
observed.
|
||
Consequence: cheap — one store, one accessor, and two mechanisms fewer than before. A rebuild reparses the
|
||
theme every time rather than only when templates changed, which is microseconds against a content scan.
|
||
Expensive — `web.Handler`'s signature changed and twenty test construction sites moved with it, and a
|
||
`Renderer` can no longer be handed around and updated, which no caller wanted anyway.
|
||
Revisit if: reparsing the theme on every content change ever shows up in a profile.
|
||
|
||
## ADR-0078 — Task lists are enabled, superseding ADR-0058's exclusion
|
||
Date: 2026-08-02 · Status: accepted (supersedes the task-list half of ADR-0058)
|
||
Decision: `extension.TaskList` joins the dialect. `- [ ]` and `- [x]` render as goldmark emits them, with a
|
||
disabled checkbox — static markup, no script, no interactivity. `extension.GFM` is still refused, and
|
||
linkify with it.
|
||
Why: ADR-0058 excluded task lists as "a note-taking affordance, not a publishing one". That reasoning was
|
||
about the wrong axis. A checklist inside a published technical piece — setup steps, a migration runbook, a
|
||
what-I-tried list — is publishing, and the reader benefit is the same whether or not the author also uses
|
||
checkboxes for private notes. The human asked for them with that use in mind, and no other mechanism here
|
||
expresses "this item is done" without the author hand-writing an entity.
|
||
The narrower refusal ADR-0058 also made is untouched and is the part that mattered: the GFM bundle stays
|
||
out, because it drags linkify in with the tables it is wanted for, and linkify rewrites an author's plain
|
||
text into markup — the line ADR-0034 draws. Enabling one named extension is not enabling a bundle.
|
||
Consequence: one line in `cmd/khosra/wire.go` and no counter moves — an upstream extension enabled in the
|
||
list is dialect rather than a feature of this engine (`state.md` counters, "does not count" column). The
|
||
checkbox is `disabled`, so a reader cannot tick it and nothing is stored; a theme that wants the list to
|
||
read as prose unbullets it in CSS, which the reference theme now does.
|
||
Revisit if: authors start using task lists for working notes inside published bundles, in which case the
|
||
answer is `extras/`, which already renders Markdown and is excluded from every listing — not a change here.
|
||
|
||
## ADR-0079 — A page carries the assets its own content asked for, named by the theme
|
||
Date: 2026-08-02 · Status: accepted
|
||
Decision: a page's CSS and JS come from two places and nowhere else. The theme defines `assets:<name>`
|
||
fragments beside its other fragments; the engine records which shortcodes a conversion actually called,
|
||
adds the bundle's `use:` list, and renders each matching fragment **once** into `Page.Assets` for the
|
||
theme's `head` block. Separately, a bundle names its own files in `styles:` and `scripts:`, which are
|
||
bundle-relative — a name containing `..` or starting with `/` is dropped and logged, the refusal
|
||
`::include` and a code block's `file=` already make (ADR-0038). The engine builds those URLs, because a
|
||
theme must not construct an address.
|
||
Why: the human wants demos, games and runnable embeds to carry real assets while ordinary pages stay
|
||
scriptless, and wants adding an asset to be theme work rather than a rebuild. Naming the fragment
|
||
`assets:<name>` reuses the mechanism that already gives that property to shortcodes, so no new file
|
||
format, no manifest parser, and no table in Go mapping a shortcode to the files it wants — which would
|
||
have hardcoded exactly what was deliberately made data-driven. The alternative considered was
|
||
`templates/assets.yaml`; it reads more declaratively and buys a parser, a contract shape and a rebuild
|
||
for conditional markup.
|
||
Collection is parse-phase: shortcodes record their own name as they are opened, so this moves no render
|
||
transform counter — goldmark's extender list is already the ordered pipeline for parse work
|
||
(`state.md` counters). Deduplication is first-call order, so a gallery calling one shortcode forty times
|
||
carries its stylesheet once.
|
||
Consequence: `base.html` gains an empty `head` block that only `page.html` fills, because a listing has no
|
||
such field to read. A theme that defines no `assets:` fragment behaves exactly as before, and the
|
||
reference theme still ships none — ADR-0063's "no assets" holds for what the engine embeds, and this is a
|
||
mechanism for a theme rather than a decision to use it. The reference theme emits `.Assets` and `.Styles`
|
||
but **not** `.Scripts`: it contains no `<script>` in any form, which `verify.sh` already enforced before
|
||
this change and still does unaltered. A theme opts into the tag by redefining `head`, and
|
||
`examples/demo-site` does exactly that — so the exception is demonstrated by a site rather than built
|
||
into the binary.
|
||
Revisit if: two shortcodes ever need assets emitted in a guaranteed order relative to each other, which
|
||
first-call order does not promise.
|
||
|
||
## ADR-0080 — The antifeature list is a decision, and it has exactly one exception
|
||
Date: 2026-08-02 · Status: accepted
|
||
Decision: the following are refused on ordinary pages, permanently and by choice, not by omission —
|
||
client-side rendering where the server can do it, custom cursors, forced scroll smoothing or inertia,
|
||
site-wide link hover-preview popups, autoplay media, parallax and scroll-hijacking, infinite scroll and
|
||
auto-loading pagination, modal and exit-intent popups, third-party embeds that load trackers to show a
|
||
static preview, CAPTCHA and heavy anti-bot friction for readers, and disabling native browser behaviour
|
||
(text selection, right-click, pinch-zoom). No tracking scripts, therefore no cookie-consent banner.
|
||
The **one exception** is author-invoked, page-scoped assets (ADR-0079): a bundle may carry CSS and JS for
|
||
a demo, a game, an app or a runnable embed. It is an exception because it cannot happen by accident — a
|
||
page gets a script only when its own frontmatter or its own shortcode call asks, the reference theme
|
||
defines no `assets:` fragment, and a page that asks for nothing emits nothing.
|
||
Why: an antifeature that is not written down does not bind anything. Each of these dies to one
|
||
reasonable-looking request at a time, and the value of the list is precisely that it is decided in
|
||
advance rather than argued case by case. Writing the exception into the same decision is the point:
|
||
an unrecorded exception is how the rest of a list stops being believed.
|
||
Consequence: a request for any of the above is a conflict to surface (`CLAUDE.md` rule 9), not a feature
|
||
to plan. Spam handling is honeypot fields and rate limiting rather than CAPTCHA, which constrains the
|
||
Arc 3 comment path. Hover-preview *footnotes* are not the popups this refuses — a footnote is the
|
||
author's own text, and `:::aside` already renders margin notes server-side with no script.
|
||
Revisit if: a reader-facing need arises that genuinely cannot be met server-side. "Would be nicer with
|
||
JavaScript" is not that, and never has been for any item on this list.
|
||
|
||
## ADR-0081 — A feature may own a route, and `root/` is the first one
|
||
Date: 2026-08-02 · Status: accepted
|
||
Decision: `cmd` collects `map[string]http.Handler` from the features it enables and hands it to
|
||
`web.Handler`, which mounts each as an exact mux pattern. Core learns that some paths belong to somebody
|
||
else and nothing about who. A path core already answers — `/`, `/robots.txt`, `/sitemap.xml`, `/static/`,
|
||
`/derived/` — is skipped with a warning rather than overridden, because `http.ServeMux` **panics** on a
|
||
duplicate pattern and a site shipping `root/robots.txt` must not take the server down.
|
||
The first user is `internal/ext/passthrough/`: files in `root/` are served at the path they occupy, so
|
||
`root/pubkey` answers `/pubkey` and `root/.well-known/security.txt` answers that. A `.tmpl` suffix opts a
|
||
file into templating and is stripped from its URL; `root/_headers.yaml` declares response headers per exact
|
||
path and is excluded from serving by the leading-underscore rule that already means "not addressable".
|
||
Why: this is the trigger the extension registry has been waiting for, named in ADR-0042 — "the seam to
|
||
revisit when a second feature wants output of its own" — and in `state.md`'s counter note, which said to
|
||
build it "when a feature wants a **route**". Addresses like `/.well-known/security.txt` are fixed by
|
||
somebody else's spec: none is a bundle, none belongs under `/static/`, and core had no way to serve one.
|
||
Only `Routes` was built, not the seven-field `Extension` struct `extensions.md` describes. Five of the six
|
||
other fields have no implementor, and building them would be the speculation rule 6 forbids. This also kept
|
||
the change inside the core budget, which had 65 lines left — the feature's own code lands in `internal/ext/`.
|
||
Templating is `text/template`, never `html/template`: these files are plain text and JSON, where escaping
|
||
an ampersand or a quote corrupts the file rather than protecting anyone. The site root is trusted
|
||
(ADR-0003, ADR-0060), so there is nothing to escape against. Opt-in is by filename rather than by sniffing
|
||
the content type, because a key or a signature may contain anything and a pass that decided for itself
|
||
which files to rewrite would eventually eat one — the failure this engine keeps producing.
|
||
The data a template sees is the site's own declarations and nothing more, which is the point: a
|
||
`security.txt` naming its canonical URL should not repeat what `site.yaml` already says.
|
||
Consequence: the Extensions counter moves to 5 and the standing "a registry buys nothing" note is retired —
|
||
it was right until a feature wanted a route, and said so in those words. Paths are fixed at startup, since
|
||
a pattern cannot be added to a running mux; contents are read per request, so editing a served file takes
|
||
effect immediately and only adding or removing one needs a restart, exactly as `site.yaml` does (ADR-0055).
|
||
A broken template serves its own source rather than 404ing, because a promised address answering nothing is
|
||
worse than one answering unrendered (ADR-0029).
|
||
Revisit if: a feature needs a path *prefix* rather than exact paths, or two features claim the same path —
|
||
neither is expressible today, and both would want the resolver rather than the mux.
|
||
|
||
## ADR-0082 — `docs/` becomes `harness/`, and `docs/` is reserved for the reader
|
||
Date: 2026-08-02 · Status: accepted
|
||
Decision: the twelve engine- and agent-facing documents move from `docs/` to `harness/`. `docs/` is
|
||
reserved for documentation written for whoever *uses* khosra and is deliberately left absent until it
|
||
exists. `HARNESS.md` stays at the repository root: root holds the three entry points — `README.md` for a
|
||
human, `CLAUDE.md` for an agent, `HARNESS.md` for whoever maintains the machine — and `harness/README.md`
|
||
is the map of the directory, so moving the guide inside would collide with it for no gain.
|
||
Why: `docs/content-model.md` opens with "Engine specification" and is where the rule that a leading
|
||
underscore makes a file unaddressable is written. The human, who owns this site, did not know that rule —
|
||
because nothing in this repository is addressed to an author. Twelve documents named `docs/` while being
|
||
exclusively about building the parser is a signpost pointing at the wrong room, and the name is the part
|
||
that misleads: an author looking for how to write a post opens `docs/` and finds a specification for the
|
||
person implementing it.
|
||
Naming the directory for its audience makes the gap visible instead of hiding it. An empty `docs/` is an
|
||
honest statement that end-user documentation does not exist; `docs/` full of engine specs was a claim that
|
||
it did.
|
||
Consequence: mechanical and wide — 100 path references across 24 files, every gate in `verify.sh` that
|
||
names a doc by path, `surface.sh`'s output target, the pre-commit hook, `CLAUDE.md`'s read order, and the
|
||
directory lists the dangling-path and ADR-number gates scan. No rule, threshold, gate or obligation
|
||
changed: this is a rename, and the gates proved it by staying green with the new paths and by catching this
|
||
very ADR's number before it existed.
|
||
An end-user documentation site is planned and deliberately not built here: it wants its own decision about
|
||
where it lives and whether its claims are gated, and the human deferred both. `examples/demo-site` was
|
||
also considered for a move under `docs/` and deferred with it, since it is a live site root that
|
||
`verify.sh`, the coverage test and `make demo` all point at — moving it would couple a rename to a design
|
||
nobody has made.
|
||
Revisit if: `docs/` is still empty when the first person other than its author tries to use this engine, at
|
||
which point the absence has stopped being honest and become neglect.
|