- The allowance comes from the trust level, not from a setting: level 0 permits 2 comments
- a day, then 3, 5, 8 and 12. It is a rolling 24-hour window rather than a midnight reset,
- so capacity frees up 24 hours after each comment. The level rises on its own while
- comments survive and earn upvotes, and falls back the moment one is removed or heavily
- downvoted. Two removals in a row, or a comment the public cannot see, pauses the account
- for you.
+ Left automatic, the allowance follows the trust level: level 0 permits 2 comments a day,
+ then 3, 5, 8 and 12, rising while comments survive and earn upvotes and falling back the
+ moment one is removed or heavily downvoted. Set a daily limit to override that. Either
+ way it is a rolling 24-hour window rather than a midnight reset, so capacity frees up 24
+ hours after each comment, and two removals in a row or a comment the public cannot see
+ pauses the account regardless.
+ This is above the {settings.adaptiveAllowance}/day this account has earned, so the
+ adaptive backoff can no longer slow it down after a bad comment. The pause on two
+ removals in a row, or a comment the public cannot see, still applies.
+
+ ) : null}
+
{
+ // 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;
diff --git a/src/database/growth-repository.ts b/src/database/growth-repository.ts
index 4abd08f..403a134 100644
--- a/src/database/growth-repository.ts
+++ b/src/database/growth-repository.ts
@@ -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): 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,
diff --git a/src/services/growth-pacer.ts b/src/services/growth-pacer.ts
index 607ce63..0ce05ae 100644
--- a/src/services/growth-pacer.ts
+++ b/src/services/growth-pacer.ts
@@ -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,
+): 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,
diff --git a/src/web/growth-contracts.ts b/src/web/growth-contracts.ts
index bde2ef6..292d27b 100644
--- a/src/web/growth-contracts.ts
+++ b/src/web/growth-contracts.ts
@@ -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,
diff --git a/src/web/routes/growth.ts b/src/web/routes/growth.ts
index 33a1c52..e9f56d1 100644
--- a/src/web/routes/growth.ts
+++ b/src/web/routes/growth.ts
@@ -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,
diff --git a/tests/dashboard/growth.test.tsx b/tests/dashboard/growth.test.tsx
index 96cb32a..f15cba5 100644
--- a/tests/dashboard/growth.test.tsx
+++ b/tests/dashboard/growth.test.tsx
@@ -16,6 +16,8 @@ function settings(overrides: Record = {}) {
enabled: true,
trustLevel: 1,
dailyAllowance: 3,
+ adaptiveAllowance: 3,
+ dailyLimit: null,
minDelayMs: 3_600_000,
jitterMs: 900_000,
quietHours: { enabled: true, startHour: 23, endHour: 7 },
@@ -197,7 +199,7 @@ describe('GrowthPage', () => {
expect(await screen.findByText('1 / 3')).toBeVisible();
expect(screen.getByText('Used in last 24h')).toBeVisible();
- expect(screen.getByText('1 of 4 → 3/day')).toBeVisible();
+ expect(screen.getByText('1 of 4 → 3/day earned')).toBeVisible();
// 6 posted less 1 removed.
expect(screen.getByText('5')).toBeVisible();
expect(screen.getByText('4')).toBeVisible();
@@ -213,6 +215,74 @@ describe('GrowthPage', () => {
expect(screen.getByText(/Two removals in a row/i)).toBeVisible();
});
+ describe('daily limit', () => {
+ it('shows the allowance as automatic until an operator overrides it', async () => {
+ renderGrowth();
+
+ expect(await screen.findByText('Automatic — 3/day')).toBeVisible();
+ expect(screen.getByText('1 of 4 → 3/day earned')).toBeVisible();
+ });
+
+ it('sets a limit through the API', async () => {
+ renderGrowth();
+ const user = userEvent.setup();
+
+ const field = await screen.findByLabelText('Daily limit');
+ await user.clear(field);
+ await user.type(field, '9');
+ await user.click(screen.getByRole('button', { name: 'Save limit' }));
+
+ await waitFor(() => expect(requests).toHaveLength(1));
+ expect(requests[0]).toMatchObject({ method: 'PATCH', body: { dailyLimit: 9 } });
+ });
+
+ it('clearing the field hands the allowance back to the ladder', async () => {
+ detailPayload = detail({ settings: settings({ dailyLimit: 9, dailyAllowance: 9 }) });
+ renderGrowth();
+ const user = userEvent.setup();
+
+ await user.clear(await screen.findByLabelText('Daily limit'));
+ await user.click(screen.getByRole('button', { name: 'Save limit' }));
+
+ await waitFor(() => expect(requests).toHaveLength(1));
+ expect(requests[0]).toMatchObject({ body: { dailyLimit: null } });
+ });
+
+ it('does not submit an out-of-range limit', async () => {
+ renderGrowth();
+ const user = userEvent.setup();
+
+ const field = await screen.findByLabelText('Daily limit');
+ await user.clear(field);
+ await user.type(field, '99');
+ await user.click(screen.getByRole('button', { name: 'Save limit' }));
+
+ expect(requests).toHaveLength(0);
+ });
+
+ it('shows an override and warns when it exceeds what was earned', async () => {
+ detailPayload = detail({
+ settings: settings({ trustLevel: 0, dailyLimit: 12, dailyAllowance: 12, adaptiveAllowance: 2 }),
+ });
+ renderGrowth();
+
+ expect(await screen.findByText('Set to 12/day')).toBeVisible();
+ expect(screen.getByText(/above the 2\/day this account has earned/i)).toBeVisible();
+ // The safety stop is not something a limit can switch off.
+ expect(screen.getByText(/pause on two removals in a row.*still applies/i)).toBeVisible();
+ });
+
+ it('does not warn when the override sits within what was earned', async () => {
+ detailPayload = detail({
+ settings: settings({ trustLevel: 4, dailyLimit: 3, dailyAllowance: 3, adaptiveAllowance: 12 }),
+ });
+ renderGrowth();
+
+ expect(await screen.findByText('Set to 3/day')).toBeVisible();
+ expect(screen.queryByText(/this account has earned/i)).not.toBeInTheDocument();
+ });
+ });
+
it('warns when a community cap exceeds what the account allowance permits', async () => {
detailPayload = detail({
settings: settings({ trustLevel: 0, dailyAllowance: 2 }),
diff --git a/tests/growth-pacer.test.ts b/tests/growth-pacer.test.ts
index 843c2d0..786478a 100644
--- a/tests/growth-pacer.test.ts
+++ b/tests/growth-pacer.test.ts
@@ -5,6 +5,7 @@ import type {
} from '../src/database/growth-repository.js';
import {
DEMOTION_SCORE_THRESHOLD,
+ effectiveDailyAllowance,
MAX_TRUST_LEVEL,
PROMOTION_HOLD_MS,
TRUST_LEVEL_ALLOWANCES,
@@ -23,6 +24,7 @@ function settings(overrides: Partial = {}): GrowthSettings
accountId: '@t2_a:reddit.com',
enabled: true,
trustLevel: 0,
+ dailyLimit: null,
levelChangedAt: null,
consecutiveRemovals: 0,
pausedReason: null,
@@ -64,6 +66,25 @@ describe('dailyAllowance', () => {
});
});
+describe('effectiveDailyAllowance', () => {
+ it('uses the earned allowance when no override is set', () => {
+ expect(effectiveDailyAllowance({ trustLevel: 2, dailyLimit: null })).toBe(5);
+ });
+
+ it('enforces an override above the earned allowance', () => {
+ expect(effectiveDailyAllowance({ trustLevel: 0, dailyLimit: 20 })).toBe(20);
+ });
+
+ it('enforces an override below the earned allowance', () => {
+ expect(effectiveDailyAllowance({ trustLevel: 4, dailyLimit: 1 })).toBe(1);
+ });
+
+ it('ignores a nonsensical override rather than blocking all commenting', () => {
+ expect(effectiveDailyAllowance({ trustLevel: 1, dailyLimit: 0 })).toBe(3);
+ expect(effectiveDailyAllowance({ trustLevel: 1, dailyLimit: -5 })).toBe(3);
+ });
+});
+
describe('assessTrust', () => {
it('holds at level 0 with no history', () => {
const assessment = assessTrust(settings(), [], NOW);
@@ -372,6 +393,38 @@ describe('evaluateGrowthWindow', () => {
});
});
+ it('enforces an operator override instead of the earned allowance', () => {
+ const decision = evaluateGrowthWindow({
+ ...base,
+ settings: settings({ trustLevel: 0, dailyLimit: 10 }),
+ postedLast24h: 4,
+ });
+
+ expect(decision).toEqual({ allowed: true, allowance: 10, postedLast24h: 4 });
+ });
+
+ it('stops at an override lower than the earned allowance', () => {
+ const decision = evaluateGrowthWindow({
+ ...base,
+ settings: settings({ trustLevel: 4, dailyLimit: 2 }),
+ postedLast24h: 2,
+ });
+
+ expect(decision).toMatchObject({ allowed: false });
+ if (!decision.allowed) expect(decision.reason).toMatch(/allowance of 2 comments/);
+ });
+
+ it('an override never overrides the safety pause', () => {
+ const decision = evaluateGrowthWindow({
+ ...base,
+ settings: settings({ dailyLimit: 50, pausedReason: 'Two consecutive removals' }),
+ });
+
+ expect(decision).toEqual({
+ allowed: false, reason: 'Two consecutive removals', retryAt: null,
+ });
+ });
+
it('uses the raised allowance at a higher trust level', () => {
const decision = evaluateGrowthWindow({
...base,
diff --git a/tests/growth-repository.test.ts b/tests/growth-repository.test.ts
index 7d6ee76..1b01989 100644
--- a/tests/growth-repository.test.ts
+++ b/tests/growth-repository.test.ts
@@ -9,6 +9,8 @@ import type { RedditPost } from '../src/reddit/api.js';
const ACCOUNT = '@t2_grower:reddit.com';
const OTHER_ACCOUNT = '@t2_other:reddit.com';
+/** Migrations that make up account growth, replayed together in the upgrade test. */
+const GROWTH_MIGRATION_COUNT = 2;
function thread(id: string, overrides: Partial = {}): RedditPost {
return {
@@ -76,6 +78,26 @@ describe('AccountGrowthRepository', () => {
expect(saved.trustLevel).toBe(3);
});
+ it('leaves the daily limit automatic until an operator sets one', () => {
+ expect(repo.getSettings(ACCOUNT).dailyLimit).toBeNull();
+
+ expect(repo.saveSettings(ACCOUNT, { dailyLimit: 6 }).dailyLimit).toBe(6);
+ // Null is meaningful: it hands the allowance back to the adaptive ladder.
+ expect(repo.saveSettings(ACCOUNT, { dailyLimit: null }).dailyLimit).toBeNull();
+ });
+
+ it('keeps a stored daily limit when an unrelated setting is saved', () => {
+ repo.saveSettings(ACCOUNT, { dailyLimit: 8 });
+
+ expect(repo.saveSettings(ACCOUNT, { enabled: true }).dailyLimit).toBe(8);
+ });
+
+ it('rejects a daily limit outside the allowed range', () => {
+ for (const dailyLimit of [0, -1, 51, 2.5]) {
+ expect(() => repo.saveSettings(ACCOUNT, { dailyLimit })).toThrow(/between 1 and 50/);
+ }
+ });
+
it('rejects invalid quiet hours and negative pacing', () => {
expect(() => repo.saveSettings(ACCOUNT, {
quietHours: { enabled: true, startHour: 25, endHour: 3 },
@@ -456,26 +478,54 @@ describe('AccountGrowthRepository', () => {
});
describe('growth migration', () => {
- it('upgrades a database created before growth existed', () => {
- const db = new Database(':memory:');
- db.pragma('foreign_keys = ON');
- // Simulate the pre-growth schema by running migrations, then rewinding the
- // version so the growth migration is applied on its own.
- runMigrations(db);
+ /** Rewinds past every growth migration so they replay from a pre-growth state. */
+ function rewindPastGrowth(db: Database.Database): void {
const version = db.pragma('user_version', { simple: true }) as number;
db.exec('DROP TABLE account_growth_comments');
db.exec('DROP TABLE account_growth_subreddits');
db.exec('DROP TABLE account_growth_settings');
- db.pragma(`user_version = ${version - 1}`);
+ db.pragma(`user_version = ${version - GROWTH_MIGRATION_COUNT}`);
+ }
+
+ it('upgrades a database created before growth existed', () => {
+ const db = new Database(':memory:');
+ db.pragma('foreign_keys = ON');
+ runMigrations(db);
+ rewindPastGrowth(db);
runMigrations(db);
insertAccount(db, ACCOUNT);
const repo = new AccountGrowthRepository(db);
expect(repo.getSettings(ACCOUNT).enabled).toBe(false);
+ expect(repo.getSettings(ACCOUNT).dailyLimit).toBeNull();
expect(repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000).discovered).toBe(1);
});
+ it('adds the daily limit to a database that already had the growth tables', () => {
+ // The real upgrade path for an instance already running account growth.
+ const db = new Database(':memory:');
+ db.pragma('foreign_keys = ON');
+ runMigrations(db);
+ const version = db.pragma('user_version', { simple: true }) as number;
+ db.exec('ALTER TABLE account_growth_settings DROP COLUMN daily_limit');
+ db.pragma(`user_version = ${version - 1}`);
+ insertAccount(db, ACCOUNT);
+ // Seed a row with the old shape so the upgrade runs against real data.
+ db.prepare(`
+ INSERT INTO account_growth_settings (account_id, enabled, trust_level, updated_at)
+ VALUES (?, 1, 3, 100)
+ `).run(ACCOUNT);
+
+ runMigrations(db);
+
+ const settings = new AccountGrowthRepository(db).getSettings(ACCOUNT);
+ expect(settings.dailyLimit).toBeNull();
+ // The upgrade must not disturb what the account already earned.
+ expect(settings.trustLevel).toBe(3);
+ expect(settings.enabled).toBe(true);
+ });
+
it('is idempotent when run twice', () => {
const db = new Database(':memory:');
runMigrations(db);
diff --git a/tests/web/growth-route.test.ts b/tests/web/growth-route.test.ts
index 908d7b6..a7f6a83 100644
--- a/tests/web/growth-route.test.ts
+++ b/tests/web/growth-route.test.ts
@@ -187,6 +187,43 @@ describe('growth route', () => {
});
});
+ it('sets and clears the daily limit override', async () => {
+ const set = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: 7 });
+
+ expect(set.status).toBe(200);
+ GrowthAccountResponseSchema.parse(set.json);
+ expect(set.json.account.settings).toMatchObject({
+ dailyLimit: 7,
+ dailyAllowance: 7,
+ adaptiveAllowance: 2,
+ });
+
+ const cleared = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: null });
+
+ expect(cleared.json.account.settings).toMatchObject({
+ dailyLimit: null,
+ dailyAllowance: 2,
+ adaptiveAllowance: 2,
+ });
+ });
+
+ it('reports the override as the enforced allowance even below the earned one', async () => {
+ repo.setTrustLevel(ACCOUNT, 4);
+ await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: 1 });
+
+ const { json } = await call(app, 'GET', `/api/growth/${ENCODED}`);
+
+ expect(json.account.settings).toMatchObject({
+ dailyLimit: 1, dailyAllowance: 1, adaptiveAllowance: 12,
+ });
+ });
+
+ it.each([0, 51, 2.5])('rejects a daily limit of %s', async (dailyLimit) => {
+ const { status } = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit });
+
+ expect(status).toBe(400);
+ });
+
it('never resets an earned trust level', async () => {
repo.setTrustLevel(ACCOUNT, 2);