Skip to content
quarry Artifact [e530f957d6]
Login

Artifact [e530f957d6]

Artifact e530f957d68c0dfaf978527a04abace9430952386d2ebc85066e53d932410ddd:


// moderation.go is the forum's pre-commit gate (0001 §8). A staged post
// exists only here: approval BIRTHS the artifact (zit forum post runs
// then), rejection means it never existed — so nothing ever needs
// un-syncing and the artifact layer is untouched. Posts that arrived by
// sync are already content; for those, moderation is display state
// (hidden, with a public reason), never deletion.
//
// The moderation log is append-only BY CONSTRUCTION: this file exposes
// no update or delete for it, and it is public — Lobsters' rule, the
// same honesty invariant the rest of quarry keeps.
package oltp

import (
	"database/sql"
	"errors"
	"time"
)

const moderationSchema = `
CREATE TABLE IF NOT EXISTS forum_moderation (
	repo    TEXT PRIMARY KEY,
	enabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS forum_staged (
	id       INTEGER PRIMARY KEY AUTOINCREMENT,
	repo     TEXT NOT NULL,
	kind     TEXT NOT NULL,            -- 'post' | 'reply'
	root     TEXT NOT NULL DEFAULT '', -- reply target (thread root hash)
	title    TEXT NOT NULL DEFAULT '',
	body     TEXT NOT NULL,
	mimetype TEXT NOT NULL DEFAULT '',
	author   TEXT NOT NULL,
	at       TEXT NOT NULL,
	state    TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected
	-- Advisory hook verdict; a human always outranks it.
	ai_action TEXT NOT NULL DEFAULT '',
	ai_score  REAL NOT NULL DEFAULT 0,
	ai_reason TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS forum_staged_by_state ON forum_staged (repo, state, at);
CREATE TABLE IF NOT EXISTS forum_modlog (
	repo   TEXT NOT NULL,
	at     TEXT NOT NULL,
	actor  TEXT NOT NULL, -- an account, or 'hook' for automated actions
	action TEXT NOT NULL, -- approve | reject | hide | unhide | enable | disable | ai-*
	target TEXT NOT NULL, -- what it acted on: a title, post hash, or staged id
	reason TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS forum_modlog_by_repo ON forum_modlog (repo, at);
CREATE TABLE IF NOT EXISTS forum_hidden (
	repo   TEXT NOT NULL,
	post   TEXT NOT NULL,
	reason TEXT NOT NULL,
	actor  TEXT NOT NULL,
	at     TEXT NOT NULL,
	PRIMARY KEY (repo, post)
);`

// Staged is one held submission.
type Staged struct {
	ID       int64   `json:"id"`
	Repo     string  `json:"repo"`
	Kind     string  `json:"kind"`
	Root     string  `json:"root"`
	Title    string  `json:"title"`
	Body     string  `json:"body"`
	Mimetype string  `json:"mimetype"`
	Author   string  `json:"author"`
	At       string  `json:"at"`
	State    string  `json:"state"`
	AIAction string  `json:"aiAction"`
	AIScore  float64 `json:"aiScore"`
	AIReason string  `json:"aiReason"`
}

// ModEntry is one public moderation-log line.
type ModEntry struct {
	At     string `json:"at"`
	Actor  string `json:"actor"`
	Action string `json:"action"`
	Target string `json:"target"`
	Reason string `json:"reason"`
}

func now() string { return time.Now().UTC().Format(time.RFC3339) }

// SetModeration switches the pre-commit gate for a repo.
func (d *DB) SetModeration(repo string, enabled bool) error {
	on := 0
	if enabled {
		on = 1
	}
	_, err := d.db.Exec(`INSERT INTO forum_moderation (repo, enabled) VALUES (?, ?)
		ON CONFLICT (repo) DO UPDATE SET enabled = excluded.enabled`, repo, on)
	return err
}

// ModerationEnabled reports whether the gate is on.
func (d *DB) ModerationEnabled(repo string) bool {
	var on int
	err := d.db.QueryRow(`SELECT enabled FROM forum_moderation WHERE repo = ?`, repo).Scan(&on)
	return err == nil && on == 1
}

// StagePost holds a submission for review and returns its queue id.
func (d *DB) StagePost(repo, kind, root, title, body, mimetype, author string) (int64, error) {
	res, err := d.db.Exec(`INSERT INTO forum_staged (repo, kind, root, title, body, mimetype, author, at)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, repo, kind, root, title, body, mimetype, author, now())
	if err != nil {
		return 0, err
	}
	return res.LastInsertId()
}

// StagedPending lists a repo's queue, oldest first.
func (d *DB) StagedPending(repo string) ([]Staged, error) {
	rows, err := d.db.Query(`SELECT id, repo, kind, root, title, body, mimetype, author, at, state,
			ai_action, ai_score, ai_reason
		FROM forum_staged WHERE repo = ? AND state = 'pending' ORDER BY at, id`, repo)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Staged
	for rows.Next() {
		var s Staged
		if err := rows.Scan(&s.ID, &s.Repo, &s.Kind, &s.Root, &s.Title, &s.Body, &s.Mimetype,
			&s.Author, &s.At, &s.State, &s.AIAction, &s.AIScore, &s.AIReason); err != nil {
			return nil, err
		}
		out = append(out, s)
	}
	return out, rows.Err()
}

// StagedMine lists one account's own pending submissions in a repo,
// oldest first.
//
// The author is the one reader who must always be able to see their own
// held content, and today they are the one reader who cannot. A staged
// submission lives in forum_staged and never in the DAG, and every
// forum surface reads the DAG -- so the author's post is invisible to
// them BY CONSTRUCTION rather than by any filter somebody wrote. That
// is why the silence is total: there was no code path that decided to
// hide it, so there was none to soften either.
//
// jva measured the whole of it from a non-moderator account (G-118):
// after `staged: true` the post is absent from forum.list, absent from
// the forum page, and the queue answers 403 -- and the rejection REASON
// is published in a modlog the author is never pointed at. An author
// who sees less than a stranger does is the defect, in this path and in
// the attachment one (G-119).
//
// Scoped to ONE author by the query itself rather than by a caller's
// filter: a queue read is the moderation letter's business, and this
// must not become a second way to read it.
func (d *DB) StagedMine(repo, author string) ([]Staged, error) {
	if author == "" {
		return nil, nil
	}
	rows, err := d.db.Query(`SELECT id, repo, kind, root, title, body, mimetype, author, at, state,
			ai_action, ai_score, ai_reason
		FROM forum_staged WHERE repo = ? AND author = ? AND state = 'pending'
		ORDER BY at, id`, repo, author)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Staged
	for rows.Next() {
		var s Staged
		if err := rows.Scan(&s.ID, &s.Repo, &s.Kind, &s.Root, &s.Title, &s.Body, &s.Mimetype,
			&s.Author, &s.At, &s.State, &s.AIAction, &s.AIScore, &s.AIReason); err != nil {
			return nil, err
		}
		out = append(out, s)
	}
	return out, rows.Err()
}

// StagedByID fetches one queue entry.
func (d *DB) StagedByID(repo string, id int64) (Staged, bool, error) {
	var s Staged
	err := d.db.QueryRow(`SELECT id, repo, kind, root, title, body, mimetype, author, at, state,
			ai_action, ai_score, ai_reason
		FROM forum_staged WHERE repo = ? AND id = ?`, repo, id).
		Scan(&s.ID, &s.Repo, &s.Kind, &s.Root, &s.Title, &s.Body, &s.Mimetype,
			&s.Author, &s.At, &s.State, &s.AIAction, &s.AIScore, &s.AIReason)
	if errors.Is(err, sql.ErrNoRows) {
		return Staged{}, false, nil
	}
	return s, err == nil, err
}

// ResolveStaged moves pending → approved|rejected. It refuses an entry
// that is not pending, so two moderators racing the same submission
// release it exactly once.
func (d *DB) ResolveStaged(repo string, id int64, state string) error {
	res, err := d.db.Exec(`UPDATE forum_staged SET state = ?
		WHERE repo = ? AND id = ? AND state = 'pending'`, state, repo, id)
	if err != nil {
		return err
	}
	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return errors.New("oltp: staged entry is not pending")
	}
	return nil
}

// ReopenStaged puts an approved-but-unreleased entry back in the queue
// — the artifact write failed after the claim, and a person must see it
// rather than it rotting approved-but-absent.
func (d *DB) ReopenStaged(repo string, id int64) error {
	_, err := d.db.Exec(`UPDATE forum_staged SET state = 'pending'
		WHERE repo = ? AND id = ? AND state = 'approved'`, repo, id)
	return err
}

// SetStagedAI attaches the hook's advisory verdict.
func (d *DB) SetStagedAI(repo string, id int64, action string, score float64, reason string) error {
	_, err := d.db.Exec(`UPDATE forum_staged SET ai_action = ?, ai_score = ?, ai_reason = ?
		WHERE repo = ? AND id = ?`, action, score, reason, repo, id)
	return err
}

// AppendModLog records one public moderation action. There is no way to
// edit or remove an entry.
func (d *DB) AppendModLog(repo, actor, action, target, reason string) error {
	_, err := d.db.Exec(`INSERT INTO forum_modlog (repo, at, actor, action, target, reason)
		VALUES (?, ?, ?, ?, ?, ?)`, repo, now(), actor, action, target, reason)
	return err
}

// ModLog lists a repo's moderation actions, newest first.
func (d *DB) ModLog(repo string, limit int) ([]ModEntry, error) {
	rows, err := d.db.Query(`SELECT at, actor, action, target, reason FROM forum_modlog
		WHERE repo = ? ORDER BY at DESC, rowid DESC LIMIT ?`, repo, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []ModEntry
	for rows.Next() {
		var e ModEntry
		if err := rows.Scan(&e.At, &e.Actor, &e.Action, &e.Target, &e.Reason); err != nil {
			return nil, err
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

// HidePost collapses a synced-in post behind its reason; UnhidePost
// restores it. Both are display state — the artifact is never touched.
func (d *DB) HidePost(repo, post, reason, actor string) error {
	_, err := d.db.Exec(`INSERT INTO forum_hidden (repo, post, reason, actor, at)
		VALUES (?, ?, ?, ?, ?)
		ON CONFLICT (repo, post) DO UPDATE SET reason = excluded.reason, actor = excluded.actor, at = excluded.at`,
		repo, post, reason, actor, now())
	return err
}

func (d *DB) UnhidePost(repo, post string) error {
	_, err := d.db.Exec(`DELETE FROM forum_hidden WHERE repo = ? AND post = ?`, repo, post)
	return err
}

// HiddenPosts returns post → reason for one repo.
func (d *DB) HiddenPosts(repo string) (map[string]string, error) {
	rows, err := d.db.Query(`SELECT post, reason FROM forum_hidden WHERE repo = ?`, repo)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]string{}
	for rows.Next() {
		var p, r string
		if err := rows.Scan(&p, &r); err != nil {
			return nil, err
		}
		out[p] = r
	}
	return out, rows.Err()
}