Proposals Schema¶
Proposals (training mode and second opinions) are stored on a single subreddit wiki page at toolbox-nxg/proposals.
Overview¶
A proposal is a moderation action that was captured for review instead of being performed. A reviewer can accept it — which performs the real action by replaying a frozen intent — or reject it with feedback. See the Training Mode & Second Opinions user guide for the feature itself.
Proposals are an NXG-only feature with no Toolbox 6.x mirror. Unlike config and usernotes, there is no classic-layout counterpart — the page exists only at the NXG path, and 6.x clients neither read nor write it. The page is restricted to moderator-only access by wiki page settings (permlevel: 2).
The whole subreddit lives on one page. Reddit’s /api/wiki/edit honors the previous revision parameter (a stale write fails with HTTP 409 EDIT_CONFLICT) and read-after-write lag is small, so optimistic concurrency on a single page is safe; there is no sharded or bucketed layout. The path is exposed as a function (getProposalsPagePath) to leave room for a future bucketed layout without changing callers.
Page format¶
The page holds a single JSON object, ProposalsData:
{
"ver": 1,
"seq": 42,
"proposals": {
"k3f9q2": { ... },
"p7m1xa": { ... }
}
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
yes |
integer |
Schema version; |
|
no |
integer |
Monotonic page version, bumped by one on every committed write (see Page version) |
|
yes |
|
All proposals, keyed by their stable |
The current build writes, and only accepts, schema version 1.
Page version¶
seq is a monotonically increasing counter bumped by one on every committed write to the page. It is distinct from ver (the schema version, which only changes across builds) and from Reddit’s opaque wiki revision id (which is not orderable). Because it lives in the data, it travels with the page from any source — a fresh read, a local write, or a cross-tab broadcast — giving display caches a single lag-proof order so they never roll backward when reads arrive out of order. Legacy pages and ad-hoc literals without it are treated as 0. A hand-edit or admin revision-restore that lowers seq can leave an open tab showing newer-cached data until it reloads; this is outside the normal mutation flow and self-heals on reload.
Proposal¶
Each value in proposals is a Proposal:
{
"id": "k3f9q2",
"itemId": "t3_abc123",
"itemKind": "post",
"action": { "type": "remove", "spam": false },
"proposedBy": "trainee_mod",
"proposedAt": 1718000000,
"source": "training",
"note": "Looks like spam to me",
"link": "/r/example/comments/abc123/title/",
"status": "pending",
"updatedAt": 1718000000
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
yes |
string |
Stable, generated, collision-free id; also the map key. Not derived from item/time/mod (those collide on rapid double-clicks) |
|
yes |
string |
Target identifier: a Reddit fullname ( |
|
yes |
|
|
|
yes |
|
The captured action to replay on accept (see |
|
yes |
string |
Username of the moderator who proposed the action |
|
yes |
integer |
Epoch seconds when the proposal was created |
|
yes |
|
Why the proposal exists: |
|
yes |
|
Current lifecycle status (see Lifecycle) |
|
yes |
integer |
Epoch seconds of the last mutation to this proposal (any field) |
|
no |
string |
Optional free-text rationale from the proposer |
|
no |
string |
Squashed permalink to the target, for display/linking |
|
no |
string |
Username of the resolver, or a system sentinel for |
|
no |
integer |
Epoch seconds when the proposal reached a terminal status |
|
no |
string |
Rejecting reviewer’s explanation (reject only) |
|
no |
|
Why the proposal auto-resolved (obsolete only) |
|
no |
|
Failure diagnostics (needs_attention only; see |
|
no |
|
In-flight accept claim that gates concurrent accepts (see |
|
no |
boolean |
Whether the proposer has acknowledged the outcome. Gates pruning (see Pruning) |
All timestamps are epoch seconds (note: seconds, not milliseconds).
ProposalItemKind¶
What the proposal targets, and therefore how itemId reads:
Value |
|
|---|---|
|
a post fullname |
|
a comment fullname |
|
a username |
Lifecycle¶
status moves through a small state machine:
Status |
Meaning |
|---|---|
|
Awaiting review |
|
A reviewer accepted it and the real action replay fully succeeded |
|
A reviewer declined it (optionally with |
|
Auto-resolved without a verdict because the target went away or was actioned elsewhere (see |
|
An accept was attempted but replay failed partway; carries |
Allowed transitions:
pending→ any other status.needs_attention→accepted(retry succeeded),rejected, orobsolete.accepted,rejected, andobsoleteare terminal — they never transition again.
needs_attention is deliberately not terminal. The mutation layer enforces these transitions, so a stale write against an already-resolved proposal fails with a typed result rather than silently overwriting a verdict.
ObsoleteReason¶
Set only when status is obsolete:
Value |
Meaning |
|---|---|
|
The author deleted the target (reliably detectable) |
|
The target was approved/removed outside the proposal flow. Persisted only on strong (modlog-derived) evidence — never on best-effort |
NeedsAttentionDetail¶
Recorded when an accept attempt fails partway through replay; present only when status is needs_attention:
Field |
Type |
Description |
|---|---|---|
|
string |
Username of the moderator who attempted the accept |
|
integer |
Epoch seconds when the attempt happened |
|
string |
Which replay step failed (e.g. |
|
boolean |
Whether an irreversible side effect already landed before the failure — tells a reviewer whether a naive retry is safe |
|
string |
Human-readable error text from the failed step |
ReplayClaim¶
A short-lived claim a reviewer writes onto a proposal in the same atomic wiki write that begins an accept, immediately before the action is replayed. Two reviewers accepting the same proposal would otherwise both replay the (often irreversible) side effect before either marked it accepted; persisting the claim turns the conditional wiki write into a compare-and-set that lets only one in. It is cleared when the proposal resolves or the claim is released.
Field |
Type |
Description |
|---|---|---|
|
string |
Username of the reviewer who holds the claim |
|
integer |
Epoch seconds when the claim was placed |
A claim older than the replay-claim TTL (300 seconds) is treated as absent, so a crashed or abandoned accept frees the proposal for retry without manual repair. A normal accept always clears its own claim — on success, on failure (→ needs_attention), and on explicit release — so the TTL only governs the hard-crash case (the holding tab dies between claiming and resolving). The window is set well above any plausible worst-case replay rather than tuned tight: erring long only delays retry of a genuinely-crashed accept, which is strictly safer than ever acting twice.
ProposedAction¶
The captured action, a union discriminated by type. Thing-targeted actions (itemId is a fullname): approve, remove, removal-reason, lock, unlock, distinguish, marknsfw, sticky. User-targeted actions (itemId is a username): ban, unban, mute, unmute, userflair.
Each action also carries a replay class — atomic (replayed inline by a single moderation primitive) or composite (a multi-step pipeline replayed through a registered handler). removal-reason is the only composite; every other type is atomic.
|
Extra fields |
Description |
|---|---|---|
|
— |
Approve the target |
|
|
Remove the target, optionally as spam |
|
|
Remove via the full removal-reasons composite (see below) |
|
— |
Lock the target thing |
|
— |
Unlock the target thing |
|
|
Distinguish the target thing, optionally stickied |
|
|
Mark the target post NSFW ( |
|
|
Sticky the target submission into a slot, or unsticky it ( |
|
|
Ban the target user (see |
|
— |
Unban the target user |
|
|
Mute the target user (see |
|
— |
Unmute the target user |
|
|
Set the target user’s flair (each field present only when captured) |
ProposedBan¶
{
"type": "ban",
"permanent": false,
"days": 7,
"note": "repeated spam",
"message": "You have been banned for 7 days.",
"context": "t3_abc123"
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
yes |
boolean |
Permanent ban (ignores |
|
yes |
integer |
Duration in days when not permanent |
|
yes |
string |
Mod note (private) |
|
yes |
string |
Ban message sent to the user |
|
no |
string |
Fullname of the thing that prompted the ban, for context |
ProposedMute¶
{
"type": "mute",
"duration": 28,
"note": "modmail abuse"
}
Field |
Required |
Type |
Description |
|---|---|---|---|
|
no |
integer |
Mute duration in days. Reddit’s mute is fixed-length; stored for fidelity |
|
no |
string |
Mod note (private) |
FrozenRemovalIntent¶
The fully-rendered, post-templating intent for a removal-reason proposal. Replay reconstructs the removal submission params from this and hands them back to the removal pipeline. It stores resolved values (a reason can be edited or deleted between propose and accept, so reason ids are not re-resolved), but is hand-curated to only what replay needs: empty/default fields are omitted, and the optional steps (flair, usernote, ban, log) are nested and present only when used. The item’s own metadata (author, permalink, kind) is not stored — it is re-fetched from the proposal’s itemId at replay time, since the item still exists until the proposal is accepted.
Field |
Required |
Type |
Description |
|---|---|---|---|
|
yes |
string |
Final composed reason text, with header/footer and tokens already applied |
|
no |
string |
Display title(s) of the selected reason template(s), joined with |
|
yes |
|
Delivery mode: |
|
yes |
string |
PM/modmail subject line (used for pm/modmail/ban delivery) |
|
no |
string |
Log subreddit to cross-post the removal to; only when removal logging is on |
|
no |
string |
Log post title (before |
|
no |
string |
Public log reason substituted into |
|
no |
|
Flair to apply ( |
|
no |
boolean |
Sticky the removal reply comment; omitted when false |
|
no |
boolean |
Send PM delivery via modmail as the subreddit; omitted when false |
|
no |
boolean |
Auto-archive the removal modmail conversation; omitted when false |
|
no |
boolean |
Post the removal reply as the subreddit (vs the mod); omitted when false |
|
no |
boolean |
Lock the removed thread; omitted when false |
|
no |
boolean |
Lock the removal reply comment; omitted when false |
|
no |
|
Usernote to leave ( |
|
no |
|
Ban to issue ( |
|
no |
|
The trainee’s structured reason selection, captured purely to re-seed the overlay on Edit & accept (see below); omitted on captures that predate this field |
FrozenRemovalSelection¶
Additive metadata stored alongside reasonText so a reviewer can re-open the full removal overlay pre-filled with exactly what the trainee composed (“Edit & accept”). Replay, display, and plain Accept use reasonText and never touch this; when it is absent, Edit & accept falls back to plain Accept.
Field |
Required |
Type |
Description |
|---|---|---|---|
|
yes |
|
Selected reasons in display order |
|
no |
boolean |
Whether the configured header was included; present only when a header is configured |
|
no |
boolean |
Whether the configured footer was included; present only when a footer is configured |
Each FrozenSelectionReason carries the persistent reason id (so the overlay can re-check it against current config), the resolved per-reason text (fill-in tokens substituted, inline edits applied), and an optional display title.
Field |
Required |
Type |
Description |
|---|---|---|---|
|
yes |
string |
Persistent |
|
yes |
string |
Resolved per-reason message body (fill-in substitution / inline edit) |
|
no |
string |
The reason’s display title, for the overlay/preview; omitted when none |
Pruning¶
A resolved proposal is kept until it is acknowledged or ages out:
A terminal proposal (
accepted/rejected/obsolete) is pruned once its proposer setsackedByProposer(the “Dismiss” action), or onceproposalRetentionDays(a per-subreddit config value, default 14) has elapsed since it resolved.pendingproposals are never pruned.
The retention window is configured per subreddit; see proposalRetentionDays in the Subreddit Config Schema.
Writing proposals¶
The proposals page must have moderator-only edit permissions (permlevel: 2). Writers must use optimistic concurrency: pass the current revision as previous to /api/wiki/edit and retry on a 409 EDIT_CONFLICT. Preserve proposals and unknown fields you did not author rather than rewriting the whole proposals map, and respect the transition rules above — do not move a terminal proposal to another status.