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>
231 lines
9.1 KiB
JavaScript
231 lines
9.1 KiB
JavaScript
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’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’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>
|
||
)
|
||
}
|