harness: withdraw five pre-code ADRs to deferred ideas
Twenty-seven ADRs existed before a line of Go. Five specified the shape of unbuilt mechanisms — cache validity, declared types, the settings cascade, taxonomies, extras — which breaks the rule against abstraction before a second concrete use, in prose where the counters cannot see it. They move to ideas/deferred-decisions.md as recorded intent and return shaped by whatever implements them. Citations retargeted throughout; where one was decoration the rule now stands on its own reasoning. Type declarations and the cascade drop to [spec] with the MVP behaviour stated instead, so the first prompts have less to build. conventions.md names http.ServeMux as the router, closing a hole that invited hand-rolling a path splitter. The ADR gate now checks a number is registered in the log rather than headed by an entry, so withdrawals resolve and invented numbers still fail. Two architecture invariants corrected: identity no longer implies a required language suffix, and the duplicated permalink clause is gone.
This commit is contained in:
+3
-1
@@ -119,7 +119,7 @@ is a codebase still navigable by `go doc` and `git log` alone, years from now, w
|
||||
**The style floor fails, it does not warn.** Forbidden package names, `init()`, importing `log`
|
||||
instead of `log/slog`, `panic()` outside `cmd/`, and `time.Now()` outside a `clock.go` are stated
|
||||
absolutely in `conventions.md`, so they exit non-zero. That last one exists because a Stage that reads
|
||||
the clock without declaring a cache validity window (ADR-0013) would serve staleness invisibly. A rule enforced as a suggestion teaches the agent to read every rule as one.
|
||||
the clock without an expiry would serve staleness invisibly. A rule enforced as a suggestion teaches the agent to read every rule as one.
|
||||
Softer signals — `fmt.Errorf` without `%w`, `interface{}`, nesting past 4, exported-and-referenced-
|
||||
once — stay advisory.
|
||||
|
||||
@@ -144,6 +144,8 @@ that describe it, in the same change.**
|
||||
- The gate is not optional. `scripts/hooks/pre-commit` runs it on every commit; enable once per clone
|
||||
with `git config core.hooksPath scripts/hooks`. `--no-verify` bypasses it, and the commit body should
|
||||
say why.
|
||||
- `docs/decisions.md` registers every ADR number ever used, entries and withdrawals alike, so a citation
|
||||
can resolve to a decision or to a deferral but never to nothing.
|
||||
- `./scripts/verify.sh --list` names every gate that exists. A doc claiming enforcement is checkable
|
||||
against it in one command, and `/refresh-docs` checks it in both directions — a claimed gate that is
|
||||
missing, and a real gate nothing explains. Asserting a mechanism before it exists is the drift that
|
||||
|
||||
+13
-22
@@ -23,7 +23,7 @@ interactions. Identity is the bundle; language is a variant of it.
|
||||
## Stage — a transform during rendering
|
||||
`func(ctx, *Page) error`, in fixed order. Every rendering feature is a Stage: typography, Bengali
|
||||
numerals, shortcodes, image sizing, dithering. A Stage runs on everything by default and is switched off
|
||||
by the settings cascade (ADR-0017), never by a type predicate compiled into it; a Stage with nothing to
|
||||
by a settings cascade key (`ideas/deferred-decisions.md`), never by a type predicate compiled into it; a Stage with nothing to
|
||||
do returns early on content shape.
|
||||
**STATUS: deferred.** Called inline in the render path until the transform counter in `state.md`
|
||||
reaches its threshold. Check it; do not guess.
|
||||
@@ -72,14 +72,13 @@ extract the resolver when the routing counter in `state.md` is due, not before.
|
||||
1. **Open page object.** Known fields as struct members plus a `Meta`/`Extra` bag. Absence equals
|
||||
zero value; templates read what exists and never crash on a missing field. Never add a required
|
||||
field. Never make a template failure fatal at request time.
|
||||
2. **Trusted / untrusted pipeline modes.** Content from the site root is trusted; everything else is
|
||||
not, and
|
||||
never reaches shortcode or template evaluation. This is the RCE boundary — the one place where an
|
||||
2. **Trusted / untrusted pipeline modes.** Content from the site root is trusted; everything else is not,
|
||||
and never reaches shortcode or template evaluation. This is the RCE boundary — the one place where an
|
||||
extra check beats an elegant unification.
|
||||
3. **Identity is not language.** `slug.bn.md` / `slug.en.md` with a defined fallback, even while one
|
||||
language exists. Identity stays stable across translations.
|
||||
4. **Request-time render behind a cache, fixed permalink policy.** The server shape is the superset;
|
||||
static export is cache-warming, never a separate code path.
|
||||
3. **Identity is not language.** The bundle key excludes the language suffix, which is optional for the
|
||||
default locale (ADR-0021). Identity stays stable across translations.
|
||||
4. **Request-time render behind a cache.** The server shape is the superset; static export is
|
||||
cache-warming, never a separate code path.
|
||||
5. **Permalinks are permanent.** Renames add aliases and redirects. URLs are not reused.
|
||||
6. **Interactions are off the content graph.** A comment invalidates one fragment, never a build.
|
||||
7. **Every feature is a leaf** — a View, Stage, Query, Interaction adapter, Effect, or
|
||||
@@ -95,21 +94,13 @@ extract the resolver when the routing counter in `state.md` is due, not before.
|
||||
|
||||
## Re-render and invalidation (target shape, not built)
|
||||
|
||||
One validity record per cached entry, five axes, served only while all hold (ADR-0013):
|
||||
A cached entry needs more than a content-dependency key: time, an engine/template epoch, and an opt-out
|
||||
all invalidate too. The shape is recorded as intent in `ideas/deferred-decisions.md`; the cache that implements it decides the fields.
|
||||
|
||||
| Axis | Invalidated by |
|
||||
|---|---|
|
||||
| content deps | bundle keys plus query signatures, taking `old ∪ new` for membership changes |
|
||||
| interaction fragments | a comment or webmention arriving — the fragment, never the page (invariant 6) |
|
||||
| `valid-until` | the clock passing the minimum window the render's Stages declared |
|
||||
| epoch | an engine or template change, which stales every entry at once |
|
||||
| cacheable | the bundle or route declaring itself uncacheable |
|
||||
|
||||
The record maps onto HTTP: ETag from deps plus epoch, `Expires` from `valid-until`, `no-store` for
|
||||
opt-out — so a proxy in front stays correct without engine code. Stages reach the clock only through an
|
||||
injected accessor, so a window can never be silently forgotten. Defer the inverted index — v1
|
||||
re-renders everything, correct until it is slow. Asset derivatives are content-addressed and therefore
|
||||
immutable: never invalidated, far-future cacheable, a different lifetime from page renders.
|
||||
Two things hold regardless. Stages reach the clock only through an injected accessor, so a
|
||||
time-dependent render can never silently outlive its truth. And asset derivatives are content-addressed
|
||||
and therefore immutable — never invalidated, far-future cacheable, a different lifetime from page
|
||||
renders. Defer the inverted index: v1 re-renders everything, correct until it is slow.
|
||||
|
||||
## Dropped on purpose
|
||||
|
||||
|
||||
+16
-15
@@ -7,8 +7,8 @@ statement here describes engine behaviour, not an obligation on content.
|
||||
The most expensive thing in the engine to change, because published URLs are permanent and the files it
|
||||
reads are somebody's database.
|
||||
|
||||
Markers are build order, not status: `[arc1]` build now · `[spec]` agreed shape, build at first real
|
||||
use. What exists is `state.md`'s job. Never build a `[spec]` section because it is written here.
|
||||
Markers are build order, not status: `[arc1]` build now · `[spec]` recorded intent, shape decided by
|
||||
whatever builds it. What exists is `state.md`'s job. Never build a `[spec]` section because it is written here.
|
||||
|
||||
## The site root
|
||||
|
||||
@@ -71,7 +71,7 @@ readable by templates (ADR-0002). Never add a required field.
|
||||
| `slug` | string | Overrides the derived slug. The engine serves the new path only; the old one 404s unless it appears in `aliases` |
|
||||
| `aliases` | []string | Additional paths the engine resolves to this bundle, each redirecting permanently to the canonical one (ADR-0008) |
|
||||
| `draft` | bool | Excluded from queries and feeds |
|
||||
| `nocache` | bool | Never cache this bundle's render (ADR-0013). Named so absence means cacheable, per ADR-0002 |
|
||||
| `nocache` | bool | Never cache this bundle's render. Named so absence means cacheable, per ADR-0002 |
|
||||
| `summary` | string | Explicit summary; otherwise derived |
|
||||
| `tags` | []string | Flat, case-preserved, Unicode |
|
||||
| `series` / `order` | string / int | Series membership and position. Sparse by convention (10, 20, 30) so insertion is one edit; never appears in a URL (ADR-0016) |
|
||||
@@ -82,13 +82,13 @@ readable by templates (ADR-0002). Never add a required field.
|
||||
|
||||
## Post types
|
||||
|
||||
Types are **declarations**, not code paths (ADR-0014). The binary embeds the default set below; a
|
||||
`types:` block in `site.yaml` extends or overrides it, so a seventh type costs a declaration plus a
|
||||
template and no engine change. An incoherent declaration is a loud startup failure.
|
||||
A type is a frontmatter value, never a code path: it may change the default View and default query
|
||||
membership and nothing else. Adding one must not add a branch to the core. Absent `type`, it defaults
|
||||
from the top-level section directory.
|
||||
|
||||
Each declaration carries: `section` (default directory), `view` (default View), `order` (date, sequence,
|
||||
or manual), `feeds` (primary, section-only, or none), `required` (frontmatter fields that must exist),
|
||||
`titleless` (whether an empty title is legal), and `taxonomies`.
|
||||
`[spec]` A declaration file (`types:` in `site.yaml`) carrying per-type defaults — view, ordering, feed
|
||||
membership, required fields, titleless legality, taxonomies — is recorded intent (`ideas/deferred-decisions.md`), not built.
|
||||
The set below is what the engine knows without one.
|
||||
|
||||
| Type | Section | Order | Feeds | Distinguishing need |
|
||||
|---|---|---|---|---|
|
||||
@@ -155,7 +155,7 @@ graph. Pagination is a Query parameter plus a permalink rule for page 2+, still
|
||||
|
||||
## Taxonomies `[spec]`
|
||||
|
||||
Two kinds, deliberately (ADR-0018).
|
||||
Two kinds, deliberately. Recorded intent (`ideas/deferred-decisions.md`), not built.
|
||||
|
||||
**`tags` are one global namespace** across every type. `/tags/{tag}/` lists everything carrying the tag;
|
||||
`/{section}/tags/{tag}/` narrows it; listing views group results by type so a busy tag stays readable.
|
||||
@@ -169,9 +169,10 @@ test: a tag is free-form and cross-cutting, a declared taxonomy has known terms
|
||||
Feeds follow the same shape: `/feed.xml` carries every type declared `primary`, `/{section}/feed.xml`
|
||||
carries a section, and `/tags/{tag}/feed.xml` comes free from the same Query.
|
||||
|
||||
## The settings cascade
|
||||
## The settings cascade `[spec]`
|
||||
|
||||
Settings resolve site → section → bundle, nearest explicit value winning (ADR-0017):
|
||||
Recorded intent (`ideas/deferred-decisions.md`), not built. Until it exists, settings come from a bundle's own frontmatter.
|
||||
The shape: site → section → bundle, nearest explicit value winning.
|
||||
|
||||
| Level | Where it lives |
|
||||
|---|---|
|
||||
@@ -190,7 +191,7 @@ This is also how a template is chosen: type default, then section override, then
|
||||
## Extras: enumerated local assets `[spec]`
|
||||
|
||||
A bundle may hold a directory of supporting files — drafts, notes, logs, scans, media. Default `extras/`,
|
||||
renamed by a cascade key so a comic can use `process/` and a story `notes/` (ADR-0025).
|
||||
renamed by a cascade key so a comic can use `process/` and a story `notes/`. Recorded intent (`ideas/deferred-decisions.md`).
|
||||
|
||||
- The bundle scanner **skips it entirely**. A `.md` inside is an asset, not a bundle: no frontmatter, no
|
||||
identity, no language variants.
|
||||
@@ -208,7 +209,7 @@ asset, everything under extras (ADR-0024). An unpublished bundle answers **404**
|
||||
assets; 403 would confirm the work exists. `-dev` reveals drafts and future-dated bundles with their
|
||||
assets, defaults off, and is the only thing that changes the answer.
|
||||
|
||||
For a future-dated bundle the 404 carries `valid-until` = its publish time (ADR-0013), so it expires
|
||||
For a future-dated bundle the 404 expires at its publish time, so it becomes visible
|
||||
exactly when the bundle becomes public.
|
||||
|
||||
## Includes and shortcodes `[spec]`
|
||||
@@ -229,7 +230,7 @@ with an ADR.
|
||||
Anything derivable from a page plus the current clock is computed by a Stage, never stored in content:
|
||||
an "this article is old" banner compares `date` to now, relative dates likewise. Because renders are
|
||||
cached (ADR-0005), such a Stage also declares how long its output stays true — a banner until
|
||||
`date`+threshold, a relative date for a minute — and the entry expires then (ADR-0013). Future-dated
|
||||
`date`+threshold, a relative date for a minute — and the cached entry expires then. Future-dated
|
||||
publication is the same shape seen from the other side: the page becomes reachable at a moment nobody is
|
||||
requesting it.
|
||||
|
||||
|
||||
+6
-5
@@ -5,8 +5,9 @@ goes through an ADR, not a diff.
|
||||
|
||||
## Language and dependencies
|
||||
- Go, current stable release. Stdlib first, every time.
|
||||
- `net/http`, `html/template`, `log/slog`, `os`, `io/fs`, `embed`. No web framework, ORM, router
|
||||
library, or config library — flags plus environment variables, parsed in one place. The site root
|
||||
- `net/http`, `html/template`, `log/slog`, `os`, `io/fs`, `embed`. Routing is `http.ServeMux` with Go
|
||||
1.22+ patterns — `GET /{section}/{slug}/` and `{path...}` for multi-segment slugs — so no router
|
||||
library is needed. No web framework, ORM, or config library — flags plus environment variables, parsed in one place. The site root
|
||||
(`-site`, `ATELIER_SITE`) is the only required setting; default templates are `embed`ded so a bare
|
||||
site root renders (ADR-0011).
|
||||
- New dependency = ADR + human approval + `scripts/allowed-deps.txt`. `verify.sh` enforces it.
|
||||
@@ -37,9 +38,9 @@ wants splitting.
|
||||
- No `init()`. No package-level mutable state. No singletons. Wire explicitly in `cmd`.
|
||||
- Accept interfaces only where a second implementation exists; return concrete types.
|
||||
- `ctx context.Context` first when a call can block or be cancelled — not decoratively.
|
||||
- Never call `time.Now()` outside a `clock.go`. Stages read the clock through an injected accessor and
|
||||
declare a validity window with it (ADR-0013); `verify.sh` fails on any other caller, because a
|
||||
forgotten window serves staleness silently.
|
||||
- Never call `time.Now()` outside a `clock.go`. A render that reads the clock is only true for a while,
|
||||
and an injected accessor is what makes that expressible later; `verify.sh` fails on any other caller,
|
||||
because a forgotten expiry serves staleness silently.
|
||||
- Comments explain *why*, never *what*. Delete a comment narrating the next line. One stating a
|
||||
non-obvious invariant is worth ten describing control flow.
|
||||
|
||||
|
||||
+10
-91
@@ -1,6 +1,13 @@
|
||||
# Decisions (ADR log)
|
||||
|
||||
Append-only. Never rewrite history; supersede with a new entry. Six lines each:
|
||||
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
|
||||
@@ -136,42 +143,6 @@ means the engine has background work, so every Effect must be idempotent and its
|
||||
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-0013 — Cache validity is one record with five axes
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: a cache entry carries a validity record — content dependencies, interaction fragment refs,
|
||||
`valid-until`, engine+template epoch, and a cacheable flag — and is served only while all five hold.
|
||||
`valid-until` is the minimum of the windows the render's Stages declare; Stages reach the clock through
|
||||
an injected accessor, never `time.Now()`. An optional scheduled Effect may pre-warm entries, but
|
||||
scheduling is never what makes a page correct.
|
||||
Why: invalidation has five causes, not one. Content edits and interactions were already modelled; time
|
||||
(old-article banner, relative dates, future-dated publication), epoch (a template fix silently serving
|
||||
old HTML), and opt-out (search, a random-page route) were not. A scheduled re-render sweep expresses
|
||||
only the time axis, and coarsely — relative dates would need a sweep finer than their own granularity.
|
||||
Consequence: cheap — the record maps onto HTTP semantics (ETag from deps plus epoch, `Expires` from
|
||||
`valid-until`, `no-store` for opt-out), so a reverse proxy or CDN in front is correct with no extra
|
||||
code, and a new time-dependent Stage needs no central registry because windows compose by minimum.
|
||||
Expensive — the Stage contract gains a return channel, and a Stage that reads the clock without
|
||||
declaring a window would serve staleness silently, which is why the clock accessor is enforced by
|
||||
`verify.sh` rather than trusted.
|
||||
Revisit if: windows cannot express some invalidation cause — then add an axis, do not add a mechanism.
|
||||
|
||||
## ADR-0014 — Content types are declared, not coded
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: each content type is a declaration — section, default view, ordering rule, feed membership,
|
||||
required frontmatter, whether titleless is legal, applicable taxonomies. The binary embeds a default set
|
||||
(`post`, `comic`, `art`, `writing`, `page`, `status`); a declaration file in the site root extends or
|
||||
overrides it. Adding a type costs a declaration plus a template and no core change; an unparseable or
|
||||
incoherent declaration is a loud startup failure.
|
||||
Why: six types already carry per-type behaviour, described in prose. Prose becomes either a `switch type`
|
||||
in the core — forbidden, and it would grow with every new type — or unwritten template convention. A
|
||||
declaration is the only form that keeps the seventh type as cheap as the sixth.
|
||||
Consequence: cheap — a new type is a site-repo change with no engine deploy (ADR-0011), and the content
|
||||
`check` command gets its validation rules from the same declarations for free. Expensive — the engine
|
||||
must treat the type set as data, so nothing may assume a fixed list, and per-type behaviour reachable
|
||||
only from code (a bespoke View) still needs a template rather than a branch.
|
||||
Revisit if: a type needs behaviour no declaration can express — then it is a View or an Effect, not a
|
||||
new field on every type.
|
||||
|
||||
## 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,
|
||||
@@ -206,46 +177,12 @@ Expensive — ordering is invisible in a directory listing, so authors read it f
|
||||
`order` values want leaving gaps.
|
||||
Revisit if: never. Position in a permalink is the mistake this exists to prevent.
|
||||
|
||||
## ADR-0017 — Settings cascade: site → section → bundle
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: settings resolve down a cascade — site declaration, then each enclosing section's `_index`,
|
||||
then the bundle's own frontmatter — with the nearest explicit value winning. The cascade carries stage
|
||||
toggles, view selection, taxonomy defaults, cache flags, and metadata defaults; it carries *declared*
|
||||
keys only, never arbitrary engine internals. Stages apply to everything by default and are switched off
|
||||
by a cascade key, not by a predicate compiled into the stage.
|
||||
Why: stages are mostly no-ops where they do not apply, and their early return is a check on content
|
||||
shape ("no images here") which is type-independent and belongs in the stage anyway. The cases that really
|
||||
need control are narrower and cut across types — dithering wrong on one diagram, autolinking wrong in one
|
||||
poem — which a per-type stage set cannot express and a cascade can. It also unifies with template
|
||||
selection, which wants the same resolution order.
|
||||
Consequence: cheap — one resolution rule covers rendering, presentation and metadata defaults; a section
|
||||
sets a policy once for everything beneath it. Expensive — resolution must be cheap and cached, since it
|
||||
runs per bundle, and the set of cascadable keys must stay declared or it becomes unbounded config.
|
||||
Revisit if: cascade resolution shows up in a render-path profile.
|
||||
|
||||
## ADR-0018 — Global flat tags, declared structural taxonomies
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: `tags` is one global namespace across every type — `/tags/{tag}/` lists everything carrying it,
|
||||
`/{section}/tags/{tag}/` narrows to a section, and listing views group results by type. Structural
|
||||
metadata with known terms that drives behaviour — `series`, `medium`, `genre` — is a *declared* taxonomy
|
||||
on the type (ADR-0014) and never enters the tag pool. Feeds follow the same shape: `/feed.xml` carries
|
||||
every type declared `primary`, `/{section}/feed.xml` carries a section, `/tags/{tag}/feed.xml` falls out
|
||||
of the same Query.
|
||||
Why: cross-type discovery is the point of a single-author site — one tag spanning a comic, a poem and a
|
||||
photo essay is a feature. Per-section tag pools would fragment that for a readability problem better
|
||||
solved by grouping in the View. But a tag is free-form and cross-cutting, while a structural taxonomy has
|
||||
a fixed term set and changes what the engine does; conflating them makes both worse.
|
||||
Consequence: cheap — one Query with an optional section predicate serves tag pages, section tag pages and
|
||||
their feeds. Expensive — tag hygiene is now the author's discipline, since nothing scopes them; the
|
||||
content `check` command should report near-duplicate terms.
|
||||
Revisit if: the tag pool becomes unusable in practice — and then the answer is curation, not namespacing.
|
||||
|
||||
## 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 (ADR-0017): type default, section override, then the bundle's own `view`.
|
||||
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
|
||||
@@ -271,7 +208,6 @@ multi-line strings work as authors expect. Expensive — the third allowlist ent
|
||||
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
|
||||
@@ -336,30 +272,13 @@ unfinished work leak through the asset path — which looks like static file ser
|
||||
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 carries `valid-until` = its publish time (ADR-0013), so it expires exactly when it should
|
||||
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-0025 — Extras: local assets, enumerated and browsable
|
||||
Date: 2026-07-29 · Status: accepted
|
||||
Decision: a bundle may hold a directory of supporting files — default `extras/`, named by a cascade key —
|
||||
which the bundle scanner **skips entirely**: a `.md` in there is an asset, never a bundle. The engine
|
||||
enumerates it as a tree, classifies each entry by extension, renders the ones it can (markdown, plain
|
||||
text), and serves the rest as bytes behind the ADR-0024 guard. Two behaviours on one route:
|
||||
`…/extras/{path}` renders the listing with that entry selected, `?raw` returns the bytes. Sorted by
|
||||
filename; excluded from feeds, queries and search.
|
||||
Why: drafts, notes and logs are worth publishing as artefacts of the process, and they are not bundles —
|
||||
no frontmatter, no identity, no language variants. `architecture.md` already defines a Bundle as carrying
|
||||
local assets; the only thing missing was enumerating them instead of merely referencing them relatively.
|
||||
Consequence: cheap — no new primitive, and selecting an entry is an ordinary link, so the whole feature
|
||||
works without JavaScript. Expensive — the scanner needs an exclusion rule it did not have, and the extras
|
||||
segment becomes a name no child of a bundle may use, which is why it is a cascade key rather than a
|
||||
constant.
|
||||
Revisit if: extras need per-file metadata — and then they are bundles after all, and this entry was wrong.
|
||||
|
||||
## 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
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ wearing a disguise.
|
||||
| `PhasePage` | the assembled page object | OpenGraph, JSON-LD, related posts, series nav |
|
||||
| `PhaseOutput` | the final byte stream | minification, dithering, gemtext conversion |
|
||||
|
||||
Every Stage runs on every bundle unless the cascade disables it (ADR-0017), and declares its trust
|
||||
Every Stage runs on every bundle unless a cascade key disables it (`ideas/deferred-decisions.md`), and declares its trust
|
||||
requirement. A Stage evaluating templates or shortcodes runs in trusted mode only, and the pipeline
|
||||
refuses it otherwise — enforced in code, not by convention, and
|
||||
tested with an untrusted-input case.
|
||||
|
||||
@@ -70,3 +70,4 @@ Bengali tokenisation for search is the genuinely novel problem here — solve it
|
||||
and deliberately, not as a side effect of adding search. The low-bandwidth, low-carbon ethos is
|
||||
coherent with Gemini output, PWA offline, and no-JS defaults; let that coherence break ties when two
|
||||
designs are otherwise equal.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# State
|
||||
|
||||
**Verified against:** `<commit sha>` on `<date>` — update this line every change.
|
||||
**Verified against:** `54c14a2` on 2026-07-30 — update this line every change.
|
||||
If this file disagrees with the code, the code is right and this file is a bug.
|
||||
|
||||
## Inventory
|
||||
|
||||
@@ -20,17 +20,17 @@ Breaking the contract is not a feature — it is a new contract version, and it
|
||||
| Provides | Detail |
|
||||
|---|---|
|
||||
| the page object | known fields plus an `Extra` bag carrying unknown frontmatter (ADR-0002) |
|
||||
| the resolved cascade | settings for this bundle after site → section → bundle resolution (ADR-0017) |
|
||||
| the resolved cascade | settings after site → section → bundle resolution (`ideas/deferred-decisions.md`) |
|
||||
| queries the page needs | its sequence neighbours, its taxonomy terms, its section's members |
|
||||
| named template lookup | per-type sets; a theme redefines a named block and inherits the rest (ADR-0019) |
|
||||
| URLs | every path the engine emits, so a theme never constructs one by hand |
|
||||
| per-page assets | the `styles` / `scripts` frontmatter lists, resolved relative to the bundle |
|
||||
| chrome strings | looked up by key and language, never hardcoded English in a template |
|
||||
| validity windows | a template that renders time-dependent output declares one (ADR-0013) |
|
||||
| validity windows | a template that renders time-dependent output declares one |
|
||||
|
||||
## Extras view
|
||||
|
||||
For a request under a bundle's extras directory (ADR-0025) the engine additionally provides:
|
||||
For a request under a bundle's extras directory (`ideas/deferred-decisions.md`) the engine additionally provides:
|
||||
|
||||
| Field | Contents |
|
||||
|---|---|
|
||||
|
||||
@@ -39,6 +39,7 @@ you, not the agent; `verify.sh` keeps it honest without reading it into context.
|
||||
|
||||
## Index
|
||||
|
||||
- [deferred-decisions.md](deferred-decisions.md) — five pre-code ADRs demoted to intent; each returns when something implements it. **parked**
|
||||
- [specs-as-secondary-artifacts.md](specs-as-secondary-artifacts.md) — optional per-feature specs, derived by default, plus the named-test convention. **parked**
|
||||
- [engine-design-review.md](engine-design-review.md) — open design decisions for a multi-type site; items graduate to ADRs one at a time. **parked**
|
||||
- [token-conservation.md](token-conservation.md) — cut agent token cost without losing output quality. **parked**
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Deferred decisions — recorded intent, not binding shape
|
||||
|
||||
Status: parked
|
||||
Raised: 2026-07-30
|
||||
|
||||
Five ADRs were written before any code existed. They describe a direction worth keeping, but their
|
||||
*shape* is speculation: the harness's own rule is no abstraction before its second concrete use, and a
|
||||
field-level specification of an unbuilt mechanism breaks that rule in prose instead of code.
|
||||
|
||||
Their entries in `decisions.md` are now `Status: intent` stubs pointing here, so citations still resolve
|
||||
and nothing is lost. Each returns as a full ADR when something implements it — and the implementation,
|
||||
not this file, decides the shape.
|
||||
|
||||
## Cache validity model
|
||||
|
||||
Deferred because no cache exists, and the harness forbids one until requests feel slow.
|
||||
|
||||
## Cache validity is one record with five axes
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: a cache entry carries a validity record — content dependencies, interaction fragment refs,
|
||||
`valid-until`, engine+template epoch, and a cacheable flag — and is served only while all five hold.
|
||||
`valid-until` is the minimum of the windows the render's Stages declare; Stages reach the clock through
|
||||
an injected accessor, never `time.Now()`. An optional scheduled Effect may pre-warm entries, but
|
||||
scheduling is never what makes a page correct.
|
||||
Why: invalidation has five causes, not one. Content edits and interactions were already modelled; time
|
||||
(old-article banner, relative dates, future-dated publication), epoch (a template fix silently serving
|
||||
old HTML), and opt-out (search, a random-page route) were not. A scheduled re-render sweep expresses
|
||||
only the time axis, and coarsely — relative dates would need a sweep finer than their own granularity.
|
||||
Consequence: cheap — the record maps onto HTTP semantics (ETag from deps plus epoch, `Expires` from
|
||||
`valid-until`, `no-store` for opt-out), so a reverse proxy or CDN in front is correct with no extra
|
||||
code, and a new time-dependent Stage needs no central registry because windows compose by minimum.
|
||||
Expensive — the Stage contract gains a return channel, and a Stage that reads the clock without
|
||||
declaring a window would serve staleness silently, which is why the clock accessor is enforced by
|
||||
`verify.sh` rather than trusted.
|
||||
Revisit if: windows cannot express some invalidation cause — then add an axis, do not add a mechanism.
|
||||
|
||||
## Declared content types
|
||||
|
||||
Deferred because the MVP can default a type from its section directory; a declaration file earns itself at the seventh type or the first per-type override.
|
||||
|
||||
## Content types are declared, not coded
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: each content type is a declaration — section, default view, ordering rule, feed membership,
|
||||
required frontmatter, whether titleless is legal, applicable taxonomies. The binary embeds a default set
|
||||
(`post`, `comic`, `art`, `writing`, `page`, `status`); a declaration file in the site root extends or
|
||||
overrides it. Adding a type costs a declaration plus a template and no core change; an unparseable or
|
||||
incoherent declaration is a loud startup failure.
|
||||
Why: six types already carry per-type behaviour, described in prose. Prose becomes either a `switch type`
|
||||
in the core — forbidden, and it would grow with every new type — or unwritten template convention. A
|
||||
declaration is the only form that keeps the seventh type as cheap as the sixth.
|
||||
Consequence: cheap — a new type is a site-repo change with no engine deploy (ADR-0011), and the content
|
||||
`check` command gets its validation rules from the same declarations for free. Expensive — the engine
|
||||
must treat the type set as data, so nothing may assume a fixed list, and per-type behaviour reachable
|
||||
only from code (a bespoke View) still needs a template rather than a branch.
|
||||
Revisit if: a type needs behaviour no declaration can express — then it is a View or an Effect, not a
|
||||
new field on every type.
|
||||
|
||||
## Settings cascade
|
||||
|
||||
Deferred because frontmatter alone covers the MVP; a cascade earns itself when a section-wide policy is actually wanted.
|
||||
|
||||
## Settings cascade: site → section → bundle
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: settings resolve down a cascade — site declaration, then each enclosing section's `_index`,
|
||||
then the bundle's own frontmatter — with the nearest explicit value winning. The cascade carries stage
|
||||
toggles, view selection, taxonomy defaults, cache flags, and metadata defaults; it carries *declared*
|
||||
keys only, never arbitrary engine internals. Stages apply to everything by default and are switched off
|
||||
by a cascade key, not by a predicate compiled into the stage.
|
||||
Why: stages are mostly no-ops where they do not apply, and their early return is a check on content
|
||||
shape ("no images here") which is type-independent and belongs in the stage anyway. The cases that really
|
||||
need control are narrower and cut across types — dithering wrong on one diagram, autolinking wrong in one
|
||||
poem — which a per-type stage set cannot express and a cascade can. It also unifies with template
|
||||
selection, which wants the same resolution order.
|
||||
Consequence: cheap — one resolution rule covers rendering, presentation and metadata defaults; a section
|
||||
sets a policy once for everything beneath it. Expensive — resolution must be cheap and cached, since it
|
||||
runs per bundle, and the set of cascadable keys must stay declared or it becomes unbounded config.
|
||||
Revisit if: cascade resolution shows up in a render-path profile.
|
||||
|
||||
## Taxonomies and feed shape
|
||||
|
||||
Deferred because no tag pages and no feeds in the MVP.
|
||||
|
||||
## Global flat tags, declared structural taxonomies
|
||||
Date: 2026-07-28 · Status: accepted
|
||||
Decision: `tags` is one global namespace across every type — `/tags/{tag}/` lists everything carrying it,
|
||||
`/{section}/tags/{tag}/` narrows to a section, and listing views group results by type. Structural
|
||||
metadata with known terms that drives behaviour — `series`, `medium`, `genre` — is a *declared* taxonomy
|
||||
on the type (ADR-0014) and never enters the tag pool. Feeds follow the same shape: `/feed.xml` carries
|
||||
every type declared `primary`, `/{section}/feed.xml` carries a section, `/tags/{tag}/feed.xml` falls out
|
||||
of the same Query.
|
||||
Why: cross-type discovery is the point of a single-author site — one tag spanning a comic, a poem and a
|
||||
photo essay is a feature. Per-section tag pools would fragment that for a readability problem better
|
||||
solved by grouping in the View. But a tag is free-form and cross-cutting, while a structural taxonomy has
|
||||
a fixed term set and changes what the engine does; conflating them makes both worse.
|
||||
Consequence: cheap — one Query with an optional section predicate serves tag pages, section tag pages and
|
||||
their feeds. Expensive — tag hygiene is now the author's discipline, since nothing scopes them; the
|
||||
content `check` command should report near-duplicate terms.
|
||||
Revisit if: the tag pool becomes unusable in practice — and then the answer is curation, not namespacing.
|
||||
|
||||
## Extras
|
||||
|
||||
Deferred because not in the MVP, and its shape should be discovered by building it.
|
||||
|
||||
## Extras: local assets, enumerated and browsable
|
||||
Date: 2026-07-29 · Status: accepted
|
||||
Decision: a bundle may hold a directory of supporting files — default `extras/`, named by a cascade key —
|
||||
which the bundle scanner **skips entirely**: a `.md` in there is an asset, never a bundle. The engine
|
||||
enumerates it as a tree, classifies each entry by extension, renders the ones it can (markdown, plain
|
||||
text), and serves the rest as bytes behind the ADR-0024 guard. Two behaviours on one route:
|
||||
`…/extras/{path}` renders the listing with that entry selected, `?raw` returns the bytes. Sorted by
|
||||
filename; excluded from feeds, queries and search.
|
||||
Why: drafts, notes and logs are worth publishing as artefacts of the process, and they are not bundles —
|
||||
no frontmatter, no identity, no language variants. `architecture.md` already defines a Bundle as carrying
|
||||
local assets; the only thing missing was enumerating them instead of merely referencing them relatively.
|
||||
Consequence: cheap — no new primitive, and selecting an entry is an ordinary link, so the whole feature
|
||||
works without JavaScript. Expensive — the scanner needs an exclusion rule it did not have, and the extras
|
||||
segment becomes a name no child of a bundle may use, which is why it is a cascade key rather than a
|
||||
constant.
|
||||
Revisit if: extras need per-file metadata — and then they are bundles after all, and this entry was wrong.
|
||||
@@ -6,9 +6,9 @@ Raised: 2026-07-28
|
||||
One review, many decisions. Each item below graduates independently: when one is settled it becomes an
|
||||
ADR (or a change to its owning doc) and is struck from this list. When the list empties, delete the
|
||||
file. Graduated so far: site root external to the engine repo (ADR-0011), Effect as the sixth
|
||||
primitive (ADR-0012), the cache validity model (ADR-0013), declared content types (ADR-0014),
|
||||
NFC normalisation and slug derivation (ADR-0015), sequence position (ADR-0016), the settings cascade
|
||||
(ADR-0017), taxonomies and feeds (ADR-0018), template composition (ADR-0019), feature locality (ADR-0027).
|
||||
primitive (ADR-0012), NFC normalisation and slug derivation (ADR-0015), sequence position (ADR-0016),
|
||||
template composition (ADR-0019), feature locality (ADR-0027). Five more were recorded and then deferred
|
||||
to `deferred-decisions.md`: cache validity, declared types, the cascade, taxonomies, extras.
|
||||
|
||||
## Why it came up
|
||||
|
||||
|
||||
+3
-2
@@ -90,7 +90,8 @@ if [ -d .git ] && command -v git >/dev/null 2>&1; then
|
||||
adrs=$(grep -rhoE 'ADR-[0-9]{4}' docs CLAUDE.md HARNESS.md ideas reference .claude scripts cmd internal 2>/dev/null | sort -u)
|
||||
missingadr=""
|
||||
for a in $adrs; do
|
||||
grep -q "^## $a" docs/decisions.md 2>/dev/null || missingadr="$missingadr $a"
|
||||
# the log registers every number ever used — as an entry, or in the withdrawn line
|
||||
grep -q "$a" docs/decisions.md 2>/dev/null || missingadr="$missingadr $a"
|
||||
done
|
||||
[ -n "$missingadr" ] && bad "reference to an ADR with no entry in decisions.md:$missingadr"
|
||||
|
||||
@@ -341,7 +342,7 @@ badlog=$(echo "$pairs" | awk '$2=="log" {print $1}' | sort -u)
|
||||
if [ -n "$badlog" ]; then bad "imports log, not log/slog: $(echo "$badlog" | tr '\n' ' ')"; else pass "log/slog only"; fi
|
||||
|
||||
clocks=$(echo "$gofiles" | grep -v '_test\.go$' | grep -v '/clock\.go$' | xargs grep -ln 'time\.Now(' 2>/dev/null || true)
|
||||
if [ -n "$clocks" ]; then bad "time.Now() outside a clock.go — Stages need an injected clock to declare a validity window (ADR-0013): $(echo "$clocks" | tr '\n' ' ')"; else pass "clock accessed through clock.go"; fi
|
||||
if [ -n "$clocks" ]; then bad "time.Now() outside a clock.go — a render that reads the clock is only true for a while (conventions.md): $(echo "$clocks" | tr '\n' ' ')"; else pass "clock accessed through clock.go"; fi
|
||||
|
||||
panics=$(echo "$gofiles" | grep -v '_test\.go$' | grep -v '^\./cmd/' | xargs grep -ln 'panic(' 2>/dev/null || true)
|
||||
if [ -n "$panics" ]; then bad "panic() outside cmd/ — request-time failure degrades (conventions.md): $(echo "$panics" | tr '\n' ' ')"; else pass "no panic outside cmd"; fi
|
||||
|
||||
Reference in New Issue
Block a user