One idea at a time: definition, necessity, priority, layer, recommendation. Every row is now shipped, scheduled, parked with a trigger, or dropped with a reason — so both tracking files are gone rather than left as a parallel backlog. Roughly a third already shipped. A quarter needed only a theme fragment or CSS, including several the list assumed were features: arbitrary `theme.*` frontmatter attributes already reach templates through Extra (verified), native lazy loading already ships and is deliberately absent on lead figures, and in-page timelines are a theme-defined container. roadmap.md gains an "Order of work" — nine bodies of work in sequence, because the sections this review added had accumulated without one. Item 0 is a decision, not work: core sits at ~2965 of 3000 and logging alone wants the remainder, so discover.go moves out *and* the ceiling rises with an ADR. feed.go and web/extras.go cannot move; they are resolver cases, and three parked items now wait on that same seam decision. Two ADRs came out of it. ADR-0083: the dependency rule described a practice it forbade. "Stdlib first, always" and "usually 40 lines of stdlib" would, read literally, have argued against all five modules here — every one is a format or algorithm somebody else specified, and their allowlist comments say so. Surfaced when this agent proposed hand-rolling Reed–Solomon for QR encoding. The test is now whether you can verify it, not whether you can write it, with compute-versus-hold as the counterweight. conventions.md and allowed-deps.txt contradicted it and were realigned. ADR-0084: the Effect primitive said every Effect is "idempotent and re-runnable from scratch" — true of derivatives, false of anything outbound, since a sent message cannot be recalled. Split into artifact-producing and outbound, the latter idempotent only against a delivery ledger, at-least-once with a dedupe key, and ledgers in a -state directory distinct from the disposable cache. Asked for as forward-looking design so webmentions need no redesign; recorded as a shape rather than built, the way extensions.md records the Extension struct. Also fixed a STATUS claiming Effects were "not buildable yet" when the derivative pass has inhabited them since ADR-0042. Findings that were defects rather than ideas: the Atom feed emits no <author>, which RFC 4287 requires. HTML comments in content are published verbatim — verified — so anything commented out is already public, and a check warning is scheduled rather than the engine deleting authored bytes. `Page` has no Date, so a theme cannot mark up dt-published or show an article's own date. A frontmatter naming rule after this agent proposed `archive` alongside the accepted `archived`: no near-homograph keys, and name the benefit rather than the vendor. reference/microformats-and-indieweb.md is new and is the reference asked for — microformats2 properties and IndieWeb rel values mapped to where a theme puts them, with spec URLs, marked scheduled. rel="me" is the highest-value lowest-cost item in that space and needs no endpoint at all. Four things this agent got wrong and the human caught: asserting "the engine never fetches" from a sentence scoped to the content repo, twice; inventing a data-sovereignty argument for an item he had filed as "a cool geeky thing", when the raw Markdown in his git already is the sovereignty; the archive naming; and claiming microformats could be fully implemented today. 15 files, +556/-385. No code changed. Nothing was scheduled that this agent could not name a consumer for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 KiB
Roadmap
Arcs, not sprints. An arc ends when its earn-triggers have fired, not on a date. Nothing from a later arc gets built early "since we are in the file anyway."
Arc 1 — the spine
- HTTP server, one binary, no cache.
- Bundle with language variants and fallback (ADR-0004).
- Permalinks per ADR-0008, including aliases and permanent redirects.
- Language routing per ADR-0009: English at root,
/bn/prefix,/en/…redirects to root. - Path traversal guard before anything faces the internet. The target is a self-hosted container (ADR-0010), so "not yet internet-facing" expires at the first deploy.
Done when: a URL reliably maps to a bundle, in two languages, with URLs you are willing to promise are permanent.
Order of work — decided 2026-08-02
Eight bodies of work came out of the idea review. Arcs say what earns a primitive; this says what happens next, and it exists because the sections below accumulated without a sequence.
- Core budget: do both. Decided 2026-08-02. Move
internal/web/discover.gointo a feature (~71 lines, once ADR-0081's seam passes the live index) and raiseCORE_LOC_MAXwith an ADR stating what moved and why, perHARNESS.md's rule for any ceiling change.feed.goandweb/extras.gocannot move: they are resolver cases, not exact paths, which is a separate seam decision. - Logging. First build, and first for a reason beyond its own value: notify's retries and backoff are unobservable without it, and there is no access log at all today.
- Relative links in content. Authoring QoL, and it makes links survive
slug:renames. - Events → notify. Outbound Effect per ADR-0084.
- Summary and composed metadata, including author identity — one addition wanted by feeds, SEO, the OpenGraph image and IndieWeb identity.
- IndieWeb markup and identity. Mostly theme work; needs
Page.Dateand 3 first. - Arc 2: the View cluster — View layer, declared content types, minimal settings cascade. Closes Arc 2 and freezes the core, which is why 0 matters.
- The Effect runner and outbound-link archiving. Second outbound Effect, earns the extraction.
- Exact-form search. No core growth, no Effect, so it can move earlier if wanted.
Unarced items land in the gaps, in any order.
Logging
No access log exists and slog is never configured — no slog.New, no level, no JSON — despite
conventions.md making the import mandatory. So this is a gap, not polish.
Scope, deliberately small and adding no new package, because log/slog is the module and a wrapper
around it would be an abstraction with one caller:
-log-leveland-log-formatwired once incmd. JSON is the part that makes logs parseable.- One middleware around the mux: method, path, status, duration, bytes. This is the access log, and it is what makes offline log analysis a real answer to analytics rather than an assumed proxy.
- The level convention in
conventions.md, so code written later lands at the right level. - Backfill and re-level existing code. Today: 38 Error, 7 Warn, 4 Info, 0 Debug. Most of those Errors are ADR-0029's "logged, not fatal" cases where the engine coped — a misspelled directive, an asset path climbing out of its bundle — which are warnings. Error meaning "something failed" is what lets an operator triage at all. The same pass adds lines where the engine does something notable and says nothing: rebuild completion, derivative counts, a language fallback serving a different variant.
Relative links in content
An author writes ./post2 or ../notes-on-water/ relative to the file's place on disk, and the engine
emits the served URL — so a link works in an editor preview and on the site. Output stays root-relative,
which is already the rule (ADR-0039).
It also makes links survive renames. A hand-written /posts/a-better-name/ breaks when a slug:
changes; a relative link resolves through key → route, and a slug never moves the key (ADR-0035). Legitimate
because it changes an address between two representations of one target, not an author's words — the
distinction ADR-0045 actually draws.
The traps, all of them from disk layout not matching URL layout:
- A directory bundle sits one level deeper than a single-file one, so relative depth differs between them. Inherent to the bundle model, not to be papered over.
- Resolve to the route, never the key, or a slugged bundle gets the wrong address.
- Prefer the current language and fall back, as
Lookupalready does. - Assets already work by accident —
cover.jpgresolves because the URL shape mirrors the disk shape — so rewrite only destinations that resolve to a bundle and leave absolute, root-relative, fragment-only,mailto:and non-bundle files alone. - Refuse escapes above the site root, the rule
::includeand codefile=already enforce. - Accept
./post2and./post2.en.mdalike: the bare form is friendlier, the full filename is what makes an editor preview resolve.
Parse-phase, so it lives in a feature with no core growth; the one seam it needs is a key → route
resolver callback set at wiring time, exactly like the existing sections and compose seams. Ships with an
eats-prose table and a khosra check rule, because a mistyped relative path is silent today.
Arc 2 — earn the primitives at first real use
In this order, each at its trigger, never before:
- Query — at the first collection page (section index, latest posts).
- Stage pipeline — when the transform counter is due.
- View layer — per-bundle override; the data contract is already frozen by then (ADR-0023).
Done when: the View layer lands. The core stops growing here. Everything after is
composition; core growth past this point means the plan was wrong. Record the core number
verify.sh prints on freeze day in the retro line — from then on it is the figure that must not move.
Arc 3 — the networked layer
- Feeds, JSON-LD, sitemaps, microformats — all Query + Stage + Effect.
- Interaction primitive: comments, with the ADR-0003 trust boundary.
- A thin internal ContentAPI, introduced with the comment write path — not before.
- Access control as exactly one permission check, in one place.
Done when: an external party can react to a page without touching the content graph.
Arc 4 — pure composition
IndieWeb adapters (Micropub first — it buys an existing editor ecosystem for free), then ActivityPub only if still wanted, then delight: backlinks, transclusion, page-as-program, revision-as-craft. Optional admin panel here, as a second client over the ContentAPI.
IndieWeb markup and identity
Scheduled, and almost entirely theme work. Marking pages up as h-entry, h-card and h-feed makes
them parseable by anything in the IndieWeb ecosystem; the rel values make them actionable. Properties,
placement and spec URLs: reference/microformats-and-indieweb.md.
rel="me" is the highest-value, lowest-cost item in the whole space — a few profile URLs in <head>, and an
identity claim becomes checkable rather than asserted. It is what Mastodon's verified link reads and what
IndieAuth identifies you by, and it needs no endpoint and no protocol.
The engine's share is three fields, each scheduled elsewhere and each wanted by other features too:
Page.Date (Unarced — dt-published cannot be expressed without it), .Summary (the summary work), and
author identity in site config (the metadata work). Nothing here needs a new mechanism.
Deliberately not emitted yet: rel="webmention" and rel="micropub" advertise endpoints, so publishing
them before the endpoints exist would advertise a lie. rel="hub" is dropped outright.
Unarced — small features, ordered by appetite
Things that earn no primitive, move no counter and block nothing. They have no arc because they have no
sequence: any of them can land in the gaps of whichever arc is open. A row belongs here only if all three
hold — anything that moves a counter or gates another feature is arc work and goes above, and anything
whose shape is undecided is an idea and goes to ideas/.
Kept short on purpose. A long list here means small work is being used to avoid the arc.
| Item | Why it is small | Note |
|---|---|---|
Page.Date in the theme contract |
one field plus a contract line | A defect rather than a feature: date is lifted out of Extra, so a bundle template cannot read its own publication date and the reference theme shows none. Feeds and listings have it; the article does not |
| Reading time and word count | a walk over text nodes already in hand | Parse-phase, so it moves no transform counter — counting Page.HTML instead would be worse engineering adopted to move a number. Excludes code blocks, and the words-per-minute divisor is per language, in the chrome table beside the month names. Must include content pulled in by ::include: merged fragments come free since they are one tree, but include: embed converts separately and needs its count accumulated through the parse context, the way shortcode asset names already are |
| Inline image alignment | an argument the fragment already receives | Not merely possible but depended on by live content, so the demo must show it and the reference stylesheet must carry the classes. Hand-written <img class=…> works too, since the site root is trusted |
khosra check warns on HTML comments in content |
one rule | Comments are published today — verified: <!-- x --> reaches the served HTML verbatim, block and inline, because raw HTML renders (ADR-0060) and a comment is raw HTML. The docs never said so, so an author assuming comments are private is publishing them. The warning catches it on existing content without the engine deleting authored bytes; [//]: # (…) is the form that renders nothing, and belongs in content-model.md as the recommended private note |
Atom <author> |
one element | A defect: RFC 4287 requires atom:feed to carry an author unless every entry does, and feed.go emits none — so the feed is invalid and readers show no author. Blocked only on author identity existing in site config |
archived: documented |
a table row | Accepted, unlifted, read from Extra by the theme. The author's override of the derived staleness signal |
Events, and the outbound dispatch they share
The first feature to build after the reviews. The engine emits events; something else decides what they mean. n8n already runs and does routing, formatting and per-platform posting, which is precisely why the engine's share of this is small — it ships facts to one endpoint and owns none of the fan-out.
Three events, detected by diffing snapshots: published (became visible — new, undrafted, or a future date
passed), updated (content hash changed while staying visible), unpublished (became invisible). All three
are always sent; the consumer filters, the engine does not. Arc 3 adds webmention.received to the same
channel, which is why this is an event type rather than a publish hook.
Fingerprint by content hash, never mtime. A git clone rewrites mtimes, so an mtime fingerprint reports
every bundle as updated after every deploy. Hashing is the established pattern (ADR-0042).
This is an outbound Effect, so its delivery contract, ledger, state directory and silent-seeding rule
are ADR-0084's, not restated here. Its dedupe key is bundle key + event type + content hash. Backoff of
roughly 1s, 5s, 30s, 2m, 10m before giving up and logging loudly; a goroutine is legitimate because
conventions.md bars them from the render path and an Effect is off it by definition.
The endpoint URL lives in an environment variable, not site.yaml: it carries a token and site.yaml is
in the content git repo. notify: false skips a bundle — stage-toggle #2, which with preserve_links
earns the settings cascade.
What this unlocks, and the extraction to expect. Outbound webmention and archive.org submission are the same shape — for each thing derived from a published bundle, POST somewhere, retry, remember success — and the last two share an input, the set of outbound links in a page. Build notify concretely first; the second consumer is what earns the shared dispatcher, and the link extraction wants to be separable from the start so it can feed both without being generalised early.
The Effect that earns the runner
Outbound-link archiving is the second Effect, and the extraction is the point. architecture.md already
names it as an Effect example and ideas/exploration.md stars it, so this is a written intention that was
never built rather than a new idea. The counter stands at 1, due at 2, so building it forces the Effect
runner and its trigger wiring — change, schedule, demand — out of cmd where the derivative pass currently
sits alone.
Four parked items wait on that runner and none of them on archive.org: corrections derived from repository history, IndexNow pings, a search index, and outbound syndication. That is the reason to sequence this rather than its own value, which is real but modest.
Per page, preserve_links: false switches it off, read by presence so absence keeps the default — and it
is stage-toggle #1 for the settings cascade (ideas/deferred-decisions.md), which was designed to carry
exactly these. One does not earn the cascade; the second toggle does, instead of a third bespoke boolean.
Not noarchive, which is the robots meta value meaning "keep no copy of this page" — nearly the opposite
concern. And not archive, which is a near-homograph of the accepted archived staleness label
(conventions.md).
Exact-form search
Server-rendered, no JavaScript, one page at /search. Exact-form is the honest name: whitespace
tokenising plus the NFC that is already unconditional (ADR-0015) finds the word you typed, in English or
Bengali, if it appears in that form. Bengali morphology — বই / বইয়ের / বইগুলো are one lemma — is not
solved by it and is documented as absent, so nobody mistakes this for the research in ideas/.
No index, and no Effect. The whole corpus is already resident: Site.byKeyLang holds every Bundle
including its Body, swapped atomically on rebuild. So v1 scans what is already in memory and ranks in one
pass; an inverted index is an optimisation, and building it first would be a cache with no measurement
behind it — the reasoning that declined to memoise sequence resolution. If scanning ever feels slow, the
index arrives with a number, and only then is there a question about where it lives: memory, the cache
directory like derivatives, or lazy on first query.
No core growth. /search is an exact path, so it fits ADR-0081's seam as it stands, and the feature
lives entirely under internal/ext/. Nothing here competes with the View cluster for the remaining core
budget, and nothing here waits on the Effect runner.
Ranking is arithmetic, so it is written rather than imported — ADR-0083's test, answered in the stdlib direction for once.
Summary, and composed metadata
summary is promised and unread. content-model.md says "explicit summary; otherwise derived" and
nothing reads it in either form — the seventh documented-but-absent claim this review has found. Atom
entries carry no summary either, so feeds gain from this as much as any metadata tag does, which is the
better argument since feeds are the chosen notification layer.
Resolution order, explicit first:
summaryin frontmatter.- Everything before the first line consisting solely of
===preceded by a blank line. The blank line is load-bearing, not style: a Setext underline must immediately follow its paragraph, soSummary\n===becomes an<h1>whileSummary\n\n===stays a paragraph. Both verified. Without the precondition the engine could not tell the delimiter from an author's genuine Setext heading, and would silently take everything before it. - The first 20–40 words — words rather than characters, which sidesteps runes and grapheme clusters entirely, since a count that split a Bengali matra from its consonant would produce broken text. Prefer ending at a sentence boundary inside that window; cut at the upper bound otherwise.
Metadata is composed, not authored twice. A metadata: parent key in frontmatter carries description
and friends; the final set resolves from page metadata, then taxonomy, then site-wide defaults declared in
site.yaml. That is site → bundle resolution, so with preserve_links it is cascade consumer #2 — the
second concrete use that earns the settings cascade rather than a third bespoke mechanism
(ideas/deferred-decisions.md).
OpenGraph preview image, in order: the page's first large image, then an image named in frontmatter, then a site default. The hard half is already built — every image is inspected for dimensions, memoised and LRU-bounded (ADR-0042, ADR-0044) — so this needs one decision: what "large" means as a threshold.
Author identity is part of this, and four things want it. site.yaml declares base and title and
nothing about a person. An h-card, rel="me", rel="author", the Atom <author> element and a default
OpenGraph image all resolve from the same handful of fields — name, URL, photo, and a list of profile URLs
for rel="me". One addition, four consumers, so it lands with the site-wide metadata block rather than
separately. Markup guidance for all of it: reference/microformats-and-indieweb.md.
Arbitrary theme attributes need nothing. Verified: theme: {hero_image: hero.jpg} in frontmatter is
readable as {{.Extra.theme.hero_image}} today, because unlifted keys land in Extra and nesting survives
(ADR-0002). The work is documenting the convention so themes can rely on it — the same gap that cost this
project's own author the underscore rule.
Cross-cutting — when actually needed
Cache (when requests feel slow) — in-process first; Redis only once a second process must share it, and never as the only copy of anything (ADR-0010). Containerisation: one Dockerfile, one binary, no orchestration until a second service exists, and the site root is a mounted volume rather than baked in (ADR-0022). Updating that volume is the host's job; the engine only notices. Admin/editor deferred — git already works, and Micropub may remove the need entirely.
Admin/editor position
Reframed as a second client over the thin internal ContentAPI. If built: a Markdown editor with live preview, not WYSIWYG. Better: a Micropub endpoint, which buys an ecosystem of existing editor apps and requires no UI. Likely optional forever, given git.
Closing an arc
Earn-triggers fired, /invariants run and clean, latent list triaged (fixed, scheduled, or accepted with
a reason), retro line written in state.md.
Governors
- No abstraction before its second concrete use; the counters in
state.mddecide, not intuition. - Own the data; borrow only infrastructure you can walk away from. Test: if this vanishes, do I lose data or just convenience?
- Every feature is a leaf (
architecture.mdinvariant 7). Trunks wait for a human decision. - Typography split: smart quotes, dashes, ellipses, widow prevention = server-side markup-aware Stage after Markdown parse, skipping code spans. Glyph shaping = the browser's job. Ligatures, text-balance, variable fonts = CSS.
Bengali and context specifics
Unicode slugs, NFC-normalised at every identifier boundary with hand overrides for terms and sections
(ADR-0015). Bengali numerals and relative dates are a cheap Stage (২ ঘণ্টা আগে) applied to chrome —
UI strings and server-pulled dynamic fragments — never to authored body text, which stays as written.
Bengali tokenisation for search is the genuinely novel problem here — solve it well
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.