go.mod: take go-git past GO-2026-6214
govulncheck reports GO-2026-6214 (path traversal via crafted reference
names) as reachable from this service, not merely present in the module
graph. All three traces land in the write path:
gitx/write.go:433 CommitProposal -> dotgit.DotGit.Ref
gitx/write.go:380 DeleteProposalBranch -> dotgit.DotGit.RemoveRef
gitx/write.go:466 CommitProposal -> dotgit.DotGit.SetRef
Those are the functions that create, update and delete proposal
branches, so every agent push crosses the advisory.
Measured on this tree with go1.26.5, GOFLAGS=-mod=readonly:
before 8 called advisories, "from 1 module and the Go standard
library", GO-2026-6214 among them
after 7 called advisories, "from the Go standard library" alone,
GO-2026-6214 gone
The remaining seven are unchanged stdlib findings waiting on go1.26.6;
diffing the two reports shows no content change other than the removal
of the 6214 block. Imported-but-uncalled findings drop 2 to 1 for the
same reason.
v5.19.2 raises its own floors on x/crypto, x/net, x/sys and x/text.
This module already sits above all four, so MVS selects nothing new and
go mod tidy moves only the two hash lines for the bumped version;
go.sum stays at 435 lines.
mcpsrv, doc: pin the version the handshake announces, and re-sync AGENTS.md
A mutant survived the version work: replacing the argument mcpsrv.Handler is
given with a literal left the whole cmd/specsrht suite green, because the guard
there runs `specsrht version` and the handshake needs a live server. One line in
the handshake test closes it — mutated to check, and it now goes red naming both
strings.
AGENTS.md had drifted from CLAUDE.md: the coverage-profile paragraph was
corrected in one and not the other, which is what the mirror comment in both
files exists to catch.
cmd: drop the version parameter newSurfaces no longer needs
It was a pass-through: one caller, one possible value, and it existed only
because main held the version in a const it had to hand down. What the
parameter still buys is the ability to give the MCP handshake a different
string from the one `specsrht version` prints, which is exactly the
divergence this branch is about.
Found by mutating it. Replacing the argument with a literal "dev" left go vet
clean and the whole cmd/specsrht suite green -- TestVersionIsWhatTheLinkerPutThere
runs the binary's `version` subcommand and cannot see the handshake, and
reaching the real handshake needs Postgres, a repos root and a listener.
Removing the parameter removes the seam rather than testing it; a literal
written inside newSurfaces itself would still survive, and the test now says so
instead of implying otherwise.
While checking that claim: mcpsrv/http_test.go connects a real client and
asserts InitializeResult().ServerInfo.Name, not .Version. So the field this
work is about is the one part of the handshake nothing checks. An earlier
draft of the comment above asserted the opposite; one line in mcpsrv/ would
make it true.
cmd, make: let the linker tell the service its own version
cmd/specsrht declared `const version = "dev"` and handed it to
mcpsrv.Handler, so every build -- packaged or not -- introduced itself to an
MCP client as "dev". Nothing in the tree called debug.ReadBuildInfo either.
The obvious repair is a VERSION and a -ldflags -X in the Makefile, and on its
own it is worse than the gap. `-X` at a const, and `-X` at a package-level
symbol that does not exist, are both accepted in silence. Measured on go1.26.5
against the const that was here:
$ go build -ldflags "-X main.version=1.2.3-demo" -o /tmp/d ./cmd/specsrht
$ echo $?
0
$ go version -m /tmp/d | grep ldflags
build -ldflags="-X main.version=1.2.3-demo"
$ go tool nm /tmp/d | grep -w main.version
(nothing: a const is inlined and has no linker symbol)
Exit 0, no diagnostic, the flag proudly recorded in the build info, and not a
byte written anywhere. So the variable comes first and the flag second.
internal/build follows cov.sr.ht and tokens.sr.ht: one exported var, no
imports, so the linker flag names an import path this repository controls
instead of depending on which package happens to be `main`. api/layering_test
places it in the bottom tier -- on "imports nothing" rather than on "domain",
which the tier's prose now says.
`specsrht version` is new and is what makes the stamp answerable of the
artefact rather than of the source. Until it existed the only surface reporting
the version was the MCP handshake, so checking what a binary will tell an agent
meant standing up Postgres, a repos root and a listener first.
TestVersionIsWhatTheLinkerPutThere is the guard, and it links a real binary
because nothing weaker can be one: an assertion on build.Version inside `go
test` passes with the linker doing nothing at all -- it measures the default.
It reads VERSION_PKG out of the Makefile, links cmd/specsrht into a temp
directory with -X, runs it and compares. That puts the Makefile, the var being
a var, the import and the subcommand all inside one assertion. 1.5s warm.
$ make build VERSION=0.2.0_git7 && ./specsrht version
0.2.0_git7
check-version is untouched and still asks its own, different question -- the
vcs.revision/vcs.modified pair `go version -m` prints, which is provenance and
not the version the service quotes. Its comment said this file would grow a
VERSION "on the day cmd/specsrht grows a var to put it in"; that day is this
commit, and the comment now describes the arrangement instead of the plan.
.build.yml exports only PKGVER, not the family's SRHT_VERSION, so the APKBUILD
fallback is what applies in CI and the packaged binary is stamped with the
apk's own version. That is a true answer rather than a placeholder; exporting
SRHT_VERSION=$desc alongside would be an improvement and belongs with the
manifest, which is 57 bytes under builds.sr.ht's submission limit.
cmd: refuse a missing [mail] smtp-from instead of panicking on it
WithDefaultMiddleware builds core-go's shared email queue unconditionally, and
email.NewQueue's first act is to panic when [mail] smtp-from is absent. This
service sends no mail of its own, so nothing on the startup path suggested the
key mattered, and neither pre-check in validateConfig nor service.LoadConfig
covered it: both read [spec.sr.ht] and the [sr.ht] identity keys, and [mail] is
an instance-wide section nothing here read at all.
Reproduced against sr-ht-core v0.0.0-20260816094344 with the config the daemon
builds, minus that one key:
panic: expected [mail]smtp-from in config
sr-ht-core/email.NewQueue(...) email/worker.go:227
sr-ht-core/server.(*Server).WithDefaultMiddleware(...) server/server.go:331
Three frames inside another module, over a config key -- and late: run opens
the pool, seeds the owner row, refreshes every space's hooks and has the hook
socket listening before it calls coreserver.New. So the panic is only reachable
once an operator has already provisioned Postgres, which is the worst possible
moment to learn about an ini key.
One require() line beside the other two now names it at validation time. The
same binary, same config file:
spec.sr.ht did not start: incomplete configuration.
Missing keys the SourceHut runtime requires:
[mail] smtp-from -- envelope sender; core-go's email queue panics without it
and with the key supplied it gets as far as the database it could not reach
before ('reach the database: dial tcp 127.0.0.1:59999: connection refused'),
which is what places the refusal ahead of every open handle.
completeConfig gains the key, so metrics_test's serverConfig -- 'completeConfig
plus the one key core-go's WithDefaultMiddleware panics without' -- is gone.
That helper was the tree already knowing about this gap and routing one test
around it; its comment said validateConfig does not require the key, and that
is no longer true.
Siblings in the same call, not fixed here and worth their own issue: a present
but malformed smtp-from still panics ('invalid [mail]smtp-from: mail: missing
'@' or angle-addr', worker.go:239), an unreadable [mail] pgp-privkey panics,
and [spec.sr.ht::api] max-duration goes through a bare panic(err) that names
neither section nor key ('time: missing unit in duration "30"',
server/server.go:325).
make: ignore the coverage profile make cover writes
COVERPROFILE?=cover.out puts the profile in the checkout, and .gitignore did
not list it — so `make cover`, the gate CLAUDE.md and .build.yml both name,
left an untracked file behind. Go reads vcs.modified from
`git status --porcelain`, which counts untracked files, so the next
`go build` stamped its binary <sha>-dirty and `make check-version` refused
over a file the documentation had just told the reader to produce.
Measured before the change: `git status --porcelain` empty, `make cover
PKG=./core/`, then `?? cover.out`. After it, the same sequence leaves the
tree clean and `git check-ignore -v cover.out` names .gitignore:25.
CI was never affected — .build.yml passes COVERPROFILE="$HOME/cover.out"
because that is where `artifacts:` looks — which is why this only ever bit a
laptop. cov.sr.ht and tokens.sr.ht carry the same anchored /cover.out line for
the same reason; this follows their pattern.
The paragraph in CLAUDE.md that described the gap is now the description of a
fixed one.
doc: say that codegen works, now that it does
Both files told the next agent that go generate ./graph is broken, damages the
tree and is blocked in sr-ht-core. Two commits ago that was true; it stopped
being true with the gqlgen bump, and it is exactly the shape of stale claim
these files exist to prevent — the one an agent acts on without checking.
Re-measured before rewriting: the pinned run is a fixed point, twice in a row,
go.mod and go.sum included.
graph: correct what the codegen comments claim, now that they are false
generate.go said codegen is broken here and that adopting v0.17.94 is
blocked outside this repository. Neither survived the commit before this
one, and a comment that outlives the thing it describes is the defect this
repository keeps finding. It now records what is still true — v0.17.36
cannot be run as `go run …@v0.17.36`, re-measured today and still failing
on golang.org/x/tools v0.9.3's "invalid array length -delta * delta" — as
history rather than as a standing warning, and says that the ceiling came
off when sr-ht-core 68dbc35 landed.
While there: the directive is pinned to @v0.17.94 the way the five
siblings pin theirs, so the run no longer resolves the generator through
this module's graph and no longer has to be followed by a `go mod tidy` to
undo what that dragged in. The helpers.go rule moves in from CLAUDE.md so
it sits next to the generator it constrains.
introspection_test.go pointed at "the note there" in go.mod, where there
is no note and never was, to explain a v2.5.21 pin that is now v2.5.36.
The assertions are untouched: the test derives its field list from
whichever prelude is in use, and it passed across this bump without an
edit, which is the transition it was written to check.
go.mod, graph: take gqlgen v0.17.94 and regenerate against it
sr-ht-core now passes the context that complexity.Calculate wants
(68dbc35, here as v0.0.0-20260821193637-68dbc3533caa), which was the only
thing holding this module at gqlgen v0.17.36. With it out of the way the
gqlparser ceiling goes too: the v0.17.94 executor knows the fields the
v2.5.36 prelude declares, so v2.5.21 is no longer the highest version
this endpoint can serve without advertising fields that panic when
selected. graph/introspection_test.go passes unchanged at v2.5.36 —
it derives its field list from whichever prelude is in use, which is
exactly the transition it was written for.
The bump and the regenerated code are one commit because neither builds
without the other: v0.17.94 widens graphql.ExecutableSchema.Complexity to
take a context and drops DeferredGroup.Label and CollectedField.Deferrable,
so the v0.17.36 output stops compiling the moment go.mod moves.
What the regeneration changed beyond banners and ordering:
- executableSchema.Complexity takes ctx and map[string]any, and builds
its executionContext through newExecutionContext.
- The deferred-delivery plumbing follows the runtime: Label is gone from
DeferredGroup and the Deferrable checks with it (35 references to 24).
- Every interface{} is spelled any (183 to 0).
- The introspection executor gained isOneOf (0 references to 8), which is
what makes the gqlparser bump safe.
- models_gen.go gains empty Query and Mutation structs and JSON marshalers
on the three enums.
- No resolver signature moved: schema.resolvers.go changed only in its
banner, one rewrapped comment and the grouping of the six resolver
type declarations.
go mod tidy keeps the codegen tree out of go.mod as generate.go promises —
goccy/go-yaml, urfave/cli/v3, x/mod and x/tools are dropped again. What
stays is v0.17.94's runtime: coder/websocket replaces gorilla/websocket in
the transport, go-viper/mapstructure/v2 replaces mitchellh/mapstructure in
the APQ extension, and sosodev/duration and x/sync/semaphore arrive with
graphql itself. The x/crypto, x/net, x/sys, x/text and protobuf bumps come
from sr-ht-core's own graph.
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.