Two things already recorded settle the shape. The theme contract says the engine decides nothing about how content looks, "including how media is embedded", so a <figure> assembled in Go would be the engine dressing content. And invariant 2 wants the trusted/untrusted split as real code, not goldmark's default. Both are satisfied by the same design: parse the shortcode into an AST node, then render it by executing a theme template of that name. Raw HTML stays disabled, so every byte of HTML on a page came from a template the site owns, and an author's text survives only as arguments that html/template escapes. Recorded before the code because the syntax authors type is a disk contract, and because a feature that cannot render itself needs a partial-rendering function passed in at wiring time — a consequence worth agreeing to in advance.
36 KiB
Decisions (ADR log)
Append-only. Never rewrite history; supersede with a new entry. Six lines each.
Withdrawn: ADR-0013, ADR-0014, ADR-0017, ADR-0018, ADR-0025 — recorded pre-code, now intent in
ideas/deferred-decisions.md.
Numbers are never reused. A gap means an entry was withdrawn to ideas/deferred-decisions.md — recorded
before any code existed, so its shape was speculation rather than a decision. Do not add a pre-code ADR
for a mechanism nothing implements; write it as an idea and let the implementation decide the shape.
## ADR-NNNN — Title
Date · Status: accepted | proposed | superseded by ADR-NNNN
Decision: one sentence, imperative.
Why: the forcing reason, not the full debate.
Consequence: what this makes cheap, what it makes expensive.
Revisit if: the specific observation that would overturn it.
ADR-0001 — Rendering is an ordered pipeline of stage functions
Date: front-loaded · Status: accepted (target shape; see counters before building)
Decision: rendering is a sequence of (ctx, page) → page transforms, not a monolith.
Why: every rendering feature must be addable and deletable without touching a renderer core.
Consequence: cheap to add typography, shortcodes, localisation, dithering; requires discipline about stage ordering.
Revisit if: ordering constraints between stages become a graph rather than a list.
ADR-0002 — The page object is open
Date: front-loaded · Status: accepted
Decision: known fields as struct members plus a Meta/Extra bag; absence equals zero value.
Why: templates and extensions must survive fields they do not know about, in both directions.
Consequence: no schema migrations for new metadata; slightly weaker compile-time safety.
Revisit if: silent typos in frontmatter keys start costing real debugging time (then add a lint, not a schema).
ADR-0003 — Pipeline has trusted and untrusted modes
Date: front-loaded · Status: accepted Decision: untrusted content never receives shortcode or template evaluation. Why: this is the RCE boundary; it is the one asymmetric risk in the whole engine. Consequence: comments render through a strictly narrower path; some unification is permanently off the table. Revisit if: never. Narrow the untrusted path, never widen it.
ADR-0004 — Page identity is separate from language variant
Date: front-loaded · Status: accepted; the day-one-suffix instruction superseded by ADR-0021
Decision: slug.bn.md / slug.en.md with a fallback chain, adopted from day one at one language.
Why: retrofitting identity/variant separation touches routing, caching, feeds, and every URL.
Consequence: translations are free later; a tiny amount of ceremony now.
Revisit if: never.
ADR-0005 — Render at request time behind a cache; static export is cache-warming
Date: front-loaded · Status: accepted Decision: the server is the superset; export walks the same code path and writes files. Why: two code paths for the same output diverge, and the divergence always shows up in production. Consequence: dynamic features (comments, search, content negotiation) stay possible; needs a cache eventually. Revisit if: hosting constraints make a running process impossible.
ADR-0006 — No guest authors
Date: front-loaded · Status: accepted Decision: whoever commits to the site repo is the single trusted author; no multi-author model. Why: keeps the trust model a clean binary, which keeps the untrusted boundary auditable. Consequence: collaboration happens via git; the comment boundary stays the only untrusted path. Revisit if: a real co-author appears — and then reconsider from scratch, not by patching.
ADR-0007 — Dependency budget with an allowlist
Date: harness · Status: accepted
Decision: non-stdlib dependencies live in scripts/allowed-deps.txt; additions need an ADR; verify.sh enforces it.
Why: sovereignty and comprehensibility are the point; dependencies are the usual way both die.
Consequence: some features cost more code; the whole engine stays readable in an afternoon.
Revisit if: a budget raise is justified in an ADR of its own.
ADR-0008 — Permalink policy: /{section}/{slug}/
Date: 2026-07-28 · Status: accepted
Decision: every bundle lives at /{section}/{slug}/ with no exceptions — section is the content
type, the top-level directory under content/ (posts, comics, art, writing, status,
pages), and slug comes from the bundle path or a slug override. pages/about/ therefore serves at
/pages/about/. Trailing slash is canonical; the slashless form permanently redirects. Bengali
composes with the prefix outermost: /bn/{section}/{slug}/ (ADR-0009).
Why: one rule with no exemption. Root-level pages were considered and rejected: they would give the
prettier /about/, but they permanently share the root namespace with the engine, so every future
root route (/tags/, /search/, /feed.xml) becomes a slug no page may ever use — a growing set of
reserved words discovered years after publishing. Uniformity also keeps the resolver at one URL shape.
Consequence: cheap — section feeds, indexes and queries map onto a URL prefix; the root stays entirely
engine-owned, so emitted files and future routes need no collision check. Expensive — /pages/about/
is a less handsome URL than /about/, and moving a bundle between sections changes its URL and needs
an aliases entry, so the section list is effectively permanent once anything is published.
Revisit if: never. This is the decision every shared URL depends on.
ADR-0009 — Language routing: default at root, others prefixed
Date: 2026-07-28 · Status: accepted
Decision: English (the default language) is served at the root; every other language is served
under a /<lang>/ prefix on the same path — /pages/about/ and /bn/pages/about/. /en/… is never a
live URL; it permanently redirects to the root form so it can never fork.
Why: English is the front door, and Bengali must still be linkable, cacheable, and shareable as
its own URL — which content negotiation on a single URL prevents.
Consequence: one redirect rule; hreflang plus canonical emitted per bundle from the variants
that exist; a third language costs nothing. Identity remains the slug (ADR-0004).
Revisit if: Bengali becomes the dominant language of the site — and then it is an alias and
default-language problem, not a routing rewrite.
ADR-0010 — Deploy: self-hosted Docker, external infra permitted for derived state only
Date: 2026-07-28 · Status: accepted
Decision: ship a single binary in a container on a self-hosted server. External infrastructure
services (Redis, object storage, a search index) are permitted where they earn their place.
Why: the full server shape keeps comments, search, and content negotiation possible, and the
container makes the host itself disposable.
Consequence: confirms ADR-0005 (server is the superset; export stays available as the same code
path, now optional insurance rather than the target). The path-traversal guard becomes a hard
pre-deploy blocker. Every infra client is a dependency and counts against DEPS_MAX.
Constraint: external services may hold only derived or disposable state — cache, index,
session, queue. Canonical content stays in the site repo. Test before adding one: if this
service vanishes, does a rebuild restore it, or is something lost?
Revisit if: the engine can no longer boot and serve correctly with every external service off.
ADR-0011 — The site root is external to the engine repository
Date: 2026-07-28 · Status: accepted
Decision: the engine is pointed at a site root — a directory outside this repository, versioned in
its own git repo — holding content/, static/, and optionally templates/ overriding the defaults
the binary embeds. Selected by -site <dir> or KHOSRA_SITE. This repository contains engine source
only; no content, ever, not even an example.
Why: content is the database, and a database does not live in the application's repo. Mixing them
interleaves "fixed a typo in a poem" with "extracted the resolver" in one history, makes every typo a
code deploy, and bloats every clone of the engine with image history.
Consequence: cheap — content-only publishing without a rebuild, a second site is a second root rather
than a fork, fixture sites live in testdata/, and the gate needs no content-exclusion rules.
Expensive — two repos to track, and a disk-contract change can no longer migrate the author's files:
it must ship a documented migration step or a subcommand, because the engine does not own that data.
Revisit if: never usefully. The separation only pays better as content grows.
ADR-0012 — Effect is the sixth primitive
Date: 2026-07-28 · Status: accepted
Decision: work that happens outside the request path is an Effect, triggered on content change, on
a schedule, or on demand. It absorbs what the harness previously called an "emitted file" and the
Emitter slot in the extension contract — one name, not three. Definition and rules:
architecture.md.
Why: half the roadmap is not request-time — image derivatives, search index, sitemap and feed files,
webmention sending, POSSE, link archiving, EPUB, future-dated publication. None reduce to the other
five: Interaction is inbound by definition, and a Stage runs per render. Without this, those features
are not leaves, so invariant 7 fails and invariant 9's freeze cannot be checked.
Consequence: cheap — the Arc 3–4 networked layer becomes composition rather than core growth, and
scheduling is an in-process ticker in the one binary. Expensive — a second trigger kind (the clock)
means the engine has background work, so every Effect must be idempotent and its absence must degrade
rather than break. Anything requiring a separate scheduler process is a trunk under ADR-0010.
Revisit if: an Effect cannot be made idempotent, or scheduling genuinely needs a second process.
ADR-0015 — Normalise to NFC everywhere; derive slugs by locale, override by hand
Date: 2026-07-28 · Status: accepted
Decision: every string entering the engine as an identifier — filenames, bundle keys, taxonomy terms,
frontmatter slugs, request paths — is normalised to NFC at that boundary, unconditionally and with no
opt-out. Slug derivation is separate and locale-aware: default rules come from the site's default
locale, and any derived slug may be overridden by hand — slug on a bundle, and a term-to-slug mapping
for taxonomy and section segments.
Why: Bengali conjuncts have several byte encodings for identical-looking text, macOS hands back NFD, git
and editors pass through whatever they are given. Un-normalised, two visually identical files produce
different bundle keys and therefore different URLs, and a request never matches the page it names. This
is unfixable after publication except by accumulating aliases. Normalisation is correctness and cannot be
optional; romanisation and casing are taste and must be overridable.
Consequence: cheap — one chokepoint, and comparisons become byte comparisons again. Expensive — adds
golang.org/x/text (no transitive dependencies), the first entry on the allowlist, and every identifier
boundary must route through the normaliser rather than accepting a raw string.
Revisit if: never for normalisation. Slug derivation rules change with the default locale.
ADR-0016 — Sequence position is metadata and never appears in a URL
Date: 2026-07-28 · Status: accepted; its membership clause is superseded by ADR-0033 (position rules stand)
Decision: a bundle's slug is its name, never its position — comics/the-long-monsoon/the-flood/, not
.../02-the-flood/. Position comes from a single declared source per type (order in the type
declaration: date, sequence, or manual), sparse by convention (10, 20, 30) so inserting between two
members is one edit. Sequence resolution — first, prev, next, last, index, count, honouring drafts and
language fallback — is defined once and shared by comics, serial fiction and multi-part essays. A slug
may therefore contain slashes, which clarifies ADR-0008's single-segment reading.
Why: with position in the path, inserting a chapter between 3 and 4 renumbers everything after it, which
renames directories, changes bundle keys, changes published URLs, and demands an aliases entry for
each — one editorial decision becoming a permalink event, against ADR-0008. Two sources of truth
(filename prefix and frontmatter field) also drift, so the archive and the prev/next links can disagree.
Consequence: cheap — insertion is local and free, and prev/next exists once rather than three times.
Expensive — ordering is invisible in a directory listing, so authors read it from frontmatter, and
order values want leaving gaps.
Revisit if: never. Position in a permalink is the mistake this exists to prevent.
ADR-0019 — Templates: per-type sets, block-level override, cascade selection
Date: 2026-07-28 · Status: accepted
Decision: one parsed template set per type, each set being base plus partials plus that type's
definitions. A site root override is parsed after the embedded defaults into the same set, so it may
redefine a single named block and inherit everything else. Which set renders a bundle resolves through the
cascade (ideas/deferred-decisions.md): type default, section override, then the bundle's own view.
Why: html/template has no extends — inheritance is "last definition of a name wins in a parsed set",
so a global set makes two types defining main collide, and per-type sets are the only clean answer.
File-level override would force copying a whole template to change one block, after which it stops
inheriting engine improvements; block-level costs a few lines of parse ordering and keeps site overrides
minimal.
Consequence: cheap — a site writes only what it changes, and item-level overrides need no new mechanism.
Expensive — parse order becomes load-bearing and must be asserted in a test, since a silently wrong order
means an override that quietly does nothing.
Revisit if: never cheaply — every template ever written assumes this convention.
ADR-0020 — YAML is the author-facing format; allowlist gopkg.in/yaml.v3
Date: 2026-07-28 · Status: accepted
Decision: frontmatter and the site declaration (site.yaml at the site root) are YAML, parsed by
gopkg.in/yaml.v3 — one dependency, no transitive requirements, one parser for both.
Why: frontmatter is the author's primary interface with the engine and must behave exactly as they
expect. Go has no stdlib YAML, and the alternatives are worse: JSON is hostile to hand-write, TOML costs
a dependency anyway, and a hand-rolled subset would diverge from real YAML in ways only discovered while
writing a post. This is the case conventions.md's "40 lines over a dependency" rule does not cover —
the 40 lines would be wrong in edge cases the author cannot predict.
Consequence: cheap — one parser for frontmatter, the site declaration and the cascade; anchors and
multi-line strings work as authors expect. Expensive — the third allowlist entry, and YAML's own traps
(the Norway problem, tabs) become the engine's to document rather than to invent.
Revisit if: the parser proves a maintenance burden, or the author-facing format changes — and the second
requires migrating every existing file.
ADR-0021 — The default locale's suffix is optional, permanently
Date: 2026-07-28 · Status: accepted (supersedes ADR-0004's instruction to adopt slug.en.md from day one)
Decision: a missing language suffix means the default locale, always — not merely while one language
exists. about.md and about.en.md name the same variant and the parser accepts both. Identity still
excludes language (ADR-0004's actual point, unaffected).
Why: ADR-0004 told the author how to name files, which is not the engine's business — the site root
belongs to its owner (ADR-0011). The engine's job is to accept both spellings and derive the same bundle
key from either. Requiring a suffix bought nothing: identity is already language-free by construction.
Consequence: cheap — nothing in the harness prescribes a filename any more, and a single-language site
never types a suffix. Expensive — the parser must treat two spellings as one variant and reject a bundle
that supplies both, since that is ambiguous rather than harmless.
Revisit if: never. This removes a rule rather than adding one.
ADR-0022 — Content change is detected by polling the site root
Date: 2026-07-28 · Status: accepted
Decision: the engine detects change by walking the site root and comparing modification times, on an
interval set by one flag (-poll, zero to disable for immutable deployments), and acting only after a
settle window with no further changes. Stdlib only — no filesystem-watcher dependency. Fetching is not
the engine's job: something on the host updates the site root, and the engine notices. The site root is
therefore a mounted volume in the container, never baked into the image.
Why: the engine already runs against a directory, so the filesystem is the obvious signal, and it needs no
inbound endpoint — the untrusted surface stays empty until webmentions or Micropub actually arrive. A
watcher would cost a dependency for what a short walk does, and inotify is unreliable on exactly the
places this will run: bind mounts, overlay and network filesystems. A poll is boring and works everywhere.
Consequence: cheap — change detection is the on change trigger from ADR-0012 with nothing new, and a
template edit in the site root invalidates through the same path as content. Expensive — a checkout writes
many files, so the settle window is required rather than optional; editor droppings and .DS_Store must
be ignored or the engine re-renders continuously; and detection latency is bounded by the interval.
Revisit if: a site grows large enough that walking it costs real time — and then the fix is a cheaper
signal, not a watcher dependency.
ADR-0023 — Three edit surfaces; this repo is bound to the theme contract, not the theme
Date: 2026-07-28 · Status: accepted
Decision: engine source, content, and theme are three separate surfaces with three separate owners. This
repository holds the engine and is bound to the theme contract — the data available to templates, the
template and block names it looks for, the helpers it provides, the URLs it emits — recorded in
docs/theme-contract.md. It is not bound to any theme's markup, layout, or styling. Where a theme comes
from (a directory in the site root, its own repo checked out into place) is deliberately outside this
repo's concern: the engine knows a path and a contract.
Why: a request that reads as one feature is often split — "supporting files listed in a sidebar" is a
contract change here and a layout decision there. Absorbing the theme half into the engine puts markup
and presentation choices into the core, which grows it against invariant 9 and makes the theme
unswappable. Keeping the boundary at the contract is what lets the theme change without an engine release
and the engine change without rewriting a theme.
Consequence: cheap — a split request produces a contract extension plus a written note of what the theme
must do, and the theme side is somebody's separate change. Expensive — the contract is now a published
interface: fields may be added, never renamed or removed, which is the View-layer freeze in
architecture.md arriving earlier than Arc 2. Embedded default templates are a reference implementation
of the contract, not the contract itself.
Revisit if: never usefully. Merging the surfaces is how a publishing engine becomes one site's code.
ADR-0024 — Bundle-local assets inherit the bundle's publish status; -dev reveals
Date: 2026-07-29 · Status: accepted
Decision: every byte served from inside a bundle — body, cover image, any local asset, anything under the
extras directory — inherits that bundle's publish status, derived per request rather than stored. An
unpublished bundle and all its assets answer 404, never 403. Reaching asset bytes without having
resolved the owning bundle is structurally impossible, not merely discouraged: one guard, every route.
-dev (default off) reveals drafts and future-dated bundles with their assets, and is the only thing that
changes the answer.
Why: without this, a draft's cover.jpg is world-readable while its page is not, and notes about
unfinished work leak through the asset path — which looks like static file serving, and static file serving
looks like it needs no context. That is the shape this bug always takes. 403 would confirm the work exists,
which for drafts is itself the thing worth not leaking.
Consequence: cheap — visibility is one derived predicate with no state to keep in sync, and a future-dated
bundle's 404 expires at its publish time, so it becomes visible exactly when it should
rather than needing a sweep. Expensive — no route may serve bundle bytes by path alone, so a fast static
path for assets is off the table; and -dev becomes security-relevant, so it must default off and be
obvious when on.
Revisit if: never. The generalisation from "extras are public" to "assets inherit visibility" is the whole
point of the entry.
ADR-0026 — The engine ships a minimal reference theme
Date: 2026-07-29 · Status: accepted
Decision: the binary embeds a reference theme — templates plus one small stylesheet — sufficient to render
every declared type and the extras view, with semantic HTML and no JavaScript. It exists to make the theme
contract executable: a bare site root renders, and a golden-file test through it catches contract
regressions. It is deliberately not a design: legibility only, no branding, no visual opinions, and it
demonstrates every contract feature and nothing more.
Why: a contract nobody implements is a contract nobody has tested. Without a reference theme, the first
real theme discovers the contract's gaps, and docs/theme-contract.md stays aspirational. It also means
someone can point the binary at a folder of Markdown and see a site, which is the whole promise.
Consequence: cheap — templates and CSS are not Go, so they cost nothing against CORE_LOC_MAX, which is
the same incentive that pushes presentation out of the core. Expensive — the reference theme is a
maintenance obligation that grows with the contract, and if it ever becomes handsome it becomes the theme
nobody replaces, which is why "minimal" is a rule and not a preference.
Revisit if: it starts accumulating design decisions — then split it into a reference theme and a real one.
ADR-0027 — Feature locality: one directory, no sibling imports, a mandatory doc.go
Date: 2026-07-30 · Status: accepted
Decision: a feature is one directory under internal/ext/<name>/ from its first use, plus one line in
cmd/khosra/wire.go — the only file that knows every feature. No internal/ext package may import
another. Every one carries a doc.go stating, in four lines: what it contributes, which cascade keys it
reads, which theme-contract fields it adds, and what it deliberately does not do. A feature may import
internal/content and internal/render; never internal/web, never cmd/, never a sibling.
Why: the cost that matters when an agent adds a feature is how much of the codebase it must read first.
Sibling imports are what make a read set compound — understand one feature, then two, then four. A
doc.go in a fixed shape turns orientation into a fifteen-line read instead of a two-hundred-line one.
Routes reach web by being assembled in wire.go, so web never learns features exist and the
dependency arrows hold.
Consequence: cheap — adding a feature is a new directory and one wiring line, so the diff is local and
the read set is roughly the extension types plus wire.go. Shared logic between two features must move
inward (earning it under the counters) or stay duplicated until the third use, which is the existing rule
rather than a new one. Expensive — wire.go absorbs all the coupling on purpose and will look
repetitive; and this buys cheap features, not cheap spine changes, which still need the core read.
Revisit if: wire.go becomes hard to read, which means the registry from extensions.md is due.
ADR-0028 — Pagination lives in the path: /{section}/page/2/
Date: 2026-07-30 · Status: accepted
Decision: page two of a listing is /{section}/page/2/; page one is the bare listing URL and never
/page/1/, which permanently redirects to it. Tag listings compose the same way:
/tags/{tag}/page/2/. page is therefore a reserved segment inside a section.
Why: a query parameter is not a permalink — it reads as ephemeral, is dropped by careless sharing, and
splits caching by URL shape rather than by content. Putting it in the path keeps every listing page
linkable and cacheable on the same terms as a bundle.
Consequence: cheap — one more resolver case, and every paginated listing is a first-class URL. Expensive
— no bundle may be slugged page inside a paginated section, and changing the page size renumbers pages,
so page URLs are stable only while the size is.
Revisit if: never for the shape. Page size is a separate setting and changing it is a URL event.
ADR-0029 — A bundle that fails to parse is skipped loudly, not fatal
Date: 2026-07-30 · Status: accepted
Decision: a bundle whose frontmatter or filename cannot be parsed is logged at error level with its path
and line, excluded from the site, and does not stop startup or any request. The check command reports
the same failures as errors, and that is where a non-zero exit belongs.
Why: conventions.md says startup failure is fatal and loud, and separately that request-time failure
degrades. A single mistyped colon in one post is not a startup failure — treating it as one means one
typo takes the whole site down, which is the worst possible failure mode for a personal site published
from a text editor. Fatal is right for a broken site root or an unreadable directory, not for one file.
Consequence: cheap — the site always serves whatever is valid, and the author sees the error in the log
and in check. Expensive — a silently absent page is a real failure mode, so the log line must name the
file and the reason plainly, and check must exist early enough to be the place you look.
Revisit if: skipped bundles start going unnoticed in practice — then check runs in CI, rather than the
engine becoming fatal.
ADR-0030 — The project is named khosra
Date: 2026-07-30 · Status: accepted
Decision: the engine is khosra. Module path khosra, binary khosra, command directory
cmd/khosra/, environment variable KHOSRA_SITE, feature-loop skill khosra-feature-loop. The former
name atelier appears nowhere, including in earlier ADRs, which describe this project under its old
name rather than a different project.
Why: naming is the author's, and it is free now — before a module is published, a URL is shared, or a
binary is deployed. Every day it waits, it costs more.
Consequence: cheap now, and permanent-ish once the module path is fetched by anything. Expensive later —
a module rename after publication needs a redirect or a major-version bump, and a deployed binary name
appears in service files and container tags.
Revisit if: never. Renaming again costs strictly more than this did.
ADR-0031 — Path safety is os.Root, not a hand-rolled check
Date: 2026-07-30 · Status: accepted
Decision: every read of the site root goes through an *os.Root obtained by os.OpenRoot (Go 1.24+).
No code cleans, joins or validates a request path itself, and os.DirFS is not used for the site root.
Why: os.Root refuses any name resolving outside the root, including through a symlink; os.DirFS
documents that it does not prevent symlink escape. So the traversal guard that has sat on the latent
list becomes a property of the type rather than a check somebody has to remember at every call site —
which is the only version that survives twenty features.
Consequence: cheap — the guard cannot be forgotten, and the test is one symlink. Expensive — reads must
go through the root handle, so no helper may take a string path and open it directly, and the root is
held for the life of the process.
Revisit if: never. A hand-rolled cleaner is strictly worse.
ADR-0032 — Tags: one global namespace, grouped in the listing
Date: 2026-07-30 · Status: accepted (the tag half of withdrawn ADR-0018, now built; the feed half is still
deferred in ideas/deferred-decisions.md)
Decision: tags is one global namespace across every section. /tags/{term}/ lists everything carrying a
term, /{section}/tags/{term}/ narrows it, and listings group by section. The URL form of a term is
lowercased with whitespace hyphenated, preserving script, so case is not a distinction and Bengali passes
through unchanged. Structural metadata with a fixed term set — series and its kin — is not a tag.
Why: cross-type discovery is the point of a single-author site; one term spanning a comic, a poem and a
photo essay is the feature, not the noise. Per-section pools would fragment that to solve a readability
problem the View solves by grouping. And a free-form cross-cutting tag behaves nothing like a fixed term
set that drives engine behaviour, so conflating them makes both worse.
Consequence: cheap — one Query with an optional section predicate serves both listings, and the second
caller is what earned Query.Tag rather than a guess. Expensive — tag hygiene is the author's discipline
since nothing scopes terms, so check owes a near-duplicate report; and tags is now reserved at the top
level and inside every section.
Revisit if: the pool becomes unusable in practice — and then the answer is curation, not namespacing.
ADR-0033 — Series membership is structural; order is optional
Date: 2026-07-30 · Status: accepted (supersedes ADR-0016's membership clause, not its position rules)
Decision: a bundle is a member of a series because it is nested under the series' landing bundle —
comics/the-long-monsoon/first-rain under comics/the-long-monsoon — so the engine reads no series
field. Reading order is order ascending where it is set, then by key; a member without order sorts
after every member that has one. A bundle with bundles nested under it is a series landing page, and one
nested inside another series reports its own members rather than its siblings.
Why: the directory already states membership unambiguously, and a second statement of it in frontmatter
can only agree or be a bug — a typo in series silently orphans a chapter, which is precisely the class
of error a single source of truth removes. Making order optional keeps the cheap case cheap: a series
whose filenames already sort correctly needs no frontmatter at all, and order is for when they do not.
Consequence: cheap — nothing to declare, nothing to keep in sync, and moving a series moves its
membership with it. Expensive — a series can never span directories, so a cross-directory collection
needs a Query over some other field, not this; and membership now depends on the bundle key, which
ADR-0008 makes permanent, so re-parenting a chapter is a permalink event with an alias.
Revisit if: a real collection must span sections. Then it is a declared taxonomy (ADR-0032's second
half), not a change to how a series is joined.
ADR-0034 — Chrome text is the engine's; body text is the author's
Date: 2026-07-30 · Status: accepted
Decision: every word the engine puts on a page that the author did not write — labels, page counts,
month names, digits — comes from a table in the engine keyed by (key, language), reached from templates
through t, num and day, so no template hardcodes English. Authored body text is never localised and
never rewritten beyond typographic smoothing, which is goldmark's typographer: quotes, dashes, ellipses,
code spans untouched. Machine-readable output — datetime, URLs, anything a parser reads — stays ASCII
whatever the locale.
Why: a Bengali page that says "Page 2 of 3" in English is broken, and a theme cannot fix it without
hardcoding the very strings a theme must not own (theme-contract.md). Meanwhile the opposite mistake is
worse: localising body text means the engine editing prose, and Bengali numerals inside an author's
sentence are the author's decision, not the renderer's. So the line is drawn by who wrote the words.
Consequence: cheap — a new label is one table row with both languages beside each other, and a missing
translation falls back to the default locale and then to the key, so it can never blank a page or fail a
render. Expensive — the engine now carries human-language strings, which is a small localisation surface
that grows with the theme; a site root cannot yet add or override a key, so a theme needing its own words
must write them in its own block.
Revisit if: a site root needs its own chrome strings. That is the settings cascade's problem
(ideas/deferred-decisions.md), not a second table.
ADR-0035 — A slug belongs to the bundle, not to a variant
Date: 2026-07-30 · Status: accepted (confirms ADR-0009's "same path" clause against a per-language address)
Decision: slug renames the bundle in every language, so a variant is always reached at the bundle's one
path under its language prefix — /posts/hello-world/ and /bn/posts/hello-world/, never a Bengali
spelling of the address. Where two variants declare different slugs the declaration is ambiguous: log both
and keep the derived path, the same way colliding keys and contested aliases are handled (ADR-0029). The
engine never derives a slug from a title in any language; a path changes only because an author wrote one.
Why: the alternative makes the path a function of (key, language) rather than of the key alone, which
costs a second lookup direction in the resolver and a permalink shape that differs per language — while
invariant 3 says identity is stable across translations and ADR-0009 already promised the same path under
a prefix. A Bengali-looking address is a real reader benefit, but not one worth a second identity rule
before anything is published.
Consequence: cheap — one path shape, one lookup direction, /bn/ stays pure prefixing, and a translation
can be added or dropped without touching a URL. Expensive — a Bengali reader sees a Latin address, and
changing that later needs this decision reversed plus an alias for every path already published.
Revisit if: Bengali becomes the dominant language of the site — the trigger ADR-0009 already names. That
is a default-language change, not per-variant slugs.
ADR-0036 — Shortcodes parse to engine-built nodes and render through theme templates
Date: 2026-07-30 · Status: accepted
Decision: a shortcode is {{< name key="value" >}} alone on a line, parsed into an AST node by a goldmark
block parser. It renders by executing a theme template of the same name with its arguments as data — the
engine supplies no markup — and raw HTML in Markdown stays disabled, so the only HTML on a page comes from
a template the site owns. A shortcode whose template is missing logs and renders nothing rather than
failing the request.
Why: two constraints meet here and both are already recorded. theme-contract.md says the engine decides
nothing about how something looks, "including how media is embedded", so a <figure> built in Go would be
the engine dressing content. And invariant 2 needs the trusted/untrusted split to be real code rather than
goldmark's default: with html.WithUnsafe() still off, an author's raw HTML is dropped while a
shortcode's output is trusted because a template produced it, and the author's bytes survive only as
arguments, which html/template escapes.
Consequence: cheap — markup lives where markup belongs, a theme restyles a shortcode by redefining one
template, and the escaping is the standard library's rather than ours. Expensive — a feature under
internal/ext cannot render itself, so it is handed a partial-rendering function at wiring time
(cmd/khosra/wire.go), and the shortcode's argument names become part of the theme contract, additive
only.
Revisit if: a shortcode needs to emit something no template can express. That is an argument for a new
contract field, not for the engine writing HTML.