diff --git a/client/src/App.jsx b/client/src/App.jsx
index 48aa783..2b0e598 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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() {
} />
} />
} />
+ } />
} />
{/* Staff have an inbox and channel preferences like anyone else —
diff --git a/client/src/api/client.js b/client/src/api/client.js
index d732f14..966adc4 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -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
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index e8ec10c..3010eac 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -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
diff --git a/client/src/routes/admin/views/EngagementRetention.jsx b/client/src/routes/admin/views/EngagementRetention.jsx
new file mode 100644
index 0000000..2c22e56
--- /dev/null
+++ b/client/src/routes/admin/views/EngagementRetention.jsx
@@ -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
+ if (error) return
+
+ const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name]))
+
+ return (
+
+
+ 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.
+
+ 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:{' '}
+ Lift on the row, in Admin → Engagement → Suppressions.
+
+ {longestCooldown > 0 && (
+
+ The longest cooldown on an enabled rule right now is {longestCooldown} seconds.
+
+ )}
+
+
+ )
+}
diff --git a/client/src/routes/admin/views/EngagementSendLog.jsx b/client/src/routes/admin/views/EngagementSendLog.jsx
index bba6231..8de2904 100644
--- a/client/src/routes/admin/views/EngagementSendLog.jsx
+++ b/client/src/routes/admin/views/EngagementSendLog.jsx
@@ -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() {
{offset + 1}–{to} of {total}
+ {retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
if (error) return
@@ -211,6 +231,7 @@ export default function EngagementSuppressions() {
Detail
Channel
Since
+
@@ -231,6 +252,20 @@ export default function EngagementSuppressions() {
{new Date(r.created_at).toLocaleString()}
+
+
+
))}
diff --git a/server/db/schema.sql b/server/db/schema.sql
index f6ec2eb..f7fa01b 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -1850,7 +1850,10 @@ CREATE TABLE IF NOT EXISTS engagement_outbox (
INDEX idx_engo_due (status, due_at),
-- What a RESOLVING event queries: a house repaired back to LikeNew cancels
-- every scheduled row for that (rule, user, house).
- INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
+ INDEX idx_engo_cancel (rule_id, user_id, subject_key, status),
+ -- Phase 14. The sweep is `status IN (terminal) AND created_at < ?`, and
+ -- `idx_engo_due` cannot serve it: its second column is `due_at`.
+ INDEX idx_engo_sweep (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
@@ -1877,7 +1880,11 @@ CREATE TABLE IF NOT EXISTS engagement_sends (
INDEX idx_engs_user (user_id, created_at),
-- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be
-- an index range scan rather than a table scan: it runs once per rule per event.
- INDEX idx_engs_rule_window (rule_id, created_at)
+ INDEX idx_engs_rule_window (rule_id, created_at),
+ -- Phase 14's retention sweep deletes by age alone, so it needs `created_at`
+ -- LEADING. Every index above has it in second position, which serves a
+ -- per-rule or per-user window and is useless to a whole-table horizon.
+ INDEX idx_engs_sweep (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
@@ -1937,6 +1944,14 @@ CREATE TABLE IF NOT EXISTS engagement_templates (
-- link in it. Same vocabulary as engagement_digest_state.scope_key below.
ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL;
+-- Phase 14 (retention). The two sweep indexes, for deployments whose tables
+-- predate them. `IF NOT EXISTS` on an index is MariaDB-only and already used
+-- above (`idx_wiki_search`), so this needs no INFORMATION_SCHEMA guard like the
+-- cooldown primary-key change did -- that one needed one because MariaDB has no
+-- conditional form of a PRIMARY KEY change, not because indexes lack one.
+ALTER TABLE engagement_outbox ADD INDEX IF NOT EXISTS idx_engo_sweep (status, created_at);
+ALTER TABLE engagement_sends ADD INDEX IF NOT EXISTS idx_engs_sweep (created_at);
+
-- §4.2b: digest state, and DELIBERATELY not a digest queue.
--
-- The generic engine enqueues an outbox row per (rule, user, channel) at emit
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 65b2ac2..56551b2 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -194,6 +194,24 @@
"requireAuth"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/engagement/retention",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/engagement/retention",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules",
@@ -320,6 +338,15 @@
"requireAuth"
]
},
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/engagement/suppressions/by-hash/:hash",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 6767767..4dce8bf 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -85,6 +85,14 @@
"method": "GET",
"path": "/api/v1/admin/engagement/channels"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/engagement/retention"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/engagement/retention"
+ },
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules"
@@ -141,6 +149,10 @@
"method": "POST",
"path": "/api/v1/admin/engagement/suppressions"
},
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/engagement/suppressions/by-hash/:hash"
+ },
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates"
diff --git a/server/src/model/engagement/engagementCooldowns.db.js b/server/src/model/engagement/engagementCooldowns.db.js
index 25a0589..82668bd 100644
--- a/server/src/model/engagement/engagementCooldowns.db.js
+++ b/server/src/model/engagement/engagementCooldowns.db.js
@@ -78,8 +78,23 @@ const get = async (ruleId, userId, subjectKey, channel) => {
* failure `teamActivityPrune` was written for. A dropped row means the next fire
* is treated as a first fire, which is correct as long as the retention window is
* longer than the longest configured cooldown - the caller's job, not this one's.
+ * Phase 14 gave it that caller (`engagementRetentionPrune`), which also enforces
+ * the horizon-versus-longest-cooldown rule this comment names.
+ *
+ * `limit` bounds one statement, for `userNotificationsPrune`'s reason: a first
+ * sweep after a long outage must be a series of bounded DELETEs rather than one
+ * that holds locks over a million rows. Omitting it keeps the original
+ * unbounded behaviour, so existing callers and tests are unaffected.
+ *
+ * @returns {Promise} rows deleted
*/
-const prune = (olderThan) =>
- query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
+const prune = async (olderThan, limit = 0) => {
+ const bounded = Number(limit) > 0
+ const result = await query(
+ `DELETE FROM engagement_cooldowns WHERE last_fired_at < ?${bounded ? ' LIMIT ?' : ''}`,
+ bounded ? [olderThan, Math.floor(limit)] : [olderThan],
+ )
+ return Number(result?.affectedRows || 0)
+}
module.exports = { claim, get, prune }
diff --git a/server/src/model/engagement/engagementOutbox.db.js b/server/src/model/engagement/engagementOutbox.db.js
index 54ddbe4..9063b03 100644
--- a/server/src/model/engagement/engagementOutbox.db.js
+++ b/server/src/model/engagement/engagementOutbox.db.js
@@ -132,11 +132,61 @@ async function cancel(ruleId, subjectKey, userId = null) {
* stamped it), and the window has to be comfortably longer than the slowest
* legitimate send or this reclaims rows that are merely slow.
*/
-const reclaimStale = (before) =>
- query(
+const reclaimStale = async (before, maxAttempts = 0) => {
+ // Give up first, reclaim second, and in that order: a row that has already
+ // burned its attempts must leave 'sending' as `failed`, or the reclaim below
+ // hands it straight back to `findDue` and it is retried forever.
+ //
+ // **This is what makes `pruneTerminal` a bound at all** (Phase 14). Attempts
+ // are incremented by `claim`, but `MAX_ATTEMPTS` is only consulted on a
+ // graceful `retry` outcome — a send that kills the process mid-flight never
+ // reaches that branch, so before this the row cycled sending → scheduled →
+ // sending forever, never reached a terminal status, and was therefore never
+ // eligible for any retention sweep. One poisoned payload was an outbox row
+ // that outlived every horizon.
+ let failed = 0
+ if (Number(maxAttempts) > 0) {
+ const gaveUp = await query(
+ `UPDATE engagement_outbox
+ SET status = 'failed', last_error = 'gave up after repeated interruptions'
+ WHERE status = 'sending' AND updated_at < ? AND attempts >= ?`,
+ [before, Math.floor(maxAttempts)],
+ )
+ failed = Number(gaveUp?.affectedRows || 0)
+ }
+ const reclaimed = await query(
"UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?",
[before],
)
+ return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
+}
+
+/**
+ * Delete terminal rows older than `before` (Phase 14).
+ *
+ * **Terminal only, and the status list is the whole policy.** A `scheduled` row
+ * is a promise the engine has not kept yet — `delay_seconds` can legitimately
+ * put one up to a day out (`MAX_DELAY_SECONDS`) — and a `sending` row may be a
+ * worker mid-flight. Deleting either is not retention, it is cancelling a send
+ * nobody asked to cancel. Only `sent`, `failed`, `cancelled` and `suppressed`
+ * are outcomes that have already happened.
+ *
+ * `created_at` rather than `updated_at` is the clock deliberately: the horizon
+ * an operator sets means "how long we keep the record of a delivery", which is
+ * measured from when it was enqueued, not from whenever it was last touched.
+ *
+ * @returns {Promise} rows deleted
+ */
+const pruneTerminal = async (before, limit = 1000) => {
+ const result = await query(
+ `DELETE FROM engagement_outbox
+ WHERE status IN ('sent', 'failed', 'cancelled', 'suppressed')
+ AND created_at < ?
+ LIMIT ?`,
+ [before, Math.floor(limit)],
+ )
+ return Number(result?.affectedRows || 0)
+}
const getById = async (id) => {
const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id])
@@ -157,6 +207,7 @@ module.exports = {
finish,
cancel,
reclaimStale,
+ pruneTerminal,
getById,
listForRule,
}
diff --git a/server/src/model/engagement/engagementRetention.model.js b/server/src/model/engagement/engagementRetention.model.js
new file mode 100644
index 0000000..bfa7fe6
--- /dev/null
+++ b/server/src/model/engagement/engagementRetention.model.js
@@ -0,0 +1,175 @@
+// ── Engagement retention policy ────────────────────────────────────────────
+//
+// ENGAGEMENT.md Phase 14. Three horizons, one place to read and write them, so
+// the nightly worker and Admin → Engagement → Retention cannot disagree about
+// what this deployment keeps — and so `/privacy` has one thing to describe.
+//
+// **The fourth engagement table is deliberately absent from this file.**
+// `engagement_suppressions` does not expire (org lead, 2026-09-01): a
+// suppression is a standing decision, and ageing out a hard bounce re-mails an
+// address that already bounced, which is how a sender loses a domain's
+// reputation. The one way out of that table stays what Phase 9 built — a
+// deliberate act by an admin, which Phase 14 only made reachable per row.
+//
+// Settings rows rather than env, for `teamActivityPrune`'s reason: an operator
+// tightening a busy shard should not need a deploy. Unlike the two workers this
+// copies, these three get a screen — the send log's horizon changes what an
+// operator-facing page can show, so it cannot be an invisible key.
+
+const settings = require('../settings/settings.model')
+const rulesDb = require('./engagementRules.db')
+const log = require('../../utils/logger')('engagement')
+
+/**
+ * One entry per horizon. `min` is not a UI nicety — each is the point below
+ * which the sweep breaks something that is not retention:
+ *
+ * - **cooldowns**: a pruned row makes the next fire a FIRST fire, i.e. a
+ * duplicate send. `MAX_COOLDOWN_SECONDS` is a validated 86 400 (one day), so
+ * 2 days is the smallest provably-safe value against any rule that can be
+ * saved. `checkCooldownHorizon` re-checks it against the rules that exist.
+ * - **outbox**: `MAX_DELAY_SECONDS` is also 86 400, so no `scheduled` row is
+ * ever more than a day out; terminal rows younger than that are still the
+ * most recent thing an operator would look at.
+ * - **sends**: the per-rule hourly ceiling (§7.1 Q3) counts this table, so a
+ * horizon under an hour would silently disable it. The floor is set far
+ * above that, at the point the Send Log stops being worth opening.
+ */
+const HORIZONS = {
+ sends: {
+ key: 'engagement_sends_retain_days',
+ default: 180,
+ min: 7,
+ max: 3650,
+ label: 'Send log',
+ },
+ cooldowns: {
+ key: 'engagement_cooldowns_retain_days',
+ default: 30,
+ min: 2,
+ max: 3650,
+ label: 'Cooldowns',
+ },
+ outbox: {
+ key: 'engagement_outbox_retain_days',
+ default: 30,
+ min: 2,
+ max: 3650,
+ label: 'Outbox',
+ },
+}
+
+const NAMES = Object.keys(HORIZONS)
+
+/**
+ * The current policy, as `{ sends, cooldowns, outbox }` in days.
+ *
+ * Wrapped in a try like `teamActivity.retentionConfig` and for its reason: the
+ * nightly worker calls this with nobody watching, so a settings table that is
+ * briefly unavailable must yield defaults rather than an exception that kills
+ * the job. An unreadable, absent, non-numeric or out-of-range value all mean
+ * the same thing — use the default — because none of them is a horizon.
+ */
+async function get() {
+ const out = {}
+ for (const name of NAMES) {
+ const spec = HORIZONS[name]
+ let days = spec.default
+ try {
+ const raw = await settings.get(spec.key)
+ const n = Number(raw)
+ if (Number.isFinite(n) && n >= spec.min && n <= spec.max) days = Math.floor(n)
+ } catch (err) {
+ log.debug('retention setting unreadable; using the default', {
+ key: spec.key,
+ message: err.message,
+ })
+ }
+ out[name] = days
+ }
+ return out
+}
+
+/**
+ * Write one or more horizons. Unknown names are ignored rather than rejected, so
+ * a client sending the whole object back is not coupled to this list; an
+ * out-of-range value IS rejected, because silently clamping a number an operator
+ * typed would leave the screen showing something the deployment is not doing.
+ *
+ * @returns {Promise