Skip to content
quarry Artifact [d2cd790191]
Login

Artifact [d2cd790191]

Artifact d2cd790191c0091e5893e3fd5c05a3437aee7d0c15d696d74f266a242dfb3f26:


// 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"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/lease"
	"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"`
}

// postNoun is what the author called it, so a reply is not announced
// as a post.
func postNoun(s oltp.Staged) string {
	if s.Kind == "reply" {
		return "reply"
	}
	return "post"
}

// postDescription names a staged submission in the one line a
// notification has: its title when it has one, else the opening of its
// body. A person needs to know WHICH of their posts this was.
func postDescription(s oltp.Staged) string {
	if s.Title != "" {
		return s.Title
	}
	return attentionSummary(s.Body)
}

// moderationEvent is the message a moderation decision sends its
// author, and ok reports whether there is anybody to send it to.
//
// Separate from the firing so the decision about WHAT a person is told
// can be tested without a notify plane behind it — the same shape
// attachmentGetAnswer and UnmatchableScope take.
//
// noun is what they submitted ("post", "attachment"); what NAMES the
// particular one, because a person with several held things needs to
// know which. id is the stable identity of the decision, so a replayed
// trigger does not arrive twice.
//
// Not ok when there is nobody to tell — an anonymous submission, or a
// row that could not be read — and not ok when the moderator IS the
// author, who does not need telling what they just did.
func moderationEvent(repo, author, moderator, noun, state, what, reason, link, id string) (NotifyEvent, bool) {
	if author == "" || author == moderator {
		return NotifyEvent{}, false
	}
	text := what
	if reason != "" {
		text += " — " + reason
	}
	// THE OUTCOME BELONGS IN THE LINE A CLIENT RENDERS, not only in the
	// title beside it.
	//
	// The first version put the verb in the title and the substance in
	// the summary, and the inbox shows the summary — so the row said
	// what happened and not to what. Fixing that moved the substance in
	// and left the verb out, so the row named the submission and not
	// what had become of it: a client rendering only `summary` showed
	// its reader a bare title. An over-correction, and jva found it by
	// hunting for the case where an approval and a rejection read
	// alike.
	//
	// They cannot actually collide — a rejection must carry a reason,
	// refused at the op, so it always has the suffix an approval never
	// has — but a reader should not have to infer the outcome from the
	// presence of a dash.
	lead := strings.ToUpper(state[:1]) + state[1:] + ": " + text
	return NotifyEvent{
		Workflow: "quarry-moderation",
		To:       author,
		Payload: map[string]any{
			"repo": repo, "from": moderator,
			"title": "Your " + noun + " in " + repo + " was " + state,
			"text":  text, "link": link,
			// SUMMARY IS WHAT THE READER ACTUALLY SEES, and leaving it
			// out is why the first version of this told nobody anything.
			//
			// attentionOf builds the attention event's summary from
			// "summary", else "title", else the text. With a title and
			// no summary it took the title — "Your post in X was
			// rejected" — and the inbox row shows that, because a
			// paired row prefers its twin's summary. So the reason rode
			// in "text", which no author-facing surface renders: not
			// the inbox, not the recent list, and not the author's held
			// list, which no longer holds a rejected item at all.
			//
			// jva measured all three before calling it, precisely
			// because the last inbox defect was "the richer copy exists
			// somewhere else" (G-136). It did not, this time.
			// Bounded like every other attention summary rather than
			// passed whole: a reason may run to 500 characters and an
			// inbox row has one line to spend. attentionOf bounds the
			// "text" fallback and not this key, so the bounding has to
			// happen here or moderation rows would be the only ones
			// that can run long.
			"summary": attentionSummary(lead),
		},
		TxnID: "moderation:" + repo + ":" + id + ":" + state,
	}, true
}

// notifyModerated tells the author what became of their held
// submission, and is the only place a moderation reason is addressed to
// a person rather than to the record.
func (d Deps) notifyModerated(repo, author, moderator, noun, state, what, reason, link, id string) {
	if e, ok := moderationEvent(repo, author, moderator, noun, state, what, reason, link, id); ok {
		d.fireNotify(e)
	}
}

// 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", s.Author)
		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, s.Author)
	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", s.Author)
		}
	}
}

// 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(tok lease.Token) (err error) {
		if s.Kind == "reply" {
			hash, err = repo.Zit.ForumReply(tok, s.Root, s.Author, replyMimetype(s.Mimetype), []byte(s.Body))
		} else {
			hash, err = repo.Zit.ForumPost(tok, 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), s.Author)
	// And tell the author it is live.
	//
	// APPROVAL IS THE HALF WITH NO OTHER TELLER, which is the opposite
	// of what I first assumed. A rejected submission changes state in
	// the author's own held list, so a person who comes back learns
	// what happened. An approved one LEAVES that list and becomes an
	// ordinary post, so there is nothing marked to come back to: a post
	// can sit live for a day with its author never learning it was
	// released. Argued by my colleague, who was right.
	//
	// AFTER THE LAST STEP ANY COMPENSATION CAN UNDO, which is the real
	// rule and is stronger than "after the artifact exists".
	//
	// A notification is the one effect that cannot be compensated:
	// every other step here has an undo — ResolveStaged is reversed by
	// ReopenStaged when the artifact write fails — and nobody can
	// un-tell a person. So it goes after the point where nothing
	// downstream can still take it back, not at the state transition.
	//
	// The general test, which is worth more than this call site: if a
	// write is one transaction that either happens or does not, tell
	// people inside it. If there is a compensating path, tell them
	// after the last thing that path can reach. Sending at the
	// transition here would mean telling an author their post is live
	// and then quietly reopening it.
	d.notifyModerated(repoName, s.Author, actor, postNoun(s), "approved",
		postDescription(s), "", d.link("/r/"+repoName+"/forum/"+hash[:min(16, len(hash))]),
		"staged:"+strconv.FormatInt(id, 10))
	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 moderationStateIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

// moderationStateOut separates "this forum is moderated" from "it would
// hold YOURS": a maintainer posting to a moderated forum is not held,
// and telling them otherwise would be a lie in the other direction.
type moderationStateOut struct {
	Enabled   bool `json:"enabled"`
	WouldHold bool `json:"wouldHold"`
}

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 is a boolean, as the spec and the answer (moderationSetOut)
	// already said it was. It used to be the string "on"/"off" because
	// the HTML toggle posts that, so a JSON caller sending `true` got
	// back `cannot unmarshal bool into Go struct field ... of type
	// string` — a Go type name in the API's mouth, for a field every
	// reader of the spec takes as a boolean. The form is the one door
	// that speaks "on"; it now translates before it calls, and the op
	// takes the type the fact has.
	Enabled bool `json:"enabled"`
}

type moderationSetOut struct {
	Enabled bool `json:"enabled"`
}

// The four ops below are Token, gated on 5 alone (ModForum) — a
// repository's own forum-moderation letter, not site admin. Forum
// moderation is squarely the repository's own business (docs/hosting.md);
// gating it at Admin would have meant a repository's admin ('a') AND a
// site admin's floor both, when the letter the fossil vocabulary already
// names for this is 5.
func registerModeration(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[modQueueIn, modQueueOut]{
		Name:  "forum.queue",
		Caps:  "5",
		Doc:   "The moderation queue: submissions held for review, oldest first.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/forum-queue",
		Run: func(ctx context.Context, in modQueueIn) (modQueueOut, error) {
			if _, err := d.forumRepoFor(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",
		Caps:  "5",
		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.Token,
		Route: "POST /api/v1/r/{repo}/forum-queue",
		Run: func(ctx context.Context, in moderateIn) (moderateOut, error) {
			if _, err := d.forumRepoFor(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)
			}
			// Read BEFORE resolving: the row is what names the author,
			// and after this the submission is a rejected record nobody
			// goes looking for.
			st, _, _ := d.Forum.StagedByID(in.Repo, id)
			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, st.Author)
			// And tell the author, who is otherwise the one person this
			// concerns and the only one who is never told. Their post
			// never entered the DAG, so there is no page to find and no
			// absence to notice; the reason above is addressed to
			// everybody and therefore to nobody (b32aeba12bc6, found by
			// jva testing the surface from a non-moderator account).
			// Linked to the MODERATION LOG, because a rejection has no
			// post to point at — the submission never entered the DAG.
			// The asymmetry ran the wrong way round before this: the
			// approval, which arguably announces itself by appearing,
			// carried a link; the rejection, where the thing vanishes
			// and the logged reason is the only artifact, carried none.
			d.notifyModerated(in.Repo, st.Author, author(ctx), postNoun(st), "rejected",
				postDescription(st), in.Reason, d.link("/r/"+in.Repo+"/forum/modlog"),
				"staged:"+strconv.FormatInt(id, 10))
			return moderateOut{ID: in.ID, State: "rejected"}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[hideIn, hideOut]{
		Name: "forum.hide",
		Caps: "5",
		Doc: "Collapse (or restore) a synced-in post behind a public reason. Display state only — the artifact is untouched. " +
			"A site admin may hide abuse without a role, with a receipt to the owner, where the post is public: on a " +
			"repository they cannot read, judging it would mean reading it, so there they have no such path.",
		Kind: ops.Command,
		Auth: ops.Token,
		// The operator path for abuse (#318). forumRepoFor still resolves
		// the repository as the caller may READ it, so a private one stays
		// not found and its posts are never theirs to judge.
		Operator: true,
		Route:    "POST /api/v1/r/{repo}/forum-hide",
		Run: func(ctx context.Context, in hideIn) (hideOut, error) {
			repo, err := d.forumRepoFor(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)
			}
			// Who wrote it, for the log's author column. Best-effort:
			// a log line that cannot name its subject is still a log
			// line, and hiding must not be refused because the author
			// lookup missed.
			about, _, _ := repo.Index.ForumPostAuthor(post)
			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, about)
				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, about)
			return hideOut{Post: post, Hidden: false}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[modlogIn, modlogOut]{
		Name: "forum.modlog",
		Caps: "2",
		Doc: "The moderation log: every action, actor and reason, newest first. The people who " +
			"moderate see every entry; everybody else sees the entries about themselves. There is " +
			"no way to edit or remove an entry.",
		Kind: ops.Query,
		// Signed in, not anonymous: an entry names the person it is
		// about, and a stranger has no business reading who was
		// moderated and why.
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/forum-modlog",
		Run: func(ctx context.Context, in modlogIn) (modlogOut, error) {
			if _, err := d.forumRepoFor(ctx, in.Repo); err != nil {
				return modlogOut{}, err
			}
			// A moderator sees the whole log; anybody else sees only the
			// entries ABOUT them. The letter deciding it is the forum's
			// moderation letter, which `a` implies — the same question
			// the queue asks, so "who moderates here" has one answer
			// rather than two that can drift.
			//
			// d.holds, not ops.CapsOf: the ctx carries caps only on
			// paths that set them, and this op is also reached from the
			// web layer, where the answer comes from the configured
			// resolver instead. Reading the ctx directly made every
			// moderator take the author branch and see an empty log.
			var (
				entries []oltp.ModEntry
				err     error
			)
			if d.holds(ctx, in.Repo, "5") {
				entries, err = d.Forum.ModLog(in.Repo, 200)
			} else {
				entries, err = d.Forum.ModLogAbout(in.Repo, author(ctx), 200)
			}
			if err != nil {
				return modlogOut{}, err
			}
			return modlogOut{Entries: entries, Count: len(entries)}, nil
		},
	}); err != nil {
		return err
	}
	// The answer a would-be POSTER needs, which until now only a
	// moderator could get: forum.queue carries `enabled` and takes the
	// 5 letter, so the person the gate actually acts on could not ask.
	// They learned their post was held from the result AFTER writing it
	// (jva, G-140 list item 5).
	if err := ops.Register(r, ops.Op[moderationStateIn, moderationStateOut]{
		Name: "forum.moderation.state",
		// The forum read letter: this says no more than what happens
		// when you press the button on a forum you can already read.
		Caps:  "2",
		Doc:   "Whether this forum holds submissions for moderation, and whether it would hold THIS caller's.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/forum-moderation",
		Run: func(ctx context.Context, in moderationStateIn) (moderationStateOut, error) {
			if _, err := d.forumRepoFor(ctx, in.Repo); err != nil {
				return moderationStateOut{}, err
			}
			if d.Forum == nil || !d.Forum.ModerationEnabled(in.Repo) {
				return moderationStateOut{}, nil
			}
			// ONE function answers both questions, for this op and for
			// forum.list, because they are the same two questions and
			// were answered twice: forum.list had the signed-out guard
			// and this did not, so an anonymous caller was told their
			// post would be held here while the listing told them it
			// would not. Two implementations of one rule is how they
			// came apart, so this call replaces the second copy rather
			// than acquiring a matching guard of its own.
			//
			// ops.User(ctx), not author(ctx): a signed-out caller has no
			// account, and the substituted name "anonymous" is not the
			// absence of one.
			m := forumModerationFor(d, in.Repo, ops.User(ctx))
			return moderationStateOut{Enabled: m.moderated, WouldHold: m.yours}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[moderationSetIn, moderationSetOut]{
		Name:  "forum.moderation",
		Caps:  "5",
		Doc:   "Switch the repository's pre-commit moderation gate on or off (logged).",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/forum-moderation",
		Run: func(ctx context.Context, in moderationSetIn) (moderationSetOut, error) {
			if _, err := d.forumRepoFor(ctx, in.Repo); err != nil {
				return moderationSetOut{}, err
			}
			on := in.Enabled
			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
		},
	})
}