Files
bdeshiandClaude Opus 5 30c26bd1ac resolve relative links against the disk, serve them as addresses
Item 2 of the order of work. An author writes `../day-01.en.md` — the path an
editor preview resolves — and the engine emits `/posts/day-01/`.

The larger effect is durability. Resolution goes through key → route, and a slug
moves the route while never moving the key (ADR-0035), so a relative link survives
a rename that a hand-written /posts/a-better-name/ does not. The demo proves it:
`../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote
the filename and got the slugged address.

This is the engine altering authored markup, which ADR-0045 polices, so the test
that matters is what it declines to touch. Fourteen cases must survive exactly as
written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative
path, a bare fragment, a bare query, a name climbing out of content/, and every
relative path whose extension is not .md. That last line is what keeps cover.jpg
working — a bundle's assets already resolve because its URL mirrors its directory,
so rewriting them would break what works. Nine rewrite cases sit beside them.

Key derivation goes through content.KeyFromName, exported for this: the
language-suffix rule is the part that would drift between two copies, so it lives
in one place while the five lines of joining are duplicated in check.

khosra check now reports a relative .md link resolving to no bundle, as fatal —
verified by mistyping one and watching exit 1. Only the .md form: an extensionless
relative path may be an asset, and a checker that calls a working link broken gets
ignored wholesale.

Two debts this change paid rather than deferred.

render.go reached the file-length advisory, so theme parsing moved to theme.go —
414 and 105 lines, one topic each, since parsing runs per rebuild and rendering
runs per request. Not a _helpers.go shard.

And the demo's coverage test bound its renderer with a *copy* of the rebuilder's
wiring, so it missed this feature entirely while the real binary served it
correctly. Navigation had already drifted the same way. Both now call one bind(),
which is exactly what ADR-0072 was written about — and the test failing is the only
reason the copy was found.

Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is
in ext where it belongs.

18 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:44:48 +06:00

135 KiB
Raw Permalink Blame History

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.

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

ADR-0083 — The dependency test is whether you can verify it, not whether you can write it

Date: 2026-08-02 · Status: accepted (amends rule 2's "stdlib first, always") Decision: stdlib first stands for anything small enough to verify by reading it. For a task somebody else specified — a wire format, a grammar, an error-correcting code, a Unicode algorithm, a compression scheme — take a maintained module. Everything else about rule 2 is unchanged: an ADR, human approval, an allowlist line with the reason, and DEPS_MAX. Why: the rule as written — "stdlib first, always", and the skill's "usually 40 lines of stdlib" — would, read literally, have argued against every dependency this project has taken. All five are the nontrivial-and-specified-elsewhere case, and their own allowlist comments say so: "no stdlib resizer exists", "the only mature pure-Go one". The text lagged the practice, and the gap surfaced when this agent proposed hand-rolling QR encoding: ReedSolomon over GF(256) plus mask evaluation, roughly 400 lines whose correctness is measured against spec test vectors it would also have to transcribe. Writing such a thing is not the work — verifying it is, and a maintained module has already done both. A hand-rolled encoder that is subtly wrong fails silently, which is this engine's recurring failure mode in a new costume. The counterweight is unchanged and still decides the other direction: a dependency is supply-chain surface and a thing to be walked away from. The sovereignty test applies as always — if this vanishes, do I lose data or only convenience? A module that merely computes is walkaway-safe, because its output is reproducible by any replacement; a module that holds data or state is not, and no amount of nontriviality buys one of those a place here. Consequence: the balance is stated where the rule lives rather than being folded into each ADR. Rule 2 in CLAUDE.md and the skill's failure-mode table both gain the second half. Nothing already allowlisted changes, and nothing new is admitted by this decision alone — a module still needs its own ADR. Six modules of a permitted nine are in use, so the first few applications of this need no budget change. Revisit if: a "specified elsewhere" module turns out to hold state or data rather than compute, which is the case this decision does not cover and must not be read as permitting.

ADR-0084 — Effects come in two kinds, and only one is re-runnable from scratch

Date: 2026-08-02 · Status: accepted (amends the Effect primitive in architecture.md) Decision: an Effect is artifact-producing or outbound, and the difference is contractual rather than descriptive. An artifact-producing Effect writes bytes somewhere the engine may write — image derivatives, a search index, an EPUB. It is idempotent and re-runnable from scratch, because regenerating yields the same result; losing its output costs time and nothing else. An outbound Effect makes a call somebody else observes — a notification, a webmention, an archive submission. It is not re-runnable from scratch: re-running it duplicates messages that cannot be recalled. It is idempotent only with respect to a delivery ledger recording what has already succeeded, and its contract is at-least-once with a stable dedupe key rather than exactly-once, which is unachievable across a network boundary. Ledgers and any other state that is neither the author's content nor regenerable live in a state directory (-state), distinct from the disposable derivative cache (-cache). Deleting the cache must stay a safe act; deleting state must not be one, and conflating them would make a rm -rf on a regenerable directory arm a notification storm. Where a ledger's absence is survivable, the Effect seeds silently on first run — recording current state and calling nothing — so loss costs missed calls rather than duplicates. Why: the primitive said "every Effect is idempotent and re-runnable from scratch", written when the only Effect was the derivative pass. The first outbound Effect breaks that sentence rather than extending it, and discovering the contradiction while building would have meant either redesigning the primitive under pressure or quietly violating it. Recording the split now costs a paragraph; the alternative was a redesign. Consequence: the retry shape is stated once for everything outbound — bounded in-process backoff, then a ledger of successes, with rebuilder's startup pass as the long-term backstop, and no persistent queue. Three outbound Effects are foreseen (notify, webmention sending, outbound-link archiving) and the last two share an input, the set of a page's external links, so that extraction wants to be separable from the first one built. The shared dispatcher is not built until the second consumer exists — rule 1 is unchanged by this decision, which records a shape rather than authorising an abstraction. An Interaction may trigger an outbound Effect: verifying a received webmention means fetching a stranger's URL, which is outbound work that must not happen on the request path. That is the existing "on demand" trigger, not a fourth one. Revisit if: an Effect is neither artifact-producing nor outbound, which would mean a third kind and a real gap in this split.

ADR-0085 — discover becomes a feature, and the core ceiling rises to 3400

Date: 2026-08-03 · Status: accepted Decision: /robots.txt and /sitemap.xml move from internal/web/discover.go into internal/ext/discover/, mounted through ADR-0081's Routes seam. The seam gains one parameter — a func() *content.Site — because a sitemap must list what is served now and the index is swapped whole on every rebuild (ADR-0077); a captured pointer would freeze the site at startup. CORE_LOC_MAX rises from 3000 to 3400. Why the move: these are exact paths somebody else's software asks for by name, own no core concept, and pass every test the architecture applies to a feature. They sat in core only because a feature could not own a route until ADR-0081. Core went 2965 → 2908. Why the raise as well as the move: the review on 2026-08-02 scheduled four core-bound items — logging, the View layer, declared content types and a minimal settings cascade — and 35 free lines would not have fitted the first of them. feed.go and web/extras.go cannot follow discover out, because a feed lives at /{section}/feed.xml and extras under a bundle's own URL: both are resolver cases, and the seam mounts exact paths only. So the move alone could not buy the room, and raising by the minimum that unblocks one item produces a ceiling nobody believes. 3400 fits the View cluster with headroom. HARNESS.md asks that a core raise be read as evidence something belongs in internal/ext/ before evidence the number was small. Both readings are true here, which is why both actions were taken rather than either. Consequence: web no longer reserves those two paths, so a clash between features is wire.go's to settle — it merges route maps in declaration order, keeps the earlier claim, and logs the loser. Verified: a site shipping root/robots.txt starts, serves the engine's robots.txt, and logs the passthrough claim, where an unguarded mux.Handle would have panicked. Output is byte-identical before and after for both paths on the demo site. One real cost: internal/web's visibility test asserted that a listing, a feed and a sitemap all hide unpublished bundles — one property, one test, because all three share a Query. The sitemap half moved to the feature rather than a web test importing ext, which would invert the one-way layering the architecture gate enforces. The property is now asserted twice, once per package that owns a surface. That is a genuine loss, recorded in both tests. Revisit if: the resolver gains feature participation, at which point feed.go and web/extras.go can leave too and the ceiling should be reconsidered downward rather than left as headroom.

ADR-0086 — One log line per request, and error stops meaning "look at this"

Date: 2026-08-03 · Status: accepted Decision: cmd configures exactly one logger behind -log-level (debug/info/warn/error) and -log-format (text/json), rejecting an unusable value at startup rather than falling back. web.Logged wraps the finished handler and writes one Info line per request — method, path, status, bytes, duration — which is the access log this engine did not have. Levels are fixed in conventions.md: error is something the engine could not do (a request or build step failed, startup aborting), warn is something it worked around while still serving, info is lifecycle and requests, debug is off by default. Why: conventions.md made importing log instead of log/slog a hard failure while never configuring slog — no level, no JSON, default handler. And nothing recorded requests at all, so a deployment of the binary alone (ADR-0010) produced no access log, which matters because offline log analysis is this project's answer to analytics: no counter on the read path, no third-party script. The re-levelling is the larger half. Counts were 38 error, 7 warn, 4 info, 0 debug; they are now 7, 40, 5, 0. Almost every one of those errors was ADR-0029's "logged, not fatal" category — a misspelled directive, an asset path climbing out of its bundle, an unreadable picture — where the engine coped and the reader still got a good page. Error used as "I want somebody to see this" means an operator cannot tell a broken build from a typo, which is the same failure as a warning nobody can act on. The seven that remain are the five requests that answer 500 and the two inside fatal. Consequence: the middleware is applied by cmd rather than inside Handler, so tests and the demo's coverage test stay quiet and logging is the operator's choice. Duration comes from content.Now because the clock is confined to one file and verify.sh enforces it by filename. The recorder deliberately does not forward Flusher or ReaderFrom: nothing here streams, so the only cost is an io.Copy fast path on static files, and implementing interfaces no caller needs is the speculation rule 6 forbids. A successful rebuild now logs too — it swapped silently before, which made the failures unreadable for want of anything to compare them to. No wrapper package: log/slog is the module, and a layer over it would be an abstraction with one caller. Only serve takes the flags; check and new are short-lived and print their own findings. Revisit if: request logging shows up in a profile, or an operator needs per-route levels — neither of which a two-flag configuration can express, and both of which would be evidence for a real logging design.

Date: 2026-08-03 · Status: accepted Decision: a Markdown link whose destination is a relative path naming a bundle is rewritten to the URL that bundle is served at. ../day-01.en.md, ../day-01.md and ../day-01 all resolve; a directory bundle resolves by ../notes-on-water/ or its index.en.md. Output stays root-relative (ADR-0039). The language being rendered is preferred, falling back as every lookup does. internal/ext/links/ does the work; the one seam is Origin.Resolve, a callback the renderer receives per rebuild beside Navigation because only the index that exists now knows which route a key answers at. Why: an author writes links against the files, which is what an editor preview resolves, and the engine publishing them unchanged means either broken previews or hand-maintained absolute paths. The second effect matters more: resolution goes through key → route, and a slug moves the route while never moving the key (ADR-0035), so a relative link survives a rename that a hand-written /posts/a-better-name/ does not. This is the engine altering authored markup, which ADR-0045 polices. Legitimate here: it changes an address between two representations of one target, not an author's words. The test that keeps it honest is what it declines to touch, and that table is the feature's largest test — an absolute URL, a scheme-relative URL, a mailto:, a tel:, a root-relative path, a bare fragment, a bare query, a name climbing out of content/, and every relative path whose extension is not .md. That last line is what keeps cover.jpg working: a bundle's assets already resolve because a bundle's URL mirrors its directory, so rewriting them would break what works. Key derivation goes through content.KeyFromName, exported for this. The language-suffix rule — two or three lowercase letters before .md, index naming its directory (ADR-0021) — is the part that would drift between two copies, so it stays in one place; the five lines of joining and prefix-testing are duplicated in check rather than shared, which is the cheaper trade. khosra check reports a relative link ending in .md that resolves to no bundle, as fatal. Only that form: an extensionless relative path may legitimately be an asset, and a checker calling a working link broken is worse than one missing a case, because a checker nobody trusts gets ignored wholesale. Consequence: two things this change owed and paid. render.go reached the file-length advisory, so theme parsing moved to theme.go — one topic per file rather than a shard, since parsing runs per rebuild and rendering runs per request. And the demo's coverage test bound its renderer with a copy of the rebuilder's wiring, so it missed this feature entirely while the binary served it correctly; both now call one bind, which is what ADR-0072 was written about and had already drifted for Navigation. Revisit if: images want the same treatment. They deliberately do not get it — an image is an asset, and the asset case is the one this must never touch.