Skip to content

Diagnostic Slack

cannot_reply_to_message: the parent stopped taking replies

The deploy bot has threaded every status update under the same parent message for three weeks. This morning it returned {"ok": false, "error": "cannot_reply_to_message"} for every update, and the channel is fine, the bot is in it, and the token is valid.

Somebody deleted the parent yesterday while cleaning up. Slack left a tombstone in its place, which holds the position in the channel and cannot hold a reply. Nothing in your code changed; the thing your code was pointing at did.

Read-only token Python and Node.js Tests included
A calculator sitting on top of a table next to another calculator
Photo by iSawRed on Unsplash
The short answer

cannot_reply_to_message means the specific message you passed as thread_ts cannot host a reply. The usual reasons are that it was deleted and Slack left a tombstone where it was, that an administrator locked the thread (which also surfaces as restricted_action_thread_locked), or that the message is one of the subtypes - joins and leaves among them - that never hosted threads.

This is about one message, not about the channel. A channel that forbids top-level posts or forbids threading entirely is a different problem with a different repair, and it applies to every message in the channel rather than to the one you stored.

The whole state is readable: conversations.replies with limit=1 against the stored thread_ts answers with the parent, or with an error that names why it cannot. So the check is one read per stored parent, run before the reply rather than after the refusal - and no reply is ever sent, because a reply is a write and a script that tests whether a thread accepts replies by posting one has already posted one.

The problem in plain words

The mechanism is that a ts is a reference to something you do not control. It goes into a config file, a database column or an environment variable, and from that moment it is a pointer to a message any member of the channel can delete, any administrator can lock, and any retention policy can remove. Nothing notifies you. The reference simply stops resolving, weeks after anybody touched the code that wrote it.

Deletion is the common one and it is the least obvious, because the message does not vanish from the channel. Slack leaves a tombstone: the position stays, the content is replaced, and to a reader scrolling past it is clearly "this message was deleted". To an integration, it is a ts that still exists, still returns from a read, and refuses every reply.

Locking is rarer and reads completely differently in the logs. An administrator locks a thread on a heated incident, and now every automated update to that incident is refused. The repair is not in your code and not in your reach: it needs a person with the permission to unlock it, or a new parent. Reporting that in the same bucket as a deleted message sends somebody to the wrong place.

And the whole class is silent until it fires. A parent that has worked for three weeks proves nothing about the fourth. The only thing that separates a healthy pointer from a broken one is a read, and the read is cheap: one call, one message, one answer.

A ts iscapturedand stored as theparentWeeks ofrepliesall landingcorrectlyThe parent isdeleteda tombstone isleftcannot_reply_to_messageevery reply, fromnow onThe thread isorphanedand the alertsstop
A ts captured once and threaded under forever is a reference to something somebody else can delete, lock or archive without ever knowing your app was pointing at it.

Why it happens

The state belongs to the parent, not to the channel. Everything this script reports is about one message: is it there, is it a tombstone, is it locked, is it a type that can host a thread. The channel's own threading rules are a separate question with a separate answer, and mixing them produces a report where nobody can tell which of the two to fix.

A read answers the whole question, so nothing needs to be sent. conversations.replies with a limit of one returns the parent or the reason it cannot. Testing this by replying puts a message under somebody's incident thread, and if the parent turns out to be fine, it stays there.

Gone, locked and unreadable want three different people. A missing parent needs the code to establish a new one. A locked thread needs an administrator. An error like not_in_channel or missing_scope is the channel refusing the read and says nothing about the parent at all, so it is reported as unknown rather than as broken.

The subtype table is deliberately short. Slack adds message subtypes. A subtype this script does not recognise is reported as unknown and never counted as a fault, because an audit that fails the day somebody upgrades a client is an audit that gets switched off within a week.

Age is the mechanism, so age is reported. Nothing about a parent changes when it is created and everything can change while it sits in a config file. A parent that has been the parent for forty days is not broken, and it is the shape of the thing that breaks, so the script says so without calling it a failure.

A reply used as a parent is somebody else's note. If the stored ts turns out to be a reply, this script hands it over rather than absorbing it, because that fault has no error attached and a completely different repair.

The fix, as a flow

A chain about a message that stopped being able to host a reply while nothing about your code changed. The parent is deleted three weeks after the ts was written into a config file, and the red box is the deletion rather than the reply. The fix branch is a sort by who has to act next, because a parent that is gone needs a new parent, a locked one needs a person, and an unreadable one needs a scope.

One read per stored parentlimit of one, nothing sentDeleted, a tombstone leftestablish a new parentGone entirelythread_not_foundLocked by an adminneeds a person, not a retryUnreadable from herea scope answer, not a thread onePresent and a rootstill takes a reply
conversations.replies with a limit of one answers the whole question without sending anything. The states it returns want four different people, and reporting them as one count sends the wrong one.

How to fix it

Collect the parents your app actually threads under

Every thread_ts in a config file, a database column or an environment variable. Pass them as --parent CHANNEL:THREAD_TS or in a JSON file. This list is usually shorter than people expect and longer than the one person who remembers it.

Read each one once, with a limit of one

conversations.replies?channel=...&ts=...&limit=1 is the whole check. It returns the parent message or an error naming why it cannot. No reply is sent, which matters when the parent turns out to be a live incident thread.

Read the tombstone as a deletion rather than a gap

A deleted parent does not disappear. It comes back with subtype: "tombstone", holding its position in the channel and unable to hold a reply. parent_state names it, because "the message is there and refuses replies" is otherwise a confusing sentence.

Keep a locked thread out of the broken bucket

restricted_action_thread_locked means an administrator closed the thread. The parent is healthy and the refusal is deliberate. This one needs a person rather than a retry, and putting it beside a deleted parent in the report loses that.

Report an unreadable channel as unknown, not as broken

not_in_channel, missing_scope and channel_not_found are the channel refusing the read. The parent's state is unknown, not bad. A checker that reports those as faults is one nobody trusts on private channels.

Give the stored parent a lifetime

stale_root reports how long each ts has been the parent. Nothing is wrong with an old parent and everything that goes wrong here goes wrong to old parents, so store one with an expiry and establish a new one rather than holding a pointer forever.

How to check it worked

Establish a new parent, run the check again, and every row should come back usable before a single reply is attempted.

python3 slack_thread_parent.py --parent C01ABCDE9:1735689600.000100
# identity   U0APPBOT11 in acme
# parent     tombstone        C01ABCDE9:17356...  the parent was deleted and Slack left a
#                             tombstone; a tombstone holds the position in the channel
#                             and cannot hold a reply
# age        aging            C01ABCDE9:17356... has been the parent for 21.4 day(s)
# roll call  1 parent(s): 0 usable, 1 broken, 0 unreadable
#   repair: read the parent with conversations.replies before you thread under it, and
#           post at the top level when the read fails

The full code

Three pure functions and one read per parent. parent_state turns a single conversations.replies answer into what a reply would do, and its job is mostly restraint: an unrecognised subtype is unknown, an unreadable channel is unknown, and a reply used as a root is handed to the note next door. stale_root reports the age of the pointer, because age is the mechanism here rather than a symptom. roll_call sorts the results by who has to act on them, which is the ordering that decides whether anybody does.

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_thread_parent.py
"""Check every thread_ts your app still replies under before it replies again.

Read only. conversations.replies with limit 1 per stored parent, and nothing
else: a reply is a write, and a script that finds out whether a thread accepts
replies by posting one has already put a test message under somebody's incident.
The parent either answers as a usable root or it does not, and both answers come
back from a read.
"""
import argparse
import json
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_thread_parent")

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

# The parent is not there any more. Both spellings turn up depending on how the
# message went away, and they mean the same thing for a reply.
GONE = {"thread_not_found", "message_not_found"}
# The channel is refusing the read for a reason that has nothing to do with the
# parent, so the parent's state is unknown rather than bad. Reporting these as
# faults is how a checker starts crying wolf on private channels.
UNREADABLE = {"channel_not_found", "not_in_channel", "missing_scope",
              "not_allowed_token_type", "invalid_auth", "access_denied"}
# The thread exists and is closed to new replies by an administrator.
LOCKED = {"restricted_action_thread_locked"}

# Subtypes that cannot host a reply. Deliberately short: an unrecognised
# subtype is reported as unknown and never as a fault, because a checker that
# fails on a subtype Slack added last month is a checker that gets switched off.
NOT_THREADABLE = {"tombstone", "channel_join", "channel_leave", "group_join",
                  "group_leave"}

# A ts stored this long ago is a ts nobody has looked at. Days, and adjustable,
# because the right number is however long your parent is expected to live.
DEFAULT_TTL_DAYS = 7


def parent_state(error="", parent=None):
    """Turn one conversations.replies answer into what a reply would do. Pure.

    Returns (state, detail). The states are gone, locked, tombstone,
    not-threadable, not-a-root, unreadable, unknown-subtype and usable, and
    only the first four are this note's fault. not-a-root belongs to the note
    next door and is handed over rather than absorbed.
    """
    error = (error or "").strip()
    if error:
        if error in GONE:
            return ("gone", "conversations.replies answers %s: the parent is no longer "
                            "there, so cannot_reply_to_message is what a reply gets"
                            % error)
        if error in LOCKED:
            return ("locked", "the thread is locked by an administrator; replies are "
                              "refused for everyone, including your app")
        if error in UNREADABLE:
            return ("unreadable", "%s: the channel is refusing the read, so the "
                                  "parent's state is unknown rather than bad" % error)
        return ("unknown-error", "%s, which this table does not have a rule for; read "
                                 "it before treating the parent as broken" % error)

    if not isinstance(parent, dict) or not parent.get("ts"):
        return ("gone", "the reply list came back with no parent message in it")

    subtype = parent.get("subtype") or ""
    if subtype == "tombstone":
        return ("tombstone", "the parent was deleted and Slack left a tombstone; a "
                             "tombstone holds the position in the channel and cannot "
                             "hold a reply")
    if subtype in NOT_THREADABLE:
        return ("not-threadable", "the parent is a %s message, which is one of the "
                                  "types that cannot host a thread" % subtype)

    thread_ts = parent.get("thread_ts")
    if thread_ts and thread_ts != parent.get("ts"):
        return ("not-a-root", "this ts is a reply inside thread %s rather than a "
                              "thread root; Slack will accept the reply and move it, "
                              "which is a different problem" % thread_ts)
    if subtype:
        return ("unknown-subtype", "subtype %r is not in this table; the parent looks "
                                   "usable and the subtype is reported rather than "
                                   "judged" % subtype)
    return ("usable", "the parent is present, is a root, and can take a reply")


def stale_root(thread_ts, now=None, ttl_days=DEFAULT_TTL_DAYS):
    """How long the app has been threading under the same parent. Pure.

    Age is the mechanism rather than a symptom: nothing about a parent changes
    when it is created, and everything can change while it sits in a config
    file. Returns (state, age_days).
    """
    try:
        created = float(str(thread_ts).split(".")[0])
    except (TypeError, ValueError):
        return ("unparseable", -1.0)
    now = float(now if now is not None else time.time())
    exact = (now - created) / 86400.0
    # Rounded for the report and compared unrounded, so a parent one second in
    # the future is not rounded into the present.
    age = round(exact, 2)
    if exact < 0:
        return ("in-the-future", age)
    if exact >= ttl_days * 2:
        return ("stale", age)
    if exact >= ttl_days:
        return ("aging", age)
    return ("fresh", age)


def roll_call(results):
    """Sort a set of stored parents into what to do about each. Pure.

    The ordering matters: a parent that is gone needs a new parent, a locked
    one needs a person, and an unreadable one needs a scope. Reporting them as
    one number sends somebody to fix the wrong thing.
    """
    order = ["gone", "tombstone", "not-threadable", "locked", "not-a-root",
             "unknown-error", "unreadable", "unknown-subtype", "usable"]
    buckets = {}
    for r in results or []:
        buckets.setdefault(r.get("state", "unknown-error"), []).append(r)
    broken = sum(len(buckets.get(k, [])) for k in
                 ("gone", "tombstone", "not-threadable", "locked"))
    return {
        "total": sum(len(v) for v in buckets.values()),
        "broken": broken,
        "usable": len(buckets.get("usable", [])),
        "ambiguous": len(buckets.get("unreadable", [])),
        "order": [k for k in order if k in buckets],
        "buckets": buckets,
    }


def _stored(args):
    """The parents to check: from a file, or from repeated --parent flags."""
    out = []
    if args.parents:
        body = json.loads(open(args.parents, encoding="utf-8").read())
        for label, value in (body.items() if isinstance(body, dict)
                             else enumerate(body)):
            if isinstance(value, dict):
                out.append((str(label), value.get("channel"), value.get("thread_ts")))
            else:
                out.append((str(label), args.channel, str(value)))
    for pair in args.parent:
        channel, _, ts = pair.partition(":")
        out.append((pair, channel, ts))
    return [(label, c, t) for label, c, t in out if c and t]


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("--parent", action="append", default=[],
                    help="a stored parent as CHANNEL:THREAD_TS; repeatable")
    ap.add_argument("--parents", help="JSON file of stored parents")
    ap.add_argument("--channel", help="default channel for a bare ts in --parents")
    ap.add_argument("--ttl-days", type=int, default=DEFAULT_TTL_DAYS,
                    help="how long a stored parent is expected to stay usable")
    args = ap.parse_args()

    parents = _stored(args)
    if not parents:
        log.error("pass --parent CHANNEL:THREAD_TS or --parents FILE")
        return 2
    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s (channels:history is enough)", 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 in %s", who.get("user_id"), who.get("team"))

    results = []
    for label, channel, thread_ts in parents:
        body = s.get(API + "conversations.replies", timeout=30,
                     params={"channel": channel, "ts": thread_ts, "limit": "1"}).json()
        error = "" if body.get("ok") is True else (body.get("error") or "unknown")
        messages = body.get("messages") or []
        state, detail = parent_state(error, messages[0] if messages else None)
        age_state, age = stale_root(thread_ts, ttl_days=args.ttl_days)
        results.append({"label": label, "channel": channel, "ts": thread_ts,
                        "state": state, "detail": detail, "age": age,
                        "age_state": age_state})
        (log.info if state in ("usable", "unknown-subtype") else log.warning)(
            "parent     %-16s %-16s %s", state, label, detail)
        if age_state in ("aging", "stale"):
            log.warning("age        %-16s %s has been the parent for %.1f day(s)",
                        age_state, label, age)

    r = roll_call(results)
    log.info("roll call  %d parent(s): %d usable, %d broken, %d unreadable",
             r["total"], r["usable"], r["broken"], r["ambiguous"])
    if r["broken"]:
        log.warning("  repair: read the parent with conversations.replies before you "
                    "thread under it, and post at the top level when the read fails")
        log.warning("  repair: store the parent ts with a lifetime and establish a new "
                    "parent when the old one expires, rather than keeping one forever")
        log.warning("  repair: a locked thread needs a person rather than a retry; the "
                    "lock is an administrator decision and your app cannot lift it")
        return 1
    if r["ambiguous"]:
        log.warning("  note: %d parent(s) could not be read at all, which is a scope or "
                    "membership answer rather than a thread answer", r["ambiguous"])
    log.info("verdict    clear          every stored parent can still take a reply")
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-thread-parent.mjs
/**
 * Check every thread_ts your app still replies under before it replies again.
 *
 * Read only. conversations.replies with limit 1 per stored parent, and nothing
 * else: a reply is a write, and a script that finds out whether a thread
 * accepts replies by posting one has already put a test message under
 * somebody's incident.
 */
import { readFile } from 'node:fs/promises';

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

// The parent is not there any more. Both spellings turn up depending on how
// the message went away, and they mean the same thing for a reply.
const GONE = new Set(['thread_not_found', 'message_not_found']);
// The channel is refusing the read for a reason that has nothing to do with
// the parent, so the parent's state is unknown rather than bad.
const UNREADABLE = new Set(['channel_not_found', 'not_in_channel', 'missing_scope',
  'not_allowed_token_type', 'invalid_auth', 'access_denied']);
// The thread exists and is closed to new replies by an administrator.
const LOCKED = new Set(['restricted_action_thread_locked']);

// Deliberately short: an unrecognised subtype is reported as unknown and never
// as a fault, because a checker that fails on a subtype Slack added last month
// is a checker that gets switched off.
const NOT_THREADABLE = new Set(['tombstone', 'channel_join', 'channel_leave',
  'group_join', 'group_leave']);

export const DEFAULT_TTL_DAYS = 7;

/**
 * Turn one conversations.replies answer into what a reply would do. Pure.
 * Returns [state, detail]. not-a-root belongs to the note next door and is
 * handed over rather than absorbed.
 */
export function parentState(error = '', parent = null) {
  const err = String(error ?? '').trim();
  if (err) {
    if (GONE.has(err)) {
      return ['gone', `conversations.replies answers ${err}: the parent is no longer `
        + 'there, so cannot_reply_to_message is what a reply gets'];
    }
    if (LOCKED.has(err)) {
      return ['locked', 'the thread is locked by an administrator; replies are refused '
        + 'for everyone, including your app'];
    }
    if (UNREADABLE.has(err)) {
      return ['unreadable', `${err}: the channel is refusing the read, so the parent's `
        + 'state is unknown rather than bad'];
    }
    return ['unknown-error', `${err}, which this table does not have a rule for; read `
      + 'it before treating the parent as broken'];
  }

  if (!parent || typeof parent !== 'object' || !parent.ts) {
    return ['gone', 'the reply list came back with no parent message in it'];
  }

  const subtype = parent.subtype ?? '';
  if (subtype === 'tombstone') {
    return ['tombstone', 'the parent was deleted and Slack left a tombstone; a '
      + 'tombstone holds the position in the channel and cannot hold a reply'];
  }
  if (NOT_THREADABLE.has(subtype)) {
    return ['not-threadable', `the parent is a ${subtype} message, which is one of the `
      + 'types that cannot host a thread'];
  }
  if (parent.thread_ts && parent.thread_ts !== parent.ts) {
    return ['not-a-root', `this ts is a reply inside thread ${parent.thread_ts} rather `
      + 'than a thread root; Slack will accept the reply and move it, which is a '
      + 'different problem'];
  }
  if (subtype) {
    return ['unknown-subtype', `subtype "${subtype}" is not in this table; the parent `
      + 'looks usable and the subtype is reported rather than judged'];
  }
  return ['usable', 'the parent is present, is a root, and can take a reply'];
}

/**
 * How long the app has been threading under the same parent. Pure.
 * Age is the mechanism rather than a symptom: nothing about a parent changes
 * when it is created, and everything can change while it sits in a config file.
 */
export function staleRoot(threadTs, now = null, ttlDays = DEFAULT_TTL_DAYS) {
  const created = Number(String(threadTs ?? '').split('.')[0]);
  if (!Number.isFinite(created) || String(threadTs ?? '').trim() === '') {
    return ['unparseable', -1];
  }
  const at = Number(now ?? Date.now() / 1000);
  const exact = (at - created) / 86400;
  // Rounded for the report and compared unrounded, so a parent one second in
  // the future is not rounded into the present.
  const age = Math.round(exact * 100) / 100;
  if (exact < 0) return ['in-the-future', age];
  if (exact >= ttlDays * 2) return ['stale', age];
  if (exact >= ttlDays) return ['aging', age];
  return ['fresh', age];
}

/**
 * Sort a set of stored parents into what to do about each. Pure.
 * A parent that is gone needs a new parent, a locked one needs a person, and
 * an unreadable one needs a scope; one number sends somebody to the wrong fix.
 */
export function rollCall(results) {
  const order = ['gone', 'tombstone', 'not-threadable', 'locked', 'not-a-root',
    'unknown-error', 'unreadable', 'unknown-subtype', 'usable'];
  const buckets = new Map();
  for (const r of results ?? []) {
    const key = r.state ?? 'unknown-error';
    if (!buckets.has(key)) buckets.set(key, []);
    buckets.get(key).push(r);
  }
  const size = (k) => (buckets.get(k) ?? []).length;
  const broken = size('gone') + size('tombstone') + size('not-threadable')
    + size('locked');
  let total = 0;
  for (const v of buckets.values()) total += v.length;
  return {
    total,
    broken,
    usable: size('usable'),
    ambiguous: size('unreadable'),
    order: order.filter((k) => buckets.has(k)),
    buckets,
  };
}

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

function argAll(args, name) {
  const out = [];
  for (let i = 0; i < args.length; i += 1) {
    if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
  }
  return out;
}

async function stored(args) {
  const out = [];
  const file = arg(args, '--parents');
  const fallbackChannel = arg(args, '--channel');
  if (file) {
    const body = JSON.parse(await readFile(file, 'utf8'));
    const entries = Array.isArray(body) ? body.entries() : Object.entries(body);
    for (const [label, value] of entries) {
      if (value && typeof value === 'object') {
        out.push([String(label), value.channel, value.thread_ts]);
      } else {
        out.push([String(label), fallbackChannel, String(value)]);
      }
    }
  }
  for (const pair of argAll(args, '--parent')) {
    const i = pair.indexOf(':');
    out.push([pair, i === -1 ? '' : pair.slice(0, i), i === -1 ? '' : pair.slice(i + 1)]);
  }
  return out.filter(([, c, t]) => c && t);
}

async function main() {
  const args = process.argv.slice(2);
  const parents = await stored(args);
  if (!parents.length) {
    console.error('pass --parent CHANNEL:THREAD_TS or --parents FILE');
    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:history is enough)`);
    process.exitCode = 2;
    return;
  }
  const ttlDays = Number(arg(args, '--ttl-days', String(DEFAULT_TTL_DAYS)));
  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} in ${who.team}`);

  const results = [];
  for (const [label, channel, threadTs] of parents) {
    const url = `${API}conversations.replies?channel=${encodeURIComponent(channel)}`
      + `&ts=${encodeURIComponent(threadTs)}&limit=1`;
    const body = await (await fetch(url, { headers })).json();
    const error = body.ok === true ? '' : (body.error ?? 'unknown');
    const messages = body.messages ?? [];
    const [state, detail] = parentState(error, messages[0] ?? null);
    const [ageState, age] = staleRoot(threadTs, null, ttlDays);
    results.push({ label, channel, ts: threadTs, state, detail, age, ageState });
    (['usable', 'unknown-subtype'].includes(state) ? console.log : console.warn)(
      `parent     ${state.padEnd(16)} ${label.padEnd(16)} ${detail}`);
    if (['aging', 'stale'].includes(ageState)) {
      console.warn(`age        ${ageState.padEnd(16)} ${label} has been the parent for `
        + `${age.toFixed(1)} day(s)`);
    }
  }

  const r = rollCall(results);
  console.log(`roll call  ${r.total} parent(s): ${r.usable} usable, ${r.broken} broken, `
    + `${r.ambiguous} unreadable`);
  if (r.broken) {
    console.warn('  repair: read the parent with conversations.replies before you '
      + 'thread under it, and post at the top level when the read fails');
    console.warn('  repair: store the parent ts with a lifetime and establish a new '
      + 'parent when the old one expires, rather than keeping one forever');
    console.warn('  repair: a locked thread needs a person rather than a retry; the '
      + 'lock is an administrator decision and your app cannot lift it');
    process.exitCode = 1;
    return;
  }
  if (r.ambiguous) {
    console.warn(`  note: ${r.ambiguous} parent(s) could not be read at all, which is a `
      + 'scope or membership answer rather than a thread answer');
  }
  console.log('verdict    clear          every stored parent can still take a reply');
}

// 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

The assertions are mostly about what the script refuses to call a fault. An unrecognised subtype and an unrecognised error both have to come back reported rather than failed, because this table will be out of date the moment Slack adds something. A channel that refuses the read has to leave the parent unknown rather than broken, since that error is about the token and not about the thread. And a stored ts that turns out to be a reply has to be handed to the neighbouring note by name, because absorbing it would mean reporting a silent reparenting as a refusal that never happened.

test_slack_thread_parent.py
from slack_thread_parent import parent_state, roll_call, stale_root

NOW = 1700000000.0
ROOT = {"ts": "1699999000.000100", "text": "deploy started"}


def test_a_present_root_can_take_a_reply():
    state, detail = parent_state("", ROOT)
    assert state == "usable"
    assert "can take a reply" in detail
    assert parent_state("", {"ts": "1.0", "thread_ts": "1.0"})[0] == "usable"


def test_a_parent_that_is_gone_is_reported_as_the_error_a_reply_would_get():
    for error in ("thread_not_found", "message_not_found"):
        state, detail = parent_state(error)
        assert state == "gone"
        assert "cannot_reply_to_message" in detail
        assert error in detail


def test_an_empty_reply_list_is_the_same_finding_as_a_missing_parent():
    state, detail = parent_state("", None)
    assert state == "gone"
    assert "no parent message in it" in detail
    assert parent_state("", {})[0] == "gone"


def test_a_deleted_parent_leaves_a_tombstone_that_cannot_hold_a_reply():
    state, detail = parent_state("", {"ts": "1.0", "subtype": "tombstone"})
    assert state == "tombstone"
    assert "deleted" in detail
    assert "cannot hold a reply" in detail


def test_a_join_message_is_one_of_the_types_that_cannot_host_a_thread():
    state, detail = parent_state("", {"ts": "1.0", "subtype": "channel_join"})
    assert state == "not-threadable"
    assert "channel_join" in detail
    assert parent_state("", {"ts": "1.0", "subtype": "group_leave"})[0] == "not-threadable"


def test_a_locked_thread_is_a_person_problem_rather_than_a_parent_problem():
    state, detail = parent_state("restricted_action_thread_locked")
    assert state == "locked"
    assert "administrator" in detail


def test_a_channel_that_refuses_the_read_leaves_the_parent_unknown():
    for error in ("channel_not_found", "not_in_channel", "missing_scope"):
        state, detail = parent_state(error)
        assert state == "unreadable"
        assert "unknown rather than bad" in detail


def test_a_reply_used_as_a_root_is_handed_to_the_note_next_door():
    state, detail = parent_state("", {"ts": "1699999500.0001",
                                      "thread_ts": "1699999000.0001"})
    assert state == "not-a-root"
    assert "1699999000.0001" in detail
    assert "different problem" in detail


def test_an_unrecognised_subtype_is_reported_and_not_failed():
    state, detail = parent_state("", {"ts": "1.0", "subtype": "some_new_subtype"})
    assert state == "unknown-subtype"
    assert "reported rather than judged" in detail


def test_an_unrecognised_error_is_reported_and_not_failed():
    state, detail = parent_state("something_new")
    assert state == "unknown-error"
    assert "does not have a rule for" in detail


def test_a_parent_stored_a_few_hours_ago_is_fresh():
    state, age = stale_root("1699999000.000100", NOW)
    assert state == "fresh"
    assert 0 <= age < 1


def test_a_parent_a_week_old_is_aging_and_a_fortnight_old_is_stale():
    assert stale_root("1699000000.000100", NOW)[0] == "aging"
    assert stale_root("1698000000.000100", NOW)[0] == "stale"
    assert stale_root("1699000000.000100", NOW, ttl_days=30)[0] == "fresh"


def test_a_ts_that_is_not_a_ts_is_unparseable_rather_than_old():
    assert stale_root("not-a-ts", NOW) == ("unparseable", -1.0)
    assert stale_root(None, NOW)[0] == "unparseable"
    assert stale_root("1700000001.0", NOW)[0] == "in-the-future"


def test_the_roll_call_separates_broken_from_merely_unreadable():
    r = roll_call([
        {"state": "usable"}, {"state": "usable"},
        {"state": "gone"}, {"state": "tombstone"}, {"state": "locked"},
        {"state": "unreadable"}, {"state": "not-a-root"},
    ])
    assert r["total"] == 7
    assert r["usable"] == 2
    assert r["broken"] == 3
    assert r["ambiguous"] == 1
    assert r["order"][0] == "gone"
    assert "usable" in r["order"]


def test_a_roll_call_over_healthy_parents_reports_nothing_broken():
    r = roll_call([{"state": "usable"}, {"state": "unknown-subtype"}])
    assert r["broken"] == 0
    assert r["ambiguous"] == 0
    assert r["total"] == 2


def test_an_empty_roll_call_is_not_a_finding():
    r = roll_call([])
    assert r == {"total": 0, "broken": 0, "usable": 0, "ambiguous": 0,
                 "order": [], "buckets": {}}
slack-thread-parent.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parentState, rollCall, staleRoot } from './slack-thread-parent.mjs';

const NOW = 1700000000;
const ROOT = { ts: '1699999000.000100', text: 'deploy started' };

test('a present root can take a reply', () => {
  const [state, detail] = parentState('', ROOT);
  assert.equal(state, 'usable');
  assert.match(detail, /can take a reply/);
  assert.equal(parentState('', { ts: '1.0', thread_ts: '1.0' })[0], 'usable');
});

test('a parent that is gone is reported as the error a reply would get', () => {
  for (const error of ['thread_not_found', 'message_not_found']) {
    const [state, detail] = parentState(error);
    assert.equal(state, 'gone');
    assert.match(detail, /cannot_reply_to_message/);
    assert.ok(detail.includes(error));
  }
});

test('an empty reply list is the same finding as a missing parent', () => {
  const [state, detail] = parentState('', null);
  assert.equal(state, 'gone');
  assert.match(detail, /no parent message in it/);
  assert.equal(parentState('', {})[0], 'gone');
});

test('a deleted parent leaves a tombstone that cannot hold a reply', () => {
  const [state, detail] = parentState('', { ts: '1.0', subtype: 'tombstone' });
  assert.equal(state, 'tombstone');
  assert.match(detail, /deleted/);
  assert.match(detail, /cannot hold a reply/);
});

test('a join message is one of the types that cannot host a thread', () => {
  const [state, detail] = parentState('', { ts: '1.0', subtype: 'channel_join' });
  assert.equal(state, 'not-threadable');
  assert.match(detail, /channel_join/);
  assert.equal(parentState('', { ts: '1.0', subtype: 'group_leave' })[0],
    'not-threadable');
});

test('a locked thread is a person problem rather than a parent problem', () => {
  const [state, detail] = parentState('restricted_action_thread_locked');
  assert.equal(state, 'locked');
  assert.match(detail, /administrator/);
});

test('a channel that refuses the read leaves the parent unknown', () => {
  for (const error of ['channel_not_found', 'not_in_channel', 'missing_scope']) {
    const [state, detail] = parentState(error);
    assert.equal(state, 'unreadable');
    assert.match(detail, /unknown rather than bad/);
  }
});

test('a reply used as a root is handed to the note next door', () => {
  const [state, detail] = parentState('',
    { ts: '1699999500.0001', thread_ts: '1699999000.0001' });
  assert.equal(state, 'not-a-root');
  assert.match(detail, /1699999000\.0001/);
  assert.match(detail, /different problem/);
});

test('an unrecognised subtype is reported and not failed', () => {
  const [state, detail] = parentState('', { ts: '1.0', subtype: 'some_new_subtype' });
  assert.equal(state, 'unknown-subtype');
  assert.match(detail, /reported rather than judged/);
});

test('an unrecognised error is reported and not failed', () => {
  const [state, detail] = parentState('something_new');
  assert.equal(state, 'unknown-error');
  assert.match(detail, /does not have a rule for/);
});

test('a parent stored a few hours ago is fresh', () => {
  const [state, age] = staleRoot('1699999000.000100', NOW);
  assert.equal(state, 'fresh');
  assert.ok(age >= 0 && age < 1);
});

test('a parent a week old is aging and a fortnight old is stale', () => {
  assert.equal(staleRoot('1699000000.000100', NOW)[0], 'aging');
  assert.equal(staleRoot('1698000000.000100', NOW)[0], 'stale');
  assert.equal(staleRoot('1699000000.000100', NOW, 30)[0], 'fresh');
});

test('a ts that is not a ts is unparseable rather than old', () => {
  assert.deepEqual(staleRoot('not-a-ts', NOW), ['unparseable', -1]);
  assert.equal(staleRoot(null, NOW)[0], 'unparseable');
  assert.equal(staleRoot('1700000001.0', NOW)[0], 'in-the-future');
});

test('the roll call separates broken from merely unreadable', () => {
  const r = rollCall([
    { state: 'usable' }, { state: 'usable' },
    { state: 'gone' }, { state: 'tombstone' }, { state: 'locked' },
    { state: 'unreadable' }, { state: 'not-a-root' },
  ]);
  assert.equal(r.total, 7);
  assert.equal(r.usable, 2);
  assert.equal(r.broken, 3);
  assert.equal(r.ambiguous, 1);
  assert.equal(r.order[0], 'gone');
  assert.ok(r.order.includes('usable'));
});

test('a roll call over healthy parents reports nothing broken', () => {
  const r = rollCall([{ state: 'usable' }, { state: 'unknown-subtype' }]);
  assert.equal(r.broken, 0);
  assert.equal(r.ambiguous, 0);
  assert.equal(r.total, 2);
});

test('an empty roll call is not a finding', () => {
  const r = rollCall([]);
  assert.equal(r.total, 0);
  assert.equal(r.broken, 0);
  assert.deepEqual(r.order, []);
  assert.equal(r.buckets.size, 0);
});

FAQ

What is the difference between this and a channel that refuses threads?

This note is about one message: the specific parent you stored has been deleted, locked, or was never a type that could host a thread. A channel that forbids top-level posts, or forbids threading entirely, is a policy that applies to every message in it and is refused with a different error. The repairs differ too: here you establish a new parent, there you change which posting mode you use for that channel.

Why does the parent still appear in the channel if it was deleted?

Slack leaves a tombstone. The message keeps its position in the conversation and its content is replaced, so a reader sees that something was deleted and an integration sees a ts that still resolves. That is why the read is worth doing: the tombstone is the difference between a parent that is gone and one that looks present and refuses every reply.

Can my app unlock a locked thread and carry on?

No. Locking is an administrator action and lifting it is an administrator action. The correct behaviour for an integration is to stop trying, report that the thread is locked, and post at the top level or under a new parent instead. The script keeps locked threads out of the same bucket as deleted parents precisely because the next step is a person rather than a retry.

How long should I hold on to a thread_ts?

As long as the thing it represents is live, and no longer. A ts stored with an incident should expire with the incident; a ts stored for a daily thread should be replaced daily. The script reports the age of every stored parent because nothing about a parent changes when it is created and everything can change while it sits in a config file for six weeks.

The read came back missing_scope. Is my parent broken?

Unknown, which is a genuinely different answer. missing_scope, not_in_channel and channel_not_found are the channel refusing the read, and a bot without groups:read cannot even distinguish a private channel that does not exist from one it is not allowed to see. The script reports these as unreadable rather than as faults, so the report does not blame a parent it never managed to look at.

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.