Skip to content

Diagnostic Slack

feature_not_enabled: the admin API needs Enterprise

The token is a user token, the human behind it is an org owner, the scopes are exactly right, and admin.teams.list answers {"ok": false, "error": "feature_not_enabled"}. You have now spent an afternoon fixing two things that were never broken.

This customer is on Business+. The admin API is an Enterprise Grid surface, and on a plan that does not include it there is no configuration — no scope, no role, no reinstall — that makes those methods answer. The interesting version of this is not the afternoon; it is the multi-tenant app that ships an admin-powered feature to four hundred installations and discovers on the third day that a hundred and eighty of them cannot use it.

Read-only token Python and Node.js Tests included
Padlocks
Photo by FlyD on Unsplash
The short answer

The admin.* families are Enterprise Grid features. On a workspace that is not part of an Enterprise organization they return feature_not_enabled regardless of who calls them with which scopes, and admin.apps.restricted.list is documented as available only on the Enterprise plan. This is a property of the customer's contract, not of your integration.

The plan itself is not readable. There is no documented read method that returns it: team.info comes back with id, name, domain, email_domain, icon and, on Grid, enterprise_id and enterprise_name, and nothing about billing or tier. So capability has to be measured rather than looked up: a non-null enterprise_id on auth.test is a signal that the workspace sits inside an organization, and a single admin.teams.list?limit=1 probe turns that signal into an answer.

The whole value of the script below is in one rule it refuses to break. feature_not_enabled means the surface is unavailable. not_an_admin, not_allowed_token_type and missing_scope mean the probe never reached the question, and recording any of those as “this customer is not on Enterprise” poisons the matrix with a permanent false negative that no later run corrects, because the feature is now gated off and never probed again.

Then degrade rather than fail. conversations.list for admin.conversations.search, users.list for admin.users.list, team.info for admin.teams.list — each narrower, each real.

The problem in plain words

A single-tenant integration meets this once, spends an afternoon on it and is done. A multi-tenant app meets it as an architecture problem, because availability now varies per customer and the code has no vocabulary for that. The feature was built against one workspace — usually the developer's own Grid sandbox, where everything works — and the assumption that the admin surface exists is baked into a call path with no branch in it.

What ships is an outage that only some customers see. The nightly job that reconciles channels across the organization runs fine for the Enterprise tenants and throws for everybody else, and because the error arrives inside a loop over installations, the log fills with a stack trace whose only distinguishing feature is a tenant id. Support hears "the app is broken" from a subset of customers that nobody can characterise, which is the most expensive shape a bug can have.

The naive fix makes it worse. Somebody adds a try around the call and sets admin_available = False in the handler, which is correct exactly when the error was feature_not_enabled and wrong every other time. A tenant whose token was briefly missing a scope, or whose admin account lost its role for a week, gets permanently marked as not-Enterprise. The flag is stored, the feature is hidden, the probe never runs again because the flag says not to, and the customer's own administrator cannot work out why the capability they are paying for is invisible.

Underneath all of it is the thing that cannot be read: the plan. There is no method that answers "what tier is this workspace on", so an app cannot look up the answer at install time and be done. It has to conduct an experiment, interpret the result carefully, and re-run the experiment when the result was inconclusive — which is a different discipline from feature detection against a documented capability list.

Built on a Gridsandboxwhere everythinganswersShipped to 400installsone call path, nobranchA Business+tenantsame code, sametokenfeature_not_enabledthe plan, not thecallerThe flag isstored offon whatever camebackgated off, so never probed again
The refusal is a plan boundary. The loop underneath is the audit storing an inconclusive probe as an absence, which removes the only thing that could ever correct it.

Why it happens

Capability is measured, and the measurement is one cheap read. admin.teams.list?limit=1 is a Tier 2 read that returns a single row on a Grid organization and feature_not_enabled where the surface does not exist. One request per installation, at install time, answers the question for good — and the answer is stored, so the feature is gated on a measured fact rather than on an assumption made in a sprint planning meeting.

The enterprise_id on auth.test is a signal and is labelled as one. Its absence strongly suggests a workspace outside any organization, and its presence suggests the opposite, but neither is the capability. Treating the signal as the answer is how an app decides a customer has the admin surface and then fails on the first call anyway. The script prints the signal, prints the probe, and only the probe feeds the gate.

An unknown is never stored as an absence, and that is the whole discipline. capability() distinguishes unavailable-plan from unknown-role, unknown-class, unknown-scope and unknown-transient, and gate() turns only the first into an off switch. Everything else becomes retry, which means the tenant is probed again rather than written off. A false off is invisible and permanent; a false retry costs one request.

The fallbacks are named per method, with what they cost. "Degrade gracefully" is not an instruction. conversations.list in place of admin.conversations.search sees only the conversations the token can see, in one workspace, which for most tenants is most of what the feature needed. Printing the replacement and the loss together lets somebody decide whether the degraded version is worth building, which is the actual decision.

The refusals that belong to other notes are handed over rather than absorbed. A probe that comes back not_an_admin is a real finding, just not this one, and the script says which note owns it. This matters more here than elsewhere, because this is the check people run first when an admin call fails, and its most common honest output is "this is not a plan problem".

Nothing writes, and the probe is chosen because it cannot. admin.teams.list reads. It is the smallest question that produces feature_not_enabled, and it does not enumerate anything sensitive at limit=1.

The fix, as a flow

Two things go wrong here and the second is worse than the first. The red arrow is where a customer on a smaller plan meets a surface their contract does not include, which is nobody's mistake and is repaired by a branch in your code rather than by a change in theirs. The curve underneath is the expensive one. A probe that was refused for some other reason gets recorded as this reason, the feature is gated off on the strength of it, and a gated feature is never probed again, so the wrong answer preserves itself indefinitely and the customer's administrator cannot work out why a capability they pay for is invisible. The fix branch sorts probe responses into the flag each one is allowed to set, and only the second row is allowed to switch anything off.

One probe per tenantand what it may concludeAnswered okavailable, and store it onfeature_not_enabledthe only off switch there isnot_an_adminretry; the plan was not testednot_allowed_token_typeretry; the plan was not testedRate limited mid sweepretry; ask again later
Four refusals arrive at the same catch block and only one of them is about the plan. A false off is invisible and permanent. A false retry costs one request.

How to fix it

Read the signal before you spend a request

grid_signal reads enterprise_id from auth.test. A null value is a strong hint that no admin method will work, and the script prints it as a hint. It never feeds the stored flag, because a hint that gates a feature is an assumption with extra steps.

Probe once, with the cheapest admin read there is

admin.teams.list?limit=1. One row, Tier 2, and its error list contains feature_not_enabled explicitly. That single response is the measurement, and everything downstream reads it rather than re-deriving it.

Classify the refusal into available, unavailable or unknown

capability returns one of seven states. Only unavailable-plan comes from feature_not_enabled; not_an_admin, not_allowed_token_type and missing_scope each get their own unknown- state and a pointer to the note that owns them.

Store a flag, and refuse to store an unknown as an absence

gate maps the capability to on, off or retry. Only a measured feature_not_enabled produces off. Everything inconclusive produces retry, so the tenant is asked again instead of being quietly written off forever.

Print the fallback and what it costs

fallback_for maps each admin method your feature uses to its non-admin equivalent and states the loss: one workspace instead of the organization, visible conversations instead of all of them, no cross-workspace search.

Gate the feature on the stored flag, and say why in the product

A tenant with off should see “this requires Enterprise Grid” rather than a spinner or a stack trace. A tenant with retry should see the feature as pending, not missing, and be re-probed on the next run.

How to check it worked

Run across every installation you hold. The matrix should contain no unknown- rows: each tenant is either measured available or measured unavailable, and anything else is a probe that needs a better credential before it means anything.

python3 slack_admin_capability.py --installs installs.json \
  --methods admin.conversations.search,admin.users.list
# tenant     acme-grid        signal=grid       capability=available     gate=on
# tenant     bolt-co          signal=not-grid   capability=unavailable-plan gate=off
#            note             feature_not_enabled; no scope or role changes this
# tenant     crate-ltd        signal=grid       capability=unknown-role  gate=retry
#            see also         /slack/not-an-admin/
# fallback   admin.conversations.search -> conversations.list
#            loses            one workspace instead of the organization, and only
#                             conversations this token can already see
# fallback   admin.users.list -> users.list
#            loses            one workspace instead of every workspace in the org
# matrix     3 tenant(s)      1 available, 1 unavailable, 1 unknown
# verdict    1 unknown        re-probe before gating; an unknown is not an absence
#   note:   the plan itself is not readable; team.info returns no billing field

The full code

One probe per tenant and the rest is bookkeeping with an opinion. capability is the classifier and it is deliberately verbose in its unknown states, because the difference between “this customer is not on Enterprise” and “this probe never got that far” is the difference between a correct feature gate and a permanent invisible one. gate encodes that as a rule with tests: only feature_not_enabled turns a feature off. grid_signal is labelled a signal everywhere it appears, plan_is_readable exists to record that the plan is not available from any documented read method, and fallback_for names the narrower call and what it costs.

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_admin_capability.py
"""Measure, per installation, whether the admin API surface exists at all.

Read only, one probe per tenant. The admin.* families are Enterprise Grid
features, and a workspace on a smaller plan answers feature_not_enabled no
matter who calls them with what scopes. That is a property of the customer's
contract rather than of your integration, so the job here is to measure it and
store it, not to fix it.

The plan is not readable. No documented read method returns it: team.info
comes back with id, name, domain, email_domain, icon and, on Grid,
enterprise_id and enterprise_name, and nothing about tier or billing. So
capability is an experiment: one admin.teams.list?limit=1 per installation.

The rule this script exists to enforce: only feature_not_enabled means the
surface is unavailable. not_an_admin, not_allowed_token_type and missing_scope
mean the probe never reached the question, and storing any of them as an
absence creates a permanent false negative that no later run corrects, because
the feature is gated off and never probed again.
"""
import argparse
import json
import logging
import sys

import requests

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

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

# The cheapest admin read that produces the answer. Tier 2, one row, and
# feature_not_enabled is in its documented error list.
PROBE = ("admin.teams.list", {"limit": 1})

# Refusals that mean the probe never reached the question, mapped to the note
# that owns each one. Every value here becomes an unknown, never an absence.
NOT_THIS_QUESTION = {
    "not_an_admin": "/slack/not-an-admin/",
    "not_allowed_token_type": "/slack/admin-method-needs-user-token/",
    "missing_scope": "/slack/missing-scope-on-read/",
    "accesslimited": "/slack/accesslimited-ip-allowlist/",
    "ekm_access_denied": "/slack/ekm-access-denied/",
}

TRANSIENT = ("ratelimited", "internal_error", "service_unavailable", "fatal_error",
             "request_timeout", "unparseable_body")

# Admin method -> (non-admin equivalent, what the equivalent cannot see).
FALLBACKS = {
    "admin.conversations.search": (
        "conversations.list",
        "one workspace instead of the organization, and only conversations this "
        "token can already see"),
    "admin.users.list": (
        "users.list",
        "one workspace instead of every workspace in the org"),
    "admin.teams.list": (
        "team.info",
        "the workspace you are in, rather than an enumeration of the org"),
    "admin.usergroups.listChannels": (
        "usergroups.list with include_users",
        "the groups in this workspace, without the org wide channel assignment"),
}


def plan_is_readable():
    """Can the workspace's plan be read from the API? Pure.

    A function rather than a comment because it is load bearing: every other
    decision here is shaped by the fact that the answer is no, and a test
    keeps the claim attached to the code that depends on it.
    """
    return (False, "no documented read method returns the plan; team.info returns "
                   "id, name, domain, email_domain, icon and, on Grid, enterprise_id "
                   "and enterprise_name, and no billing or tier field")


def grid_signal(auth_body):
    """Does this token sit inside an Enterprise organization? Pure.

    A signal, and labelled one everywhere it is printed. It is suggestive
    about capability and it is not capability, so it never feeds the gate.
    """
    doc = auth_body or {}
    if doc.get("ok") is not True:
        return ("unknown", "auth.test did not answer: %s" % (doc.get("error") or "?"))
    if doc.get("enterprise_id"):
        return ("grid", "auth.test returned an enterprise_id, so this workspace sits "
                        "inside an organization")
    return ("not-grid", "auth.test returned no enterprise_id, which strongly suggests "
                        "the admin surface will not answer")


def capability(body):
    """Read one probe response as a capability. Pure.

    Seven states, and the verbosity of the unknown half is the point:

      available          the probe answered ok.
      unavailable-plan   feature_not_enabled; the only state that gates a feature off.
      unknown-role       not_an_admin; the caller lacks the organization role.
      unknown-class      not_allowed_token_type; a bot token was used.
      unknown-scope      missing_scope; the grant is short.
      unknown-transient  ratelimited or a 5xx shaped error; ask again.
      unknown-other      anything else, including the Grid session refusals.
    """
    doc = body or {}
    if doc.get("ok") is True:
        return ("available", "the probe answered ok, so the admin surface exists here")
    error = str(doc.get("error") or "")
    if error == "feature_not_enabled":
        return ("unavailable-plan", "feature_not_enabled; the admin API is an "
                                    "Enterprise Grid surface and no scope or role "
                                    "changes that")
    if error == "not_an_admin":
        return ("unknown-role", "not_an_admin; the caller does not hold the "
                                "organization role, so the plan was never tested")
    if error == "not_allowed_token_type":
        return ("unknown-class", "not_allowed_token_type; this is a bot token, so the "
                                 "plan was never tested")
    if error == "missing_scope":
        return ("unknown-scope", "missing_scope; the grant is short, so the plan was "
                                 "never tested")
    if error in TRANSIENT:
        return ("unknown-transient", "%s; ask again rather than concluding anything"
                                     % (error or "no error"))
    return ("unknown-other", "%s; not a refusal that answers the plan question"
                             % (error or "no error"))


def gate(state):
    """Turn a capability into the flag you are allowed to store. Pure.

    on     the feature is available for this tenant.
    off    measured unavailable, and only ever from feature_not_enabled.
    retry  inconclusive; probe again rather than writing the tenant off.

    A false off is invisible and permanent, because a gated feature is never
    probed again. A false retry costs one request.
    """
    if state == "available":
        return ("on", "measured available")
    if state == "unavailable-plan":
        return ("off", "measured unavailable; this is the only state that gates a "
                       "feature off")
    return ("retry", "inconclusive; an unknown is not an absence and must not be "
                     "stored as one")


def owner_of(error):
    """Which note owns this refusal, if not this one? Pure."""
    return NOT_THIS_QUESTION.get(str(error or "").strip())


def fallback_for(method):
    """The non-admin equivalent and what it cannot see. Pure."""
    return FALLBACKS.get(str(method or "").strip())


def matrix_verdict(rows):
    """One line for the whole matrix. Pure.

    rows: [(tenant, capability), ...]. Unknowns are reported as the finding,
    because a matrix with unknowns in it is not yet a matrix.
    """
    counts = {}
    for _tenant, state in rows or []:
        key = "unknown" if state.startswith("unknown") else state
        counts[key] = counts.get(key, 0) + 1
    if not rows:
        return ("empty", "no installations were probed")
    unknown = counts.get("unknown", 0)
    summary = "%d available, %d unavailable, %d unknown" % (
        counts.get("available", 0), counts.get("unavailable-plan", 0), unknown)
    if unknown:
        return ("%d unknown" % unknown, summary)
    return ("measured", summary)


def get(session, method, token, params=None):
    """One GET with one tenant's token. Returns the parsed body."""
    r = session.get(API + method, params=params or {},
                    headers={"Authorization": "Bearer " + token}, timeout=30)
    try:
        return r.json()
    except ValueError:
        return {"ok": False, "error": "unparseable_body"}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--installs", required=True,
                    help="JSON list of installations, each with id and user_token")
    ap.add_argument("--methods", default="admin.conversations.search",
                    help="comma separated admin methods your feature depends on, so "
                         "their fallbacks are printed")
    args = ap.parse_args()

    with open(args.installs, encoding="utf-8") as handle:
        installs = json.load(handle) or []

    readable, why = plan_is_readable()
    if not readable:
        log.info("plan       not readable     %s", why)

    session = requests.Session()
    rows = []
    for row in installs:
        row = row or {}
        tenant = str(row.get("id") or "?")
        token = row.get("user_token") or ""
        if not token:
            log.warning("tenant     %-16s no user token stored, so nothing can be "
                        "measured", tenant)
            rows.append((tenant, "unknown-class"))
            continue
        signal, signal_why = grid_signal(get(session, "auth.test", token))
        body = get(session, PROBE[0], token, PROBE[1])
        state, detail = capability(body)
        flag, flag_why = gate(state)
        level = log.info if state in ("available", "unavailable-plan") else log.warning
        level("tenant     %-16s signal=%-9s capability=%-18s gate=%s",
              tenant, signal, state, flag)
        log.info("           signal           %s", signal_why)
        log.info("           note             %s", detail)
        if flag == "retry":
            log.warning("           gate             %s", flag_why)
        pointer = owner_of(body.get("error"))
        if pointer:
            log.info("           see also         %s", pointer)
        rows.append((tenant, state))

    for method in [m.strip() for m in args.methods.split(",") if m.strip()]:
        pair = fallback_for(method)
        if not pair:
            log.info("fallback   %-30s none named here; check the method reference",
                     method)
            continue
        log.info("fallback   %s -> %s", method, pair[0])
        log.info("           loses            %s", pair[1])

    state, summary = matrix_verdict(rows)
    if state == "measured":
        log.info("matrix     %d tenant(s)      %s", len(rows), summary)
        log.info("verdict    measured         every tenant was gated on a measurement")
        return 0
    log.warning("matrix     %d tenant(s)      %s", len(rows), summary)
    log.warning("verdict    %-16s re-probe before gating; an unknown is not an "
                "absence", state)
    log.warning("  repair: feature detect at install time and store the result, so the "
                "gate reads a measured fact rather than an assumption")
    log.warning("  repair: present a clear requires Enterprise Grid message for a "
                "measured off, and a pending state for a retry")
    log.warning("  repair: build the non-admin fallback where the narrower answer is "
                "worth having; the loss is printed above")
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-admin-capability.mjs
/**
 * Measure, per installation, whether the admin API surface exists at all.
 *
 * Read only, one probe per tenant. The admin.* families are Enterprise Grid
 * features and a workspace on a smaller plan answers feature_not_enabled
 * whoever calls them with whatever scopes.
 *
 * The plan is not readable: no documented read method returns it, so
 * capability is an experiment rather than a lookup. The rule this module
 * exists to enforce is that only feature_not_enabled means unavailable.
 * not_an_admin, not_allowed_token_type and missing_scope mean the probe never
 * reached the question, and storing one of those as an absence creates a
 * permanent false negative that no later run corrects.
 */
import { readFile } from 'node:fs/promises';

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

export const PROBE = ['admin.teams.list', { limit: 1 }];

export const NOT_THIS_QUESTION = {
  not_an_admin: '/slack/not-an-admin/',
  not_allowed_token_type: '/slack/admin-method-needs-user-token/',
  missing_scope: '/slack/missing-scope-on-read/',
  accesslimited: '/slack/accesslimited-ip-allowlist/',
  ekm_access_denied: '/slack/ekm-access-denied/',
};

export const TRANSIENT = ['ratelimited', 'internal_error', 'service_unavailable',
  'fatal_error', 'request_timeout', 'unparseable_body'];

export const FALLBACKS = {
  'admin.conversations.search': ['conversations.list',
    'one workspace instead of the organization, and only conversations this token '
    + 'can already see'],
  'admin.users.list': ['users.list',
    'one workspace instead of every workspace in the org'],
  'admin.teams.list': ['team.info',
    'the workspace you are in, rather than an enumeration of the org'],
  'admin.usergroups.listChannels': ['usergroups.list with include_users',
    'the groups in this workspace, without the org wide channel assignment'],
};

// A function rather than a comment because it is load bearing.
export function planIsReadable() {
  return [false, 'no documented read method returns the plan; team.info returns id, '
    + 'name, domain, email_domain, icon and, on Grid, enterprise_id and '
    + 'enterprise_name, and no billing or tier field'];
}

// Suggestive about capability, and not capability. Never feeds the gate.
export function gridSignal(authBody) {
  const doc = authBody ?? {};
  if (doc.ok !== true) return ['unknown', `auth.test did not answer: ${doc.error ?? '?'}`];
  if (doc.enterprise_id) {
    return ['grid', 'auth.test returned an enterprise_id, so this workspace sits '
      + 'inside an organization'];
  }
  return ['not-grid', 'auth.test returned no enterprise_id, which strongly suggests '
    + 'the admin surface will not answer'];
}

export function capability(body) {
  const doc = body ?? {};
  if (doc.ok === true) {
    return ['available', 'the probe answered ok, so the admin surface exists here'];
  }
  const error = String(doc.error ?? '');
  if (error === 'feature_not_enabled') {
    return ['unavailable-plan', 'feature_not_enabled; the admin API is an Enterprise '
      + 'Grid surface and no scope or role changes that'];
  }
  if (error === 'not_an_admin') {
    return ['unknown-role', 'not_an_admin; the caller does not hold the organization '
      + 'role, so the plan was never tested'];
  }
  if (error === 'not_allowed_token_type') {
    return ['unknown-class', 'not_allowed_token_type; this is a bot token, so the plan '
      + 'was never tested'];
  }
  if (error === 'missing_scope') {
    return ['unknown-scope', 'missing_scope; the grant is short, so the plan was never '
      + 'tested'];
  }
  if (TRANSIENT.includes(error)) {
    return ['unknown-transient', `${error || 'no error'}; ask again rather than `
      + 'concluding anything'];
  }
  return ['unknown-other', `${error || 'no error'}; not a refusal that answers the `
    + 'plan question'];
}

// A false off is invisible and permanent. A false retry costs one request.
export function gate(state) {
  if (state === 'available') return ['on', 'measured available'];
  if (state === 'unavailable-plan') {
    return ['off', 'measured unavailable; this is the only state that gates a feature off'];
  }
  return ['retry', 'inconclusive; an unknown is not an absence and must not be stored '
    + 'as one'];
}

export function ownerOf(error) {
  return NOT_THIS_QUESTION[String(error ?? '').trim()] ?? null;
}

export function fallbackFor(method) {
  return FALLBACKS[String(method ?? '').trim()] ?? null;
}

export function matrixVerdict(rows) {
  const counts = {};
  for (const [, state] of rows ?? []) {
    const key = state.startsWith('unknown') ? 'unknown' : state;
    counts[key] = (counts[key] ?? 0) + 1;
  }
  if (!(rows ?? []).length) return ['empty', 'no installations were probed'];
  const unknown = counts.unknown ?? 0;
  const summary = `${counts.available ?? 0} available, `
    + `${counts['unavailable-plan'] ?? 0} unavailable, ${unknown} unknown`;
  if (unknown) return [`${unknown} unknown`, summary];
  return ['measured', summary];
}

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

async function read(token, method, params = {}) {
  const url = new URL(API + method);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  try {
    return await r.json();
  } catch {
    return { ok: false, error: 'unparseable_body' };
  }
}

async function main() {
  const args = process.argv.slice(2);
  const installsPath = arg(args, '--installs');
  if (!installsPath) {
    console.error('pass --installs with a JSON list of installations');
    process.exitCode = 2;
    return;
  }
  const installs = JSON.parse(await readFile(installsPath, 'utf8')) ?? [];
  const methods = arg(args, '--methods', 'admin.conversations.search')
    .split(',').map((s) => s.trim()).filter(Boolean);

  const [readable, why] = planIsReadable();
  if (!readable) console.log(`plan       not readable     ${why}`);

  const rows = [];
  for (const raw of installs) {
    const row = raw ?? {};
    const tenant = String(row.id ?? '?');
    const token = row.user_token ?? '';
    if (!token) {
      console.warn(`tenant     ${tenant.padEnd(16)} no user token stored, so nothing `
        + 'can be measured');
      rows.push([tenant, 'unknown-class']);
      continue;
    }
    // eslint-disable-next-line no-await-in-loop
    const [signal, signalWhy] = gridSignal(await read(token, 'auth.test'));
    // eslint-disable-next-line no-await-in-loop
    const body = await read(token, PROBE[0], PROBE[1]);
    const [state, detail] = capability(body);
    const [flag, flagWhy] = gate(state);
    const line = `tenant     ${tenant.padEnd(16)} signal=${signal.padEnd(9)} `
      + `capability=${state.padEnd(18)} gate=${flag}`;
    if (state === 'available' || state === 'unavailable-plan') console.log(line);
    else console.warn(line);
    console.log(`           signal           ${signalWhy}`);
    console.log(`           note             ${detail}`);
    if (flag === 'retry') console.warn(`           gate             ${flagWhy}`);
    const pointer = ownerOf(body.error);
    if (pointer) console.log(`           see also         ${pointer}`);
    rows.push([tenant, state]);
  }

  for (const method of methods) {
    const pair = fallbackFor(method);
    if (!pair) {
      console.log(`fallback   ${method.padEnd(30)} none named here; check the method `
        + 'reference');
      continue;
    }
    console.log(`fallback   ${method} -> ${pair[0]}`);
    console.log(`           loses            ${pair[1]}`);
  }

  const [state, summary] = matrixVerdict(rows);
  if (state === 'measured') {
    console.log(`matrix     ${rows.length} tenant(s)      ${summary}`);
    console.log('verdict    measured         every tenant was gated on a measurement');
    return;
  }
  console.warn(`matrix     ${rows.length} tenant(s)      ${summary}`);
  console.warn(`verdict    ${state.padEnd(16)} re-probe before gating; an unknown is `
    + 'not an absence');
  console.warn('  repair: feature detect at install time and store the result, so the '
    + 'gate reads a measured fact rather than an assumption');
  console.warn('  repair: present a clear requires Enterprise Grid message for a '
    + 'measured off, and a pending state for a retry');
  console.warn('  repair: build the non-admin fallback where the narrower answer is '
    + 'worth having; the loss is printed above');
  process.exitCode = 1;
}

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

Add a test

The classifier gets one test per refusal, and then the gate gets the assertion the whole note is built on: every state except unavailable-plan has to produce retry, so no inconclusive probe can ever turn a customer's feature off. There is a matching test that feature_not_enabled really does produce off, because a check so cautious it never concludes anything is just as useless. plan_is_readable is tested too, which looks odd until you notice that every design decision here rests on it: if the plan were readable this script would be a lookup.

test_slack_admin_capability.py
from slack_admin_capability import (
    capability, fallback_for, gate, grid_signal, matrix_verdict, owner_of,
    plan_is_readable,
)


def test_the_plan_is_not_readable_and_the_code_says_so():
    readable, why = plan_is_readable()
    assert readable is False
    assert "team.info" in why


def test_an_ok_probe_is_available():
    assert capability({"ok": True, "teams": []})[0] == "available"


def test_feature_not_enabled_is_the_only_state_that_gates_a_feature_off():
    state, why = capability({"ok": False, "error": "feature_not_enabled"})
    assert state == "unavailable-plan"
    assert gate(state)[0] == "off"
    assert "Enterprise Grid surface" in why


def test_a_role_refusal_never_becomes_an_absence():
    state, _why = capability({"ok": False, "error": "not_an_admin"})
    assert state == "unknown-role"
    assert gate(state)[0] == "retry"


def test_a_class_refusal_never_becomes_an_absence():
    assert gate(capability({"ok": False, "error": "not_allowed_token_type"})[0])[0] \
        == "retry"


def test_a_scope_refusal_never_becomes_an_absence():
    assert gate(capability({"ok": False, "error": "missing_scope"})[0])[0] == "retry"


def test_a_transient_refusal_is_asked_again():
    assert capability({"ok": False, "error": "ratelimited"})[0] == "unknown-transient"
    assert gate("unknown-transient")[0] == "retry"


def test_an_unrecognised_refusal_is_unknown_rather_than_unavailable():
    state, why = capability({"ok": False, "error": "org_login_required"})
    assert state == "unknown-other"
    assert "org_login_required" in why


def test_every_unknown_state_gates_to_retry():
    for state in ("unknown-role", "unknown-class", "unknown-scope",
                  "unknown-transient", "unknown-other"):
        assert gate(state)[0] == "retry"


def test_the_grid_signal_is_a_signal_and_is_labelled_one():
    assert grid_signal({"ok": True, "enterprise_id": "E1"})[0] == "grid"
    state, why = grid_signal({"ok": True, "team_id": "T1"})
    assert state == "not-grid"
    assert "suggests" in why


def test_an_unanswered_auth_test_leaves_the_signal_unknown():
    assert grid_signal({"ok": False, "error": "invalid_auth"})[0] == "unknown"


def test_refusals_that_belong_elsewhere_are_handed_over():
    assert owner_of("not_an_admin") == "/slack/not-an-admin/"
    assert owner_of("feature_not_enabled") is None


def test_each_fallback_names_the_method_and_the_loss():
    replacement, loses = fallback_for("admin.conversations.search")
    assert replacement == "conversations.list"
    assert "one workspace" in loses
    assert fallback_for("admin.somethingNew.list") is None


def test_the_matrix_reports_unknowns_as_the_finding():
    state, summary = matrix_verdict([("a", "available"), ("b", "unavailable-plan"),
                                     ("c", "unknown-role")])
    assert state == "1 unknown"
    assert summary == "1 available, 1 unavailable, 1 unknown"


def test_a_fully_measured_matrix_says_measured():
    assert matrix_verdict([("a", "available"), ("b", "unavailable-plan")])[0] == "measured"


def test_an_empty_matrix_is_not_a_measurement():
    assert matrix_verdict([])[0] == "empty"
slack-admin-capability.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  capability, fallbackFor, gate, gridSignal, matrixVerdict, ownerOf, planIsReadable,
} from './slack-admin-capability.mjs';

test('the plan is not readable and the code says so', () => {
  const [readable, why] = planIsReadable();
  assert.equal(readable, false);
  assert.match(why, /team\.info/);
});

test('an ok probe is available', () => {
  assert.equal(capability({ ok: true, teams: [] })[0], 'available');
});

test('feature_not_enabled is the only state that gates a feature off', () => {
  const [state, why] = capability({ ok: false, error: 'feature_not_enabled' });
  assert.equal(state, 'unavailable-plan');
  assert.equal(gate(state)[0], 'off');
  assert.match(why, /Enterprise Grid surface/);
});

test('a role refusal never becomes an absence', () => {
  const [state] = capability({ ok: false, error: 'not_an_admin' });
  assert.equal(state, 'unknown-role');
  assert.equal(gate(state)[0], 'retry');
});

test('a class refusal never becomes an absence', () => {
  assert.equal(gate(capability({ ok: false, error: 'not_allowed_token_type' })[0])[0],
    'retry');
});

test('a scope refusal never becomes an absence', () => {
  assert.equal(gate(capability({ ok: false, error: 'missing_scope' })[0])[0], 'retry');
});

test('a transient refusal is asked again', () => {
  assert.equal(capability({ ok: false, error: 'ratelimited' })[0], 'unknown-transient');
  assert.equal(gate('unknown-transient')[0], 'retry');
});

test('an unrecognised refusal is unknown rather than unavailable', () => {
  const [state, why] = capability({ ok: false, error: 'org_login_required' });
  assert.equal(state, 'unknown-other');
  assert.match(why, /org_login_required/);
});

test('every unknown state gates to retry', () => {
  for (const state of ['unknown-role', 'unknown-class', 'unknown-scope',
    'unknown-transient', 'unknown-other']) {
    assert.equal(gate(state)[0], 'retry');
  }
});

test('the grid signal is a signal and is labelled one', () => {
  assert.equal(gridSignal({ ok: true, enterprise_id: 'E1' })[0], 'grid');
  const [state, why] = gridSignal({ ok: true, team_id: 'T1' });
  assert.equal(state, 'not-grid');
  assert.match(why, /suggests/);
});

test('an unanswered auth.test leaves the signal unknown', () => {
  assert.equal(gridSignal({ ok: false, error: 'invalid_auth' })[0], 'unknown');
});

test('refusals that belong elsewhere are handed over', () => {
  assert.equal(ownerOf('not_an_admin'), '/slack/not-an-admin/');
  assert.equal(ownerOf('feature_not_enabled'), null);
});

test('each fallback names the method and the loss', () => {
  const [replacement, loses] = fallbackFor('admin.conversations.search');
  assert.equal(replacement, 'conversations.list');
  assert.match(loses, /one workspace/);
  assert.equal(fallbackFor('admin.somethingNew.list'), null);
});

test('the matrix reports unknowns as the finding', () => {
  const [state, summary] = matrixVerdict([['a', 'available'], ['b', 'unavailable-plan'],
    ['c', 'unknown-role']]);
  assert.equal(state, '1 unknown');
  assert.equal(summary, '1 available, 1 unavailable, 1 unknown');
});

test('a fully measured matrix says measured', () => {
  assert.equal(matrixVerdict([['a', 'available'], ['b', 'unavailable-plan']])[0],
    'measured');
});

test('an empty matrix is not a measurement', () => {
  assert.equal(matrixVerdict([])[0], 'empty');
});

FAQ

Can I just read the workspace's plan and skip the probe?

There is no documented read method that returns it. team.info gives you the workspace id, name, domain, email domain, icon and, on Enterprise Grid, the enterprise id and name, and nothing about tier or billing. The nearest available signal is whether auth.test returns an enterprise_id, which tells you the workspace sits inside an organization and still does not tell you the admin API will answer. So the plan question is settled by an experiment, which is one cheap read, once, stored.

Why not set the flag to false whenever the admin call throws?

Because four different refusals arrive at that catch block and only one of them is about the plan. A tenant whose token was briefly missing a scope, or whose admin account lost the role for a week, gets permanently marked as not-Enterprise, and the damage is invisible: the feature is now gated off, so the probe never runs again and nothing ever corrects the record. Only feature_not_enabled turns the flag off here. Everything else is retry.

A tenant is on Enterprise Grid and still gets feature_not_enabled. Is the check wrong?

Check the credential before the plan. That error is specific, but it is also worth confirming that the probe used the tenant's own user token rather than a token from another installation, since a matrix built by looping over installations is exactly the code that mixes those up. If the token is right and the org is genuinely on Grid, the customer's administrator is the next stop: admin API availability is something they can confirm from their side in a way your app cannot.

What can the non-admin fallbacks actually do?

Less, but usefully less. conversations.list sees the conversations your token can see in one workspace instead of every conversation in the organization; users.list is one workspace instead of all of them; team.info describes the workspace you are in rather than enumerating the org. For a single-workspace customer, which is what a non-Enterprise customer is, the narrower call is frequently the whole answer. The script prints the loss next to the replacement so you can decide.

Should the customer see anything about this?

Yes, and it should say what it means. A measured off should produce a clear line that the capability requires Enterprise Grid, not a spinner, an empty state or a stack trace, because the customer's administrator is the only person who can act on it and they need to know it is a plan boundary rather than a bug. A retry should show as pending rather than missing, since it means your own probe has not yet succeeded.

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.