Status: being built. Phase 0 gate passed; Phase 1 mostly landed —
core/, gitx/, db/, authn/, prosediff/, doc/ and
cmd/specsrht-migrate are committed and green. This document is kept
corrected against the implementation: where building something proved a
section wrong, the section was fixed and the correction noted rather than
quietly overwritten.
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.
Addressing when id: is absent or duplicated. Because
--push-option=skip-validation exists, both states are reachable on the approved
branch, and the read plane still has to name such a document. The rule, mirroring
what the merge already does:
Cross-space ID resolution belongs to service/, not the read layer. A
rendered archive is one space at one revision, so [[SPEC-0007]] can only be
resolved space-locally there. The global hop needs the document_id registry,
which means the link resolver is layered: doc/ resolves within the space and
reports misses; service/ resolves the misses against the registry.
"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.
An earlier draft specified two hooks and put the skip-validation escape hatch
in update. That is unimplementable, for two independent reasons found by
building it:
update. githooks(5) documents
GIT_PUSH_OPTION_COUNT/_N for pre-receive and post-receive only, and
git observably runs update with them unset. The escape hatch was therefore
unreachable where the design put it.pre-receive cannot read the pushed objects. During pre-receive they
live in GIT_QUARANTINE_PATH; a process opening the bare repo normally gets
"could not get object info". git migrates the quarantine out after
pre-receive and before the first update. So update is the earliest
hook at which the daemon can actually read what was pushed — the design's
choice of update as the rejecter is correct, but for a reason it never
stated.Neither hook can do the whole job, so the work splits across three:
pre-receive — carries the push options (the only place they exist) and
acts as an early liveness check. Cannot validate content: the objects are
quarantined.
update (per-ref, 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, recalling the skip flag
pre-receive recorded.
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
rebuilds that space at its new revision and updates the index rev stamp.
The three are correlated by (repository, receive-pack pid) plus an exact
ref-update match, so a recycled pid cannot be mistaken for a live push. The new
failure mode this introduces is honest and fail-closed: a daemon restarted
between pre-receive and update rejects with "the daemon did not see the
pre-receive phase of this push; push again".
Per-ref rejection is not atomic. update runs once per ref, so pushing
main and proposals/7 together with main rejected still lands
proposals/7. Making it all-or-nothing would require deciding in pre-receive,
which cannot read the objects. Accepted, and stated here so it is not discovered
during a partial push.
Note the unit: a space at a revision, not a set of changed documents. An
earlier draft said "reindexes the changed documents", which contradicts the
decision to absorb the batch rebuilder — and nothing tracks per-document change,
because index_stamp records exactly a space and a rev.
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).
Audience, settled. An earlier draft called this plane "anonymous-capable" while the verification checklist required "anonymous request → no content leaks". With visibility levels deleted from v1 there is no knob reconciling those, so the read plane is fail-closed: the owner and agent tokens read; everyone else is redirected to meta login, or gets 401 on the machine formats. A publicly readable corpus is a one-line change if that is ever wanted, but it has to be a decision rather than an ambiguity.
GET /~user/space/specs/0007-storage renders. Content negotiation gives
.md raw, .json metadata+body, ?rev=<sha> pinned to an immutable revision.
URL grammar, pinned. An earlier draft wrote both "GET /~user/space/path.md
renders" and ".md gives raw" — which cannot both be true of one URL. The
extension is a format selector, so the document's own address carries no
extension: /~user/space/specs/0007-storage renders, + .md is raw source,
+ .json is metadata plus body.
Reads default to the approved revision. The rule is enforced in the shared
service layer, not in each surface: a read accepts only the approved-head
default or a full 40-character object name. Ref names are refused, because
?rev=proposals/42 would otherwise make the read plane serve unreviewed text
that then flows into agent context as though approved — the precise failure this
service exists to prevent, and reachable by a crafted query string. Abbreviated
shas are refused too: one that is unique today can become ambiguous later, so a
pinned revision would silently stop meaning one thing.
Reading a proposal branch is a real need for the review UI, so it exists as a separate, deliberately awkward method rather than a flag — something a caller asks for by name and a reviewer can grep for, never something a read surface can be talked into. 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.
Correction on its cost. An earlier draft claimed this costs "one frontmatter
parse of the changed set". It does not: resolving IDs to paths requires an
ID→path index over all of H and all of B, so every document in both trees
is parsed, not just the changed ones. At the confirmed volume this is free; at
ten thousand documents it is two full-tree reads per merge. The document_id
table is exactly this index, but gitx must not depend on db/ — and git is the
source of truth regardless — so the index is rebuilt from the trees.
Four staleness cases, not one. The two-line rule above only covers "the blob changed". The others are silent corruption if unhandled, and are implemented:
| Case | Why it must 409 |
|---|---|
| blob changed under us | the ordinary conflict |
document removed from H since B |
splicing it back silently resurrects a deletion |
same ID appeared on H after B |
two documents now claim one identity |
a new document's path is occupied on H |
the splice would overwrite an unrelated document |
Already-merged proposals need an ancestry check, not a staleness check. Once
H carries the proposal's own blobs, the blob comparison is trivially "changed"
and returns a confusing 409. service/ must test IsAncestor(proposalHead, H)
first and report "already merged" — the staleness rule cannot distinguish the two
cases on its own.
Malformed documents on the approved branch are tolerated, not fatal. Because
--push-option=skip-validation exists, a document with a broken or duplicated
id: can be on the approved branch. Failing the ID-index build on encountering
one would let a single typo block every future merge in that space — an outage
caused by a cosmetic error. Such a document is instead recorded by path, so its
path stays occupied and nothing silently overwrites it, and excluded from the ID
map; a duplicate ID is refused only when a merge actually needs to resolve it.
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 decision:
DELETE and a move operation to the proposal API.status: superseded instead, which is reviewable and reversible.So is .spec.yml, and the design contradicted itself here. The Space section
says "policy changes are themselves reviewable", implying an agent proposes one —
but the merge is defined purely over document IDs, and .spec.yml has none, so
there is no way to express it. Rather than add a path-keyed side channel to the
merge for a single file, policy changes reach the approved branch only by human
push, consistent with deletion and rename. A proposal containing any
non-document change is refused outright. With one reviewer this costs nothing:
the person who would approve the policy change is the person pushing it.
Refs outside the two namespaces are rejected for both principals. The design
covers the approved branch and proposals/* and was silent on tags and other
branches. Silence would mean "allowed", and a ref nothing ever reads is a place
for content to rot unnoticed. Loosening this later is one branch in a switch.
Specs here are written in Russian and English, often within one document (cf. the
ru-spec-style skill). The design's suggested fix — detect each document's
language and route it to a ru- or en-analyzed field — was implemented,
measured, and found insufficient. The resolution is per-line routing into
title_ru/title_en and body_ru/body_en, queried across all four. Each
document still carries one dominant-language label, used for reporting and
snippet selection, never for placing text.
The measurements that settled it, each a committed test:
ru analyzer leaves "indexes rebuild all the documents"
entirely unstemmed (and keeps English stop words); the en analyzer does the
same to Russian. Foreign-script terms still match literally, which is why
this looks fine in casual testing. The failure is subtler and worse: singular
stops finding plural.body_ru: searching attachments finds it, searching attachment does not.
That is exactly the document shape this corpus has.Dual-writing the whole text into both fields was rejected, although it also fixes stemming: it doubles the index and makes every mixed document match in two fields at once, summing both scores in the disjunction — a systematic ranking bias toward mixed documents that has nothing to do with relevance.
The threshold is 35% Cyrillic, not 50%, requiring at least 12 letters to decide at all, falling back to the document's dominant language otherwise. The error directions are not symmetric: English prose has essentially no Cyrillic, whereas Russian technical prose here routinely runs a third Latin characters — identifiers, product names, untranslated terms.
Residual limitation, stated plainly: a single line mixing both languages goes wholly to one field. Fixing that needs token-level routing and is not worth it.
Filters must be exact-match, and warren's were not. warren filtered by
section using a match query on an analyzed field. At this scale that is a
correctness bug rather than a wart: a project is a space filter, so an
analyzed ~bigbes/home-ops tokenizes and a filter for one space can return
another's documents. space, section and lang are keyword-analyzed and
matched as terms.
Measured rebuild cost (1000 synthetic specs across 5 spaces, ~3 KB each): full rebuild 284 ms, single space of 200 documents 58 ms, queries 0.4 ms. At roughly 30× the confirmed daily volume, the "absorb the batch rebuild, do not build incremental indexing" decision holds by a wide margin — and rebuild duration is instrumented, so the decision to revisit it will be measured rather than guessed.
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.
Spaces order the other way round: repository first, then the row, with the directory removed if the insert fails. Git is authoritative and a space's owner and name are recoverable from its path, whereas a row without a repository is a phantom that lists correctly and 404s on every read — and, with no delete for it, is unrepairable.
| Crash between | Symptom | Repair |
|---|---|---|
| row insert and branch write | open row, no branch, older than the grace window |
delete 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 | row still open, branch merged into the approved head and tip ≠ its recorded base |
mark merged (the ref is truth for merged-ness) |
| merge and reindex | stale index | per-space index rev stamp ≠ approved head → reindex |
The two emphasised guards are not fussiness. Without them this table eats live data, and both were found by implementing it:
open → merged is terminal.
Comparing the tip against the proposal's recorded base_rev distinguishes
"never written to" from "actually merged".Ordering within a pass matters too: list proposal refs for every space before reading any proposal row. The other order makes a concurrently-opened proposal look like "branch with no row" and deletes a live agent's branch; this order makes it look like "row with no branch", which the grace window covers.
Marking merged cannot fully satisfy the schema, and invents two values. The
merged state requires approval and merged_rev, and neither is recoverable
from a ref — the same argument that killed rebuilding a row from a ref. So:
approval is recorded as policy. Of the two available lies it is the safe
one: human would launder unreviewed content as blessed, whereas policy
merely over-reports into the digest, where a human sees it again. A sound
inference exists — a merge touching any path that fails Policy.AutoMerges
cannot have been policy-authorized — and is worth adding once auto-merge
evaluation exists in Phase 3.merged_rev is recorded as the current approved head, not the merge commit.
They coincide in the common crash-right-after-merge case and diverge if the
branch moved on. Finding the true merge commit needs a history walk for "the
commit whose second parent is this tip", which gitx does not expose.Two states are deliberately not repaired, and are reported rather than touched: a merged or rejected row whose branch still exists (not a half-finished write — branch cleanup is separate), and a space row whose repository is missing (the table covers proposals and the index; a missing repo is an operator problem, not a torn write).
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 was the Phase 0 gate. compare.sr.ht's @pierre/diffs
bundle is a code differ and is the wrong tool here; the prose differ is
net-new (segment into blocks → align blocks → word-diff within matched blocks).
Measured against 11 real adjacent-commit pairs of this very document, plus a
control. The headline number: rewrapping the real 1139-line docs/DESIGN.md
from ~80 to 58 columns, without changing a single word, produces 1563 changed
lines under a line differ and exactly 0 changes under the prose differ. That is
the whole premise of the service, confirmed rather than assumed.
But the verdict carries a Phase 4 requirement. Of 69 real prose modifications: 39% are one clean substitution, 38% a few separate edits, 10% busy but followable, and 13% shred into interleaved fragments. The shredding is genuine — those paragraphs really were rewritten sentence by sentence — and is therefore not fixable in the differ. It is a presentation problem:
Every badly-shredded block scores
Similarity ≤ 0.73. The review UI must render inline word diffs above ~0.75 and a two-column old/new view below it.Similarityis exported on every change for exactly this switch.
Building only the inline renderer would make one review in eight unreadable, and that is the one where the agent rewrote the most.
Known limitations, each pinned by a test rather than discovered later:
|---|---| into the
table node, so |---| → |:--| reports no change at all. A real gap; small,
but silent.../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.
The Phase 0 differ already emits this tuple, so the anchoring model is
validated earlier than expected: every block carries Hash, HeadingPath and
Ordinal. One detail worth keeping — HeadingPath is deliberately not part of
the hash, so renaming a section does not dirty every block beneath it and
orphan every comment in it at once.
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
);
CREATE TABLE project (
id SERIAL PRIMARY KEY,
owner TEXT NOT NULL,
name TEXT NOT NULL,
created TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (owner, name)
);
-- Membership is a SET: the composite key both dedupes and indexes project->space.
CREATE TABLE project_space (
project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE,
PRIMARY KEY (project_id, space_id)
);
CREATE INDEX ix_project_space_space ON project_space (space_id);
The meta-project is not a row. ~owner/+everything resolves to an implicit
"all spaces" filter, and the name is reserved so nothing can create a row that
shadows it. A stored +everything would need a sync job appending every
newly created space — which the Project section explicitly rules out — and that
job's single failure mode is a meta-project silently missing a space, which is
invisible. It also must not be renameable or deletable: a corpus-wide view is not
an object with a lifecycle. Consequence to remember: a project listing cannot
show the meta-project, and any UI must add it by hand.
Empty-means-nothing, and the polarity trap that follows. A freshly created
project has no members and must select nothing; the corpus-wide filter is a
distinct All flag, not an empty list. This is the opposite polarity from the
search layer, where an empty space list means every space:
Wiring a project to a search query by passing its member list straight through inverts the meaning of an empty project — from "nothing" to "the entire corpus". A silent scope inversion, invisible in any test that only uses non-empty projects.
Documented on both types today; the durable fix is for the query to take the filter type itself rather than a bare slice, so the mistake stops being expressible. That change is deferred only because the read surfaces were being built against the current signature at the time.
The meta-project is corpus-wide, not viewer-scoped. With no visibility levels and no ACL rows in v1 there is no "spaces the caller may read" to scope it to; that phrase implies machinery that does not exist.
comment remains 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, one character
distinguishing a project from a space (~user/space), with +everything
reserved for the meta-project. Isolated in the ref parser, so changing it later
is one function./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..md only, so ![[diagram.png]] has
nothing to resolve against and renders as visibly missing. Closing it needs a
WalkBlobs-shaped addition to gitx plus an asset index in doc/. Deliberately
not faked: an invented href would be worse than an honest missing link. The
cheaper answer may be to prefer Mermaid in fenced blocks by convention —
it stays text, diffs properly, and needs none of the above.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. |