Problem
Bench publishes ratings and "official" verified-judge responses about named public figures, so duplicate accounts, inflatable helpful-counts, stackable reviews, and duplicate verified identities are trust failures, not cosmetic bugs. Eight verified gaps share one theme: invariants the code already assumes are never enforced in the SQLite schema or at runtime.
Findings
- users.email has no UNIQUE — only a non-unique (email, name) index exists (
database/migrations/0000000113-create-users_email_name_index-index-in-users.sql); register() is a non-atomic check-then-insert whose own comments promise a transaction + unique index that don't exist (storage/framework/core/auth/src/register.ts:33,41). Concurrent signups create duplicates; login then resolves an arbitrary row. - judge_reviews_likes has no UNIQUE(judge_review_id, user_id) — the pivot has zero indexes (
database/migrations/0000000038-create-judge_reviews_likes-table.sql), so the likeable trait's idempotency catch is dead code (storage/framework/core/orm/src/traits/likeable.ts:62-85) and the isLiked→like race (app/Actions/Reviews/LikeReviewAction.ts:73-80) makes helpful-counts scriptable upward. - FK enforcement is OFF at runtime —
configureSqlitePragmas()exists for exactly this (storage/framework/core/database/src/drivers/sqlite.ts:57-66) but is never called. Delete paths orphan children: admin delete removes only the review row (app/Actions/Admin/Reviews/DeleteReviewAction.ts:36); self-delete cleans 2 of 6 child tables (app/Actions/Me/DeleteMyReviewAction.ts:47-49). Orphaned photos keep serving for moderation-deleted reviews. - No one-review-per-user-per-judge — SubmitReviewAction inserts with no existing-review lookup (
app/Actions/Reviews/SubmitReviewAction.ts:102-140) and no UNIQUE(user_id, judge_id); ratings are averaged live (app/Actions/HomeHighlightsAction.ts:46), so one account can stack 60 reviews/hour under the throttle (routes/api.ts:156-160) and skew a judge's score. - Two verified judges can hold the same profile — uniqueness is checked only at claim time (
app/Actions/Me/ClaimJudgeProfileAction.ts:48-57); the approve branch never re-checks claimed_judge_id collisions (app/Actions/Admin/Credentials/VerifyCredentialAction.ts:43-72) and no partial unique index exists. Both holders can post "official" responses. - judge_reviews has no index on judge_id/status/user_id — only title/anonymized indexes exist (
app/Models/JudgeReview.ts:10-23); judge profile (app/Actions/Reviews/ReviewsByJudgeAction.ts:48-57), public feed (app/Actions/Reviews/LatestReviewsAction.ts:30-31), and my-reviews are full scans on the fastest-growing table. - comments counter drifts — SELECT-then-SET increment/decrement (
app/Actions/Reviews/CommentSubmitAction.ts:66-103,app/Actions/Me/DeleteMyCommentAction.ts:55-60); DeleteAccountAction bulk-deletes comments without reconciling; the seeder writescomments: 5with zero rows (database/seeders/ReviewSeeder.ts:210) — phantom engagement on cards. - Mixed created_at formats — DEFAULT CURRENT_TIMESTAMP ('YYYY-MM-DD HH:MM:SS') and
toISOString()rows sort lexicographically in the same TEXT columns; within a day every ISO row outranks every default row. Live data already mixes (15 vs 1 in judge_reviews), in the exact column the feed orders by (app/Actions/Reviews/LatestReviewsAction.ts:30-31).
Root causes for the email UNIQUE, likes UNIQUE, and FK pragma are framework bugs filed separately against stacksjs/stacks — but bench needs its own .sql migrations and a boot-time pragma call regardless.
Acceptance criteria
- Dedupe existing duplicate
users.emailrows, then a.sqlmigration addsCREATE UNIQUE INDEX users_email_unique ON users(email); register's 409 path also maps the constraint error. - Dedupe likes pairs (keep MIN(id)), then
CREATE UNIQUE INDEX judge_reviews_likes_user_review_unique ON judge_reviews_likes(judge_review_id, user_id). configureSqlitePragmas()is called on API server boot (foreign_keys=ON, WAL, busy_timeout).- A shared
deleteReviewCascade(reviewId)covers review_comments, review_photos, review_flags, judge_responses, judge_reviews_likes, user_notifications and is used by DeleteReviewAction, DeleteMyReviewAction, and DeleteAccountAction. - SubmitReviewAction returns 409 for an existing non-rejected review by the same user for the same judge, backed by a partial unique index ON judge_reviews(user_id, judge_id) WHERE status != 'rejected' AND user_id IS NOT NULL.
- VerifyCredentialAction's approve branch re-checks for an existing verified holder of the same claimed_judge_id (409 on conflict), backed by
CREATE UNIQUE INDEX users_verified_judge_claim_unique ON users(claimed_judge_id) WHERE credential_verified_at IS NOT NULL AND credential_type = 'judge'. - Index migrations for judge_reviews: (judge_id, status, created_at), (status, created_at), and (user_id).
- Comment counts are derived at read time (likes pattern,
app/Helpers/reviewLikes.ts) or every mutation sets the column via subquery COUNT(*); the seeder no longer writes phantom counts. - One-time migration normalizes existing created_at/updated_at values to ISO-8601; inserts set created_at explicitly (or new defaults use
strftime('%Y-%m-%dT%H:%M:%fZ','now')).
From the 2026-06-10 production-readiness audit.
Upstream (framework root causes):
- https://github.com/stacksjs/stacks/issues/1952 — model unique:true never reaches SQL migrations
- https://github.com/stacksjs/stacks/issues/1954 — likeable pivot lacks UNIQUE
- https://github.com/stacksjs/stacks/issues/1951 — configureSqlitePragmas never called
- https://github.com/stacksjs/stacks/issues/1953 — register() check-then-insert