feat(core,db): comment anchors and the comment table (spec-by6.3.1)
DESIGN.md held the comment schema back until the review UI existed, because a
schema is the expensive thing to get wrong here. The UI exists, so this lands
the anchoring model it specifies: (doc_id, heading_path, block_index,
block_hash), resolved content-first — the block's hash wherever it moved to,
then position under the same headings, and outdated when neither matches. A
comment that lost its place says so rather than being relocated to a best
guess, because a comment on the wrong paragraph reads as authoritative.
Two refinements the specified model left open:
block_index is the block's position WITHIN its heading path, not within the
document. prosediff numbers blocks document-globally, but the positional
fallback exists precisely for blocks whose content changed, and a global index
is destroyed by any insertion above it — so a global index would fail exactly
when it is needed.
A hash match is not unique. Documents repeat themselves ("TBD" under half the
headings), so the comment's own section wins outright and the nearest index
breaks what that does not. Without it, which duplicate a comment landed on
would depend on document order.
Anchor state is derived, never stored. A comment is not outdated in general, it
is outdated at a revision, and the proposal branch moves under it as the agent
revises; a column would cache a function of a moving input.
The table encodes the rest as constraints: a root carries the whole anchor and
a reply carries none of it (one predicate over all six columns, so a
half-written anchor is unwritable), agent provenance on proposal's rule, and
resolution only on a thread root.
Verified against a real Postgres 16, which caught what Go-level validation did
not: pq.Array sends a nil slice as SQL NULL, so a comment on a block before the
first heading tripped ck_comment_anchor and every document preamble was
uncommentable.
schema.sql keeps the comment table last, matching migration order — the
agreement test compares the two statement for statement. Its absent-list, which
required a design change rather than a quiet migration to add this table, is
the gate this commit passes through.
spec-by6.3.1
refactor(doc): one route from a revision to an Archive (spec-wcr #2, #4)
doc.Scan/DocumentSource were production-dead after service.Archive landed —
their only consumer was doc's own test fixture — and they were the seam that
made web's layering violation writable: a surface could reach past service/
into gitx and build its own archive. Deleted, so doc/ now owns no way to read
a revision and service.Archive is the single route from rev to Archive. The
fixture reads through ListDocuments + FromDocuments instead.
TestScanReportsGitErrors covered an unknown revision failing rather than
walking empty, which gitx did not test itself. Relocated there as
TestWalkOfAnUnknownRevisionFails rather than dropped.
linkHierarchy passed path.Dir(p.Path) where every other call site passes DirOf.
Unobservable today: the two differ only at the space root, and there the
section-proximity step is subsumed by the same-directory step above it, so "."
only ever skipped a lookup that had already answered. Verified by reverting and
re-running. Changed anyway — it stays unobservable only by coincidence of two
ranking rules — with a test pinning the invariant that `parent:` resolves to
whatever the same wikilink in the body resolves to.
spec-wcr
chore(beads): enable Dolt auto-push to dolt.srht.bigb.es
Beads changes were only reaching the remote on a manual bd dolt push, so
issue state drifted from the tracker between sessions. bd has native
auto-push (debounced, pushes to the configured origin remote); it was
simply never enabled — sync.remote was set but dolt.auto-push was unset.
Enable it with an explicit 5m debounce rather than relying on the implicit
default. Verified end-to-end: a write fires "dolt auto-push: pushed
successfully", and a fresh dolt clone of the remote shows this session's
closed beads (spec-jjo, spec-mfm, spec-ejq.1) as closed.
Also commits the JSONL exports, which were stale from this session's work.
test(db): guard OpenProposal branch SQL against core.ProposalBranch drift (spec-wcr #1)
OpenProposal allocates a proposal's id and branch in one INSERT, so it
cannot call core.ProposalBranch — it spells the name out in SQL as
BranchPrefix || id::text. That is a third derivation of the branch name
sharing only the prefix constant with the canonical function; a change to
how core.ProposalBranch formats the id would leave the SQL silently
producing a different name. Pin the coupling with a pure unit test so the
divergence fails loudly instead of in production.
Part of spec-wcr (loose ends). Pure test, no behavior change.
refactor(authn): one Principal.CanRead() for the read-plane ACL (spec-ejq.1)
graph's gate, web's mayRead and mcpsrv's Gate each hand-spelled
'IsOwner() || IsAgent()' — three copies of the read ACL, which graph's own
comment warned is how a corpus leaks when they drift. Define it once as
authn.Principal.CanRead and route all three through it.
coreauth.Derive keeps its own owner||agent test on purpose: it answers a
different question (is this an owner-backed identity to bridge to
AUTH_INTERNAL), and coupling it to the read ACL would misroute a future
read-only viewer kind to the owner's UserID.
Closes spec-ejq.1
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
fix(mcpsrv): gate read tools to owner+agents (spec-jjo)
The MCP read tools (spec_search/spec_read/spec_list) enforced no ACL:
anyone clearing the Host allowlist could read approved content. graph's
/query and the web UI gate reads to owner+agents; /mcp did not. Add the
same fail-closed gate to the whole MCP surface — initialize, tools/list
and tools/call — mounted inside the resolver middleware so it sees the
resolved principal. spec_propose was already fail-closed in service.Propose;
this makes the read tools match.
Pre-existing since Phase 2, cheap now that /mcp resolves a principal.
Closes spec-jjo
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.