doc: say in CLAUDE.md and AGENTS.md what this repository actually does The stub still carried its two placeholder lines. What replaces them is only what was measured here: the make targets that exist, the Postgres variable and the CI switch that turns its skips into failures, the layering the guard test encodes, and the state of go generate. Deliberately absent: make check, make test-pg and make test-all, which a sibling has and this repository does not; a testify rule, since four packages here use none; and anything about SPEC.md, which does not exist in this tree yet. AGENTS.md is mirrored rather than symlinked. The two were never equal — it carries its own beads block with a different hash and a codex-setup section CLAUDE.md has no counterpart for — so a symlink would delete one tool's generated content and put both regenerations on one inode.
doc: correct two lines the measurements contradicted
doc: describe the service the code implements, and what it does not say
contrib: add the unit and the server block a plain-host install needs
doc: say how to build, run and test this from a checkout
doc: write down every config key the two binaries actually read
go.mod: promote getopt, which the metrics test imports directly The bind-address and metrics work made cmd/specsrht a direct consumer of getopt, and the requirement stayed in the indirect block. Every plain `go build` then rewrote go.mod to promote it — which dirties the checkout and makes check-version refuse the binary it just built, on a tree the author never touched. Found by an agent that ran `make build` against a real instance and had to `git checkout go.mod` after each one.
web: stop promising a scripts seam this layout does not have The header listed "three seams" and described "scripts" as one nothing used yet. No such block was ever declared, so the list was one short of the truth and read as an invitation to use something absent — a page defining "scripts" renders nothing at all and reports nothing, verified against html/template: an unreferenced define is dropped and Execute still returns nil. Not adding the block instead. proposal.html is the only page with a script and it defines "head" with <script src="/static/diff.js" defer>, which is where a deferred script belongs: fetched during parsing, executed after parsing and before DOMContentLoaded, i.e. exactly where an end-of-body script runs, minus the later fetch. diff.js does not depend on either — it calls init at once when document.readyState has left "loading" and waits for DOMContentLoaded otherwise. The seam goes in when a page needs what defer cannot give it.
make: name the missing tool before deleting the stylesheet it would rebuild `make css` opened by removing web/static/main.min.*.css and only then reached for sassc, so a machine without it lost a working stylesheet to learn that: measured, `PATH=/usr/bin:/bin make css` died on the sassc line and left check-css reporting no stylesheet and an orphan main.css for go:embed to pick up. The three checks go in front of the rm for that reason, not for the message alone — re-measured after, the same failure now leaves the hashed file untouched and check-css still passes. The message is worth something too, most of all for the partials. Missing, sassc says "File to import not found or unreadable: base ... on line 16:1 of scss/main.scss" and never prints the -I path it searched, so a correct @import in our own stylesheet reads as the bug. The check prints the ASSETS path and names core.sr.ht's `make install` as what fills it. MINIFY joins SASSC as a variable so the guard and the recipe cannot come to name different tools, and so an install under another path stays buildable.
make: say what the version stamp is, and what it is not The check-version comment claimed the binary "reports itself from the VCS revision Go records". It does not. cmd/specsrht declares `const version = "dev"` and hands it to mcpsrv.Handler, nothing in the tree calls debug.ReadBuildInfo, so every build introduces itself to an MCP client as "dev". The stamp is provenance readable with `go version -m`, which is what this gate is for and all it now claims. Keep the mechanism rather than adopting the siblings' -ldflags: there is no symbol to set. `-X` at a const and `-X` at a symbol that does not exist are both silently ignored on go1.26.5 — exit 0, no diagnostic, the binary still prints "dev" — so a VERSION here would read like a version and do nothing. Record what has to change first, and cov.sr.ht's argument for changing it. Also name the linked-worktree case in the empty-dirt branch, which is how it was found: built from .worktrees/chore/makefile-preflight with a clean tree, the binary came out vcs.modified=true because Go had stamped the parent checkout. Every measurement above was taken in a plain clone for that reason.
cmd: keep the metrics listener on loopback unless asked otherwise core-go defaults the Prometheus bind to ":0" - a random port on every interface - while giving pprof "localhost:0", and this daemon inherited the wrong half of that asymmetry: /metrics is served by core-go's own mux with no credential in front of it, so anyone who could reach the host read the counters of an instance whose service port is deliberately on loopback behind nginx. withMetricsDefault inserts -m localhost:0 immediately after argv[0], so an operator's own -m is parsed after it and still wins - server.New keeps the last -m it sees. The vector is built once in run and handed to both bindAddresses and server.New, so the startup line and the daemon cannot disagree about it.
cmd: take the listen address from the config the operator already edits The daemon's only HTTP listener was set by -b alone, with localhost:5091 compiled in as the fallback, so an instance that needs a different address had to carry it in the unit file or the container entrypoint rather than in the config.ini it edits for every other setting. phoebe-lab's template says so in as many words: "the web listener is set with -b in the entrypoint ... so no bind-address key is needed here", while every containerised sibling beside it binds 0.0.0.0 through its own key. [spec.sr.ht] bind-address is that key. The spelling is not invented here: six siblings already read it under that name -- artifacts, bench, cover, curator, snip and tokens -- and upstream's own builds.sr.ht worker reads [builds.sr.ht::worker] bind-address, so it is the name an operator sharing one config.ini across services already knows. Precedence is flag > config > default, stated in service.DefaultBindAddress and in bindAddresses rather than left to be inferred: server.New falls back to the address it was constructed with only when the vector carries no -b, so handing it cfg.BindAddress is what produces that order. The address is validated while the config is read, next to repos and origin, so a malformed one is refused by a message naming the key instead of by a bare net error once the database is open and the hook socket is held. Port 0 is refused with the malformed ones -- it is legal to bind and useless behind a proxy that connects to a fixed port. bindAddresses reads the vector with core-go's own getopt rather than scanning for "-b", so the attached form and clustered flags are read the way the real parse reads them, and the startup line reports the addresses actually bound rather than the configured one -- which -b had already been making it misreport.
web: say in the embed directive that partials are wanted //go:embed templates/*.html carried _threads.html by accident of a rule rather than by saying so: embed excludes a name starting with '.' or '_' when it walks a directory, not when a pattern matches the file directly. Measured before changing anything — the partial is in the embedded FS today, and TestEveryTemplateOnDiskIsEmbedded is what keeps it there. The "all:" prefix states the rule instead of relying on the reader knowing the distinction, so the line is right under either reading of it and cannot be shortened into the directory form without the prefix being deleted first. The glob stays: "all:templates" is the same intention with no extension to match, and would compile a stray .DS_Store or an editor swap file into the release binary, which pages.Load would filter out in silence. The comment on the neighbouring static directive moves with it, since the two forms now differ on the page: that tree is served to a browser by name, so excluding '.' and '_' is what is wanted there. It also listed two assets where there are three — proposal.html has loaded /static/diff.js since the prose differ grew a client side.
web: serve the asset tree for the two methods it answers r.Mount registers a subtree under every method chi knows, so the routing table listed all ten against a handler that answers two, and a same-origin POST to /static/main.min.<sha>.css came back 200 with the whole 133 KB stylesheet instead of a 405. Nothing was writable and nothing leaked; the description was wrong, and the table is what chi's own 405 and anything walking the router read. chimw.GetHead over the pattern Mount would have appended, so the asset tree is registered the way every other read route on this surface is. Nothing of Mount's is lost: its RoutePath shift and its 404/405 inheritance are for a mounted *Mux, and assets.Handler is a plain handler that reads r.URL.Path and strips the prefix itself. Measured against a live listener, before and after: the hashed asset still answers 200 with public, max-age=31536000, immutable and no Vary, a HEAD still answers the same headers with no body, an If-None-Match: * still answers 304 carrying the asset policy rather than the page's private, no-store, and /static, /static/ and a name that is not there still get this service's own 404 page. POST, PUT, PATCH, DELETE and OPTIONS now get the rendered 405. The 405 carries no Allow header, which is chi's limitation rather than this route's: it hands the list of methods that would have matched only to its built-in responder, through unexported types, so the custom handler chimw.RenderRefusals installs cannot emit one. Every 405 on this surface is missing it, not just this route.
api: tell caches that the write plane answers per credential Every answer the REST write plane gives is computed from the bearer token on the request and from nothing else the request line shows: the 201 names a proposal opened for the agent that presented the credential, the 401 names the challenge, the 403 says this token may not propose. The mux carried no cache policy at all, which made it the last credential-varying surface of the service without one — web/ has it, /mcp has it, and /query got it two commits ago. The policy is sr-ht-ecore's PrivateCache, the same helper the other three use: private, no-store with Vary: Cookie, Authorization. Where it goes is a short question here and was a long one for mountGraphQL, because that Group also holds api-meta.json and had to keep it publicly cacheable. This mux mounts exactly one route and it is the credential-varying PUT, so the policy covers the whole mux and picks up two answers a route-level one would miss: chi's 404 for an address this surface does not serve, and the resolver middleware's 401, written before the router has looked at the path. The honest ranking, which is why this is one line: a PUT is even less cacheable than the POST /query carries the same policy for, so this is defence in depth and not a live leak. The test drives httptest.NewServer and a real client rather than a recorder, which hands back the live header map and so cannot observe when a header was set
doc: check the href a review link publishes, not the destination it came from Mutation found the dangerous-URL refusal had grown two guards for one property: with an archive supplied, the check on the way out caught what the check on the way in was for, so removing the incoming one turned nothing red. There is one check now, on the href, which is the thing that reaches the anchor — and removing it fails four tests in both configurations. The relative-link test went the same way: a sibling destination resolves through doc.Archive's space-wide stem fallback as well, so it passed with the linking document's directory thrown away. It uses a path-qualified destination now, which has no fallback.
web: resolve a proposal's links against the revision under review An internal link inside a proposed document used to reach the review page as the destination as typed, so following one landed nowhere. The page now reads the proposal through ProposalReview and resolves each block against the archive of the revision that block's text is from: the branch tip for the text under review, the base for a block the proposal deletes. Every href is pinned to the revision it was resolved at. A document's site path names the document and not the revision, so an unpinned link out of a proposed block would land on the approved head — the text the proposal is changing. Two things the resolver could not say before and says now: a link to a document that does not exist is marked, and so is a citation of a section the proposal has renamed, which is the error a proposal introduces and review is the moment to catch.
service: read a proposal's two revisions as archives ProposalDiff hands over bytes and a path, so nothing above it can turn a wikilink in a proposed document into a URL. ProposalReview returns the same documents plus the whole document set of each side as an archive, built from the tree walks the diff already does. Two archives and not one because the review page renders two revisions: a block the proposal deletes belongs to the base, and resolving its links against the branch tip would answer for it with the very text the proposal is changing. Each is built once per proposal rather than once per document; LinkPass is a parse of every document in the revision. ProposalDiff is left as it was, so the comment POST path and the MCP surface pay for neither.
doc: let the review renderer resolve against a supplied archive Inline now takes the linking document's directory and the document set to resolve against, and wraps whatever it is handed in reviewResolver rather than holding one. The wrapping is the security boundary: doc.Archive applies no dangerous-destination check, correctly, because the read plane's documents are approved and render with unsafe on. web/ still passes nothing, so the review page is unchanged by this commit.
web: describe the selection the composer actually gets The composer's note explained that the script prefixes a selected line range onto it and removes that element on Escape. Nothing prefixes anything any more: the gutter drag is gone and a selection becomes a quotation in the textarea. The comment now says that, since it is the one place a reader looks to learn what the composer promises before they type.