~bigbes/sr-ht-spec

df98f429 — Eugene Blikh 3 hours ago master
doc: the startup report covers unusable values now, not just missing keys

The README still named [mail] smtp-from as the exception that arrives as a stack
trace. Two commits removed that: one made a missing key a named refusal, the
other did the same for a value the runtime cannot parse. The paragraph described
a service that stopped existing while it was being written.
1b44ee02 — Eugene Blikh 3 hours ago
cmd: name the charset the mail gate would have got wrong

The comment claimed net/mail and emersion/go-message/mail "differ on RFC 2047
encoded words", which is too loose to be useful and wrong where it matters
most: Go's default mime.WordDecoder knows utf-8 and iso-8859-1, so both
parsers accept the encoded words anyone would try first, and a spot check
concludes there is no difference. They part on charsets only
go-message/charset registers — measured:

	=?koi8-r?Q?=F3=D0=C5=C3?= <spec@example.invalid>
	  net/mail: charset not supported: "koi8-r"
	  emersion: accepted

so the substitution fails in the refusing direction: a daemon that will not
start on a section core-go reads happily. That address is now the last case
in TestValidateConfigAcceptsEveryAddressCoreGoAccepts, which is the only line
in the tree that would notice the swap. Confirmed by making it: with
checkMailConfig rewritten as the net/mail lookalike, that case goes red — and
so do both pgp-privkey cases, since a lookalike for one key stops checking
the other.
59962945 — Eugene Blikh 3 hours ago
cmd: re-measure the smtp-from stack against the pinned core-go

The frame the comment cites moved when sr-ht-core was bumped: WithDefaultMiddleware
calls email.NewQueue at server.go:340 in v0.0.0-20260821193637, not the
server.go:331 measured against v0.0.0-20260816094344. The panic site inside
NewQueue is unchanged at worker.go:227. Re-run through the production path, not
counted from a diff. The older number stays in the prose as what it was, since a
reader with that version in the module cache will find it there.
71db8b74 — Eugene Blikh 3 hours ago
cmd: refuse a config value that would panic three frames down

The gate added two commits ago answers one question — is this key present —
and three of the values it lets through still take the daemon out with a
stack trace inside another module. All three were reproduced through the
production path, coreserver.New(...).WithDefaultMiddleware(), against
sr-ht-core v0.0.0-20260821193637:

	panic: invalid [mail]smtp-from: mail: missing '@' or angle-addr
	panic: time: missing unit in duration "30"
	panic: failed to open [mail]pgp-privkey: ... no such file or directory

The middle one is the worst of them: core-go raises it as a bare panic(err)
naming neither the section nor the key, leaving an operator to guess which
duration-valued setting was being read. And it is raised over a value this
service never spends: core-go installs middleware.Timeout on the
authenticated router, this daemon registers every route on AnonRouter and
never calls WithSchema, so max-duration decorates nothing. Measured at 1ms
against a handler twenty times slower — no deadline on the request context,
200 on the wire — and pinned, with a control for the probe, by
TestMaxDurationIsInertOnThisService. A malformed value therefore stops the
daemon over a setting that would have done nothing had it parsed, which is
an argument for naming it early rather than for ignoring it.

So validateConfig now asks a second question, and keeps it apart from the
first: missing keys and unusable values are separate lists under separate
headings, because "add this line" and "correct this line" are different
work. A shape check whose key is absent stays quiet, so no key is described
twice.

The parsers are the consumers' own. max-duration goes through
time.ParseDuration under byte-for-byte core-go's condition — no TrimSpace,
since an empty value reaches the parser there too. The [mail] section is
checked by building core-go's email queue and recovering the panic, rather
than by reimplementing four checks that would then be a second opinion: the
address parser is emersion/go-message's and not net/mail's, and the key file
must be openable, armored, single and unencrypted. A validator that accepts
what the consumer rejects has moved the panic, not removed it; running the
constructor cannot drift from it, because it is it.

The real defect is upstream. A library that panics with a bare err on a
config value makes every service embedding it fail this way, and six do.
This is the honest local half until that lands.
fd511bfb — Eugene Blikh 3 hours ago
web: assert the Allow the 405 now carries

The comment on TestStaticTreeAnswersOnlyGetAndHead said the 405 carried no
Allow header, because chi hands the matching methods only to its built-in
responder and the custom one chimw.RenderRefusals installs could not reach them
— an instance-wide trade rather than a gap in this route. The ecore bump makes
it false: RenderRefusals reads the allowed methods back off the routing tree
through chi's exported matcher, so the header is the tree's own answer.

Measured before writing anything: POST, PUT, DELETE and OPTIONS on
/static/logo.svg all answer 405 with `Allow: GET, HEAD`, a GET of /inbox/seen
answers `Allow: POST`, and a 404 carries none.

The old comment's other half held, and is worth recording as having held: the
test asserted nothing about an empty Allow, so the upstream fix arrived without
turning it red. That is also why it now needs an assertion — the header
appearing is a change in what this surface says, and nothing was holding it. It
is written as the exact value rather than as non-empty, since an Allow naming
POST would satisfy the weaker form while describing a service this one is not.
5184c7b3 — Eugene Blikh 3 hours ago
mcpsrv: import the cache helpers instead of keeping a copy of them

cache.go was a deliberate byte-for-byte copy of sr-ht-ecore/mcphttp's cache
wrapper, and its own doc comment named the one thing holding the swap: mcphttp's
Vary was a package constant, so importing it would have imported a claim about
this endpoint's inputs that this endpoint could not choose. The ecore pin bumped
in the previous commit makes it a parameter, so this is what that comment
promised — a delete and an import.

PrivateCache and not PrivateCacheVarying: this endpoint means exactly
mcphttp.DefaultVary. Both credential planes genuinely reach /mcp here, which is
the one thing that separates it from dolt.sr.ht's bearer-only surface, and that
argument is kept at the call site because it is about this service and not about
the mechanism. Gate's 401 is written outside the wrapper — Gate runs inside the
resolver middleware and Handler runs inside Gate — so it keeps writing the pair
itself, now through mcphttp.SetPrivateCache, which exists for exactly that case.

The wrapper-level tests go with the wrapper: mcphttp's own cover every case
cache_internal_test.go made, the three commit paths and the Unwrap deadline
included. What stays here is the endpoint-level statement, and its expected
values stay literals on purpose — read from mcphttp.DefaultVary they would agree
with the import by construction and pin nothing.

Measured rather than asserted, since "no observable change to any response" is
the promise the deleted file made: the headers of all seventeen shapes /mcp
answers in — streamed 200, 400, 415, 405, both 403s, both 401s — captured over a
real server with a real client before and after, and diffed byte for byte with
only the random session id masked. Identical.
88e9c4c3 — Eugene Blikh 3 hours ago
deps: bump the ecore pin to the mcphttp with a Vary parameter

675a7d5 makes two things reachable that this repository has been waiting on.
mcphttp's cache directives now take the Vary as a parameter
(PrivateCacheVarying, SetPrivateCache) instead of baking a package constant
into the wrapper, which is the one thing that kept mcpsrv/cache.go a private
copy rather than an import. And chimw.RenderRefusals reads the allowed methods
back off the routing tree, so a 405 on this surface can carry an Allow header.

go mod tidy moves nothing else: one module line and its two go.sum entries,
with the superseded pin dropped.
491d6b3e — Eugene Blikh 3 hours ago
graph: answer me from the request's own principal

The mapping is asked of CanRead's two predicates rather than of
Principal.Kind, so the field cannot come to disagree with the ACL that
admitted the caller: gate lets in exactly IsOwner or IsAgent, and those
are exactly the two values CallerKind has. An agent is reported as an
agent whoever's account minted its credential.

The remaining arm is not the anonymous HTTP request — the gate refuses
that one with 401 before the query is parsed — but the schema's other
execution: a webhook delivery, where a subscriber's stored query runs
against this same schema off a proposal event with no request behind it.
That is a question with no answer rather than a caller with no name, so
it fails and says so, which is what let the field be non-null without
inventing an identity for the case.

The tests drive the production chain over a live server and a real
client, because which caller is asking is decided in resolveCaller and
gate and not in the resolver: an agent naming itself, an agent that does
not, a personal access token that cannot, an anonymous request, a token
without the grant, a token of another account, and the owner's cookie —
which this endpoint reads none of, so OWNER is a value no request to
/query can produce. The unreachable arm is covered over the principal
directly, and the no-caller arm through gqlgen's executor the way
core-go's webhooks.Exec drives it.
37f497df — Eugene Blikh 3 hours ago
graph: publish a me field naming the caller

A federated client could not ask spec.sr.ht who it is. Four of the five
sibling schemas that have a GraphQL surface answer `me` — bench, coverage
and dolt as `User`, artifacts as `Viewer` — and all four agree on the
shape: nullable, `username` plus `canonicalName`, no id and nothing about
the credential. This adds the field with two deliberate departures.

The type is `Viewer` and carries a `kind`, because a flat username would
be the same string for every caller this endpoint admits. An agent
authenticates with a credential of the owner's and acts for them, so
"who do you act as" has one answer here; whether the caller *is* the
owner is the other question, and it is the one that decides whether a
proposal may be approved. A bare name would invite reading a read token
as approval authority.

The field is non-null, because anonymous is not a caller here. The
siblings' read planes answer anonymous viewers and their `me` is
nullable for that reason; this one refuses a caller with no read
authority with 401 before the query is parsed, so a nullable field would
have a null case no client could ever reach.

`agent` is the caller's own `X-Agent` assertion echoed back, and it is
the only field that varies between two agents. It earns its place from
the federation gateway, which builds its upstream request itself and
forwards the credential alone, so a query arriving that way sees null
whatever it sent — and the write plane refuses an edit that names no
agent.

Nothing else about the credential is published: not the grant set, whose
disclosure would hand a read-only token a map of the write plane, and
not the token name, the session or the local user row's id.

Generated with the pinned gqlgen v0.17.94; go.mod and go.sum are
untouched and the second run leaves the tree unchanged.
a634e8c5 — Eugene Blikh 3 hours ago
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.
f11da40f — Eugene Blikh 3 hours ago
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.
d266ebaf — Eugene Blikh 4 hours ago
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.
cf57666a — Eugene Blikh 4 hours ago
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.
d171d802 — Eugene Blikh 4 hours ago
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).
db0d0556 — Eugene Blikh 4 hours ago
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.
56504335 — Eugene Blikh 4 hours ago
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.
166db52d — Eugene Blikh 4 hours ago
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.
0607751d — Eugene Blikh 4 hours ago
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.
948edab2 — Eugene Blikh 4 hours ago
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.
498bb128 — Eugene Blikh 4 hours ago
doc: correct two lines the measurements contradicted
Next