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(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(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.
chore: promote go-arg to a direct dependency
specsrht-migrate embeds brant's cli.Args, which is go-arg based, so the
toolchain reclassifies it. No version changed and no module was added.
chore: promote fernet-go and go-ini to direct dependencies
authn/ imports both directly to decrypt the unified-login cookie and read
the instance ini, so the toolchain reclassifies them. No version changed
and no module was added.
Deliberately not running go mod tidy yet: bleve, chi, brant and the MCP
SDK have no importer until phases 2 and 3, and tidy would drop them from
go.mod, reintroducing it into every later parallel wave's file set.
feat: foundation — go.mod with every dependency, and the core/ domain
Phase 1 foundation commit. Two things, so that later parallel waves write
disjoint directories and never touch go.mod:
- go.mod / go.sum carrying every external dependency the whole module will
need (go-git, bleve, goldmark, chi, lib/pq, yaml.v3, the MCP SDK, brant,
auxilia, testify, and the sr-ht-core fork). Populated by building a
throwaway blank-import file, which is then deleted; `go mod tidy` runs
once, at the very end of the build-out.
- core/, the pure domain: owner/space names, safe relative paths, the
globally-unique document ID grammar, frontmatter parsing and schema
validation, `.spec.yml` policy with auto_merge glob matching, and the
proposal state machine. Standard library plus yaml.v3, nothing else.
Two design invariants are enforced here rather than documented and hoped for:
"approved" is not a status (it is a property of the branch a document is
reachable from), and the proposal machine has exactly open/merged/rejected.
A per-space `.spec.yml` cannot reintroduce either.
Note on the sr-ht-core pin: the design calls for a `replace` onto
git.srht.bigb.es/~bigbes/core-go at c2c2f38, but that commit's go.mod still
declares `module git.sr.ht/~sircmpwn/core-go`, so Go rejects the replacement.
Both siblings pin the later dd418a20 under the canonical path with no
replace; this does the same.