Files
khosra/harness/architecture.md
T
bdeshiandClaude Opus 5 b313b729d9 review 130 feature ideas, and delete the lists that held them
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>
2026-08-03 16:14:52 +06:00

10 KiB

Architecture

Six primitives. Every feature is a composition of them — never a special case, never a plugin slot bolted to the side. This file is the target the code follows.

STATUS says what is legal to build now — a permission, not a report. What actually exists is state.md's job alone, so a STATUS line never needs editing as code lands.

request ──► resolver ──► Bundle ──► [Stage…] ──► View ──► bytes
                │            ▲                            │
              Query ─────────┘                          cache
                                       Interaction ──► (own path, off the content graph)

change / clock / command ──► Effect ──► derived artifacts, outbound calls

Bundle — the atom

A content folder (or single file): metadata + one or more language bodies + local assets + attached interactions. Identity is the bundle; language is a variant of it. STATUS: buildable. Arc 1's spine: single-language, single-file bundles first, then language fallback.

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 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.

Query — filter/sort bundles into a list

Returns a list plus a signature doubling as a cache key. All grouping is a Query: sections, tags, series, latest, related, pagination, feeds. STATUS: live. Earned at the first collection page. content.Query{Section, Lang} with Site.Run; it carries no cache signature yet, because nothing caches and a signature with no consumer is speculation.

View — bundle-or-query → output

Overridable per bundle. All presentation. Emits HTML, gemtext, PDF, a program, JSON. STATUS: deferred until Arc 2. Until then, templates chosen by a small explicit lookup. Its data contract is frozen from the moment a theme exists, not at Arc 2 — see theme-contract.md (ADR-0023).

Interaction — external input attached to a bundle

Comments, webmentions, likes, via adapters. Off the content dependency graph: own fragment cache, one-page blast radius, hydrated client-side or spliced late. STATUS: not buildable yet. Arc 3. Nothing may assume it exists.

Effect — work outside the request path

Derived artifacts and outbound calls: image derivatives, search index, sitemap and feed files, webmention sending, POSSE, outbound-link archiving, EPUB builds, future-dated publication.

Three triggers, and one Effect may accept more than one: on change (a bundle changed), on schedule (a clock), on demand (a CLI subcommand, or an Interaction needing outbound work such as verifying a received webmention). Every Effect never writes into the site root — that is the author's data (ADR-0011) — logs and retries on failure rather than dying, and leaves the engine correct if it has never run at all: a missing derivative serves the original, a missing index disables search (invariant 8).

Two kinds, and only one is re-runnable from scratch (ADR-0084). An artifact-producing Effect writes bytes — derivatives, a search index, an EPUB — and is idempotent by regeneration; losing its output costs time. An outbound Effect makes a call somebody else observes — a notification, a webmention, an archive submission — and cannot be re-run from scratch, because a sent message cannot be recalled. It is idempotent only against a delivery ledger of what already succeeded, and its contract is at-least-once with a stable dedupe key rather than exactly-once, which no network boundary allows.

Ledgers live in a state directory (-state), never in the disposable derivative cache (-cache): deleting the cache must remain safe, and conflating the two would let a rm -rf on regenerable files arm a storm of duplicate calls. Where losing a ledger is survivable, the Effect seeds silently on first run, so loss costs missed calls rather than duplicates.

Not an Effect: anything computable at render time from the page plus the clock. An "this article is old" banner is a Stage reading date — right on every request, no job, no staleness, nothing written. Inbound reactions are Interactions. If an Effect would need to mutate content to be visible, it is a Stage wearing a disguise.

STATUS: partly live. Earned at the first derived artifact and inhabited by one — the derivative pass (ADR-0042), called from cmd inside rebuilder. The runner with real trigger wiring is not built: check the Effects counter in state.md, which stands at 1 and extracts at 2. No outbound Effect exists yet, so the ledger and state directory ADR-0084 describes are a recorded shape rather than code. Scheduling is an in-process ticker inside the single binary — no cron container and no queue until ADR-0010's second-service test is actually met.

Routing = URL → (Bundle, View), via a resolver. STATUS: live. The resolver was earned at the second routing case — the default locale at the root, every other language under a prefix — and now owns URL shape so the mux has one entry.


Layers — build at the outermost one that can do the job

A primitive says what a feature is. This says where it belongs, and it is the question that catches a feature nothing else does (ADR-0045).

Layer Owns Test
Content bytes an author wrote Would a reader's own words change? Then only the author may touch it
Engine facts only the engine can produce — which bundles exist, an image's size, a derivative's name, a permalink Could anything downstream work this out for itself? If yes, it does not belong here
Theme markup and which fields go where Is the answer "how it looks"? Then it is a template, not Go
Browser line breaking, spacing, colour, motion — CSS, and only then JS Does the browser already know something the engine cannot, like the line box? Then leave it there

Two rules fall out. The engine never edits authored text to change how it looks: it may transform text (-- into an en dash is a transformation no stylesheet can express) but never lay it out. And a feature that needs no engine fact is not an engine feature — it is a stylesheet, a template, or nothing.

The failure this exists to prevent: widow prevention was built as a Markdown transform that inserted a non-breaking space into an author's prose. It worked, it had tests, and it was wrong at the layer — text-wrap: pretty does it better with no bytes in the content, and the engine could never see the line box it was guessing about.

Every feature built so far has been audited against this, with the verdicts in ADR-0046. Two patterns came out of it worth reusing. Offer the shape, do not choose it: templates cannot group, so the engine supplies a partitioned listing — but it supplies the flat one too, because which to show is markup. And a split feature is normal: search will be an engine-built index and a browser-side query, not one or the other.

Invariants — violating one is a stop condition

  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 extra check beats an elegant unification.
  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 bundle-as-program, deletable without trauma. Mechanical since ADR-0069: verify.sh allows internal/ext/… to be imported from cmd/ and nowhere else, siblings included. Anything wanting a permanent service or a core-model change is a trunk and waits for a human decision (ideas/exploration.md).
  8. The engine serves correctly with every external service off. Redis, an index, object storage — each is a shortcut around work the engine can still do itself, slowly. Anything that cannot degrade that way holds canonical state and does not belong there (ADR-0010).
  9. Core stops growing after Arc 2. Post-freeze, features arrive by composition only; core growth is a bug in the plan. Measured, not asserted: CORE_LOC_MAX covers cmd/ + internal/{content,render,web}, EXT_LOC_MAX covers internal/ext/, reported separately by verify.sh. An invariant nothing measures is decoration.

Re-render and invalidation (target shape, not built)

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.

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

Guest authors. The trust model is a clean binary — site-root content trusted, everything else not — and multi-author would smear it. Comments remain the only untrusted path.