docs(up): finalize Phase 5a decision + deferred log
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.
feat(cmd,graph): wire /query onto core-go's server for webhooks (Phase 5a)
The faithful runtime wiring. /query moves from spec's anon-router handler
onto core-go's authenticated router, so the webhook engine gets the
auth/database/server context it requires.
- cmd: build the executable schema via graph.NewSchema and hand it to
both webhooks.NewQueue and server.WithSchema (one schema, both). The
server is coreserver.New().WithDefaultMiddleware() (core-go auth +
database + server context + the delivery worker via WithQueues). web,
MCP and REST stay on the anon router with spec's own authn — only
/query changes. The owner "user" row is seeded at startup so core-go's
LookupUser stays local.
- ownerOnly middleware on /query: 403s any non-owner (core-go auth admits
any meta user; spec is single-owner) and remaps the owner to
AUTH_INTERNAL so the webhook engine's NewAuthConfig/FilterWebhooks
(which refuse AUTH_COOKIE) accept them.
- graph: NewSchema exposes the raw executable schema; the webhook
resolver ACL now requires AUTH_INTERNAL (only the owner gets it, via
ownerOnly) instead of spec's authn, which is no longer in the /query
chain.
Accepted trade-off: agents lose GraphQL /query reads (they keep MCP +
REST). New deploy requirement: WithDefaultMiddleware needs [mail]
smtp-from (core-go's notification queue).
Verified against a live daemon on Postgres: owner cookie creates and
lists webhooks (row stored INTERNAL/user_id 1); non-owner 403; unauth
401; web UI 200 on the anon router.
feat(graph): GraphQL-native webhook surface (Phase 5a)
The webhook types, mutations, and resolvers, adapted from the pages.sr.ht
core-go template for spec's single-owner model.
- SDL: WebhookEvent (PROPOSAL_OPENED/MERGED/REJECTED), WebhookSubscription
interface + UserWebhookSubscription, WebhookDelivery, WebhookPayload
interface + ProposalEvent (carries a Proposal), cursor wrappers,
`webhook` payload root field, and a `type Mutation` with
createUserWebhook / deleteUserWebhook. No OAuth `client` field and no
@access/@private directives — spec has no OAuth clients or scopes, so
the owner gate is the entire ACL.
- Models: hand-written database.Model impls (UserWebhookSubscription,
WebhookDelivery) so gqlgen autobinds rather than generates them; events
via pq.Array; cursor keyset pagination.
- Resolvers: all owner-gated via authn (spec's ACL), using core-go's
webhook engine — Validate, NewAuthConfig (INTERNAL, via the coreauth
bridge), FilterWebhooks, WebhookContext.Exec for the sample, and the
`webhook`→Payload(ctx) root. Proposal writes deliberately stay off this
surface (only webhook mutations; the schema test now asserts exactly
that).
- gqlgen.yml binds Cursor to core-go's model.Cursor; generated code
regenerated with the pinned gqlgen v0.17.36 (reproducible).
Compiles and vets clean; existing graph read tests still pass. Runtime
context wiring and event firing are the next slices.
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.