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