fix: never let a guessed threshold hold an account out of sending

The profile check was allowed to set state 'unestablished', which made
isSendable() false, which made canAcceptPost() false — so a flagged
account never attempted an invitation and the functional probe could
never run. The one signal with real authority was gated behind the one
that is guessing, and because each check pushed retryAt forward by
another interval, accounts that send perfectly well were held
indefinitely.

Reddit does not publish the thresholds its chat gate applies, so ours are
a guess and are now advisory only. Sending is held solely by a live
rejection, tracked as sendingHeld on the account summary and keyed off by
the attention feed, the sending state and the Accounts page.

Reason strings now say "below the configured floor of N" rather than
"needs N", and the Accounts page distinguishes "Sending on hold" from
"Below thresholds", stating outright that the latter is our own number
and the account is still sending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 09:49:40 +01:00
1 parent 89eb4583f8
commit 3a40651930
9 files changed
+164 -71

No files matched your search

+33 -8
View File
@@ -78,9 +78,9 @@ describe('profile evaluation', () => {
expect(verdict.established).toBe(false);
expect(verdict.reasons).toEqual([
'Account is 2 days old (needs 7 days)',
'Total karma is 2 (needs 20)',
'Comment karma is 1 (needs 5)',
'Account is 2 days old (below the configured floor of 7 days)',
'Total karma is 2 (below the configured floor of 20)',
'Comment karma is 1 (below the configured floor of 5)',
'Email address is not verified',
]);
});
@@ -176,16 +176,18 @@ describe('AccountEstablishmentService', () => {
profileCheckedAt: NOW,
probeFailureCount: 0,
});
expect(record!.reasons).toContain('Account is 1 day old (needs 7 days)');
expect(service.isSendable()).toBe(false);
expect(record!.reasons).toContain('Account is 1 day old (below the configured floor of 7 days)');
// A guessed threshold must never hold the account back: it has to be
// allowed to try, or Reddit's authoritative answer can never arrive.
expect(record!.retryAt).toBeNull();
expect(service.isSendable()).toBe(true);
});
it('clears the flag once the account has aged and earned karma', async () => {
let current = profile({ createdAt: NOW - DAY_MS, linkKarma: 0, commentKarma: 0, totalKarma: 0 });
const service = createService(async () => current);
await service.checkNow();
expect(service.isSendable()).toBe(false);
expect(await service.checkNow()).toMatchObject({ state: 'unestablished' });
now = NOW + 10 * DAY_MS;
current = profile({ createdAt: NOW - DAY_MS, commentKarma: 30, totalKarma: 60 });
@@ -302,17 +304,40 @@ describe('AccountEstablishmentService', () => {
expect(accounts.list()[0].establishment).toMatchObject({
state: 'unestablished',
// Below our thresholds, but nothing Reddit has refused — so not held.
sendingHeld: false,
accountAgeDays: 3,
linkKarma: 1,
commentKarma: 2,
totalKarma: 3,
hasVerifiedEmail: false,
profileCheckedAt: NOW,
retryAt: NOW + settings.profileCheckIntervalMs,
retryAt: null,
});
expect(accounts.list()[0].establishment!.reasons.length).toBeGreaterThan(0);
});
it('marks sending as held only while a Reddit rejection is still in force', () => {
const accounts = new RedditAccountsService(
new RedditAccountsRepository(db),
{} as never,
{ getSnapshot: () => ({ accountId: ACCOUNT_ID }) } as never,
undefined,
undefined,
repository,
);
// The listing derives the hold against the wall clock the dashboard renders
// in, so this account's rejection is recorded there too.
now = Date.now();
const service = createService(async () => profile());
service.recordSendFailure('account is too new');
expect(accounts.list()[0].establishment).toMatchObject({ state: 'unestablished', sendingHeld: true });
service.recordSendSuccess();
expect(accounts.list()[0].establishment).toMatchObject({ state: 'established', sendingHeld: false });
});
it('skips the check while the account session is not authenticated', async () => {
const getAccountProfile = vi.fn(async () => profile());
const service = new AccountEstablishmentService(
+31 -12
View File
@@ -23,15 +23,16 @@ const account = {
const establishment = {
state: 'unestablished' as const,
reasons: ['Account is 2 days old (needs 7 days)', 'Total karma is 4 (needs 20)'],
sendingHeld: true,
reasons: ['Account is 2 days old (below the configured floor of 7 days)'],
accountAgeDays: 2,
linkKarma: 1,
commentKarma: 3,
totalKarma: 4,
hasVerifiedEmail: false,
profileCheckedAt: 1_700_000_000_000,
lastProbeAt: null,
lastProbeFailure: null,
lastProbeAt: 1_700_000_000_000,
lastProbeFailure: 'Reddit rejected an invitation from this account: account is too new (rate.invitation_limit_new_account)',
retryAt: Date.now() + 4 * 60 * 60_000,
};
@@ -95,16 +96,14 @@ describe('accounts page', () => {
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
});
it('explains why an unestablished account is not sending, with the profile evidence', async () => {
it('explains a held account with what Reddit actually said, plus the profile evidence', async () => {
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
renderAccounts();
expect(await screen.findByText('u/example-account is not established yet.')).toBeInTheDocument();
expect(screen.getByText(/Reddit will not accept chat invitations from a new account/)).toBeInTheDocument();
expect(screen.getByText('Not established')).toBeInTheDocument();
expect(screen.getByText(
'Account is 2 days old (needs 7 days); Total karma is 4 (needs 20)',
)).toBeInTheDocument();
expect(await screen.findByText('u/example-account cannot start chats yet.')).toBeInTheDocument();
expect(screen.getByText(/Reddit rejected an invitation from a new account/)).toBeInTheDocument();
expect(screen.getByText('Sending on hold')).toBeInTheDocument();
expect(screen.getByText(/rate\.invitation_limit_new_account/)).toBeInTheDocument();
expect(screen.getByText(/Next re-test in about 4 hours/)).toBeInTheDocument();
const metrics = screen.getByText('Comment karma').closest('dl')!;
@@ -113,12 +112,31 @@ describe('accounts page', () => {
expect(within(metrics).getByText('No')).toBeInTheDocument();
});
it('does not claim an account is blocked when it is only below our own thresholds', async () => {
server.use(http.get('/api/accounts', () => HttpResponse.json([{
...account,
establishment: { ...establishment, sendingHeld: false, lastProbeFailure: null, retryAt: null },
}])));
renderAccounts();
expect(await screen.findByText('Below thresholds')).toBeInTheDocument();
expect(screen.getByText(/Still sending normally/)).toBeInTheDocument();
expect(screen.getByText(/not limits Reddit\s+publishes/)).toBeInTheDocument();
expect(screen.getByText('Account is 2 days old (below the configured floor of 7 days)')).toBeInTheDocument();
// Nothing may imply sending has stopped.
expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument();
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
expect(screen.queryByText('Sending on hold')).not.toBeInTheDocument();
});
it('marks an account that has cleared the thresholds as established', async () => {
server.use(http.get('/api/accounts', () => HttpResponse.json([{
...account,
establishment: {
...establishment,
state: 'established',
sendingHeld: false,
lastProbeFailure: null,
reasons: [],
accountAgeDays: 400,
commentKarma: 900,
@@ -130,7 +148,7 @@ describe('accounts page', () => {
renderAccounts();
expect(await screen.findByText('Established')).toBeInTheDocument();
expect(screen.queryByText(/not established yet/)).not.toBeInTheDocument();
expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument();
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
expect(screen.getByText('1,200')).toBeInTheDocument();
});
@@ -140,7 +158,8 @@ describe('accounts page', () => {
renderAccounts();
expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument();
expect(screen.queryByText('Not established')).not.toBeInTheDocument();
expect(screen.queryByText('Sending on hold')).not.toBeInTheDocument();
expect(screen.queryByText('Below thresholds')).not.toBeInTheDocument();
expect(screen.queryByText('Established')).not.toBeInTheDocument();
expect(screen.queryByText('Comment karma')).not.toBeInTheDocument();
});
+26 -7
View File
@@ -39,15 +39,17 @@ const account = {
};
describe('attention route establishment items', () => {
it('raises an unestablished account with its reasons and hold window', async () => {
it('raises an account Reddit has refused, with what Reddit said and the hold window', async () => {
const response = await get(contextWith([{
...account,
establishment: {
state: 'unestablished',
reasons: ['Account is 2 days old (needs 7 days)'],
sendingHeld: true,
reasons: ['Reddit rejected an invitation from this account: account is too new'],
lastProbeFailure: 'Reddit rejected an invitation from this account: account is too new',
retryAt: NOW + 4 * 60 * 60_000,
profileCheckedAt: NOW,
lastProbeAt: null,
lastProbeAt: NOW,
},
}]), '/api/attention');
@@ -58,18 +60,33 @@ describe('attention route establishment items', () => {
id: 'establishment:@t2_new:reddit.com',
kind: 'account-unestablished',
severity: 'warning',
title: 'u/new-account is not established yet',
title: 'u/new-account cannot start chats yet',
occurredAt: NOW,
owner: { accountId: account.accountId, redditUsername: 'new-account' },
});
expect(body.items[0].detail).toContain('Account is 2 days old (needs 7 days)');
expect(body.items[0].detail).toContain('account is too new');
expect(body.items[0].detail).toContain('Outbound sending is on hold');
});
it('stays quiet for established accounts and for accounts never checked', async () => {
it('stays quiet for an account that is only below our own thresholds', async () => {
const response = await get(contextWith([
{ ...account, establishment: { state: 'established', reasons: [], retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } },
{ ...account, establishment: { state: 'established', sendingHeld: false, reasons: [], lastProbeFailure: null, retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } },
{ ...account, accountId: '@t2_other:reddit.com', redditUsername: 'other', establishment: null },
{
...account,
accountId: '@t2_soft:reddit.com',
redditUsername: 'soft',
// Below the configured floor but never refused by Reddit — advisory only.
establishment: {
state: 'unestablished',
sendingHeld: false,
reasons: ['Comment karma is 3 (below the configured floor of 5)'],
lastProbeFailure: null,
retryAt: null,
profileCheckedAt: NOW,
lastProbeAt: null,
},
},
]), '/api/attention');
const body = AttentionResponseSchema.parse(await response.json());
@@ -82,7 +99,9 @@ describe('attention route establishment items', () => {
status: 'login-required',
establishment: {
state: 'unestablished',
sendingHeld: true,
reasons: [],
lastProbeFailure: null,
retryAt: null,
profileCheckedAt: NOW,
lastProbeAt: null,