~bigbes/sr-ht-spec

ref: cb60d4f754f4ea21121414f7e3fc8b10118c8ac4 sr-ht-spec/service d---------
be33cce1 — Eugene Blikh 9 days ago
instconf: one reading of this instance's origins
3d811988 — Eugene Blikh 9 days ago
service: wrap the merge and reject failures with culpa

Three sites, all of them the outermost wrap on an error that becomes a 500:
the merge path's non-staleness arm, the reject path's default arm, and the
merged-but-the-row-did-not case, whose remedy now rides on the error as a hint
instead of sitting in a sentence one wrap away from being buried.

Outermost is the whole criterion. scribe.Err type-asserts the error it is
handed for slog.LogValuer rather than searching the chain, so a culpa error with
an fmt.Errorf above it logs as a plain string and the stacktrace is lost — which
is also why the sentinel arms stay fmt.Errorf: they carry no cause worth a
stack, and their text is what a 404 or 409 shows a viewer.

errors.Is and errors.As traverse culpa's wrap, so the sentinel mapping in the
surfaces is unchanged.
a4d8cc52 — Eugene Blikh 9 days ago
authn: remove the local agent-token plane

The agent_token table and everything that minted, verified, listed or revoked
it. Agent credentials are tokens.sr.ht working tokens now: signed, expiring,
owned by a meta.sr.ht account and carrying grants, verified locally by
sr-ht-ecore's bearer package. One door, and nothing behind it — a credential the
instance plane refuses is refused, where it used to be offered to a second store
that might say yes.

DEPLOY GATE: do not deploy this until every agent configured with the shared
secret holds a tokens.sr.ht token with spec:propose (spec:read to read).
Migration 0005 drops the table, migrate-on-upgrade runs it on deploy, and
deploying early locks out every agent at once — including the SSH push path.

hooks/ no longer reads agent_token directly. The push path goes through the same
authn.Resolver the HTTP surfaces use and demands spec:propose, because a push by
an agent is a proposal by another transport. The refs rule and the provenance
requirement are untouched on both counts: a universal grant is still only an
agent to the receive path, and X-Agent / X-Agent-Session are still mandatory on
every agent write.

bearer.ErrNotOurs is now a permanent refusal. A meta.sr.ht PAT used to fall
through to the local store and miss there; with no store to fall through to it
earns a 401 rather than the 503 an unclassified error would.

[tokens.sr.ht] origin becomes a required config key: with no issuer there is no
credential to check, so the daemon fails startup instead of serving reads and
refusing every agent write one request at a time.

/tokens redirects to the daemon that issues (tokens SPEC ch. 7).
c7477607 — Eugene Blikh 10 days ago
authn: accept tokens.sr.ht working tokens beside the agent token

A second agent credential plane, next to the existing one rather than in
place of it. The agent_token table, every agent configured with it, and
the refs rule and provenance requirement around it are untouched; the
local plane is removed in a later phase, not this one.

The resolver tries the instance plane first and falls back to the local
store on exactly two refusals, bearer.ErrInvalid and bearer.ErrNotOurs.
spec's local token has no prefix to discriminate on — it is 32 random
bytes in base64, which is precisely what "did not decode as one of ours"
looks like — so the fallback replaces the shape test bench and cover can
afford. ErrRevoked, ErrForbidden and ErrUnavailable are terminal: a
withdrawn credential must not get a second chance at the old door, and an
unreachable daemon must not silently degrade into the legacy plane.

Grants ride on the principal and are checked where the action is known,
never in the middleware, which runs upstream of the router: spec:propose
in service.Propose, below both write surfaces, and spec:read in each read
surface's gate. /mcp checks per tool rather than at its Gate, because one
endpoint carries both kinds and a surface-wide read grant would refuse a
propose-only token at initialize. Principal.Authorize is a no-op off the
instance plane, which is what keeps the local token working.

The instance plane brings an owner where the local token had none, so a
working token belonging to anybody but [sr.ht] owner-name is refused
rather than admitted as a second identity: Principal.Owner is read by the
provenance committer, the refs rule's principal kind and the coreauth
AuthContext, all written for one human.

StatusFor is the one status table. ErrUnavailable is 503 and never 401 —
reading "I could not ask tokens.sr.ht" as "revoked" would refuse every
live instance token while a daemon that is deliberately off the hot path
restarts.

An instance with no [tokens.sr.ht] section builds no instance plane and
starts anyway, serving its own agent token as before.
8219ede1 — Eugene Blikh 13 days ago
feat(web,service): the owner mints and revokes agent tokens in a browser

Issuing a credential required SSH to the host, which made the remote
agent write plane unusable from anywhere else: to hand an agent a token
the owner had to be at the machine. /tokens is that page — list, mint,
revoke — behind the same owner-only gate and same-origin guard as
approve/reject.

The mint is owner-only, and that rule is what revocation depends on: an
agent allowed to mint would survive having its own credential revoked by
issuing itself another, and "revoke the token" is the entire incident
response this design has. An agent asking for the page gets 403 rather
than the read plane's login redirect — it is authenticated already, so
bouncing it to meta would answer a question it did not ask.

The plaintext is rendered in the response to the POST rather than after a
redirect. A redirect would either drop the secret or carry it in a URL,
where it lands in history and in every proxy log on the way; the cost is
that a reload re-submits and mints a second token, which is one click to
revoke on that same page, whereas a lost token is not recoverable.

service.IssueAgentToken/ListAgentTokens/RevokeAgentToken hold the ACL and
the mint, and `specsrht token` now goes through them too, so the CLI and
the page cannot drift into two ideas of what issuing a token is.

spec-ejq.3
472bcb1f — Eugene Blikh 24 days ago
feat(web): commentable prose diff with honest anchor state (spec-by6.3.3)

Reverses the diff view's founding rule. renderDocDiff skipped ChangeEqual
outright — "the review shows only what changed" — but any block of a proposed
document must be commentable, so unchanged blocks now render as collapsed,
dimmed context. Changed blocks keep their border, tint and full body, so the
page still reads as a diff at a glance rather than as a document dump. A
context block carrying a comment renders open. ChangeMoveIn now shows its text
too, since a comment control on invisible text is a control on nothing;
ChangeMoveOut stays a bare marker and is deliberately not commentable, because
the same paragraph is anchorable at its move-in position and two anchors for
one paragraph is the bug that avoids.

Every rendered block carries id="b-<16 hex>", hashed from the whole anchor
tuple. Not the page ordinal: an ordinal renumbers on any insertion above it, so
a saved link would silently scroll to a neighbouring paragraph, whereas
including the block hash makes a stale link resolve to nothing instead.

Threads are placed by anchor and by nothing else. Anything no rendered block
claims — an outdated anchor, an old-side anchor whose block the diff no longer
draws, a document the proposal no longer changes — is collected into a
page-level "comments that lost their anchor" area. Never dropped, never moved
onto a neighbour: a comment reads as authoritative about the block it sits
beside, so attaching it to the wrong one is worse than admitting it lost its
place. An edited anchor is drawn on its block and badged.

The comment form's anchor is built at submit time from the branch as it now
reads, through service.AnchorOf — hand-rolling the ordinal conversion here
would put the browser's comments on different blocks than the MCP tool's, which
is the one way two surfaces of one conversation disagree without either looking
broken. The form's block hash guards it: a block that moved while the page sat
open is a 409, not a comment attached to whatever took its place.

That hash is required rather than checked-when-present. Skipping the guard for
a form that omits it would let a later template refactor drop the hidden field
and disable the staleness check silently, with every test still green.

Authority is surfaced, not re-implemented: compose and resolve are the owner's
because service says so and ErrForbidden becomes a 403.

spec-by6.3.3
c6e5d667 — Eugene Blikh 24 days ago
feat(service): review threads and the policy auto-merge gate (spec-by6.3.2)

The comment API above db/, plus the rule that gives comments teeth: an
unresolved review thread suppresses policy auto-merge.

The gate matters because tryAutoMerge runs on every Propose, including an
agent's revision of an open proposal — so a proposal the owner stopped to
comment on could otherwise land unattended on the agent's next push. It gates
policy merges only. MergeHuman does not consult it: clicking approve is the
judgement the thread was asking for, and a comment nobody got round to
resolving must not be able to wedge a proposal shut.

Authority follows from that. The owner opens and resolves threads; an agent may
reply but may do neither, because both would hand the gate's control to the
thing it exists to hold back. An agent reply is not a resolution — answering a
critique is not the owner accepting the answer.

AnchorThreads lives here rather than in each surface, for the reason
Service.Archive does: the review page and the MCP tool must agree about whether
a comment still fits, and two surfaces each segmenting and matching would agree
only until one was changed. That is why service/ now imports prosediff. Each
document is segmented at most once per side however many threads hang off it.

AnchorOf is the other half: a surface offering "comment on this block" has a
document-global block ordinal, and the anchor needs the index within the block's
own heading path. Converting in one place is what stops the web form and the MCP
tool numbering blocks differently and putting their comments on different
blocks of the same document. A test asserts the two agree for every block of a
document.

A thread whose document is no longer among the proposal's changes — the agent
reverted it — is outdated, not dropped: a comment that silently vanished would
look like one that was never made.

Tested against a real Postgres 16, with a control test proving policy
auto-merge still fires without a thread, so the gate test cannot pass for the
wrong reason.

spec-by6.3.2
865a21fa — Eugene Blikh 24 days ago
feat(web): digest tracks 'since you last looked' via digest_mark (spec-mfm)

The policy-merged digest showed the last N auto-merges by count; the
design intends "what auto-merged since you last saw it", backed by the
digest_mark table that existed but nothing read. The inbox GET now reads
the mark to flag each digest row that merged after it as new, count them,
and draw a divider before the already-seen rows — staying a pure read.

Advancing the mark is a write, so it is an explicit POST /inbox/seen
behind the owner-only + same-origin guard approve/reject already use, not
a side-effecting GET. service.DigestMark/MarkDigestSeen wrap the store,
mapping "no mark yet" to (zero, false) so the first-ever view reads the
whole digest as new.

Closes spec-mfm
d8a5164f — Eugene Blikh 25 days ago
feat(webhooks): fire on proposal open/merge/reject (Phase 5a)

The firing half — proposal lifecycle events now deliver GraphQL-native
webhooks. Verified end to end against a live daemon: an agent REST
propose delivers a signed POST whose body is the subscription's stored
query executed against the ProposalEvent payload.

- service: an EventSink seam (service/events.go). Propose emits
  PROPOSAL_OPENED for a new proposal, mergeProposal emits PROPOSAL_MERGED
  (the single merge point — both auto-merge and the human approve reach
  it), Reject emits PROPOSAL_REJECTED. Nil-safe; a Service with no sink
  emits nothing.
- graph.NewProposalEvent builds the *model.ProposalEvent payload from a
  service.Proposal (reusing the existing service→graph→model mapping).
- cmd webhookEventSink: proposal events happen in the service layer,
  which has none of core-go's request context, so the sink enqueues a
  dowork task onto the webhook queue. The task runs in the queue's worker
  context (server+database+config, from WithQueues), adds the owner's
  INTERNAL auth, and calls Schedule — which renders each subscriber's
  query and delivers it Ed25519-signed. Fire-and-forget off the write
  path: a webhook never blocks or fails a proposal write.

Phase 5a (webhooks) is complete: DB, the authn→AuthContext bridge, the
GraphQL surface, the core-go server wiring, and firing.
64e4ac89 — Eugene Blikh 25 days ago
feat(coreauth): owner user seed + authn→core-go AuthContext bridge (Phase 5a)

The compatibility shim that lets core-go's webhook engine run inside this
single-owner, agent-aware service — spec keeps authn as its real auth.

- db.EnsureUser + service.EnsureOwnerUser: seed and cache the owner's
  "user" row (the FK target core-go's user-scoped webhook model needs).
  Idempotent; run at startup.
- coreauth.Derive/Context: map authn.Principal → auth.AuthContext. Owner
  and agent both become AUTH_INTERNAL (not COOKIE) deliberately —
  INTERNAL bypasses core-go's @access scope checks AND is accepted by
  webhooks.NewAuthConfig (which refuses cookie auth), which is what lets
  the single owner create webhooks. The agent identity rides in the
  payload, not the auth context; webhook management stays owner-gated in
  the resolvers.

Factored as its own package so it lifts cleanly into a shared sr-ht-ext
module later (deferred). Build + tests green.
7f779fef — Eugene Blikh 26 days ago
feat(web): review queue — inbox + policy-merged digest (Phase 4)

The backstop for work no link reached. /inbox lists every open proposal
on the instance ("waiting on you") and, below it, the digest of recently
policy-merged content — the firehose a human sees after the fact, which
is the whole reason approval=policy is kept distinct from human.

- service.InboxProposals / DigestProposals list instance-wide (one
  reviewer, so a per-space inbox would make them hunt), mapping each
  stored proposal's space_id back to a reference once from the space list.
- web/inbox.go + inbox.html render the two sections; the landing page
  links the queue for a logged-in owner.

Follow-up: the digest currently shows recent policy-merges rather than
"since you last looked" — the digest_mark table exists to track that, but
advancing it is a write and GET stays pure. Filed separately.
3c563e4d — Eugene Blikh 26 days ago
feat(web): proposal review page — prose diff + approve/reject (Phase 4)

The browser review plane at /~owner/space/p/<id>, the stable URL every
write already returns. The owner opens the link an agent handed them,
reads a prose diff of each changed document, and approves (merges now) or
rejects.

- web/diff.go: the prose-diff HTML renderer, consuming prosediff's block
  model (the package renders text only; HTML is the web layer's job). It
  implements the Phase 0 verdict's hard requirement — inline word diffs
  above 0.75 block similarity, a two-column old/new view below it, because
  13% of real edits shred and are unreadable inline. All document content
  is HTML-escaped; only the diff structure is markup.
- service/review.go: ProposalDiff reads each changed document's base and
  proposed content for the page to diff (branch tip resolved to a sha, the
  legitimate pinned-rev read, not the ReadDocumentAtRef bypass), and
  MergeHuman fixes the approval kind so a browser approve is always human.
- web/proposal.go: the GET page and the approve/reject POSTs. Only the
  owner may act (an agent is authenticated but has no more approval
  authority than anyone); a cross-site guard on Origin/Referer is the CSRF
  defense a form post needs when the session cookie is meta's. Post-
  redirect-get back to the page. Stale/already-merged approve → 409.
- web.Reader gains the proposal reads and the two actions; the diff-view
  styles go in scss/main.scss (inline marks, two-column, code line diffs).

Inbox and the policy-merged digest are the remaining Phase 4 surfaces.
1476e065 — Eugene Blikh 26 days ago
feat(api): REST write plane — PUT a document to propose (Phase 3)

The other agent-facing write surface, PUT /api/v1/spaces/~owner/name/
docs/<path> with If-Match and X-Proposal headers, returning
{proposal, url}. Like spec_propose it holds no proposal logic: it parses
the request into a service.ProposeRequest, calls the same service.Propose,
and maps the result and the service sentinels onto status codes — 201 on
open, 200 on add, 403 non-agent, 422 malformed document, 409 stale /
already-merged, 404 missing. The body is the whole document; title,
rationale and message ride in the query string so the body stays the
document. The acting agent is resolved from the bearer token by the
principal middleware the endpoint installs, and service.Propose is the
one ACL — an anonymous caller is a 403 there.

Split the service error taxonomy this surface exposed: ErrInvalid (422,
a malformed document the agent must fix) is now distinct from
ErrForbidden (403, a principal that may not propose at all). Conflating
them answered "bad document" with "you are not allowed", which is exactly
the distinction the retrying agent needs.
65eac3cf — Eugene Blikh 26 days ago
feat(service): write plane — Propose, Merge, ListProposals (Phase 3)

The service-layer orchestration for spec-zqb: the plane where the merge
model and the proposal state machine first run under real proposals
rather than on paper. Primitives (db proposal CRUD/merge, gitx
branch/commit/merge, authn provenance, core policy matcher) already
existed and were unit-tested; this composes them.

- Propose: the write plane, identical for REST and MCP. Row-first open
  (branch name derives from the serial id), branch cut, and a
  provenance-stamped commit — the agent authors, the owner commits, and
  the X-Agent-Session / X-Agent-Base trailers carry the rest into a
  plain git log. Adds to an existing proposal via ProposalID against its
  fixed base. Frontmatter/schema/id validation at propose time, mirroring
  the update hook the in-process agent write bypasses. Returns
  {proposal, url}.
- Auto-merge policy: a proposal whose every changed path matches the
  space's .spec.yml auto_merge lands immediately with ApprovalPolicy,
  best-effort — a stale or mixed-path proposal falls back to human review
  rather than failing the write.
- Merge / Reject: the state machine. Merge does the already-merged
  ancestry check before gitx's If-Match staleness (design: the two need
  different tests), the owner-signed merge commit, and the atomic row +
  document-registry flip. ErrStale / ErrAlreadyMerged / ErrForbidden map
  the 409/403 boundary.
- ListProposals / GetProposal / ProposalURL: the space-scoped read the
  graph Proposals port declared and left nil, plus the stable
  <origin>/~owner/space/p/<id> link.
- db.ListProposalsBySpace: the per-space, per-state listing.

Tested end to end against Postgres: open, auto-merge, mixed-path
fallback, stale-base 409, human merge, reject, add-to-existing, drifted
base. Surfaces (graph wiring, spec_propose, REST api/) fan out next.
e0938e94 — Eugene Blikh 27 days ago
refactor: make the filter-polarity trap inexpressible

service.SpaceFilter meant "empty membership selects nothing" — a newly
created project has no members — while search.Query.Spaces was a bare
[]core.SpaceRef whose empty case meant every space. Passing a project's
members into a query therefore turned an empty project into the whole
corpus: a silent scope inversion, invisible when it happens, and passing
every test written with a non-empty project.

The filter moves to core.SpaceFilter with unexported fields, and
search.Query takes it whole. There is no slice to hand over any more, so
the inversion cannot be written. Its three states are distinct: every
space (EverythingFilter), exactly these — possibly none — (SpacesFilter),
and the zero value, which is neither. Search refuses the zero value
rather than defaulting it, because both plausible defaults are wrong for
one of the two callers that can produce one, and returns no hits for a
filter that selects no space without asking the index.

service.SpaceFilter is now an alias for the core type, so ResolveProject
and its callers keep their names. Tests that built a Query without a
scope now say core.EverythingFilter(), which is what they always meant.
5a10600a — Eugene Blikh 27 days ago
feat: service.Archive — one accessor, one tree walk, one link graph

web/ and mcpsrv/ both needed a *doc.Archive and each invented its own way
to get one. web/ handed sp.Repo to doc.Scan — reaching past service/ into
gitx, which the layering rule forbids — and then listed the documents a
second time for their bodies, two tree walks per page view. mcpsrv/
converted []service.Document back into []gitx.Document, rebuilding hashes
service/ had already stringified. Two workarounds around one missing
accessor is how three agent-facing surfaces stop being identical.

Service.Archive resolves the revision, walks the tree once, and returns
the archive with the bodies. ArchiveFrom is the same construction over a
Document set a caller already holds — the one place a hex object name is
converted back to a hash, malformed ids refused rather than zeroed.

doc.Page.Links and Page.WordCount were documented as "filled in by a
render pass" and nothing filled them, so Archive.Backlinks always
returned empty; web/ worked around that by re-rendering every document of
the space on each page view. doc.Archive.LinkPass now owns the pass and
the accessor runs it, so the link graph exists wherever the archive does.

web/ and mcpsrv/ no longer import gitx or go-git, in tests either.
doc.DirOf replaces the three copies of the same fromDir helper.
eaa2b65f — bigbes 27 days ago
fix(service): separate the read-plane rev guard from the review path

The previous commit's guard broke two service tests, which were right and
the guard was too broad: rendering and diffing a proposal branch genuinely
needs to read one, so a blanket ban on ref names is not the shape.

ReadDocument keeps the strict object-name rule. ReadDocumentAtRef reaches
an arbitrary ref, deliberately as a separate awkwardly-named method rather
than a flag, so serving unreviewed content is something a caller asks for
by name and a reviewer can grep for.

A rejected rev now wraps ErrNotFound as well, preserving the existing
deliberate property that a crafted revision cannot distinguish malformed
from absent by probing.
8edca94e — bigbes 27 days ago
feat(mcpsrv): MCP read tools, with Host validation replacing the SDK guard

Adds the Phase 2 read tools (spec_search, spec_read, spec_list) over the
service layer.

Two security fixes came out of building them.

The read plane could serve proposal content. gitx resolves ref names, and
service.resolveRev passed any string through, so rev=proposals/42 made the
READ plane hand back unreviewed text — which would then flow into agent
context as though approved, the single failure this service exists to
prevent. ValidateReadRev now admits only the approved-head sentinel or a
full 40-character object name, at the layer all three surfaces share.
Abbreviations are refused too: one that is unique today can become
ambiguous later, so a pinned revision would silently stop meaning one
thing.

The MCP SDK's DNS-rebinding guard rejects a loopback listener whose Host
is not loopback, which is exactly nginx forwarding to 127.0.0.1 — it would
403 only in production, passing every local test. The SDK offers no
allowlist, so the guard is disabled and replaced by a stricter check: Host
must equal the configured origin, or a loopback name for development. A
rebinding attack carries the attacker's name in Host and fails it. An
unusable origin logs loudly rather than quietly unguarding the endpoint.
c23aec57 — bigbes 27 days ago
feat: projects — a saved filter over one global index, not a container

A project is a named space set; querying one filters the single global
index. The meta-project is an implicit filter at a reserved address rather
than a row: a stored +everything would need a sync job on every space
creation, and its one failure mode is silently omitting a space.

SpaceFilter distinguishes All from an empty member list, because a freshly
created project has no members and must mean selects-nothing. Collapsing
the two would make every new project silently match the whole corpus.
b217c7a7 — Eugene Blikh 27 days ago
refactor: move the reconciler's two deletes down to the layers that own them

The reconciler reached past its layer twice, because the primitives it
needed did not exist: a raw DELETE FROM proposal — the only SQL written
outside db/ — and a go-git RemoveReference under gitx's write lock. Both
move down, with no change in behaviour.

db.Store.DeleteOpenProposal keeps the guard in the statement, as
resolveProposal does, and distinguishes the two ways it can bite: a row
that is gone is ErrNotFound, a row that has been resolved is the new
ErrProposalNotOpen, which tells the reconciler "nothing to repair" apart
from "the repair no longer applies".

gitx.Repo.DeleteProposalBranch refuses anything outside proposals/* —
the only thing between a caller bug and a deleted approved branch — takes
the per-space write lock like every other write, and treats an
already-absent branch as success: the repair is a postcondition, and the
ref may legitimately vanish between the listing that found it and the
delete.
Next