Item 2 of the order of work. An author writes `../day-01.en.md` — the path an editor preview resolves — and the engine emits `/posts/day-01/`. The larger effect is durability. Resolution goes through key → route, and a slug moves the route while never moving the key (ADR-0035), so a relative link survives a rename that a hand-written /posts/a-better-name/ does not. The demo proves it: `../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote the filename and got the slugged address. This is the engine altering authored markup, which ADR-0045 polices, so the test that matters is what it declines to touch. Fourteen cases must survive exactly as written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative path, a bare fragment, a bare query, a name climbing out of content/, and every relative path whose extension is not .md. That last line is what keeps cover.jpg working — a bundle's assets already resolve because its URL mirrors its directory, so rewriting them would break what works. Nine rewrite cases sit beside them. Key derivation goes through content.KeyFromName, exported for this: the language-suffix rule is the part that would drift between two copies, so it lives in one place while the five lines of joining are duplicated in check. khosra check now reports a relative .md link resolving to no bundle, as fatal — verified by mistyping one and watching exit 1. Only the .md form: an extensionless relative path may be an asset, and a checker that calls a working link broken gets ignored wholesale. Two debts this change paid rather than deferred. render.go reached the file-length advisory, so theme parsing moved to theme.go — 414 and 105 lines, one topic each, since parsing runs per rebuild and rendering runs per request. Not a _helpers.go shard. And the demo's coverage test bound its renderer with a *copy* of the rebuilder's wiring, so it missed this feature entirely while the real binary served it correctly. Navigation had already drifted the same way. Both now call one bind(), which is exactly what ADR-0072 was written about — and the test failing is the only reason the copy was found. Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is in ext where it belongs. 18 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
25 KiB
State
Currency is compared, not declared. verify.sh warns unless this file's last commit is at least as new
as the last commit touching .go, so it ships inside the change it describes — no sha to write, and no
trailing commit to write one (ADR-0057).
If this file disagrees with the code, the code is right and this file is a bug.
Inventory
The engine- and agent-facing docs live in harness/, not docs/ (ADR-0082). docs/ is reserved for
documentation aimed at whoever uses khosra and does not exist yet — an end-user documentation site is
planned, with its home and whether its claims are gated both undecided.
Line counts are not here: harness/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/theme.go |
parsing the theme: the embedded reference templates, a site's overlay of them, and the stylesheet. Split from render.go at the length advisory — parsing a theme runs once per rebuild, rendering with one runs per request |
internal/render/render.go |
goldmark with the typographer, the render methods. A Renderer never changes after New: a rebuild builds a new one and it is swapped with the index as a single web.Snapshot, so no page is assembled from two of them (ADR-0055, ADR-0056, ADR-0077). Heading ids are a parser option set here, declared or derived (ADR-0058, ADR-0066), and this is the one renderer that enables raw HTML (ADR-0060). Compose is the seam a merging bundle's splice arrives through |
internal/render/view.go |
the theme contract in Go, and now actually all of it: Page (with Assets, Styles and Scripts — the assets a page's own calls and use: asked for, ADR-0079), List, Sequence, Extras, Item, Partial, Fragment (with Body, Headings and Lang — ADR-0064, ADR-0065, ADR-0067), Heading, 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), including the words a shortcode fragment supplies when the author gives none (ADR-0067) |
internal/render/templates/ |
reference theme, complete (six icon names map to Unicode, no assets — ADR-0063): base.html (shell, navigation, language links, feed and OpenGraph), page.html (bundle, sequence, tags, extras), list.html, extras.html, shortcodes/ — seven fragment files rather than one, and a site may use either form (ADR-0071) — with the sizes its own layout implies (ADR-0068), 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, bounded and least-recently-used in memo.go (ADR-0042, ADR-0044, ADR-0073). FootnotePrefix namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus icons.go: :name: inline, rendered by the theme's one icon fragment (ADR-0063), containers.go: :::name{…} … ::: wrapping a rendered body (ADR-0064), toc.go: the document's headings for a ::toc call, to a depth the call may set (ADR-0065, ADR-0066), and code.go: chroma highlighting, a fence's options, and snippets read from a file (ADR-0075). Merge splices includes before the parse for a bundle that asks for it (ADR-0066) |
internal/ext/notation/ |
the inline marks CommonMark lacks: ~sub~, ^sup^, ==mark==, and ~~strike~~, which it owns so a single tilde can mean subscript (ADR-0061). abbr.go adds *[TERM]: definitions and the pass that expands them (ADR-0062) |
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/ on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). site.yaml is deliberately not fingerprinted (ADR-0055) |
internal/ext/links/ |
eighth feature: relative Markdown links become the URLs they are served at, resolved through key → route so a slug rename cannot break one (ADR-0087). Rewrites only destinations naming a bundle; an absolute URL, a fragment, a mailto: and any non-.md relative path are left exactly as written |
internal/ext/discover/ |
seventh feature: /robots.txt and /sitemap.xml, absolute and only with a declared base (ADR-0039). Left core in ADR-0085 — exact paths somebody else's software asks for by name, owning no core concept |
internal/ext/passthrough/ |
fifth feature, and the first to own a route (ADR-0081): files in root/ served at the exact path they occupy, .tmpl rendered as text with the site's own settings, headers declared per path in root/_headers.yaml, underscore-prefixed names not addressable |
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, and calls left in the retired shortcode form (ADR-0059) |
cmd/khosra/wire.go |
the only list of enabled features (extensions.md) — extenders() for the ones goldmark composes and routes() for the ones owning a URL path (ADR-0081), the theme function that builds the renderer this build ships (ADR-0072), and the Markdown dialect with it — tables, footnotes, definition lists, strikethrough, task lists (ADR-0058, ADR-0078) |
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/logging.go |
the access log: one Info line per request with method, path, status, bytes and duration, wrapped around the finished handler by cmd so tests stay quiet. Duration comes from content.Now, since the clock lives in one file (ADR-0086) |
internal/web/web.go |
handler: Snapshot pairs the index with the theme that was current with it (ADR-0077); serve dispatches by kind, serveBundle answers the commonest one; listings, /static/, /derived/, degrade on failure. Mounts the exact paths features own, skipping any the engine already answers — a duplicate pattern would panic (ADR-0081). Since ADR-0085 it reserves only /: /robots.txt and /sitemap.xml are a feature's, so a clash between features is wire.go's to settle |
cmd/khosra/main.go |
flags (including -poll, zero to stop watching, and -log-level/-log-format which configure the one logger before anything can use it — ADR-0086), wiring, startup, the derivative pass, and the one atomic swap a change goes through, theme and index together in rebuilder (ADR-0077). 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 — shortcodes has one each for icons, containers and the contents list; 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 — that last one in cmd/khosra, beside the wiring it proves (ADR-0072) |
root/ in the site root is served at the URL root: root/pubkey answers /pubkey, a .tmpl suffix renders
the file as text with the site's own settings and is dropped from the address, and root/_headers.yaml
declares headers per exact path (ADR-0081). Paths the engine already answers are skipped and logged.
Every request produces one log line, and levels mean one thing each: error is something the engine could
not do, warn is something it worked around while still serving — all of ADR-0029's category — info is
lifecycle and requests, debug is off by default (ADR-0086, conventions.md). -log-level and
-log-format (text or json) configure it.
A page carries only the CSS and JS its own shortcode calls or its use: list asked for, rendered once each from the theme's assets:<name> fragments, plus its own styles/scripts files — bundle-relative, anything climbing out dropped (ADR-0079, ADR-0080). The reference theme emits the stylesheets and no <script> at all, which verify.sh enforces; examples/demo-site redefines the head block to add the tag, so the one JavaScript exception is demonstrated by a site rather than built into the binary.
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.
Markdown is CommonMark plus tables, footnotes, definition lists, heading ids, task lists and the inline marks
(~sub~, ^sup^, ==mark==, ~~strike~~, *[TERM]:), and nothing else (ADR-0058, ADR-0061, ADR-0062, ADR-0078); HTML an author writes renders, because the site root is trusted (ADR-0060). 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 — -poll, zero to stop — and swaps both the index and the parsed theme atomically, so a content or
template edit appears without a restart and every page updates together (ADR-0022, ADR-0055, ADR-0056);
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
polls four times a second (ADR-0056).
-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, draft, styles,
scripts, use. include: embed is read from Extra by the renderer; merging is the default (ADR-0066, ADR-0076). Every other key in
content-model.md's table — including 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 — 31 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. Features are spread across the bundles where a real site would
put them rather than piled on one page: text marks and the margin note on writing/typography, icons and
panels on pages/colophon, the include, table, glossary and contents list on writing/notes-on-water.
A Dockerfile ships the binary alone: the site root is a mounted volume, never copied in (ADR-0010, ADR-0011).
Dependencies: five direct, all allowlisted — goldmark, golang.org/x/text, golang.org/x/image,
gopkg.in/yaml.v3, chroma (ADR-0075); six modules of nine with regexp2 indirect.
Counters — the earn-it authority
Never anticipate a threshold. Increment when the code lands, then check whether the extraction is due this change.
Every row states what does not count. Four of these counters had to be re-scoped on first contact —
transforms, views, effects, extensions — because each measured a symptom rather than the mechanism, and the
re-scoping was always a sentence about what had been wrongly included. Writing that sentence up front is the
cheapest way to find out whether a counter means anything, so the column is required and verify.sh fails on
a row that leaves it empty (ADR-0070).
| Counter | Now | Extraction due at | What it buys | Does not count |
|---|---|---|---|---|
| Render transforms — page-level only | 0 | 3 | Stage pipeline (ordered func(ctx,*Page) error). This counts transforms over the assembled page, which nothing hosts yet — OpenGraph and JSON-LD (queue 15) are the first candidates |
Parse-phase work. goldmark's extender list is already an ordered pipeline for it, so typography, shortcodes and notation compose there (cmd/khosra/wire.go); a second pipeline beside it would be pure duplication |
| Routing cases | 11 | 2 — done | Resolver at internal/web/resolve.go: bundle, language prefix, pagination, tag, section-narrowed tag |
An exact path a crawler asks for by name. /robots.txt and /sitemap.xml are mux entries because no bundle can collide with them, so they never reach the resolver |
| Collection pages | 4 | 1 — done | Query primitive: content.Query{Section, Tag, Lang} + Site.Run |
A series archive. Membership is structural and the sort ascends, so it resolves through Site.Sequence — sharing 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. 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 only one is the derivative pass (ADR-0042), called from cmd inside rebuilder, so it already answers both triggers it will ever need — startup and a settled change (ADR-0048) |
An in-memory swap. Replacing the index or the theme re-reads the site root into memory, writing no artifact and calling nothing outbound (ADR-0055) |
| Extensions | 8 | 3 — passed, and the registry is now partly built | 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. The note below says which field was built and why the rest were not |
An upstream extension enabled in the list. Table, Footnote and DefinitionList are goldmark's, so they are dialect rather than features of this engine (ADR-0058) — only a package under internal/ext/ counts |
| Interface implementations | — | 2 | The interface itself | An interface this repo did not declare. Satisfying fs.FS, http.Handler or goldmark.Extender is using somebody else's abstraction, which is the opposite of inventing one |
| Non-stdlib dependencies | 4 direct | budget in scripts/budgets.env |
— | The standard library, and a dependency's own test-only modules — go list -m all shows those, and the gate counts require entries instead (scripts/budgets.env) |
The registry's trigger fired, and exactly one field of it was built (ADR-0081). The note below stood for
five features and was right until passthrough wanted a route — the condition its own last paragraph
named. What exists now is Routes: a feature returns map[string]http.Handler from wire.go, core mounts
it, and nothing else from extensions.md's struct was built, because the other six fields have no
implementor. The reasoning that kept the rest unbuilt is unchanged and still applies:
Counted by hand this change and found wrong. The row read 4 while five packages existed — notation
landed (ADR-0061, ADR-0062) and the count was never incremented, though the prose below already named all
five. Six now, with passthrough. This is the latent item about the counters having no mechanical check,
demonstrating itself; the count is authoritative only because someone just ran ls internal/ext/.
A registry over the other attachment points would still buy nothing. The eight features
attach in four unrelated ways: shortcodes and notation are goldmark extenders listed in extenders(),
check and scaffold are functions cmd calls for a subcommand, watch is a goroutine, and
passthrough and discover hand back maps of URL paths. A registry would
have to abstract over "extends Markdown", "validates content", "writes a file" and "polls a directory", which
share nothing but the word feature — one member and no leverage. Adding notation made this clearer rather
than more urgent: two goldmark extenders compose in goldmark's own extender list, which is already the registry
for that shape, and they need no order relative to each other because one is inline and the other block.
A feature wanting a route was the recorded trigger and it has now fired, which is why Routes exists and
nothing else does. Build the next field when two features genuinely need to agree on an order that no
existing mechanism expresses, or when one of the remaining six gets a second implementor.
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 mechanical in a different way since ADR-0060 — verify.sh requires html.WithUnsafe() in exactly one file, so a second pipeline trusting its input cannot appear unnoticed — 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 |
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 next time base.html is edited for any reason — its old trigger named queue entry G4, which has been dropped |
khosra check cannot report a root/ file shadowing an engine path |
The knowledge lives in internal/ext/passthrough/, and verify.sh fails a feature that imports a sibling — so check would need the reserved paths moved into a shared package for a cosmetic gain. The startup warning fires on every boot and cannot be missed, which is louder than a check finding anyway (ADR-0081) |
A second feature owning routes, at which point reserved paths stop belonging to one feature and want a home of their own |
| Core has ~35 lines of headroom and at least four core-bound items remain before the Arc 2 freeze | Held on the human's call until every pending idea has been placed and prioritised, since how much core room is needed depends on that review. The bound items: the View layer, the Stage pipeline (roadmap.md Arc 2 item 2), a minimal settings cascade the View counter couples to selection, and declared content types. Moving route-owning code out is only partly possible — internal/web/discover.go (~71 lines) fits ADR-0081's seam once it passes the live index, but feed.go and web/extras.go do not, because feeds and extras are resolver cases and the seam mounts exact paths only |
The idea review concluding, which decides between extending the seam to resolver participation and raising CORE_LOC_MAX with an ADR |
| A draft member's absence from a sequence has no test | Proven by hand against the real binary on 2026-08-02 — a three-chapter series with a draft middle lists two members, prev/next closes over the gap, and the draft 404s. It holds by construction: members() resolves through Lookup, which is the one place ADR-0024 hides unpublished bundles, so there is no second code path to drift |
The first change to members(), or to how Lookup decides visibility |
Under include: embed, a fragment's footnotes render where the include sits, so a long one puts an <hr> and a numbered list mid-article |
Spotted 2026-08-01 by looking at the served page, not by any test. It is ADR-0038's documented consequence, and the ids are correctly namespaced (ADR-0058); only the placement reads badly. Merging is the default since ADR-0076, so this is now something an author opts into by asking for containment | Nothing: it is the documented cost of the model you chose |
Open questions
Answered 2026-08-02: no subagents. Read-heavy sweeps stay in the main window. The case for and against
is in ideas/token-conservation.md; the decision is that a verdict arriving without the reading behind it
cannot be audited, which is the thing this harness is built to make possible. Nothing blocks on it.
Nothing 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/continue.md, which is deliberately not committed — git log is the
record of what actually landed, and the handoff is read against the log rather than instead of it
(harness/README.md). 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.Rootmakes 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 untidygo.modhiding 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.