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.
feat(db): webhook + user tables (Phase 5a foundation)
The DB foundation for GraphQL-native webhooks, matching the core-go /
pages.sr.ht convention:
- "user" table — spec is single-owner, but the webhook subscription is
user-scoped in core-go's model, so a user row is the owner's identity
and the FK target. Columns mirror core-go auth.LookupUser so the table
is ready if the service ever adopts core-go auth; today only id/username
are used (owner seeded at startup).
- webhook_event enum (PROPOSAL_OPENED/MERGED/REJECTED), auth_method enum.
- gql_user_wh_sub / gql_user_wh_delivery — column names match core-go's
webhooks engine exactly (it selects/inserts by these names). The
events check uses cardinality() not array_length() so an empty event
list is rejected at the DB (array_length returns NULL there, which a
CHECK does not reject — a latent gap in the upstream convention).
Webhook tables use bare `timestamp` (not spec's TIMESTAMPTZ) to match
core-go's `NOW() at time zone 'utc'` inserts. Verified: schema.sql and
the 0003 migration both apply and reverse cleanly; FK/check/cascade all
behave.
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.
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.
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.
feat(mcpsrv): spec_propose write tool (Phase 3)
The agent-facing half of the write plane over MCP. spec_propose uploads
whole documents and returns {proposal, url, merged}, calling the same
service.Propose the REST PUT will — one implementation of If-Match,
provenance and auto-merge behind both surfaces, never two that drift.
- Writer is an optional Backend field: nil keeps the server read-only
(the three read tools, unchanged), so a read-only deploy or a test
needs no mutable backend. Set, it registers spec_propose with neither
the read-only nor the idempotent hint — proposing twice opens two
proposals.
- The acting agent is resolved from the bearer token on the tool call by
the resolver middleware now installed on /mcp; the read tools never
needed it. The ACL stays in service/: service.Propose refuses a
non-agent, so an anonymous or owner caller is rejected there.
feat(graph): wire the proposals read to service.ListProposals (Phase 3)
The Proposals port declared a proposal listing and left Options.Proposals
nil, so the `proposals` query failed loudly with "service/ exposes no
proposal listing yet". Phase 3 supplies it: an adapter maps
service.Proposal onto graph.Proposal at the edge — the two structs are
identical, but service/ must not import graph/, so the rename lives here
beside web.NewReader's equivalent — and cmd wires graph.NewProposals(svc)
into the schema. The `proposals` field now answers from service/.
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.
build: package spec.sr.ht as an apk and wire push->build->mirror
Deploy the service from our own apk repo instead of cloning and
compiling this tree inside the srht stack's Dockerfile.
- APKBUILD: build css then static binaries (CGO_ENABLED=0), install
under ASSETS=/usr/share/sourcehut so specsrht-migrate resolves schema
and migrations at the real runtime path. pkgver rewritten by CI to
0.0.<commit-count> for a monotonic, pinnable version.
- .build.yml: builds.sr.ht manifest — assemble the shared sourcehut
scss partials (core.sr.ht CORE_VER + pinned Bootstrap submodule),
throwaway per-build signing key, abuild, publish *.apk to the Garage
repo bucket (append-only; apk-mirror on phoebe re-indexes and signs).
- .sourcecraft/webhooks.yaml: push webhook to the phoebe gitsync
service so the git.srht.bigb.es mirror updates in seconds, which is
what makes the push -> apk build fire immediately.
chore(beads): track the spec.sr.ht issue backlog
Seven issues under the epic: Phases 3-5 (write plane -> review UI ->
deferred pile, chained by blocks), the loose-ends bucket of latent bugs,
and two ops tasks (nav restart, stale apk pins). Exported to a
git-tracked JSONL so the backlog is durable and portable, not only in the
local embedded Dolt DB.
bd init: initialize beads issue tracking
feat(cmd): reindex a space when a push lands
The push notifier was a Phase 1 stub that logged 'reindex pending' because
bleve did not exist yet. It does now, so a pushed document was readable
but never searchable, and the reconciler reported permanent staleness.
Pushes touching only proposal branches skip the rebuild: the index holds
the approved revision, and making unreviewed text searchable is the same
leak as serving it from the read plane.
The index stamp is written last and only on success. Written earlier it
would assert the index reflects a revision it does not — the exact
staleness the reconciler exists to catch, and it would catch nothing.
The hook server is now built after the surfaces, since the notifier
reindexes through the same single-writer index they read from.
feat(cmd): specsrht space create/list
Spaces had no entry point at all: the read plane only reads, and the
proposal API operates on documents inside a space that already exists, so
a freshly deployed instance could not hold anything.
Creation installs the receive hooks itself rather than relying on the
daemon's startup refresh. A space created while the daemon runs would
otherwise accept unvalidated pushes until the next restart — the exact
fail-open the receive path exists to prevent.
feat(cmd): mount the read plane, MCP and GraphQL surfaces
The three Phase 2 surfaces were built but never served: each was written
under an instruction not to touch cmd/, so every one reported its mounting
call and none of them wired it. The daemon answered /healthz and 404'd
everything else.
They share one search.Index, because bleve is single-writer and a second
Open on the same directory is wrong rather than merely wasteful.
Route order is load-bearing: /mcp and /query register before the web UI
mounts at /, which would otherwise swallow them as document paths — the
router has no reason to think 'mcp' is not a space name.
The MCP handler takes the configured origin as its Host allowlist, which
is why Traefik must pass the Host header through.
docs: the empty-space-list polarity is retracted, not just documented
Search queries now take the filter type with three explicit states, so an
empty set selects nothing everywhere and the corpus-wide case is spelled
EverythingFilter. The document previously said an empty search space list
means every space, which the fix deliberately reverses.
Also records that introspection is gated along with the rest of the read
plane, and that nothing bounds GraphQL query complexity because the
service authenticates on its own router rather than core-go's.
feat(graph): the read-only GraphQL schema at /query
Eight query fields over the service layer, no Mutation and no
Subscription — the design defers mutations until the proposal state
machine settles, and TestSchemaHasNoMutations stands guard on that.
Access is fail-closed and gated before parse, matching web's ACL exactly,
so introspection is treated as content too. A federating api.sr.ht must
therefore present a token or skip us, which costs one log line.
A malformed rev is reported as a GraphQL error rather than folded into
null. service/ deliberately hides malformed-versus-absent from probing,
but the caller here is already authenticated as the owner or its own
agent, and a bare null for rev=proposals/42 is indistinguishable from an
absent document — it reads as a silently dropped argument.
Proposal listing declares its port but is unwired: service/ exposes no
proposal read yet, and returning an empty list would tell a reviewer their
queue is clear when it is merely unread.
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.
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.