chore: promote go-arg to a direct dependency
specsrht-migrate embeds brant's cli.Args, which is go-arg based, so the
toolchain reclassifies it. No version changed and no module was added.
docs: three receive hooks, and the read plane's rev guard
The two-hook split was unimplementable. Push options reach pre-receive and
post-receive only, so the skip-validation escape hatch was unreachable
where the design put it; and pre-receive cannot read the pushed objects at
all, because they sit in the quarantine until after it runs. update is
therefore the earliest hook that can validate, which the design relied on
without saying why. Work splits across three hooks correlated by
repository and receive-pack pid.
Records that per-ref rejection is not atomic: a push of main plus a
proposal branch, with main rejected, still lands the proposal.
Settles the read plane's audience, which was anonymous-capable in one
section and leak-free in the verification checklist. It is fail-closed.
Documents the rev guard: reads take the approved head or a full object
name, never a ref, because rev=proposals/42 would otherwise serve
unreviewed text as approved through the read plane itself.
fix(service): separate the read-plane rev guard from the review path
The previous commit's guard broke two service tests, which were right and
the guard was too broad: rendering and diffing a proposal branch genuinely
needs to read one, so a blanket ban on ref names is not the shape.
ReadDocument keeps the strict object-name rule. ReadDocumentAtRef reaches
an arbitrary ref, deliberately as a separate awkwardly-named method rather
than a flag, so serving unreviewed content is something a caller asks for
by name and a reviewer can grep for.
A rejected rev now wraps ErrNotFound as well, preserving the existing
deliberate property that a crafted revision cannot distinguish malformed
from absent by probing.
feat(mcpsrv): MCP read tools, with Host validation replacing the SDK guard
Adds the Phase 2 read tools (spec_search, spec_read, spec_list) over the
service layer.
Two security fixes came out of building them.
The read plane could serve proposal content. gitx resolves ref names, and
service.resolveRev passed any string through, so rev=proposals/42 made the
READ plane hand back unreviewed text — which would then flow into agent
context as though approved, the single failure this service exists to
prevent. ValidateReadRev now admits only the approved-head sentinel or a
full 40-character object name, at the layer all three surfaces share.
Abbreviations are refused too: one that is unique today can become
ambiguous later, so a pinned revision would silently stop meaning one
thing.
The MCP SDK's DNS-rebinding guard rejects a loopback listener whose Host
is not loopback, which is exactly nginx forwarding to 127.0.0.1 — it would
403 only in production, passing every local test. The SDK offers no
allowlist, so the guard is disabled and replaced by a stricter check: Host
must equal the configured origin, or a loopback name for development. A
rebinding attack carries the attacker's name in Host and fails it. An
unusable origin logs loudly rather than quietly unguarding the endpoint.
feat: hooks — the receive path, its daemon RPC, and the specsrht daemon
The hooks are thin shims that RPC into the running daemon over a unix
socket under the repos root, so validation lives in one place and the
push path cannot drift from the API. They fail closed: an unreachable
daemon rejects the push.
Three hooks are installed, not two, because of two properties of git
verified against 2.55 rather than inferred:
- GIT_PUSH_OPTION_* reaches pre-receive and post-receive only; the
update hook observably runs without them, so it cannot see
--push-option=skip-validation on its own.
- during pre-receive the pushed objects are still in receive-pack's
quarantine and unreadable by any other process, so the daemon
cannot validate there. git migrates them out before the first
update hook, which makes update the earliest hook that works.
pre-receive therefore forwards the push options and the ref list; the
daemon records them keyed by (repository, receive-pack pid) and by the
exact ref update; update does the rejecting; post-receive notifies.
An update with no recorded pre-receive phase is refused rather than
assumed unskippable.
Each hook is a symlink to the specsrht binary, which dispatches on the
name git invoked it as. Install also sets receive.advertisePushOptions,
without which the escape hatch fails client-side. The daemon refreshes
every space's hooks at startup and treats a failure as fatal.
cmd/specsrht validates every config key at once before opening
anything, serves /healthz on -b (default localhost:5091), runs the
reconciler, and warm-shuts-down on SIGINT and SIGTERM alike by
bridging SIGTERM into the SIGINT core-go's server.Run waits for.
Tested with a real git push against a real bare repo with the hooks
installed: a valid push lands, bad frontmatter is rejected with a
readable message, skip-validation waives it, a force-push to the
approved branch is refused with or without skip-validation, a mistyped
push option is refused rather than ignored, and a push with no daemon
is rejected.
feat: web — the Phase 2 read plane UI and its SCSS entry
SourceHut chrome (nav service-switcher, login block, environment banner,
error page, embedded hashed static assets) ported from compare.sr.ht, plus
the read-plane pages: landing, space document tree, rendered document with
frontmatter/backlinks, and search.
The URL grammar is the design's pinned one: a document's address carries no
extension, ".md" is raw source, ".json" is metadata plus body, and ?rev=
pins any of the three to an immutable revision. Links rendered inside a
pinned page keep the pin, so following one does not silently land on the
approved head.
One human and no visibility levels, so the read ACL is one line: the owner
and its agents read, everyone else is redirected to meta's login (a browser)
or refused with 401 (a client asking for .md/.json).
docs: add the project schema and record the filter-polarity trap
Projects are no longer deferred, so the schema section carries them.
Records why the meta-project is an implicit filter rather than a row: a
stored +everything needs a sync job on every space creation, whose only
failure mode is silently omitting a space, and a corpus-wide view must not
be renameable or deletable.
Flags the polarity hazard between the two layers. An empty project selects
nothing while an empty search space list means every space, so passing a
project's members straight into a query turns an empty project into the
whole corpus. The durable fix is for queries to take the filter type
rather than a bare slice, which is deferred while the read surfaces are
mid-build against the current signature.
feat: projects — a saved filter over one global index, not a container
A project is a named space set; querying one filters the single global
index. The meta-project is an implicit filter at a reserved address rather
than a row: a stored +everything would need a sync job on every space
creation, and its one failure mode is silently omitting a space.
SpaceFilter distinguishes All from an empty member list, because a freshly
created project has no members and must mean selects-nothing. Collapsing
the two would make every new project silently match the whole corpus.
docs: resolve bilingual search with per-line routing
Per-document language routing, the option this document suggested, was
implemented and measured as insufficient: a dominantly Russian spec
quoting English verbatim matches attachments but not attachment. A single
analyzer does not break the other language, it silently stops stemming it,
which is why the flaw survives casual testing.
Per-line routing into ru and en title/body fields, queried across all
four, finds both halves. Dual-writing was rejected despite also fixing
stemming, because it biases ranking toward mixed documents by summing two
field scores. The threshold is 35 percent Cyrillic rather than 50, since
Russian technical prose here routinely runs a third Latin.
Also records that filters must be exact-match — warren filtered on an
analyzed field, which at our scale means a filter for one space can return
another's documents, and a project is defined as a space filter — and
corrects post-receive's unit of freshness to a space at a revision.
feat: search — one global bleve index with per-line ru/en routing
Absorbs warren's index/ and search/ packages, keyword half only, with the
three structural changes the design calls for:
- One global index, not one per project. Every document carries its space;
a project is a term filter over that field (Query.Spaces), so a merge
touches one index and the meta-project is a filter that excludes nothing.
- Rebuilds, not incremental updates. RebuildSpace replaces one space at a
revision, RebuildAll replaces the corpus by building beside the live index
and swapping. Both report duration in Stats.
- Keyword only. warren's vector store and RRF fusion are not ported, not
even as dead code; Search returns ranked hits a later ranker can fuse.
Resolves the design's open mixed Russian/English question. Per-document
routing is not sufficient: the ru analyzer passes English through unstemmed
and vice versa, so a Russian spec quoting English requirements loses
singular/plural matching on whichever half is the minority. Text is routed
per line into ru- and en-analyzed field pairs and queried across both.
Not ported: chunking (an embedding concern, and the vector path is Phase 5),
the graph/pages/meta JSON artifacts, and the vault-commit shell-out.
refactor: move the reconciler's two deletes down to the layers that own them
The reconciler reached past its layer twice, because the primitives it
needed did not exist: a raw DELETE FROM proposal — the only SQL written
outside db/ — and a go-git RemoveReference under gitx's write lock. Both
move down, with no change in behaviour.
db.Store.DeleteOpenProposal keeps the guard in the statement, as
resolveProposal does, and distinguishes the two ways it can bite: a row
that is gone is ErrNotFound, a row that has been resolved is the new
ErrProposalNotOpen, which tells the reconciler "nothing to repair" apart
from "the repair no longer applies".
gitx.Repo.DeleteProposalBranch refuses anything outside proposals/* —
the only thing between a caller bug and a deleted approved branch — takes
the per-space write lock like every other write, and treats an
already-absent branch as success: the repair is a postcondition, and the
ref may legitimately vanish between the listing that found it and the
delete.
docs: guard the reconciler against eating live proposals
Implementing the corrected repair table showed it destroys data as
written, in two ways.
Row-first ordering is forced by the schema, so every proposal passes
through the exact state the table says to delete. An ungraced reconciler
on a timer would delete an agent's work at random and never once in a
test. A row younger than the grace window is in flight, not abandoned.
A proposal branch is cut at the approved head, so its tip is trivially an
ancestor of that head until the agent's first commit. The plain ancestry
rule marks a contentless proposal merged, and open to merged is terminal.
Comparing the tip against the recorded base distinguishes never-written
from actually-merged.
Also records the within-pass ordering that makes the grace window
sufficient, that marking merged must invent approval and merged_rev and
why policy is the safe lie, the two states left unrepaired, and that
spaces are created repository-first while proposals are created row-first.
feat: service — wiring, space lifecycle, read paths, push validation, reconciler
Phase 1 of the implementation plan: the orchestration layer's read, validate
and repair halves. The write plane (propose/merge) is Phase 3 and is absent.
- Config/New assemble a Service from the shared config.ini, reporting every
missing key in one message; TokenStore adapts db/ to authn.TokenStore,
mapping db.ErrNotFound onto authn.ErrUnknownToken so an unknown credential
is a 401 rather than a 503.
- CreateSpace writes the repository first and the row second, removing the
repository again if the insert fails; a crash between the two leaves content
that is merely unlisted rather than a phantom space.
- ReadDocument/ListDocuments/Policy/ResolveRev resolve the approved head or a
pinned revision through one code path.
- ValidatePush checks the refs rule first and unconditionally, then frontmatter
and document-id uniqueness, which --push-option=skip-validation waives. The
rejection is a structured, terminal-shaped message naming the document.
- PlanRepairs is the repair table as a pure, table-tested function; Reconcile
gathers the facts and applies them, listing stale-index spaces for Phase 2.
Two departures from the design's repair table, both to stop the reconciler
destroying live state, documented at their definitions: an open row with no
branch is left alone inside a grace window (every propose passes through that
state), and a branch still sitting on its recorded base is never treated as
merged (its tip is trivially an ancestor of the approved head).
docs: the design is no longer unimplemented
The header still claimed nothing was built. Six packages and one binary
are committed and green, and the document has been corrected against them
six times.
docs: pin URL grammar, document addressing, and the attachment gap
The read plane specified both that /~user/space/path.md renders and that
.md returns raw source, which cannot both hold for one URL. Extensions are
format selectors, so a document's own address carries none.
Adds the addressing rule for documents whose id is absent or duplicated,
which skip-validation makes reachable on the approved branch. A duplicated
id resolves to neither document: picking one silently would aim links and
search results at a document nobody chose, with no visible sign.
Records that cross-space id resolution cannot live in the read layer,
since an archive is one space at one revision; the global hop needs the
registry and therefore belongs in service/.
Promotes attachments from a hypothetical to a measured gap: the git layer
walks and accepts .md only, so embeds have nothing to resolve against.
feat: doc — warren's vault+render absorbed onto the git-object read path
warren scanned a directory (filepath.WalkDir + os.ReadFile). There is no
checkout here, so Scan walks a git tree through gitx instead and feeds the
FromPages seam: the approved head, a pinned sha and a proposal branch are one
code path with a different revision, and nothing downstream of Archive knows
where its pages came from.
Frontmatter is core's, not warren's. Front embeds core.Frontmatter and adds
only what core deliberately does not model — parent, aliases, planned, and the
ordered key list used for display and search text. Two parsers that disagree
about a document header is a bug that surfaces in the ID registry months later.
Documents key on their frontmatter id, falling back to their path: paths move
and ids do not, and a duplicated id resolves to neither document rather than
letting one win silently, matching what the merge already does with the
approved branch. A header core rejects degrades to "no frontmatter" instead of
failing, because --push-option=skip-validation means such a document can exist
and refusing to render it would turn a typo into an outage.
fix: anchor installed data paths to ASSETS, and install schema.sql
Two install-time bugs that only show up on a packaged deploy, never in a
checkout.
MIGRATIONDIR derived from PREFIX, so a default build installed migrations
to /usr/local/share/sourcehut/migrations while specsrht-migrate looked
under [sr.ht]assets, default /usr/share/sourcehut. The two agreed only at
PREFIX=/usr. Every installed data path now anchors to ASSETS, which is the
runtime lookup root.
schema.sql was never installed, so specsrht-migrate init could not find
the schema it applies wholesale on a fresh database. sourcehut-dolt has
the same latent path mismatch.
Static assets are guarded so install works before web/ exists.
feat: specsrht-migrate — brant wrapper for the spec.sr.ht schema
Single-service wrapper around git.sr.ht/~bitfehler/brant, a close sibling of
doltsrht-migrate: the brant subcommands (up, down, current, list, stamp,
validate, ping) plus an extra `init` that applies schema.sql wholesale and
stamps to head for a fresh install.
The DSN comes from [spec.sr.ht]connection-string unless --dsn overrides it;
a missing or empty value is a fatal error rather than a default connection.
Migrations load from ./migrations in a checkout, else from
<[sr.ht]assets>/migrations/spec.sr.ht. -a honours [spec.sr.ht]migrate-on-upgrade
and exits early when it is off. lib/pq's "postgres" driver replaces brant's
pgx default, which this module does not link.
Tests cover flag parsing, DSN precedence and the migrations-directory
resolution order without a database; the init/up round trips against a scratch
schema skip unless SPECSRHT_TEST_PG is set.
docs: record the Phase 0 verdict and its Phase 4 requirement
The gate passed on real data: rewrapping this document from 80 to 58
columns without changing a word gives 1563 changed lines under a line
differ and zero under the prose differ.
The verdict carries a requirement rather than just a pass. Thirteen
percent of real prose modifications shred into interleaved fragments
because those paragraphs genuinely were rewritten sentence by sentence,
so it cannot be fixed in the differ. Every such block scores similarity
at or below 0.73, so the review UI must switch to a two-column old/new
view below ~0.75; building only the inline renderer would make one review
in eight unreadable.
Also records that the differ already emits the comment-anchor tuple, with
heading path excluded from the hash so renaming a section does not orphan
every comment beneath it.
chore: promote fernet-go and go-ini to direct dependencies
authn/ imports both directly to decrypt the unified-login cookie and read
the instance ini, so the toolchain reclassifies them. No version changed
and no module was added.
Deliberately not running go mod tidy yet: bleve, chi, brant and the MCP
SDK have no importer until phases 2 and 3, and tidy would drop them from
go.mod, reintroducing it into every later parallel wave's file set.