Skip to content
quarry Artifact [29fe5089f1]
Login

Artifact [29fe5089f1]

Artifact 29fe5089f178b6030311eada1de9ea091aff92a980e48136f2eac254f344e85d:


// 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()
}

// 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()
}