Status: design / proposal — nothing implemented yet. Author: bigbes (with Claude). Last updated: 2026-07-22.
bigbes wants a third custom service on the self-hosted SourceHut instance
(*.srht.bigb.es), after dolt.sr.ht (../sourcehut-dolt) and compare.sr.ht
(../sourcehut-compare): separate storage for specs and documents that is
reviewable by a human, modifiable by a human, and uploaded to and read by bots.
Those three verbs are not three features. They are one loop:
bot produces → human curates → bots consume.
Everything below follows from taking that loop seriously. Three properties of the
loop are what a plain git repo, a wiki, or upstream man.sr.ht each fail to
provide:
Same integration model as the two siblings: one Go module, config-driven
[spec.sr.ht] section, unified-login cookie, no upstream SourceHut
modification. Runs at https://spec.srht.bigb.es.
| Decision | Choice |
|---|---|
| Review gate | Proposal-first. Bots always write to a proposal branch; a human approves before anything reaches the approved revision. Per-space policy may whitelist auto-merge namespaces. |
| Storage substrate | Own bare git repos, service-owned (<repos>/~user/<space>), like dolt.sr.ht owns its NBS stores. |
../warren |
Absorbed. Its vault/render/index/search/mcpsrv packages become the read plane of this module. warren remains a standalone tool for local vaults. |
| v1 scope | Full loop, thin. Your git push path + read plane + upload-as-proposal (REST + MCP) + minimal review page (prose diff, approve/reject) + scoped agent tokens. Inline comments come later; the web editor is dropped. |
| Aggregation | Projects (below) — a named set of spaces, which is a saved filter over one global index, not a container. The "meta-project" is the degenerate case (a filter excluding nothing), not special machinery. |
| GraphQL | Read schema at our own /query in Phase 2; mutations stay on REST + MCP until the review model settles. Federation into api.sr.ht is a free config line, not a motivation — the earlier hut and "one endpoint" arguments were wrong and are retracted below. |
| Relationship to existing doc homes | New home for agent-authored specs only. second-brain, the Confluence-synced RFCs, yonote and ultrapack task files stay independent and untouched. Read-only mounts stay in the model as an escape hatch but drop out of v1. |
| Audience | Single-user: bigbes plus his agents. No other human reads or reviews. Approval collapses to triage; visibility levels, approver lists and approval counts drop from v1. |
| Cadence | Bimodal. Specs/RFCs get careful review; research notes and reports flow through with light or automatic approval. .spec.yml policy carries the split. |
| Human edit path | git clone, edit locally, push. The web editor is not v1 and may never be. This makes a real git remote a v1 requirement — see "Two write paths" below. |
| Review surface | Browser, reached by a link the agent hands you. Confirms the prose differ as a v1 build and keeps it as the Phase 0 gate. The inbox is the backstop for work no link reached, not the primary entry point. |
| Read contract | Pinned ?rev= plus an approved/draft split. Nearly free, since the review UI needs blob→render at arbitrary revs anyway, and it makes X-Agent-Base provenance auditable rather than decorative. |
| Storage tiers | One. Git objects are read directly; the materialized checkout is dropped, leaving a bleve index and a blob-sha-keyed render cache as the only caches. |
| Volume | Tens of documents a day or fewer. warren's batch index rebuild is absorbed unchanged; incremental indexing is explicitly not built. |
| Agent tokens | One token plus mandatory provenance. The refs rule (agents write only proposals/*) is the boundary that matters; per-space scoping deferred. |
| Push validation | Your pushes are validated too, with a --push-option=skip-validation escape hatch. |
| Name | spec.sr.ht at spec.srht.bigb.es; module sourcecraft.dev/bigbes/sr-ht-spec, binary specsrht. |
One Go module sourcecraft.dev/bigbes/sr-ht-spec, one binary specsrht
(plus specsrht-migrate, a brant wrapper, exactly as dolt.sr.ht does it).
agents ──MCP / REST──► specsrht ──► bare git repos (authoritative)
│ <repos>/~user/<space>
you ──git push (ssh)───────────────────────┘ ▲
│ │ update + post-receive hooks
├──► one global bleve index (cache)
├──► render cache, keyed by blob sha (cache)
└──► Postgres (proposals, comments, agent
tokens, ID registry)
you ──browser──────────► read + review UI (no editing)
Note the asymmetry: agents never speak git, and you never go through the write API. Each principal has exactly one write path, which is what makes the refs rule below enforceable.
Git is authoritative for document bodies. Postgres never stores a body.
That keeps git clone a complete export of the content, keeps authorship
visible in git log offline, and keeps the blast radius of a Postgres restore
small. It is not a complete export of everything: review decisions, proposal
rationale and comments live in Postgres, so a clone gives you the documents and
who wrote them, not the review record.
Every read resolves a git tree and reads blobs. The approved head, a pinned
?rev=<sha>, and a proposal branch are all the same code path with a different
ref. Nothing is materialized to disk.
Earlier drafts kept a second tier — a checkout of the approved branch, rebuilt on each merge — so warren's file-oriented indexer and renderer could work unchanged. It is dropped. Pinned reads are in scope (see the read contract), and the review UI must render and diff proposal content regardless, so the git-object read path has to exist either way. Keeping a checkout as well would mean two paths that must agree, plus atomic rebuilds, rev stamps, staleness coupling, and a cache directory to operate.
The absorption cost is small and bounded. vault.Scan(root string) touches
the filesystem in exactly two places (filepath.WalkDir, os.ReadFile), and the
seam that replaces it already exists: vault.FromPages(pages, aliases, assets)
builds an Archive with no filesystem at all. A git-tree walk feeding
FromPages replaces the scan, and nothing downstream of Archive changes.
What remains is a render cache keyed by blob sha — content-addressed, so it can never go stale, and droppable at any moment.
One bare git repo = one space, addressed ~<user>/<space>. The unit of
ownership, ACL, and review policy. Contains markdown documents with YAML
frontmatter, plus attachments.
A versioned .spec.yml at the repo root carries the space's own policy — which
means policy changes are themselves reviewable:
review:
# Single-user: the owner is the only approver, so there is no approver list
# and no approval count. The only real knob is which paths skip the gate.
auto_merge: [notes/**, reports/**]
schema: # frontmatter contract, enforced at propose time
required: [id, title, status]
status: [draft, review, superseded]
This one key is what implements the bimodal cadence: specs/ proposals wait
for you, notes/ and reports/ land immediately. Two things follow that are
easy to miss:
approval: human | policy. A bot asking for "the approved text"
of a spec should be able to require human approval and get a different answer
than for a firehose note. Collapsing the two would quietly launder unreviewed
agent output as blessed.Markdown + YAML frontmatter, matching the second-brain / warren conventions
already in use (wikilinks, type/summary/tags).
id: SPEC-0007 # stable; NEVER changes, including on rename
title: Proposal storage model
status: draft | review | superseded # NOT "approved" — see below
supersedes: SPEC-0003
owners: [~bigbes]
tags: [storage, review]
id is the load-bearing field. Paths move; IDs do not. Cross-space links
resolve by ID, comment anchors reference IDs, and bots pin to IDs. The service
enforces global ID uniqueness via a registry table.
"Approved" is a property of the branch, not of the frontmatter. A document
reachable from the approved ref is approved; that is the whole definition. Had
approved stayed in the status enum, either the approved branch would
permanently carry status: draft, or the merge would have to rewrite frontmatter
nobody authored — which would also silently invalidate the agent's If-Match
base on its next read. status stays authored metadata (is this a rough draft,
is it superseded); approval state is read from git.
Frontmatter is validated at propose time. A bot that omits status: gets a
422, not a silent merge. Schema validation at the door is the cheapest available
defense against agent slop, and it costs almost nothing to implement.
A branch proposals/<id> plus a Postgres row. Bundles N document edits with a
title and rationale. State machine:
open ──► merged
└───► rejected
Collapsed from a five-state machine for the single-user case: with nobody else in
the loop, "approve" is "merge now", and there is no one to request changes
from — a proposal you dislike is rejected and the agent proposes again. Keeping
approved and merged as separate states, or a changes-requested cycle, would
have been machinery serving a review conversation that has no second party.
This falls directly out of "human edits happen via git clone" plus
"single-user", and it is the cleanest rule in the whole design:
The human pushes to the approved branch. Agents may only write proposal branches. Your push is the approval — there is nobody to review it.
Enforced at the receive path, not by convention: an agent token can only update
refs matching proposals/*, and only bigbes' own credentials can fast-forward
the approved branch. One rule, two principals, no approval UI needed for the
human half.
The consequence is a real git remote becomes a v1 requirement, which the earlier drafts did not account for. Options, cheapest first:
git push spec:/var/lib/spec/~bigbes/rfcs main needs no transport code.git http-backend — needs auth plumbing we would otherwise
not write, and a second code path to the same refs.transport/server — pure Go and in-house, but server-side
support is the weakest part of go-git and this would be net-new risk on the
critical path.Option 1 is the recommendation for v1: single-user means there is no multi-tenant credential story to build, and the hook applies to every write path, including anything that bypasses the API. Option 2 or 3 only becomes necessary if the audience stops being one person.
Correction. Earlier drafts of this document put validation in
post-receiveand claimed a non-zero exit would reject the push, at "zero service code". Both claims were wrong, and since this is the mechanism the whole human write path rests on, the corrected version is spelled out below.
post-receive runs after refs have already been updated; its exit status is
ignored. Rejection must therefore happen earlier:
update (per-ref, runs before the ref moves, can reject) — enforces the
refs rule: an agent token may only touch proposals/*; the approved branch
accepts fast-forwards from you only, never a force-update. Also where
frontmatter and global-ID-collision validation rejects a bad push.
Your own pushes are validated too, with a --push-option=skip-validation
escape hatch. The failure mode being guarded is not malice, it is a typo: a
duplicated or malformed id: corrupts the global registry and silently breaks
link resolution and search, and is far cheaper to reject at push time than to
find weeks later. The escape hatch exists so that a hook bug or a bad schema
can never lock you out of your own repository.
post-receive (after the fact, cannot reject) — notify the daemon so it
reindexes the changed documents and updates the space's index rev stamp.
bleve is single-writer and the running specsrht daemon holds the index
open, so a separate hook process cannot reindex — and validation needs the same
schema/policy logic the API uses. Both hooks are therefore thin shims that RPC
into the daemon over a localhost socket, calling internal
ValidatePush / Materialize / Reindex entry points.
That makes daemon availability part of the push path, so it needs a stated
behavior: fail closed — if the daemon is unreachable, update rejects the
push. A rejected push is recoverable in one command; a silently unvalidated,
unindexed one is a corruption you discover much later. (The reconciler in
"Consistency and recovery" is the backstop that repairs the other direction.)
Cost note: warren's index.Build is a batch full rebuild (bleve.New
plus a full vault rescan), with no incremental path. At the confirmed volume —
tens of documents a day or fewer — a full rebuild per merge is acceptable, so
this is absorbed as-is rather than rewritten. See the reuse inventory.
Agents keep using the REST/MCP write plane; they never speak git at all. That
asymmetry is deliberate — it is what makes If-Match and provenance trailers
enforceable, since the service constructs every agent commit itself.
A named set of spaces sharing one search scope, one MCP view, and one wikilink namespace. Modeled on hub.sr.ht's project-groups-repos idea.
A project is pure metadata — a saved filter, not a container. It owns no index and no storage.
project: ~bigbes/+everything
spaces:
- ~bigbes/tarantool-rfcs
- ~bigbes/home-ops
The requested meta-project — "merge all my doc work into one searchable thing" — is just a project whose membership is everything. There is no separate aggregate entity, no copying, and no sync job.
Scope note. "All my doc work" means all spaces owned by this service, not
everything on disk: the confirmed boundary is agent-authored specs only, with
second-brain and the Confluence-synced RFCs staying independent. The
meta-project therefore unifies what spec.sr.ht owns, and the service is
deliberately empty until you and your agents fill it.
Mounting external corpora read-only is a plausible future, deliberately left unspecified. No syntax, no mount types, no external-ID mapping — designing it now would be guessing. The one thing keeping the door open is already decided: document IDs are globally unique, so a later import cannot collide with what is already here. That is the whole of the forward compatibility this needs.
Two consequences that must be designed in from the start rather than retrofitted:
One global index, filtered at query time. Every indexed document carries its space ID; a project query is that index filtered to the project's spaces, and an agent's scope is the same filter intersected with its token.
Correction. An earlier draft stated the principle "never build per-viewer indexes" and then specified one bleve index per project — with the
+everythingmeta-project as a full second copy of the corpus. That is the same disease renamed: every merge fans out to N indexes and adding a space to a project forces a rebuild. One global index removes it, and makes the meta-project genuinely degenerate (a filter that excludes nothing).
Document IDs are globally unique, not per-project. Per-project uniqueness
had a hole with no good answer: projects are edited after merges, so adding
a space could juxtapose two already-merged documents sharing an ID with no
merge left to reject. Single user, single instance — a global registry costs
nothing and deletes the problem. [[SPEC-0007]] then resolves the same way
everywhere; relative paths stay space-local.
Reindex on merge touches one index, not N: a merge updates the changed documents in the single global index, and every project that contains the space sees the change for free. Whether that update is incremental or a full rebuild is a volume question, not an architectural one (see the reuse inventory).
GET /~user/space/path.md renders. Content negotiation gives .md raw,
.json metadata+body, ?rev=<sha> pinned to an immutable revision.
Reads default to the approved revision, with a visible "draft is 3 changes ahead" affordance. This is the plane bots consume; it must be boring and pinnable. Serving drafts by default would poison every downstream agent context with unreviewed text — which is the exact failure this whole service exists to prevent.
Implementation is warren, absorbed: vault/ (scan + frontmatter), render/
(goldmark + wikilinks), index/ + search/ (bleve keyword + optional vector),
linkcheck/.
PUT /api/v1/spaces/~bigbes/rfcs/docs/specs/0007-storage.md
If-Match: <base-rev-sha>
X-Proposal: <id> # omit to open a new one
If-Match is the space's approved-head sha at the time the agent read the
document — not the blob sha and not the proposal branch head. One value, one
meaning, identical across REST and MCP:
B, and the
branch is cut from it. Rejected with 409 if it is not an ancestor of the
current approved head.B, which does not
move as the proposal accumulates edits. An agent revising its own proposal
therefore keeps sending the same value, and only a change to the approved
branch under it produces a 409.Leaving this ambiguous is how REST and MCP end up with quietly different 409 semantics, so it is pinned here rather than in the implementation.
The body is the whole document. Whole-document upload, not patches — that is
how agents actually work, and it makes the merge model trivial (see below).
If-Match gives optimistic concurrency: two agents editing the same document
cannot silently clobber each other, and the loser gets a 409 telling it to refetch
and re-propose. That is exactly the right UX for a bot, which can re-derive its
edit cheaply.
The write always lands on proposals/<id>, never on the approved branch.
Review happens in a browser (user-confirmed), which keeps the prose differ on the critical path and keeps the Phase 0 gate as written.
The link is the entry point; the inbox is the backstop. The normal flow is that an agent finishes, hands you a URL, and you open it — you are usually already talking to the agent when it proposes, so a link beats going to look for one. Two things follow, and the first is an API requirement rather than a UI nicety:
spec_propose over MCP and
the REST PUT both return {proposal, url}, so the agent can say
"proposed: https://spec.srht.bigb.es/~bigbes/rfcs/p/42" in the transcript.
An agent that proposes without surfacing a link makes the work invisible.The inbox ("N proposals waiting on you") then catches what no link reached: work from unattended agents, cron-driven runs, and anything proposed while you were away. It shares the page with the digest of policy-merged firehose content, which has the same problem — nobody handed you a link for it either.
A proposal page shows the prose diff, per-document, with approve (which merges immediately) or reject, and (post-v1) inline comments. There is no request-changes cycle and no web editor: with one reviewer, changes are made by rejecting and re-proposing, or by editing locally and pushing.
Verified constraint: go-git v5.19.1 implements only FastForwardMerge
(repository.go:1800; anything else returns ErrUnsupportedMergeStrategy).
There is no three-way merge available in-process.
This constraint is a gift, because the whole-document grain makes a text merge
unnecessary — which is the real justification, standing on its own without any
appeal to a no-shell-out rule. Merging proposal P (based on B, touching file set F) into
approved head H:
for d in F: # F is a set of document IDs, not paths
if blob(path(d)@H) != blob(path(d)@B): # changed under us since B
return 409 stale # refetch and re-propose
newTree = tree(H) with each d's blob replaced (at its path in H)
commit newTree with parents [H, P.head]
Pure plumbing — object.Tree manipulation plus a commit with two parents, all of
which go-git supports directly. No merge algorithm, no conflict markers, no
conflict-resolution UI, ever. A conflict is always "your base moved, re-propose",
which is trivial for an agent and comprehensible for a human. The real merge
commit keeps the proposal visible in git log.
Staleness is keyed by document ID, not path. An earlier draft compared
blob(f)@H to blob(f)@B by path, inside a system whose entire premise is
"paths move, IDs do not". A rename between B and H would then surface as a
baffling 409 — or worse, a proposal re-adding the old path would silently
resurrect a document that had been moved. Resolving each ID to its path in H
before comparing costs one frontmatter parse of the changed set and removes both
failure modes.
Deletion and rename need an explicit surface. The write plane is
whole-document PUT, which gives an agent no way to express "delete this" or
"move this". Two coherent answers; the second is the v1 recommendation:
DELETE and a move operation to the proposal API.status: superseded instead, which is reviewable and reversible.Three systems are touched by a merge — git refs, the bleve index, and Postgres — and none of it is transactional. The rule that makes this tractable:
Git refs are the source of truth for whether a proposal has merged. The Postgres row is the source of truth for that a proposal exists and what it is. The index and the render cache are pure caches.
An earlier draft made git refs authoritative for existence too, and paired that with a repair reading "reconciler recreates the row from the ref". That repair is not implementable, and the split above is the fix.
A bare proposals/42 ref cannot reconstruct its row: the 42 is a Postgres
serial, and title, rationale, base_rev, agent and agent_session live
nowhere in a ref — trailers are on commits, not refs, and title and rationale
are nowhere in git at all. Inventing placeholder values would be worse than the
divergence.
Write ordering removes the unrecoverable case rather than repairing it. The branch name is derived from the proposal's serial id, so the row must be inserted before the branch can be named. Row-first is therefore forced by the schema, and it is also the safe order: a crash can leave a row with no branch — fully recoverable, since the row holds every field — but never a branch whose metadata is lost.
| Crash between | Symptom | Repair |
|---|---|---|
| row insert and branch write | open row, no branch |
reconciler deletes the row; the agent re-proposes (it holds the content) |
| — | orphan proposals/* ref with no row |
delete the ref: it is unreferenced and its content is unrecoverable anyway |
| merge commit and row update | merged ref, row still open |
reconciler marks merged (the ref is truth for merged-ness) |
| merge and reindex | stale index | per-space index rev stamp ≠ approved head → reindex |
A reconciler runs at startup and periodically: scan proposals/* refs and
each space's approved head, compare against rows and index rev stamps, repair
divergence. Deleting an unreferenced ref or a contentless row is safe precisely
because an agent's proposal is cheap to reproduce — it still holds the document
it wanted to write.
Schema-enforced invariants (added during implementation, worth stating in the design because they encode decisions rather than mechanics):
state = 'merged' iff approval IS NOT NULL iff merged_rev IS NOT NULL; state = 'open' iff resolved IS NULL. A merged row with a null
approval would silently launder policy-merged content as human-approved — the
exact distinction the bimodal decision exists to preserve.agent and agent_session are non-empty, not merely NOT NULL. Provenance is
described as mandatory in prose; '' would satisfy NOT NULL and gut it.UPDATE ... WHERE state = 'open'), not only
in Go, so a second writer cannot merge an already-merged proposal.receive-pack
(spawned by sshd); agent proposals and merges are go-git in-process. Two
independent ref-locking implementations on the same loose refs and
packed-refs. go-git's locking is not verified to interoperate with native
git's, so the daemon takes a per-space mutex for all its git writes, and a
merge that loses a ref CAS to a concurrent human push retries rather than
failing.Naming these now so they are not discovered late.
Markdown reflows. A one-word edit renders as a whole-paragraph replace under a
line-oriented differ, which makes reviewing agent output miserable — and
reviewing agent output is the entire product. What is needed is word-level
intra-paragraph diffing over the rendered block structure, not
diff --git output piped into a viewer.
This is the single UI decision that determines whether the service is pleasant or
useless, which is why it is the Phase 0 gate below. Note that compare.sr.ht's
@pierre/diffs bundle is a code differ and is the wrong tool here; the prose
differ is likely net-new (segment into blocks → align blocks → word-diff within
matched blocks).
../sourcehut-compare/docs/inline-comments.md already hit this with
(file, side, line) against moving refs. In prose it is worse, because line
numbers are meaningless across a reflow.
Anchor to (doc id, heading path, block index, block content hash). Resolve by
content hash first, fall back to heading-path + block index, and when both fail
mark the comment outdated rather than silently relocating it. Anchoring to
doc id rather than path is what makes comments survive renames.
Single-user does not mean "no authorization". It relocates it. There is only one human, so no human-vs-human boundary exists — but there are many agents, they are the ones actually writing, and constraining what each may touch is the whole point of the permission model.
Concretely, this deletes from v1: visibility levels (public/unlisted/private), approver lists, approval counts, request-changes round-trips, and per-human ACL rows. The unified-login cookie is still needed — not to tell users apart, but to tell you from an unauthenticated request.
v1 ships one agent token plus mandatory provenance, not per-agent scopes. The distinction worth drawing is between the two boundaries this design has:
proposals/*. That is what
actually bounds the damage a confused or runaway agent can do, and it holds
with a single shared token.specs/ is real defence in depth, but
every agent here is a session you launched yourself, and a bad proposal is
visible and rejectable rather than destructive. Adding scopes later is a column
on the token row plus a filter clause — not an architectural change.What is not optional is provenance: agent identity and session ID are required on every write and recorded in git trailers. One token still yields a full audit trail, because the trailers, not the credential, are what identify who did what.
One agent token (no roles, no per-space scopes — see above) plus a required agent identity string. Every commit records it in a way that survives clone:
Author: claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
Committer: bigbes <bigbes@gmail.com>
Add storage model section
X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
X-Agent-Base: <sha>
Git trailers rather than a Postgres-only audit table, so provenance is visible in
plain git log on any clone and cannot drift from the content it describes.
Details settled during implementation, recorded here because the sketch above left them open:
agent@<host of [spec.sr.ht] origin> — so agent@spec.srht.bigb.es. Earlier drafts wrote
agent@srht.bigb.es (the bare cookie domain) with no stated source. Deriving
it from our own origin adds no config key and cannot drift from it.X-Agent-Base is mandatory, validated as 7–64 lowercase hex. An empty base
is a provenance claim with nothing behind it, which would make the trailer
decorative rather than auditable — the thing the read-contract decision was
supposed to buy. It must be an object name, never a ref name.X-Agent / X-Agent-Session request
headers, named after the trailers they become. Deliberately no
X-Agent-Base header: the base is If-Match, and a second spelling of one
value is exactly how REST and MCP drift apart. The MCP tools map arguments onto
the same three values.cookieAuth uses
DecryptWithoutExpiration. We match it, so "expired" collapses into "sealed
with a key we no longer hold" — i.e. rotating [sr.ht] network-key is the
only logout-everywhere lever, instance-wide.MCP is a first-class surface, not a wrapper — it is how agents will actually
consume this: spec_search, spec_read, spec_propose, spec_comment,
spec_status. warren's mcpsrv/ is the starting point.
| From | What | Notes |
|---|---|---|
../warren |
vault/, render/, index/, search/, mcpsrv/, linkcheck/ |
Most of the read plane, already written. Absorbed — but see the two gaps below; this is not free. |
../sourcehut-compare |
chrome/templates, cookie→identity, GraphQL authorizer + TTL cache, SCSS pipeline, contrib/ nginx+systemd shape |
Closest sibling; copy the integration scaffolding wholesale. |
../sourcehut-dolt |
bare-store lifecycle under <repos>/~user/<name>, brant migration wrapper, config validation |
Same storage-root and migration patterns. |
sr-ht-core (fork) |
config, crypto, auth, database, server, gqlgen scaffolding + webhooks |
Pinned to sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152, byte-identical to both siblings. No replace directive — see the note below. Never go get -u. The gqlgen path is the blessed one and is what Phase 2's /query is built on. |
Note on the core-go pin. dolt.sr.ht's README describes this dependency as "replaced by the instance fork via a
replacedirective". That is stale — neither sibling'sgo.modcontains such a directive, and this service has none either. The fork declares the module pathsourcecraft.dev/bigbes/sr-ht-coreas of commitdd418a20, so it is required directly under that path; areplacepointing atgit.srht.bigb.es/~bigbes/core-gois rejected by the Go toolchain for any commit at or beforec2c2f38, where the fork'sgo.modstill saidgit.sr.ht/~sircmpwn/core-go. Fetching still needsGOPRIVATE=git.srht.bigb.es.
Two absorption gaps, both understated in earlier drafts:
index.Build is a batch full rebuild (bleve.New + full vault rescan +
chunking), with no incremental path. At the confirmed volume — tens of
documents a day or fewer — this is fine and is absorbed as-is. Incremental
per-document upsert/delete is explicitly not built. If merges ever get slow
enough to notice, that is the signal to revisit; worth instrumenting rebuild
duration so the trigger is measured rather than guessed.vault.Scan is file-oriented, so it is replaced by a git-tree walk feeding
the existing vault.FromPages seam (see "One storage tier"). Bounded and
small; everything downstream of Archive is untouched.Genuinely net-new: proposals, prose diff, review plane, provenance trailers, frontmatter lifecycle, and the reconciler.
Recipe B from the sourcehut-custom-service model: pure Go, API side on
core-go, web chrome reimplemented (core-go has no HTML templating — the
Jinja chrome lives only in Python core.sr.ht). compare.sr.ht already did
exactly this, so its nav template, cookie→identity handler, and SCSS entry are
the thing to copy rather than rederive.
Integration is entirely config + nginx + DNS. No upstream source is modified.
The section name must be the literal spec.sr.ht — the .sr.ht suffix is
what puts us in the nav network list (core.sr.ht/srht/app/flask.py::_network)
and what other services look us up by.
[spec.sr.ht]
origin=https://spec.srht.bigb.es
; Federated into api.sr.ht from Phase 2 (read side). api.sr.ht fetches
; <api-origin>/query; omit this key and it falls back to <origin>/query.
api-origin=https://spec.srht.bigb.es
connection-string=postgresql://specsrht@localhost/spec.sr.ht?sslmode=disable
; Bare repos, service-owned: <repos>/~<user>/<space>
repos=/var/lib/spec
; Bleve index + render cache. Pure cache; safe to delete at any time.
cache=/var/cache/spec
static-dir=/usr/share/sourcehut/spec.sr.ht/static
;bind-address=127.0.0.1:5091
migrate-on-upgrade=yes
Canonical key names only — origin, api-origin, connection-string,
migrate-on-upgrade are read by the shared accessors (config.GetOrigin,
config.GetAPI, server.WithDefaultMiddleware). repos mirrors
dolt.sr.ht/git.sr.ht; static-dir and bind-address are the local house
convention already used by both siblings. cache is ours.
| Key | Used for |
|---|---|
[sr.ht] network-key |
Fernet-decrypt sr.ht.unified-login.v1; mint Internal auth tokens |
[sr.ht] owner-name / owner-email |
config.GetOwner panics if missing; also the committer identity on merges |
[sr.ht] site-name / environment |
nav brand; non-production shows the dev banner |
[sr.ht] internal-ipnet |
this host must fall inside it or internal GraphQL calls are rejected |
[webhooks] private-key |
crypto.InitCrypto fatally requires it even though v1 emits no webhooks |
[meta.sr.ht] origin |
login/logout redirects, profile fetch, PAT validation |
[git.sr.ht] repos / api-origin |
only if read-only mounts of docs/ dirs in git.sr.ht repos are ever enabled (not v1) |
[spec.sr.ht] origin= line must be visible to every other
service's config, not just ours; each service builds its own nav
independently. One shared /etc/sr.ht/config.ini makes this automatic —
then restart the other services so they pick up the switcher entry.spec.srht.bigb.es must be under the shared cookie domain
(*.srht.bigb.es), or the unified-login cookie is never sent to us and every
viewer looks anonymous.proxy_pass http://127.0.0.1:5091, modeled on
contrib/compare.sr.ht.conf. Note client_max_body_size needs raising if
attachments are allowed.specsrht-migrate, a brant wrapper, copied from
doltsrht-migrate./query in Phase 2Serve a read-side GraphQL schema — space, document, project, search,
proposal listing — at https://spec.srht.bigb.es/query in Phase 2. Mutations
stay on REST + MCP until the review model has settled. Federation into
api.sr.ht is then a single config line (api-origin=) that we may as well set,
but it is not a reason to do any of this.
Correction. An earlier draft argued for federation on the grounds of "one endpoint, one token" and
hutergonomics. Both arguments were wrong and are retracted here.
Two tempting arguments that do not survive checking:
hut does not go through the gateway. hut/client.go builds its endpoint
from the per-service origin (inst.Services[service].Origin + "/query"), not
from api.sr.ht. So federating buys hut nothing; what hut needs is a
/query at our own origin — and realistically also a fork, since its
subcommands are generated per service. For the same reason "one endpoint, one
token" is hollow: a meta PAT already validates against each service's own
/query directly.What actually justifies serving GraphQL at all:
core-go/webhooks is GraphQL-native.
Skipping GraphQL is a deferral, not a saving — and a costlier one once a schema
has to be retrofitted around an established REST surface.That is a decent case for a schema and a weak case for the gateway. If it turns
out api.sr.ht is not deployed here (see below), nothing about Phase 2 changes.
Why writes stay on REST for now, and this is a technical reason rather than
scope discipline: the write plane's concurrency story is If-Match: <base-rev>,
an HTTP idiom with well-defined 409 semantics that agents get right by default.
Modeling base-rev as a mutation argument is perfectly doable, but it is a
contract worth designing once, after the proposal state machine has stopped
moving. Federation compounds this — once a type is in the gateway it is a
consumed contract, so churning the proposal/review types there is expensive.
Read types (space, document, project, search) are stable from the start; the
review types are not, which is precisely the line drawn above.
MCP and GraphQL are not competitors here. The MCP tools call the same resolver layer, not a parallel implementation.
Opting out, if we ever do, is verified safe. api.sr.ht federates every
config section ending in .sr.ht with no allow-list, pointing at
api-origin (else origin) + "/query". updateSchema (api.sr.ht/main.go)
logs and skips services that are offline or serve an invalid schema, and
BuildSchema runs over the healthy ones only — so a non-GraphQL service costs
one Unable to update service log line, not a broken gateway. It is also not a
hot loop: schema refresh is SIGHUP-driven, not on a ticker (the goroutine
selects on signalChan), so the fetch happens at startup and explicit reload
only. dolt.sr.ht and compare.sr.ht run with exactly this property today.
Open: whether api.sr.ht is deployed on this instance at all is unconfirmed —
sourcehut/sr.ht-nginx/ is the upstream mirror, not our instance config, and no
api.srht.bigb.es reference exists in the tree. Given the retraction above this
barely matters: Phase 2 serves /query at our own origin either way, and hut
targets that origin directly. Federation is a config line to set if the gateway
happens to exist.
Mirrors the siblings: a pure core/ with no external dependencies, one package
per subsystem, one service/ layer that everything user-facing calls.
core/ pure domain. Space/doc/rev/ID validation, .spec.yml policy,
proposal state machine, sentinel errors. No external deps.
gitx/ bare-repo lifecycle, tree walk, blob read, proposal branches,
the tree-splice merge, refs-rule enforcement. go-git only.
db/ Postgres: proposals, ID registry, agent token, index stamps.
doc/ absorbed warren vault/ + render/: frontmatter, Archive,
goldmark rendering, wikilink resolution. Fed by gitx trees.
search/ absorbed warren index/ + search/: one global bleve index,
query-time project filtering.
prosediff/ word-level prose diff over rendered block structure. Net-new,
riskiest, and the Phase 0 gate.
authn/ unified-login cookie -> identity, agent token validation,
provenance trailer construction.
service/ orchestration: read, propose, merge, reconcile, digest. The
single layer REST, MCP and GraphQL all call.
api/ REST handlers (write plane + JSON reads).
mcpsrv/ absorbed warren mcpsrv/, wired to service/.
graph/ gqlgen read schema + resolvers (Phase 2).
web/ chi router, SourceHut chrome, templates, review UI, static.
hooks/ update + post-receive shims and the daemon-side RPC endpoints.
cmd/specsrht/ daemon entry point, startup validation.
cmd/specsrht-migrate/ brant wrapper, copied from doltsrht-migrate.
contrib/ nginx block, systemd unit, forced-command wrapper.
migrations/ brant .sql migrations. scss/ stylesheet entry.
Dependency direction is strictly downward: core/ depends on nothing,
service/ depends on everything below it, and api/mcpsrv/graph/web
depend only on service/. Nothing above service/ may touch gitx/ or db/
directly — that is what keeps the three agent-facing surfaces behaviourally
identical.
Git holds the documents; Postgres holds only what git cannot answer cheaply. Every table here is reconstructable from refs by the reconciler, which is what makes the non-transactional writes tolerable.
-- Spaces exist as repos; this table is for listing and index bookkeeping.
CREATE TABLE space (
id SERIAL PRIMARY KEY,
owner TEXT NOT NULL, -- "bigbes", no ~ prefix
name TEXT NOT NULL,
created TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (owner, name)
);
-- Global, not per-project: a later import cannot collide.
CREATE TABLE document_id (
doc_id TEXT PRIMARY KEY, -- "SPEC-0007"
space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- current path on the approved branch
updated_rev TEXT NOT NULL
);
CREATE TABLE proposal (
id SERIAL PRIMARY KEY,
space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE,
title TEXT NOT NULL,
rationale TEXT,
base_rev TEXT NOT NULL, -- the If-Match value; does not move
branch TEXT NOT NULL, -- "proposals/42"
state TEXT NOT NULL, -- open | merged | rejected
approval TEXT, -- human | policy, set on merge
merged_rev TEXT,
agent TEXT NOT NULL, -- "claude-code/spec-writer"
agent_session TEXT NOT NULL,
created TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved TIMESTAMPTZ
);
CREATE INDEX ON proposal (state, created DESC);
CREATE TABLE agent_token (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
token_hash BYTEA NOT NULL UNIQUE,
created TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked TIMESTAMPTZ
);
-- Index staleness: compared against the space's approved head.
CREATE TABLE index_stamp (
space_id INTEGER PRIMARY KEY REFERENCES space(id) ON DELETE CASCADE,
rev TEXT NOT NULL,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- "What landed since you last looked", for the policy-merged digest.
CREATE TABLE digest_mark (
owner TEXT PRIMARY KEY,
seen_at TIMESTAMPTZ NOT NULL
);
Projects are deliberately absent: a project is a saved filter, so it is a name plus a space-id list, and adding it before there are several spaces to filter would be speculative. It arrives with the meta-project in Phase 2.
comment is likewise absent — inline comments are post-v1, and the anchoring
model (doc_id, heading path, block index, block hash) should be settled by
building the review UI before it is committed to a schema.
Milestones are feature-shaped; within each, work is dispatched in waves
following the parallel-implementer convention: the root package is implemented
and committed first, then siblings that import it and write disjoint directories
go out in parallel. Every external dependency is added in the foundation commit
(with a throwaway smoke-import build to populate go.sum) so go.mod stays out
of every parallel wave's file set; go mod tidy runs once, at the very end. Each
parallel implementer builds and tests only its own package — never ./...,
since siblings may not compile yet.
Ordering constraint. Because the confirmed scope is a fresh silo with no read-only mounts, the store is empty until somebody fills it — a read plane shipped first would have nothing to render. The human push path therefore lands in Phase 1: it is how content first exists, and it needs little service code.
Two spikes. Both must pass before Phase 1 starts.
Spike 2 is deliberately the one with no fallback plan, so it runs first.
Foundation commit (serial). go.mod with every external dependency
(go-git, bleve, goldmark, chi, lib/pq, yaml.v3, the MCP SDK, brant, plus the
core-go fork replace), then core/ complete with table-driven tests: name
and path validation, ID grammar, .spec.yml parse and policy evaluation,
frontmatter schema validation, the proposal state machine.
Wave A (parallel — gitx/, db/, authn/). Disjoint directories, all
importing only the committed core/.
gitx/ — bare-repo lifecycle, tree walk, blob read, branch create, tree-splice
merge, refs-rule predicate. Tests build fixtures programmatically.db/ — the schema above plus queries; schema.sql and the first brant
migration.authn/ — cookie decrypt to identity, agent-token validation, provenance
trailer construction. Tests forge cookies with a synthesized ini (random fernet
key + ed25519 seed), as dolt.sr.ht's authn/ does.Wave B (serial). service/ read + reconcile paths, hooks/ (the update
and post-receive shims and their daemon RPC endpoints), cmd/specsrht skeleton,
cmd/specsrht-migrate.
End state: git push a space; malformed pushes are rejected with a useful
message; the daemon can read what landed; the reconciler repairs a killed daemon.
Wave A (parallel — doc/, search/). doc/ is warren's vault + render
with Scan replaced by a git-tree walk feeding the existing FromPages seam.
search/ is warren's index + search against one global bleve index, with
query-time project filtering. Both are ports with focused tests, not new design.
Wave B (parallel — web/, graph/, mcpsrv/ read tools). All depend only
on service/. web/ brings the chrome, cookie login and SCSS pipeline copied
from compare.sr.ht.
End state: agents read and search everything you have pushed, over MCP, REST
and /query. Useful on its own, before any review machinery exists — which
is the point of this ordering.
service/ write paths (propose, If-Match resolution, merge, auto-merge policy,
digest bookkeeping), then api/ and mcpsrv/ write tools in parallel. Every
write response carries the proposal URL.
End state: an agent proposes and hands you a link; approving from an API call merges; a stale base returns 409.
prosediff/ productionised from the Phase 0 spike, then the review UI: proposal
pages at stable URLs, approve/reject, the inbox, and the policy-merged digest.
End state: the full loop — bot produces, you curate in a browser, bots consume.
Inline comments (settle anchoring against the built UI first), webhooks and notifications, GraphQL mutations once the proposal types stop moving, vector search, per-space token scoping, read-only mounts.
Dropped outright: the web editor, and with it the concurrent-web-edit-versus- push conflict it would have created.
Run against the real instance, in order. Each step fails loudly rather than degrading, which is the point of listing them.
GET https://spec.srht.bigb.es/healthz → ok; the service appears in the nav
of git.sr.ht and meta.sr.ht (after restarting them).git push a space with a valid document → accepted; it renders in the browser
and appears in search within one merge cycle.git push a document with a duplicate id: → rejected by the update
hook with a message naming the collision; retry with
--push-option=skip-validation → accepted.git push --force to the approved branch → rejected.proposals/* → accepted; the same token
attempting the approved branch → rejected.auto_merge path → lands immediately, and shows in the digest
marked approval: policy, not human.?rev=<sha> of a superseded revision → renders that revision, not the head.kill -9 the daemon mid-merge, restart → the reconciler repairs the proposal
row and the index stamp; no manual intervention.receive-pack on the same refs. Mitigated by a per-space
mutex and CAS retry, but the interoperation of the two locking implementations
is assumed rather than proven. Worth a deliberate concurrent-push test.vault/render/search are copied in, they
fork from upstream warren. Accepted deliberately — but it means bug fixes do
not flow back, and that should be a conscious choice each time.spec.sr.ht / spec.srht.bigb.es follows the
named-by-function pattern of the two siblings. docs.sr.ht collides
conceptually with upstream man.sr.ht.~user/+project distinguishes projects from spaces
(~user/space) in one character; alternatives are /projects/~user/name or
reusing hub.sr.ht's own namespace./mcp) keeps it to
one listener and one nginx block; a second port is only needed if MCP ends up
wanting different timeouts than the web UI.search/keyword.go already wires both the lang/en and lang/ru analyzers
with per-index selection, so the starting point is better than an earlier draft
claimed. What is still missing is per-document language routing: specs here
are written in both (cf. the ru-spec-style skill), and one analyzer per index
mangles whichever language it was not chosen for. Options: detect language at
index time and write to ru/en fields, querying both; or accept degraded
stemming on the minority language.All seven questions raised by the independent review have been decided and folded into the sections above:
| Question | Answer |
|---|---|
?rev= pinning and approved/draft split? |
Both. Nearly free — the review UI needs blob→render at arbitrary revs regardless — and it makes X-Agent-Base auditable. |
| Materialized checkout? | Dropped. One read path over git objects; render cache keyed by blob sha. |
| Firehose volume? | Tens of documents a day or fewer. Batch index rebuild absorbed as-is; incremental indexing not built; retention a non-issue. |
| Agent token machinery? | One token + mandatory provenance. The refs rule is the boundary that matters; per-space scoping deferred to a column and a filter clause. |
| Validate your own pushes? | Yes, with --push-option=skip-validation. Guards typos that corrupt the global ID registry, without a lockout risk. |
| Import external corpora? | Probably eventually; left unspecified. Global IDs are the only forward compatibility required. |
| Name? | spec.sr.ht at spec.srht.bigb.es, module sourcecraft.dev/bigbes/sr-ht-spec, binary specsrht. |