// 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, repliedTo := hash, ""
if s.Kind == "reply" {
// s.Root is the resolved post being answered; its thread and
// author come from the index.
if _, t, ok, err := repo.Index.ForumPostThread(s.Root); err == nil && ok {
thread = t
} else {
thread = s.Root
}
if s.Root != thread { // aimed below the root: personal
if _, posts, ok, err := repo.Index.ForumThreadPosts(thread); err == nil && ok {
for _, p := range posts {
if p.Hash == s.Root {
repliedTo = p.User
break
}
}
}
}
}
forumFanout(d, repoName, thread, hash, s.Author, s.Title, s.Body, repliedTo)
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
},
})
}