Files
khosra/docs/state.md
T
Claude Opus 5andbdeshi 633debf743 apply a template edit without a restart
The watcher fingerprinted templates/ but a rebuild only re-scanned content, so
editing a template fired a rebuild that changed nothing. ADR-0022 already
promised the opposite — "a template edit in the site root invalidates through
the same path as content" — which makes this a defect against a recorded
decision rather than a missing feature. ADR-0055 records the fix and supersedes
ADR-0048's narrower clause.

The parsed sets and the stylesheet become one parsedTheme behind an
atomic.Pointer, swapped by Refresh once per rebuild instead of per request. A
parse failure keeps the theme that was working, so a typo cannot take the site
down. The swap also retires the in-place field mutation -dev was doing, which
was a data race with every in-flight render.

site.yaml goes the other way and leaves the fingerprint: the settings are copied
by value into the renderer, the handler, the feeds and the sitemap, so applying
a change to some of them is worse than applying it to none. It is restart-only.

Corrects the Effects counter row while proving it did not move: it still said
startup was the only change signal "until queue 21", but queue 21 shipped as
ADR-0048 and put the derivative pass inside rebuilder, so that has been wrong
since. The row now also answers the question ADR-0055 invites — an in-memory
swap is not an Effect, because it writes no artifact and calls nothing outbound.

Measured on the real binary: a template edit went live in ~2s; a typo logged
"keeping the previous theme" and kept answering 200 with the last good markup; a
site.yaml edit now fires no rebuild at all. core 2766/2800, ext 1030/2000,
34 gates green, 0 warnings.
2026-08-01 10:54:30 +06:00

147 lines
16 KiB
Markdown

# State
**Verified against:** `1909a31` on 2026-08-01 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
Line counts are **not** here: `docs/surface.md` is generated from the code and carries per-file sizes
plus every declaration's location (ADR-0053). This column drifted on six files before it was removed,
which is what a number written in two places always does. What each file is *for* is the fact this
table owns.
| File | Purpose |
|---|---|
| `go.mod` | module `khosra`; `goldmark`, `x/text`, `yaml.v3` direct |
| `internal/content/doc.go` | package comment |
| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, partial files, permalink building |
| `internal/content/clock.go` | the one place the engine reads the wall clock, which `verify.sh` enforces by filename |
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) |
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`, swapped by `Refresh` on every rebuild (ADR-0055) |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) |
| `internal/render/templates/` | reference theme, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) |
| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044) |
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/`, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) |
| `internal/web/resolve.go` | URL → (key, lang, page, tag, feed, extras) or a canonical redirect |
| `internal/web/extras.go` | the extras route: listing, one entry selected, or `?raw` bytes, all behind the bundle lookup |
| `internal/web/asset.go` | files inside a bundle's own directory, looked up through the owning bundle so visibility can only ever inherit (ADR-0024) |
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) |
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) |
| `internal/web/web.go` | handler: `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure |
| `cmd/khosra/main.go` | flags, wiring, startup, the derivative pass, and the two atomic swaps a rebuild goes through — theme and index. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature |
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach, the root listing, and the example site end to end |
Serves a listing of everything at `/` (ADR-0050), a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
section-narrowed, sequence navigation and a series archive on any nested bundle, `static/` verbatim, a directory bundle's own files under its
URL, generated derivatives under `/derived/`, Atom feeds per site,
section and tag, a bundle's extras as a browsable tree, plus `/robots.txt` and `/sitemap.xml`.
Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with
`khosra check` validates a site root and exits non-zero on anything that makes it wrong; `khosra new`
scaffolds a draft bundle into one. A running server notices changes under `content/` and `templates/` by
polling and swaps both the index and the parsed theme atomically, so a content *or* template edit appears
without a restart (ADR-0022, ADR-0055); `site.yaml` applies at startup only. A draft or
future-dated bundle is not served at all — nor is any file inside it (ADR-0024) — until `-dev on` reveals it and
reloads templates per request.
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
URLs go absolute (ADR-0039).
Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`, `slug`. Every other key in
`content-model.md`'s table — including `draft` and `type` — lands in `Extra` unread, so that table
is the accepted format, not a list of what runs.
`examples/demo-site/` is a complete site kept in the repository to be read and served — 30 bundles across six
sections, one case per feature in `TestTheExampleSiteExercisesEveryFeature`, and `khosra check` run over it by
`verify.sh` (ADR-0051). `make demo` serves it.
A `Dockerfile` ships the binary alone: the site root is a mounted volume, never copied in (ADR-0010, ADR-0011).
Dependencies: four, all allowlisted — `goldmark`, `golang.org/x/text`, `golang.org/x/image`, `gopkg.in/yaml.v3`.
## Counters — the earn-it authority
Never anticipate a threshold. Increment when the code lands, then check whether the extraction is *due
this change*.
| Counter | Now | Extraction due at | What it buys |
|---|---|---|---|
| Render transforms — **page-level only** | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`). Parse-phase work does *not* count and must not: goldmark's extender list is already an ordered pipeline for it, so typography and shortcodes compose there (`cmd/khosra/wire.go`) and a second pipeline beside it would be pure duplication. This counts transforms over the assembled page, which nothing hosts yet — OpenGraph and JSON-LD (queue 15) are the first candidates |
| Routing cases | 11 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run`. The fourth — a series archive — resolves through `Site.Sequence` instead: membership is structural and the sort ascends, so it shares the index but not the Query |
| Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet. *Output formats* are counted separately and are not it: HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage |
| Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The first and only is the derivative pass (ADR-0042), called straight from `cmd` inside `rebuilder`, so it already answers both triggers it will ever need — startup and a settled change (ADR-0048) — and one call needs no runner. Swapping the index or the theme is **not** an Effect: both re-read the site root into memory, writing no artifact and calling nothing outbound (ADR-0055) |
| Extensions | 3 | **3** — due, and the answer is still no | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. It is 3 again with `scaffold`, and the note below the table says why a registry still buys nothing |
| Interface implementations | — | **2** | The interface itself |
| Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — |
**The Extensions counter is due, and a registry would still buy nothing.** The three features attach in two
unrelated ways: `shortcodes` is a goldmark extender listed in `extenders()`, while `check` and `scaffold` are
functions `cmd` calls for a subcommand. A registry would have to abstract over "extends Markdown", "validates
content" and "writes a file", which share nothing but the word *feature* — one member and no leverage. What the
counter is really detecting is that two of the three are commands, and commands compose fine as a switch in
`main`. Build the registry when a feature wants a **route** (the seam ADR-0042 named) or when two features need
to agree on an order.
Allowlist, all four imported: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
`gopkg.in/yaml.v3` (frontmatter, ADR-0020), `golang.org/x/image` (resampling and WebP, ADR-0040).
## Latent items — known, deliberately unfixed
Do not fix these mid-feature. They become features when the human says so. An arc does not close
with an untriaged item: at each arc boundary every row is fixed, scheduled into an arc, or accepted
with a stated reason. A list nothing drains is a graveyard of known defects.
| Item | Why it waits | Trigger to fix |
|---|---|---|
| No mechanical check that the counters are *correct* | Accepted at the Arc 1 boundary: the coupling gate makes forgetting them impossible, which is the real failure mode, and checking the values needs code to count | The first page-level transform (queue 15), now that the transform counter means something narrower |
| No mechanical gate on the untrusted boundary (ADR-0003) | Scheduled to Arc 3: nothing untrusted is read yet. Half of it is now mechanical — `verify.sh` rejects `WithUnsafe`, so authored Markdown cannot become HTML — but there is still no check that a *future* untrusted source stays out of shortcode and template evaluation | The comment path — a test that untrusted input reaches no shortcode or template evaluation |
| `date` stays in `Extra` after being lifted onto `Bundle.Date`, unlike `title`, `aliases`, `tags` and `order`, which are deleted | Spotted while adding `order`; the theme contract says `Extra` holds what the parser does not name, so one of the two is wrong. Harmless today — a template reading `.Extra.date` gets the raw YAML value | Whatever next reads `Extra` generically: feeds (queue 14) or `check` (17) |
| A gallery's images carry no `alt` | `width`/`height` now come from the original (ADR-0042), so only alt text is missing, and a filename does not supply one. An empty `alt` is honest for a picture the page has already introduced | Captions per gallery entry — a sidecar or a frontmatter list — if the reference theme ever needs them |
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
| The root listing's `<title>` repeats itself — "A Khosra Demo · A Khosra Demo" | Spotted 2026-08-01 by looking at the served page, not by any test: `base.html` joins page title and site title unconditionally, and at the root those are the same string. Cosmetic, and the fix is one `if` in a template — theme layer, not engine | The first time the reference theme is worked on (Phase G4 touches it), or sooner if a feed or OpenGraph title inherits the same doubling |
| `Renderer.Tag` is the one render method that never calls `fresh()`, so under `-dev on` a tag listing shows an edited template only at the next poll, not on the next request | Spotted 2026-08-01 while making the theme swappable (ADR-0055). Harmless in a serving build, where the rebuild swaps the theme for every method alike, and bounded by the poll interval even in `-dev`. The fix is three lines, but nothing tests `Reload()` today, so it would be three untested lines | The first test of `-dev`'s per-request reparse, which is what should have caught this |
| ADR-0022 says the poll interval is set by one flag, `-poll`, "zero to disable for immutable deployments". There is no such flag: `watch.Interval` is a package variable only tests assign | Spotted 2026-08-01 reading ADR-0022 against `watch.go` for G1. The decision was recorded before the feature was built and the flag was never part of what shipped (ADR-0048 does not mention it) | An immutable deployment that wants polling off, which is the only case the flag was for — then it is a flag plus a superseding ADR, not a rediscovery |
| The picture memo is never evicted — one entry per picture on the site, for the life of the process | Correct for one author's site, and the alternative is an eviction policy nothing needs. It is keyed on size and modification time, so it cannot go stale, only grow | A site root large enough that memory matters, or a long-running process where pictures churn |
## Open questions
**Subagents for fan-out reads — policy, not judgment.** Delegating read-heavy sweeps (`/audit`,
`/refresh-docs`, `/invariants`, rename sweeps) keeps thousands of lines of file dumps out of the main
window and returns only the verdict. Deferred 2026-08-01; the case for and against is written out in
`ideas/token-conservation.md`, including where it is clearly right (locating things) and clearly wrong
(deciding things). Nothing blocks on it.
Nothing else blocks Arc 1 or the first deploy.
Every feature has been audited against the layer test (ADR-0046). One was wrong and was deleted (widow
prevention, ADR-0045); one quietly decided presentation and now offers both shapes (tag listing grouping). The
verdict table is in the ADR, and remaining queue entries carry a layer note before they are built.
Every ADR in `decisions.md` is accepted; none is open or proposed.
## Build queue
Working plan lives in `.scratch/build-queue.md`, which is deliberately not committed — `git log` is the
record of what actually landed. If that file is absent, read the log and rebuild the plan from it.
## Arc retro log
One line per completed arc: what it cost, what it taught, what it made unnecessary.
- **Arc 1 — the spine.** 619 core lines, 3 dependencies, 4 queue entries. Taught: `os.Root` makes the
path guard a property of the type, so the latent item that shipped with the harness died instead of
being implemented; and running the gates against real code found six defects *in the gates* — an
allowlist parser that rejected its own documented format, two advisories that fired only on correct
code, a coupling gate that demanded explanations for permission edits, an untidy `go.mod` hiding a
direct dependency, and a nesting check off by one level. Made unnecessary: a hand-rolled traversal
cleaner, and a second routing branch — the resolver arrived by counter at exactly the right moment.