feat/room-retention #174

Merged
longjacksonle merged 7 commits from feat/room-retention into main 2026-05-21 22:10:46 +02:00
No description provided.
Commit 1 of per-room message retention. Adds three chat migrations and updates every test file that hand-rolls the chat migration list. No runtime code yet: the retention sweep lands in a later commit, gated behind LETS_CHAT_RETENTION_SWEEP_ENABLED (default off), because the strict-vs-loose retention semantics question is still pending with the ticket author.

0043 adds a nullable rooms.retention_days column with CHECK retention_days IS NULL OR retention_days >= 1 (rejects 0 = delete-everything). A partial index covers only retention-enabled rooms so the global sweep query stays cheap.

0044 rebuilds link_filter_quarantine with ON DELETE CASCADE on message_id. The original schema in 0032 left the FK without an action because the codebase only soft-deleted messages; retention is the first hard-delete path, and without this rebuild a sweep touching a quarantined row would fail with SQLITE_CONSTRAINT_FOREIGNKEY. SQLite cannot ALTER an existing FK action, hence the create-new + copy + drop + rename rebuild.

0045 adds an AFTER DELETE trigger on messages that issues the FTS5 'delete' command on messages_fts. Existing triggers in 0008 fire only on UPDATE OF deleted_at and UPDATE OF body; no trigger fired on a real DELETE because no hard-delete path existed. Without this trigger, retention-swept rows would leave orphan FTS entries that still match search queries against deleted content.

Test-file drift sweep: 18 of the 78 integration binaries hand-roll a chat migration list via include_str!. All three new migrations are appended to those lists. Exception: migration_enclaves.rs skips 0032, so the 0044 quarantine rebuild is skipped there too. Files using sqlx::migrate!("./migrations/chat") (db_dm.rs, db_moderation.rs, message_editing.rs) auto-pick up new migrations.

just check and just test pass in both standalone and saas modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced while doing the migration-list drift sweep for the per-room retention scaffold in the previous commit on this branch. Two things were wrong in the existing note:

1. The introductory paragraph claimed "there is no shared tests/common/ helper" but server/tests/common/mod.rs exists and exposes chat_pool / auth_pool / settings_pool helpers backed by sqlx::migrate!. 40+ test files already use it. The note's framing fooled me into eyeballing every file as if it were hand-rolled before I caught the helper.

2. The "Two patterns coexist" entry named db_dm.rs / db_moderation.rs / message_editing.rs as the canonical verbose-per-migration files. Those have all refactored to either common helpers or inline sqlx::migrate!. The only file still using the verbose per-migration form today is db_private_rooms.rs.

Updated to "Three patterns coexist": common helpers (drift-immune, 40+ files), array form (drift-prone, 17 files), verbose form (drift-prone, 1 file). Added explicit "Prefer the macro form for new test files" guidance so the cure for migration-list drift is to stop maintaining the list, not to maintain it more carefully. Same cleanup principle as the LC-62-era refactor that introduced common/mod.rs in the first place (the module's own doc comment cites a `messages.quote_id` drift incident as the motivation).

No code changes; doc-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commit 2 of per-room message retention. Adds server/tests/retention_cascade.rs with 16 #[tokio::test] functions that prove every FK referencing messages.id, plus the FTS5 DELETE trigger added in 0045, fires as the schema declares. Retention is the first hard-DELETE on messages to ever ship; every cascade has been theoretical until this branch, and this file makes the cascade behavior verified-by-test before the sweep that exercises it lands in a later commit.

Coverage:
- 8 ON DELETE CASCADE: reactions, bookmarks, pinned_messages, message_edits, mentions, polls (which recursively cascades through poll_options + poll_votes), reminders, link_filter_quarantine (the 0044 rebuild in action).
- 3 ON DELETE SET NULL: messages.quote_id (self-ref), file_uploads.message_id, scheduled_messages.parent_id + quote_id (one test exercises both columns).
- 3 thread (messages.parent_id self-ref CASCADE) variants: direct child deleted with root, grandchild deleted recursively, and a sibling-isolation test that confirms unrelated threads survive when one root is purged.
- 1 FTS5 trigger: the 0045 AFTER DELETE trigger removes the messages_fts entry.
- 1 integration test: a single rich message with every kind of referencing row attached, hard-deleted in one shot, asserts every cascade fires cleanly and no FK interaction blocks the DELETE.

All 16 pass in both standalone and saas modes. No cascade behaved differently than the schema claims; the 13 never-fired FKs all fire exactly as advertised, which is the safety property this commit exists to prove before the destructive sweep code lands.

One test-author gotcha worth recording for future FTS tests: FTS5's MATCH parser interprets hyphens as token separators and bare digits as column references, so a search token like "xenonberry-4242" explodes at parse time with "no such column: 4242". Stick to plain alphabetic tokens (or quote the phrase) when seeding FTS test content.

Pool setup uses the drift-immune common::chat_pool() helper from server/tests/common/mod.rs (the pattern the previous commit's CLAUDE.md update prescribed for new test files). PRAGMA foreign_keys = ON is set explicitly per test as belt-and-braces, matching db_scheduled.rs::room_delete_cascades_to_scheduled_rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commit 3 of per-room message retention. Lands the destructive sweep body and its preview helper, both sharing a single candidate_predicate so they cannot drift. No spawn wiring, no broadcast, no audit (commit 4+ scope). The sweep stays dark until LETS_CHAT_RETENTION_SWEEP_ENABLED is set; the env-flag check is in run_retention_sweep, the unguarded body is exposed as sweep_once for tests.

New module server/src/retention:

- predicate.rs::candidate_predicate(cutoff_expr) returns the shared WHERE-clause fragment that selects retention-candidate messages. Same pattern as scheduled::validation::check_delivery_gates: define the rule once, use it everywhere. Doc comment carries the SAFETY INVARIANT in capitals: cutoff_expr is interpolated verbatim into the rendered SQL with no escaping, so callers must pass compile-time literals only. The day-count N flows through bound `?` parameters in the caller's SQL, not through this function. Both current call sites pass string literals; the comment is there so a future caller cannot quietly open a SQL injection by reaching for `format!`.

- sweep.rs::run_retention_sweep checks the env flag and delegates to sweep_once on the green path. sweep_once acquires a connection, opens BEGIN IMMEDIATE TRANSACTION (mirrors uploads::sweep::sweep_one), runs the LIMIT 500 candidate SELECT joined against rooms.retention_days, logs the dry-run count, executes the DELETE, and commits. Rolls back on any error so a half-cascade-then-fail never lands.

- sweep.rs::hard_delete_messages is the bare DELETE helper carved out so future retention-adjacent code (LC-67 ephemeral, admin purge-user-data) can compose the same path. Predicate is the caller's responsibility.

- sweep.rs::count_candidates_for_room is the preview helper for the room-settings UI in a later commit. Shares the predicate; the load-bearing preview_count_equals_sweep_actual_delete test enforces that the two never drift.

Migration 0046_messages_fts_purge_guard.sql:

Tightens the FTS purge trigger from 0045 against the soft-delete + hard-delete interaction. Surfaced by tests/retention_sweep.rs::soft_deleted_message_past_cutoff_is_hard_deleted - which exercises the settled decision that soft-deleted messages do not escape retention. The combined sequence soft-delete (UPDATE deleted_at; 0008 trigger removes FTS row) then retention hard-delete (DELETE; 0045 trigger tries to remove the same FTS row again) crashed with SqliteError code 267 "database disk image is malformed". FTS5 uses that code to mean "you asked me to delete a rowid that is not in the index" - the disk is fine, the second 'delete' command failed the rowid+body match check.

Fix is a WHEN guard on the trigger: AFTER DELETE ON messages WHEN old.deleted_at IS NULL. This encodes the data-model invariant established by 0008: messages_fts contains exactly the non-soft-deleted messages. If the row was already soft-deleted, the FTS entry is already gone and the trigger correctly no-ops on the subsequent hard-delete.

The cascade tests in commit 2 did not catch this because they only hard-deleted messages that were never soft-deleted. The interaction is a real cascade-behavior surprise (the kind the cascade-test commit existed to catch); shipping 0046 as a follow-up migration in this commit rather than amending 0045 keeps the branch's commit history linear and the discovery+fix attributable.

Tests:

server/tests/retention_sweep.rs (18 tests, all pass standalone + saas):

- skipping: empty pool, room without retention_days, DM room with retention set (defense-in-depth predicate filter).
- cutoff boundary: past survives + recent survives + exact-cutoff survives (strict less-than).
- LIMIT bounding: SWEEP_LIMIT enforced per tick; multiple ticks drain backlog.
- thread semantics (loose-correct, sweep-by-newest-reply): recent reply preserves root; all-stale replies cascade-delete with root; reply never selected directly even if past cutoff (the parent_id IS NULL clause excludes it).
- settled-decision inclusions: soft-deleted / quarantined / system / pinned messages all hard-delete past cutoff.
- preview safety rail: count_candidates_for_room returns expected count; preview_count_equals_sweep_actual_delete enforces the shared-predicate invariant on a mixed-content room.
- API surface pin: sweep_once never sets flag_disabled (only run_retention_sweep does), so the spawn function in commit 4 can rely on the field to distinguish off-state from nothing-to-do.

Test-file drift sweep: 0046 appended to all 18 hand-rolled migration lists (same 17 array + 1 verbose set as 0043/0044/0045). Files using common::chat_pool() pick it up automatically.

just check + just test (standalone) + just test-saas all green. routes_uploads under concurrent load remains flaky per CLAUDE.md test-maintenance note; passes in isolation, out of scope for this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commit 4 of per-room message retention. Adds the spawn function, the new ChatEvent variant for retention-driven hard-deletes, and the OOB fragment that removes the message node from connected clients' DOM. The sweep logic from commit 3 stays unchanged; this commit only wires its destructive path into the running server and the WS layer.

ChatEvent::MessagePurged { message_id, room_id }: distinct from MessageDeleted (which is the moderation soft-delete tombstone). PurgedMessageFragment renders `<div id="msg-{id}" hx-swap-oob="delete"></div>`: no "[deleted]" placeholder, because a visible tombstone leaks the metadata that a message existed there and defeats the compliance posture retention is for. The render arm slots into ws_fragments::render_event next to MessageDeleted; the catchall in routes/ws.rs::dispatch routes new variants through render_event automatically so no per-recipient match update is needed.

SweepStats grows a `purged: Vec<(i64, i64)>` field that carries the directly-deleted (message_id, room_id) pairs in deletion order. The spawn function iterates this list AFTER the transaction commits, outside the BEGIN IMMEDIATE critical section, so channel writes never block the writer lock (same shape principle the dry-run log followed: only essential statements live between SELECT and DELETE). Cascade-deleted descendants (thread replies, etc.) intentionally do NOT appear in purged: the client renders threads nested, so removing the parent's DOM node also removes its rendered children, and the cascade fragments would be redundant.

spawn_message_retention_sweeper in main.rs: 1-hour tick, skip-first-tick, mirrors spawn_orphan_sweeper's shape (sibling slow-clock sweep). The env flag (LETS_CHAT_RETENTION_SWEEP_ENABLED) is checked at spawn time, not per-tick: if unset, the task is never spawned at all. Flipping the flag requires a server restart, which is the right shape for a destructive feature gate the operator is opting into deliberately. The run_retention_sweep wrapper from commit 3 stays in the API surface for callers that want the flag check inline (a future admin "purge now" handler).

Broadcast skip-for-old-messages optimization (the brainstorm raised it as a thundering-herd guard for the first-run cliff): consciously deferred. SWEEP_LIMIT is 500 per hour and the fragment is ~50 bytes; per-room broadcast already targets only currently-subscribed clients via the hub's channel routing. 500 * 50B/hr from this source is negligible by chat-server standards. Revisit if metrics surface pressure on the hub, but inline-it-now would be optimizing without data. Documented in the spawn function's docstring.

Tests:

server/tests/retention_sweep.rs grows by 1 test and amends another:

- message_past_cutoff_is_deleted now asserts stats.purged == vec![(m, room)] in addition to the count, pinning the broadcast contract.
- purged_field_groups_messages_by_room_for_broadcast: 3 messages across 2 rooms + a stale thread reply that cascade-deletes. Asserts purged carries exactly the 3 directly-deleted message ids with their room ids, and the cascade-deleted reply is NOT in purged (the parent's DOM removal covers the reply visually when the client renders threads nested).

19 sweep tests + 16 cascade tests, all pass in standalone and saas. just check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(retention): admin UI + audit + env-var docs
Some checks failed
Check / clippy + fmt + tests (pull_request) Has been cancelled
fd0293491d
Commit 5 of per-room message retention; PR-closing commit.

Adds the admin-facing surface that lets an admin or enclave-owner set rooms.retention_days. Lives on the existing /room/{id}/moderators page (same gate, same audit log target, sits next to posting policy). Two new routes:

- GET /room/{id}/retention/preview?days=N -> HTMX confirmation fragment showing the count of messages the next sweep would delete at that setting, plus the permanence warnings, plus an embedded POST form. The count is computed by retention::sweep::count_candidates_for_room, the same function the sweep itself uses via the shared candidate_predicate; the preview_count_equals_sweep_actual_delete test from commit 3 enforces the SQL-level invariant. Empty days renders a "disable retention" fragment with the irreversibility notice instead.

- POST /room/{id}/retention -> validates (1-day floor, integer, empty = disable; 0 / negative / non-numeric all 400), rejects DM rooms (defense-in-depth on top of the sweep predicate's room_type != 'dm' filter), writes rooms.retention_days, audits to mod_actions with target_user='-' / action='retention_set' / metadata={"old_days":..,"new_days":..}, and redirects back to the moderators page.

Both routes gate on require_can_manage (now pub(crate) so sibling route modules can share it; same enclave-owner / site-admin set that posting policy uses).

Permanence warning copy is accurate to the loose-correct sweep that currently ships: "messages older than N days are deleted, except messages in threads with replies newer than N days (active threads are preserved as a unit)." Does not promise strict behavior the sweep does not currently do. Also surfaces the no-pinned-exemption rule and points users at the room wiki as the escape hatch for important content.

Honest update to retention::sweep::SweepStats::purged docstring: thread replies in the codebase's UI render as flat siblings inside #thread-replies-{parent_id} in the thread side panel, NOT as DOM children of the root message bubble. hx-swap-oob="delete" against the root's id removes the root's node but does not touch the sibling reply nodes that may still be visible in any open thread panel. The replies are gone server-side; the stale DOM resolves on reload. Emitting MessagePurged for cascaded reply ids would require a recursive descendant SELECT before the DELETE; deferred to a follow-up if the cosmetic gap surfaces as a real complaint.

New db helpers in db::chat: get_room_retention_days, set_room_retention_days. Don't extend the Room struct (which is wide and read everywhere) since these are only needed by retention-specific code paths.

CLAUDE.md env-var table grows a row for LETS_CHAT_RETENTION_SWEEP_ENABLED that captures the default-off rationale (strict-vs-loose semantics pending with the ticket author) and the restart-required note.

10 new integration tests in routes_retention.rs:
- admin_can_set_and_disable_retention_with_audit (happy path round-trip + audit row shape for both enable and disable)
- post_days_zero / negative / non_numeric_days each 400 (1-day floor in three error shapes)
- non_admin_member_cannot_set_retention (403, no state mutation)
- dm_room_rejects_retention_post_with_400 (defense-in-depth on top of the sweep predicate)
- preview_fragment_includes_count_and_warnings (count number AND permanence AND no-pinned-exemption AND loose-correct thread language all in fragment)
- preview_with_days_zero_is_rejected_with_400
- preview_with_empty_days_renders_disable_fragment (disable UX path)
- preview_on_dm_room_rejects_with_400

just check + just test (standalone, modulo the documented routes_uploads concurrent-load flake) + just test-saas all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merge branch 'main' into feat/room-retention
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 2m14s
c125d9bb7b
longjacksonle deleted branch feat/room-retention 2026-05-21 22:10:47 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!174
No description provided.