Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:34:18 +06:00
co-authored by Claude Opus 5
commit 02268f9121
33 changed files with 2730 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
---
description: Record an architectural decision in six lines
---
Append an ADR to `docs/decisions.md` using the exact format at the top of that file.
First, before writing anything: find the doc that owns this topic via the ownership table in
`docs/README.md` and read it. If it already carries the rule, do not write an ADR — amend that doc
and say that is what you did. An ADR that restates an existing doc is a duplicate, not a decision.
Rules:
- Next sequential number. Never renumber, never rewrite an existing entry.
- To reverse a decision, add a new ADR and mark the old one `superseded by ADR-NNNN`.
- Six lines. If the reasoning needs more, the decision is not yet made.
- `Revisit if:` must name a specific observable event, not "if requirements change".
- If the decision adds a dependency, update `scripts/allowed-deps.txt` in the same change.
- If the decision raises a budget, update `scripts/budgets.env` in the same change and state
the old and new values in the ADR.
If the argument for `$ARGUMENTS` is thin — no forcing reason, or no consequence you can name —
say so and ask one question rather than writing a hollow entry.
+26
View File
@@ -0,0 +1,26 @@
---
description: Hunt speculative complexity and propose deletions
---
Read the code and find what has not earned its place. Propose removals; change nothing yet.
Look for:
- Interfaces with one implementation. Type parameters with one instantiation.
- Function parameters, struct fields, config knobs, or frontmatter keys with no real caller
or no real author using them.
- Two implementations of the same idea (parsing, path joining, slugging, date handling).
- Abstractions built ahead of their counter: a pipeline at two transforms, a resolver at one
route, an extension registry before its counter is due.
- Files and functions over the advisory sizes in `scripts/budgets.env`; nesting past 4.
- Error handling that logs and returns, or wraps without adding information.
- Tests that assert on private helpers, or that need a mock to exist.
- Dead code, commented-out code, `TODO`s older than the feature that introduced them.
- Comments that narrate control flow.
- Anything in `docs/` describing code that no longer exists — excluding `ideas/` and
`reference/`, which are never audited against the code and must not be opened here.
Report as a table: item, location, why it has not earned its place, LOC recovered if deleted,
and risk of deleting. Order by LOC recovered, descending. Recommend a top three.
Then stop. Deletions happen as their own small changes, with a go-ahead each, and each one runs
`./scripts/verify.sh`.
+33
View File
@@ -0,0 +1,33 @@
---
description: Check the architecture invariants against the code, not against the docs
---
`verify.sh` checks what a grep can check. This pass checks the nine invariants in
`docs/architecture.md`, which need reading the code. Run at arc boundaries, before a freeze, and
before the first deploy.
For each invariant, answer **held / violated / not yet applicable**, with a file and line when
violated. Do not fix anything in this pass.
1. **Open page object** — are all fields optional in practice? Find any place a missing field causes
a nil deref, an error return, or a template failure that reaches the response.
2. **Trusted / untrusted modes** — trace every path by which non-repo bytes could reach shortcode or
template evaluation. This is the one invariant where "probably fine" is not an answer: name the
check that stops it, or report it violated.
3. **Identity is not language** — is the bundle key free of language? Any place a language suffix
leaks into a cache key, a URL, or a dependency key is a violation.
4. **Request-time render behind a cache** — does export walk the same code path as the server, or has
a second path appeared?
5. **Permalinks permanent** — does any code derive a URL by a rule other than the one in
`content-model.md`? Does every rename path emit an alias plus a permanent redirect?
6. **Interactions off the content graph** — can an interaction invalidate more than its own fragment?
7. **Every feature is a leaf** — is each `internal/ext/*` package deletable without touching the
core? Try naming the diff that removes one.
8. **Degrades with external services off** — for each external client, is there a path that still
serves correctly, slowly, when it is unreachable?
9. **Core stops growing after Arc 2** — compare the `core` figure `verify.sh` prints against the one
recorded at the freeze in `state.md`. Post-freeze growth is the finding, not the number.
Then: report held/violated per invariant, and for anything violated, whether it is a bug to fix now
or a latent item with a trigger. A violated invariant is a stop condition (`CLAUDE.md §6`) — say so
plainly rather than documenting it as the new normal.
+24
View File
@@ -0,0 +1,24 @@
---
description: Decide whether a catalog item is a leaf worth building, or a trunk that waits
---
Evaluate `$ARGUMENTS` against the leaf/trunk test in `docs/exploration.md`.
Research it only as far as needed to answer accurately — what it actually requires, not what its
homepage claims. If it is a protocol or spec, state which parts are mandatory versus optional.
Answer these, briefly:
1. **Leaf or trunk?** Per `architecture.md` invariant 7.
2. **Reduces to:** which primitive, concretely, with the shape of the implementation in two lines.
3. **Prerequisites:** which arc or primitive must exist first. Is that gate open today?
4. **Cost:** estimated LOC, new dependencies, new disk fields, ongoing maintenance burden,
and anything it makes permanent (URLs, identifiers, published data).
5. **Sovereignty test:** apply the one in `roadmap.md` Governors.
6. **Fit:** does it serve fiction, webcomics, essays, Bengali-language work, or the
low-bandwidth ethos — or is it interesting for its own sake? Interesting-for-its-own-sake is
a legitimate answer for a cheap leaf and a disqualifying one for anything expensive.
7. **Verdict:** build now / build when gate X opens / trunk, needs an ADR / no.
Write the verdict as one row in the Verdicts table in `docs/exploration.md`. Do not implement
anything in this pass.
+33
View File
@@ -0,0 +1,33 @@
---
description: Reconcile the docs against the actual code and report drift
---
Reconcile documentation with reality. The code is the truth; the docs are the suspects.
1. Inventory the actual Go files, their line counts, and the non-stdlib dependencies in `go.mod`.
2. Compare against `docs/state.md`: inventory rows, counters, latent items, `verified against`.
Recount the counters **from the code** — number of render transforms, routing cases, views,
output formats, extensions — rather than trusting the recorded numbers.
3. Check `docs/architecture.md` STATUS lines: has a primitive become real, or is one described
as live when it is not built?
4. Check `docs/content-model.md` `[spec]` versus `[live]` markers against what the parser
actually accepts. Frontmatter fields the code reads but the doc omits are drift; fields the
doc promises but the code ignores are worse drift.
5. Check `scripts/allowed-deps.txt` against `go.mod`.
6. Look for facts stated in two docs. Delete one, link to the other.
7. Run `./scripts/verify.sh --list` and check every doc sentence claiming a gate against it. A doc that
says "`verify.sh` fails on X" where no such gate exists is the most damaging drift there is: it reads
as enforcement and is decoration.
8. Check the reverse too — a gate in the list that no doc explains. Either document it or delete it.
9. Follow every cross-doc citation of a *section* ("see `roadmap.md` Governors", "`CLAUDE.md` §6") and
confirm the heading exists. Whole sections have gone missing while another doc still cited them.
Scope: the docs listed above and nothing else. **Do not open `ideas/` or `reference/`**
they describe proposals and facts, never the state of the code, so they cannot be drifted against
it. `verify.sh` already checks their indexes mechanically.
Then:
- Fix the docs. Doc-only diff; no code changes in this pass, no matter what you find.
- Anything in the code that contradicts an ADR: report it, do not silently document it as
correct. A drifted invariant is a bug, not a new decision.
- Report drift found, drift fixed, and anything that needs a human decision.
+18
View File
@@ -0,0 +1,18 @@
---
description: Run the objective gates and report evidence, not opinions
---
Run `./scripts/verify.sh` and report the result.
Then, if the working tree has uncommitted changes, produce feature-specific evidence for what
changed: start the server and `curl` the affected URL, run the relevant test by name, or diff
the golden file. Show real output.
If anything fails:
1. Report the failure verbatim before interpreting it.
2. Diagnose in one paragraph.
3. Propose the smallest fix. Do not apply it without a go-ahead unless it is a formatting fix.
If a budget in `scripts/budgets.env` is exceeded, do not raise the budget. Report which budget,
by how much, and which files are responsible. Offer the two options: shrink the code, or an ADR
raising the ceiling deliberately.
+39
View File
@@ -0,0 +1,39 @@
{
"permissions": {
"defaultMode": "default",
"allow": [
"Bash(gofmt:*)",
"Bash(go build:*)",
"Bash(go vet:*)",
"Bash(go test:*)",
"Bash(go run:*)",
"Bash(go doc:*)",
"Bash(go list:*)",
"Bash(go mod tidy)",
"Bash(go mod why:*)",
"Bash(./scripts/verify.sh)",
"Bash(bash scripts/verify.sh)",
"Bash(git rev-parse:*)",
"Bash(git status:*)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(rg:*)",
"Bash(wc:*)",
"Bash(curl -s http://localhost:*)",
"Bash(curl -s http://127.0.0.1:*)"
],
"ask": [
"Bash(go get:*)",
"Bash(git commit:*)"
],
"deny": [
"Bash(git push:*)",
"Bash(rm -rf:*)",
"Read(./.env)",
"Read(./.env.*)",
"Read(./.envrc)"
]
}
}
@@ -0,0 +1,169 @@
# The feature loop
Six steps, in order: **Clarify → Plan → Implement → Verify → Document → Report.**
`CLAUDE.md` holds the rules; this file holds the procedure.
Scale, do not skip. A one-sentence request gets one line of plan and a short report — and still gets
the conflict check, propagation, verification, the `state.md` touch and the stale-reference sweep.
Casual phrasing is not a smaller change: *"default-language files do not need the `.en` part"* is one
unambiguous sentence that rewrites filenames on disk, makes a suffix optional in code, and falsifies
every doc calling it required.
## Propagation — a change is not done until every surface agrees
One request can touch four surfaces. Name each as done or n/a before reporting; the ones people miss
are the first and the last.
| Surface | What it means |
|---|---|
| the site root | Out of reach: it lives in its own repo (ADR-0011). A disk-contract change ships a **written migration step** the author runs, plus a note on whether any bundle key or URL moves. Never claim to have migrated files you cannot see. |
| code | The engine, plus a test for the new behaviour. |
| the theme | Out of reach, like the site root (ADR-0023). This repo ships a **contract extension** in
`docs/theme-contract.md` plus a written note of what a theme must do — never the theme itself, and never
a layout or markup decision dressed as an engine feature. |
| fixtures and emitted output | Fixture sites in `testdata/`, the embedded default templates (a reference implementation of the contract), and anything reading a field or path shape you altered. |
| harness docs | Every doc, ADR, marker or example that assumes the old form. An ADR that mandates what you just made optional is not stale, it is **contradicted** — supersede it, do not quietly reword it. |
---
## 1. Clarify
Read `docs/state.md`, then `docs/README.md` — the map, always, before deciding what else to open.
Use its topic-ownership table to list the docs owning anything this change asserts a rule about,
and read those. That list is a floor: skipping it is how a rule gets written twice and the second
copy contradicts the first.
### Conflict check — every request
Before anything else, ask whether the request contradicts something already decided. Classify and
act; do not average two positions into a compromise nobody chose.
| Kind | Examples | What to do |
|---|---|---|
| **Hard** — reverses a deliberate decision | an ADR, an architecture invariant, the permalink shape, the untrusted boundary, a `_MAX` budget, a frozen contract, the dependency policy | **Stop.** Quote the line, name the file, give both paths: comply, or change the decision (new ADR, or supersede the old one). Wait. Never pick for them. |
| **Soft** — exceeds a convention or a `[spec]` shape | a `_WARN` threshold, a style-floor preference, an unbuilt `[spec]` section's suggested shape, a latent item's trigger | State it in one line, proceed with the request, record the deviation where the convention lives. Conventions are a floor, not a decision. |
| **Stale** — a doc contradicts the code | `state.md` inventory, a STATUS line, an out-of-date example | Auto-resolve: the code wins. Fix the doc in Document, mention it in the report. No question needed. |
| **None** — the request refines or extends what is written | asking for something a `[spec]` section already describes | Proceed. Say nothing about it. |
Resolve intelligently where the answer is genuinely unambiguous — a stale doc, or a request that is a
superset of what is written. Surface anything where a reasonable person could have meant either
thing, and everything in the Hard row without exception. One conflict is worth one message; a
silently reversed decision costs the trust in every other decision.
Then decide what you genuinely do not know. Ask **only** questions whose answer changes the code or
the bytes on disk. Maximum three, one message, up front, each with a **bold** default.
Never ask about: anything `conventions.md`, `content-model.md`, or an ADR already decides; naming,
formatting, file placement, test style; permission to follow the constitution; "would you like me
to also…" — that is scope creep wearing a question mark.
Do ask when: the feature has two plausible disk formats, URL shapes, or authoring ergonomics; it
touches an open question in `state.md`; success criteria are not observable from the request;
it appears to need a dependency, a new package, or a frozen-contract change.
No such questions? One line — *"No questions — assuming tag pages live at the section root and
reuse the post list template."* — and continue.
## 2. Plan
Post this before writing code. Under fifteen lines.
```
Goal: one sentence, observable from outside the program
Reduces to: Bundle | Stage | Query | View | Interaction | Effect | bundle-as-program
Success criteria: the checks that will prove it works (commands, URLs, expected output)
Files: exact paths, marked new / edit, with an estimated ±LOC each
New deps: none (anything else needs an ADR first — stop and ask)
Earn-it check: which counter this increments, and whether an extraction is due this change
Trust check: does any untrusted input reach this code? ("n/a" if not)
Docs read: the owning docs you opened ("none" only if the change asserts no rules)
Not doing: the two or three adjacent temptations you are declining
```
Wait for a go-ahead unless the user said "just do it" or the change is under ten lines in one file.
If the plan reveals a trunk, say so instead of planning and offer the leaf.
## 3. Implement
- Smallest code satisfying the success criteria. Nothing for the version after next.
- Only the planned files. No renames, reordering, reformatting beyond `gofmt`, or tidying of
neighbouring code.
- Something wrong outside the plan? One line on the Latent list in `docs/state.md`, keep moving.
That is the whole mechanism; use it instead of a drive-by fix.
- Reuse the existing shape before inventing one. Copying eight lines twice beats an abstraction at
use two; the third use is when it becomes a design.
- Halt and ask if any `CLAUDE.md §6` stop condition fires. Halting costs one message.
## 4. Verify
Run `./scripts/verify.sh` — doc coupling, format, vet, build, test, dependency allowlist, budgets.
Then **at least one piece of feature-specific evidence you actually executed**: the new test name
and its output, `curl -s localhost:PORT/path | head`, a golden-file diff, the before/after fragment
of real generated markup, or benchmark numbers.
Budget failure: shrink the change, or stop and propose an ADR raising it. Never raise it silently.
Never write "should work", "this will now…", or your own diff summarised as a result. If you could
not run something, say which and why.
## 5. Document
Same change, not later. Triggers: `docs/README.md`. Walk the propagation table above — every surface
done or explicitly n/a. Minimum:
- `docs/state.md`: inventory rows, counters, `verified against` line, latent items added/removed.
- `docs/decisions.md`: an ADR if the choice is expensive to reverse. Six lines. First open the doc
that owns the topic — if it already carries the rule, amend it there; an ADR restating an
existing doc is a duplicate, not a decision.
- Any other doc **only if the change made it wrong.** Never state one fact in two docs.
Doc edits are surgical too. Prefer deleting a stale line to appending a corrected one.
**Then sweep for what you replaced.** Any rule, value, name or path you changed may be described
elsewhere in the old terms:
```
grep -rn '<old form>' docs CLAUDE.md HARNESS.md ideas reference .claude scripts
```
`verify.sh` fails on dangling file paths, ADR numbers and `CLAUDE.md` section refs. It cannot detect
a sentence that is merely now untrue, or a concept renamed in one place — that sweep is the author's,
and skipping it is how a doc ends up contradicting the file it points at.
## 6. Report
Short — one line each, no prose unless a conflict or a stop condition needs explaining. Drop the lines
that are genuinely n/a rather than padding them.
```
Did: what now works, in the user's terms
Evidence: the command you ran and its result
Diff: files touched, ±LOC
Propagated: content / code / templates / docs — each done or n/a, with content files named
Earned: counters after this change; anything now due for extraction
Skipped: what you deliberately did not do, and the latent items you logged
Conflicts: hard ones surfaced and how they were settled, soft ones deviated from, or "none"
Swept: the old form you grepped for after a rename, or "n/a"
```
---
## Failure modes to catch in yourself
| Symptom | What it means | Do instead |
|---|---|---|
| Interface with one implementation | Anticipating, not designing | Concrete type; wait for the second |
| A parameter no caller passes | Speculative flexibility | Delete it |
| Reaching for a dependency | Usually 40 lines of stdlib | Write the 40 lines, or stop and ask |
| Wanting a `switch` on post type in the core | A View or Query is the seam | Move it out |
| Needing a mock to test | Wrong seam | `fstest.MapFS` or a `testdata` dir |
| Renaming "for clarity" mid-feature | Drive-by refactor | Latent list |
| Explaining why the boundary can bend here | It cannot (ADR-0003) | Stop and ask |
| Plan grew while implementing | Scope drift | Stop, re-plan, continue |
| Writing a rule without reading its owning doc | Guessing where you could look | `docs/README.md` topic table, then amend that doc |
| Quietly doing what was asked against a recorded decision | The conflict was real and you hid it | Quote the line, give both paths, wait |
| Changed the rule in code, left the docs describing the old one | Propagation stopped at the first surface | Walk all four surfaces |
| Made a field optional, left the ADR mandating it | Contradicted, not stale | Supersede the ADR |
| Treated a one-sentence request as a narrow change | Articulation size is not blast radius | Walk the propagation table |
| Splitting the difference between request and policy | A compromise nobody chose | Surface it; the human picks |
+4
View File
@@ -0,0 +1,4 @@
/atelier
*.test
*.out
.DS_Store
+111
View File
@@ -0,0 +1,111 @@
# atelier — agent constitution
`atelier` is a flat-file personal publishing engine in Go, built solo, one feature at a time.
You implement; the human owns scope and taste. This file overrides your defaults.
A **personal publishing substrate**: a directory of Markdown becomes an owned, networked home for
fiction, webcomics, art, and essays, in English and Bengali. Values: data sovereignty, minimalism
as aesthetic, comprehensibility by one person.
---
## 1. Read order (do not skip, do not exceed)
1. This file.
2. `docs/state.md` — what exists **right now**, plus the earn-it counters.
3. `docs/README.md` — always. The map: how you find which doc owns your topic. Not "exceeding".
4. Every doc owning a topic your change asserts a rule about (ownership table in `docs/README.md`).
5. Only the source files you will edit, plus their direct callers.
No reading the repo "for context", no speculative greps. Where `docs/state.md` and the code
disagree, the code wins — say so, fix the doc in Document.
**The ceiling has a floor.** "Read less" governs breadth, never the doc that owns what you are
writing. Before stating a rule, contract, threshold, or gate, read its owning doc; if it already
says it, amend there instead of restating elsewhere.
`ideas/` and `reference/` are out of context by default, indexes included. Open one only when the
human names it. Never sweep, never list, never cite unasked. Storage, not background.
## 2. The primitives — everything reduces to one
**Bundle · Stage · Query · View · Interaction · Effect** (+ bundle-as-program).
Definitions and STATUS: `docs/architecture.md`.
Name the primitive before writing code. If it reduces to none it is a **trunk** (wants a permanent
service or a core-model change): stop, say so in a paragraph, propose the leaf, wait.
## 3. Hard rules
1. **No abstraction before its second concrete use** — pipeline, resolver, interface, generic, config
knob, registry. The counters table in `docs/state.md` holds every threshold and is the only place
they are written down: read them, increment them, never anticipate them.
2. **No new dependency** without an ADR and human approval. Allowlist:
`scripts/allowed-deps.txt`. Stdlib first, always.
3. **Surgical diffs.** Only the lines the feature needs. No renames, no reformatting beyond
`gofmt`, no "while I was in there". Spotted something bad? Latent list in `docs/state.md`.
4. **The untrusted boundary is absolute.** Anything not from the site root (comments, webmentions,
form input) never reaches shortcode or template evaluation. Crossing it needs a plan callout.
5. **Permalinks are permanent.** A published URL never changes meaning; renames add aliases and
permanent redirects. The path shape is decided (ADR-0008) and written in `docs/content-model.md`
read it before emitting a URL, and never invent a second shape.
6. **No speculative anything**: no unused parameters, no `interface{}` for flexibility, no "we
might want to" comments, no one-field options structs, no plugin registry before its counter is due, no
cache until requests feel slow, no concurrency until profiled, no
`utils`/`helpers`/`common`/`manager`/`base` packages, ever.
7. **Delete before you add.** If removing code buys the feature, do that.
8. **Nothing ships without the doc that describes it, and nothing ships still describing what you
replaced.** Code carries its `state.md` update; harness changes (this file, `scripts/`,
`.claude/`) carry their `HARNESS.md` update; both are gated. Changed a rule, value, name or path?
Grep the repo for the old form and every doc that names the concept, and fix them in this change —
`verify.sh` catches dangling paths and ADR numbers, never a superseded sentence. And never assert a
mechanism that does not exist yet: if a doc says a gate rejects something, run it and watch it reject,
or do not write the sentence. `./scripts/verify.sh --list` names every gate there actually is. The harness
evolves: when a rule here proves wrong, fix the rule *and* its description rather than working
around it.
9. **Surface conflicts; never resolve them silently.** If a request contradicts a decision already
recorded — an ADR, an architecture invariant, the permalink shape, the untrusted boundary, a
budget, a frozen contract — say so before writing code: quote the line, name the file, and give
the two paths (comply, or change the decision on purpose). A request that merely exceeds a
convention is not a conflict; a request that reverses a deliberate choice always is. Kinds and
procedure: the Clarify step in `SKILL.md`. Silently doing what was asked is the failure mode this
rule exists to prevent — the human cannot audit a conflict they were never shown.
## 4. The loop (every request, no exceptions)
`Clarify → Plan → Implement → Verify → Document → Report`.
Procedure: `.claude/skills/atelier-feature-loop/SKILL.md`. The two gates people skip:
**Clarify.** Only questions whose answer changes the code or the bytes on disk. Max three,
batched, up front, each with a **bold** default so silence answers. Never about naming, formatting,
or anything `docs/conventions.md` decides. None to ask? State assumptions in one line and move on.
**Verify.** `./scripts/verify.sh` green, plus one piece of feature-specific evidence you actually
ran (golden file, `curl`, test name, benchmark). Never report success from reading your own diff.
"Should work" is not a result.
## 5. Definition of done
- [ ] Plan's success criteria demonstrated with real output.
- [ ] `./scripts/verify.sh` green.
- [ ] Diff contains nothing outside the planned files.
- [ ] `docs/state.md` updated (inventory, counters, latent items, verified-at line).
- [ ] ADR in `docs/decisions.md` if a load-bearing choice was made.
- [ ] Report states LOC delta, what is now earned, what you deliberately did not do.
## 6. Stop conditions — halt and ask
- The change exceeds planned LOC by ~50%, or touches an unplanned file.
- You need a new dependency, a new package, or a change to a frozen contract.
- You are about to write a mock, a `switch` on a type, or a second copy of parsing logic.
- The feature only works if the untrusted boundary bends.
- Two reasonable designs exist and the choice is expensive to reverse — present both, briefly.
Stopping early costs one message. Guessing costs a refactor.
## 7. Style floor
Go stdlib idiom, `net/http` + `html/template`, no framework. `%w` wrapping at package boundaries
only. `log/slog`. Table-driven tests, golden files in `testdata/`. Explicit wiring in one file, no
`init()`. Size thresholds live in `scripts/budgets.env` only. Full rules: `docs/conventions.md`
read them, do not ask.
+163
View File
@@ -0,0 +1,163 @@
# The atelier harness — human's guide
Scaffolding that makes an agent build `atelier` the way you want: minimally, surgically, questions
before code, docs that stay true.
`README.md` is a two-line signpost — human here, agent to `CLAUDE.md`. `CLAUDE.md` is the
constitution (always loaded). `docs/` is what the agent needs to build the
engine — anything in it may be pulled into context on demand. `ideas/` and `reference/` sit outside
`docs/` deliberately: storage, opened only when you name a file, swept by nothing.
`.claude/` holds the feature-loop skill and six commands. `scripts/` holds the gate.
## How you use it
You ask for a feature. The skill fires by itself and runs
`Clarify → Plan → Implement → Verify → Document → Report`.
This runs for casual requests too — the skill fires on "the default-language files do not need the
`.en` part" exactly as it does on "add tag pages", with a one-line plan instead of a full one. A short
sentence is not a small change: that one renames content on disk, makes a suffix optional in code, and
contradicts every doc calling it required. The agent walks every surface — code, fixture sites under
`testdata/`, harness docs — and, because it cannot reach your site root, writes down the migration you
need to run there rather than pretending to have done it.
1. **You:** "Add tag pages."
2. **Agent:** up to three clarifying questions, each with a default. Answer or ignore.
3. **Agent:** a short plan — goal, primitive, success criteria, files, ±LOC, deps, earn-it check,
and what it is deliberately *not* doing.
4. **You:** "go", or edit the plan. "Just do it" skips the gate on small changes.
5. **Agent:** implements, runs `./scripts/verify.sh`, shows real output, updates docs, reports.
Occasional maintenance, by you:
- "Park this" → the agent writes `ideas/<slug>.md`, resumable cold, and indexes it. Name the file
later to pick the thread up; it reads these only when named.
- `/audit` every ~5 features — finds abstractions that never earned their keep.
- `/invariants` at arc boundaries, before a freeze, before the first deploy — checks the nine
architecture invariants against the code, which no grep can do.
**Conflicts come back to you.** If a request contradicts a recorded decision — an ADR, an invariant,
the permalink shape, the untrusted boundary, a hard budget — the agent stops, quotes the line, and
gives you two paths: comply, or change the decision on purpose. It resolves only what is genuinely
unambiguous, such as a doc that has fallen behind the code. Repeating the request is a valid answer
and is taken as your decision; what it will not do is quietly reverse a choice you made earlier, or
split the difference into a compromise nobody picked.
- `/refresh-docs` after a burst of work — reconciles docs with reality, reports drift.
- `/verify`, `/adr`, `/leaf <topic>` as needed.
**Three surfaces, one of them yours to edit here.** Engine source lives in this repo; content lives in
the site root (ADR-0011); the theme is a third surface with its own owner (ADR-0023). A request that spans
engine and theme produces a *contract extension* in `docs/theme-contract.md` plus a note of what the theme
must do — not the theme. `verify.sh` fails if the embedded reference theme changes without the contract doc
changing, because in practice those two drift together — and it fails on a `<script>` tag in that theme,
because a reference theme that grows taste stops being a reference (ADR-0026).
## Coming back after a long absence
In order, cheapest first:
1. `./scripts/verify.sh` — one command, tells you whether the thing is still coherent and whether
`docs/state.md` has fallen behind the code.
2. `docs/state.md` — what exists, the earn-it counters, the latent list, the commit it was last verified
against. This is the only doc that describes the present.
3. `git log --oneline` — one feature per commit, each body saying *why* (`conventions.md`). This is the
real map of how the code got here.
4. `/refresh-docs` — reconciles every doc against the actual code and reports drift, which is exactly the
question you have after a year.
5. `docs/decisions.md` — the ADR log, when you hit something and think "why on earth is it like this".
Each entry names the observation that would overturn it, so you can tell a stale decision from a
deliberate one.
6. `docs/toolchain.md` — if something is broken rather than merely unfamiliar. It records what this was
built against and which agent-tooling contracts it assumes, so a tooling change is diagnosable instead
of looking like a harness bug.
Then `docs/README.md` for whichever topic you are actually here for.
## Why the pieces exist
**Counters in `docs/state.md`** turn "no abstraction before its second use" into arithmetic. Every
threshold lives in that one table and nowhere else. The agent cannot argue a pipeline into existence
one transform early — it writes the next one inline and lets the count force the extraction. Most
load-bearing mechanism here, and the one with least machine enforcement, which is why `verify.sh`
fails any `.go` change that does not touch `state.md`. That does not prove the counters are *right*;
it makes forgetting them impossible, which is the real failure mode.
**The dependency allowlist** names modules that are permitted, not required. Being listed is permission;
`DEPS_MAX` counts what `go.mod` actually pulls in. `scripts/allowed-deps.txt` is the list.
**Budgets in `scripts/budgets.env`.** Two hard LOC ceilings, core and extensions, plus a dependency
cap. Exceeding one fails `verify.sh`, so raising it is a deliberate act with an ADR attached rather
than a drift. The split makes "the core stops growing after Arc 2" measurable: post-freeze the core
figure holds and only ext rises. File and function length are `_WARN`s, not ceilings — the cheapest
way to satisfy a hard per-file limit is sharding a coherent file into a `_helpers.go`, which is
worse code with a greener gate.
**The latent list** absorbs the urge to refactor mid-feature: a line in `state.md` instead of a
drive-by fix. You decide when latent items become features — but an arc cannot close with an
untriaged one, so the list drains instead of becoming a graveyard.
**This repo is engine source only.** The site root — `content/`, `static/`, optional `templates/`
lives in its own repository and is passed to the binary with `-site` (ADR-0011), and validating content
is the engine's own job rather than the gate's. The gate skips `ideas/` and `reference/` as well, so
exploratory scratch code parked there never has to compile.
**The architecture gate.** `verify.sh` enforces the layering in `conventions.md` from `go list`
output: content imports no sibling, render imports neither web nor ext, web imports no ext, nothing
imports `cmd`. One convenient sibling import is what turns a layered engine into a ball of mud, and
it always looks locally reasonable — so it is a hard failure, not a review note.
**Feature locality is enforced, not hoped for.** A feature is one directory under `internal/ext/<name>/`
plus one line in `cmd/atelier/wire.go`. `verify.sh` fails on a feature importing a sibling and on a
feature package without a `doc.go` — the first because sibling imports make an agent's read set compound,
the second because a four-line `doc.go` turns orientation into a fifteen-line read (ADR-0027).
**Documentation is gated too.** Every package carries a package comment, every exported identifier a doc
comment, and a decision cited in code (`// Path shape: ADR-0008.`) must name an ADR that exists. The point
is a codebase still navigable by `go doc` and `git log` alone, years from now, with no agent available —
`verify.sh` enforces presence, and only you can enforce that the comment says something.
**The style floor fails, it does not warn.** Forbidden package names, `init()`, importing `log`
instead of `log/slog`, `panic()` outside `cmd/`, and `time.Now()` outside a `clock.go` are stated
absolutely in `conventions.md`, so they exit non-zero. That last one exists because a Stage that reads
the clock without declaring a cache validity window (ADR-0013) would serve staleness invisibly. A rule enforced as a suggestion teaches the agent to read every rule as one.
Softer signals — `fmt.Errorf` without `%w`, `interface{}`, nesting past 4, exported-and-referenced-
once — stay advisory.
**STATUS markers in `docs/architecture.md`** give the target shape *and* what is legal today, so
the agent can read the endgame without building toward it.
**Six primitives, and everything reduces to one.** `Effect` (ADR-0012) covers work off the request
path — derivatives, indexes, feed files, outbound syndication — on content change, on a schedule, or on
demand. The test that keeps it honest: if the thing is computable at render time from the page plus the
clock, it is a Stage, not an Effect. An "old article" banner is a Stage; it needs no job and can never
be stale.
## The harness maintains itself
The harness is code and drifts like code. Same rule as the engine: **a change ships with the docs
that describe it, in the same change.**
- `verify.sh` fails when `CLAUDE.md`, `scripts/` or `.claude/` changes without `HARNESS.md`
changing. This file is the current description of the machine, not a snapshot of its design.
- `verify.sh` fails when a `.go` file changes without `docs/state.md` changing, and when `cmd/` or
`internal/` code changes without a `_test.go` changing — behaviour ships with a test.
- The gate is not optional. `scripts/hooks/pre-commit` runs it on every commit; enable once per clone
with `git config core.hooksPath scripts/hooks`. `--no-verify` bypasses it, and the commit body should
say why.
- `./scripts/verify.sh --list` names every gate that exists. A doc claiming enforcement is checkable
against it in one command, and `/refresh-docs` checks it in both directions — a claimed gate that is
missing, and a real gate nothing explains. Asserting a mechanism before it exists is the drift that
reads as enforcement and is decoration; `CLAUDE.md` rule 8 forbids it.
- `docs/README.md` carries three tables: topic → doc to **read before asserting a rule**, change →
doc to **update after making one**, and an authority table naming the one home of every value.
A new mechanism adds a row; nothing outside a value's home may restate it. A number written twice
eventually disagrees with itself.
## Open
- No ADR gate blocks Arc 1. One question remains in `docs/state.md`: the language suffix on the first
content file.
- `go.mod` does not exist yet — `go mod init` belongs to the first feature, and the module path is
still unchosen.
- `.claude/settings.json` denies reading `./.env*`, but secrets a parent directory's `.envrc`
exports are in every command's environment regardless. The deny rule is narrower than it looks.
+11
View File
@@ -0,0 +1,11 @@
# atelier
A flat-file personal publishing engine in Go. Point the binary at a directory of Markdown and it
becomes an owned, networked home for fiction, webcomics, art, and essays, in English and Bengali.
The site's content lives in its own repository, not this one (ADR-0011).
**If you are a human:** start at [HARNESS.md](HARNESS.md) — what the scaffolding is, how to use it,
and what to decide.
**If you are an agent:** start at [CLAUDE.md](CLAUDE.md) — the constitution and the read order. It is
loaded for you automatically; this file is not a prerequisite and nothing here is a rule.
+122
View File
@@ -0,0 +1,122 @@
# Doc map
Read the one you need. Do not read them all.
| Doc | Contains | Mutability |
|---|---|---|
| `architecture.md` | The six primitives, invariants, per-primitive STATUS | Only via an ADR |
| `state.md` | What exists now: inventory, earn-it counters, latent items | Every feature |
| `decisions.md` | ADR log — one entry per load-bearing choice | Append-only |
| `roadmap.md` | Arcs, earn-triggers, the core freeze point | When an arc completes |
| `content-model.md` | On-disk layout, frontmatter, post types, permalinks | When the disk format changes |
| `conventions.md` | Go style floor, package layout, perf and test rules | Rarely; via ADR if contested |
| `extensions.md` | Extension/plugin contract: target shape + earn gates | When earned or frozen |
| `exploration.md` | Catalog of possible future *engine features*, leaf/trunk verdicts | When a verdict is reached |
| `toolchain.md` | Versions and agent-tooling contracts this was built against | When you deliberately move a version |
| `theme-contract.md` | What the engine promises a theme; the only theme-facing obligation | When the contract is extended — additively only |
Two sibling folders sit **outside** `docs/` because they are storage, not working material:
`../ideas/` (parked ideas, any topic) and `../reference/` (durable facts from conversation). Write
there when asked to park something; open a file only when the human names it. Nothing sweeps them.
## Topic ownership — read before you assert
The inverse of the refresh triggers: those say what to update *after* changing code, this says what
to read *before* writing a rule — including when you are only writing an ADR or a comment about it.
Nearly always exactly one doc; if two seem to apply, the single-source rule is already broken and
that is the finding to report.
| About to assert something about… | Read first |
|---|---|
| plugins, extensions, features-as-packages, stage phases | `extensions.md` |
| a primitive, an invariant, or what is "earned" | `architecture.md` + counters in `state.md` |
| URL paths, permalinks, frontmatter, on-disk layout, post types | `content-model.md` (+ ADR-0008) |
| Go style, package layout, file/function size, test shape | `conventions.md` + `scripts/budgets.env` |
| a dependency | `scripts/allowed-deps.txt` + ADR-0007 |
| a budget or a gate | `scripts/budgets.env` + `scripts/verify.sh` |
| the untrusted boundary, comments, webmentions, form input | ADR-0003 + `extensions.md` |
| language, translation, fallback | ADR-0004 + ADR-0009 |
| deploy, containers, external services | ADR-0010 |
| whether a future feature is worth building | `exploration.md` + `roadmap.md` |
| how the harness works — a gate, counter, budget, the loop | `HARNESS.md` + `scripts/verify.sh` + `CLAUDE.md` |
| a tool version, or why agent tooling stopped working | `toolchain.md` |
| templates, layout, presentation, what a theme can rely on | `theme-contract.md` (+ ADR-0019, ADR-0023) |
## Single-source rule
Each fact lives in exactly one doc. Need it elsewhere? Link. This binds ADRs too: an ADR restating
a rule an owning doc already carries is a duplicate, not a decision — delete it and amend the owner.
Values that have drifted before, and their one home. Everywhere else names the concept and points
here; nothing else may state the value:
| Fact | Sole home |
|---|---|
| Earn-it thresholds (transforms, routes, views, extensions) | the counters table in `state.md` |
| LOC ceilings, size warnings, dependency cap | `scripts/budgets.env` |
| The permalink path shape | `content-model.md` (decision recorded in ADR-0008) |
| Which language is at the root, and the prefix form | ADR-0009 |
| The leaf/trunk definition | `architecture.md` invariant 7 |
| The sovereignty test | `roadmap.md` Governors |
| Package layout and the forbidden package names | `conventions.md` |
| What exists in the code right now | `state.md` inventory |
A value restated in two places is not redundancy for safety; it is a future contradiction waiting for
whichever copy gets edited alone.
`state.md` is the only doc describing the present; the rest describe rules, shapes, or intentions.
When code and `state.md` disagree, the code wins and `state.md` is wrong.
## Refresh triggers
Update in the Document step of the change that caused them. Not later.
| Change | Update |
|---|---|
| Any code change at all | `state.md` inventory + `verified against` line |
| New transform, route, view, extension, or dependency | the counters table in `state.md` |
| A choice expensive to reverse | new ADR in `decisions.md` |
| A primitive becomes real (earned) | STATUS line in `architecture.md` + counters |
| New frontmatter field, post type, or path shape | `content-model.md` |
| New dependency approved | `scripts/allowed-deps.txt` + ADR |
| Budget raised | `scripts/budgets.env` + ADR — once the first feature has shipped. While the harness is still being tuned, edit in place. |
| A latent item fixed | remove from the Latent list in `state.md` |
| Arc finished | `roadmap.md` + a retro line in `state.md` |
| Catalog item accepted or rejected | verdict line in `exploration.md` |
| `CLAUDE.md`, `scripts/` or `.claude/` changed | `HARNESS.md`, same change. Enforced by `verify.sh`. |
| A tool version deliberately moved, or a new external contract relied on | `toolchain.md` |
| The theme contract extended, or embedded templates changed | `theme-contract.md`, same change. Enforced by `verify.sh`. |
| A request spans engine and theme | contract extension here + a written note of the theme's part; never the theme itself |
| A new gate, counter, or budget added | `HARNESS.md` "Why the pieces exist" + a row in one of these tables |
| A rule, value, name or path changed | grep the repo for the old form; every doc naming the concept, same change |
| The disk contract changed | `content-model.md` + `testdata/` fixtures + a written migration step for the site root, which the engine repo cannot touch |
| A doc or ADR mandates what a change made optional | supersede the ADR; do not reword it in place |
| A request reversed a recorded decision | new ADR, or supersede the old one — the request does not win by being newer |
| A request exceeded a convention on purpose | note the deviation where the convention lives |
| An idea floated, not built now | a file in `../ideas/` + its index line. Adopted later: `Status: adopted → <where>` |
| A fact worth keeping surfaced in conversation | a file in `../reference/` + its index line, stating how it was established |
## Staleness
`/refresh-docs` reconciles docs against code and reports drift. Run it after a burst of work,
before a new arc, and any time a doc surprises you.
## House style
Present tense. Terse. Written for a reader who has forgotten everything, including their own
reasoning. No changelog prose, no "recently we…", no preamble. Delete a line before rewriting it.
A doc needing a table of contents is too long.
## Compression contract
Shortening a harness doc removes **words, never instructions**. Same rules, gates, thresholds,
triggers and table rows before and after; the agent must act identically. Redundancy between
`CLAUDE.md` and `SKILL.md` is deliberate — the constitution is always loaded, the skill is not —
and is not duplication to be collapsed.
Deleting a rule, adding one, or narrowing one is a rule change: it needs its own decision and its
own reason, and it never rides along in a compression pass.
Verify before reporting a saving: table rows, headings, list items and checklist boxes must match
the pre-pass file (`git diff --stat`, or a snapshot if the change is uncommitted). Report word count,
not lines — reflowing prose shrinks lines without saving anything.
+117
View File
@@ -0,0 +1,117 @@
# 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 the settings cascade (ADR-0017), 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: deferred until the first collection page.** One-off directory reads are fine before then.
## 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). Every Effect is idempotent and re-runnable
from scratch, 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).
**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: not buildable yet.** Earned at the first derived artifact; check the Effects counter in
`state.md`. 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: buildable, inline.** Keep cases inline;
extract the resolver when the routing counter in `state.md` is due, not before.
---
## 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.** `slug.bn.md` / `slug.en.md` with a defined fallback, even while one
language exists. Identity stays stable across translations.
4. **Request-time render behind a cache, fixed permalink policy.** 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. Anything wanting a permanent service or a core-model
change is a **trunk** and waits for a human decision (`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)
One validity record per cached entry, five axes, served only while all hold (ADR-0013):
| Axis | Invalidated by |
|---|---|
| content deps | bundle keys plus query signatures, taking `old new` for membership changes |
| interaction fragments | a comment or webmention arriving — the fragment, never the page (invariant 6) |
| `valid-until` | the clock passing the minimum window the render's Stages declared |
| epoch | an engine or template change, which stales every entry at once |
| cacheable | the bundle or route declaring itself uncacheable |
The record maps onto HTTP: ETag from deps plus epoch, `Expires` from `valid-until`, `no-store` for
opt-out — so a proxy in front stays correct without engine code. Stages reach the clock only through an
injected accessor, so a window can never be silently forgotten. Defer the inverted index — v1
re-renders everything, correct until it is slow. Asset derivatives are content-addressed and therefore
immutable: never invalidated, far-future cacheable, a different lifetime from page renders.
## 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.
+240
View File
@@ -0,0 +1,240 @@
# Content model
**Engine specification** — the format the parser accepts and the URLs the engine emits. This is not a
rule about how anyone organises their files: the site root belongs to its owner (ADR-0011), and every
statement here describes engine behaviour, not an obligation on content.
The most expensive thing in the engine to change, because published URLs are permanent and the files it
reads are somebody's database.
Markers are build order, not status: `[arc1]` build now · `[spec]` agreed shape, build at first real
use. What exists is `state.md`'s job. Never build a `[spec]` section because it is written here.
## The site root
The engine is pointed at a site root outside this repository (`-site <dir>`, or `ATELIER_SITE`), with
its own git history (ADR-0011). Nothing in the engine repo is content. `templates/` in the site root
overrides the defaults the binary embeds, so a bare root still renders.
```
<site>/
content/ # bundles — the disk contract below
static/ # verbatim, served as-is
templates/ # html/template overrides, optional
<site>/content/
posts/ # general blog [arc1]
2026-03-hello-world/ # directory bundle
index.en.md
index.bn.md
cover.jpg # local asset, referenced relatively
comics/ # webcomics [spec]
the-long-monsoon/
_index.en.md # the series bundle; also the cascade point for it
first-rain/ # slug is the name, never the position (ADR-0016)
index.en.md
page.png
art/ # single images or sets [spec]
writing/ # short stories, poems [spec]
status/ # short IndieWeb-style notes [spec]
2026-07-28-1030.md # single-file bundle, no folder
pages/ # about, contact, now [spec]
about/index.en.md
```
## Bundle rules
- A bundle is **either** a directory containing `index.<lang>.md` **or** a single file
`<slug>.<lang>.md`. Nothing else. A single file is the degenerate case, not a special case.
- The bundle key is its path relative to `<site>/content/`, minus language and extension. It is the cache
key, the dependency key, and the identity in ADR-0004. It never changes silently.
- The language suffix is optional and its absence means the default locale, always — not only while one
language exists (ADR-0021). `about.md` and `about.en.md` are the same variant; the parser accepts
either. Fallback chain: requested → default → any → 404.
- `_index.<lang>.md` in a directory containing other bundles makes that directory itself a bundle (a
section or series landing page) rather than a plain container.
- Local assets sit beside the body, referenced relatively. Assets never live in frontmatter. Moving a
bundle moves its assets — the entire point of bundles.
- A directory starting with `_` other than `_index` is ignored. `draft: true` is excluded from queries
and feeds but reachable at its own URL in dev.
## Frontmatter
YAML (ADR-0020), minimal, all fields optional except `title`. Unknown keys land in the `Extra` bag and are
readable by templates (ADR-0002). Never add a required field.
| Field | Type | Meaning |
|---|---|---|
| `title` | string | Only required field |
| `date` / `updated` | date | Publication; `updated` drives feeds and `Last-Modified` |
| `type` | string | Post type; defaults from the top-level section |
| `slug` | string | Overrides the derived slug. The engine serves the new path only; the old one 404s unless it appears in `aliases` |
| `aliases` | []string | Additional paths the engine resolves to this bundle, each redirecting permanently to the canonical one (ADR-0008) |
| `draft` | bool | Excluded from queries and feeds |
| `nocache` | bool | Never cache this bundle's render (ADR-0013). Named so absence means cacheable, per ADR-0002 |
| `summary` | string | Explicit summary; otherwise derived |
| `tags` | []string | Flat, case-preserved, Unicode |
| `series` / `order` | string / int | Series membership and position. Sparse by convention (10, 20, 30) so insertion is one edit; never appears in a URL (ADR-0016) |
| `cover` | string | Relative path to the lead image |
| `view` | string | Per-bundle View override (Arc 2) |
| `styles` / `scripts` | []string | Page-specific assets, relative to the bundle |
| `lang` | string | Explicit language when the filename cannot carry it |
## Post types
Types are **declarations**, not code paths (ADR-0014). The binary embeds the default set below; a
`types:` block in `site.yaml` extends or overrides it, so a seventh type costs a declaration plus a
template and no engine change. An incoherent declaration is a loud startup failure.
Each declaration carries: `section` (default directory), `view` (default View), `order` (date, sequence,
or manual), `feeds` (primary, section-only, or none), `required` (frontmatter fields that must exist),
`titleless` (whether an empty title is legal), and `taxonomies`.
| Type | Section | Order | Feeds | Distinguishing need |
|---|---|---|---|---|
| `post` | `posts/` | date | primary | The baseline |
| `comic` | `comics/` | sequence | primary | Ordered within a series; prev/next; image is the content |
| `art` | `art/` | date | section | Image-first; gallery membership; caption over body |
| `writing` | `writing/` | sequence | primary | Long-form; series-aware; print View eventually |
| `page` | `pages/` | manual | none | Standalone, dateless |
| `status` | `status/` | date | primary | Titleless, timestamp-slugged, syndication source |
Titleless is legal for `status`: the derived title is empty and Views must not assume one exists — a
direct consequence of the open page object. Nothing in the engine may assume the type list is fixed.
## Identifiers, normalisation and slugs
Two separate things (ADR-0015).
**Normalisation is unconditional.** Every string that acts as an identifier — filename, bundle key,
taxonomy term, frontmatter `slug`, request path — is normalised to NFC where it enters the engine, with
no opt-out. Bengali conjuncts have several byte encodings for identical-looking text and macOS yields
NFD, so without this two visually identical files take different bundle keys and a request never matches
the page it names. Unfixable after publication except by accumulating aliases.
**Derivation is locale-aware and overridable.** Default slug rules come from the site's default locale;
Unicode is preserved rather than transliterated. Any derived slug may be replaced by hand:
- a bundle, with `slug` in frontmatter — the engine then serves that path, and the previous one only if
`aliases` lists it
- a taxonomy term or section segment, with a term-to-slug mapping in the type declaration, so a Bengali
tag can carry a chosen URL form instead of a derived one
Overrides are normalised like everything else: writing a slug by hand does not exempt it.
## Permalinks
`/{section}/{slug}/`, no exceptions (ADR-0008). Section is the content type — the top-level directory
under `content/`, including `pages` — and slug comes from the bundle path or a `slug` override. So
`pages/about/` serves at `/pages/about/`, and the root stays engine-owned: emitted files and future
routes like `/tags/` can never collide with a bundle.
Trailing slash is canonical, the slashless form redirects permanently — one rule, applied once. A
published URL never changes meaning; renames add `aliases` and emit permanent redirects. Moving a
bundle between sections changes the URL the engine emits, and the old path resolves only through
`aliases` — so a section list is effectively permanent once anything is published.
Language routing is decided (ADR-0009): English at root, other languages under `/<lang>/` on the same
path — `/pages/about/` and `/bn/pages/about/`. `/en/…` permanently redirects to the root form and is
never live.
Emit `hreflang` and `canonical` from the variants that actually exist.
## Sequences, galleries, collections `[spec]`
All of these are a Query over bundle metadata with a stable sort; none justifies a new primitive.
A **sequence** is the one that needs naming, because comics, serial fiction and multi-part essays all
reduce to it (ADR-0016). Membership is `series`; position is whatever the type declares as its `order`
rule — date, sequence, or manual. Position lives in frontmatter, sparse, and never in the path, so
inserting a chapter between two others is a single edit with no renames, no changed bundle keys and no
aliases. Resolution — first, prev, next, last, index, count — is defined once, honours `draft`, and
respects the language fallback chain, so a missing Bengali chapter does not break Bengali prev/next.
A gallery is a Query plus an image View. Related posts are a Query with a scoring function, not a stored
graph. Pagination is a Query parameter plus a permalink rule for page 2+, still undecided.
## Taxonomies `[spec]`
Two kinds, deliberately (ADR-0018).
**`tags` are one global namespace** across every type. `/tags/{tag}/` lists everything carrying the tag;
`/{section}/tags/{tag}/` narrows it; listing views group results by type so a busy tag stays readable.
Cross-type discovery is the point — one tag spanning a comic, a poem and a photo essay is a feature here,
not noise. Term slugs are derived by locale and overridable by hand (ADR-0015).
**Declared taxonomies** are structural: a fixed term set that changes engine behaviour, declared on the
type — `series` for ordering, `medium` for art, `genre` for writing. These never enter the tag pool. The
test: a tag is free-form and cross-cutting, a declared taxonomy has known terms and drives behaviour.
Feeds follow the same shape: `/feed.xml` carries every type declared `primary`, `/{section}/feed.xml`
carries a section, and `/tags/{tag}/feed.xml` comes free from the same Query.
## The settings cascade
Settings resolve site → section → bundle, nearest explicit value winning (ADR-0017):
| Level | Where it lives |
|---|---|
| site | `site.yaml` at the site root |
| section, and any enclosing section | that directory's `_index.<lang>.md` frontmatter |
| bundle | the bundle's own frontmatter |
The cascade carries stage toggles, `view` selection, taxonomy defaults, cache flags, and metadata
defaults — declared keys only, never arbitrary engine internals. Stages run on everything by default and
are switched off by a cascade key, not by a predicate inside the stage: a stage that finds nothing to do
returns early on content *shape* ("no images here"), which has nothing to do with type.
This is also how a template is chosen: type default, then section override, then the bundle's `view`
(ADR-0019).
## Extras: enumerated local assets `[spec]`
A bundle may hold a directory of supporting files — drafts, notes, logs, scans, media. Default `extras/`,
renamed by a cascade key so a comic can use `process/` and a story `notes/` (ADR-0025).
- The bundle scanner **skips it entirely**. A `.md` inside is an asset, not a bundle: no frontmatter, no
identity, no language variants.
- Enumerated as a tree, sorted by filename — the sparse numeric-prefix convention orders entries without
putting numbers in URLs (ADR-0016).
- Each entry is classified by extension: `markdown`, `text`, `image`, `pdf`, `audio`, `video`, `other`.
Markdown and text are rendered; everything else is served as bytes.
- `…/extras/{path}` renders the listing with that entry selected; `?raw` returns the bytes.
- Excluded from feeds, queries and search.
## Visibility of everything inside a bundle
Every byte served from inside a bundle inherits that bundle's publish status — body, cover image, any local
asset, everything under extras (ADR-0024). An unpublished bundle answers **404** for itself and all its
assets; 403 would confirm the work exists. `-dev` reveals drafts and future-dated bundles with their
assets, defaults off, and is the only thing that changes the answer.
For a future-dated bundle the 404 carries `valid-until` = its publish time (ADR-0013), so it expires
exactly when the bundle becomes public.
## Includes and shortcodes `[spec]`
Shortcodes are a Stage running on trusted content only (ADR-0003), never on comments. File inclusion
resolves relative to the including bundle and may not escape the site root. Transclusion of another
bundle's body is Arc 4 and needs a cycle guard on the first attempt.
## Images `[spec]`
Optimisation and sizing are a Stage plus emitted derivative files, content-addressed by source hash
and target width so rebuilds are idempotent and cheap. Emit width/height into the markup to prevent
layout shift. Never mutate the author's original. Prefer stdlib decoders; a small dependency only
with an ADR.
## Time-dependent presentation `[spec]`
Anything derivable from a page plus the current clock is computed by a Stage, never stored in content:
an "this article is old" banner compares `date` to now, relative dates likewise. Because renders are
cached (ADR-0005), such a Stage also declares how long its output stays true — a banner until
`date`+threshold, a relative date for a minute — and the entry expires then (ADR-0013). Future-dated
publication is the same shape seen from the other side: the page becomes reachable at a moment nobody is
requesting it.
## Metadata output `[spec]`
OpenGraph, Twitter cards, JSON-LD, microformats2, canonical links, and `hreflang` are Stages reading
only fields that already exist on the page. SEO adds no new disk fields; if it seems to need one, the
field belongs in the model for its own sake.
+100
View File
@@ -0,0 +1,100 @@
# Conventions
The style floor. Do not ask about anything here — read it and comply. Disagreement is legitimate but
goes through an ADR, not a diff.
## Language and dependencies
- Go, current stable release. Stdlib first, every time.
- `net/http`, `html/template`, `log/slog`, `os`, `io/fs`, `embed`. No web framework, ORM, router
library, or config library — flags plus environment variables, parsed in one place. The site root
(`-site`, `ATELIER_SITE`) is the only required setting; default templates are `embed`ded so a bare
site root renders (ADR-0011).
- New dependency = ADR + human approval + `scripts/allowed-deps.txt`. `verify.sh` enforces it.
- Prefer 40 lines of obvious code over a dependency doing it in one call — unless the 40 lines would be
subtly wrong in cases the author cannot predict, which is why YAML is a dependency (ADR-0020).
## Package layout
```
cmd/atelier/ main, flag parsing, explicit wiring — the only place things are assembled
internal/content/ bundles, frontmatter, slugs, queries — knows the disk, not HTTP
internal/render/ markdown, transforms, templates — knows content, not HTTP
internal/web/ handlers, routing, headers, caching — knows both, exposes neither
internal/ext/ extensions, one package each — earned at its counter (see extensions.md)
```
Dependencies point inward. `internal/content` imports nothing from the others; `cmd` imports everything
and is imported by nothing. A feature under `internal/ext/` may import `internal/content` and
`internal/render`, and must not import `internal/web`, `cmd/`, or **another feature** — sibling imports
are what make an agent's read set compound (ADR-0027). Every `internal/ext/*` package carries a `doc.go`;
`verify.sh` fails without one. Routes reach `web` by assembly in `cmd/atelier/wire.go`, so `web` never
learns features exist. No `utils`, `helpers`, `common`, `shared`, `manager`, `base`,
`impl`, `core` — a package name not describing a domain is a smell. Flat until a package exceeds
`FILE_LOC_WARN`; do not pre-partition, and a single-file package therefore warns at the same point it
wants splitting.
## Naming and shape
- Functions under `FUNC_LOC_WARN`, ideally under 20. Nesting depth under 4. File length is not a
target: one coherent file beats two split to satisfy a counter. Values: `scripts/budgets.env`.
- No `init()`. No package-level mutable state. No singletons. Wire explicitly in `cmd`.
- Accept interfaces only where a second implementation exists; return concrete types.
- `ctx context.Context` first when a call can block or be cancelled — not decoratively.
- Never call `time.Now()` outside a `clock.go`. Stages read the clock through an injected accessor and
declare a validity window with it (ADR-0013); `verify.sh` fails on any other caller, because a
forgotten window serves staleness silently.
- Comments explain *why*, never *what*. Delete a comment narrating the next line. One stating a
non-obvious invariant is worth ten describing control flow.
## Documentation
Written for a maintainer working alone, years from now, with no agent to explain anything. `verify.sh`
enforces presence; only a human can enforce that it says something.
- **Every package has a package comment.** What it owns, what it does not, and which packages may import
it. For `internal/ext/*` that is the four-line `doc.go` shape in `extensions.md`.
- **Every exported identifier has a doc comment.** The exported surface of a 2000-line core is small, and
`go doc ./...` is the only navigation tool that still works when nothing else does. A comment that
restates the name (`// Load loads.`) is worse than none: say what it returns on absence, what it costs,
what it assumes.
- **Cite the ADR where the decision lives in the code.** `// Path shape: ADR-0008.` at the point that
builds a URL, `// Visibility inherits: ADR-0024.` at the guard. Twenty-seven decisions are unreachable
from code otherwise, and the next maintainer changes something whose reasoning they never saw.
`verify.sh` fails on a citation naming an ADR that does not exist.
- **Comment the trap, not the mechanism.** NFC normalisation, the settle window, parse order for template
overrides, the `old new` membership rule — each is a line of code that looks arbitrary and is not.
Those are the comments worth writing.
## Errors
- Wrap with `%w` at package boundaries, with operation and path: `parse %s: %w`.
- Never log and return the same error. Handle it, or return it.
- Request-time render failure degrades: log, serve what exists, never 500 on a missing field.
Startup failure is fatal and loud. Content authoring errors name the file and line.
## Tests
- **Behaviour ships with a test.** A change to `cmd/` or `internal/` carries a `_test.go` change in
the same commit; `verify.sh` fails otherwise. Test the observable contract, not coverage for its own
sake — one test proving the new behaviour is enough, and a refactor with no behaviour change needs
only the existing tests to still pass (touch them or say why not).
- Table-driven. Golden files in `testdata/`, regenerated behind a `-update` flag.
- Test the observable contract: URL in → bytes out; file on disk → page struct. Do not test private
helpers, and do not add a seam solely to make something testable.
- Needing a mock means the design is probably wrong — use a `testdata` directory with
`os.DirFS`/`fstest.MapFS`.
- One end-to-end test per route beats ten unit tests of render internals.
## Performance
Correct and small first; fast where measured. The render path is the only hot path.
- Write to `io.Writer`, never build pages by string concatenation.
- Parse templates once at startup; never per request.
- No `sync.Pool`, caching layer, or goroutines in the render path until a benchmark justifies it and
the number goes in the commit message.
- No reflection in the hot path. `Extra` lookups are map reads, not reflection.
- Benchmarks live next to what they measure, added only when a decision depends on them.
## Frontend output
Semantic HTML working with zero JavaScript. Progressive enhancement only. No build step for CSS.
Page-specific styles and scripts come from the bundle (`styles`/`scripts` frontmatter). Respect
`prefers-reduced-motion`. Every image gets width, height, and alt. Payload discipline is a feature of
this project, not an optimisation.
## Git
One feature per commit. Imperative subject under 72 characters; body says *why*. Doc updates ride in
the same commit as the code that made them true.
+396
View File
@@ -0,0 +1,396 @@
# Decisions (ADR log)
Append-only. Never rewrite history; supersede with a new entry. Six lines each:
```
## ADR-NNNN — Title
Date · Status: accepted | proposed | superseded by ADR-NNNN
Decision: one sentence, imperative.
Why: the forcing reason, not the full debate.
Consequence: what this makes cheap, what it makes expensive.
Revisit if: the specific observation that would overturn it.
```
---
## ADR-0001 — Rendering is an ordered pipeline of stage functions
Date: front-loaded · Status: accepted (target shape; see counters before building)
Decision: rendering is a sequence of `(ctx, page) → page` transforms, not a monolith.
Why: every rendering feature must be addable and deletable without touching a renderer core.
Consequence: cheap to add typography, shortcodes, localisation, dithering; requires discipline about stage ordering.
Revisit if: ordering constraints between stages become a graph rather than a list.
## ADR-0002 — The page object is open
Date: front-loaded · Status: accepted
Decision: known fields as struct members plus a `Meta`/`Extra` bag; absence equals zero value.
Why: templates and extensions must survive fields they do not know about, in both directions.
Consequence: no schema migrations for new metadata; slightly weaker compile-time safety.
Revisit if: silent typos in frontmatter keys start costing real debugging time (then add a lint, not a schema).
## ADR-0003 — Pipeline has trusted and untrusted modes
Date: front-loaded · Status: accepted
Decision: untrusted content never receives shortcode or template evaluation.
Why: this is the RCE boundary; it is the one asymmetric risk in the whole engine.
Consequence: comments render through a strictly narrower path; some unification is permanently off the table.
Revisit if: never. Narrow the untrusted path, never widen it.
## ADR-0004 — Page identity is separate from language variant
Date: front-loaded · Status: accepted; the day-one-suffix instruction superseded by ADR-0021
Decision: `slug.bn.md` / `slug.en.md` with a fallback chain, adopted from day one at one language.
Why: retrofitting identity/variant separation touches routing, caching, feeds, and every URL.
Consequence: translations are free later; a tiny amount of ceremony now.
Revisit if: never.
## ADR-0005 — Render at request time behind a cache; static export is cache-warming
Date: front-loaded · Status: accepted
Decision: the server is the superset; export walks the same code path and writes files.
Why: two code paths for the same output diverge, and the divergence always shows up in production.
Consequence: dynamic features (comments, search, content negotiation) stay possible; needs a cache eventually.
Revisit if: hosting constraints make a running process impossible.
## ADR-0006 — No guest authors
Date: front-loaded · Status: accepted
Decision: whoever commits to the site repo is the single trusted author; no multi-author model.
Why: keeps the trust model a clean binary, which keeps the untrusted boundary auditable.
Consequence: collaboration happens via git; the comment boundary stays the only untrusted path.
Revisit if: a real co-author appears — and then reconsider from scratch, not by patching.
## ADR-0007 — Dependency budget with an allowlist
Date: harness · Status: accepted
Decision: non-stdlib dependencies live in `scripts/allowed-deps.txt`; additions need an ADR; `verify.sh` enforces it.
Why: sovereignty and comprehensibility are the point; dependencies are the usual way both die.
Consequence: some features cost more code; the whole engine stays readable in an afternoon.
Revisit if: a budget raise is justified in an ADR of its own.
## ADR-0008 — Permalink policy: `/{section}/{slug}/`
Date: 2026-07-28 · Status: accepted
Decision: every bundle lives at `/{section}/{slug}/` with **no exceptions** — section is the content
type, the top-level directory under `content/` (`posts`, `comics`, `art`, `writing`, `status`,
`pages`), and slug comes from the bundle path or a `slug` override. `pages/about/` therefore serves at
`/pages/about/`. Trailing slash is canonical; the slashless form permanently redirects. Bengali
composes with the prefix outermost: `/bn/{section}/{slug}/` (ADR-0009).
Why: one rule with no exemption. Root-level pages were considered and rejected: they would give the
prettier `/about/`, but they permanently share the root namespace with the engine, so every future
root route (`/tags/`, `/search/`, `/feed.xml`) becomes a slug no page may ever use — a growing set of
reserved words discovered years after publishing. Uniformity also keeps the resolver at one URL shape.
Consequence: cheap — section feeds, indexes and queries map onto a URL prefix; the root stays entirely
engine-owned, so emitted files and future routes need no collision check. Expensive — `/pages/about/`
is a less handsome URL than `/about/`, and moving a bundle between sections changes its URL and needs
an `aliases` entry, so the section list is effectively permanent once anything is published.
Revisit if: never. This is the decision every shared URL depends on.
## ADR-0009 — Language routing: default at root, others prefixed
Date: 2026-07-28 · Status: accepted
Decision: English (the default language) is served at the root; every other language is served
under a `/<lang>/` prefix on the **same path**`/pages/about/` and `/bn/pages/about/`. `/en/…` is never a
live URL; it permanently redirects to the root form so it can never fork.
Why: English is the front door, and Bengali must still be linkable, cacheable, and shareable as
its own URL — which content negotiation on a single URL prevents.
Consequence: one redirect rule; `hreflang` plus `canonical` emitted per bundle from the variants
that exist; a third language costs nothing. Identity remains the slug (ADR-0004).
Revisit if: Bengali becomes the dominant language of the site — and then it is an alias and
default-language problem, not a routing rewrite.
## ADR-0010 — Deploy: self-hosted Docker, external infra permitted for derived state only
Date: 2026-07-28 · Status: accepted
Decision: ship a single binary in a container on a self-hosted server. External infrastructure
services (Redis, object storage, a search index) are permitted where they earn their place.
Why: the full server shape keeps comments, search, and content negotiation possible, and the
container makes the host itself disposable.
Consequence: confirms ADR-0005 (server is the superset; export stays available as the same code
path, now optional insurance rather than the target). The path-traversal guard becomes a hard
pre-deploy blocker. Every infra client is a dependency and counts against `DEPS_MAX`.
Constraint: external services may hold **only derived or disposable state** — cache, index,
session, queue. Canonical content stays in the site repo. Test before adding one: *if this
service vanishes, does a rebuild restore it, or is something lost?*
Revisit if: the engine can no longer boot and serve correctly with every external service off.
## ADR-0011 — The site root is external to the engine repository
Date: 2026-07-28 · Status: accepted
Decision: the engine is pointed at a **site root** — a directory outside this repository, versioned in
its own git repo — holding `content/`, `static/`, and optionally `templates/` overriding the defaults
the binary embeds. Selected by `-site <dir>` or `ATELIER_SITE`. This repository contains engine source
only; no content, ever, not even an example.
Why: content is the database, and a database does not live in the application's repo. Mixing them
interleaves "fixed a typo in a poem" with "extracted the resolver" in one history, makes every typo a
code deploy, and bloats every clone of the engine with image history.
Consequence: cheap — content-only publishing without a rebuild, a second site is a second root rather
than a fork, fixture sites live in `testdata/`, and the gate needs no content-exclusion rules.
Expensive — two repos to track, and a disk-contract change can no longer migrate the author's files:
it must ship a documented migration step or a subcommand, because the engine does not own that data.
Revisit if: never usefully. The separation only pays better as content grows.
## ADR-0012 — Effect is the sixth primitive
Date: 2026-07-28 · Status: accepted
Decision: work that happens outside the request path is an **Effect**, triggered on content change, on
a schedule, or on demand. It absorbs what the harness previously called an "emitted file" and the
`Emitter` slot in the extension contract — one name, not three. Definition and rules:
`architecture.md`.
Why: half the roadmap is not request-time — image derivatives, search index, sitemap and feed files,
webmention *sending*, POSSE, link archiving, EPUB, future-dated publication. None reduce to the other
five: `Interaction` is inbound by definition, and a Stage runs per render. Without this, those features
are not leaves, so invariant 7 fails and invariant 9's freeze cannot be checked.
Consequence: cheap — the Arc 34 networked layer becomes composition rather than core growth, and
scheduling is an in-process ticker in the one binary. Expensive — a second trigger kind (the clock)
means the engine has background work, so every Effect must be idempotent and its absence must degrade
rather than break. Anything requiring a separate scheduler process is a trunk under ADR-0010.
Revisit if: an Effect cannot be made idempotent, or scheduling genuinely needs a second process.
## ADR-0013 — Cache validity is one record with five axes
Date: 2026-07-28 · Status: accepted
Decision: a cache entry carries a validity record — content dependencies, interaction fragment refs,
`valid-until`, engine+template epoch, and a cacheable flag — and is served only while all five hold.
`valid-until` is the minimum of the windows the render's Stages declare; Stages reach the clock through
an injected accessor, never `time.Now()`. An optional scheduled Effect may pre-warm entries, but
scheduling is never what makes a page correct.
Why: invalidation has five causes, not one. Content edits and interactions were already modelled; time
(old-article banner, relative dates, future-dated publication), epoch (a template fix silently serving
old HTML), and opt-out (search, a random-page route) were not. A scheduled re-render sweep expresses
only the time axis, and coarsely — relative dates would need a sweep finer than their own granularity.
Consequence: cheap — the record maps onto HTTP semantics (ETag from deps plus epoch, `Expires` from
`valid-until`, `no-store` for opt-out), so a reverse proxy or CDN in front is correct with no extra
code, and a new time-dependent Stage needs no central registry because windows compose by minimum.
Expensive — the Stage contract gains a return channel, and a Stage that reads the clock without
declaring a window would serve staleness silently, which is why the clock accessor is enforced by
`verify.sh` rather than trusted.
Revisit if: windows cannot express some invalidation cause — then add an axis, do not add a mechanism.
## ADR-0014 — Content types are declared, not coded
Date: 2026-07-28 · Status: accepted
Decision: each content type is a declaration — section, default view, ordering rule, feed membership,
required frontmatter, whether titleless is legal, applicable taxonomies. The binary embeds a default set
(`post`, `comic`, `art`, `writing`, `page`, `status`); a declaration file in the site root extends or
overrides it. Adding a type costs a declaration plus a template and no core change; an unparseable or
incoherent declaration is a loud startup failure.
Why: six types already carry per-type behaviour, described in prose. Prose becomes either a `switch type`
in the core — forbidden, and it would grow with every new type — or unwritten template convention. A
declaration is the only form that keeps the seventh type as cheap as the sixth.
Consequence: cheap — a new type is a site-repo change with no engine deploy (ADR-0011), and the content
`check` command gets its validation rules from the same declarations for free. Expensive — the engine
must treat the type set as data, so nothing may assume a fixed list, and per-type behaviour reachable
only from code (a bespoke View) still needs a template rather than a branch.
Revisit if: a type needs behaviour no declaration can express — then it is a View or an Effect, not a
new field on every type.
## ADR-0015 — Normalise to NFC everywhere; derive slugs by locale, override by hand
Date: 2026-07-28 · Status: accepted
Decision: every string entering the engine as an identifier — filenames, bundle keys, taxonomy terms,
frontmatter slugs, request paths — is normalised to NFC at that boundary, unconditionally and with no
opt-out. Slug *derivation* is separate and locale-aware: default rules come from the site's default
locale, and any derived slug may be overridden by hand — `slug` on a bundle, and a term-to-slug mapping
for taxonomy and section segments.
Why: Bengali conjuncts have several byte encodings for identical-looking text, macOS hands back NFD, git
and editors pass through whatever they are given. Un-normalised, two visually identical files produce
different bundle keys and therefore different URLs, and a request never matches the page it names. This
is unfixable after publication except by accumulating aliases. Normalisation is correctness and cannot be
optional; romanisation and casing are taste and must be overridable.
Consequence: cheap — one chokepoint, and comparisons become byte comparisons again. Expensive — adds
`golang.org/x/text` (no transitive dependencies), the first entry on the allowlist, and every identifier
boundary must route through the normaliser rather than accepting a raw string.
Revisit if: never for normalisation. Slug derivation rules change with the default locale.
## ADR-0016 — Sequence position is metadata and never appears in a URL
Date: 2026-07-28 · Status: accepted
Decision: a bundle's slug is its name, never its position — `comics/the-long-monsoon/the-flood/`, not
`.../02-the-flood/`. Position comes from a single declared source per type (`order` in the type
declaration: date, sequence, or manual), sparse by convention (10, 20, 30) so inserting between two
members is one edit. Sequence resolution — first, prev, next, last, index, count, honouring drafts and
language fallback — is defined once and shared by comics, serial fiction and multi-part essays. A slug
may therefore contain slashes, which clarifies ADR-0008's single-segment reading.
Why: with position in the path, inserting a chapter between 3 and 4 renumbers everything after it, which
renames directories, changes bundle keys, changes published URLs, and demands an `aliases` entry for
each — one editorial decision becoming a permalink event, against ADR-0008. Two sources of truth
(filename prefix and frontmatter field) also drift, so the archive and the prev/next links can disagree.
Consequence: cheap — insertion is local and free, and prev/next exists once rather than three times.
Expensive — ordering is invisible in a directory listing, so authors read it from frontmatter, and
`order` values want leaving gaps.
Revisit if: never. Position in a permalink is the mistake this exists to prevent.
## ADR-0017 — Settings cascade: site → section → bundle
Date: 2026-07-28 · Status: accepted
Decision: settings resolve down a cascade — site declaration, then each enclosing section's `_index`,
then the bundle's own frontmatter — with the nearest explicit value winning. The cascade carries stage
toggles, view selection, taxonomy defaults, cache flags, and metadata defaults; it carries *declared*
keys only, never arbitrary engine internals. Stages apply to everything by default and are switched off
by a cascade key, not by a predicate compiled into the stage.
Why: stages are mostly no-ops where they do not apply, and their early return is a check on content
shape ("no images here") which is type-independent and belongs in the stage anyway. The cases that really
need control are narrower and cut across types — dithering wrong on one diagram, autolinking wrong in one
poem — which a per-type stage set cannot express and a cascade can. It also unifies with template
selection, which wants the same resolution order.
Consequence: cheap — one resolution rule covers rendering, presentation and metadata defaults; a section
sets a policy once for everything beneath it. Expensive — resolution must be cheap and cached, since it
runs per bundle, and the set of cascadable keys must stay declared or it becomes unbounded config.
Revisit if: cascade resolution shows up in a render-path profile.
## ADR-0018 — Global flat tags, declared structural taxonomies
Date: 2026-07-28 · Status: accepted
Decision: `tags` is one global namespace across every type — `/tags/{tag}/` lists everything carrying it,
`/{section}/tags/{tag}/` narrows to a section, and listing views group results by type. Structural
metadata with known terms that drives behaviour — `series`, `medium`, `genre` — is a *declared* taxonomy
on the type (ADR-0014) and never enters the tag pool. Feeds follow the same shape: `/feed.xml` carries
every type declared `primary`, `/{section}/feed.xml` carries a section, `/tags/{tag}/feed.xml` falls out
of the same Query.
Why: cross-type discovery is the point of a single-author site — one tag spanning a comic, a poem and a
photo essay is a feature. Per-section tag pools would fragment that for a readability problem better
solved by grouping in the View. But a tag is free-form and cross-cutting, while a structural taxonomy has
a fixed term set and changes what the engine does; conflating them makes both worse.
Consequence: cheap — one Query with an optional section predicate serves tag pages, section tag pages and
their feeds. Expensive — tag hygiene is now the author's discipline, since nothing scopes them; the
content `check` command should report near-duplicate terms.
Revisit if: the tag pool becomes unusable in practice — and then the answer is curation, not namespacing.
## ADR-0019 — Templates: per-type sets, block-level override, cascade selection
Date: 2026-07-28 · Status: accepted
Decision: one parsed template set per type, each set being base plus partials plus that type's
definitions. A site root override is parsed *after* the embedded defaults into the same set, so it may
redefine a single named block and inherit everything else. Which set renders a bundle resolves through the
cascade (ADR-0017): type default, section override, then the bundle's own `view`.
Why: `html/template` has no `extends` — inheritance is "last definition of a name wins in a parsed set",
so a global set makes two types defining `main` collide, and per-type sets are the only clean answer.
File-level override would force copying a whole template to change one block, after which it stops
inheriting engine improvements; block-level costs a few lines of parse ordering and keeps site overrides
minimal.
Consequence: cheap — a site writes only what it changes, and item-level overrides need no new mechanism.
Expensive — parse order becomes load-bearing and must be asserted in a test, since a silently wrong order
means an override that quietly does nothing.
Revisit if: never cheaply — every template ever written assumes this convention.
## ADR-0020 — YAML is the author-facing format; allowlist `gopkg.in/yaml.v3`
Date: 2026-07-28 · Status: accepted
Decision: frontmatter and the site declaration (`site.yaml` at the site root) are YAML, parsed by
`gopkg.in/yaml.v3` — one dependency, no transitive requirements, one parser for both.
Why: frontmatter is the author's primary interface with the engine and must behave exactly as they
expect. Go has no stdlib YAML, and the alternatives are worse: JSON is hostile to hand-write, TOML costs
a dependency anyway, and a hand-rolled subset would diverge from real YAML in ways only discovered while
writing a post. This is the case `conventions.md`'s "40 lines over a dependency" rule does not cover —
the 40 lines would be wrong in edge cases the author cannot predict.
Consequence: cheap — one parser for frontmatter, the site declaration and the cascade; anchors and
multi-line strings work as authors expect. Expensive — the third allowlist entry, and YAML's own traps
(the Norway problem, tabs) become the engine's to document rather than to invent.
Revisit if: the parser proves a maintenance burden, or the author-facing format changes — and the second
requires migrating every existing file.
## ADR-0021 — The default locale's suffix is optional, permanently
Date: 2026-07-28 · Status: accepted (supersedes ADR-0004's instruction to adopt `slug.en.md` from day one)
Decision: a missing language suffix means the default locale, always — not merely while one language
exists. `about.md` and `about.en.md` name the same variant and the parser accepts both. Identity still
excludes language (ADR-0004's actual point, unaffected).
Why: ADR-0004 told the *author* how to name files, which is not the engine's business — the site root
belongs to its owner (ADR-0011). The engine's job is to accept both spellings and derive the same bundle
key from either. Requiring a suffix bought nothing: identity is already language-free by construction.
Consequence: cheap — nothing in the harness prescribes a filename any more, and a single-language site
never types a suffix. Expensive — the parser must treat two spellings as one variant and reject a bundle
that supplies both, since that is ambiguous rather than harmless.
Revisit if: never. This removes a rule rather than adding one.
## ADR-0022 — Content change is detected by polling the site root
Date: 2026-07-28 · Status: accepted
Decision: the engine detects change by walking the site root and comparing modification times, on an
interval set by one flag (`-poll`, zero to disable for immutable deployments), and acting only after a
settle window with no further changes. Stdlib only — no filesystem-watcher dependency. Fetching is not
the engine's job: something on the host updates the site root, and the engine notices. The site root is
therefore a mounted volume in the container, never baked into the image.
Why: the engine already runs against a directory, so the filesystem is the obvious signal, and it needs no
inbound endpoint — the untrusted surface stays empty until webmentions or Micropub actually arrive. A
watcher would cost a dependency for what a short walk does, and inotify is unreliable on exactly the
places this will run: bind mounts, overlay and network filesystems. A poll is boring and works everywhere.
Consequence: cheap — change detection is the `on change` trigger from ADR-0012 with nothing new, and a
template edit in the site root invalidates through the same path as content. Expensive — a checkout writes
many files, so the settle window is required rather than optional; editor droppings and `.DS_Store` must
be ignored or the engine re-renders continuously; and detection latency is bounded by the interval.
Revisit if: a site grows large enough that walking it costs real time — and then the fix is a cheaper
signal, not a watcher dependency.
## ADR-0023 — Three edit surfaces; this repo is bound to the theme contract, not the theme
Date: 2026-07-28 · Status: accepted
Decision: engine source, content, and theme are three separate surfaces with three separate owners. This
repository holds the engine and is bound to the **theme contract** — the data available to templates, the
template and block names it looks for, the helpers it provides, the URLs it emits — recorded in
`docs/theme-contract.md`. It is not bound to any theme's markup, layout, or styling. Where a theme comes
from (a directory in the site root, its own repo checked out into place) is deliberately outside this
repo's concern: the engine knows a path and a contract.
Why: a request that reads as one feature is often split — "supporting files listed in a sidebar" is a
contract change here and a layout decision there. Absorbing the theme half into the engine puts markup
and presentation choices into the core, which grows it against invariant 9 and makes the theme
unswappable. Keeping the boundary at the contract is what lets the theme change without an engine release
and the engine change without rewriting a theme.
Consequence: cheap — a split request produces a contract extension plus a written note of what the theme
must do, and the theme side is somebody's separate change. Expensive — the contract is now a published
interface: fields may be added, never renamed or removed, which is the View-layer freeze in
`architecture.md` arriving earlier than Arc 2. Embedded default templates are a reference implementation
of the contract, not the contract itself.
Revisit if: never usefully. Merging the surfaces is how a publishing engine becomes one site's code.
## ADR-0024 — Bundle-local assets inherit the bundle's publish status; `-dev` reveals
Date: 2026-07-29 · Status: accepted
Decision: every byte served from inside a bundle — body, cover image, any local asset, anything under the
extras directory — inherits that bundle's publish status, derived per request rather than stored. An
unpublished bundle and all its assets answer **404**, never 403. Reaching asset bytes without having
resolved the owning bundle is structurally impossible, not merely discouraged: one guard, every route.
`-dev` (default off) reveals drafts and future-dated bundles with their assets, and is the only thing that
changes the answer.
Why: without this, a draft's `cover.jpg` is world-readable while its page is not, and notes about
unfinished work leak through the asset path — which looks like static file serving, and static file serving
looks like it needs no context. That is the shape this bug always takes. 403 would confirm the work exists,
which for drafts is itself the thing worth not leaking.
Consequence: cheap — visibility is one derived predicate with no state to keep in sync, and a future-dated
bundle's 404 carries `valid-until` = its publish time (ADR-0013), so it expires exactly when it should
rather than needing a sweep. Expensive — no route may serve bundle bytes by path alone, so a fast static
path for assets is off the table; and `-dev` becomes security-relevant, so it must default off and be
obvious when on.
Revisit if: never. The generalisation from "extras are public" to "assets inherit visibility" is the whole
point of the entry.
## ADR-0025 — Extras: local assets, enumerated and browsable
Date: 2026-07-29 · Status: accepted
Decision: a bundle may hold a directory of supporting files — default `extras/`, named by a cascade key —
which the bundle scanner **skips entirely**: a `.md` in there is an asset, never a bundle. The engine
enumerates it as a tree, classifies each entry by extension, renders the ones it can (markdown, plain
text), and serves the rest as bytes behind the ADR-0024 guard. Two behaviours on one route:
`…/extras/{path}` renders the listing with that entry selected, `?raw` returns the bytes. Sorted by
filename; excluded from feeds, queries and search.
Why: drafts, notes and logs are worth publishing as artefacts of the process, and they are not bundles —
no frontmatter, no identity, no language variants. `architecture.md` already defines a Bundle as carrying
local assets; the only thing missing was enumerating them instead of merely referencing them relatively.
Consequence: cheap — no new primitive, and selecting an entry is an ordinary link, so the whole feature
works without JavaScript. Expensive — the scanner needs an exclusion rule it did not have, and the extras
segment becomes a name no child of a bundle may use, which is why it is a cascade key rather than a
constant.
Revisit if: extras need per-file metadata — and then they are bundles after all, and this entry was wrong.
## ADR-0026 — The engine ships a minimal reference theme
Date: 2026-07-29 · Status: accepted
Decision: the binary embeds a reference theme — templates plus one small stylesheet — sufficient to render
every declared type and the extras view, with semantic HTML and no JavaScript. It exists to make the theme
contract executable: a bare site root renders, and a golden-file test through it catches contract
regressions. It is deliberately not a design: legibility only, no branding, no visual opinions, and it
demonstrates every contract feature and nothing more.
Why: a contract nobody implements is a contract nobody has tested. Without a reference theme, the first
real theme discovers the contract's gaps, and `docs/theme-contract.md` stays aspirational. It also means
someone can point the binary at a folder of Markdown and see a site, which is the whole promise.
Consequence: cheap — templates and CSS are not Go, so they cost nothing against `CORE_LOC_MAX`, which is
the same incentive that pushes presentation out of the core. Expensive — the reference theme is a
maintenance obligation that grows with the contract, and if it ever becomes handsome it becomes the theme
nobody replaces, which is why "minimal" is a rule and not a preference.
Revisit if: it starts accumulating design decisions — then split it into a reference theme and a real one.
## ADR-0027 — Feature locality: one directory, no sibling imports, a mandatory `doc.go`
Date: 2026-07-30 · Status: accepted
Decision: a feature is one directory under `internal/ext/<name>/` from its first use, plus one line in
`cmd/atelier/wire.go` — the only file that knows every feature. No `internal/ext` package may import
another. Every one carries a `doc.go` stating, in four lines: what it contributes, which cascade keys it
reads, which theme-contract fields it adds, and what it deliberately does not do. A feature may import
`internal/content` and `internal/render`; never `internal/web`, never `cmd/`, never a sibling.
Why: the cost that matters when an agent adds a feature is how much of the codebase it must read first.
Sibling imports are what make a read set compound — understand one feature, then two, then four. A
`doc.go` in a fixed shape turns orientation into a fifteen-line read instead of a two-hundred-line one.
Routes reach `web` by being assembled in `wire.go`, so `web` never learns features exist and the
dependency arrows hold.
Consequence: cheap — adding a feature is a new directory and one wiring line, so the diff is local and
the read set is roughly the extension types plus `wire.go`. Shared logic between two features must move
inward (earning it under the counters) or stay duplicated until the third use, which is the existing rule
rather than a new one. Expensive — `wire.go` absorbs all the coupling on purpose and will look
repetitive; and this buys cheap *features*, not cheap spine changes, which still need the core read.
Revisit if: `wire.go` becomes hard to read, which means the registry from `extensions.md` is due.
+72
View File
@@ -0,0 +1,72 @@
# Exploration catalog
Possible futures. Nothing here is committed. Presence in this list is not permission to build.
`★` = highest fit-to-effort for this project's identity and values.
## The leaf/trunk test — apply before any of this gets built
Definitions: `architecture.md` invariant 7. **Leaf** → buildable when wanted. **Trunk** → waits for an
explicit human decision, in an ADR, on purpose.
Known trunks: ActivityPub, IPFS, Solid, hosted annotation servers, CRDT-backed editing.
Sovereignty test for any of them: *if this vanishes, do I lose data, or only convenience?*
Use `/leaf <topic>` to get a verdict written into this file.
## Verdicts
| Item | Verdict | Reduces to | Note |
|---|---|---|---|
| (empty — filled by `/leaf`) | | | |
## Catalog
**IndieWeb** — Micropub★, Microsub, Webmention, WebSub, microformats2, IndieAuth, rel=me,
PESOS/POSSE, Bridgy and Bridgy Fed, Vouch.
**Protocols** — Gemini + gemtext★, Gopher, Nostr (NIP-23 long-form), ActivityPub *(trunk)*,
AT Protocol, SSB, Solid *(trunk)*, IPFS *(trunk)*, Hypercore,
content negotiation (one URL → many formats).
**Small web and discovery** — webrings, guestbooks, 88×31 buttons, finger, tildeverse,
OPML blogroll★, full-text feeds★ (for indie search indexing: Marginalia, Kagi Small Web,
ooh.directory, indieblog.page, feedle, powRSS), random / "surprise me", `/now` pages.
**Reading and annotation** — Web Annotation + Hypothesis *(hosted = trunk)*, reading progress,
interlinear and parallel translation.
**Permanence** — outbound-link archiving★, WARC self-archive, Memento over git, permalink
discipline, exposed revision history.
**Identity and integrity** — rel=me, Keyoxide, signing (age/minisign/PGP), C2PA, DIDs.
**Feeds and data** — RSS/Atom, JSON Feed, h-feed, OPDS (publication catalog), JSON-LD and
schema.org, OpenGraph, Dublin Core, sitemaps, RSS-to-email.
**Page as program** — Ink★, Twine, Inform 7, PICO-8/TIC-80, Godot HTML5 embeds, explorable
explanations, TiddlyWiki and single-file artifacts.
**Print** — Typst, Pandoc, EPUB and PDF chapbooks, on-demand anthologies.
**Client side** — View Transitions★ (palette-shift-as-navigation), Houdini, Canvas, WebGL,
WebGPU, modern CSS, WASM, islands, HTMX, Web Components, PWA offline, prefetch,
accessibility and `prefers-reduced-motion`.
**Build time** — SQLite FTS5, Pagefind, sqlite-wasm + OPFS, Djot, Templ, libvips, ThumbHash and
BlurHash, content-addressed and Merkle builds, Datasette, CRDTs *(heavy, trunk)*.
**Aesthetic** — dithering and palette Stage★, ANSI/PETSCII, teletext, demoscene, e-ink View.
**Values**`robots.txt` / `ai.txt` (training control), `llms.txt` (low proven impact),
sustainable and low-carbon web, privacy-respecting analytics or none at all, sovereignty,
sneakernet and QR offline distribution.
## Standing notes
- **Bengali tokenisation for search** is the one item here nobody else will solve for you.
Treat it as original work, not as a checkbox inside "add search."
- **Micropub before an admin panel.** It buys an ecosystem of existing editors for the price of
one endpoint, and it composes with git instead of fighting it.
- **Gemini output** is nearly free once the View layer exists, and it validates the claim that a
View can target something other than HTML. Good early proof, low cost.
+95
View File
@@ -0,0 +1,95 @@
# Extensions
The plugin story, and the gate keeping it from arriving early.
**STATUS: not buildable yet.** A feature is its own directory under `internal/ext/<name>/`, called
explicitly from `wire.go` (ADR-0027) — correct and sufficient until the counters say otherwise. This document exists so the eventual shape is known, not
so it can be built now.
## The gate
| Stage of growth | What a feature looks like | Trigger to advance |
|---|---|---|
| Now (02 features) | Its own directory under `internal/ext/<name>/`, called explicitly from `wire.go` | — |
| Transform counter due | Extract the Stage pipeline: an ordered `[]Stage` in one wire file | `state.md` |
| Extension counter due | Extract the `Extension` struct below; move each into `internal/ext/<name>` | `state.md` |
| After Arc 2 | Composition only; the core no longer grows | Arc 2 closes |
`state.md` holds the thresholds and is the only place they are written. Do not extract early. Do not
"prepare".
## Target shape
Compile-time registry. No `plugin.so`, no `init()` side effects, no discovery, no config file listing
plugins. One slice, one file, source order — the order *is* the semantics.
Compile-time is not a preference: Go's `plugin` package forbids a static binary and demands an exact
toolchain match, which the container target (ADR-0010) rules out. Dynamic loading buys only
extension-without-recompiling, worth nothing to the single author (ADR-0006) holding commit access.
The registry costs a few hundred lines that render zero pages — hence real callers before a contract.
```go
// internal/ext/ext.go — the whole contract, once earned.
type Extension struct {
Name string
Stages []Stage // ordered; Phase decides placement
Views map[string]View // named, referenced by frontmatter `view:`
Shortcodes map[string]Shortcode // trusted content only (ADR-0003)
Effects []Effect // derived artifacts and outbound calls, off the request path
Adapters []Adapter // Interaction sources, Arc 3
Routes []Route // additional URL cases, via the resolver
}
```
`cmd/atelier/wire.go` holds the only list of enabled extensions. Enabling or disabling one is a
one-line diff and a rebuild. Removing one leaves no trace elsewhere — that property is the test of
whether the contract is right.
## Stage phases
An ordered list, not a dependency graph. Two stages needing a graph to be correct are one stage
wearing a disguise.
| Phase | Operates on | Examples |
|---|---|---|
| `PhaseLoad` | raw bytes + frontmatter | includes, translation fallback |
| `PhaseParse` | the parsed Markdown tree | shortcodes, transclusion, image derivatives |
| `PhaseMarkup` | rendered HTML fragments, code spans skipped | smart quotes, dashes, widows, Bengali numerals |
| `PhasePage` | the assembled page object | OpenGraph, JSON-LD, related posts, series nav |
| `PhaseOutput` | the final byte stream | minification, dithering, gemtext conversion |
Every Stage runs on every bundle unless the cascade disables it (ADR-0017), and declares its trust
requirement. A Stage evaluating templates or shortcodes runs in trusted mode only, and the pipeline
refuses it otherwise — enforced in code, not by convention, and
tested with an untrusted-input case.
## Rules for any extension
1. Deletable without trauma. Removing the package leaves the engine building and serving.
2. Reads only what already exists on the page; adds through the `Extra` bag, never by widening the
core struct for its own convenience.
3. Owns its output files under a namespaced path, or none.
4. No new dependency without an ADR — extensions get no looser budget than the core, and their Go
lines count against `EXT_LOC_MAX`. Presentation features (OpenGraph, galleries, series nav,
related posts) belong in templates and frontmatter where they cost nothing; reach for Go only when
there is real logic.
5. Failure degrades: a broken extension logs and is skipped, never takes a request down.
6. Needing a permanent external service or a primitive change makes it a **trunk** — see
`exploration.md`.
7. One directory, no sibling imports, and a `doc.go` in this shape (ADR-0027):
```go
// Package feeds emits RSS and Atom for the primary feed and each section.
//
// Contributes: Effect (on change).
// Cascade keys: feeds.enabled, feeds.limit.
// Contract fields: none.
// Not doing: JSON Feed, WebSub — separate features if wanted.
package feeds
```
## ContentAPI
A thin internal write path, introduced **with comments** in Arc 3 and not before. It exists so a
second client (admin panel, Micropub endpoint) becomes possible without the engine growing a UI.
Read paths keep going straight to the filesystem; git remains the source of truth.
+72
View File
@@ -0,0 +1,72 @@
# 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.
## Arc 2 — earn the primitives at first real use
In this order, each at its trigger, never before:
1. **Query** — at the first collection page (section index, latest posts).
2. **Stage pipeline** — when the transform counter is due.
3. **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.
## 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.md` decide, 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.md` invariant 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.
+54
View File
@@ -0,0 +1,54 @@
# State
**Verified against:** `<commit sha>` on `<date>` — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
No Go source, no `go.mod`. The harness is installed; the engine is unwritten. This repo holds engine
source only — the site root is external and passed with `-site` (ADR-0011).
Dependencies: none.
## Counters — the earn-it authority
Never anticipate a threshold. Increment when the code lands, then check whether the extraction is *due
this change*.
| Counter | Now | Extraction due at | What it buys |
|---|---|---|---|
| Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) |
| Routing cases | 0 | **2** | Resolver extraction |
| Collection pages | 0 | **1** | Query primitive |
| Views / output formats | 0 | **2** | View layer (contract per `theme-contract.md`) |
| Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) |
| Extensions | 0 | **3** | Extension registry + wire file (`extensions.md`) |
| Interface implementations | — | **2** | The interface itself |
| Non-stdlib dependencies | 0 | budget in `scripts/budgets.env` | — |
Allowlisted but not yet required: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
`gopkg.in/yaml.v3` (frontmatter, ADR-0020).
## 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 |
|---|---|---|
| Path traversal guard on URL → file mapping | Not yet internet-facing | **Before first public deploy — hard blocker; the target is a real server (ADR-0010). Nothing mechanical enforces this — `verify.sh` does not read this list. Make it a table-driven test when the first file read lands.** |
| No mechanical check that the counters are *correct* | The coupling gate makes forgetting them impossible, which is the real failure mode; checking values needs code to count | 3rd transform or 2nd route |
| No mechanical gate on the untrusted boundary (ADR-0003) | Nothing untrusted exists yet | The comment path, Arc 3 — a test that untrusted input reaches no shortcode or template evaluation |
## Open questions blocking Arc 1
None. Every decision the engine needs before Arc 1 and before the first deploy is recorded.
Every ADR in `decisions.md` is accepted; none is open or proposed.
## Arc retro log
One line per completed arc: what it cost, what it taught, what it made unnecessary.
- (empty)
+79
View File
@@ -0,0 +1,79 @@
# Theme contract
What the engine promises a theme, and the only thing this repository is bound to (ADR-0023). A theme's
markup, layout and styling are not the engine's business; a theme's *inputs* are.
**STATUS: not built.** This is the shape the contract takes when the first template renders. Everything
here is engine obligation, not theme instruction — a theme may ignore any of it.
## The stability rule
Fields and names are **added, never renamed or removed**. Absence is always legal: a template reading a
field that does not exist gets the zero value and must not crash, and the engine must not make a missing
field fatal at request time (ADR-0002, invariant 1). This is the View-layer freeze from
`architecture.md`, arriving as soon as a theme exists rather than at Arc 2.
Breaking the contract is not a feature — it is a new contract version, and it needs an ADR.
## What the engine provides
| Provides | Detail |
|---|---|
| the page object | known fields plus an `Extra` bag carrying unknown frontmatter (ADR-0002) |
| the resolved cascade | settings for this bundle after site → section → bundle resolution (ADR-0017) |
| queries the page needs | its sequence neighbours, its taxonomy terms, its section's members |
| named template lookup | per-type sets; a theme redefines a named block and inherits the rest (ADR-0019) |
| URLs | every path the engine emits, so a theme never constructs one by hand |
| per-page assets | the `styles` / `scripts` frontmatter lists, resolved relative to the bundle |
| chrome strings | looked up by key and language, never hardcoded English in a template |
| validity windows | a template that renders time-dependent output declares one (ADR-0013) |
## Extras view
For a request under a bundle's extras directory (ADR-0025) the engine additionally provides:
| Field | Contents |
|---|---|
| `.Extras.Entries` | the tree: `Name`, `Path`, `URL`, `RawURL`, `Kind`, `Size`, `IsDir`, `ModTime` |
| `.Extras.Selected` | nil on the bare listing; otherwise the chosen entry |
| `.Extras.Selected.HTML` | rendered output for `markdown` and `text` kinds; empty otherwise |
| `.Extras.Selected.RawURL` | always present — for `<img src>`, `<object data>`, or a download link |
`.Bundle` is the parent, so a theme has its title, language and breadcrumb. Selecting an entry is an
ordinary link and a full re-render, so a sidebar-plus-pane layout needs no JavaScript; swapping the pane
client-side is a later enhancement over working markup, never a requirement.
## What the engine does not provide
Layout, class names, CSS, client-side behaviour, and any choice about how something *looks* — including
how media is embedded, how a listing is arranged, and whether something appears in a sidebar. Those are
theme decisions, and a request touching them is split: a contract extension here, a change there.
The engine also does not provide a component library, a CSS build step, or a JavaScript runtime.
`conventions.md` holds the output floor the engine itself meets — semantic HTML, zero-JS, every image
carrying width, height and alt — and a theme is expected to stay inside it, but the engine does not
enforce a theme's markup.
## The reference theme
The binary embeds a reference theme — templates plus one small stylesheet — so a bare site root renders
(ADR-0026). It exists to make this document executable: it implements every field and block named here and
nothing else, and a golden-file test through it catches contract regressions before a real theme does.
It is not a design. Legibility only, no branding, no visual opinions, no JavaScript — `verify.sh` fails on
a `<script` tag in it. If it starts accumulating taste, it has stopped being a reference.
It demonstrates the contract; it is not the contract. Changing it does not change what a theme may rely on
— which is why `verify.sh` also fails when it changes without this document changing, since in practice the
two drift together.
## Splitting a request
When a feature spans engine and theme, this repo delivers:
1. the contract extension — new fields, new named blocks, new queries, new URLs;
2. a note stating what a theme must do to use it, precise enough to act on without this conversation;
3. embedded defaults updated far enough to prove the extension works.
It does not deliver the theme. Claiming otherwise is the same error as claiming to have migrated content
in a site root this repo cannot see.
+45
View File
@@ -0,0 +1,45 @@
# Toolchain and external contracts
What this harness was built against, and which of those are contracts it depends on rather than
incidentals. Recorded because a failure long after the fact is otherwise indistinguishable from a bug in
the harness itself. Update the version line when you deliberately move; leave the rest alone.
**Verified working:** 2026-07-28
| Thing | Version then | Harness depends on |
|---|---|---|
| Go | 1.26.1 (darwin/arm64) | `go list -f`, `go vet`, `go build`, `go test -race`, `gofmt -l`. All stable since 1.11 |
| git | 2.53.0 | `status --porcelain -uall`, `rev-list <sha>..HEAD -- '*.go'`, `rev-parse --verify`, `core.hooksPath` (2.9+) |
| Claude Code | 2.1.119 | the four formats below |
| bash | macOS default (3.2-era) + BSD userland | `verify.sh` avoids bashisms and GNU-only flags; `grep -L`, `wc -l`, `xargs`, `awk` are POSIX use |
## The four agent-tooling contracts
These are the ones most likely to move, and the ones whose breakage looks like a harness bug:
1. **`CLAUDE.md` is loaded automatically** from the repo root on every request. If that stops being true,
nothing enforces the constitution and the agent will behave like a stock assistant.
2. **`.claude/skills/<name>/SKILL.md`** — a Markdown file in a directory named for the skill, which fires
on request content matching its description. The feature loop depends on firing *without* being
invoked; if skills come to need explicit invocation, the loop silently stops applying.
3. **`.claude/commands/<name>.md`** — Markdown with a YAML frontmatter `description:`, invoked as
`/<name>`, `$ARGUMENTS` substituted. Used by `/verify`, `/audit`, `/leaf`, `/refresh-docs`, `/adr`,
`/invariants`.
4. **`.claude/settings.json`** — a `permissions` object with `allow` / `ask` / `deny` arrays of
`Tool(pattern)` strings. Only a convenience: if the schema changes, the harness still works, you just
get more prompts.
## Not depended on
No CI service, no container registry, no language server, no linter binary, no package manager beyond
`go mod`. `verify.sh` runs offline with nothing but Go and git on `PATH`, which is deliberate — the gate
must work on a machine you have not configured for two years.
`VERIFY_DOCKER=1` adds a container build step and needs `docker`; it is opt-in and off by default.
## If something breaks after a long absence
Check in this order, cheapest first: `go version` and `git --version` against the table above; then
whether `verify.sh` fails in the `documentation` block (a harness-internal problem) or the `go` block (a
toolchain or code problem); then whether the agent is still reading `CLAUDE.md` at all — ask it to quote
a hard rule, and if it cannot, the problem is contract 1 and nothing downstream will behave.
+44
View File
@@ -0,0 +1,44 @@
# Ideas — parked, not dropped
One file per idea, self-contained enough to resume cold. Nothing here is committed to; presence is not
permission to build. An idea file exists so picking the thread back up costs **one read instead of one
re-derivation** — the whole justification for writing it while the context is still in hand.
## What goes where
| It is… | Where |
|---|---|
| an engine feature you might build (webmentions, gemtext, galleries) | `docs/exploration.md` catalog line, `/leaf` for a verdict |
| that same feature, but with a discussion worth not repeating | an idea file here, linked from the catalog line |
| the harness, agent workflow, token cost, tooling, process | here — `exploration.md` is engine-only |
| a decision already made | `docs/decisions.md` |
| a known code flaw deliberately unfixed | Latent list in `docs/state.md` |
| a durable fact, number, or link | `reference/` |
## Format
Five headings, present tense, terse. Copy an existing file. Required first lines:
```
# <title>
Status: parked | active | adopted → <where> | dropped: <one-line why>
Raised: YYYY-MM-DD
```
Keep `dropped` files — a one-line reason is the cheapest defence against relitigating it in four
months.
## Reading discipline
Exploratory scratch code may live here too; `verify.sh` never formats, vets, builds or budgets it, so
it does not have to compile.
Out of agent context by default, **this index included**. A file is opened when the human names that
idea, not otherwise: not swept, not listed to see what is here, not cited unasked. This index is for
you, not the agent; `verify.sh` keeps it honest without reading it into context.
## Index
- [specs-as-secondary-artifacts.md](specs-as-secondary-artifacts.md) — optional per-feature specs, derived by default, plus the named-test convention. **parked**
- [engine-design-review.md](engine-design-review.md) — open design decisions for a multi-type site; items graduate to ADRs one at a time. **parked**
- [token-conservation.md](token-conservation.md) — cut agent token cost without losing output quality. **parked**
+50
View File
@@ -0,0 +1,50 @@
# Engine design review — open items
Status: parked
Raised: 2026-07-28
One review, many decisions. Each item below graduates independently: when one is settled it becomes an
ADR (or a change to its owning doc) and is struck from this list. When the list empties, delete the
file. Graduated so far: site root external to the engine repo (ADR-0011), Effect as the sixth
primitive (ADR-0012), the cache validity model (ADR-0013), declared content types (ADR-0014),
NFC normalisation and slug derivation (ADR-0015), sequence position (ADR-0016), the settings cascade
(ADR-0017), taxonomies and feeds (ADR-0018), template composition (ADR-0019), feature locality (ADR-0027).
## Why it came up
The harness was written for a generic blog engine and then aimed at a site hosting blog posts,
webcomics, art, literary writing, standalone pages and IndieWeb status notes, with a featureset meant
to keep evolving. Several things that are cheap to decide now get expensive once content exists or the
first URL is published.
## Decide before Arc 1 writes code
**Pagination URL shape.** Undecided and permanent — `/posts/page/2/` versus a query parameter. ADR-0008
settled the bundle shape only, and ADR-0016 settled that a slug may contain slashes; this is the last
open piece of the permalink space.
## Decide during Arc 12, as the code arrives
**UI chrome strings and Bengali collation.** i18n covers content variants, not chrome ("Next chapter",
"Read more") — a small message catalog, decided before templates multiply. Separately, byte sort is
wrong for Bengali indexes: adopt collation, or decide explicitly that ordering is date-only.
## Cheap, whenever
**A `check` command in the engine.** Missing required fields per type, image without alt, broken
internal link, dangling `series` reference, orphaned asset, slug collision, non-normalised filename,
alias colliding with a live URL. A walk over already-loaded bundles. Explicitly *not* `verify.sh`:
that gate answers "is the engine sound", this answers "is my content sound", and it runs against a
site root the engine repo cannot see.
**Search as a static artifact.** A prebuilt index file shipped with the site needs no running service
— the most sovereign option available, and it satisfies ADR-0010 trivially because the index is
derived. An Effect (ADR-0012) builds it.
**No-JS reading experience.** Server-rendered prev/next always present; keyboard navigation and
prefetch as enhancement only. Decide before the comic reader exists, not after.
## Dropped
Binary assets in git. Now purely a decision about the site repo, outside the engine repo entirely, so
the harness has no stake in it.
+87
View File
@@ -0,0 +1,87 @@
# Specs as secondary artifacts, and named tests
Status: parked
Raised: 2026-07-28
## Why it came up
The harness is deliberately not an SDD framework: the goal is to maintain content and trust the harness
to keep the code minimal and maintainable, rather than to author requirements for every change. But two
things are missing that SDD would have given for free — a place to put a well-defined spec when one
already exists, and any trace from a code change back to the behaviour it was supposed to produce.
Proposal: per-feature specs as **secondary** artifacts. ADRs, `state.md` and the engine-wide contracts
stay primary. A spec may be written first (true SDD for a well-defined feature) or generated after the
fact from what was requested and what was built. Either way it can then be edited, and the edit is
implemented — flowing upward into the harness where it touches anything the harness owns.
## What is already established
- The harness has no per-feature artifact. A plan lives in the conversation; what survives is an ADR
plus a `state.md` delta. There is no way to ask later "which behaviour was this code meant to have".
- Tests are mandatory for `cmd/` and `internal/` changes but nothing ties a test to the rule it verifies.
`content-model.md` says a bundle key excludes language, and that a bundle supplying both `about.md`
and `about.en.md` is rejected — nothing checks a test exists for either.
- One fact, one place is enforced throughout (`docs/README.md` authority table). Specs must not become a
fourth copy of decisions.
## The non-overlapping slot
The only content a spec should hold is what nothing else does:
| Artifact | Holds |
|---|---|
| ADR | why a load-bearing choice was made, permanently |
| `state.md` | what exists right now |
| `architecture.md`, `content-model.md` | engine-wide shape and contract |
| **spec** | **per-feature observable behaviour: given X, the engine does Y** |
Acceptance criteria, in other words — which is also exactly the traceability gap. So the cheapest useful
spec is a list of criteria whose names are the test names, and the named-test convention below is the
subset of this proposal that can ship on its own.
## The rule that stops it rotting: two states, one owner each
A generated spec describes what exists; an edited spec describes what should exist. Flipping between
those per edit, with nothing marking which, is how spec systems rot — after three round trips nobody
knows whether a clause is a record or a requirement. So each spec carries a status and it cycles:
- **`derived`** — describes what exists. The code wins; a mismatch is a spec bug, fixed silently.
- **`authored`** — a human edited it, or wrote it first. The spec wins; a mismatch is unimplemented work.
The next implement pass brings the code into line and flips it back to `derived`.
`verify.sh` can surface `authored` specs as a warning — pending requirements become visible state rather
than a silent backlog. This mirrors the existing "if `state.md` disagrees with the code, the code wins"
rule, which works precisely because the direction is never ambiguous.
## Two things to get right or it is worse than nothing
**A derived spec must not paraphrase the code.** If it restates the implementation it carries no
information, costs tokens every feature, and goes stale instantly. Its value is the half the code cannot
hold: the request as made, the behaviour at the edges, what was deliberately not done. Capture the
request, not the diff.
**Not every change earns a spec.** A spec per one-line tweak is overhead. The natural threshold is the
one already enforced: a change that must ship a test is a change with observable behaviour worth pinning,
and the spec's criteria are that test's names. Changes below that line get an ADR or nothing.
## Upward flow needs no new machinery
An edited spec is just another request, so the existing conflict check (`CLAUDE.md` rule 9) applies
unchanged: contradicts an ADR or an engine contract → hard conflict, stop and surface; is a load-bearing
choice → it produces an ADR; is plain new behaviour → code plus spec, nothing else. "Flows upward" means
the spec is an *entry point* to the loop, not a parallel process.
## Cheapest next step
Two independent pieces, in order:
1. **Named tests, alone.** A line in `conventions.md`: a rule stated in an engine contract earns a test
named after it — `TestBundleKeyExcludesLanguage`, `TestDuplicateVariantRejected`. Zero
infrastructure, most of the traceability, and it makes the spec step later almost free because the
criteria already exist as test names.
2. **`specs/<feature>.md`**, one file per feature with a `Status:` line, an index like `ideas/`, excluded
from the Go gates the way `ideas/` and `reference/` are, read on demand and never swept.
Open question if this is adopted: whether a spec is ever deleted. Suggest not — a `derived` spec for a
shipped feature is the closest thing to living documentation of behaviour, and it is cheap to keep.
+49
View File
@@ -0,0 +1,49 @@
# Token conservation
Status: parked
Raised: 2026-07-28
## Why it came up
Development pace is limited by session and usage limits, not by typing speed. Cutting token cost
per feature buys more features per session. The goal is explicitly *conserve tokens without
lowering output quality* — not "be terse and worse".
## What is already established
Do not re-derive these.
- **Turn count dominates, not file size.** The whole context is re-sent on every tool call, so a
six-call verification sequence costs roughly 6× the context. Batching is the biggest lever.
- **`CLAUDE.md` is the most expensive file per byte in the repo** — it is re-sent every turn.
- **Agent prose is a large, entirely self-inflicted cost.** The target is density, not brevity: same
information, fewer words. Length follows content; padding, restatement, hedging and rhetorical
closers do not.
- Two accepted rules cost tokens on purpose and are worth keeping: hard rule 8 (docs ship with the
change) and the topic-ownership read floor. The ownership table is what keeps the floor cheap —
it sends you to one doc instead of a grep sweep.
## The six mechanisms, ranked by saving
1. Batch tool calls — one Bash call per phase, not per command.
2. Prose density in reports and replies — cut restatement and flourish, keep every finding, number and
caveat. Biggest win, costs nothing. Not a length cap: truncating information is not a saving.
3. Line ceiling on `CLAUDE.md` in `budgets.env`, gated like any other budget.
4. Targeted reads — `grep -n` plus `Read offset/limit`; whole-file reads only for the doc that
owns a rule being asserted.
5. Evidence proportional to risk — one case for a behaviour change, a full matrix only for a gate
or a security boundary.
6. Never re-read after `Edit`; run `verify.sh` once per feature, at the end.
## Open question — needs the human
**Subagents for fan-out search.** Dispatching "find every place X appears" to a subagent keeps its
file dumps out of the main context: you pay for the conclusion, not the search. A real multiplier
on exploration-heavy work. Currently disallowed unless explicitly requested, so it is a policy
call, not a judgment call.
## Cheapest next step
Apply 16 as one batched change: `CLAUDE.md` (efficiency rule), `SKILL.md` (report cap, evidence
proportionality), `budgets.env` + `verify.sh` (`CLAUDE.md` line ceiling). Trips the `HARNESS.md`
coupling gate by design. Estimated one feature-sized change.
+20
View File
@@ -0,0 +1,20 @@
# Reference — facts worth keeping
Durable information surfaced in conversation that would otherwise be lost: measured numbers, external
constraints, how a tool actually behaves, links worth having. One file per subject.
Not decisions (`docs/decisions.md`), not proposals (`ideas/`), not the present state of the code
(`docs/state.md`). A fact only true today belongs in `state.md`; a rule belongs in the doc owning the
topic.
Each file states **how the fact was established** — measured, read from a spec, or asserted — so a
later reader knows whether to trust it or re-check it. An unattributed number is a rumour.
Scratch code illustrating a fact is welcome; `verify.sh` ignores `.go` here entirely, so it need not
compile.
Out of agent context by default, this index included. Opened only when the human names the topic.
## Index
- [agent-session-costs.md](agent-session-costs.md) — how context and token cost accumulate in an agent session
+39
View File
@@ -0,0 +1,39 @@
# How agent session cost accumulates
Established: mechanism, asserted from how the tool loop works — not measured in this repo.
Recorded: 2026-07-28
## The model
An agent turn sends the entire conversation context to the model, every time. A tool call is a
turn. So the cost of a task is roughly:
```
total ≈ (number of tool calls) × (average context size)
```
Both factors matter, but the first is the one usually left on the floor. Consequences that follow
directly and are easy to get backwards:
- **Ten small commands cost more than one large one.** Batching independent reads into a single
message, or chaining shell commands into one call, cuts turns without cutting information.
- **An always-loaded file is multiplied by every turn in the session.** A 100-line constitution over
40 turns costs more than a 2000-line doc read once. Per-byte, the always-loaded file is the most
expensive in the repo; every other doc is read on demand and paid for once.
- **Output length is paid twice** — once as generated tokens, then again as context on every
subsequent turn of the session.
- **A file read mid-session stays in context.** Reading speculatively is not a one-off cost; it
raises the floor for the rest of the session — the mechanical reason behind `CLAUDE.md` §1's read
discipline.
- **A subagent's context is separate.** Its file dumps never enter the parent context; only its
conclusion does. This is why delegating fan-out search is a real multiplier rather than a
wash.
- **Rework is the most expensive thing available.** A wrong assumption discovered late costs the
whole re-derivation plus the original attempt. Clarifying gates and plan-before-code are token
optimisations, not just quality ones — which is why "read the owning doc first" saves more than
it spends.
## Practical consequence for this repo
Terseness in docs and reports is not a stylistic preference here; it is a budget. See
`ideas/token-conservation.md` for the open proposal on enforcing it.
+8
View File
@@ -0,0 +1,8 @@
# Non-stdlib dependency allowlist. One module path per line; # starts a comment.
# Adding a line requires an ADR in docs/decisions.md. Stdlib first, always.
# Only direct requirements are checked here; the total module count is capped by DEPS_MAX.
# Infrastructure clients (Redis, S3, search) are dependencies like any other and get no exemption.
github.com/yuin/goldmark
golang.org/x/text # NFC normalisation (ADR-0015); collation later if earned
gopkg.in/yaml.v3 # frontmatter + site declaration (ADR-0020)
+19
View File
@@ -0,0 +1,19 @@
# Growth budgets. `_MAX` fails verify.sh; `_WARN` prints and moves on.
# Raising a _MAX needs an ADR stating old and new values. Growth requires a signature.
#
# Only whole-system budgets fail. A hard per-file limit is the one gate whose cheapest fix makes the
# code worse — sharding a coherent file into a `_helpers.go` turns it green while creating the package
# CLAUDE.md rule 3.6 bans. Total mass cannot be gamed by moving code between files; file length can.
CORE_LOC_MAX=2000 # cmd/ + internal/{content,render,web} + repo root, non-test .go
EXT_LOC_MAX=2000 # internal/ext/ — composition, grows after the core freezes
FILE_LOC_WARN=500 # any single .go file — advisory
FUNC_LOC_WARN=60 # any single function — advisory
DEPS_MAX=6 # total modules in go.mod, direct plus indirect
# Two ceilings because "the core stops growing after Arc 2" (architecture.md invariant 9) is only an
# invariant if something measures it: post-freeze CORE holds and only EXT rises.
# Costed, not round: spine, bundles, queries, render, routing, templates ~1200-1800 → CORE 2000.
# Feeds, sitemap, OpenGraph, shortcodes, image sizing, galleries, paging, indieweb ~900-1200 → EXT
# 2000, deliberately loose since that is where growth belongs. Reaching CORE asks what to delete;
# reaching EXT asks whether a template would have done it — only .go lines count here.
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Makes the gate non-optional. Enable once per clone:
# git config core.hooksPath scripts/hooks
# Bypass deliberately with `git commit --no-verify` — and say why in the commit body.
exec ./scripts/verify.sh
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env bash
# The objective gate. No opinions, only exit codes.
# Usage: ./scripts/verify.sh
set -uo pipefail
# --list answers "does this gate actually exist?" without reading the script. Docs that claim a gate are
# checkable against it. The pattern is written pas[s] so this grep does not match its own source line.
if [ "${1:-}" = "--list" ]; then
printf 'gates in %s:\n' "$0"
grep -oE 'pas[s] "[^"]+"' "$0" | awk -F'"' '{print $2}' |
sed 's/\$([^)]*)/…/g; s/\$[A-Za-z_][A-Za-z_0-9]*/…/g; s/^/ /' | sort -u
exit 0
fi
cd "$(dirname "$0")/.." || exit 1
[ -f scripts/budgets.env ] && . scripts/budgets.env
CORE_LOC_MAX=${CORE_LOC_MAX:-2000}
EXT_LOC_MAX=${EXT_LOC_MAX:-2000}
FILE_LOC_WARN=${FILE_LOC_WARN:-500}
FUNC_LOC_WARN=${FUNC_LOC_WARN:-60}
DEPS_MAX=${DEPS_MAX:-6}
fail=0
warn=0
pass() { printf ' ok %s\n' "$1"; }
bad() { printf ' FAIL %s\n' "$1"; fail=1; }
note() { printf ' warn %s\n' "$1"; warn=$((warn + 1)); }
head_() { printf '\n%s\n' "$1"; }
if ! command -v go >/dev/null 2>&1 || ! command -v gofmt >/dev/null 2>&1; then
echo "FAIL go toolchain not found on PATH — cannot verify anything"
exit 1
fi
# --- documentation coupling -------------------------------------------------
# Runs before the Go gates so it still applies while the repo is harness-only.
# Code and the docs describing it move together, or they drift apart silently.
head_ "documentation"
if [ -d .git ] && command -v git >/dev/null 2>&1; then
# -uall matters: the default collapses untracked directories to "dir/", which would hide a
# brand-new internal/ext/feeds/feeds.go from the .go check below.
changed=$(git status --porcelain -uall 2>/dev/null | sed 's/^...//' | sed 's/.* -> //')
if [ -z "$changed" ]; then
pass "working tree clean — nothing to couple"
else
if echo "$changed" | grep -qE '\.go$' && ! echo "$changed" | grep -qx 'docs/state.md'; then
bad "*.go changed but docs/state.md did not — inventory, counters and the verified-against line move with the code"
else
pass "code/state.md coupling"
fi
if echo "$changed" | grep -qE '^(cmd|internal)/.*\.go$' && ! echo "$changed" | grep -qE '_test\.go$'; then
bad "cmd/ or internal/ .go changed but no _test.go did — behaviour ships with a test (conventions.md)"
else
pass "code/test coupling"
fi
if echo "$changed" | grep -qE '^internal/.*templates/.*\.html$' && ! echo "$changed" | grep -qx 'docs/theme-contract.md'; then
bad "embedded templates changed but docs/theme-contract.md did not — they drift together (ADR-0023)"
else
pass "templates/theme-contract coupling"
fi
if echo "$changed" | grep -qE '^(CLAUDE\.md$|scripts/|\.claude/)' && ! echo "$changed" | grep -qx 'HARNESS.md'; then
bad "the harness changed (CLAUDE.md, scripts/ or .claude/) but HARNESS.md did not — the guide to the machine is part of the machine"
else
pass "harness/HARNESS.md coupling"
fi
fi
# The reference theme is a contract demonstration, not a design: zero JavaScript (ADR-0026).
themefiles=$(find internal -path '*templates*' -name '*.html' 2>/dev/null || true)
if [ -n "$themefiles" ]; then
scripted=$(echo "$themefiles" | xargs grep -ln '<script' 2>/dev/null || true)
if [ -n "$scripted" ]; then
bad "<script> in the reference theme — it is a contract demonstration, not a design (ADR-0026): $(echo "$scripted" | tr '\n' ' ')"
else
pass "reference theme is script-free"
fi
fi
# Dangling references. Every one of these found a real stale pointer when run by hand.
refs=$(grep -rhoE '`(docs|scripts|ideas|reference|\.claude)/[A-Za-z0-9_./-]+`' \
docs CLAUDE.md HARNESS.md ideas reference .claude scripts 2>/dev/null | tr -d '`' | sort -u)
dangling=""
for f in $refs; do [ -e "$f" ] || dangling="$dangling $f"; done
[ -n "$dangling" ] && bad "reference to a path that does not exist:$dangling"
adrs=$(grep -rhoE 'ADR-[0-9]{4}' docs CLAUDE.md HARNESS.md ideas reference .claude scripts cmd internal 2>/dev/null | sort -u)
missingadr=""
for a in $adrs; do
grep -q "^## $a" docs/decisions.md 2>/dev/null || missingadr="$missingadr $a"
done
[ -n "$missingadr" ] && bad "reference to an ADR with no entry in decisions.md:$missingadr"
secs=$(grep -rhoE 'CLAUDE\.md`? §[0-9]+' docs HARNESS.md ideas reference .claude scripts 2>/dev/null |
grep -oE '§[0-9]+' | tr -d '§' | sort -u)
missingsec=""
for s in $secs; do
grep -q "^## $s\. " CLAUDE.md || missingsec="$missingsec §$s"
done
[ -n "$missingsec" ] && bad "reference to a CLAUDE.md section that does not exist:$missingsec"
[ -z "$dangling$missingadr$missingsec" ] && pass "references resolve"
# Index rot: a folder of files nobody lists is a folder nobody reads.
for d in ideas reference; do
[ -d "$d" ] || continue
files=$(find "$d" -name '*.md' -not -name 'README.md' | wc -l | tr -d ' ')
listed=$(grep -c '^- \[' "$d/README.md" 2>/dev/null || true)
if [ "$files" != "${listed:-0}" ]; then
note "$d: $files file(s), $listed index line(s) — index out of date"
fi
if [ "$d" = "ideas" ]; then
unstatused=$(grep -L '^Status:' ideas/*.md 2>/dev/null | grep -v 'README.md' || true)
[ -n "$unstatused" ] && note "ideas: no Status line: $(echo "$unstatused" | tr '\n' ' ')"
fi
done
# True staleness: has any Go file changed since the commit state.md claims to describe?
recorded=$(awk -F'`' '/^\*\*Verified against:\*\*/{print $2; exit}' docs/state.md 2>/dev/null)
if git rev-parse --verify -q HEAD >/dev/null 2>&1; then
if git rev-parse --verify -q "${recorded:-nonexistent}^{commit}" >/dev/null 2>&1; then
behind=$(git rev-list "$recorded..HEAD" -- '*.go' 2>/dev/null | wc -l | tr -d ' ')
if [ "${behind:-0}" -gt 0 ]; then
note "docs/state.md describes $recorded; $behind commit(s) have touched .go since"
else
pass "docs/state.md is current with HEAD"
fi
else
note "docs/state.md 'verified against' does not name a commit this repo knows ($recorded)"
fi
fi
else
note "not a git repo — doc coupling unenforceable"
fi
result_and_exit() {
head_ "result"
if [ "$fail" -eq 0 ]; then
printf ' PASS %s warning(s)\n\n' "$warn"
else
printf ' FAIL fix the above before reporting success\n\n'
fi
exit "$fail"
}
if [ ! -f go.mod ]; then
head_ "go"
note "no go.mod — nothing to verify yet. Run 'go mod init' as part of the first feature."
result_and_exit
fi
mod=$(awk '/^module /{print $2; exit}' go.mod)
# Engine source only. The site root is outside this repo (ADR-0011); ideas/ and reference/ may hold
# exploratory scratch code, which is not the engine and is never formatted, vetted, built or budgeted.
gofiles=$(find . -name '*.go' -not -path './vendor/*' -not -path './.git/*' \
-not -path './ideas/*' -not -path './reference/*' 2>/dev/null)
if [ -z "$gofiles" ]; then
head_ "go"
note "go.mod present but no .go files yet — nothing to verify."
result_and_exit
fi
head_ "correctness"
pkgs=$(go list ./... 2>/dev/null | grep -vE "^$mod/(ideas|reference)(/|$)")
if [ -z "$pkgs" ]; then
head_ "go"; note "no engine packages yet — nothing to verify."
result_and_exit
fi
unformatted=$(echo "$gofiles" | xargs gofmt -l 2>/dev/null || true)
if [ -n "$unformatted" ]; then bad "gofmt: $(echo "$unformatted" | tr '\n' ' ')"; else pass "gofmt"; fi
if go vet $pkgs >/tmp/vet.log 2>&1; then pass "go vet"; else bad "go vet"; sed 's/^/ /' /tmp/vet.log; fi
if go build $pkgs >/tmp/build.log 2>&1; then pass "go build"; else bad "go build"; sed 's/^/ /' /tmp/build.log; fi
if echo "$gofiles" | grep -q '_test\.go$'; then
if go test -race $pkgs >/tmp/test.log 2>&1; then
pass "go test ($(grep -c '^ok' /tmp/test.log) packages)"
else
bad "go test"; sed 's/^/ /' /tmp/test.log
fi
else
note "no tests exist yet"
fi
head_ "dependencies"
reqs=$(awk '
/^require[ \t]*\(/ { inb = 1; next }
inb && /^\)/ { inb = 0; next }
{
ln = $0
ind = (ln ~ /\/\/[ \t]*indirect/) ? "indirect" : "direct"
sub(/\/\/.*/, "", ln)
gsub(/^[ \t]+|[ \t]+$/, "", ln)
if (ln == "") next
if (!inb) { if (ln !~ /^require[ \t]/) next; sub(/^require[ \t]+/, "", ln) }
split(ln, a, /[ \t]+/)
if (a[1] != "") print a[1], ind
}' go.mod)
direct=$(echo "$reqs" | awk '$2=="direct" {print $1}' | grep -v '^$' || true)
total=$(echo "$reqs" | grep -cv '^$' || true)
if [ -f scripts/allowed-deps.txt ]; then
allowed=$(grep -v '^[[:space:]]*#' scripts/allowed-deps.txt | grep -v '^[[:space:]]*$' || true)
unlisted=""
for d in $direct; do
echo "$allowed" | grep -qxF "$d" || unlisted="$unlisted $d"
done
if [ -n "$unlisted" ]; then
bad "dependency not on allowlist:$unlisted (needs an ADR + scripts/allowed-deps.txt)"
else
directcount=$(echo "$direct" | grep -c . || true)
pass "allowlist ($directcount direct)"
fi
fi
if [ "$total" -gt "$DEPS_MAX" ]; then
bad "module count $total exceeds DEPS_MAX=$DEPS_MAX"
else
pass "module count $total / $DEPS_MAX"
fi
head_ "architecture"
# The layering in conventions.md, enforced. Dependencies point inward; a sibling import here is
# what turns a layered engine into a ball of mud, and it always looks locally reasonable.
# One line per package: "importpath imp1 imp2 …". go list is authoritative; grepping source is not.
pkgimports=$(go list -f '{{.ImportPath}}{{range .Imports}} {{.}}{{end}}' ./... 2>/dev/null)
pairs=$(echo "$pkgimports" | awk '{for (i=2; i<=NF; i++) print $1, $i}')
layer() { # $1 = importing layer, $2..$n = layers it may not import
local from="$1"; shift
for to in "$@"; do
echo "$pairs" | awk -v f="$mod/internal/$from" -v t="$mod/internal/$to" \
'index($1,f)==1 && index($2,t)==1 {print $1" → "$2}'
done
}
violations=$(
layer content render web ext
layer render web ext
layer web ext
echo "$pairs" | awk -v c="$mod/cmd" 'index($2,c)==1 && index($1,c)!=1 {print $1" → "$2}'
)
# Sibling imports between features are what make an agent's read set compound (ADR-0027).
siblings=$(echo "$pairs" | awk -v e="$mod/internal/ext/" '
index($1,e)==1 && index($2,e)==1 && $1!=$2 {print $1" → "$2}')
if [ -n "$violations$siblings" ]; then
bad "import boundary violated (conventions.md layering): $(echo "$violations$siblings" | tr '\n' ' ')"
else
pass "import boundaries"
fi
nopkgdoc=""
for dir in $(echo "$gofiles" | grep -v '_test\.go$' | xargs -n1 dirname 2>/dev/null | sort -u); do
documented=$(awk 'prev ~ /^\/\// && /^package /{print FILENAME} {prev=$0}' "$dir"/*.go 2>/dev/null | head -1)
[ -z "$documented" ] && nopkgdoc="$nopkgdoc $dir"
done
if [ -n "$nopkgdoc" ]; then
bad "package without a package comment (conventions.md Documentation):$nopkgdoc"
else
pass "every package documented"
fi
undocumented=$(echo "$gofiles" | grep -v '_test\.go$' | xargs awk '
/^(func|type|var|const) [A-Z]/ { if (prev !~ /^\/\//) printf "%s:%d ", FILENAME, FNR }
{ prev = $0 }' 2>/dev/null)
if [ -n "$undocumented" ]; then
bad "exported identifier without a doc comment: $undocumented"
else
pass "exported identifiers documented"
fi
missingdoc=""
for dir in $(echo "$gofiles" | grep '^\./internal/ext/' | xargs -n1 dirname 2>/dev/null | sort -u); do
[ -f "$dir/doc.go" ] || missingdoc="$missingdoc $dir"
done
if [ -n "$missingdoc" ]; then
bad "internal/ext package without doc.go — contributes / cascade keys / contract fields / not doing (ADR-0027):$missingdoc"
else
pass "every feature has a doc.go"
fi
head_ "budgets"
# Two trees, two ceilings: the core must stop growing after Arc 2, ext is where growth belongs.
loc() { [ -z "$1" ] && { echo 0; return; }; echo "$1" | xargs wc -l 2>/dev/null | awk '$2!="total"{t+=$1} END{print t+0}'; }
nontest=$(echo "$gofiles" | grep -v '_test\.go$' || true)
extfiles=$(echo "$nontest" | grep '^\./internal/ext/' || true)
corefiles=$(echo "$nontest" | grep -v '^\./internal/ext/' || true)
coreloc=$(loc "$corefiles")
extloc=$(loc "$extfiles")
if [ "$coreloc" -gt "$CORE_LOC_MAX" ]; then
bad "core is $coreloc lines, over CORE_LOC_MAX=$CORE_LOC_MAX (shrink it, or raise it in an ADR)"
else
pass "core $coreloc / $CORE_LOC_MAX lines"
fi
if [ "$extloc" -gt "$EXT_LOC_MAX" ]; then
bad "internal/ext is $extloc lines, over EXT_LOC_MAX=$EXT_LOC_MAX (a template may have done it)"
else
pass "ext $extloc / $EXT_LOC_MAX lines"
fi
oversized=$(echo "$gofiles" | xargs wc -l 2>/dev/null | awk -v m="$FILE_LOC_WARN" '$2!="total" && $1>m {print $2"("$1")"}')
if [ -n "$oversized" ]; then
note "files over FILE_LOC_WARN=$FILE_LOC_WARN: $(echo "$oversized" | tr '\n' ' ')"
else
pass "no file over $FILE_LOC_WARN lines"
fi
longfuncs=$(echo "$gofiles" | xargs awk -v m="$FUNC_LOC_WARN" '
/^func / { start = FNR; name = $0; inf = 1; next }
inf && /^}/ { if (FNR - start > m) printf "%s:%d(%d)\n", FILENAME, start, FNR - start; inf = 0 }
' 2>/dev/null)
[ -n "$longfuncs" ] && note "functions over $FUNC_LOC_WARN lines: $(echo "$longfuncs" | tr '\n' ' ')"
if [ -f Dockerfile ] && [ "${VERIFY_DOCKER:-0}" = "1" ]; then
head_ "container (VERIFY_DOCKER=1)"
if command -v docker >/dev/null 2>&1; then
if docker build -q -t atelier:verify . >/tmp/docker.log 2>&1; then
pass "docker build"
else
bad "docker build"; tail -20 /tmp/docker.log | sed 's/^/ /'
fi
else
note "docker not on PATH"
fi
fi
head_ "style floor"
# conventions.md states these absolutely, so they fail. A rule enforced as a suggestion teaches
# the agent to read every rule as a suggestion.
badpkg=$(find ./cmd ./internal -type d 2>/dev/null | grep -Ei '/(utils?|helpers?|common|shared|misc|manager|base|impl|core)$' || true)
if [ -n "$badpkg" ]; then bad "package name says nothing (CLAUDE.md rule 3.6): $(echo "$badpkg" | tr '\n' ' ')"; else pass "package names"; fi
inits=$(echo "$gofiles" | xargs grep -ln '^func init()' 2>/dev/null || true)
if [ -n "$inits" ]; then bad "init() found — wire explicitly in cmd/: $(echo "$inits" | tr '\n' ' ')"; else pass "no init()"; fi
badlog=$(echo "$pairs" | awk '$2=="log" {print $1}' | sort -u)
if [ -n "$badlog" ]; then bad "imports log, not log/slog: $(echo "$badlog" | tr '\n' ' ')"; else pass "log/slog only"; fi
clocks=$(echo "$gofiles" | grep -v '_test\.go$' | grep -v '/clock\.go$' | xargs grep -ln 'time\.Now(' 2>/dev/null || true)
if [ -n "$clocks" ]; then bad "time.Now() outside a clock.go — Stages need an injected clock to declare a validity window (ADR-0013): $(echo "$clocks" | tr '\n' ' ')"; else pass "clock accessed through clock.go"; fi
panics=$(echo "$gofiles" | grep -v '_test\.go$' | grep -v '^\./cmd/' | xargs grep -ln 'panic(' 2>/dev/null || true)
if [ -n "$panics" ]; then bad "panic() outside cmd/ — request-time failure degrades (conventions.md): $(echo "$panics" | tr '\n' ' ')"; else pass "no panic outside cmd"; fi
head_ "smells (advisory)"
nowrap=$(echo "$gofiles" | xargs grep -n 'fmt\.Errorf(' 2>/dev/null | grep -v '%w' | wc -l | tr -d ' ')
[ "${nowrap:-0}" -gt 0 ] && note "$nowrap fmt.Errorf without %w (wrap at package boundaries)"
anyuse=$(echo "$gofiles" | grep -v '_test\.go$' | xargs grep -ln 'interface{}\|\bany\b' 2>/dev/null || true)
[ -n "$anyuse" ] && note "interface{} or any present (no empty interface for flexibility): $(echo "$anyuse" | tr '\n' ' ')"
deep=$(echo "$gofiles" | xargs awk '/^\t\t\t\t[^\t}]/ {print FILENAME; nextfile}' 2>/dev/null | sort -u || true)
[ -n "$deep" ] && note "nesting past 4 (conventions.md): $(echo "$deep" | tr '\n' ' ')"
deadexp=$(echo "$gofiles" | grep '^\./internal/' | grep -v '_test\.go$' | xargs grep -hoE '^func [A-Z][A-Za-z0-9_]*' 2>/dev/null | awk '{print $2}' | sort -u |
while read -r sym; do
n=$(echo "$gofiles" | xargs grep -c "\b$sym\b" 2>/dev/null | awk -F: '{s+=$2} END{print s+0}')
[ "${n:-0}" -le 1 ] && printf '%s ' "$sym"
done)
[ -n "$deadexp" ] && note "exported but referenced once — unexport or delete: $deadexp"
todos=$(echo "$gofiles" | xargs grep -c 'TODO\|FIXME\|XXX' 2>/dev/null | awk -F: '{s+=$2} END{print s+0}')
[ "${todos:-0}" -gt 0 ] && note "$todos TODO/FIXME markers (latent items belong in docs/state.md)"
result_and_exit