# 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. | ## 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 ├──► materialized checkouts (read/index cache) │ /~user// ├──► one global bleve index └──► Postgres (proposals, comments, agent tokens + scopes, 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. ### Two-tier storage, and why - **Bare repo** — the push target and the source of truth. One repo per *space*. - **Materialized checkout** of the approved branch — a plain directory of markdown, rebuilt on every merge. This exists so warren's indexer and renderer can work on files (which is what they already do), and so reads never pay object-database costs. It is a **cache**: deletable, rebuildable from the bare repo at any time. Rebuild **atomically** — build into a temp dir and rename into place — or readers observe a torn tree mid-rebuild. Stamp each checkout with the rev it was built from, so staleness is detectable rather than assumed. **Caveat this tier does not cover:** `?rev=` pinned reads have no checkout to read from, since only the approved head is materialized. They need a second read path — blob → renderer, straight from the object database, with no vault scan. warren's `vault`/`render` split is file-oriented, so this is a real if modest rework rather than pure absorption. It is also an argument for questioning the checkout tier altogether: reading blobs at the approved ref plus a render cache would make pinned and default reads *the same code path* and delete this cache subsystem entirely. Open — see "Open items". ## 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. **"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 ID-collision validation rejects a bad push. - **`post-receive`** (after the fact, cannot reject) — materialize the checkout, notify, reindex. #### 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). Per-document incremental upsert/delete is therefore **net-new work, not absorption** — see the reuse inventory. Whether it is actually needed in v1 depends on write volume; a full rebuild is fine at tens of documents a day. 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 #mounts: # read-only external corpora — NOT in v1, see below # - {type: dir, path: /Users/blikh/data/home/second-brain} ``` 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, and a live tension.** "All my doc work" now 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. So the meta-project unifies what spec.sr.ht owns. The mount mechanism stays specified because it is the escape hatch if "searchable in one place" later turns out to have meant *literally* everything — but it is not built in v1, and the service is deliberately empty until agents fill it. 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/path.md` renders. Content negotiation gives `.md` raw, `.json` metadata+body, `?rev=` pinned to an immutable revision. **Reads default to the approved revision**, with a visible "draft is 3 changes ahead" affordance. This is the plane bots consume; it must be boring and pinnable. Serving drafts by default would poison every downstream agent context with unreviewed text — which is the exact failure this whole service exists to prevent. Implementation is warren, absorbed: `vault/` (scan + frontmatter), `render/` (goldmark + wikilinks), `index/` + `search/` (bleve keyword + optional vector), `linkcheck/`. ### 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. Resolving each ID to its path *in `H`* before comparing costs one frontmatter parse of the changed set and removes both failure modes. **Deletion and rename need an explicit surface.** The write plane is whole-document `PUT`, which gives an agent no way to express "delete this" or "move this". Two coherent answers; **the second is the v1 recommendation**: 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. ## Consistency and recovery Four systems are touched by a merge — git refs, the materialized checkout, the bleve index, and Postgres — and **none of it is transactional**. Earlier drafts only said "the checkout is a rebuildable cache", which covers one of the four. The rule that makes the rest tractable: > **Git refs are the source of truth for whether a proposal exists and whether it > merged. Postgres holds metadata that is reconstructable from git. The checkout > and the index are pure caches.** That gives every crash a defined repair, rather than a bespoke recovery per failure point: | Crash between | Symptom | Repair | |---|---|---| | branch write and row insert | orphan `proposals/*` ref | reconciler recreates the row from the ref | | merge commit and row update | merged ref, row still `open` | reconciler marks merged (ref is truth) | | merge and checkout rebuild | stale checkout | rev stamp mismatch → rebuild | | checkout and reindex | stale index | index rev stamp mismatch → reindex | A **reconciler runs at startup and periodically**: scan `proposals/*` refs and each space's approved head, compare against rows and the index rev stamps, repair divergence. It is perhaps a hundred lines and it is what lets every other component crash without ceremony. ### 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 is the Phase 0 gate below. Note that compare.sr.ht's `@pierre/diffs` bundle is a *code* differ and is the wrong tool here; the prose differ is likely net-new (segment into blocks → align blocks → word-diff within matched blocks). ### 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. ## 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. It keeps, and arguably sharpens: **per-agent tokens scoped to a space set and a role**, query-time index filtering by that scope, and the refs rule from "Two write paths". The unified-login cookie is still needed — not to tell users apart, but to tell *you* from an unauthenticated request. The practical value is blast-radius control: a research agent looping over a notes space cannot touch `specs/`, and a compromised or confused token cannot reach the approved branch of anything. ### Provenance Per-space (or per-project) tokens with a role — `reader` / `proposer` / `writer` — and 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. 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 `git.srht.bigb.es/~bigbes/core-go` via `replace`, as in both siblings. Never `go get -u`. The gqlgen path is the blessed one and is what Phase 2's `/query` is built on. | **Two absorption gaps, both understated in earlier drafts:** - **`index.Build` is a batch full rebuild** (`bleve.New` + full vault rescan + chunking). There is no incremental path, so "reindex only the changed documents" is net-new work. Whether v1 needs it is a volume question — a full rebuild is fine at tens of documents a day, and the honest answer is to measure before building incremental upsert/delete. - **`vault`/`render` are file-oriented**, so `?rev=` pinned reads need a blob→renderer path that bypasses the vault scan (see "Two-tier storage"). Genuinely net-new: proposals, prose diff, review plane, agent tokens and scoping, frontmatter lifecycle, the reconciler, and incremental indexing if volume demands it. ## 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 ; Materialized checkouts + bleve project indexes. Pure cache; safe to delete. 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. ## Phases **Phase 0 — de-risk gate.** Two spikes, both must pass before anything else is built: 1. go-git write path in-process: create branch → commit blob → build merged tree → two-parent commit, on a real bare repo, no shell-out. 2. Prose word-diff rendered against two real revisions of an actual existing spec. **If the diff does not read well, the design needs rethinking before any further code.** This is the compare.sr.ht/dolt.sr.ht spike discipline applied to the riskiest assumption here. **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 moves up into Phase 1: it is how content first exists, and it needs almost no service code. **Phase 1 — core + storage + your push path.** Pure domain (space/doc/rev/ID validation, frontmatter parse + schema validation), bare-repo lifecycle, materialized checkouts with atomic swap and rev stamps, Postgres schema + migrations, the reconciler, and the **SSH push path**: an `update` hook that validates and enforces the refs rule, plus `post-receive` that materializes. Both hooks RPC into the daemon. No indexing yet — that arrives with the read plane in Phase 2. End state: you can `git push` a space, bad pushes are rejected, and the service knows about it. **Phase 2 — read plane.** warren absorbed, chrome from compare.sr.ht, unified login, `?rev=` pinning, project index with query-time scope filtering, search, and **MCP read tools**. Plus the **read-side GraphQL schema on `/query`** and `api-origin` in config, federating into `api.sr.ht`. End state: agents can read and search everything you have pushed — useful on its own, before any review machinery exists. **Phase 3 — write plane.** Proposals, `If-Match` concurrency, the merge model, agent tokens + scoping + provenance trailers, REST + MCP write tools. MCP tools and GraphQL resolvers share one service layer — no parallel implementations. **Phase 4 — review plane.** Proposal pages at stable URLs (returned by every write, so agents can hand you a link), inbox, prose diff, approve / reject, status lifecycle, and the **digest of policy-merged content** (the firehose half is unreviewed by design, so it must at least be *visible* or it rots silently). **Phase 5 — later.** Inline comments (anchoring above), webhooks and notifications (`core-go/webhooks`, GraphQL-native — the Phase 2 schema is the foundation), GraphQL **mutations** once the proposal state machine has stopped moving, vector search, read-only mounts if the boundary ever moves. **Dropped outright:** the web editor (you edit via clone and push), and with it the concurrent-web-edit-vs-push conflict problem it would have created. ## 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.** Diagrams and images in specs mean binary blobs in git: no useful diff, unbounded repo growth, and a size-cap decision. Mermaid in fenced blocks stays text and diffs properly — possibly worth *preferring* by convention over checked-in images. - **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. ### Raised by review, awaiting a decision Ordered by how much the answer would change the build: 1. **Do agents genuinely need `?rev=` pinning and the approved/draft split?** It is a founding premise of this document, but if every real consumer just wants "current approved text", the pinning machinery, the dual read path, and part of the token-scope model all simplify. 2. **Is the materialized checkout a requirement or an implementation detail?** Dropping it for blob reads at the approved ref plus a render cache removes the atomic-swap problem, the staleness coupling, and a whole cache tier — at the cost of a deeper rework of warren's file-oriented scan. 3. **What is the real firehose volume?** Decides whether full-index-rebuild-on- merge is fine (deleting the incremental-indexing work), whether retention matters, and whether merge contention is real or theoretical. 4. **Scoped per-agent tokens in v1, or one token plus mandatory provenance?** If every agent is a Claude Code session you launched, one token and trailers give full provenance for a fraction of the machinery; scoping can arrive when a genuinely autonomous agent does. 5. **Should your own pushes be validated at all, or are you trusted absolutely?** If you can push whatever you like, the `update` hook shrinks to "no force-push on approved" and the entire validation stack lives only in the API path, where it is in-process and easy. 6. **Will existing corpora ever be imported or mounted?** If the honest answer is never, delete mounts from this document entirely. 7. **Is `spec` the final name?** Low architectural leverage, but it locks the config section, DNS, module path and nav entry on day one, and every example here already hardcodes it.