feat: let the operator set the daily comment limit per account

The allowance was derived from the adaptive trust level with no way to
change it, which made the dashboard's "1 / 2 today" unexplainable and
left no way to slow an account down or push one along.

`dailyLimit` is null by default, keeping today's adaptive behaviour. A
whole number between 1 and 50 overrides it and is enforced verbatim in
either direction — it can raise an account above what it has earned or
hold it below. The trust level keeps being tracked underneath and is
reported as `adaptiveAllowance`, so the earned figure stays visible next
to the override, and clearing the field hands pacing back to the ladder.

Raising the limit above the earned allowance does remove the adaptive
backoff after a bad comment, so the dashboard says exactly that where the
limit is set. It does not touch the pause on two consecutive removals, a
comment the public cannot see, or a captcha challenge: those are safety
stops rather than rate controls and no limit setting can disable them.

Also correct two things the page was saying badly. The allowance is now
labelled "used in last 24h" with the trust level shown as what it earns,
because it is a rolling window and not a midnight reset. And a
per-community cap above the account allowance now states how much of it
is actually reachable — "1 of 10 today" beside an account limit of 2 read
as far more headroom than existed, since caps distribute the allowance
rather than adding to it.

Verified against a local instance: override above and below the earned
allowance, unrelated saves preserving it, clearing it through the UI,
rejection of 0/51/2.5/non-numeric, and a limit failing to clear a pause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 21:07:25 +01:00
1 parent f88db1ae7d
commit 5deac87cf1
15 files changed
+351 -26

No files matched your search

+4
View File
@@ -560,6 +560,10 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_account_growth_comments_posted
ON account_growth_comments(account_id, posted_at DESC);
`),
() => {
// Null keeps the adaptive allowance; a value is an operator override.
addColumnIfNotExists(db, 'account_growth_settings', 'daily_limit', 'INTEGER');
},
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
+21 -2
View File
@@ -24,6 +24,12 @@ export interface GrowthSettingsRecord {
enabled: boolean;
/** Rung on the adaptive allowance ladder. Higher means more comments per day. */
trustLevel: number;
/**
* Operator override for comments per day. Null leaves the allowance to the
* adaptive ladder. A value is enforced verbatim — the ladder keeps tracking
* the account underneath, and the removal/shadowban pause still applies.
*/
dailyLimit: number | null;
levelChangedAt: number | null;
consecutiveRemovals: number;
/** Non-null means growth is hard-paused until an operator clears it. */
@@ -38,6 +44,8 @@ export interface GrowthSettingsRecord {
export interface GrowthSettingsPatch {
enabled?: boolean;
/** Pass null to hand the allowance back to the adaptive ladder. */
dailyLimit?: number | null;
minDelayMs?: number;
jitterMs?: number;
quietHours?: GrowthQuietHours;
@@ -158,6 +166,7 @@ export class AccountGrowthRepository {
accountId,
enabled: false,
trustLevel: 0,
dailyLimit: null,
levelChangedAt: null,
consecutiveRemovals: 0,
pausedReason: null,
@@ -184,26 +193,34 @@ export class AccountGrowthRepository {
if (patch.jitterMs !== undefined && (!Number.isInteger(patch.jitterMs) || patch.jitterMs < 0)) {
throw new Error('Growth jitter must be a non-negative integer');
}
if (patch.dailyLimit !== undefined && patch.dailyLimit !== null
&& (!Number.isInteger(patch.dailyLimit) || patch.dailyLimit < 1 || patch.dailyLimit > 50)) {
throw new Error('Daily comment limit must be a whole number between 1 and 50');
}
if (patch.quietHours) validateQuietHours(patch.quietHours);
const current = this.getSettings(accountId, now);
const merged: GrowthSettingsRecord = {
...current,
...patch,
quietHours: patch.quietHours ?? current.quietHours,
// `null` is a meaningful value here — it hands the allowance back to the
// adaptive ladder — so only an explicitly present key may change it.
dailyLimit: 'dailyLimit' in patch ? patch.dailyLimit ?? null : current.dailyLimit,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO account_growth_settings (
account_id, enabled, trust_level, level_changed_at, consecutive_removals,
account_id, enabled, trust_level, daily_limit, level_changed_at, consecutive_removals,
paused_reason, paused_at, min_delay_ms, jitter_ms, quiet_hours_json,
allow_nsfw, updated_at
) VALUES (
@accountId, @enabled, @trustLevel, @levelChangedAt, @consecutiveRemovals,
@accountId, @enabled, @trustLevel, @dailyLimit, @levelChangedAt, @consecutiveRemovals,
@pausedReason, @pausedAt, @minDelayMs, @jitterMs, @quietHoursJson,
@allowNsfw, @updatedAt
)
ON CONFLICT(account_id) DO UPDATE SET
enabled = excluded.enabled,
daily_limit = excluded.daily_limit,
min_delay_ms = excluded.min_delay_ms,
jitter_ms = excluded.jitter_ms,
quiet_hours_json = excluded.quiet_hours_json,
@@ -213,6 +230,7 @@ export class AccountGrowthRepository {
accountId,
enabled: merged.enabled ? 1 : 0,
trustLevel: merged.trustLevel,
dailyLimit: merged.dailyLimit,
levelChangedAt: merged.levelChangedAt,
consecutiveRemovals: merged.consecutiveRemovals,
pausedReason: merged.pausedReason,
@@ -718,6 +736,7 @@ function mapSettings(row: Record<string, unknown>): GrowthSettingsRecord {
accountId: row.account_id as string,
enabled: row.enabled === 1,
trustLevel: row.trust_level as number,
dailyLimit: (row.daily_limit as number | null) ?? null,
levelChangedAt: (row.level_changed_at as number | null) ?? null,
consecutiveRemovals: row.consecutive_removals as number,
pausedReason: (row.paused_reason as string | null) ?? null,
+19 -1
View File
@@ -32,6 +32,24 @@ export function dailyAllowance(trustLevel: number): number {
return TRUST_LEVEL_ALLOWANCES[clamped];
}
/**
* Comments per day this account may actually post.
*
* An operator override is enforced verbatim, in either direction: it can raise
* the allowance above what the account has earned, or hold it below. The ladder
* keeps tracking the account underneath so the earned level stays visible, and
* the removal/shadowban pause is unaffected — that is a safety stop rather than
* a rate control, so no limit setting can switch it off.
*/
export function effectiveDailyAllowance(
settings: Pick<GrowthSettingsRecord, 'trustLevel' | 'dailyLimit'>,
): number {
if (settings.dailyLimit !== null && Number.isInteger(settings.dailyLimit) && settings.dailyLimit > 0) {
return settings.dailyLimit;
}
return dailyAllowance(settings.trustLevel);
}
export interface TrustAssessment {
trustLevel: number;
consecutiveRemovals: number;
@@ -171,7 +189,7 @@ export function evaluateGrowthWindow(input: GrowthWindowInput): GrowthWindowDeci
};
}
const allowance = dailyAllowance(settings.trustLevel);
const allowance = effectiveDailyAllowance(settings);
if (input.postedLast24h >= allowance) {
return {
allowed: false,
+7
View File
@@ -22,6 +22,8 @@ export const GrowthSettingsUpdateRequestSchema = z.strictObject({
enabled: z.boolean().optional(),
minDelayMs: z.number().int().min(0).max(DAY_MS).optional(),
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
/** Whole comments per day, or null to hand the allowance back to the ladder. */
dailyLimit: z.number().int().min(1).max(50).nullable().optional(),
quietHours: GrowthQuietHoursSchema.optional(),
allowNsfw: z.boolean().optional(),
});
@@ -45,7 +47,12 @@ export const GrowthSubredditUpdateRequestSchema = z.strictObject({
export const GrowthSettingsSchema = z.strictObject({
enabled: z.boolean(),
trustLevel: z.number().int(),
/** Comments per day actually enforced: the override when set, else the earned allowance. */
dailyAllowance: z.number().int(),
/** What the adaptive ladder alone would permit. */
adaptiveAllowance: z.number().int(),
/** Operator override, or null when the allowance is left adaptive. */
dailyLimit: z.number().int().nullable(),
minDelayMs: z.number().int(),
jitterMs: z.number().int(),
quietHours: GrowthQuietHoursSchema,
+6 -2
View File
@@ -7,7 +7,7 @@ import {
type GrowthSettingsRecord,
type GrowthSubredditRecord,
} from '../../database/growth-repository.js';
import { dailyAllowance } from '../../services/growth-pacer.js';
import { dailyAllowance, effectiveDailyAllowance } from '../../services/growth-pacer.js';
import {
GrowthPauseRequestSchema as PauseSchema,
GrowthSettingsUpdateRequestSchema as SettingsUpdateSchema,
@@ -215,7 +215,11 @@ function presentSettings(settings: GrowthSettingsRecord) {
return {
enabled: settings.enabled,
trustLevel: settings.trustLevel,
dailyAllowance: dailyAllowance(settings.trustLevel),
/** What is enforced: the override when set, otherwise the earned allowance. */
dailyAllowance: effectiveDailyAllowance(settings),
/** What the adaptive ladder alone would permit, shown alongside an override. */
adaptiveAllowance: dailyAllowance(settings.trustLevel),
dailyLimit: settings.dailyLimit,
minDelayMs: settings.minDelayMs,
jitterMs: settings.jitterMs,
quietHours: settings.quietHours,