Skip to content

Diagnostic Slack

One event, many installs: authorizations:read fans it out

Support says the bot only works for some customers. Your logs disagree: every event was received, handled, and acknowledged, with no errors on any of them. Both are true. Slack delivered one event that several installations should have seen, your handler processed it for the one workspace named in the payload, and nothing anywhere recorded the ones it skipped.

Read-only token Python and Node.js Tests included
Red and white love print textile
Photo by Tamanna Rumee on Unsplash
The short answer

When an event is visible to more than one installation of your app, Slack sends one delivery. The payload carries an event_context and an authorizations array that has been truncated to a single entry. You are expected to call apps.event.authorizations.list with that event_context to enumerate every installation that should see the event, and to process it once per authorization.

That method needs authorizations:read on an app-level token. Without it, the fan-out cannot happen and the app serves exactly one tenant per event, silently. The script below establishes three things and combines them: whether the app is genuinely multi-install (from your installation store), what one captured event envelope actually looked like, and whether the capability exists at all. The severity comes from the first of those, not from the third — a single-workspace app with no authorizations:read is not broken, it is one customer away from being broken.

The problem in plain words

This bug has no error. The delivery arrives, the signature verifies, the handler runs, the acknowledgement goes back inside three seconds, and Slack is satisfied. The only thing wrong is a comparison nobody performs: how many installations should have seen this event versus how many did.

It also hides behind a plausible-looking payload. The authorizations array is present and well formed and contains one entry, and a developer reading it for the first time will conclude that one installation is who this event is for. That is exactly what the field looks like when it has been truncated, because truncation here is normal — Slack documents that the array is limited and that event_context is the way to get the rest. Nothing in the payload announces that it was cut.

And it is invisible in the workspace that reported it. The customer who complains is the one who saw nothing; the customer who was served has no idea anything happened. So the bug reaches you as "it works for us but not for them", which sounds like a permissions problem in the affected workspace and sends everyone to inspect the wrong installation.

Event seen bymany installsone delivery sentauthorizationstruncatedone entry, bydesignLength read asaudienceno expansion callOther tenantsskippedno error, no logWorks for somecustomersnot for others
Every step succeeds. The handler acknowledges inside three seconds and logs a clean run, and the only wrong number is one nobody computes: how many installations should have seen this.

Why it happens

One event, one delivery. The design is deliberate: Slack does not fan an event out to every installation on your behalf, because it does not know how you shard, batch or deduplicate. It hands you a handle — event_context — and expects you to expand it. An app that ignores the handle has not disabled a feature; it has skipped a step.

The array length is not a count. Treating authorizations.length as the number of interested installations is the mistake at the centre of this note. It is a sample, and the sample size is one. Any logic that branches on it is branching on a constant.

Tenancy determines whether this matters at all. A single-workspace app can ignore the whole mechanism forever with no consequence. The identical missing scope becomes a customer-facing correctness bug the moment a second workspace installs, which is why the script reads your installation store before it reads anything else and reports a single-workspace app as dormant rather than as failing.

An org-wide install is the sharpest version. On Enterprise Grid, one installation can span many workspaces, and the truncated array will name one of them. An app that serves only the named workspace looks correct to the admin who installed it and broken to everyone else in the org.

Holding the scope is not the same as using it. The API can tell you the capability exists. It cannot tell you whether your handler calls it, paginates the cursor, or processes the event once per authorization, so the script reports capability and leaves behaviour to a source review rather than pretending to have checked.

The fix, as a flow

There is no error to sort here, so the script sorts by tenancy instead. The same missing scope is a customer-facing correctness bug on a distributed app and a roadmap note on a single workspace, and reporting both at one severity is how the real one gets ignored.

Install store plus one probetenancy decides severityOrg-wide install, no scopeworkspaces silently droppedMany teams, no scopeone tenant per eventOne team, no scopedormant, fix before you sellScope present, many teamshandler still unverifiedOne team, scope presentnothing to fan out
A single-workspace app is not broken and should not fail the run. It is one customer away from broken, which is a different sentence and belongs in a different place.

How to fix it

Ask your own store whether the app is multi-install

Count distinct team_id values, and look for any record with is_enterprise_install set. One team is dormant, several teams is live, an org-wide install is live and wider than it looks. This is the number that decides how loudly the rest of the report speaks.

Look at one real event envelope

Take a payload the app actually received and check three fields: is event_context present, is authorizations present, and how many entries does it have. One entry is the normal, truncated shape — it is not evidence that one installation was the intended audience, and code that reads it that way is the bug.

Ask once whether the capability exists

A single probe of apps.event.authorizations.list with the app-level token, answering present, absent or unknown. Nothing more granular is needed here; the fine reading of that method's errors belongs to the Socket Mode note, and this one only needs to know whether the door opens.

Rank by tenancy, not by the error

Absent capability plus several workspaces is a correctness bug affecting paying customers. Absent capability plus one workspace is a note for the roadmap. Reporting both at the same severity trains people to ignore the report, so the script gives the second one its own verdict and does not fail the run for it.

Fan out in the handler, and follow the cursor

Call apps.event.authorizations.list with the payload's event_context, page through response_metadata.next_cursor, and process the event once per returned authorization with that installation's own token. Deduplicate on the pair of event id and authorization, not on the event id alone.

Say what the probe did not check

The capability being present says nothing about whether the handler uses it. Put that sentence in the output. A report that claims to have verified fan-out when it verified a scope is worse than one that admits the boundary, because the next person will trust it.

How to check it worked

Re-run after generating an app-level token with authorizations:read. The capability line should read present, and the verdict for a multi-workspace store should stop being a finding.

python3 slack_event_fanout_audit.py --installs installs.json --event sample-event.json
# capability-present  4 distinct team_id(s) and the expansion call is available

The full code

The live half is two GETs and the quiet half is where the answer is. tenancy reads your installation store, delivery_shape reads one captured envelope, capability reduces the app-level probe to three values, and fanout_risk combines the first and third into a severity — which is the only part of this that a caller should act on.

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_event_fanout_audit.py
"""Decide whether a Slack app is silently serving one installation per event.

Read only. Two GETs: one auth.test to name the app you are standing in, and one
probe of apps.event.authorizations.list to see whether the capability that fans
an event out to every installation exists at all. Everything else is read from
your own installation store and from one captured event envelope.
"""
import argparse
import json
import logging
import os
import sys

import requests

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

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

PROBE_CONTEXT = "read-only-probe-not-a-real-event-context"

# Severities that should fail the run. A dormant finding is real but it is not
# breaking anything today, and failing on it teaches people to ignore the report.
FAILING = ("dropping-installations", "dropping-workspaces-in-org")


def tenancy(installs):
    """Classify the installation store. Pure. -> (state, detail)"""
    rows = [r for r in installs if isinstance(r, dict)]
    if not rows:
        return ("empty-store",
                "no installation records were supplied, so there is nothing to "
                "say about how many tenants an event should reach.")

    org_wide = [r for r in rows if r.get("is_enterprise_install")]
    teams = sorted({str(r.get("team_id")) for r in rows if r.get("team_id")})
    if org_wide:
        return ("enterprise-install",
                "%d org-wide install(s) across %d recorded team_id(s). One "
                "installation can span many workspaces, so a truncated "
                "authorizations array names one of them and hides the rest."
                % (len(org_wide), len(teams)))
    if len(teams) > 1:
        return ("multi-workspace",
                "%d distinct team_id(s) in the store, so events are routinely "
                "visible to more than one installation." % len(teams))
    return ("single-workspace",
            "one workspace installed. The fan-out mechanism has nothing to fan "
            "out to yet.")


def delivery_shape(event):
    """Read one captured event envelope. Pure. -> (state, detail)"""
    if not isinstance(event, dict):
        return ("no-envelope",
                "no event payload was supplied, so the delivery shape is unknown.")
    if not event.get("event_context"):
        return ("no-event-context",
                "the envelope carries no event_context. Either it predates the "
                "field or it is a hand-written fixture; with no handle there is "
                "nothing to expand and nothing to conclude.")

    authorizations = event.get("authorizations")
    if not isinstance(authorizations, list) or not authorizations:
        return ("no-authorizations",
                "event_context is present but authorizations is missing or "
                "empty, so the handler has no installation named at all and must "
                "expand the context before it can do anything.")

    shared = " The envelope is also marked is_ext_shared." if event.get("is_ext_shared") else ""
    if len(authorizations) == 1:
        return ("single-authorization",
                "one entry in authorizations, which is the normal truncated "
                "shape rather than a count of interested installations. Code "
                "that treats this length as the audience is the bug.%s" % shared)
    return ("multiple-authorizations",
            "%d entries in authorizations. Expand event_context anyway: the "
            "array is capped, so more is not the same as all.%s"
            % (len(authorizations), shared))


def capability(body):
    """Three-way read of the app-level probe. Pure. -> (state, detail)

    Deliberately coarse. All this note needs to know is whether the expansion
    call is available; the full reading of this method's errors belongs to the
    Socket Mode readiness check.
    """
    if body.get("ok") is True:
        return ("present", "the expansion call answered ok")
    error = body.get("error") or "<no error field>"
    if error in ("invalid_event_context", "event_context_invalid"):
        return ("present",
                "the probe context was refused as an argument, which means the "
                "credential and the scope were accepted before it")
    if error == "missing_scope":
        return ("absent",
                "missing_scope: the app-level token was minted without "
                "authorizations:read, so an event cannot be expanded")
    return ("unknown",
            "error=%s, which says nothing conclusive about the scope" % error)


def fanout_risk(tenancy_state, capability_state):
    """Combine tenancy with capability into a severity. Pure. -> (state, detail)"""
    if tenancy_state == "empty-store" or capability_state == "unknown":
        return ("not-assessed",
                "one of the two inputs is missing, so no severity can be "
                "assigned. Report it as unknown rather than as clean.")

    if capability_state == "absent":
        if tenancy_state == "enterprise-install":
            return ("dropping-workspaces-in-org",
                    "an org-wide installation with no way to expand an event. "
                    "The workspace named in the truncated array is served and "
                    "every other workspace in the org is silently skipped.")
        if tenancy_state == "multi-workspace":
            return ("dropping-installations",
                    "more than one installation and no way to expand an event. "
                    "Each event is handled for exactly one tenant, which is why "
                    "the bot appears to work for some customers and not others.")
        return ("dormant",
                "one workspace, so nothing is being dropped today. The day a "
                "second installation appears this becomes a correctness bug "
                "with no error attached, so fix it before you sell it.")

    if tenancy_state == "single-workspace":
        return ("capability-unused",
                "the scope is present and there is only one installation to "
                "serve. Nothing to do, and nothing wasted.")
    return ("capability-present",
            "the expansion call is available. Whether the handler actually "
            "calls it, follows the cursor and processes the event once per "
            "authorization is not visible from the API: read the handler.")


def get(session, url, token, params=None):
    r = session.get(url, headers={"Authorization": "Bearer " + token},
                    params=params or {}, 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 array of installation records: team_id, enterprise_id, "
                         "is_enterprise_install")
    ap.add_argument("--event", help="path to one captured event envelope")
    ap.add_argument("--app-token-env", default="SLACK_APP_TOKEN")
    ap.add_argument("--bot-token-env", default="SLACK_BOT_TOKEN")
    args = ap.parse_args()

    installs = json.loads(open(args.installs, encoding="utf-8").read())
    event = json.loads(open(args.event, encoding="utf-8").read()) if args.event else None

    ten_state, ten_detail = tenancy(installs)
    shape_state, shape_detail = delivery_shape(event)

    app_token = os.environ.get(args.app_token_env)
    if not app_token:
        log.error("%s is not set; the capability cannot be probed",
                  args.app_token_env)
        return 2

    s = requests.Session()
    bot_token = os.environ.get(args.bot_token_env)
    if bot_token:
        identity = get(s, API + "auth.test", bot_token)
        log.info("app: %s in team %s", identity.get("app_id") or "<unknown app_id>",
                 identity.get("team") or identity.get("team_id") or "<unknown team>")

    cap_state, cap_detail = capability(
        get(s, API + "apps.event.authorizations.list", app_token,
            {"event_context": PROBE_CONTEXT}))
    verdict, why = fanout_risk(ten_state, cap_state)

    log.info("%-28s %s", verdict, why)
    log.info("  tenancy:    %s -- %s", ten_state, ten_detail)
    log.info("  envelope:   %s -- %s", shape_state, shape_detail)
    log.info("  capability: %s -- %s", cap_state, cap_detail)

    if cap_state == "absent":
        log.warning("  repair: generate an app-level token with authorizations:read "
                    "on Basic Information, and deploy it as %s", args.app_token_env)
        log.warning("  repair: in the handler, expand payload.event_context via "
                    "apps.event.authorizations.list, page through "
                    "response_metadata.next_cursor, and process the event once per "
                    "authorization using that installation's own token")
        log.warning("  repair: deduplicate on the pair of event id and "
                    "authorization, not on the event id alone")

    return 1 if verdict in FAILING else 0


if __name__ == "__main__":
    sys.exit(main())
slack-event-fanout-audit.mjs
/**
 * Decide whether a Slack app is silently serving one installation per event.
 *
 * Read only. Two GETs: one auth.test to name the app you are standing in, and
 * one probe of apps.event.authorizations.list to see whether the capability that
 * fans an event out to every installation exists at all. Everything else is read
 * from your own installation store and from one captured event envelope.
 */
import { readFile } from 'node:fs/promises';

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

const PROBE_CONTEXT = 'read-only-probe-not-a-real-event-context';

// Severities that should fail the run. A dormant finding is real but it is not
// breaking anything today, and failing on it teaches people to ignore the report.
const FAILING = new Set(['dropping-installations', 'dropping-workspaces-in-org']);

/**
 * Classify the installation store. Pure.
 */
export function tenancy(installs) {
  const rows = (installs ?? []).filter((r) => r && typeof r === 'object');
  if (rows.length === 0) {
    return ['empty-store',
      'no installation records were supplied, so there is nothing to say about ' +
      'how many tenants an event should reach.'];
  }

  const orgWide = rows.filter((r) => r.is_enterprise_install);
  const teams = [...new Set(rows.filter((r) => r.team_id).map((r) => String(r.team_id)))];
  if (orgWide.length) {
    return ['enterprise-install',
      `${orgWide.length} org-wide install(s) across ${teams.length} recorded ` +
      'team_id(s). One installation can span many workspaces, so a truncated ' +
      'authorizations array names one of them and hides the rest.'];
  }
  if (teams.length > 1) {
    return ['multi-workspace',
      `${teams.length} distinct team_id(s) in the store, so events are routinely ` +
      'visible to more than one installation.'];
  }
  return ['single-workspace',
    'one workspace installed. The fan-out mechanism has nothing to fan out to yet.'];
}

/**
 * Read one captured event envelope. Pure.
 */
export function deliveryShape(event) {
  if (!event || typeof event !== 'object' || Array.isArray(event)) {
    return ['no-envelope',
      'no event payload was supplied, so the delivery shape is unknown.'];
  }
  if (!event.event_context) {
    return ['no-event-context',
      'the envelope carries no event_context. Either it predates the field or it ' +
      'is a hand-written fixture; with no handle there is nothing to expand and ' +
      'nothing to conclude.'];
  }

  const authorizations = event.authorizations;
  if (!Array.isArray(authorizations) || authorizations.length === 0) {
    return ['no-authorizations',
      'event_context is present but authorizations is missing or empty, so the ' +
      'handler has no installation named at all and must expand the context ' +
      'before it can do anything.'];
  }

  const shared = event.is_ext_shared ? ' The envelope is also marked is_ext_shared.' : '';
  if (authorizations.length === 1) {
    return ['single-authorization',
      'one entry in authorizations, which is the normal truncated shape rather ' +
      'than a count of interested installations. Code that treats this length as ' +
      `the audience is the bug.${shared}`];
  }
  return ['multiple-authorizations',
    `${authorizations.length} entries in authorizations. Expand event_context ` +
    `anyway: the array is capped, so more is not the same as all.${shared}`];
}

/**
 * Three-way read of the app-level probe. Pure.
 * Deliberately coarse: all this note needs to know is whether the expansion call
 * is available.
 */
export function capability(body) {
  if (body?.ok === true) return ['present', 'the expansion call answered ok'];
  const error = body?.error ?? '<no error field>';
  if (error === 'invalid_event_context' || error === 'event_context_invalid') {
    return ['present',
      'the probe context was refused as an argument, which means the credential ' +
      'and the scope were accepted before it'];
  }
  if (error === 'missing_scope') {
    return ['absent',
      'missing_scope: the app-level token was minted without authorizations:read, ' +
      'so an event cannot be expanded'];
  }
  return ['unknown', `error=${error}, which says nothing conclusive about the scope`];
}

/**
 * Combine tenancy with capability into a severity. Pure.
 */
export function fanoutRisk(tenancyState, capabilityState) {
  if (tenancyState === 'empty-store' || capabilityState === 'unknown') {
    return ['not-assessed',
      'one of the two inputs is missing, so no severity can be assigned. Report ' +
      'it as unknown rather than as clean.'];
  }

  if (capabilityState === 'absent') {
    if (tenancyState === 'enterprise-install') {
      return ['dropping-workspaces-in-org',
        'an org-wide installation with no way to expand an event. The workspace ' +
        'named in the truncated array is served and every other workspace in the ' +
        'org is silently skipped.'];
    }
    if (tenancyState === 'multi-workspace') {
      return ['dropping-installations',
        'more than one installation and no way to expand an event. Each event is ' +
        'handled for exactly one tenant, which is why the bot appears to work for ' +
        'some customers and not others.'];
    }
    return ['dormant',
      'one workspace, so nothing is being dropped today. The day a second ' +
      'installation appears this becomes a correctness bug with no error ' +
      'attached, so fix it before you sell it.'];
  }

  if (tenancyState === 'single-workspace') {
    return ['capability-unused',
      'the scope is present and there is only one installation to serve. Nothing ' +
      'to do, and nothing wasted.'];
  }
  return ['capability-present',
    'the expansion call is available. Whether the handler actually calls it, ' +
    'follows the cursor and processes the event once per authorization is not ' +
    'visible from the API: read the handler.'];
}

async function get(url, token, params = {}) {
  const qs = new URLSearchParams(params).toString();
  const res = await fetch(qs ? `${url}?${qs}` : url, {
    headers: { Authorization: `Bearer ${token}` },
  });
  try {
    return await res.json();
  } catch {
    return { ok: false, error: 'unparseable_body' };
  }
}

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 installsPath = arg(args, '--installs');
  if (!installsPath) {
    console.error('usage: --installs installs.json [--event sample-event.json]');
    process.exitCode = 2;
    return;
  }
  const eventPath = arg(args, '--event');
  const appTokenEnv = arg(args, '--app-token-env', 'SLACK_APP_TOKEN');
  const botTokenEnv = arg(args, '--bot-token-env', 'SLACK_BOT_TOKEN');

  const installs = JSON.parse(await readFile(installsPath, 'utf8'));
  const event = eventPath ? JSON.parse(await readFile(eventPath, 'utf8')) : null;

  const [tenState, tenDetail] = tenancy(installs);
  const [shapeState, shapeDetail] = deliveryShape(event);

  const appToken = process.env[appTokenEnv];
  if (!appToken) {
    console.error(`${appTokenEnv} is not set; the capability cannot be probed`);
    process.exitCode = 2;
    return;
  }

  const botToken = process.env[botTokenEnv];
  if (botToken) {
    const identity = await get(`${API}auth.test`, botToken);
    console.log(`app: ${identity.app_id ?? '<unknown app_id>'} in team ` +
                `${identity.team ?? identity.team_id ?? '<unknown team>'}`);
  }

  const [capState, capDetail] = capability(
    await get(`${API}apps.event.authorizations.list`, appToken,
      { event_context: PROBE_CONTEXT }));
  const [verdict, why] = fanoutRisk(tenState, capState);

  console.log(`${verdict.padEnd(28)} ${why}`);
  console.log(`  tenancy:    ${tenState} -- ${tenDetail}`);
  console.log(`  envelope:   ${shapeState} -- ${shapeDetail}`);
  console.log(`  capability: ${capState} -- ${capDetail}`);

  if (capState === 'absent') {
    console.warn('  repair: generate an app-level token with authorizations:read on ' +
                 `Basic Information, and deploy it as ${appTokenEnv}`);
    console.warn('  repair: in the handler, expand payload.event_context via ' +
                 'apps.event.authorizations.list, page through ' +
                 'response_metadata.next_cursor, and process the event once per ' +
                 "authorization using that installation's own token");
    console.warn('  repair: deduplicate on the pair of event id and authorization, ' +
                 'not on the event id alone');
  }

  process.exitCode = FAILING.has(verdict) ? 1 : 0;
}

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

Add a test

Two assertions carry the note. The first is that an authorizations array of length one is reported as a truncation rather than as an audience — get that wrong and the script confirms the bug it is meant to find. The second is that the same missing scope produces a failing verdict for a multi-tenant store and a non-failing one for a single workspace, which is the whole reason severity is computed rather than asserted.

test_slack_event_fanout_audit.py
from slack_event_fanout_audit import (capability, delivery_shape, fanout_risk,
                                       tenancy)


def test_distinct_teams_make_the_app_multi_install():
    state, detail = tenancy([{"team_id": "T1"}, {"team_id": "T2"}, {"team_id": "T2"}])
    assert state == "multi-workspace"
    assert "2 distinct" in detail


def test_an_org_wide_install_outranks_the_team_count():
    state, _ = tenancy([{"team_id": "T1", "is_enterprise_install": True}])
    assert state == "enterprise-install"


def test_one_workspace_is_not_a_multi_install_app():
    assert tenancy([{"team_id": "T1"}])[0] == "single-workspace"
    assert tenancy([])[0] == "empty-store"


def test_a_single_authorization_is_truncation_not_an_audience():
    state, detail = delivery_shape({"event_context": "ec-1",
                                    "authorizations": [{"team_id": "T1"}]})
    assert state == "single-authorization"
    assert "truncated shape" in detail


def test_an_externally_shared_envelope_is_called_out():
    _, detail = delivery_shape({"event_context": "ec-1", "is_ext_shared": True,
                                "authorizations": [{"team_id": "T1"}]})
    assert "is_ext_shared" in detail


def test_a_missing_event_context_stops_the_envelope_read():
    assert delivery_shape({"authorizations": []})[0] == "no-event-context"
    assert delivery_shape(None)[0] == "no-envelope"


def test_the_probe_reduces_to_three_values():
    assert capability({"ok": False, "error": "invalid_event_context"})[0] == "present"
    assert capability({"ok": False, "error": "missing_scope"})[0] == "absent"
    assert capability({"ok": False, "error": "ratelimited"})[0] == "unknown"


def test_missing_scope_on_a_multi_tenant_app_is_the_failing_finding():
    state, detail = fanout_risk("multi-workspace", "absent")
    assert state == "dropping-installations"
    assert "some customers and not others" in detail


def test_the_same_gap_on_one_workspace_is_dormant_rather_than_failing():
    state, detail = fanout_risk("single-workspace", "absent")
    assert state == "dormant"
    assert "before you sell it" in detail


def test_an_org_wide_install_gets_its_own_severity():
    assert fanout_risk("enterprise-install", "absent")[0] == "dropping-workspaces-in-org"


def test_having_the_scope_is_not_a_claim_that_the_handler_uses_it():
    state, detail = fanout_risk("multi-workspace", "present")
    assert state == "capability-present"
    assert "read the handler" in detail


def test_a_missing_input_downgrades_to_not_assessed():
    assert fanout_risk("empty-store", "present")[0] == "not-assessed"
    assert fanout_risk("multi-workspace", "unknown")[0] == "not-assessed"
slack-event-fanout-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { capability, deliveryShape, fanoutRisk, tenancy } from './slack-event-fanout-audit.mjs';

test('distinct teams make the app multi-install', () => {
  const [state, detail] = tenancy([{ team_id: 'T1' }, { team_id: 'T2' }, { team_id: 'T2' }]);
  assert.equal(state, 'multi-workspace');
  assert.match(detail, /2 distinct/);
});

test('an org-wide install outranks the team count', () => {
  assert.equal(tenancy([{ team_id: 'T1', is_enterprise_install: true }])[0],
    'enterprise-install');
});

test('one workspace is not a multi-install app', () => {
  assert.equal(tenancy([{ team_id: 'T1' }])[0], 'single-workspace');
  assert.equal(tenancy([])[0], 'empty-store');
});

test('a single authorization is truncation not an audience', () => {
  const [state, detail] = deliveryShape({
    event_context: 'ec-1', authorizations: [{ team_id: 'T1' }],
  });
  assert.equal(state, 'single-authorization');
  assert.match(detail, /truncated shape/);
});

test('an externally shared envelope is called out', () => {
  const [, detail] = deliveryShape({
    event_context: 'ec-1', is_ext_shared: true, authorizations: [{ team_id: 'T1' }],
  });
  assert.match(detail, /is_ext_shared/);
});

test('a missing event_context stops the envelope read', () => {
  assert.equal(deliveryShape({ authorizations: [] })[0], 'no-event-context');
  assert.equal(deliveryShape(null)[0], 'no-envelope');
});

test('the probe reduces to three values', () => {
  assert.equal(capability({ ok: false, error: 'invalid_event_context' })[0], 'present');
  assert.equal(capability({ ok: false, error: 'missing_scope' })[0], 'absent');
  assert.equal(capability({ ok: false, error: 'ratelimited' })[0], 'unknown');
});

test('missing scope on a multi-tenant app is the failing finding', () => {
  const [state, detail] = fanoutRisk('multi-workspace', 'absent');
  assert.equal(state, 'dropping-installations');
  assert.match(detail, /some customers and not others/);
});

test('the same gap on one workspace is dormant rather than failing', () => {
  const [state, detail] = fanoutRisk('single-workspace', 'absent');
  assert.equal(state, 'dormant');
  assert.match(detail, /before you sell it/);
});

test('having the scope is not a claim that the handler uses it', () => {
  const [state, detail] = fanoutRisk('multi-workspace', 'present');
  assert.equal(state, 'capability-present');
  assert.match(detail, /read the handler/);
});

test('a missing input downgrades to not assessed', () => {
  assert.equal(fanoutRisk('empty-store', 'present')[0], 'not-assessed');
  assert.equal(fanoutRisk('multi-workspace', 'unknown')[0], 'not-assessed');
});

FAQ

Why does Slack send one event instead of one per installation?

Because it does not know how your app is organised. Fan-out is a decision about sharding, batching and deduplication that belongs to you, so Slack sends a single delivery with a handle, event_context, and a method that expands it. The design pushes the choice to the side that can make it, at the cost of a step that is easy to skip.

Is a one-entry authorizations array ever the true audience?

For a single-workspace app, yes, trivially. For anything else, treat it as a sample: the array is documented as truncated and the length is not a count. The safe rule is to never branch on that length at all, since the code that expands event_context works correctly whether there is one interested installation or forty.

Does this affect a single-workspace app at all?

Not today. The script reports it as dormant and does not fail the run, because nothing is being dropped and the repair is a token regeneration nobody needs yet. It matters the moment a second workspace installs, and since that day usually arrives without a deploy, the finding is worth carrying on the roadmap rather than in an incident.

Can the script tell me whether my handler actually fans out?

No, and it says so in its own output. The API can show that the capability exists; it cannot see whether your code calls apps.event.authorizations.list, follows response_metadata.next_cursor, or processes the event once per authorization. That part is a source review, and treating a present scope as proof of correct behaviour is exactly the mistake this note is about.

How should deduplication work once fan-out is in place?

Key on the pair of event id and authorization rather than on the event id alone. A single event legitimately produces several units of work, one per installation, and a dedupe key built from the event id will discard all but the first of them, which reproduces the original bug through a different route.

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.