Commit Graph
6 Commits
Author SHA1 Message Date
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 9349c54d2e let a feature own a route, and serve the site's own files at exact paths
Addresses like /.well-known/security.txt are fixed by somebody else's spec.
None is a bundle, none belongs under /static/, and core had no way to serve one.

This is the trigger the extension registry has been held for, in those words:
ADR-0042 called core's generic derived-file route "the seam to revisit when a
second feature wants output of its own", and state.md's counter note said to
build the registry "when a feature wants a route". Raw passthrough is that
feature, so the seam is built rather than worked around.

Only Routes, not the seven-field Extension struct extensions.md describes. Five
of the other six fields have no implementor and building them would be the
speculation rule 6 forbids. It also kept the change inside the core budget,
which had 65 lines left: the seam is ~30 core lines and the feature's own code
lands in internal/ext/, where there is room. Core is 2965/3000.

A feature returns map[string]http.Handler; core mounts each as an exact pattern
and learns nothing about who owns it. A path core already answers is skipped
with a warning, not overridden — http.ServeMux panics on a duplicate pattern, so
a site shipping root/robots.txt would otherwise take the server down at startup.
Verified: server alive, engine keeps /robots.txt, warning logged, zero panics.

Templating is opt-in by filename. A .tmpl suffix is stripped from the URL and
the file is rendered with text/template — never html/template, which would turn
an ampersand in a contact address into &amp; and a JSON quote into &#34;. Opt-in
by name rather than by sniffing the type, because a key or a signature may
contain anything and a pass choosing for itself which files to rewrite would
eventually eat one. The data is the site's own declarations and nothing more,
which is the point: a security.txt naming its canonical URL should not repeat
what site.yaml already says.

Headers come from root/_headers.yaml, exact paths only. Globs are a second-use
feature and the concrete need is a handful of .well-known names. The manifest is
not served, by the leading-underscore rule that already means "not addressable"
everywhere else — no special case was added for it. A manifest that will not
parse is logged and ignored; the files still serve.

Found while counting: the Extensions row read 4 while five packages existed.
notation landed in ADR-0061/0062 and was never counted, though the prose beside
the number already named all five. Corrected to 6. That is the latent item about
counters having no mechanical check, demonstrating itself.

Not done, and logged as latent: khosra check cannot report a root/ file
shadowing an engine path, because verify.sh fails a feature that imports a
sibling and the reserved paths live in passthrough. The startup warning fires on
every boot, which is louder than a check finding.

Evidence against the demo with a fresh binary: /pubkey answers with its declared
text/plain despite having no extension; /.well-known/security.txt answers with
Canonical filled from site.yaml's base, plus the declared CORS header;
/humans.txt gets a derived type; /_headers.yaml is 404; / and a bundle page are
untouched. Eight unit tests cover layout, absence, interpolation, non-escaping,
declared and derived headers, a broken template, and a broken manifest.

24 files, +514/-46. Extensions 4 (miscounted) → 6. Routing cases unmoved: exact
paths are mux entries, not resolver cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:48:59 +06:00
Claude Opus 5andbdeshi b8c62d38fd swap the index and the theme as one snapshot
Two stores meant a request landing between them saw a new theme with the
previous index: each half coherent, the pair a state that never existed on disk.
Microseconds, which is why it waited — it closes now because the fix removes
machinery instead of adding it.

web.Snapshot holds both behind one atomic.Pointer that a rebuild stores once. A
Renderer never changes after New, so Refresh and the atomic inside the renderer
are gone; an immutable renderer is the simpler object, and the one place a
change is applied is now the one place it is observed.

web.Handler lost its renderer argument and twenty test construction sites moved
with it. Live reload verified on the real binary through the new path: a
template edit appeared and reverted, two rebuilds for two edits.

Also answers the session's open question: no subagents for fan-out reads. A
verdict arriving without the reading behind it cannot be audited, which is the
thing this harness exists to make possible.

Latent list: 6.
2026-08-02 00:28:05 +06:00
Claude Opus 5andbdeshi 9100ce4876 notice content changes and rebuild without a restart
Polling lives in internal/ext/watch, per the human's call to keep core under its
ceiling rather than raise it a second time — which is what ADR-0041 said a second
raise would mean. It is a poller, deletable without trace, and core stayed at
2671/2800.

A settled change calls the same `rebuilder` that startup calls, because a reload
path that differs from the startup path is a reload path that drifts. The index is
an atomic.Pointer swapped whole, so a request reads the site that was current when
it arrived instead of one being rebuilt underneath it — the alternative, mutating in
place, is a data race with every in-flight request.

Names, sizes and modification times, not contents: reading every file to detect a
change costs more than the rebuild it triggers. Editor droppings are excluded,
because saving in vim writes a swap file, a backup and the number 4913, and each
would otherwise look like a change. A change must hold still for a moment first,
since one save is often several operations.

Verified against the running binary: a page 404s, the file appears, and five seconds
later it serves — one "site root changed" in the log. Then three droppings written
at once produced no rebuild at all.

Two warnings fired and were fixed rather than silenced: `runServe` gave up the
rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to
`record`, where three exclusions read as a list instead of as nesting.

The Dockerfile ships the binary alone. The site root arrives as a volume and is
never copied in — it is somebody's content repository with its own history
(ADR-0011), so the image is the same for every site.
2026-07-31 14:00:53 +06:00
Claude Opus 5andbdeshi 282093fb55 generate sized derivatives ahead of the request
A pass over the content at startup writes three widths per picture into a cache
outside the site root, named by the source's content hash and the width (ADR-0042).
Idempotent by construction: a rerun stats and skips, an edited picture takes a new
name, and nothing stale can be served under an old one. Restarting the evidence
site made 0 derivatives the second time, as it should.

Ahead of the request rather than during it, because resampling is felt and there is
no page cache yet to hide it. Outside the site root, because the engine reads that
directory and must not leave generated files in somebody's content git — a lost
cache costs one startup pass and no correctness.

Markup now carries the original as src, the derivatives as srcset closed by the
original at its own width, and width/height from the original — which retires most
of the latent row about the output floor; only a gallery's alt is still empty, and
a filename cannot supply that.

Two things the work itself decided:

`Fragment.Items` became `Fragment.Pictures`, ADR-0037's own revisit trigger. Items
had one consumer, so widening it beat adding a second list beside it.

"A browser can show it" and "we can resample it" are different questions, and
conflating them nearly deleted content: an SVG has no decoder here, so a single
predicate would have dropped SVGs from galleries silently. Undecodable and
unsupported pictures are now rendered as they are, without a size or a srcset.
2026-07-31 03:57:52 +06:00
Claude Opus 5andbdeshi e7287a0370 serve a declared slug as an address, leaving the key alone
The human chose the second option: a route sits beside the key rather than
replacing it. So `slug` renames what a bundle is served at, in every language, and
identity stays derived from the path — which is exactly what keeps ADR-0033 intact,
since series membership is the directory. A series landing page can now be renamed
without orphaning its chapters, and there is a test that says so.

`Site` resolves routes at index time, because only it can see whether every variant
agrees. Disagreement is dropped rather than resolved, as is a slug landing where
another bundle already answers — the same rule colliding keys and contested aliases
already follow. The key a slug moved away from stops answering, so the old address
does not quietly keep working.

Two bugs surfaced doing this, both older than this change:

An alias naming its own bundle's former key was rejected as "an alias that names a
real bundle" — which made rename-plus-alias, the entire point of ADR-0008's alias
mechanism, impossible. The check now asks what a request asks: is anything actually
served there.

Aliases were counted per declaring *file*, so a bundle whose two language variants
both listed the same alias looked like two rival claimants and lost the alias. It is
a set of keys now. This one only appears with translated content, which is why no
fixture had caught it since entry 4 — the real binary did, on the first multilingual
rename.
2026-07-31 02:34:07 +06:00