# spec.sr.ht — reviewable document storage for humans and agents Status: **design / proposal** — nothing implemented yet. 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** (`/~user/`), 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) │ /~user/ 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=`, 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 `~/`. 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: ```yaml 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`). ```yaml 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/` 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 `post-receive` runs **after** refs have already been updated; its exit status is ignored. Rejection must therefore happen earlier: - **`update`** (per-ref, runs before the ref moves, can reject) — enforces the refs rule: an agent token may only touch `proposals/*`; the approved branch accepts fast-forwards from you only, never a force-update. Also where frontmatter and global-ID-collision validation rejects a bad push. **Your own pushes are validated too**, with a `--push-option=skip-validation` escape hatch. The failure mode being guarded is not malice, it is a typo: a duplicated or malformed `id:` corrupts the global registry and silently breaks link resolution and search, and is far cheaper to reject at push time than to find weeks later. The escape hatch exists so that a hook bug or a bad schema can never lock you out of your own repository. - **`post-receive`** (after the fact, cannot reject) — notify the daemon so it reindexes the changed documents and updates the space's index rev stamp. #### 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. ```yaml 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 (anonymous-capable, cached) `GET /~user/space/specs/0007-storage` renders. Content negotiation gives `.md` raw, `.json` metadata+body, `?rev=` 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**, with a visible "draft is 3 changes ahead" affordance. This is the plane bots consume; it must be boring and pinnable. Serving drafts by default would poison every downstream agent context with unreviewed text — which is the exact failure this whole service exists to prevent. Implementation is warren, absorbed: `vault/` (scan + frontmatter), `render/` (goldmark + wikilinks), `index/` + `search/` (bleve keyword + optional vector), `linkcheck/`. ### 2. Write plane (agents) ```http PUT /api/v1/spaces/~bigbes/rfcs/docs/specs/0007-storage.md If-Match: X-Proposal: # 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/`, 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. ## 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. | Crash between | Symptom | Repair | |---|---|---| | row insert and branch write | `open` row, no branch | reconciler deletes the row; the agent re-proposes (it holds the content) | | — | orphan `proposals/*` ref with no row | delete the ref: it is unreferenced and its content is unrecoverable anyway | | merge commit and row update | merged ref, row still `open` | reconciler marks merged (the ref is truth for *merged-ness*) | | merge and reindex | stale index | per-space index rev stamp ≠ approved head → reindex | A **reconciler runs at startup and periodically**: scan `proposals/*` refs and each space's approved head, compare against rows and index rev stamps, repair divergence. Deleting an unreferenced ref or a contentless row is safe precisely because an agent's proposal is cheap to reproduce — it still holds the document it wanted to write. **Schema-enforced invariants** (added during implementation, worth stating in the design because they encode decisions rather than mechanics): - `state = 'merged'` **iff** `approval IS NOT NULL` **iff** `merged_rev IS NOT NULL`; `state = 'open'` iff `resolved IS NULL`. A merged row with a null approval would silently launder policy-merged content as human-approved — the exact distinction the bimodal decision exists to preserve. - `agent` and `agent_session` are non-empty, not merely `NOT NULL`. Provenance is described as mandatory in prose; `''` would satisfy `NOT NULL` and gut it. - 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) Committer: bigbes Add storage model section X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921 X-Agent-Base: ``` 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@` — 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 `/~user/`, 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. ```ini [spec.sr.ht] origin=https://spec.srht.bigb.es ; Federated into api.sr.ht from Phase 2 (read side). api.sr.ht fetches ; /query; omit this key and it falls back to /query. api-origin=https://spec.srht.bigb.es connection-string=postgresql://specsrht@localhost/spec.sr.ht?sslmode=disable ; Bare repos, service-owned: /~/ 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. **DNS** — `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. 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. **Migrations** — `specsrht-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: `, 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. ## 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. ```sql -- Spaces exist as repos; this table is for listing and index bookkeeping. CREATE TABLE space ( id SERIAL PRIMARY KEY, owner TEXT NOT NULL, -- "bigbes", no ~ prefix name TEXT NOT NULL, created TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (owner, name) ); -- Global, not per-project: a later import cannot collide. CREATE TABLE document_id ( doc_id TEXT PRIMARY KEY, -- "SPEC-0007" space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE, path TEXT NOT NULL, -- current path on the approved branch updated_rev TEXT NOT NULL ); CREATE TABLE proposal ( id SERIAL PRIMARY KEY, space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE, title TEXT NOT NULL, rationale TEXT, base_rev TEXT NOT NULL, -- the If-Match value; does not move branch TEXT NOT NULL, -- "proposals/42" state TEXT NOT NULL, -- open | merged | rejected approval TEXT, -- human | policy, set on merge merged_rev TEXT, agent TEXT NOT NULL, -- "claude-code/spec-writer" agent_session TEXT NOT NULL, created TIMESTAMPTZ NOT NULL DEFAULT now(), resolved TIMESTAMPTZ ); CREATE INDEX ON proposal (state, created DESC); CREATE TABLE agent_token ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, token_hash BYTEA NOT NULL UNIQUE, created TIMESTAMPTZ NOT NULL DEFAULT now(), revoked TIMESTAMPTZ ); -- Index staleness: compared against the space's approved head. CREATE TABLE index_stamp ( space_id INTEGER PRIMARY KEY REFERENCES space(id) ON DELETE CASCADE, rev TEXT NOT NULL, indexed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- "What landed since you last looked", for the policy-merged digest. CREATE TABLE digest_mark ( owner TEXT PRIMARY KEY, seen_at TIMESTAMPTZ NOT NULL ); ``` Projects are deliberately absent: a project is a saved filter, so it is a name plus a space-id list, and adding it before there are several spaces to filter would be speculative. It arrives with the meta-project in Phase 2. `comment` is likewise absent — inline comments are post-v1, and the anchoring model (`doc_id`, heading path, block index, block hash) should be settled by building the review UI before it is committed to a schema. ## 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/healthz` → `ok`; 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=` 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.** `~user/+project` distinguishes projects from spaces (`~user/space`) in one character; alternatives are `/projects/~user/name` or reusing hub.sr.ht's own namespace. - **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 — partially solved, not finished.** warren's `search/keyword.go` already wires both the `lang/en` and `lang/ru` analyzers with per-index selection, so the starting point is better than an earlier draft claimed. What is still missing is **per-document** language routing: specs here are written in both (cf. the `ru-spec-style` skill), and one analyzer per index mangles whichever language it was not chosen for. Options: detect language at index time and write to `ru`/`en` fields, querying both; or accept degraded stemming on the minority language. - **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).