8 Commits
Author SHA1 Message Date
bdeshiandClaude Opus 5 30c26bd1ac resolve relative links against the disk, serve them as addresses
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>
2026-08-03 16:44:48 +06:00
bdeshiandClaude Opus 5 c3125e7ad2 log every request, and make error mean something again
Item 1 of the order of work, and two gaps rather than one polish item.

There was no access log. Nothing recorded that a request happened, and the
Dockerfile ships the binary alone with the site root mounted (ADR-0010), so a bare
deployment produced none at all — which mattered because offline log analysis is
this project's answer to analytics: no counter on the read path, no third-party
script. web.Logged now writes one Info line per request with method, path, status,
bytes and duration.

And slog was never configured. conventions.md makes importing `log` instead of
log/slog a hard failure while nothing ever set a level, a handler or a format.
Two flags now do, rejected at startup if unusable, because a logger quietly less
verbose than asked for hides exactly the lines somebody changed the flag to see.
JSON is the half that matters: it is what makes a log parseable.

The re-levelling was the larger half. Counts were 38 error, 7 warn, 4 info, 0
debug; they are now 7, 40, 6, 0. Almost every one of those errors was ADR-0029's
"logged, not fatal" category — a misspelled directive, an asset path climbing out
of its bundle, an unreadable picture — where the engine coped and the reader still
got a good page. Error used as "somebody should see this" means an operator cannot
tell a broken build from a typo. The seven that remain are the five requests that
answer 500 and the two inside fatal.

A successful rebuild now says so. It swapped silently before, so an operator could
see a failed rebuild and never a successful one, which leaves the failures with
nothing to be read against.

No wrapper package: log/slog is the module, and a layer over it would be an
abstraction with one caller. Duration comes from content.Now, since the clock is
confined to one file and verify.sh enforces it by filename. The recorder does not
forward Flusher or ReaderFrom — nothing here streams, so the cost is one io.Copy
fast path on static files, and implementing interfaces no caller needs is the
speculation rule 6 forbids.

Two things this change owed and paid. content.go's comment still said content
problems were "logged at error level", which the re-levelling made false. And the
new flags pushed runServe past the function-length advisory, so the site-opening
block became opened() — a warning that fires on correct code gets acted on, not
tolerated, and that is the whole reason the advisory exists.

Evidence, demo site, JSON: rebuilt bundles=31, then serving, then one request line
each for a 200, a 404 and robots.txt with real byte counts and durations. At
-log-level warn, request lines disappear. An invalid level exits with the reason.

12 files. Core 2913 → 2959 of 3400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:35:29 +06:00
bdeshiandClaude Opus 5 69a7eb4733 move robots and sitemap out of core, and raise the ceiling on purpose
Item 0 of the roadmap's order of work, and it blocked everything after it: core
sat at 2965 of 3000 while the review scheduled four core-bound items, the first
of which — logging — wanted the whole remainder.

/robots.txt and /sitemap.xml are exact paths somebody else's software asks for by
name. They own no core concept and pass every test the architecture applies to a
feature; they lived in internal/web only because a feature could not own a route
until ADR-0081. internal/ext/discover/ now holds them. Core 2965 → 2913.

The seam gained one parameter to make it possible: a func() *content.Site, since a
sitemap must list what is served now and the index is swapped whole on every
rebuild (ADR-0077). A captured pointer would have frozen the site at startup —
which is the kind of bug that only shows up after a rebuild, in production.

The ceiling rises to 3400 as well as the move, because the move alone could not buy
the room. feed.go and web/extras.go cannot follow discover out: a feed lives at
/{section}/feed.xml and extras under a bundle's own URL, so both are resolver cases
while the seam mounts exact paths only. Raising by the minimum that unblocks one
item produces a ceiling nobody believes, so 3400 fits the View cluster with
headroom. HARNESS.md asks that a raise be read as evidence something belongs in
ext before evidence the number was small; both readings were true, so both actions
were taken.

web no longer reserves those two paths, so a clash between features is wire.go's:
it merges route maps in declaration order, keeps the earlier claim, logs the loser.
Verified — a site shipping root/robots.txt starts, serves the engine's robots.txt,
and logs the passthrough claim, where an unguarded mux.Handle would have panicked.

Evidence: robots.txt and sitemap.xml are byte-identical before and after the move
against the demo site (67 and 2701 bytes, cmp clean), and the sitemap keeps its
application/xml type.

One real cost, recorded in both places rather than hidden. internal/web's
visibility test asserted that a listing, a feed *and* a sitemap all hide
unpublished bundles — one property, one test, because all three share a Query. The
sitemap half moved to the feature instead of a web test importing ext, which would
invert the one-way layering the architecture gate enforces. That property is now
asserted twice, once per package owning a surface.

Three gates caught real mistakes on the way: the staged-tree check found a partial
stage where git rm had staged a deletion while the caller edits were unstaged, the
coupling gates demanded state.md and HARNESS.md, and the nesting advisory rejected
a closure that put the merge loop one level too deep — fixed by making it a plain
function rather than tolerated.

Extensions 6 → 7. Routing cases unmoved: exact paths are mux entries, never
resolver cases, which is what that counter's exclusion column already said.

13 files. Core 2913/3400, ext 2495/3500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:26:40 +06:00
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
bdeshiandClaude Opus 5 c6c3f0aa3f record the core-budget squeeze and why only part of it can move out
Held on the human's call: the code move waits for the idea review, since how much
core room is needed depends on what that review places in core.

Worth recording rather than rediscovering. Core sits at ~35 lines of headroom with
at least four core-bound items before the Arc 2 freeze — the View layer, the Stage
pipeline (roadmap.md Arc 2 item 2, which I had been treating as optional), a
minimal settings cascade the Views counter couples to view selection, and declared
content types.

The relief I recommended was overstated and is corrected here. discover.go (~71
lines) can leave core once ADR-0081's seam passes the live index. feed.go and
web/extras.go cannot: a feed lives at /{section}/feed.xml and extras under a
bundle's own URL, so both are resolver cases while the seam mounts exact paths
only — the limit that ADR's own "revisit if" line predicted and I did not check
before recommending ~196 lines of relief.

So the open choice is extending the seam to resolver participation versus raising
CORE_LOC_MAX with an ADR, and it is the review's to settle.

2 files. No rule or threshold moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:34:06 +06:00
bdeshiandClaude Opus 5 949ce34224 give pending work four owners instead of six scattered lists
Ideas were spread across six places with real duplication and one list that was
about to be lost. Consolidated by merging the duplication, not the categories.

The 130-item feature-list triage lived in .scratch/, which is gitignored — 130
verified verdicts a scratchpad cleanup would have deleted. It is now
ideas/feature-list-triage.md with the header the ideas gates require, indexed,
and out of agent context by default like everything there. exploration.md's
Verdicts table was empty and designed for exactly this; it now carries one row per
body of work with a pointer, rather than 130 rows nobody could skim.

Deferred triggers had no single owner: continue.md restated them and drifted,
still listing the extension registry as pending after ADR-0081 built it.
deferred-decisions.md now holds the only trigger table, covering the two it
already described plus math, corrections, write routes and the registry's
remaining fields. continue.md links to it. Two rows record couplings worth not
rediscovering: the View layer may pull a minimal settings cascade in with it,
because the Views counter says selection resolves *through* the cascade; and
ADR-0081's seam mounts GET only, so Arc 3's first write endpoint extends it.

Corrections per post is dropped in its cheap form and parked in its real one. A
hand-maintained frontmatter list was rejected for an authoring reason rather than
a technical one — a half-remembered list of corrections misleads where none would
not, because two entries imply those were the only two. Deriving it from
repository history is the shape worth waiting for, and the file records what must
be settled first: whether the engine may read local git history at all, that it
needs a dependency or a subprocess, and that not every commit is a correction.

Not merged, on purpose: latent items stay in state.md because they describe
shipped code and are gated against .go commits; arcs stay in roadmap.md because
they are sequencing decisions. One file would have flattened four levels of
authority and made pending work cheap to load, which is the opposite of why
ideas/ sits outside harness/ at all. harness/README.md now says which list owns
what, in both the topic table and the single-source table.

7 files. No rule or threshold moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:31:24 +06:00
bdeshiandClaude Opus 5 078ec8eedf check bare paths in scripts/, and record why one file has no .sh
Two answers to "add .sh to sh files". Exactly one file lacks the suffix:
scripts/hooks/pre-commit. git locates a hook by exact filename, so renaming it
would leave a gate that looks present and does nothing — verified in a throwaway
repository, where hooks/pre-commit printed and hooks/pre-commit.sh was ignored
while the commit succeeded regardless. The name belongs to git, so the file keeps
it and conventions.md now states the exception rather than leaving it as an
inconsistency someone will try to tidy again.

The extension was never the defect anyway. The dangling-path gate had only ever
matched backticked citations, so `git add docs/surface.md` in the hook — an
argument, not a citation — survived the docs/ rename and surfaced as a fatal
inside a commit that otherwise succeeded. That gate now also reads paths in
scripts/ unquoted, which is the check that would have caught it.

Proven both ways: restoring the exact bug fails the gate with "reference to a
path that does not exist: docs/surface.md", and a working tree passes. The first
attempt did not catch it — docs had been dropped from the alternation because the
directory no longer exists, which is precisely the class of stale reference worth
failing on, so docs is in the bare pattern on purpose.

Backslashes are stripped before comparing, so a path written as a regex —
\.claude/settings\.json — is checked as the file it means rather than flagged as
the file it is not. Prose is still checked only inside backticks: a sentence
saying "under harness/" makes a point no filesystem can verify, while a script
naming a path either has it right or is broken.

One gate label changed with it. `pass "harness/HARNESS.md coupling"` read fine
while the directory was docs/ and now parses as a filename, which the new check
duly flagged; it is "harness and HARNESS.md move together". No doc cited the old
label.

4 files. No rule or threshold moved — one gate widened, one convention written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:49:08 +06:00
bdeshiandClaude Opus 5 ec6e9838f0 rename docs/ to harness/, and reserve docs/ for the reader
docs/content-model.md opens with "Engine specification". It is also where the
rule lives that a leading underscore makes a file unaddressable — and the human
who owns this site did not know that rule, because nothing in this repository is
addressed to an author. Twelve documents named docs/ while being exclusively
about building the parser is a signpost pointing at the wrong room.

Naming the directory for its audience makes the gap visible instead of hiding it.
docs/ is now reserved and deliberately absent: an empty docs/ is an honest
statement that end-user documentation does not exist, where docs/ full of parser
specs was a claim that it did.

HARNESS.md stays at the root. Root holds the three entry points — README.md for a
human, CLAUDE.md for an agent, HARNESS.md for whoever maintains the machine — and
harness/README.md is the map of the directory, so moving the guide inside would
have collided with it for nothing.

Mechanical and wide: 100 path references across 24 files. Every verify.sh gate
that names a doc by path, the directory lists the dangling-path and ADR-number
gates scan, surface.sh's output target, the Makefile, CLAUDE.md's read order, the
skill, four commands, and two Go package comments. A first pass with a shell loop
silently edited only four files and the rest still said docs/; the fix was to
write the file list out and check the remaining count was zero rather than trust
the loop's exit status.

No rule, threshold, gate or obligation moved — this is a rename, and the gates
demonstrated it twice: they stayed green on the new paths, and the ADR-number gate
caught ADR-0082 before the entry existed.

Deferred, both on the human's call: the end-user documentation site itself, which
wants its own decision about where it lives and whether its claims are gated; and
moving examples/ under docs/, since demo-site is a live site root that verify.sh,
the coverage test and make demo all point at, and moving it would couple a rename
to a design nobody has made.

31 files, +146/-106.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:11:06 +06:00