Skip to content

Diagnostic Slack

Retention deletes the history your app treats as storage

The compliance export has a hole in it, and the hole is suspiciously tidy. Everything from the last three months is there. Everything before that is not, in every channel, for every kind of content, with no gaps at the edges and no errors in the ingestion log. It looks like a bug in the backfill, so somebody rewrites the backfill.

It is not a bug. An administrator set a retention policy at some point, possibly years ago, possibly before your app existed, and Slack has been quietly deleting the workspace's own history ever since. Nothing announced it, because there is no announcement.

Read-only token Python and Node.js Tests included
Close-up of a keyboard with a prominent 'buy' button.
Photo by Money Knack on Unsplash
The short answer

Workspace and Enterprise Grid admins can set retention policies that delete messages and files after a fixed period, and free workspaces additionally stop returning history past a fixed age. Neither is reported to apps. There is no field on team.info, no header, and no error: content simply stops existing, and conversations.history answers ok: true with an empty array for the range where it used to answer with messages.

So you have to measure it. Probe conversations.history?channel=C...&oldest=<t>&latest=<t+7d>&limit=10 at increasing ages — thirty days, sixty, ninety, a year, two — and find the age at which results stop coming back. The finding is a number in days, and it is a property of the workspace rather than of any one message or file.

The measurement is only meaningful across several channels. A single channel that was quiet last spring looks exactly like a retention boundary, so the script requires agreement: two or more channels stopping at the same age is a policy, and channels stopping at different ages is a set of quiet channels. One channel that still returns older history than the others disproves the whole thing.

The problem in plain words

The reason this gets misdiagnosed is that it is too clean. Real bugs are ragged — they lose some records, they fail on Tuesdays, they break for one channel. Retention removes everything older than a line, everywhere, with no exceptions, which does not look like anything going wrong. It looks like the ingestion never ran, so the first suspect is always the ingestion, and rewriting it produces exactly the same hole.

The second thing that hides it is that it is retroactive from your point of view but not from Slack's. Nothing was deleted at export time. The messages were deleted continuously, a day at a time, for as long as the policy has been on, and the day the app finally goes looking for them is simply the day it finds out. An integration that treats Slack as its system of record has silently adopted somebody else's retention policy as its data-loss policy, and nobody in the room ever decided that.

Retention deletes messages as well as files, which is what separates this from a file being deleted on its own. When a file is deleted individually, the message that carried it survives and keeps rendering a link, so an index develops dead references while the surrounding text stays put — that is its own note and its shape is a growing fraction of broken pointers. Retention takes the message and the file together, so there is no dangling reference to find. There is nothing at all before the boundary, which is why the detector has to be a search for an edge rather than a scan for failures.

The free plan complicates the reading in a way worth being honest about. On free workspaces, history older than the plan's limit is not returned even though it has not necessarily been deleted, and from an app's side that is indistinguishable from retention: the same empty array at the same kind of boundary. The measurement is identical, the number is identical, and only the repair differs — one is a conversation with an admin about a policy, the other is a conversation about a plan. The script reports the boundary and names both possibilities rather than picking one it cannot verify.

An admin sets apolicy90 days, once, in2023Slack deletesdailymessages and filestogetherThe app reads ayearok, and an emptyarrayThe export hasa holetidy, and datedSomebodyrewrites theand gets the sameholethe horizon advances one day, every day
An admin set a policy, possibly years ago, possibly before the app existed. Nothing announces it and nothing errors: content older than a line simply is not there any more.

Why it happens

There is no read that returns the policy. A bot token cannot ask what the retention setting is; Grid admin methods can get close with admin scopes that a runtime app does not have. So the only available measurement is the effect, and the effect is an age. Everything in this script exists to turn "old stuff is missing" into a number somebody can take to an administrator.

A quiet channel is the false positive that ruins this check. An empty window means either that the content was deleted or that nothing was said. Those are indistinguishable in one channel and separable across several, which is why the script probes a set and reports agreement rather than a boundary per channel. A single-channel run is explicitly labelled undecidable.

History that reappears past an empty window disproves a horizon. If a channel returns nothing at ninety days and something at a year, that is a quiet stretch, not a policy: retention is monotonic, and anything non-monotonic is evidence against the finding rather than noise in it. The script names that state ragged and refuses to draw a boundary through it.

The boundary is worth snapping to a policy value. Measured boundaries land near round numbers because the settings are round numbers, and a measurement of eighty-eight days is a ninety-day policy. Saying "ninety days" is what makes the finding actionable; saying "somewhere between sixty and ninety" invites another week of probing.

The gap that matters is against your own lookback. A ninety-day horizon is fine for an app that reads a week and fatal for one that backfills a year. The number to report is the shortfall between what the workspace keeps and what your app assumes, because that difference is data you are already losing every day.

The repair is to stop treating Slack as the archive. Copy what matters into your own store at ingestion time, message text with its ts and file bytes through the authenticated download, and treat Slack as transport. Where genuine compliance retention is required, that is what Grid's Discovery and audit surfaces exist for, and they are a different product decision rather than a scope you can add.

The fix, as a flow

Nothing in this chain is red, because nothing in it fails. Every call returns ok, every window comes back empty and well formed, and the deletion happened months before anybody went looking. The loop underneath is the only thing moving: retention is a clock rather than an event, and the horizon advances one day every day whether or not the app is running. The fix branch sorts ages rather than objects, and the two plain rows are the ones that stop the check from inventing a policy out of a channel nobody posted in.

A ladder of windows, perchannel30, 60, 90, 180, 365, 730 daysSeveral channels, one agea workspace settingDifferent ages eachquiet channels, not a policyEmpty even last monththis channel decides nothingHistory past the empty windowmonotonic, so not an edgeKept days against read daysthe shortfall, in days
One channel cannot tell deletion from silence, so the answer is agreement rather than a reading. The last row is the sentence that belongs in the ticket, because it is a number about your app rather than about the workspace.

How to fix it

Pick several channels the bot is already in

One channel cannot answer this. The script takes --channels, or falls back to the public channels conversations.list reports the bot as a member of, and probes each of them independently so that the answers can be compared.

Probe a ladder of ages rather than bisecting blind

probe_windows builds a seven-day window at thirty, sixty, ninety, a hundred and eighty, three hundred and sixty-five and seven hundred and thirty days ago. Six reads per channel finds the decade-old edge as fast as a bisection and, unlike a bisection, the intermediate results are all still meaningful when the answer turns out to be ragged.

Find the edge, and refuse to find one that is not there

horizon returns boundary, no-boundary, silent, ragged or undecidable. Only the first is a finding. silent means even the newest window was empty, so the channel is quiet and the probe decided nothing at all.

Require the channels to agree before calling it a policy

agreement takes every channel's answer. Two or more boundaries at the same age is a workspace setting; different ages are quiet channels; and a single channel still returning older history than the rest is a contradiction that ends the investigation rather than a row to ignore.

Snap the measurement to the setting it probably is

snap_to_policy maps a measured age to the nearest of the round values these policies actually take. Ninety days carries an extra sentence, because ninety is also where a free workspace stops returning history and this measurement cannot tell those apart.

Report the shortfall against what your app assumes

expectation_gap takes the measured horizon and the --lookback-days your app backfills. losing with a number is the sentence to put in the ticket: the app reads back a year, the workspace keeps ninety days, and two hundred and seventy-five days of every backfill were gone before it started.

How to check it worked

The output to aim for is a single number and the word policy beside it, because that is the version of this finding an administrator can act on.

python3 slack_retention_horizon.py --lookback-days 365
# identity   U07BOT9QD (reports-bot) in Northwind
# probe      C01ENG9QT  30d:10 60d:10 90d:7 180d:0 365d:0 730d:0
# probe      C02OPS4QW  30d:10 60d:10 90d:4 180d:0 365d:0 730d:0
# probe      C03RND7QX  30d:10 60d:9  90d:6 180d:0 365d:0 730d:0
# horizon    boundary       C01ENG9QT  history stops between 90 and 180 days
# horizon    boundary       C02OPS4QW  history stops between 90 and 180 days
# agreement  policy         3 channel(s) stop at the same age, which is a workspace
#                           setting rather than three quiet channels
# policy     90 days        also the free plan's visible history limit; this probe
#                           cannot tell a retention policy from a plan limit
# files      boundary       files.list returns nothing older than 180 days either
# gap        losing         the app backfills 365 day(s) and the workspace keeps about
#                           180; 185 day(s) of every backfill were gone before it ran
#   repair: copy message text with its ts, and file bytes, into your own store
#   repair: ask an admin for the actual retention setting rather than inferring it

The full code

Two reads and five pure functions, and all the interesting work is in refusing to answer. horizon has five outcomes and only one of them is a finding, because an empty window is ambiguous by construction. agreement is the guard that turns three ambiguous channels into one unambiguous workspace answer, or into an explicit contradiction. snap_to_policy makes the number sayable, and expectation_gap turns it into the sentence that belongs in the ticket.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_retention_horizon.py
"""Measure how far back this workspace's history actually goes.

Read only. conversations.list, conversations.history, files.list and auth.test
are reads; nothing here writes, deletes or downloads anything, and no file
bytes and no url_private are ever fetched.

Retention policies delete messages and files on a schedule that no API reports.
There is no field to query and no error to catch: content stops existing, and
history calls answer ok with an empty array. So the policy is measured from its
effect, by asking about progressively older windows until the answers stop, and
the finding is an age in days.

The trap is that a quiet channel is also empty. That is why nothing here draws
a conclusion from one channel.
"""
import argparse
import logging
import os
import sys
import time

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_retention_horizon")

API = "https://slack.com/api/"
DAY = 86400

# Ages to probe, in days, newest first. A ladder rather than a bisection: the
# same six reads, and every intermediate answer stays meaningful when the shape
# turns out to be ragged rather than a clean edge.
LADDER = (30, 60, 90, 180, 365, 730)

# The round numbers retention settings are actually set to. A measurement of 88
# days is a 90 day policy, and saying so is what makes the finding actionable.
POLICY_VALUES = (30, 60, 90, 180, 365)


def probe_windows(now, ladder=LADDER, span_days=7):
    """The windows to ask about. Pure. Returns [(age_days, oldest, latest), ...].

    A window rather than an open-ended oldest, because "everything since two
    years ago" returns the most recent page and tells you nothing about two
    years ago. A week is wide enough that an active channel has something in it
    and narrow enough that the edge stays sharp.
    """
    try:
        base = int(now)
    except (TypeError, ValueError):
        return []
    out = []
    for age in sorted({int(a) for a in ladder if int(a) > 0}):
        oldest = base - age * DAY
        out.append((age, oldest, oldest + span_days * DAY))
    return out


def horizon(probes):
    """Where does the history stop? Pure. Returns (verdict, boundary_days, detail).

    probes is [(age_days, count), ...] in any order. Five outcomes and only one
    of them is a finding:

      boundary     results at some ages and none beyond a point. The edge.
      no-boundary  results at every age probed, so nothing was found in range.
      silent       nothing at any age, including the newest. The channel is
                   quiet and this probe has decided precisely nothing.
      ragged       history reappears past an empty window. Retention is
                   monotonic, so this is a quiet stretch and evidence against a
                   horizon rather than noise inside one.
      undecidable  fewer than two probes to compare.
    """
    rows = sorted((int(a), int(c)) for a, c in (probes or []))
    if len(rows) < 2:
        return ("undecidable", 0, "at least two ages are needed to find an edge")
    if all(c <= 0 for _a, c in rows):
        return ("silent", 0, "every window was empty, including the newest. This "
                             "channel is quiet and the probe decides nothing")
    if all(c > 0 for _a, c in rows):
        return ("no-boundary", 0, "history came back at every age probed, out to %d "
                                  "day(s). Nothing here indicates a horizon in that "
                                  "range" % rows[-1][0])
    first_empty = next(a for a, c in rows if c <= 0)
    if any(c > 0 for a, c in rows if a > first_empty):
        return ("ragged", 0, "history reappears past an empty window at %d day(s). "
                             "Retention is monotonic, so this is a quiet stretch "
                             "rather than an edge" % first_empty)
    last_full = max(a for a, c in rows if c > 0)
    return ("boundary", first_empty, "history stops between %d and %d day(s)"
                                     % (last_full, first_empty))


def agreement(answers):
    """Do the channels agree on the same edge? Pure.

    answers is [(channel, verdict, days), ...]. One channel cannot distinguish
    a retention policy from a channel nobody used, so this is where an
    ambiguous per-channel reading becomes a workspace-level one, or is refused.

    Returns (verdict, days, detail).
    """
    rows = list(answers or [])
    edges = [(c, d) for c, v, d in rows if v == "boundary" and d]
    if any(v == "no-boundary" for _c, v, _d in rows) and edges:
        return ("contradicted", 0, "at least one channel still returns history older "
                                   "than the edge the others show, so this is not a "
                                   "workspace-wide policy")
    if len(edges) < 2:
        return ("insufficient", 0, "%d channel(s) produced an edge. Two that agree are "
                                   "the minimum for calling this a policy" % len(edges))
    days = sorted(d for _c, d in edges)
    if days[0] != days[-1]:
        return ("disagreement", 0, "the channels stop at different ages (%d to %d "
                                   "day(s)), which is what a set of quiet channels "
                                   "looks like" % (days[0], days[-1]))
    return ("policy", days[0], "%d channel(s) stop at the same age, which is a "
                               "workspace setting rather than %d quiet channels"
                               % (len(edges), len(edges)))


def snap_to_policy(days):
    """Name the setting a measured boundary probably is. Pure.

    Returns (label, detail). Ninety carries an extra sentence because ninety is
    also where a free workspace stops returning history, and from an app's side
    a plan limit and a deletion policy are the same empty array.
    """
    try:
        n = int(days)
    except (TypeError, ValueError):
        return ("unknown", "no boundary to name")
    if n <= 0:
        return ("unknown", "no boundary to name")
    nearest = min(POLICY_VALUES, key=lambda v: abs(v - n))
    if abs(nearest - n) > nearest * 0.25:
        return ("custom", "%d day(s) is not near any of the usual settings, so ask for "
                          "the configured value rather than guessing at it" % n)
    if nearest == 90:
        return ("90 days", "also the free plan's visible history limit; this probe "
                           "cannot tell a retention policy from a plan limit, and the "
                           "two want different conversations")
    return ("%d days" % nearest, "a measured edge at %d day(s), which is the %d day "
                                 "setting" % (n, nearest))


def expectation_gap(retention_days, lookback_days):
    """What does your app assume, against what the workspace keeps? Pure.

    Returns (verdict, shortfall_days, detail). losing with a number is the
    sentence that belongs in the ticket, because it says how much of every
    backfill was already gone before the backfill started.
    """
    try:
        keeps = int(retention_days)
        wants = int(lookback_days)
    except (TypeError, ValueError):
        return ("unknown", 0, "both the measured horizon and the app's lookback are "
                              "needed")
    if keeps <= 0 or wants <= 0:
        return ("unknown", 0, "both the measured horizon and the app's lookback are "
                              "needed")
    if keeps >= wants * 1.5:
        return ("covered", 0, "the workspace keeps %d day(s) and the app reads back "
                              "%d" % (keeps, wants))
    if keeps >= wants:
        return ("tight", 0, "the workspace keeps %d day(s) and the app reads back %d. "
                            "One policy change closes that" % (keeps, wants))
    return ("losing", wants - keeps, "the app backfills %d day(s) and the workspace "
                                     "keeps about %d; %d day(s) of every backfill were "
                                     "gone before it ran"
                                     % (wants, keeps, wants - keeps))


def count_window(session, channel, oldest, latest, limit=10):
    """How many messages are in this window? A read."""
    body = session.get(API + "conversations.history", timeout=30, params={
        "channel": channel, "oldest": str(oldest), "latest": str(latest),
        "limit": str(limit), "inclusive": "true"}).json()
    if body.get("ok") is not True:
        return None, str(body.get("error") or "")
    return len(body.get("messages") or []), ""


def count_files(session, oldest, latest, limit=10):
    """How many files are in this window? A read, and no bytes are fetched."""
    body = session.get(API + "files.list", timeout=30, params={
        "ts_from": str(oldest), "ts_to": str(latest), "count": str(limit),
        "types": "all"}).json()
    if body.get("ok") is not True:
        return None, str(body.get("error") or "")
    return len(body.get("files") or []), ""


def member_channels(session, wanted):
    """Channels to probe: the ones asked for, or the ones the bot is in. A read."""
    if wanted:
        return [c.strip() for c in wanted.split(",") if c.strip()]
    body = session.get(API + "conversations.list", timeout=30, params={
        "types": "public_channel", "exclude_archived": "true", "limit": "200"}).json()
    if body.get("ok") is not True:
        log.error("conversations.list unavailable    %s", body.get("error"))
        return []
    return [c.get("id") for c in body.get("channels") or [] if c.get("is_member")]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--channels", default="",
                    help="comma separated channel ids; defaults to the bot's channels")
    ap.add_argument("--max-channels", type=int, default=3,
                    help="how many channels to probe; two is the minimum that decides")
    ap.add_argument("--lookback-days", type=int, default=0,
                    help="how far back your app assumes it can read")
    ap.add_argument("--span-days", type=int, default=7,
                    help="width of each probe window")
    ap.add_argument("--skip-files", action="store_true",
                    help="probe messages only")
    args = ap.parse_args()

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s to a bot token with channels:history", args.token_env)
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})
    who = s.get(API + "auth.test", timeout=30).json()
    if who.get("ok") is not True:
        log.error("auth.test  unavailable    %s", who.get("error"))
        return 2
    log.info("identity   %s (%s) in %s", who.get("user_id"), who.get("user"),
             who.get("team"))

    channels = member_channels(s, args.channels)[:max(1, args.max_channels)]
    if not channels:
        log.error("channels   none to probe; pass --channels or invite the bot")
        return 2

    windows = probe_windows(time.time(), LADDER, args.span_days)
    answers = []
    for channel in channels:
        probes, cells = [], []
        for age, oldest, latest in windows:
            count, error = count_window(s, channel, oldest, latest)
            if count is None:
                log.warning("probe      %s  %dd unavailable: %s", channel, age, error)
                continue
            probes.append((age, count))
            cells.append("%dd:%d" % (age, count))
        log.info("probe      %s  %s", channel, " ".join(cells))
        verdict, days, detail = horizon(probes)
        answers.append((channel, verdict, days))
        (log.warning if verdict == "boundary" else log.info)(
            "horizon    %-14s %s  %s", verdict, channel, detail)

    state, days, detail = agreement(answers)
    (log.warning if state == "policy" else log.info)(
        "agreement  %-14s %s", state, detail)

    if state == "policy":
        label, note = snap_to_policy(days)
        log.warning("policy     %-14s %s", label, note)

    if not args.skip_files:
        probes = []
        for age, oldest, latest in windows:
            count, error = count_files(s, oldest, latest)
            if count is None:
                log.info("files      %dd unavailable: %s", age, error)
                continue
            probes.append((age, count))
        fverdict, fdays, fdetail = horizon(probes)
        log.info("files      %-14s %s", fverdict, fdetail)
        if fverdict == "boundary" and state != "policy":
            days = days or fdays

    if args.lookback_days and days:
        gap, shortfall, gdetail = expectation_gap(days, args.lookback_days)
        (log.warning if gap == "losing" else log.info)("gap        %-14s %s", gap,
                                                       gdetail)
        if gap == "losing":
            log.warning("  repair: copy message text with its ts, and file bytes "
                        "through the authenticated download, into your own store at "
                        "ingestion time")
            log.warning("  repair: ask an admin for the configured retention setting "
                        "rather than inferring it, and record %d day(s) as the number "
                        "the app may assume", days)
            log.warning("  repair: where retention is a compliance requirement, that "
                        "is what Grid's Discovery and audit surfaces are for")
            return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-retention-horizon.mjs
/**
 * Measure how far back this workspace's history actually goes.
 *
 * Read only. conversations.list, conversations.history, files.list and
 * auth.test are reads; nothing here writes, deletes or downloads anything, and
 * no file bytes and no url_private are ever fetched.
 *
 * Retention policies delete messages and files on a schedule that no API
 * reports, so the policy is measured from its effect: ask about progressively
 * older windows until the answers stop. The trap is that a quiet channel is
 * also empty, which is why nothing here concludes anything from one channel.
 */

const API = 'https://slack.com/api/';
const DAY = 86400;

/** Ages to probe, in days. A ladder rather than a bisection. */
export const LADDER = [30, 60, 90, 180, 365, 730];
/** The round numbers retention settings are actually set to. */
export const POLICY_VALUES = [30, 60, 90, 180, 365];

/**
 * The windows to ask about. Pure. Returns [[ageDays, oldest, latest], ...].
 * A window rather than an open-ended oldest, because "everything since two
 * years ago" returns the most recent page and says nothing about two years ago.
 */
export function probeWindows(now, ladder = LADDER, spanDays = 7) {
  const base = Number.parseInt(now, 10);
  if (!Number.isFinite(base)) return [];
  const ages = [...new Set((ladder ?? []).map((a) => Number.parseInt(a, 10)))]
    .filter((a) => Number.isFinite(a) && a > 0).sort((a, b) => a - b);
  return ages.map((age) => {
    const oldest = base - age * DAY;
    return [age, oldest, oldest + spanDays * DAY];
  });
}

/**
 * Where does the history stop? Pure. Returns [verdict, boundaryDays, detail].
 * Five outcomes and only boundary is a finding: silent means the channel was
 * quiet, and ragged means history reappears past an empty window, which is
 * evidence against a horizon rather than noise inside one.
 */
export function horizon(probes) {
  const rows = (probes ?? []).map(([a, c]) => [Number.parseInt(a, 10),
    Number.parseInt(c, 10)]).sort((x, y) => x[0] - y[0]);
  if (rows.length < 2) {
    return ['undecidable', 0, 'at least two ages are needed to find an edge'];
  }
  if (rows.every(([, c]) => c <= 0)) {
    return ['silent', 0, 'every window was empty, including the newest. This channel '
      + 'is quiet and the probe decides nothing'];
  }
  if (rows.every(([, c]) => c > 0)) {
    return ['no-boundary', 0, `history came back at every age probed, out to `
      + `${rows[rows.length - 1][0]} day(s). Nothing here indicates a horizon in that `
      + 'range'];
  }
  const firstEmpty = rows.find(([, c]) => c <= 0)[0];
  if (rows.some(([a, c]) => a > firstEmpty && c > 0)) {
    return ['ragged', 0, `history reappears past an empty window at ${firstEmpty} `
      + 'day(s). Retention is monotonic, so this is a quiet stretch rather than an '
      + 'edge'];
  }
  const lastFull = Math.max(...rows.filter(([, c]) => c > 0).map(([a]) => a));
  return ['boundary', firstEmpty, `history stops between ${lastFull} and ${firstEmpty} `
    + 'day(s)'];
}

/**
 * Do the channels agree on the same edge? Pure. Returns [verdict, days, detail].
 * One channel cannot tell a retention policy from a channel nobody used.
 */
export function agreement(answers) {
  const rows = answers ?? [];
  const edges = rows.filter(([, v, d]) => v === 'boundary' && d);
  if (rows.some(([, v]) => v === 'no-boundary') && edges.length) {
    return ['contradicted', 0, 'at least one channel still returns history older than '
      + 'the edge the others show, so this is not a workspace-wide policy'];
  }
  if (edges.length < 2) {
    return ['insufficient', 0, `${edges.length} channel(s) produced an edge. Two that `
      + 'agree are the minimum for calling this a policy'];
  }
  const days = edges.map(([, , d]) => d).sort((a, b) => a - b);
  if (days[0] !== days[days.length - 1]) {
    return ['disagreement', 0, `the channels stop at different ages (${days[0]} to `
      + `${days[days.length - 1]} day(s)), which is what a set of quiet channels looks `
      + 'like'];
  }
  return ['policy', days[0], `${edges.length} channel(s) stop at the same age, which is `
    + `a workspace setting rather than ${edges.length} quiet channels`];
}

/**
 * Name the setting a measured boundary probably is. Pure.
 * Ninety carries an extra sentence: it is also where a free workspace stops
 * returning history, and a plan limit and a deletion policy look identical.
 */
export function snapToPolicy(days) {
  const n = Number.parseInt(days, 10);
  if (!Number.isFinite(n) || n <= 0) return ['unknown', 'no boundary to name'];
  const nearest = POLICY_VALUES.reduce((best, v) => (Math.abs(v - n)
    < Math.abs(best - n) ? v : best), POLICY_VALUES[0]);
  if (Math.abs(nearest - n) > nearest * 0.25) {
    return ['custom', `${n} day(s) is not near any of the usual settings, so ask for `
      + 'the configured value rather than guessing at it'];
  }
  if (nearest === 90) {
    return ['90 days', 'also the free plan visible history limit; this probe cannot '
      + 'tell a retention policy from a plan limit, and the two want different '
      + 'conversations'];
  }
  return [`${nearest} days`, `a measured edge at ${n} day(s), which is the ${nearest} `
    + 'day setting'];
}

/**
 * What does your app assume, against what the workspace keeps? Pure.
 * Returns [verdict, shortfallDays, detail].
 */
export function expectationGap(retentionDays, lookbackDays) {
  const keeps = Number.parseInt(retentionDays, 10);
  const wants = Number.parseInt(lookbackDays, 10);
  if (!Number.isFinite(keeps) || !Number.isFinite(wants) || keeps <= 0 || wants <= 0) {
    return ['unknown', 0, 'both the measured horizon and the app lookback are needed'];
  }
  if (keeps >= wants * 1.5) {
    return ['covered', 0, `the workspace keeps ${keeps} day(s) and the app reads back `
      + `${wants}`];
  }
  if (keeps >= wants) {
    return ['tight', 0, `the workspace keeps ${keeps} day(s) and the app reads back `
      + `${wants}. One policy change closes that`];
  }
  return ['losing', wants - keeps, `the app backfills ${wants} day(s) and the workspace `
    + `keeps about ${keeps}; ${wants - keeps} day(s) of every backfill were gone before `
    + 'it ran'];
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function countWindow(headers, channel, oldest, latest, limit = 10) {
  const params = new URLSearchParams({
    channel, oldest: String(oldest), latest: String(latest), limit: String(limit),
    inclusive: 'true',
  });
  const body = await (await fetch(`${API}conversations.history?${params}`,
    { headers })).json();
  if (body.ok !== true) return [null, String(body.error ?? '')];
  return [(body.messages ?? []).length, ''];
}

async function countFiles(headers, oldest, latest, limit = 10) {
  const params = new URLSearchParams({
    ts_from: String(oldest), ts_to: String(latest), count: String(limit), types: 'all',
  });
  const body = await (await fetch(`${API}files.list?${params}`, { headers })).json();
  if (body.ok !== true) return [null, String(body.error ?? '')];
  return [(body.files ?? []).length, ''];
}

async function memberChannels(headers, wanted) {
  if (wanted) return wanted.split(',').map((c) => c.trim()).filter(Boolean);
  const params = new URLSearchParams({
    types: 'public_channel', exclude_archived: 'true', limit: '200',
  });
  const body = await (await fetch(`${API}conversations.list?${params}`,
    { headers })).json();
  if (body.ok !== true) {
    console.error(`conversations.list unavailable    ${body.error}`);
    return [];
  }
  return (body.channels ?? []).filter((c) => c.is_member).map((c) => c.id);
}

async function main() {
  const args = process.argv.slice(2);
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} to a bot token with channels:history`);
    process.exitCode = 2;
    return;
  }
  const headers = { Authorization: `Bearer ${token}` };
  const who = await (await fetch(`${API}auth.test`, { headers })).json();
  if (who.ok !== true) {
    console.error(`auth.test  unavailable    ${who.error}`);
    process.exitCode = 2;
    return;
  }
  console.log(`identity   ${who.user_id} (${who.user}) in ${who.team}`);

  const maxChannels = Math.max(1, Number(arg(args, '--max-channels', 3)));
  const channels = (await memberChannels(headers, arg(args, '--channels', '')))
    .slice(0, maxChannels);
  if (!channels.length) {
    console.error('channels   none to probe; pass --channels or invite the bot');
    process.exitCode = 2;
    return;
  }

  const windows = probeWindows(Math.floor(Date.now() / 1000), LADDER,
    Number(arg(args, '--span-days', 7)));
  const answers = [];
  for (const channel of channels) {
    const probes = [];
    const cells = [];
    for (const [age, oldest, latest] of windows) {
      const [count, error] = await countWindow(headers, channel, oldest, latest);
      if (count === null) {
        console.warn(`probe      ${channel}  ${age}d unavailable: ${error}`);
        continue;
      }
      probes.push([age, count]);
      cells.push(`${age}d:${count}`);
    }
    console.log(`probe      ${channel}  ${cells.join(' ')}`);
    const [verdict, days, detail] = horizon(probes);
    answers.push([channel, verdict, days]);
    const line = `horizon    ${verdict.padEnd(14)} ${channel}  ${detail}`;
    if (verdict === 'boundary') console.warn(line); else console.log(line);
  }

  const [state, days, detail] = agreement(answers);
  const line = `agreement  ${state.padEnd(14)} ${detail}`;
  if (state === 'policy') console.warn(line); else console.log(line);
  if (state === 'policy') {
    const [label, note] = snapToPolicy(days);
    console.warn(`policy     ${label.padEnd(14)} ${note}`);
  }

  let horizonDays = days;
  if (!args.includes('--skip-files')) {
    const probes = [];
    for (const [age, oldest, latest] of windows) {
      const [count, error] = await countFiles(headers, oldest, latest);
      if (count === null) {
        console.log(`files      ${age}d unavailable: ${error}`);
        continue;
      }
      probes.push([age, count]);
    }
    const [fverdict, fdays, fdetail] = horizon(probes);
    console.log(`files      ${fverdict.padEnd(14)} ${fdetail}`);
    if (fverdict === 'boundary' && state !== 'policy') horizonDays = horizonDays || fdays;
  }

  const lookback = Number(arg(args, '--lookback-days', 0));
  if (lookback && horizonDays) {
    const [gap, , gdetail] = expectationGap(horizonDays, lookback);
    const gline = `gap        ${gap.padEnd(14)} ${gdetail}`;
    if (gap === 'losing') console.warn(gline); else console.log(gline);
    if (gap === 'losing') {
      console.warn('  repair: copy message text with its ts, and file bytes through '
        + 'the authenticated download, into your own store at ingestion time');
      console.warn('  repair: ask an admin for the configured retention setting rather '
        + `than inferring it, and record ${horizonDays} day(s) as the assumption`);
      process.exitCode = 1;
    }
  }
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Every test here is about not declaring a boundary that is not there. A channel with nothing in any window has to come back silent rather than as an edge at thirty days, because a quiet channel is the false positive that would make this check worthless. History that reappears past an empty window has to come back ragged, since retention is monotonic and anything else is evidence against the finding. And agreement is tested for the case that ends the investigation: one channel still holding older history than the others disproves a workspace-wide policy outright.

test_slack_retention_horizon.py
from slack_retention_horizon import (agreement, expectation_gap, horizon,
                                     probe_windows, snap_to_policy)

DAY = 86400


def test_the_windows_are_bounded_rather_than_open_ended():
    got = probe_windows(1000000, ladder=(30,), span_days=7)
    assert got == [(30, 1000000 - 30 * DAY, 1000000 - 30 * DAY + 7 * DAY)]


def test_the_ladder_is_sorted_and_deduplicated():
    assert [a for a, _o, _l in probe_windows(1000000, ladder=(90, 30, 30))] == [30, 90]


def test_a_bad_clock_produces_no_windows_rather_than_raising():
    assert probe_windows(None) == []


def test_an_edge_is_reported_between_the_two_ages_that_bracket_it():
    verdict, days, detail = horizon([(30, 10), (90, 7), (180, 0), (365, 0)])
    assert (verdict, days) == ("boundary", 180)
    assert "between 90 and 180" in detail


def test_history_at_every_age_is_not_a_boundary():
    assert horizon([(30, 10), (365, 4), (730, 2)])[0] == "no-boundary"


def test_a_quiet_channel_is_silent_rather_than_an_edge_at_thirty_days():
    verdict, _days, detail = horizon([(30, 0), (90, 0), (365, 0)])
    assert verdict == "silent"
    assert "decides nothing" in detail


def test_history_reappearing_past_an_empty_window_is_ragged():
    verdict, days, _detail = horizon([(30, 8), (90, 0), (365, 6)])
    assert (verdict, days) == ("ragged", 0)


def test_one_probe_cannot_find_an_edge():
    assert horizon([(30, 10)])[0] == "undecidable"
    assert horizon([])[0] == "undecidable"


def test_two_channels_stopping_at_the_same_age_is_a_policy():
    verdict, days, detail = agreement([("C1", "boundary", 180),
                                       ("C2", "boundary", 180)])
    assert (verdict, days) == ("policy", 180)
    assert "workspace setting" in detail


def test_one_channel_is_never_enough_to_call_it_a_policy():
    assert agreement([("C1", "boundary", 180)])[0] == "insufficient"


def test_channels_stopping_at_different_ages_are_just_quiet():
    assert agreement([("C1", "boundary", 90), ("C2", "boundary", 365)])[0] == \
        "disagreement"


def test_a_channel_with_older_history_disproves_the_whole_finding():
    verdict, _days, detail = agreement([("C1", "boundary", 180),
                                        ("C2", "boundary", 180),
                                        ("C3", "no-boundary", 0)])
    assert verdict == "contradicted"
    assert "not a workspace-wide policy" in detail


def test_a_measured_edge_snaps_to_the_setting_it_probably_is():
    label, _detail = snap_to_policy(188)
    assert label == "180 days"


def test_ninety_days_carries_the_free_plan_caveat():
    label, detail = snap_to_policy(88)
    assert label == "90 days"
    assert "plan limit" in detail


def test_an_edge_near_nothing_familiar_is_not_forced_onto_a_setting():
    assert snap_to_policy(500)[0] == "custom"
    assert snap_to_policy(0)[0] == "unknown"


def test_a_backfill_longer_than_the_horizon_is_losing_data_every_day():
    verdict, shortfall, detail = expectation_gap(180, 365)
    assert (verdict, shortfall) == ("losing", 185)
    assert "before it ran" in detail


def test_a_horizon_that_only_just_covers_the_lookback_is_tight():
    assert expectation_gap(400, 365)[0] == "tight"


def test_plenty_of_room_is_reported_as_covered():
    assert expectation_gap(730, 30)[0] == "covered"


def test_the_gap_needs_both_numbers():
    assert expectation_gap(0, 365)[0] == "unknown"
    assert expectation_gap(180, None)[0] == "unknown"
slack-retention-horizon.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  agreement, expectationGap, horizon, probeWindows, snapToPolicy,
} from './slack-retention-horizon.mjs';

const DAY = 86400;

test('the windows are bounded rather than open ended', () => {
  assert.deepEqual(probeWindows(1000000, [30], 7),
    [[30, 1000000 - 30 * DAY, 1000000 - 30 * DAY + 7 * DAY]]);
});

test('the ladder is sorted and deduplicated', () => {
  assert.deepEqual(probeWindows(1000000, [90, 30, 30]).map(([a]) => a), [30, 90]);
});

test('a bad clock produces no windows rather than throwing', () => {
  assert.deepEqual(probeWindows(null), []);
});

test('an edge is reported between the two ages that bracket it', () => {
  const [verdict, days, detail] = horizon([[30, 10], [90, 7], [180, 0], [365, 0]]);
  assert.equal(verdict, 'boundary');
  assert.equal(days, 180);
  assert.match(detail, /between 90 and 180/);
});

test('history at every age is not a boundary', () => {
  assert.equal(horizon([[30, 10], [365, 4], [730, 2]])[0], 'no-boundary');
});

test('a quiet channel is silent rather than an edge at thirty days', () => {
  const [verdict, , detail] = horizon([[30, 0], [90, 0], [365, 0]]);
  assert.equal(verdict, 'silent');
  assert.match(detail, /decides nothing/);
});

test('history reappearing past an empty window is ragged', () => {
  const [verdict, days] = horizon([[30, 8], [90, 0], [365, 6]]);
  assert.equal(verdict, 'ragged');
  assert.equal(days, 0);
});

test('one probe cannot find an edge', () => {
  assert.equal(horizon([[30, 10]])[0], 'undecidable');
  assert.equal(horizon([])[0], 'undecidable');
});

test('two channels stopping at the same age is a policy', () => {
  const [verdict, days, detail] = agreement([['C1', 'boundary', 180],
    ['C2', 'boundary', 180]]);
  assert.equal(verdict, 'policy');
  assert.equal(days, 180);
  assert.match(detail, /workspace setting/);
});

test('one channel is never enough to call it a policy', () => {
  assert.equal(agreement([['C1', 'boundary', 180]])[0], 'insufficient');
});

test('channels stopping at different ages are just quiet', () => {
  assert.equal(agreement([['C1', 'boundary', 90], ['C2', 'boundary', 365]])[0],
    'disagreement');
});

test('a channel with older history disproves the whole finding', () => {
  const [verdict, , detail] = agreement([['C1', 'boundary', 180],
    ['C2', 'boundary', 180], ['C3', 'no-boundary', 0]]);
  assert.equal(verdict, 'contradicted');
  assert.match(detail, /not a workspace-wide policy/);
});

test('a measured edge snaps to the setting it probably is', () => {
  assert.equal(snapToPolicy(188)[0], '180 days');
});

test('ninety days carries the free plan caveat', () => {
  const [label, detail] = snapToPolicy(88);
  assert.equal(label, '90 days');
  assert.match(detail, /plan limit/);
});

test('an edge near nothing familiar is not forced onto a setting', () => {
  assert.equal(snapToPolicy(500)[0], 'custom');
  assert.equal(snapToPolicy(0)[0], 'unknown');
});

test('a backfill longer than the horizon is losing data every day', () => {
  const [verdict, shortfall, detail] = expectationGap(180, 365);
  assert.equal(verdict, 'losing');
  assert.equal(shortfall, 185);
  assert.match(detail, /before it ran/);
});

test('a horizon that only just covers the lookback is tight', () => {
  assert.equal(expectationGap(400, 365)[0], 'tight');
});

test('plenty of room is reported as covered', () => {
  assert.equal(expectationGap(730, 30)[0], 'covered');
});

test('the gap needs both numbers', () => {
  assert.equal(expectationGap(0, 365)[0], 'unknown');
  assert.equal(expectationGap(180, null)[0], 'unknown');
});

FAQ

Can I just read the retention setting from the API?

Not with a bot token. There is no field on team.info and no header that reports it, and the Grid admin surfaces that come closest need admin scopes that a runtime app does not hold. That is precisely why this note is a measurement rather than a lookup: the only thing a read-scoped token can observe is the effect, which is that history stops at a certain age. The number you measure is the number to take to an administrator for confirmation.

How is this different from a file being deleted?

A deleted file leaves its message behind. The post still renders, the link still looks like a link, and your index develops dead pointers among live ones, which is a growing fraction of broken references and a different note. Retention removes the message and the file together, so before the boundary there is nothing at all: no dangling reference to find and no fraction to compute, just an edge. That is why this detector searches for an edge instead of scanning for failures.

Our free workspace has never had a retention policy. Why is history missing?

Free workspaces stop returning history past a fixed age, whether or not anything was deleted. From your app's side the two are identical: the same empty array at the same kind of boundary, with the same ok: true. The script reports the boundary and names both possibilities rather than picking the one it cannot verify, because the repairs differ completely - one is a conversation about a policy and the other is a conversation about a plan.

Why does the script insist on probing several channels?

Because one channel cannot distinguish deletion from silence. An empty window means either that the content was removed or that nobody posted, and a channel that was busy last year and quiet this spring produces a perfect-looking boundary that is entirely fictional. Two channels stopping at the same age is a workspace setting. Two channels stopping at different ages is two quiet channels, and the script says so rather than picking the more alarming reading.

What should we actually do about it?

Stop treating Slack as the archive. Copy what matters into your own store at ingestion time - message text with its ts, and file bytes through the authenticated download - so that your record is yours rather than a view onto someone else's retention setting. Then get the configured number from an admin and write it down as the maximum lookback your app is allowed to assume. Where retention is a compliance obligation, that is what Enterprise Grid's Discovery and audit surfaces exist for.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.