ReviewOS

stacks/bench-review

trust-safety: no threat incident-response or judicial-privacy removal path — and hard deletes destroy the evidence trail

#53
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Problem

A platform soliciting candid reviews of named judges will predictably receive two kinds of high-stakes content: threats that cross from criticism into 18 U.S.C. § 115 territory, and reviews containing judges' personal information covered by the federal Daniel Anderl Judicial Security and Privacy Act (72-hour removal on written request) and state Daniel's Laws (private rights of action, liquidated damages). Today the platform can do neither of the things those situations require: it cannot preserve evidence of what was published, and it cannot redact covered information without destroying an otherwise-lawful review. This issue is about building the operational capability to honor those obligations — it is not legal advice.

Findings

No incident-response path for threats, and hard-delete-everywhere destroys the evidence trail. Admin takedown is a hard deleteFrom('judge_reviews') (app/Actions/Admin/Reviews/DeleteReviewAction.ts:36) whose moderation-log entry records no content snapshot (:40-46); author edits overwrite content in place with no prior-version persistence (app/Actions/Me/UpdateMyReviewAction.ts:120-123); account deletion hard-cascades judge_reviews (app/Actions/Me/DeleteAccountAction.ts:80). There is no deleted_at column (database/migrations/0000000010-create-judge_reviews-table.sql) and soft deletes are globally disabled (config/query-builder.ts:62-66). The flag-reason enum (app/Actions/Reviews/FlagReviewAction.ts:25-32) has no threat option or severity, and the moderator email (app/Helpers/notifyModerators.ts) omits the review text. If law enforcement inquires about a deleted threatening review, nothing exists.

No judicial-privacy compliance path (Daniel Anderl Act / state Daniel's Laws). No statutory-aware removal-request intake or tracking exists anywhere in app/, resources/, or docs/. The only moderation tools are whole-review: a status flip (app/Actions/Admin/Reviews/UpdateReviewStatusAction.ts:21,48-51 sets only status) or hard delete — no partial redaction, despite resources/views/faq.stx:31 promising "we will remove or edit it" (no admin edit endpoint exists; see routes/api.ts:380-410). The review form (resources/components/Bench/ReviewForm.stx) gives no warning against posting home addresses; app/Helpers/sanitizeReviewHtml.ts is XSS-only.

Acceptance criteria

  • Admin review takedown uses soft delete (deleted_at + status='removed'), excluded from all public queries. Account-cascade deletion stays a hard delete by decision — see the closing comment
  • Admin status changes, deletes, and author edits snapshot the prior content into moderation_logs metadata
  • Flag reasons include a threat category with distinct escalation, and moderator notifications include the flagged content
  • A one-page internal runbook covers credible threats (preserve → escalate → law-enforcement contact); ToS states that credible threats are preserved and may be reported
  • ToS/policy gains a judicial-security / personal-information removal section with a tracked removal-request intake able to meet a 72-hour turnaround
  • Admins can edit/redact review content (sanitize+update behind admin middleware, logged with prior-content snapshot) so covered information can be excised without deleting the review
  • Review form warns users not to post anyone's home address or personal contact information

From the 2026-06-10 production-readiness audit.

9 days ago

Six of seven done — 065d3dec, 89fa77c4, 17370c80, f360b444. Criterion 1 is deliberately left unticked, because half of it conflicts with something we shipped two days ago. Detail at the bottom.

Admin takedown is now a soft delete. status='removed' + deleted_at. What hides it is the status, not a new filter — every public query already selects status='published', so the row leaves the feed, judge page, article route and sitemap immediately. A soft delete that depends on every future query remembering WHERE deleted_at IS NULL is one that leaks. I did not enable the query-builder's global softDeletes flag: that would change read behaviour across sixty-odd tables to fix one.

removed is deliberately not rejected. Rejected is moderation — the author sees it and resubmits. Removed 404s for everyone including the author, and UpdateMyReviewAction refuses it, because every edit resets status to pending and an author could otherwise edit their way straight back out of a legally-compelled removal.

The evidence trail. Author edits were the worst gap — a review could be published, reported, then quietly rewritten, with nothing holding what it said when the report was made. Edits and status changes now snapshot prior content into moderation_logs; deletes already did.

Threats. threat was in ALLOWED_REASONS and in neither report dialog, so nobody could ever pick it. Now in both, and it escalates: distinct subject line, a banner telling the moderator to preserve rather than delete, and a pointer to the runbook. Moderator emails now carry the flagged title and text, HTML stripped — mail about hostile content should not render it.

Redaction (PATCH /api/admin/reviews/{id}/content, plus a control in the queue). The case this site will meet is a review that is lawful except for one sentence. Removing the covered information and leaving the opinion is what the Daniel Anderl Act and the state Daniel's Laws ask for; deleting the whole review is over-removal. Reason required, original snapshotted, same sanitizer as the author path, and redacting to nothing is refused — if nothing is left, the honest action is a takedown.

docs/THREAT-RESPONSE.md — one page. Preserve first, take down through the admin UI rather than the database, suspend rather than delete the author's account (deletion removes their reviews, which is what you will want), escalate same day.


Why criterion 1 is still open. It asks for account-cascade review deletion to soft delete too. I did not do that, because the Privacy Policy shipped in #54 says: "When you delete your account we remove your profile, your reviews and comments... Deletion is not reversible." Soft-deleting there would make that statement false.

There is a real tension — evidence retention versus an erasure promise — and it is not mine to resolve silently. Three options:

  1. Leave as is. Account deletion hard-deletes; the evidence trail for user-initiated deletion is weaker. Honest about what we promise.
  2. Soft delete there too, and change the Privacy Policy to say reviews are withdrawn from public view and retained for a stated period.
  3. Split it: hard delete normally, but preserve where an open flag or moderation action exists on the review. Most defensible, most complexity, and it means a user's erasure is partly contingent on someone having reported them.

Happy to implement whichever — it needs counsel's view more than mine.

9 days ago

Closing. All seven criteria resolved — six built, one decided.

Option 1 taken on account deletion. It stays a hard delete. A takedown is the platform acting for cause and the question afterwards is what was published and when it came down; a person deleting their own account is exercising an erasure right, and the Privacy Policy tells them in plain words that their reviews and comments are removed and that deletion is not reversible. Keeping their writing as a soft-deleted row would make that sentence false. Between a weaker evidence trail for user-initiated deletion and a privacy promise we do not honour, the promise wins.

That decision is recorded in app/Helpers/reviewCascade.ts next to deleteReviewsByUser (4307d088), because sitting beside a soft-deleting takedown it otherwise reads as an oversight and the next person will "fix" it. The comment also says that revisiting it means changing the Privacy Policy in the same commit.

Shipped:

  • admin takedown → status='removed' + deleted_at, hidden by the existing status filter rather than a new one; 404s for everyone including the author, who also cannot edit it
  • prior content snapshotted on deletes, status changes and author edits — the last of those previously destroyed content with no record at all
  • threat offered in both report dialogs (it was accepted server-side and selectable nowhere), escalating with a distinct subject line and a preserve-don't-delete banner; moderator emails now carry the flagged text, HTML stripped
  • redaction endpoint and a control in the moderation queue, reason required, original preserved, redact-to-nothing refused
  • docs/THREAT-RESPONSE.md, plus Terms sections on threats and judicial security with the 72-hour aim

Migrations are applied — verified afterwards: deleted_at present, 2,740 judges carrying distinct slugs, all timestamps normalized to ISO, integrity and foreign-key checks clean.

Carried forward: the runbook names LEGAL_CONTACT_EMAIL as needing to be set and monitored daily for the 72-hour window to be meetable. Unset, those messages fall back to the general inbox — a configuration bug, not a default.

Sign in to comment on this issue.