diff --git a/HARNESS.md b/HARNESS.md index f55e6b5..adda5d4 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index e98e59a..2ab933a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/content-model.md b/docs/content-model.md index 2a5d123..e392322 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -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. diff --git a/docs/conventions.md b/docs/conventions.md index ce6a32f..ff20f98 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -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. diff --git a/docs/decisions.md b/docs/decisions.md index 2b46c35..647557f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 diff --git a/docs/extensions.md b/docs/extensions.md index b8e1c05..6373bd0 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0104448..3436201 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. + diff --git a/docs/state.md b/docs/state.md index fb64f93..2b35786 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `` on `` — 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 diff --git a/docs/theme-contract.md b/docs/theme-contract.md index 42231e4..2eb013f 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -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 | |---|---| diff --git a/ideas/README.md b/ideas/README.md index 5c59ad2..31cf9ff 100644 --- a/ideas/README.md +++ b/ideas/README.md @@ -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** diff --git a/ideas/deferred-decisions.md b/ideas/deferred-decisions.md new file mode 100644 index 0000000..5635a1e --- /dev/null +++ b/ideas/deferred-decisions.md @@ -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. diff --git a/ideas/engine-design-review.md b/ideas/engine-design-review.md index 25d0159..3a09907 100644 --- a/ideas/engine-design-review.md +++ b/ideas/engine-design-review.md @@ -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 diff --git a/scripts/verify.sh b/scripts/verify.sh index 331a0a6..dca5a46 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -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