Skip to content

Diagnostic Slack

channel_not_found after a rename, or worse, the wrong room

An integration that has run untouched for two years stops on a Tuesday. Nothing was deployed, no token expired, no scope changed. What happened is that a team tidied up and renamed #ops-alerts to #platform-alerts. If you are lucky, you get channel_not_found and a page. If you are not, somebody created a new #ops-alerts the following week and your alerts are still being delivered, to strangers.

Read-only token Python and Node.js Tests included
Woman in black headphones holding black and silver headphones
Photo by Charanjeet Dhiman on Unsplash
The short answer

A Slack channel name is a display label. It can be changed by anyone with permission, and when it changes the old name is released and can be claimed by a different channel. The ID never moves. Any configuration that stores the name has stored the field that is designed to change, and the failure arrives on a day nobody deployed anything.

The script below is a drift check rather than a lookup. For each configured entry it resolves the stored value, then asks two questions of the answer: is the channel it resolves to older than this configuration, and has this channel been renamed before? A resolved name pointing at a channel created after your config line was written is the dangerous finding, because nothing is failing — the messages are landing in a room that happens to be wearing the right name.

The problem in plain words

Renames are cheap and social. A team finishes a migration and drops the project prefix, a company rebrands, two channels merge, somebody fixes a typo that has annoyed them for a year. None of these people are thinking about integrations, and Slack gives them no reason to: the rename dialog does not list what points at the channel, because nothing knows.

The visible outcome is the kind one. The name stops resolving, every call returns channel_not_found, alerts stop, and eventually somebody notices and greps for the string. Annoying, findable, over in an hour.

The invisible outcome is the one worth writing a script for. Slack releases a name when it stops being used, so a week or a month later a different team can create a channel with exactly the string in your config. Now the old integration resolves again, returns ok: true, and posts production alerts into a room full of people who have no idea what they are looking at. There is no error anywhere in the system. The only signal that anything is wrong is that the channel your alerts were supposed to reach has been quiet for a month, and quiet channels do not page anybody.

The same drift runs the other way too, more harmlessly. Configuration that stores the ID with a human label beside it — C01ABCDE9 # #ops-alerts — keeps delivering correctly forever while the comment slowly becomes fiction, until an on-call engineer follows the runbook to a channel that has not existed under that name in two years.

Two quiet yearsa name in an envvarTeam renames itthe old namereleasedAlerts stopchannel_not_foundName claimedagainby another teamDeliveryresumesinto the wrongroom
The recovery is the dangerous half. Once somebody claims the freed name the calls succeed again, so the error disappears and the misdelivery does not.

Why it happens

Names are not unique over time. They are unique among live channels at any instant, which is a much weaker guarantee than it looks. Storing a name is storing a claim that was true when you wrote it, and nothing in Slack renews that claim on your behalf.

The creation timestamp is the tell. If the channel a name resolves to was created after the configuration that names it, the name has been recycled onto something new. That comparison needs one field from Slack, channel.created, and one fact you already have — when that config line was written. It is the only way a read-only script can tell a stable name from a name that quietly moved house.

previous_names is a direct confirmation. conversations.info returns previous_names on some plans, listing what the channel has been called before. A non-empty list is proof that this channel is a renaming sort of channel, which turns a "correct today" into a "correct today, and it has moved twice already".

Nothing fails, so nothing alerts. This is the note in the batch where the API is entirely happy. Every other channel problem here produces at least an ok: false somewhere. A recycled name produces a successful send to the wrong audience, which is why the detection has to be scheduled rather than triggered, and why a script has to go looking for it deliberately.

The repair is a data-model change, not a fix. Editing the config to the new name buys you until the next rename. Storing the ID ends the class of bug: IDs survive renames, survive public-to-private conversion, and are the only channel field Slack promises is stable.

The fix, as a flow

The chain runs on a calendar rather than on a request: a name released, claimed again, and delivery quietly resuming. The fix branch ranks by who already knows, which is why a successful send to the wrong room sits above an outage that has been paging people all day.

Stored value against todaycreated versus config ageResolves to a newer channeldelivered, wrong roomResolves to nothingfailing since rename dayRenamed beforea moving targetLabel beside the ID stalewarn, never failID with a current labelnothing can drift
A stale label beside a correct ID must never fail a deploy: the traffic was always going to the right place and only the comment is wrong. Flattening it into the failures is how the real finding gets ignored.

How to fix it

Write down when each config line was written

The script wants three fields per entry: the config key, the stored value, and a since timestamp for when that value was set — the commit date of the line is close enough. Without since the recycled-name check cannot run at all, because the whole comparison is against the age of your own configuration.

Resolve every stored value, IDs included

One paginated conversations.list gives you both indexes: by name for the entries that store a name, by ID for the entries that store an ID with a label. Both halves drift, in opposite directions, and a check that only handles names misses the stale-runbook case entirely.

Compare the creation date against your configuration

A name that resolves to a channel created after your config line is the finding this whole note exists for. Report it first and loudest: it is the only state here where messages are being delivered successfully to the wrong people, and it will not appear in any error budget.

Ask conversations.info what this channel used to be called

previous_names turns a suspicion into evidence. A channel with a rename history is one that will be renamed again, and a stored name pointing at it should be replaced now rather than after the next incident. Where the field is absent, say the check was unavailable rather than reporting a clean result.

Rank by silence, not by severity of the error

An outage is loud and already known by the time you run this. A silent misdelivery is neither, so it sorts above the outage in the report. The exit code treats both as failures, but the ordering in the output is what decides which one a person reads first.

Store IDs and let the label drift

Replace every stored name with its ID and keep the name as a comment. Then add a soft assertion at startup: if the current name of the stored ID differs from the label, warn and carry on. A drifted label is a documentation bug and must never take a deploy down — the traffic was always going to the right room.

How to check it worked

After the configuration stores IDs, every entry should come back pinned, and the one that warns should warn about a comment rather than about delivery.

python3 slack_channel_name_drift.py --config channels.json
# ok        pinned         ALERTS_CHANNEL  an ID with a label that still matches #platform-alerts
# ok        pinned         DIGEST_CHANNEL  an ID with a label that still matches #build-digest
# 2 entry(ies) checked, 0 drifting

The full code

The comparison at the heart of this is between a Slack timestamp and a date from your own repository, so drift_verdict takes both and stays pure. severity is the other half of the argument: it ranks a recycled name above an outright outage, because one of them is already known to everybody and the other is not known to anybody. config_line prints the replacement.

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_channel_name_drift.py
"""Find Slack channel references that have drifted since they were configured.

Read only. One paginated conversations.list plus one conversations.info per
resolved entry, GET only. Nothing is renamed and nothing is posted: the script
reports the drift and prints the config line that ends it.
"""
import argparse
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone

import requests

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

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

ID_RE = re.compile(r"^[CG][A-Z0-9]{7,}$")

# A recycled name delivers successfully to the wrong audience, so it outranks an
# outage: the outage is already known to everyone whose alerts stopped, and this
# is known to nobody.
SEVERITY = {
    "name-recycled": "silent-misdelivery",
    "name-gone": "outage",
    "id-unresolved": "outage",
    "rename-prone": "warning",
    "name-unpinned": "warning",
    "label-drifted": "warning",
    "pinned": "ok",
}
RANK = ("silent-misdelivery", "outage", "warning", "ok")


def severity(state):
    """How loudly one drift state deserves to be read. Pure."""
    return SEVERITY.get(state, "warning")


def _epoch(value):
    """ISO-8601 to a Unix timestamp, or None. Pure and tolerant of a trailing Z."""
    if value in (None, ""):
        return None
    try:
        text = str(value).replace("Z", "+00:00")
        stamp = datetime.fromisoformat(text)
    except ValueError:
        return None
    if stamp.tzinfo is None:
        stamp = stamp.replace(tzinfo=timezone.utc)
    return stamp.timestamp()


def _day(epoch):
    return datetime.fromtimestamp(float(epoch), timezone.utc).date().isoformat()


def drift_verdict(entry, channel):
    """Compare one configured reference against what it resolves to today. Pure.

    `entry` is {"key", "value", "since", "label"}: the value as stored, and when
    it was stored. `channel` is what it resolves to now, or None. The comparison
    that matters is between Slack's channel.created and the age of your own
    configuration, because a name younger than the config line is a name that was
    released by a rename and claimed by somebody else.
    """
    value = str(entry.get("value") or "").strip()
    label = str(entry.get("label") or "").lstrip("#").strip().lower()
    since = _epoch(entry.get("since"))

    if ID_RE.match(value):
        if channel is None:
            return ("id-unresolved",
                    "the stored ID resolves to nothing this token can see. The "
                    "channel was deleted, or converted to private and the scope "
                    "for it is missing. IDs do not change, so this is not a rename.")
        current = str(channel.get("name") or "").lower()
        if not label:
            return ("pinned",
                    "an ID with no label beside it. Nothing can drift, and nothing "
                    "tells the next reader which room this is either.")
        if label != current:
            return ("label-drifted",
                    "the ID still points at the right room and the label beside it "
                    "says #%s while Slack says #%s. Warn, never fail: the traffic "
                    "is fine and the runbook is wrong." % (label, current))
        return ("pinned", "an ID with a label that still matches #%s" % current)

    if channel is None:
        return ("name-gone",
                "no channel answers to %s any more. It was renamed or deleted, and "
                "every call using this value has failed since the day that "
                "happened." % value)

    created = channel.get("created")
    created = float(created) if isinstance(created, (int, float, str)) and str(created).strip() else None
    if since and created and created > since:
        return ("name-recycled",
                "%s resolves to %s, created %s, which is after this configuration "
                "was written. The name was released by a rename and claimed by a "
                "different channel. Nothing errors: the messages are being "
                "delivered, into a room nobody chose."
                % (value, channel.get("id"), _day(created)))

    previous = [str(n) for n in (channel.get("previous_names") or []) if n]
    if previous:
        return ("rename-prone",
                "%s resolves today, and this channel has already answered to %s. "
                "The name is a moving target that has simply not moved again yet."
                % (value, ", ".join("#" + n for n in previous[:3])))

    return ("name-unpinned",
            "%s resolves to %s and has no recorded rename. Correct today, and one "
            "rename away from either of the findings above."
            % (value, channel.get("id")))


def config_line(entry, channel):
    """The replacement line: ID as the value, name demoted to a comment. Pure."""
    return "%s=%s  # #%s -- store the ID, the name is a label" % (
        entry.get("key"), channel.get("id"), channel.get("name"))


def list_channels(session):
    """Every channel the token can enumerate, archives included. GET only."""
    out, cursor = [], ""
    while True:
        params = {"types": "public_channel,private_channel",
                  "exclude_archived": "false", "limit": 1000}
        if cursor:
            params["cursor"] = cursor
        body = session.get(API + "conversations.list", params=params, timeout=30).json()
        if body.get("ok") is not True:
            raise SystemExit("conversations.list answered 200 with ok: false, "
                             "error=%s" % body.get("error"))
        out.extend(body.get("channels") or [])
        cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
        if not cursor:
            return out


def with_previous_names(session, channel):
    """conversations.info for previous_names, which the listing does not carry."""
    body = session.get(API + "conversations.info",
                       params={"channel": channel.get("id")}, timeout=30).json()
    if body.get("ok") is not True:
        return channel
    merged = dict(channel)
    merged.update(body.get("channel") or {})
    return merged


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--config", required=True,
                    help="JSON array of {key, value, since, label} configuration entries")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    args = ap.parse_args()

    entries = json.loads(open(args.config, encoding="utf-8").read())
    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s (channels:read and groups:read are enough)", args.token_env)
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})

    channels = list_channels(s)
    by_id = {c.get("id"): c for c in channels}
    by_name = {}
    for c in channels:
        if not c.get("is_archived"):
            by_name.setdefault(str(c.get("name") or "").lower(), c)
    log.info("inventory: %d channel(s) the token can enumerate", len(channels))

    rows = []
    for entry in entries:
        value = str(entry.get("value") or "").strip()
        channel = by_id.get(value) or by_name.get(value.lstrip("#").lower())
        if channel is not None:
            channel = with_previous_names(s, channel)
        state, detail = drift_verdict(entry, channel)
        rows.append((severity(state), state, entry, channel, detail))

    rows.sort(key=lambda r: (RANK.index(r[0]), str(r[2].get("key"))))

    bad = 0
    for level, state, entry, channel, detail in rows:
        line = "%-19s %-15s %-15s %s" % (level, state, entry.get("key"), detail)
        if level == "ok":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if channel is not None:
            log.warning("  repair: %s", config_line(entry, channel))
        else:
            log.warning("  repair: find the channel in Slack, copy its ID from the "
                        "details panel, and store that in %s", entry.get("key"))
        if state == "name-recycled":
            log.warning("  repair: check what has been posted into %s since %s "
                        "before you repoint anything",
                        channel.get("id"), _day(channel.get("created")))

    log.info("%d entry(ies) checked, %d drifting", len(entries), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
slack-channel-name-drift.mjs
/**
 * Find Slack channel references that have drifted since they were configured.
 *
 * Read only. One paginated conversations.list plus one conversations.info per
 * resolved entry, GET only. Nothing is renamed and nothing is posted: the script
 * reports the drift and prints the config line that ends it.
 */
import { readFile } from 'node:fs/promises';

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

const ID_RE = /^[CG][A-Z0-9]{7,}$/;

// A recycled name delivers successfully to the wrong audience, so it outranks an
// outage: the outage is already known to everyone whose alerts stopped, and this
// is known to nobody.
const SEVERITY = {
  'name-recycled': 'silent-misdelivery',
  'name-gone': 'outage',
  'id-unresolved': 'outage',
  'rename-prone': 'warning',
  'name-unpinned': 'warning',
  'label-drifted': 'warning',
  pinned: 'ok',
};
const RANK = ['silent-misdelivery', 'outage', 'warning', 'ok'];

/** How loudly one drift state deserves to be read. Pure. */
export function severity(state) {
  return SEVERITY[state] ?? 'warning';
}

function epoch(value) {
  if (value === null || value === undefined || value === '') return null;
  const stamp = Date.parse(String(value));
  return Number.isNaN(stamp) ? null : stamp / 1000;
}

function day(seconds) {
  return new Date(Number(seconds) * 1000).toISOString().slice(0, 10);
}

/**
 * Compare one configured reference against what it resolves to today. Pure.
 * The comparison that matters is between Slack's channel.created and the age of
 * your own configuration: a name younger than the config line is a name that was
 * released by a rename and claimed by somebody else.
 */
export function driftVerdict(entry, channel) {
  const value = String(entry.value ?? '').trim();
  const label = String(entry.label ?? '').replace(/^#+/, '').trim().toLowerCase();
  const since = epoch(entry.since);

  if (ID_RE.test(value)) {
    if (!channel) {
      return ['id-unresolved',
        'the stored ID resolves to nothing this token can see. The channel was ' +
        'deleted, or converted to private and the scope for it is missing. IDs do ' +
        'not change, so this is not a rename.'];
    }
    const current = String(channel.name ?? '').toLowerCase();
    if (!label) {
      return ['pinned',
        'an ID with no label beside it. Nothing can drift, and nothing tells the ' +
        'next reader which room this is either.'];
    }
    if (label !== current) {
      return ['label-drifted',
        `the ID still points at the right room and the label beside it says #${label} ` +
        `while Slack says #${current}. Warn, never fail: the traffic is fine and the ` +
        'runbook is wrong.'];
    }
    return ['pinned', `an ID with a label that still matches #${current}`];
  }

  if (!channel) {
    return ['name-gone',
      `no channel answers to ${value} any more. It was renamed or deleted, and every ` +
      'call using this value has failed since the day that happened.'];
  }

  const created = Number(channel.created);
  if (since && Number.isFinite(created) && created > since) {
    return ['name-recycled',
      `${value} resolves to ${channel.id}, created ${day(created)}, which is after ` +
      'this configuration was written. The name was released by a rename and claimed ' +
      'by a different channel. Nothing errors: the messages are being delivered, into ' +
      'a room nobody chose.'];
  }

  const previous = (channel.previous_names ?? []).filter(Boolean).map(String);
  if (previous.length > 0) {
    return ['rename-prone',
      `${value} resolves today, and this channel has already answered to ` +
      `${previous.slice(0, 3).map((n) => `#${n}`).join(', ')}. The name is a moving ` +
      'target that has simply not moved again yet.'];
  }

  return ['name-unpinned',
    `${value} resolves to ${channel.id} and has no recorded rename. Correct today, ` +
    'and one rename away from either of the findings above.'];
}

/** The replacement line: ID as the value, name demoted to a comment. Pure. */
export function configLine(entry, channel) {
  return `${entry.key}=${channel.id}  # #${channel.name} -- store the ID, the name is a label`;
}

async function get(token, method, params) {
  const res = await fetch(`${API}${method}?${new URLSearchParams(params)}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  return res.json();
}

async function listChannels(token) {
  const out = [];
  let cursor = '';
  for (;;) {
    const params = {
      types: 'public_channel,private_channel',
      exclude_archived: 'false',
      limit: '1000',
    };
    if (cursor) params.cursor = cursor;
    const body = await get(token, 'conversations.list', params);
    if (body.ok !== true) {
      throw new Error(`conversations.list answered 200 with ok: false, error=${body.error}`);
    }
    out.push(...(body.channels ?? []));
    cursor = body.response_metadata?.next_cursor ?? '';
    if (!cursor) return out;
  }
}

async function withPreviousNames(token, channel) {
  const body = await get(token, 'conversations.info', { channel: channel.id });
  if (body.ok !== true) return channel;
  return { ...channel, ...(body.channel ?? {}) };
}

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

async function main() {
  const args = process.argv.slice(2);
  const configPath = arg(args, '--config');
  if (!configPath) {
    console.error('usage: --config channels.json [--token-env SLACK_BOT_TOKEN]');
    process.exitCode = 2;
    return;
  }
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} (channels:read and groups:read are enough)`);
    process.exitCode = 2;
    return;
  }

  const entries = JSON.parse(await readFile(configPath, 'utf8'));
  const channels = await listChannels(token);
  const byId = new Map(channels.map((c) => [c.id, c]));
  const byName = new Map();
  for (const c of channels) {
    const key = String(c.name ?? '').toLowerCase();
    if (!c.is_archived && !byName.has(key)) byName.set(key, c);
  }
  console.log(`inventory: ${channels.length} channel(s) the token can enumerate`);

  const rows = [];
  for (const entry of entries) {
    const value = String(entry.value ?? '').trim();
    let channel = byId.get(value) ?? byName.get(value.replace(/^#+/, '').toLowerCase()) ?? null;
    if (channel) channel = await withPreviousNames(token, channel);
    const [state, detail] = driftVerdict(entry, channel);
    rows.push([severity(state), state, entry, channel, detail]);
  }

  rows.sort((a, b) => RANK.indexOf(a[0]) - RANK.indexOf(b[0])
    || String(a[2].key).localeCompare(String(b[2].key)));

  let bad = 0;
  for (const [level, state, entry, channel, detail] of rows) {
    const line = `${level.padEnd(19)} ${state.padEnd(15)} ` +
                 `${String(entry.key).padEnd(15)} ${detail}`;
    if (level === 'ok') {
      console.log(line);
      continue;
    }
    bad += 1;
    console.warn(line);
    if (channel) {
      console.warn(`  repair: ${configLine(entry, channel)}`);
    } else {
      console.warn('  repair: find the channel in Slack, copy its ID from the details ' +
                   `panel, and store that in ${entry.key}`);
    }
    if (state === 'name-recycled') {
      console.warn(`  repair: check what has been posted into ${channel.id} since ` +
                   `${day(channel.created)} before you repoint anything`);
    }
  }

  console.log(`${entries.length} entry(ies) checked, ${bad} drifting`);
  process.exitCode = bad ? 1 : 0;
}

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

Add a test

Two entries with identical stored values and identical resolutions have to reach opposite verdicts depending on one number: whether the channel is older or younger than the configuration that names it. That pair is the suite's centre of gravity. The other assertion worth having is on severity, which has to rank the silent misdelivery above the outage — get that backwards and the report buries the only finding nobody already knows about.

test_slack_channel_name_drift.py
from slack_channel_name_drift import config_line, drift_verdict, severity

OLD = {"id": "C01ABCDE9", "name": "ops-alerts", "created": 1600000000}
NEW = {"id": "C07FRESH1", "name": "ops-alerts", "created": 1767225600}
SINCE = "2024-03-01T00:00:00Z"


def test_a_name_that_resolves_to_nothing_is_the_loud_failure():
    state, detail = drift_verdict(
        {"key": "ALERTS", "value": "#ops-alerts", "since": SINCE}, None)
    assert state == "name-gone"
    assert "renamed or deleted" in detail


def test_a_name_on_a_channel_younger_than_the_config_is_the_quiet_one():
    state, detail = drift_verdict(
        {"key": "ALERTS", "value": "#ops-alerts", "since": SINCE}, NEW)
    assert state == "name-recycled"
    assert "2026-01-01" in detail
    assert "nobody chose" in detail


def test_the_same_name_on_an_older_channel_is_not_a_recycling():
    state, _ = drift_verdict(
        {"key": "ALERTS", "value": "#ops-alerts", "since": SINCE}, OLD)
    assert state == "name-unpinned"


def test_a_rename_history_is_reported_even_when_the_name_resolves():
    channel = dict(OLD, previous_names=["ops-alerts-legacy"])
    state, detail = drift_verdict(
        {"key": "ALERTS", "value": "#ops-alerts", "since": SINCE}, channel)
    assert state == "rename-prone"
    assert "#ops-alerts-legacy" in detail


def test_a_missing_since_cannot_produce_a_recycling_verdict():
    state, _ = drift_verdict({"key": "ALERTS", "value": "#ops-alerts"}, NEW)
    assert state == "name-unpinned"


def test_a_stored_id_with_a_stale_label_is_a_warning_not_a_failure():
    entry = {"key": "ALERTS", "value": "C01ABCDE9", "label": "#platform-alerts"}
    state, detail = drift_verdict(entry, OLD)
    assert state == "label-drifted"
    assert "runbook is wrong" in detail
    assert severity(state) == "warning"


def test_a_stored_id_with_a_current_label_is_pinned():
    entry = {"key": "ALERTS", "value": "C01ABCDE9", "label": "ops-alerts"}
    assert drift_verdict(entry, OLD)[0] == "pinned"
    assert severity("pinned") == "ok"


def test_an_id_that_resolves_to_nothing_is_not_called_a_rename():
    entry = {"key": "ALERTS", "value": "C01ABCDE9", "label": "ops-alerts"}
    state, detail = drift_verdict(entry, None)
    assert state == "id-unresolved"
    assert "not a rename" in detail


def test_the_silent_misdelivery_outranks_the_outage():
    assert severity("name-recycled") == "silent-misdelivery"
    assert severity("name-gone") == "outage"


def test_an_unknown_state_is_never_silently_treated_as_healthy():
    assert severity("something-new") == "warning"


def test_the_repair_line_stores_the_id_and_demotes_the_name():
    line = config_line({"key": "ALERTS"}, OLD)
    assert line.startswith("ALERTS=C01ABCDE9")
    assert "#ops-alerts" in line
slack-channel-name-drift.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { configLine, driftVerdict, severity } from './slack-channel-name-drift.mjs';

const OLD = { id: 'C01ABCDE9', name: 'ops-alerts', created: 1600000000 };
const NEW = { id: 'C07FRESH1', name: 'ops-alerts', created: 1767225600 };
const SINCE = '2024-03-01T00:00:00Z';

test('a name that resolves to nothing is the loud failure', () => {
  const [state, detail] = driftVerdict(
    { key: 'ALERTS', value: '#ops-alerts', since: SINCE }, null);
  assert.equal(state, 'name-gone');
  assert.match(detail, /renamed or deleted/);
});

test('a name on a channel younger than the config is the quiet one', () => {
  const [state, detail] = driftVerdict(
    { key: 'ALERTS', value: '#ops-alerts', since: SINCE }, NEW);
  assert.equal(state, 'name-recycled');
  assert.match(detail, /2026-01-01/);
  assert.match(detail, /nobody chose/);
});

test('the same name on an older channel is not a recycling', () => {
  const [state] = driftVerdict(
    { key: 'ALERTS', value: '#ops-alerts', since: SINCE }, OLD);
  assert.equal(state, 'name-unpinned');
});

test('a rename history is reported even when the name resolves', () => {
  const channel = { ...OLD, previous_names: ['ops-alerts-legacy'] };
  const [state, detail] = driftVerdict(
    { key: 'ALERTS', value: '#ops-alerts', since: SINCE }, channel);
  assert.equal(state, 'rename-prone');
  assert.match(detail, /#ops-alerts-legacy/);
});

test('a missing since cannot produce a recycling verdict', () => {
  const [state] = driftVerdict({ key: 'ALERTS', value: '#ops-alerts' }, NEW);
  assert.equal(state, 'name-unpinned');
});

test('a stored id with a stale label is a warning not a failure', () => {
  const entry = { key: 'ALERTS', value: 'C01ABCDE9', label: '#platform-alerts' };
  const [state, detail] = driftVerdict(entry, OLD);
  assert.equal(state, 'label-drifted');
  assert.match(detail, /runbook is wrong/);
  assert.equal(severity(state), 'warning');
});

test('a stored id with a current label is pinned', () => {
  const entry = { key: 'ALERTS', value: 'C01ABCDE9', label: 'ops-alerts' };
  assert.equal(driftVerdict(entry, OLD)[0], 'pinned');
  assert.equal(severity('pinned'), 'ok');
});

test('an id that resolves to nothing is not called a rename', () => {
  const entry = { key: 'ALERTS', value: 'C01ABCDE9', label: 'ops-alerts' };
  const [state, detail] = driftVerdict(entry, null);
  assert.equal(state, 'id-unresolved');
  assert.match(detail, /not a rename/);
});

test('the silent misdelivery outranks the outage', () => {
  assert.equal(severity('name-recycled'), 'silent-misdelivery');
  assert.equal(severity('name-gone'), 'outage');
});

test('an unknown state is never silently treated as healthy', () => {
  assert.equal(severity('something-new'), 'warning');
});

test('the repair line stores the id and demotes the name', () => {
  const line = configLine({ key: 'ALERTS' }, OLD);
  assert.ok(line.startsWith('ALERTS=C01ABCDE9'));
  assert.match(line, /#ops-alerts/);
});

FAQ

How do I know when my config line was written?

git log -1 on the line is close enough, and a single date for the whole file is usually close enough too. The check only needs to distinguish a channel created years before your integration from one created last month, so an approximate since value works. If you genuinely have no date, use the day the service was first deployed and the check stays useful.

Is previous_names always returned?

It comes back from conversations.info on some plans and not on others, so it is not something to build the only detection on. That is why the script treats it as corroboration rather than as the primary signal: the creation timestamp comparison works everywhere, and previous_names upgrades a suspicion into a confirmed rename history when the field is there.

Does renaming a channel change its ID?

No. The ID is assigned at creation and never changes, through renames, through a conversion from public to private, and through archiving. That single property is what makes storing the ID the actual fix rather than a workaround. The name is a label attached to the ID, and Slack has never promised it stays put.

Can somebody really create a new channel with my old channel's name?

Yes, once the original has been renamed or archived and the name is released. It is not a rare edge case either: names are released precisely because they describe something a team wants to keep talking about, so the same string tends to get claimed again. That is the whole mechanism behind the silent misdelivery this note ranks first.

Should the startup check fail the deploy when a name has drifted?

Only for the states where delivery is affected. A stored ID whose human label is stale is a documentation bug and should warn, because the traffic is going exactly where it should. A stored name that no longer resolves, or that resolves to a channel younger than your configuration, should fail loudly at boot rather than at 3am on the first alert of the incident.

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.