feat(engagement): retention — three sweeps and one recorded refusal
All checks were successful
PR Checks / client-build (pull_request) Successful in 34s
PR Checks / bot-tests (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 13m23s

ENGAGEMENT.md Phase 14, the last phase of the workstream. Four engagement
tables grew on every fire and nothing had ever deleted from any of them.

Three of them now have a horizon, swept nightly by one worker
(utils/engagementRetentionPrune.js — setInterval + unref + stop(), batched
1000 x 50, each table's failure caught on its own so a lock timeout on one
does not leave the other two unbounded):

  engagement_sends      180 days   engagement_sends_retain_days      (7-3650)
  engagement_cooldowns   30 days   engagement_cooldowns_retain_days  (2-3650)
  engagement_outbox      30 days   engagement_outbox_retain_days     (2-3650)

The fourth, engagement_suppressions, does not expire, and that is the
recorded decision rather than an omission: a suppression is a standing
decision, and ageing out a hard bounce re-mails an address that already
bounced. The way out stays deliberate, and is now reachable per row.

Six decisions were settled by the org lead before any code. Two of them
widened the phase past what was offered:

  * the send-log horizon is admin-configurable, so retention got a SCREEN
    (Admin -> Engagement -> Retention) where team_activity and
    user_notifications keep theirs in invisible settings rows. The send-log
    horizon changes what an operator-facing page is able to show, so it has
    to be visible; the other two came with it, because "what does this
    deployment keep" is one question.
  * the suppression purge, which cost a Phase 9 decision. The list
    deliberately stripped address_hash from every row, so the only way out
    was a window.prompt asking the operator to retype an address the screen
    has never shown them. The row had no handle at all. The hash is now
    returned: this route is admin-only and an admin can already suppress and
    unsuppress any address they can name, so it grants no capability they
    lack. GET /sends still strips its own.

The outbox sweep is TERMINAL-ONLY and that is a correctness rule: a
scheduled row is a send this deployment still intends to make (delay_seconds
can put one a day out) and a sending row may be mid-flight.

One shipped defect had to be fixed for the sweep to be a bound at all.
reclaimStale returned every stale sending row to scheduled, and MAX_ATTEMPTS
is consulted only on a graceful retry outcome — so a send that killed the
process mid-flight cycled sending -> scheduled -> sending forever, never
terminal, therefore never eligible for any sweep. It now fails an exhausted
row BEFORE reclaiming the rest; the order is the fix.

Two indexes (idx_engo_sweep, idx_engs_sweep): every existing index on those
tables has created_at in second position, which serves a per-rule window and
is useless to a whole-table horizon.

Proved twice: engagementRetentionSql.test.js against a real MariaDB (7
tests, incl. the acceptance case and the wrong reclaim order run
deliberately), and the live stack, where a 90-day-old cancelled row was
swept and a 90-day-old scheduled row survived.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:40:53 -05:00
parent e59a68c152
commit 5779d15150
22 changed files with 1728 additions and 17 deletions

View File

@@ -48,6 +48,7 @@ import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
@@ -210,6 +211,7 @@ export default function App() {
<Route path="triggers" element={<EngagementTriggers />} />
<Route path="sends" element={<EngagementSendLog />} />
<Route path="suppressions" element={<EngagementSuppressions />} />
<Route path="retention" element={<EngagementRetention />} />
</Route>
<Route path="account" element={<AccountAdmin />} />
{/* Staff have an inbox and channel preferences like anyone else —

View File

@@ -437,8 +437,14 @@ export const api = {
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
// of a DELETE rather than in the path, and that is not style: a path
// parameter lands in the access log, the browser history and every proxy in
// front of the deployment, and this one is a real person's address. The list
// never returns a hash to use instead.
// front of the deployment, and this one is a real person's address.
//
// **Phase 14 added the second form, and it is the one the row uses.** The
// list now returns each row's `address_hash`, so the Lift button on a row
// needs no address at all — the operator is looking at a mask and has never
// been told the address. `unsuppressAddress` stays for the address the
// operator types, which is the only way to reach a row that is not on the
// page in front of them.
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
@@ -452,6 +458,19 @@ export const api = {
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
unsuppressAddress: (address, channel) =>
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
unsuppressByHash: (hash, channel) => {
const qs = new URLSearchParams()
if (channel) qs.set('channel', channel)
return req(`/admin/engagement/suppressions/by-hash/${hash}${withQs(qs.toString())}`, {
method: 'DELETE',
})
},
// Retention (Phase 14). Three horizons, one screen; `engagement_suppressions`
// is not among them because a suppression does not expire.
getEngagementRetention: () => req('/admin/engagement/retention'),
setEngagementRetention: (body) =>
req('/admin/engagement/retention', { method: 'PUT', body }),
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and

View File

@@ -113,6 +113,11 @@ export const NAV = [
// "did that message go out", and this answers "why is this person not
// getting any" - and it is the only screen that can lift a suppression.
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
// Last in the group because it is the one screen nobody visits weekly, and
// beside Suppressions on purpose: it is where the reader is told that the
// fourth engagement table does NOT expire, which is otherwise a silence
// that reads as an oversight.
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
],
},
{
@@ -196,6 +201,7 @@ const TITLES = {
'/admin/engagement/triggers': 'Triggers',
'/admin/engagement/suppressions': 'Suppressions',
'/admin/engagement/sends': 'Send Log',
'/admin/engagement/retention': 'Retention',
}
// An installed module's admin pages are not in TITLES and cannot be — core does

View File

@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Retention (ENGAGEMENT.md Phase 14).
//
// Three of the four engagement tables grew on every fire and nothing had ever
// deleted from any of them. This screen is the policy: how long the deployment
// keeps a cooldown row, a finished outbox row and a send-log entry.
//
// **Why it is a screen, when the other two retention workers in this codebase
// (`team_activity`, `user_notifications`) are invisible settings rows.** The
// send-log horizon changes what an operator-facing page is *able to show* — the
// Send Log is the only answer to "was this person told" — so an operator has to
// be able to see it and set it, not discover it by finding rows missing. Having
// made one visible, hiding the other two would be the worse split: "what does
// this deployment keep" is one question and deserves one answer.
//
// **The fourth table is on this page as prose, not as a control.** Suppressions
// do not expire (org lead, 2026-09-01), and saying so here is the point: an
// operator reading a retention screen that lists three tables would reasonably
// assume the fourth was an oversight.
const FIELDS = [
{
name: 'sends',
label: 'Send log',
table: 'engagement_sends',
// The one horizon the org lead asked to be pickable rather than typed —
// and `custom` stays, because a deployment with a compliance answer to
// give should not be limited to three numbers somebody chose.
presets: [90, 180, 365],
help:
'One row per delivery attempt. This is what Admin → Engagement → Send Log reads, so the '
+ 'horizon is also how far back "was this person told" can be answered. The per-rule hourly '
+ 'ceiling counts this table too, which is why it can never go below a week.',
},
{
name: 'cooldowns',
label: 'Cooldowns',
table: 'engagement_cooldowns',
presets: [7, 30, 90],
help:
'One row per rule, user, subject and channel, written on every fire. Deleting a row that '
+ 'is still in force makes the next fire count as a first fire — that is a duplicate '
+ 'message — so this must stay longer than the longest cooldown on any enabled rule.',
},
{
name: 'outbox',
label: 'Outbox',
table: 'engagement_outbox',
presets: [7, 30, 90],
help:
'Only finished rows are ever removed: sent, failed, cancelled and not-sent. A scheduled '
+ 'row is a message this deployment still intends to send and is never swept, however old '
+ 'the horizon.',
},
]
export default function EngagementRetention() {
const [policy, setPolicy] = useState(null)
const [limits, setLimits] = useState({})
const [warnings, setWarnings] = useState([])
const [longestCooldown, setLongestCooldown] = useState(0)
const [draft, setDraft] = useState({})
const [saving, setSaving] = useState(false)
const [note, setNote] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const apply = useCallback((result) => {
setPolicy(result.retention)
setDraft(result.retention)
setLimits(result.limits || {})
setWarnings(result.warnings || [])
setLongestCooldown(result.longestCooldownSeconds || 0)
}, [])
useEffect(() => {
let alive = true
;(async () => {
try {
const result = await api.admin.getEngagementRetention()
if (alive) apply(result)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [apply])
async function save() {
setSaving(true)
setNote(null)
try {
// The whole draft, not the changed field: this screen is the one place the
// three are set together, and a partial save would leave the warning line
// (which is computed from the cooldown horizon) describing a policy that is
// half saved. The route itself is sparse, so sending three is legal.
const result = await api.admin.setEngagementRetention(draft)
apply(result)
setNote('Saved.')
} catch (err) {
setNote(err.message)
} finally {
setSaving(false)
}
}
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name]))
return (
<section>
<p className="sans dim" style={{ fontSize: '0.88rem', maxWidth: 720, marginTop: 0 }}>
How long this deployment keeps the engagement system&rsquo;s own records. A nightly sweep
removes anything older, in batches, and skips a table it cannot read rather than failing
the run.
</p>
{warnings.map((w) => (
<p
key={w}
className="sans"
style={{
fontSize: '0.85rem',
maxWidth: 720,
padding: '10px 12px',
borderLeft: '3px solid #d98b84',
background: 'rgba(217, 139, 132, 0.08)',
}}
>
{w}
</p>
))}
<div style={{ display: 'grid', gap: 22, maxWidth: 720, marginTop: 20 }}>
{FIELDS.map((f) => {
const spec = limits[f.name] || {}
const value = draft[f.name] ?? ''
const isPreset = f.presets.includes(Number(value))
return (
<div key={f.name}>
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
<span className="field-label" style={{ fontWeight: 600 }}>{f.label}</span>
<code className="dim" style={{ fontSize: '0.74rem' }}>{f.table}</code>
</div>
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '4px 0 8px' }}>
{f.help}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label>
<span className="field-label">Keep for</span>
<select
className="select"
value={isPreset ? String(value) : 'custom'}
onChange={(e) => {
const next = e.target.value
// Choosing "custom" must not blank the field — the number
// box below is what the operator is about to edit, and an
// empty one would post NaN.
if (next === 'custom') return
setDraft({ ...draft, [f.name]: Number(next) })
}}
>
{f.presets.map((d) => (
<option key={d} value={String(d)}>{d} days</option>
))}
<option value="custom">Custom</option>
</select>
</label>
<label>
<span className="field-label">Days</span>
<input
className="input"
type="number"
min={spec.min ?? 2}
max={spec.max ?? 3650}
style={{ width: 110 }}
value={value}
onChange={(e) => setDraft({ ...draft, [f.name]: e.target.value === '' ? '' : Number(e.target.value) })}
/>
</label>
{spec.min !== undefined && (
<span className="sans dim" style={{ fontSize: '0.78rem', paddingBottom: 8 }}>
{spec.min}{spec.max} days
</span>
)}
</div>
</div>
)
})}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 24 }}>
<button type="button" className="pill" disabled={!dirty || saving} onClick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
{dirty && (
<button type="button" className="pill" disabled={saving} onClick={() => setDraft(policy)}>
Discard
</button>
)}
{note && <span className="sans" style={{ fontSize: '0.82rem' }}>{note}</span>}
</div>
<div style={{ maxWidth: 720, marginTop: 32 }}>
<h3 className="sans" style={{ fontSize: '0.95rem', marginBottom: 6 }}>
Suppressed addresses do not expire
</h3>
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>
A suppression is a standing decision, not a record of something that happened. Ageing one
out would re-mail an address that already hard-bounced or asked to be left alone, which is
how a sender loses a domain&rsquo;s reputation. The way out of that list stays a
deliberate act:{' '}
<strong>Lift</strong> on the row, in Admin Engagement Suppressions.
</p>
{longestCooldown > 0 && (
<p className="sans dim" style={{ fontSize: '0.84rem', marginBottom: 0 }}>
The longest cooldown on an enabled rule right now is {longestCooldown} seconds.
</p>
)}
</div>
</section>
)
}

View File

@@ -43,6 +43,11 @@ export default function EngagementSendLog() {
const [offset, setOffset] = useState(0)
const [status, setStatus] = useState('')
const [testTrigger, setTestTrigger] = useState('')
// Phase 14. `total` is now a truncated number, and a screen that shows a total
// without saying so is quietly wrong about the deployment's own history — this
// is the fix for that, and the reason the horizon got an operator-facing
// control rather than the invisible settings row the other two sweeps use.
const [retainDays, setRetainDays] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -55,6 +60,15 @@ export default function EngagementSendLog() {
setRows(result.sends || [])
setTotal(result.total || 0)
setTestTrigger(result.testSendTrigger || '')
// Best-effort and non-blocking: the log is worth showing even if the policy
// cannot be read, so a failure here leaves the note off rather than the
// screen empty.
try {
const policy = await api.admin.getEngagementRetention()
setRetainDays(policy?.retention?.sends ?? null)
} catch {
setRetainDays(null)
}
}, [])
useEffect(() => {
@@ -153,6 +167,7 @@ export default function EngagementSendLog() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}{to} of {total}
{retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}

View File

@@ -126,6 +126,26 @@ export default function EngagementSuppressions() {
}
}
/**
* The per-row Lift (Phase 14). No address is asked for and none is needed: the
* row carries its own `address_hash`, which is the only handle this screen has
* ever been able to have — the address itself is stored one-way.
*
* No confirm dialog, deliberately. Lifting is reversible in one click (the
* Suppress field above is right there), and a browser modal blocks the whole
* tab, which is the failure mode the automation notes in this repo warn about.
*/
async function liftRow(row) {
setNote(null)
try {
await api.admin.unsuppressByHash(row.address_hash, row.channel)
setNote(`${row.address_masked || 'That address'} can be mailed again.`)
await refresh()
} catch (err) {
setNote(err.message)
}
}
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
if (error) return <ErrorState message={error} />
@@ -211,6 +231,7 @@ export default function EngagementSuppressions() {
<th className="adm-th">Detail</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Since</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
@@ -231,6 +252,20 @@ export default function EngagementSuppressions() {
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
{new Date(r.created_at).toLocaleString()}
</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem' }}
disabled={!r.address_hash}
title={r.address_hash
? 'Let this address be mailed again'
: 'This row has no handle to act on'}
onClick={() => liftRow(r)}
>
Lift
</button>
</td>
</tr>
))}
</tbody>