FRD-011
Every warning needs a matching data stream
A risk flag that says 'device shared with 5 accounts' and offers nowhere to look is worse than no flag. And if the scorer and the panel key on different fingerprints, they will contradict each other.
A withdrawal screen that says "device shared with five accounts" and gives you no way to see those accounts has told you to make a decision and withheld the evidence. In practice the operator approves it anyway, because the alternative is punishing someone on a number they cannot check.
So every reason the scorer can produce needs a panel that shows the underlying rows. Shared devices name the accounts. Credits-outpace-spins itemises where the credits came from. Account age, verification state and payout history sit in one strip.
There is also a bug class hiding here. If the scorer counts accounts sharing a server-side fingerprint while the investigation panel matches a browser-reported one, the panel will truthfully report "no other account has used this browser" next to a warning counting five. Match both keys, and say which one matched.
/** Accounts sharing a device with this one, by EITHER key. */
function shares_device(PDO $pdo, int $userId): array
{
$sql = 'SELECT u.id, u.username, u.created_at, u.balance,
MAX(d.matched) AS matched_on,
(SELECT COUNT(*) FROM claims c WHERE c.user_id = u.id) AS claims,
(SELECT COUNT(*) FROM withdrawals w WHERE w.user_id = u.id AND w.status = "paid") AS payouts
FROM (
SELECT user_id, "server fingerprint" AS matched FROM user_devices
WHERE fingerprint IN (SELECT fingerprint FROM user_devices WHERE user_id = ? AND fingerprint <> "")
UNION ALL
SELECT user_id, "browser signature" AS matched FROM user_devices
WHERE client_fp IN (SELECT client_fp FROM user_devices WHERE user_id = ? AND client_fp <> "")
) d
JOIN users u ON u.id = d.user_id
WHERE d.user_id <> ?
GROUP BY u.id
ORDER BY u.created_at';
$st = $pdo->prepare($sql);
$st->execute([$userId, $userId, $userId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/** Plain English for each reason the scorer can emit, with what to check. */
function risk_guidance(string $reason): array
{
return [
'shared_device' => [
'means' => 'Another account has used this browser or machine.',
'check' => 'A household shares a browser as often as a farm does. '
. 'Look at whether the other accounts behave like separate people: '
. 'different hours, different tasks, different payout addresses.',
],
'credits_outpace_activity' => [
'means' => 'The balance is larger than the recorded work explains.',
'check' => 'Open "where the credits came from". An admin adjustment or a '
. 'referral bonus explains it; nothing explaining it does not.',
],
'new_account_large_payout' => [
'means' => 'First withdrawal, and it is a big one.',
'check' => 'Normal for a patient user, typical of a farm cashing out. '
. 'Compare the task mix against an established account.',
],
][$reason] ?? ['means' => 'Unrecognised signal.', 'check' => 'Check the raw rows.'];
}
Using it
Link each risk reason on the withdrawal screen straight to the panel, anchored at the relevant section. One click from warning to evidence.
Show referrals in both directions in the same panel. A referral reachable from the referrer's own browser is the clearest self-referral signal there is.
Write the guidance text to assume innocence first. The question is whether the accounts behave like separate people, not whether they share a router.
What bites people
Two fingerprint keys that disagree is not a cosmetic inconsistency — it means one of your two systems is counting the wrong thing. Fix the mismatch rather than reconciling the numbers in your head.
A panel that only shows what the scorer already said adds nothing. It has to show rows.
Do not auto-ban on any of these. They are reasons to look.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.