Artifact ce8c9fe0fc38fffe3d3ae793b8b4988c014e29e12574c784fc9f365c02d574f5:
- File
internal/api/moderation.go
— part of check-in
[893885b3d5]
at
2026-08-21 19:49:17
on branch quarry-forum
— forum subscriptions: auto-sub on authoring, reply/mention fanout, unsub sticks
Fanout rides the write path only (a reindex can never re-notify); delivery/dedup stays in the notify companion via TxnID. (user: dmestas, size: 10399) [annotate] [blame] [check-ins using] [more...]
// moderation.go is the forum's pre-commit gate and its public record. // A staged submission exists only in OLTP; APPROVAL runs `zit forum // post` — the artifact is born then, so rejection leaves nothing to // un-sync and the artifact layer never learns moderation exists. // Synced-in posts are already content: for those, moderation is // display state (hidden + reason), never deletion. Every action lands // in the append-only public log (Lobsters' rule). package api import ( "context" "fmt" "strconv" "time" "craftdesign.group/quarry/internal/oltp" "craftdesign.group/quarry/internal/ops" ) // HookVerdict is the AI hook's advisory answer. The human always // outranks it: approve releases (logged as automated), hold and reject // wait for a person — reject is a recommendation, not an act. type HookVerdict struct { Action string `json:"action"` // approve | hold | reject Score float64 `json:"score"` Reason string `json:"reason"` } // maybeStage holds a submission when the repo's gate is on and the // author is not trusted. true means "held" — the caller writes nothing. func maybeStage(ctx context.Context, d Deps, repo, kind, root, title, body, mimetype string) (bool, error) { if d.Forum == nil || !d.Forum.ModerationEnabled(repo) { return false, nil } who := author(ctx) if d.TrustedAuthor == nil || d.TrustedAuthor(who, repo) { return false, nil } id, err := d.Forum.StagePost(repo, kind, root, title, body, mimetype, who) if err != nil { return false, err } go runForumHook(d, repo, id) return true, nil } // runForumHook asks the configured hook for an advisory verdict, // asynchronously — posting never blocks on hook latency. Every failure // mode fails OPEN INTO HOLD-FOR-HUMAN: the post is never lost and never // auto-approved by an error. Post content is data; a hook must never // follow instructions embedded in it. func runForumHook(d Deps, repo string, id int64) { if d.ForumHook == nil { return } s, ok, err := d.Forum.StagedByID(repo, id) if err != nil || !ok { return } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() v, err := d.ForumHook(ctx, s) target := "staged:" + strconv.FormatInt(id, 10) if err != nil { d.Forum.SetStagedAI(repo, id, "hold", 0, "hook failed: "+err.Error()) d.Forum.AppendModLog(repo, "hook", "ai-hold", target, "hook failed; held for a person") return } switch v.Action { case "approve", "hold", "reject": default: v.Action, v.Reason = "hold", "hook returned "+v.Action+"; held for a person" } if err := d.Forum.SetStagedAI(repo, id, v.Action, v.Score, v.Reason); err != nil { return } d.Forum.AppendModLog(repo, "hook", "ai-"+v.Action, target, v.Reason) if v.Action == "approve" { if _, err := releaseStaged(d, repo, id, "hook"); err != nil { d.Forum.AppendModLog(repo, "hook", "ai-hold", target, "release failed; held for a person") } } } // releaseStaged is approval: claim the pending row (the race mutex), // then birth the artifact with the STAGED author's name. func releaseStaged(d Deps, repoName string, id int64, actor string) (string, error) { s, ok, err := d.Forum.StagedByID(repoName, id) if err != nil { return "", err } if !ok { return "", fmt.Errorf("no staged entry %d: %w", id, ops.ErrNotFound) } repo, ok := d.Repos.Get(repoName) if !ok || repo.Zit == nil { return "", fmt.Errorf("repository %q: %w", repoName, ops.ErrNotFound) } if err := d.Forum.ResolveStaged(repoName, id, "approved"); err != nil { return "", err } var hash string werr := repo.write(func() (err error) { if s.Kind == "reply" { hash, err = repo.Zit.ForumReply(s.Root, s.Author, replyMimetype(s.Mimetype), []byte(s.Body)) } else { hash, err = repo.Zit.ForumPost(s.Title, s.Author, s.Mimetype, []byte(s.Body)) } return err }) if werr != nil { // The claim happened but the birth failed: put the entry back in // front of a person rather than losing it approved-but-unwritten. d.Forum.ReopenStaged(repoName, id) return "", werr } if err := repo.synced(); err != nil { return "", err } d.Forum.AppendModLog(repoName, actor, "approve", "staged:"+strconv.FormatInt(id, 10), fmt.Sprintf("released as %.12s (by %s)", hash, s.Author)) thread := hash if s.Kind == "reply" { thread = s.Root } forumFanout(d, repoName, thread, hash, s.Author, s.Title, s.Body) return hash, nil } type modQueueIn struct { Repo string `json:"repo" validate:"required,max=64"` } type modQueueOut struct { Entries []oltp.Staged `json:"entries"` Enabled bool `json:"enabled"` Count int `json:"count"` } type moderateIn struct { Repo string `json:"repo" validate:"required,max=64"` ID int `json:"id" validate:"min=1"` Action string `json:"action" validate:"oneof=approve|reject"` Reason string `json:"reason" validate:"max=500"` } type moderateOut struct { ID int `json:"id"` State string `json:"state"` Hash string `json:"hash,omitempty"` } type hideIn struct { Repo string `json:"repo" validate:"required,max=64"` Post string `json:"post" validate:"required,min=4,max=64"` Action string `json:"action" validate:"oneof=hide|unhide"` Reason string `json:"reason" validate:"max=500"` } type hideOut struct { Post string `json:"post"` Hidden bool `json:"hidden"` } type modlogIn struct { Repo string `json:"repo" validate:"required,max=64"` } type modlogOut struct { Entries []oltp.ModEntry `json:"entries"` Count int `json:"count"` } type moderationSetIn struct { Repo string `json:"repo" validate:"required,max=64"` Enabled string `json:"enabled" validate:"oneof=on|off"` } type moderationSetOut struct { Enabled bool `json:"enabled"` } func registerModeration(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[modQueueIn, modQueueOut]{ Name: "forum.queue", Doc: "The moderation queue: submissions held for review, oldest first.", Kind: ops.Query, Auth: ops.Admin, Route: "GET /api/v1/r/{repo}/forum-queue", Run: func(ctx context.Context, in modQueueIn) (modQueueOut, error) { if _, err := d.repoFor(ctx, in.Repo); err != nil { return modQueueOut{}, err } entries, err := d.Forum.StagedPending(in.Repo) if err != nil { return modQueueOut{}, err } return modQueueOut{Entries: entries, Count: len(entries), Enabled: d.Forum.ModerationEnabled(in.Repo)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[moderateIn, moderateOut]{ Name: "forum.moderate", Doc: "Resolve a held submission: approve births the artifact, reject means it never existed. Both land in the public log.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/r/{repo}/forum-queue", Run: func(ctx context.Context, in moderateIn) (moderateOut, error) { if _, err := d.repoFor(ctx, in.Repo); err != nil { return moderateOut{}, err } id := int64(in.ID) if in.Action == "approve" { hash, err := releaseStaged(d, in.Repo, id, author(ctx)) if err != nil { return moderateOut{}, err } return moderateOut{ID: in.ID, State: "approved", Hash: hash}, nil } if in.Reason == "" { return moderateOut{}, fmt.Errorf("rejection needs a reason — the log is public: %w", ops.ErrInvalid) } if err := d.Forum.ResolveStaged(in.Repo, id, "rejected"); err != nil { return moderateOut{}, fmt.Errorf("%s: %w", err, ops.ErrInvalid) } d.Forum.AppendModLog(in.Repo, author(ctx), "reject", "staged:"+strconv.FormatInt(id, 10), in.Reason) return moderateOut{ID: in.ID, State: "rejected"}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[hideIn, hideOut]{ Name: "forum.hide", Doc: "Collapse (or restore) a synced-in post behind a public reason. Display state only — the artifact is untouched.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/r/{repo}/forum-hide", Run: func(ctx context.Context, in hideIn) (hideOut, error) { repo, err := d.repoFor(ctx, in.Repo) if err != nil { return hideOut{}, err } post, _, ok, err := repo.Index.ForumPostThread(in.Post) if err != nil { return hideOut{}, err } if !ok { return hideOut{}, fmt.Errorf("no forum post %q: %w", in.Post, ops.ErrNotFound) } if in.Action == "hide" { if in.Reason == "" { return hideOut{}, fmt.Errorf("hiding needs a reason — it renders in place of the post: %w", ops.ErrInvalid) } if err := d.Forum.HidePost(in.Repo, post, in.Reason, author(ctx)); err != nil { return hideOut{}, err } d.Forum.AppendModLog(in.Repo, author(ctx), "hide", post[:12], in.Reason) return hideOut{Post: post, Hidden: true}, nil } if err := d.Forum.UnhidePost(in.Repo, post); err != nil { return hideOut{}, err } d.Forum.AppendModLog(in.Repo, author(ctx), "unhide", post[:12], in.Reason) return hideOut{Post: post, Hidden: false}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[modlogIn, modlogOut]{ Name: "forum.modlog", Doc: "The public moderation log: every action, actor and reason, newest first. There is no way to edit or remove an entry.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/forum-modlog", Run: func(ctx context.Context, in modlogIn) (modlogOut, error) { if _, err := d.repoFor(ctx, in.Repo); err != nil { return modlogOut{}, err } entries, err := d.Forum.ModLog(in.Repo, 200) if err != nil { return modlogOut{}, err } return modlogOut{Entries: entries, Count: len(entries)}, nil }, }); err != nil { return err } return ops.Register(r, ops.Op[moderationSetIn, moderationSetOut]{ Name: "forum.moderation", Doc: "Switch the repository's pre-commit moderation gate on or off (logged).", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/r/{repo}/forum-moderation", Run: func(ctx context.Context, in moderationSetIn) (moderationSetOut, error) { if _, err := d.repoFor(ctx, in.Repo); err != nil { return moderationSetOut{}, err } on := in.Enabled == "on" if err := d.Forum.SetModeration(in.Repo, on); err != nil { return moderationSetOut{}, err } action := "disable" if on { action = "enable" } d.Forum.AppendModLog(in.Repo, author(ctx), action, "moderation", "") return moderationSetOut{Enabled: on}, nil }, }) }