~bigbes/sr-ht-spec

ref: 7f779fef12194d49b9ce97ad4e2a80af1c3d6358 sr-ht-spec/docs/DESIGN.md -rw-r--r-- 77.1 KiB
7f779fef — Eugene Blikh feat(web): review queue — inbox + policy-merged digest (Phase 4) 26 days ago

#spec.sr.ht — reviewable document storage for humans and agents

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.

#Context

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:

  1. The unit of work is a proposal, not a commit. Agents produce more text than a human can read. A store where agents write freely and nobody reviews is a wiki that rots within a week. The thing you interact with daily is a review queue, not a file tree.
  2. Bots need a stable read contract. "Give me the approved text of SPEC-0007", not "HEAD of main, which another agent is halfway through rewriting". Draft and canonical must be different addresses.
  3. Provenance is review context. Which agent, which session, on whose behalf, against which base revision. Reviewing agent output without that is reviewing anonymous text.

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.

#Decisions taken (user-confirmed, 2026-07-22)

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.

#Architecture summary

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.

#One storage tier: git objects, no checkout

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.

#Domain model

#Space

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:

  • Auto-merged is not human-approved, and readers must be able to tell. Each merge records 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.
  • The firehose still needs a digest. Auto-merged content that never appears in any view is write-only and rots invisibly — the exact failure this service exists to prevent, just relocated. A "what landed since you last looked" feed covering policy-merged content is therefore part of the review plane, not an extra.

#Document

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:

  • valid and unique in its space → the document is addressed by its ID;
  • absent or malformed → addressed by its path minus the extension;
  • duplicated → the ID resolves to neither document. Refusing to guess is the only safe option: silently picking one would make links and search results point at a document the author did not mean, and the ambiguity is invisible.

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.

#Proposal

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.

#Two write paths: you push, agents propose

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:

  1. SSH push straight to the bare repo, plus receive hooks — the repos live on the same box you already have SSH to, so git push spec:/var/lib/spec/~bigbes/rfcs main needs no transport code.
  2. Smart HTTP via git http-backend — needs auth plumbing we would otherwise not write, and a second code path to the same refs.
  3. go-git's 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-receive and 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.

#Which hook does what — three, not two

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:

  1. Push options are not visible to 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.
  2. 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.

#The hook is service code, not a shell script

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.

#Project

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:

  1. 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 +everything meta-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).

  2. 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).

#The three planes

#1. Read plane (authenticated, cached)

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/.

#2. Write plane (agents)

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:

  • Opening a proposal — the value becomes the proposal's base B, and the branch is cut from it. Rejected with 409 if it is not an ancestor of the current approved head.
  • Adding to an existing proposal — validated against 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.

#3. Review plane (humans)

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:

  • Every write response carries the proposal URL. 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.
  • Proposal URLs are stable and shareable — they outlive the branch, so a link still resolves after merge or rejection, showing the outcome.

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.

#Merge model: no text merge, ever

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:

  1. Add DELETE and a move operation to the proposal API.
  2. Deletion and rename are human-push-only. They are rare, destructive, and trivially expressed by the person who already has a git remote and full push rights to the approved branch. An agent that thinks a document should go proposes 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.

#Bilingual search: per-line, not per-document

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:

  1. A single analyzer does not break the other language — it silently stops stemming it. The 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.
  2. Per-document routing loses the minority language outright. A dominantly Russian spec quoting English requirements verbatim, indexed whole into body_ru: searching attachments finds it, searching attachment does not. That is exactly the document shape this corpus has.
  3. Per-line routing finds both halves, with neighbouring single-language documents unaffected.

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.

#Consistency and recovery

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:

  • The grace window is mandatory. Row-first ordering is forced by the schema (the branch name derives from the serial), so every proposal passes through "row exists, branch does not". An ungrace­d reconciler on a timer would delete an agent's work at random, and never once in a test. A row younger than the window (5 minutes) is in flight, not abandoned.
  • Merged-ness needs more than ancestry. A proposal branch is cut at the approved head, so between creating it and the agent's first commit its tip is trivially an ancestor of that head — and the plain ancestry rule would mark a contentless proposal merged, permanently, since 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.
  • Illegal transitions fail in SQL (UPDATE ... WHERE state = 'open'), not only in Go, so a second writer cannot merge an already-merged proposal.
  • Deleting a document unregisters its ID, so the global registry does not reserve names forever. Without this, IDs would be permanently burned by deletion, which is human-push-only and therefore easy to do by accident.

#Concurrency and ownership

  • Two writers, one repo. Human pushes go through native 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.
  • Unix ownership must be stated, not assumed. Repos are owned by the service user; your pushes go over SSH as the service user with a forced command, with identity established by SSH key rather than unix account. This keeps a single owner on every file, and gives the forced command the hook context it needs to apply the refs rule.

#The two hard parts

Naming these now so they are not discovered late.

#Prose diff, not line diff

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).

#Phase 0 verdict: PASSED — build the review UI on this

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. Similarity is 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:

  • Moves need a verbatim anchor. A verbatim section move reports as moves, and a move with one edited block inside is bridged — but a section rewritten while moving falls through as add + remove. Deliberate: matching moves by similarity alone invents relationships, and a wrong "moved from line 1065" costs a reviewer more than an honest add + remove.
  • Table alignment rows are invisible. goldmark folds |---|---| into the table node, so |---||:--| reports no change at all. A real gap; small, but silent.
  • Word diffs inside a table row can straddle a cell boundary; cell-aware diffing is a cheap later addition.
  • Blocks of two words or fewer fall below the short-block similarity floor and become add + remove rather than a word diff.

#Comment anchoring (post-v1, but design now)

../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.

#Agent identity and provenance

#Authorization is about agents, not people

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:

  • The refs rule is the boundary that matters, and it is free. No agent credential can move the approved branch — only proposals/*. That is what actually bounds the damage a confused or runaway agent can do, and it holds with a single shared token.
  • Per-space scoping is the boundary that can wait. Preventing a notes-writing agent from proposing into 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.

#Provenance

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:

  • The agent mailbox is derived, not configured. 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.
  • Agents transmit identity over 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.
  • A logged-in human who is not the owner resolves to anonymous, with the cookie name kept for logs only. Single-user makes this correct today; it is the line to revisit if anyone else ever gets an account.
  • Cookie expiry is not enforceable and is not enforced. The unified-login cookie carries no service-side TTL, and core-go's own 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.

#Reuse inventory

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 replace directive". That is stale — neither sibling's go.mod contains such a directive, and this service has none either. The fork declares the module path sourcecraft.dev/bigbes/sr-ht-core as of commit dd418a20, so it is required directly under that path; a replace pointing at git.srht.bigb.es/~bigbes/core-go is rejected by the Go toolchain for any commit at or before c2c2f38, where the fork's go.mod still said git.sr.ht/~sircmpwn/core-go. Fetching still needs GOPRIVATE=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.

#SourceHut integration

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.

#Our section

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.

#Shared keys we read in place (never duplicate)

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)

#Wiring checklist

  1. Config — the [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.
  2. DNSspec.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.
  3. nginx — plain 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.
  4. internal-ipnet — same prerequisite as both siblings.
  5. Migrationsspecsrht-migrate, a brant wrapper, copied from doltsrht-migrate.

#GraphQL: a read schema at our own /query in Phase 2

Serve 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 hut ergonomics. Both arguments were wrong and are retracted here.

Two tempting arguments that do not survive checking:

  • Federation is not cross-service search. thistle merges schemas and routes each field to its owning service. There is no join engine and no unified index. Federation does not deliver the meta-project — that remains spec.sr.ht's own bleve index, exactly as specified above.
  • 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:

  • Phase 5 pulls gqlgen in regardless. 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.
  • The dolt precedent does not generalize. dolt.sr.ht skipped GraphQL because its API is a chunk-store wire protocol with no sane graph to expose. Documents, spaces, proposals and comments are an ordinary CRUD graph.
  • It is the instance-native read surface. Anything on this instance that already speaks SourceHut GraphQL can consume specs without a bespoke client.

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.

Introspection is content, and is gated. The read plane is fail-closed, and the schema is authenticated along with everything else — so a federating api.sr.ht must present a token or it will log "Unable to update service" and skip us. Given the retraction above, that is an acceptable cost rather than a reason to open the endpoint.

No complexity limit. core-go's WithSchema would supply one from [<service>::api] max-complexity, but this service authenticates on its own router and does not use it, so nothing bounds query cost. Low risk on a single-user authenticated surface; worth revisiting before any surface becomes reachable without a credential.

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.

#Repo layout

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.

#Postgres schema

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.

Fixed, and the fix retracts the polarity described above. A search query now takes the filter type rather than a bare slice, with unexported fields and three explicit states — everything, a named set, and nothing. The zero value is none of them and a query carrying it is refused rather than defaulted, since both plausible defaults are wrong for one of the callers that can produce one.

So "an empty space list means every space" is no longer true anywhere: an empty set selects nothing, and the corpus-wide case is EverythingFilter(), spelled out. The mistake is now inexpressible rather than documented.

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.

#Implementation plan

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.

#Phase 0 — de-risk gate (throwaway code, no packages)

Two spikes. Both must pass before Phase 1 starts.

  1. go-git write path. On a real bare repo: create a branch, commit a blob, splice a tree, write a two-parent commit, and read it back — all in-process. Confirms the merge model is expressible with plumbing alone.
  2. Prose word-diff. Render a word-level diff between two real revisions of an existing spec of yours. If it does not read well, stop and rethink — the browser-review decision and most of Phase 4 rest on it. This is the single riskiest assumption in the document.

Spike 2 is deliberately the one with no fallback plan, so it runs first.

#Phase 1 — foundation, storage, and your push path

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.

#Phase 2 — read plane

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.

#Phase 3 — write plane

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.

#Phase 4 — review plane

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.

#Phase 5 — later

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.

#Verification (post-deploy, end-to-end)

Run against the real instance, in order. Each step fails loudly rather than degrading, which is the point of listing them.

  1. GET https://spec.srht.bigb.es/healthzok; the service appears in the nav of git.sr.ht and meta.sr.ht (after restarting them).
  2. git push a space with a valid document → accepted; it renders in the browser and appears in search within one merge cycle.
  3. 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.
  4. git push --force to the approved branch → rejected.
  5. An agent token proposing to proposals/* → accepted; the same token attempting the approved branch → rejected.
  6. Propose via MCP → response carries a URL; opening it shows the prose diff; approve → merges, and the document's approved text changes.
  7. Propose against a stale base → 409 with the current head.
  8. Propose into an auto_merge path → lands immediately, and shows in the digest marked approval: policy, not human.
  9. ?rev=<sha> of a superseded revision → renders that revision, not the head.
  10. kill -9 the daemon mid-merge, restart → the reconciler repairs the proposal row and the index stamp; no manual intervention.
  11. Anonymous request → no content leaks; logged-in as bigbes → full access.

#Open risks

  • Prose diff quality (highest). Retired — Phase 0 passed. Rewrapping this document from 80 to 58 columns yields 1563 line-differ changes and 0 prose-differ changes. The residual risk moved to the review UI: 13% of real modifications shred, and the two-column fallback above is now a Phase 4 requirement rather than a nicety.
  • go-git and native 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.
  • Empty-store bootstrapping. With no mounts, the service is worthless until content exists. Phase 1's push path is the mitigation; if it slips, the whole thing feels stillborn.
  • warren absorption drift. Once 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.
  • Comment anchoring, deferred not solved. The tuple is specified; whether it survives real reflowed prose is untested, and Phase 5 will discover it.

#Open items

  • Naming. 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.
  • Project URL namespace. Settled: ~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.
  • Port. compare.sr.ht is on 5090, dolt.sr.ht on 5306–5308. 5091 is free and is what the config block above assumes.
  • MCP transport. Streamable HTTP on the same chi router (/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.
  • Mixed Russian/English search. Resolved — see "Bilingual search" below. Per-document routing was measured and found insufficient; the answer is per-line routing.
  • Attachments and binaries — now a concrete gap, not a hypothetical. The Space section says a space holds "markdown documents, plus attachments", but the git layer enumerates and accepts .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.
  • Agent token distribution. How a Claude Code session actually acquires a scoped token — long-lived value in the environment, or minted per session. Per-session is better for provenance and revocation but needs an issuing flow.
  • Retention for the firehose half. Auto-merged notes accumulate forever by default. Whether they expire, get compacted, or are simply never deleted affects repo growth and index size, and is easier to decide now than later.
  • LICENSE. Unchosen, same as compare.sr.ht. SourceHut's own services are AGPL/GPL.

#Answered by review round (2026-07-22)

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.

#Still open

  • Prose diff quality — tracked under "Open risks" above; not a question to answer in prose but the Phase 0 gate to run.
  • Mixed ru/en per-document language routing (above): still needs a decision once there is a real corpus to test against.
  • Attachments and binaries (above): unresolved, and cheap to defer until a spec actually needs an image.
  • LICENSE (above).