feat(messages): ephemeral / self-destruct messages (LC-547) #516

Merged
longjacksonle merged 5 commits from feat/LC-547-ephemeral-messages into main 2026-07-07 04:43:34 +02:00

What

Ephemeral / self-destruct messages (LC-547), the second Round-4 build. A sender can attach a self-destruct timer to a message; once it passes, the message is hard-deleted (content gone at rest, not tombstoned) and removed from every connected client.

How

  • Data layer - messages.expires_at (chat/0085, partial index over the small live-ephemeral set). models::message::ephemeral_expires_at() maps a closed token set (5m / 1h / 1d / 7d) to an absolute "%Y-%m-%d %H:%M:%S" UTC stamp and returns None for anything else, so a forged token cannot request an arbitrary lifetime. db::chat::set_message_expiry() stamps the row.
  • Sweep - retention::sweep::sweep_expired_ephemeral() hard-deletes rows whose expires_at <= now, reusing hard_delete_messages and the same BEGIN IMMEDIATE transaction shape as the retention sweep. Deliberately NOT gated by LETS_CHAT_RETENTION_SWEEP_ENABLED: a per-message timer is user intent, not operator policy, so it always runs.
  • Scheduler - spawn_ephemeral_sweeper runs it unconditionally every 60s (bounding how long an expired message lingers) and broadcasts MessagePurged after commit so clients drop the node.
  • Composer - a self-destruct timer <select> (No timer / 5m / 1h / 1d / 7d) rides the existing axum::Form send as ttl; post_message stamps the expiry when the token is in the allowlist. en/es strings added (i18n parity).

Scope / follow-ups

  • v1 is sender-controlled with no persistent per-message countdown badge. Adding a visible "disappears in Xh" indicator means threading expires_at through MessageView + the Message broadcast struct (~9 construction sites) and every RawMessage SELECT; deferred to keep this slice focused and avoid the column-drift trap. The sender picks the timer in the composer; the message vanishes on expiry.
  • Applies to the main room composer (post_message); thread replies / DMs can follow.

Tests

  • Unit: ephemeral_expires_at token mapping + rejection of blank/forged tokens.
  • Integration (ephemeral_messages): sweep deletes only past-expiry rows (sparing future-dated and permanent), sweep no-ops when nothing expired, and post_message stamps a future expiry for an allowlisted token / stays permanent for empty / refuses a forged token.

Green locally on just check, full just test, and just test-saas.

🤖 Generated with Claude Code

https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw

## What Ephemeral / self-destruct messages (LC-547), the second Round-4 build. A sender can attach a self-destruct timer to a message; once it passes, the message is hard-deleted (content gone at rest, not tombstoned) and removed from every connected client. ## How - **Data layer** - `messages.expires_at` (chat/0085, partial index over the small live-ephemeral set). `models::message::ephemeral_expires_at()` maps a closed token set (5m / 1h / 1d / 7d) to an absolute `"%Y-%m-%d %H:%M:%S"` UTC stamp and returns `None` for anything else, so a forged token cannot request an arbitrary lifetime. `db::chat::set_message_expiry()` stamps the row. - **Sweep** - `retention::sweep::sweep_expired_ephemeral()` hard-deletes rows whose `expires_at <= now`, reusing `hard_delete_messages` and the same `BEGIN IMMEDIATE` transaction shape as the retention sweep. Deliberately NOT gated by `LETS_CHAT_RETENTION_SWEEP_ENABLED`: a per-message timer is user intent, not operator policy, so it always runs. - **Scheduler** - `spawn_ephemeral_sweeper` runs it unconditionally every 60s (bounding how long an expired message lingers) and broadcasts `MessagePurged` after commit so clients drop the node. - **Composer** - a self-destruct timer `<select>` (No timer / 5m / 1h / 1d / 7d) rides the existing `axum::Form` send as `ttl`; `post_message` stamps the expiry when the token is in the allowlist. en/es strings added (i18n parity). ## Scope / follow-ups - v1 is sender-controlled with no persistent per-message countdown badge. Adding a visible "disappears in Xh" indicator means threading `expires_at` through `MessageView` + the `Message` broadcast struct (~9 construction sites) and every `RawMessage` SELECT; deferred to keep this slice focused and avoid the column-drift trap. The sender picks the timer in the composer; the message vanishes on expiry. - Applies to the main room composer (`post_message`); thread replies / DMs can follow. ## Tests - Unit: `ephemeral_expires_at` token mapping + rejection of blank/forged tokens. - Integration (`ephemeral_messages`): sweep deletes only past-expiry rows (sparing future-dated and permanent), sweep no-ops when nothing expired, and `post_message` stamps a future expiry for an allowlisted token / stays permanent for empty / refuses a forged token. Green locally on `just check`, full `just test`, and `just test-saas`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
Data layer for ephemeral messages. Adds the messages.expires_at column (chat/0085, partial index over the small live-ephemeral set), a server-side ephemeral_expires_at() that maps a closed set of composer TTL tokens (5m/1h/1d/7d) to an absolute "%Y-%m-%d %H:%M:%S" UTC stamp and returns None for anything else (so a forged token cannot request an arbitrary lifetime), and set_message_expiry() to stamp a row after insert. Sweep + composer wiring land in follow-up commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
sweep_expired_ephemeral hard-deletes messages whose expires_at has passed, reusing hard_delete_messages and the same BEGIN IMMEDIATE transaction shape as the retention sweep. Unlike sweep_once it is not gated by LETS_CHAT_RETENTION_SWEEP_ENABLED: a per-message TTL is user intent, so expiry must always run. Returns purged (id, room_id) pairs for the scheduler to broadcast MessagePurged after commit; bounded by SWEEP_LIMIT per tick, scan backed by the partial index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
spawn_ephemeral_sweeper runs sweep_expired_ephemeral on a 60-second tick (skipping the first tick to avoid the cold-start window) and broadcasts MessagePurged for each hard-deleted row so connected clients drop the node. Always spawned, with no env gate, because a per-message self-destruct timer is user intent rather than an operator policy; the 60s cadence bounds how long an expired message lingers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
Adds a self-destruct timer <select> (No timer / 5m / 1h / 1d / 7d) to the composer button row; it rides the existing axum::Form send as ttl. post_message maps the token through ephemeral_expires_at and, when non-empty, stamps the sent message's expires_at so the ephemeral sweep will hard-delete it. Empty or unrecognized tokens leave the message permanent. en/es strings added (i18n parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
test(messages): sweep + composer coverage for ephemeral messages (LC-547)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 3m15s
Create release / Create release from merged PR (pull_request) Has been skipped
e3f5e06f85
Covers the two halves of self-destruct: sweep_expired_ephemeral hard-deletes only past-expiry rows (sparing future-dated and permanent NULL-expiry messages) and is a no-op when nothing has expired; post_message stamps a future expires_at for an allowlisted ttl token, leaves the message permanent for an empty token, and refuses to stamp anything for a forged out-of-allowlist token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-07 04:41:21 +02:00
longjacksonle deleted branch feat/LC-547-ephemeral-messages 2026-07-07 04:43:35 +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!516
No description provided.