Diagnostic Slack
team_added_to_org: user ids are renumbered by the migration
The migration finished on Saturday and the app came back on its own, which is what everybody hoped for. On Monday a report comes in that notifications are going to the wrong people, or to nobody, and the log is full of user_not_found for ids that resolved perfectly a week ago.
The error that preceded all this was {"ok": false, "error": "team_added_to_org"} — the workspace associated with your request is currently undergoing migration to an Enterprise Organization. Web API and other platform operations will be intermittently unavailable until the transition is complete. The outage was the visible half. The half that costs you Monday is that the workspace's user identifiers now have organization-level equivalents, and every one you cached is a workspace-local id for a workspace that is no longer standalone.
Intermittently unavailable is the important word in the error, and it is the word that separates this from the other migration error. Some calls succeed during the window. A health check that samples once and gets a good answer will report the installation healthy while two thirds of its work is failing, so the window has to be measured over a run of observations rather than a single one.
When the window closes, the identifiers have moved. A workspace inside an Enterprise organization has organization-level user ids beginning with W alongside the workspace-local ones beginning with U, and Slack publishes exactly one method for converting between them: migration.exchange, which takes up to 400 user ids per request, needs the tokens.basic scope, and returns a user_id_map plus an invalid_user_ids list. Passing to_old=true converts in the other direction.
So the audit is a cache audit rather than an availability one. Feed it the user ids your database holds for that workspace, batch them by 400, and read the map: an id that maps to itself is fine, an id that maps to a different one is a row in your database pointing at nothing, and an id that comes back in invalid_user_ids is not recognised in this organization at all.
The problem in plain words
The first difficulty is that the failure is delayed and the cause is not. The migration is loud — errors everywhere, for hours — and then it ends and everything works, which reads as a full recovery. The consequences arrive days later as user_not_found on ids nobody has touched, or worse, as a notification delivered to the wrong person because a stale id was reused. By then the migration is not in anybody's mind as a possible cause, because the migration was last week and it resolved.
The second is that intermittency defeats the instruments. During the window some calls answer and some do not, so a monitor that samples the installation once every five minutes will show a flapping service rather than a migration, and a retry that succeeds on the third attempt will hide the condition entirely. The error string is the only thing that names what is happening, and it only appears on the attempts that failed — which are the ones a retry decorator swallows.
The third is that the id problem is invisible to every ordinary check. Your database is internally consistent. The ids are well-formed. Nothing in your app can tell that a U id has been superseded, because the id has not become malformed, it has become historical. The only way to find out is to ask Slack, and the only method that answers is the one built for this exact purpose.
And the fourth is what you are left with if the ids cannot be recovered. If the only key you ever stored for a person was their Slack user id, an id that no longer resolves is a row you cannot re-associate with a human being. If you stored an email address or your own internal id alongside it, the same event is a re-resolution: enumerate the workspace again, match on the stable key, write the new id. That difference is decided long before the migration happens.
Why it happens
The window is read over a run of observations, not one. Because the documented condition is intermittent, a single probe is not evidence of anything: a success does not mean the migration finished and a failure does not mean it has not. The script asks for the observations you recorded and reports the proportion, because a proportion is what an intermittent condition actually looks like.
The remap check uses the method Slack built for it, and no other. migration.exchange exists specifically to map workspace-local ids to organization-level ones. Guessing at the mapping, or trying to derive a W id from a U id, is not possible; probing each id with users.info tells you only that it failed, not what it became.
Four outcomes per id, and the fourth is the one to be careful with. An id can be unchanged, remapped, listed as invalid, or simply not returned. The last one is not the same as unchanged, and a script that treats a missing key as “no change” will report a clean cache while quietly ignoring the ids it could not account for. It is reported as unresolved and counted separately.
Batches are 400 because the method says 400. The documented limit is 400 user ids per request, and a cache of nine thousand is twenty-three requests rather than one. The batching is a pure function so the boundary is testable without a token, which matters because getting it wrong produces too_many_users against a customer who is already having a bad week.
The stable-key question is asked even when nothing has moved. The most valuable output of this check, on a workspace that has not migrated at all, is the count of records whose only identifier is a Slack id. That is the number that decides whether a future migration is a re-resolution or a data loss, and it is knowable today.
The availability half is deliberately handed over. What the scheduler should do while the workspace is unreachable — suspend rather than fail, back off in hours, and keep the cleanup away from the row — is a different decision with its own note. This one starts where that one ends: the calls are answering again, and now the identifiers need checking.
The fix, as a flow
The red arrow is the third, which is the latest in this batch, and the delay is the whole character of the failure. The migration ended on the Saturday. The app recovered by itself, which is what everyone hoped for and also what stops anybody looking further. Two days later a cached identifier is used the way it has been used for a year, and the id is not malformed, not expired and not revoked. It is historical. The last box is the one worth sitting with, because a stale id that fails is a bug and a stale id that resolves to somebody else is an incident. The fix branch sorts identifiers against one map, and its middle row is the discipline: an id that came back in neither list is unresolved, and calling it unchanged is how a cache audit reports clean while leaving rows unaccounted for.
How to fix it
Record the error rather than letting the retry swallow it
An intermittent failure is only visible if the failed attempts are logged with their error string. If your client retries three times and reports success, team_added_to_org never reaches your logs and the migration is invisible until its consequences arrive.
Check that migration.exchange is available before trusting its answer
exchange_availability separates available from not-an-enterprise (not_enterprise_team, meaning this workspace is not in an organization), scope-missing (tokens.basic is not granted), migration-in-progress and batch-too-large. Only the first justifies reading the map.
Batch the cached ids by 400
chunk_users splits your id list into requests of the documented maximum. It clamps the size rather than trusting the caller, because the failure for exceeding it is too_many_users and it costs a round trip against a workspace you are already worried about.
Read four outcomes per id, and keep the fourth separate
remap_verdict answers unchanged, remapped, invalid or not-returned. Only the first means the row in your database is still correct. not-returned is explicitly not a synonym for unchanged.
Take the census before you take any action
cache_verdict answers clean, stale-ids, unrecognised-ids or partially-assessed with counts. Nine remapped ids out of nine thousand is an afternoon. Nine thousand out of nine thousand is a re-enumeration, and it is better to know which before starting.
Check whether you can re-resolve at all
stable_key_audit counts the records whose only identifier is a Slack id. Those are the ones a remap turns into orphans. Storing an email address or your own key alongside the Slack id converts the whole class of problem into a lookup.
How to check it worked
After re-resolving, run it again with the same id list. Every row should read unchanged, and the stable-key line should be the number you want it to be rather than the number you inherited.
python3 slack_id_remap_audit.py --cache people.json
# context enterprise enterprise_id E04NORTHWIND, so workspace-local ids
# may have organization-level equivalents
# exchange available migration.exchange answered for the first batch
# batches 3 1174 cached id(s) in requests of at most 400
# id U06UBSUN5 remapped U06UBSUN5 is W06M56XJM at the organization
# level; every row keyed on the old id points at nothing
# id W06UAZ65Q unchanged already an organization level id
# id U21ABZZXX invalid in invalid_user_ids: Slack does not
# recognise it in this organization at all
# cache stale-ids 842 remapped, 1 invalid, 331 unchanged, of 1174
# keys some-stable 409 of 1174 record(s) carry only a Slack id
# verdict 2 finding(s)
# repair: re-resolve the 842 remapped id(s) from the user_id_map before any of them
# is used again; a remapped id is a row pointing at nothing
# repair: 409 record(s) carry no stable key of your own; store an email address or
# your own id alongside the Slack id so the next remap is a lookup rather
# than a loss
The full code
Two GETs and five pure functions. The GETs are auth.test, for whether an organization is involved at all, and migration.exchange, which is the only method that answers the question this note asks. The function to read carefully is remap_verdict, because it has four outcomes rather than the obvious two: an id that came back in neither the map nor the invalid list is reported as unresolved and never quietly filed as unchanged.
"""Find cached Slack user ids that a Grid migration has superseded.
Read only. Two GET methods: auth.test, for whether an organization is involved,
and migration.exchange, which maps workspace-local user ids to their
organization-level equivalents. migration.exchange returns a mapping; it changes
nothing, and this script never writes to your database either. The repairs are
printed for a human to run.
The method is documented as accepting up to 400 user ids per request, needing
the tokens.basic scope, and returning user_id_map alongside invalid_user_ids.
Passing to_old converts in the opposite direction, which this script does not
do: the question here is what a cached old id became.
"""
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_id_remap_audit")
API = "https://slack.com/api/"
# The documented maximum number of user ids in one migration.exchange request.
MAX_USERS_PER_REQUEST = 400
# Fields that count as a key of your own: something that survives a remap.
STABLE_KEY_FIELDS = ("email", "external_id", "local_id", "person_id")
def id_shape(user_id):
"""Which kind of Slack user id is this? Pure.
Returns (state, detail).
workspace-local a U id, local to one workspace.
org-global a W id, the organization-level identity.
bot a B id, which is not a person.
absent nothing to classify.
unrecognised anything else; reported rather than assumed.
"""
uid = str(user_id or "").strip().upper()
if not uid:
return ("absent", "no id to classify")
if uid.startswith("U"):
return ("workspace-local", "%s is a workspace-local id, which is the kind a "
"migration supersedes" % uid)
if uid.startswith("W"):
return ("org-global", "%s is already an organization-level id" % uid)
if uid.startswith("B"):
return ("bot", "%s is a bot id rather than a person" % uid)
return ("unrecognised", "%s is not a shape this script recognises" % uid)
def exchange_availability(body):
"""Can migration.exchange answer for this token and workspace? Pure.
Returns (state, detail).
available the map came back.
not-an-enterprise not_enterprise_team: this workspace is not in an org,
so there is nothing to exchange.
scope-missing tokens.basic was not granted.
migration-in-progress the window has not closed; the answer would be
unreliable even where the call succeeds.
batch-too-large too_many_users: more than the documented maximum.
transient try again; nothing was learned.
unassessable anything else.
"""
doc = body or {}
if doc.get("ok") is True:
return ("available", "migration.exchange answered, so the map can be read")
error = str(doc.get("error") or "")
if error == "not_enterprise_team":
return ("not-an-enterprise", "not_enterprise_team: this workspace is not part "
"of an organization, so no id has an "
"organization-level equivalent")
if error == "missing_scope":
return ("scope-missing", "missing_scope: migration.exchange needs tokens.basic")
if error in ("org_login_required", "team_added_to_org"):
return ("migration-in-progress", "%s: the window has not closed, and an answer "
"taken during it would be unreliable even "
"where the call succeeds" % error)
if error == "too_many_users":
return ("batch-too-large", "too_many_users: the documented maximum is %d ids "
"per request" % MAX_USERS_PER_REQUEST)
if error in ("ratelimited", "service_unavailable", "internal_error",
"unparseable_body"):
return ("transient", "%s: try again; this call established nothing" % error)
if not error:
return ("unassessable", "no ok field and no error, so nothing can be read "
"from this response")
return ("unassessable", "%s, which is a different problem" % error)
def chunk_users(user_ids, size=MAX_USERS_PER_REQUEST):
"""Split cached ids into requests of at most the documented maximum. Pure.
The size is clamped rather than trusted, because exceeding it costs a round
trip and a too_many_users error against a workspace that has just migrated.
"""
ids = [str(u).strip() for u in (user_ids or []) if str(u).strip()]
try:
step = int(size)
except (TypeError, ValueError):
step = MAX_USERS_PER_REQUEST
step = max(1, min(step, MAX_USERS_PER_REQUEST))
return [ids[i:i + step] for i in range(0, len(ids), step)]
def remap_verdict(cached_id, id_map, invalid_ids=()):
"""What happened to one cached id? Pure.
Four outcomes, and the fourth is the one to be careful with: an id that came
back in neither the map nor the invalid list is unresolved, not unchanged.
Returns (state, detail).
"""
uid = str(cached_id or "").strip()
mapping = id_map or {}
invalid = {str(i) for i in (invalid_ids or ())}
if not uid:
return ("absent", "no id to check")
if uid in invalid:
return ("invalid", "%s came back in invalid_user_ids: Slack does not "
"recognise it in this organization at all" % uid)
if uid not in mapping:
return ("not-returned", "%s appeared in neither the map nor the invalid list; "
"treat it as unresolved rather than as unchanged"
% uid)
mapped = str(mapping[uid])
if mapped == uid:
return ("unchanged", "%s is its own organization-level id, so nothing keyed "
"on it moved" % uid)
if id_shape(uid)[0] == "workspace-local" and id_shape(mapped)[0] == "org-global":
return ("remapped", "%s is %s at the organization level; every row keyed on "
"the old id points at nothing" % (uid, mapped))
return ("changed", "%s now maps to %s, which is not the shape this script "
"expected" % (uid, mapped))
def cache_verdict(states):
"""The census over the whole cache. Pure. Returns (state, counts)."""
seen = [str(s) for s in (states or [])]
counts = {"ids": len(seen), "remapped": 0, "invalid": 0, "unchanged": 0,
"unresolved": 0}
for state in seen:
if state == "remapped":
counts["remapped"] += 1
elif state == "invalid":
counts["invalid"] += 1
elif state == "unchanged":
counts["unchanged"] += 1
else:
counts["unresolved"] += 1
if not seen:
return ("nothing-checked", counts)
if counts["remapped"]:
return ("stale-ids", counts)
if counts["invalid"]:
return ("unrecognised-ids", counts)
if counts["unresolved"]:
return ("partially-assessed", counts)
return ("clean", counts)
def stable_key_audit(records):
"""How many records could survive a remap? Pure. Returns (state, counts).
A record carrying only a Slack user id becomes an orphan the moment that id
is superseded. A record carrying an email address or an id of your own turns
the same event into a lookup.
"""
rows = list(records or [])
counts = {"records": len(rows), "stable": 0, "slack_only": 0}
for row in rows:
doc = row if isinstance(row, dict) else {}
if any(str(doc.get(field) or "").strip() for field in STABLE_KEY_FIELDS):
counts["stable"] += 1
else:
counts["slack_only"] += 1
if not rows:
return ("nothing-checked", counts)
if not counts["slack_only"]:
return ("all-stable", counts)
if not counts["stable"]:
return ("slack-id-only", counts)
return ("some-stable", counts)
def get(session, method, params=None):
"""One GET against the Web API. Returns the parsed body."""
r = session.get(API + method, params=params or {}, timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def load_records(path, ids):
"""Records as your database holds them, or bare ids from the command line."""
if path:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
return [{"slack_user_id": u.strip()} for u in ids.split(",") if u.strip()]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding a token with tokens.basic")
ap.add_argument("--cache", help="JSON array of your records; each needs "
"slack_user_id, plus whatever else you store")
ap.add_argument("--ids", default="", help="comma separated user ids, if you have "
"no export to hand")
ap.add_argument("--team", default="", help="team_id, required when the token is "
"an organization-level one")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("token missing set %s to a token with tokens.basic",
args.token_env)
return 2
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + token})
auth = get(session, "auth.test")
if auth.get("ok") is not True:
log.error("context unreadable auth.test answered %s",
auth.get("error") or "nothing")
return 2
enterprise = auth.get("enterprise_id") or ""
if enterprise:
log.info("context enterprise enterprise_id %s, so workspace-local "
"ids may have organization-level equivalents", enterprise)
else:
log.info("context standalone no enterprise_id, so nothing here has "
"been renumbered yet; the stable-key count below is still worth "
"reading")
records = load_records(args.cache, args.ids)
cached = [str((r or {}).get("slack_user_id") or "") for r in records]
batches = chunk_users([u for u in cached if u])
log.info("batches %-18s %d cached id(s) in requests of at most %d",
str(len(batches)), len([u for u in cached if u]), MAX_USERS_PER_REQUEST)
mapping, invalid, availability = {}, [], "nothing-checked"
for batch in batches:
params = {"users": ",".join(batch)}
if args.team:
params["team_id"] = args.team
body = get(session, "migration.exchange", params)
availability, why = exchange_availability(body)
if availability != "available":
(log.warning)("exchange %-18s %s", availability, why)
break
mapping.update(body.get("user_id_map") or {})
invalid.extend(body.get("invalid_user_ids") or [])
if availability == "available":
log.info("exchange %-18s migration.exchange answered for %d batch(es)",
availability, len(batches))
states = []
for uid in cached:
if not uid:
continue
state, why = remap_verdict(uid, mapping, invalid)
states.append(state)
(log.info if state == "unchanged" else log.warning)(
"id %-18s %-11s %s", uid, state, why)
cache_state, counts = cache_verdict(states)
(log.info if cache_state == "clean" else log.warning)(
"cache %-18s %d remapped, %d invalid, %d unchanged, of %d", cache_state,
counts["remapped"], counts["invalid"], counts["unchanged"], counts["ids"])
key_state, key_counts = stable_key_audit(records)
(log.info if key_state == "all-stable" else log.warning)(
"keys %-18s %d of %d record(s) carry only a Slack id", key_state,
key_counts["slack_only"], key_counts["records"])
findings = []
if counts["remapped"]:
findings.append("re-resolve the %d remapped id(s) from the user_id_map before "
"any of them is used again; a remapped id is a row pointing "
"at nothing" % counts["remapped"])
if counts["invalid"]:
findings.append("%d id(s) came back in invalid_user_ids; re-enumerate the "
"workspace and match those records on a key of your own"
% counts["invalid"])
if key_counts["slack_only"]:
findings.append("%d record(s) carry no stable key of your own; store an email "
"address or your own id alongside the Slack id so the next "
"remap is a lookup rather than a loss"
% key_counts["slack_only"])
if not findings:
log.info("verdict clean nothing cached has been superseded")
return 0
log.warning("verdict %d finding(s)", len(findings))
for line in findings:
log.warning(" repair: %s", line)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Find cached Slack user ids that a Grid migration has superseded.
*
* Read only. Two GET methods: auth.test, for whether an organization is
* involved, and migration.exchange, which maps workspace-local user ids to
* their organization-level equivalents. migration.exchange returns a mapping;
* it changes nothing, and this script never writes to your database either.
*
* The method is documented as accepting up to 400 user ids per request, needing
* the tokens.basic scope, and returning user_id_map alongside invalid_user_ids.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// The documented maximum number of user ids in one migration.exchange request.
export const MAX_USERS_PER_REQUEST = 400;
// Fields that count as a key of your own: something that survives a remap.
export const STABLE_KEY_FIELDS = ['email', 'external_id', 'local_id', 'person_id'];
/**
* Which kind of Slack user id is this? Pure.
* Returns [state, detail]; workspace-local, org-global, bot, absent,
* unrecognised.
*/
export function idShape(userId) {
const uid = String(userId ?? '').trim().toUpperCase();
if (!uid) return ['absent', 'no id to classify'];
if (uid.startsWith('U')) {
return ['workspace-local', `${uid} is a workspace-local id, which is the kind a `
+ 'migration supersedes'];
}
if (uid.startsWith('W')) {
return ['org-global', `${uid} is already an organization-level id`];
}
if (uid.startsWith('B')) return ['bot', `${uid} is a bot id rather than a person`];
return ['unrecognised', `${uid} is not a shape this script recognises`];
}
/**
* Can migration.exchange answer for this token and workspace? Pure.
* Returns [state, detail].
*/
export function exchangeAvailability(body) {
const doc = body ?? {};
if (doc.ok === true) {
return ['available', 'migration.exchange answered, so the map can be read'];
}
const error = String(doc.error ?? '');
if (error === 'not_enterprise_team') {
return ['not-an-enterprise', 'not_enterprise_team: this workspace is not part of '
+ 'an organization, so no id has an organization-level equivalent'];
}
if (error === 'missing_scope') {
return ['scope-missing', 'missing_scope: migration.exchange needs tokens.basic'];
}
if (error === 'org_login_required' || error === 'team_added_to_org') {
return ['migration-in-progress', `${error}: the window has not closed, and an `
+ 'answer taken during it would be unreliable even where the call succeeds'];
}
if (error === 'too_many_users') {
return ['batch-too-large', 'too_many_users: the documented maximum is '
+ `${MAX_USERS_PER_REQUEST} ids per request`];
}
if (['ratelimited', 'service_unavailable', 'internal_error',
'unparseable_body'].includes(error)) {
return ['transient', `${error}: try again; this call established nothing`];
}
if (!error) {
return ['unassessable', 'no ok field and no error, so nothing can be read from '
+ 'this response'];
}
return ['unassessable', `${error}, which is a different problem`];
}
/** Split cached ids into requests of at most the documented maximum. Pure. */
export function chunkUsers(userIds, size = MAX_USERS_PER_REQUEST) {
const ids = (userIds ?? []).map((u) => String(u).trim()).filter(Boolean);
let step = Number.parseInt(size, 10);
if (!Number.isFinite(step)) step = MAX_USERS_PER_REQUEST;
step = Math.max(1, Math.min(step, MAX_USERS_PER_REQUEST));
const out = [];
for (let i = 0; i < ids.length; i += step) out.push(ids.slice(i, i + step));
return out;
}
/**
* What happened to one cached id? Pure.
* Four outcomes, and an id that came back in neither the map nor the invalid
* list is unresolved, not unchanged.
*/
export function remapVerdict(cachedId, idMap, invalidIds = []) {
const uid = String(cachedId ?? '').trim();
const mapping = idMap ?? {};
const invalid = new Set((invalidIds ?? []).map(String));
if (!uid) return ['absent', 'no id to check'];
if (invalid.has(uid)) {
return ['invalid', `${uid} came back in invalid_user_ids: Slack does not `
+ 'recognise it in this organization at all'];
}
if (!Object.prototype.hasOwnProperty.call(mapping, uid)) {
return ['not-returned', `${uid} appeared in neither the map nor the invalid list; `
+ 'treat it as unresolved rather than as unchanged'];
}
const mapped = String(mapping[uid]);
if (mapped === uid) {
return ['unchanged', `${uid} is its own organization-level id, so nothing keyed `
+ 'on it moved'];
}
if (idShape(uid)[0] === 'workspace-local' && idShape(mapped)[0] === 'org-global') {
return ['remapped', `${uid} is ${mapped} at the organization level; every row `
+ 'keyed on the old id points at nothing'];
}
return ['changed', `${uid} now maps to ${mapped}, which is not the shape this `
+ 'script expected'];
}
/** The census over the whole cache. Pure. Returns [state, counts]. */
export function cacheVerdict(states) {
const seen = (states ?? []).map(String);
const counts = {
ids: seen.length, remapped: 0, invalid: 0, unchanged: 0, unresolved: 0,
};
for (const state of seen) {
if (state === 'remapped') counts.remapped += 1;
else if (state === 'invalid') counts.invalid += 1;
else if (state === 'unchanged') counts.unchanged += 1;
else counts.unresolved += 1;
}
if (!seen.length) return ['nothing-checked', counts];
if (counts.remapped) return ['stale-ids', counts];
if (counts.invalid) return ['unrecognised-ids', counts];
if (counts.unresolved) return ['partially-assessed', counts];
return ['clean', counts];
}
/** How many records could survive a remap? Pure. Returns [state, counts]. */
export function stableKeyAudit(records) {
const rows = records ?? [];
const counts = { records: rows.length, stable: 0, slack_only: 0 };
for (const row of rows) {
const doc = (row && typeof row === 'object') ? row : {};
const stable = STABLE_KEY_FIELDS.some((f) => String(doc[f] ?? '').trim());
if (stable) counts.stable += 1;
else counts.slack_only += 1;
}
if (!rows.length) return ['nothing-checked', counts];
if (!counts.slack_only) return ['all-stable', counts];
if (!counts.stable) return ['slack-id-only', counts];
return ['some-stable', counts];
}
function arg(args, name, fallback = '') {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function read(token, method, params) {
const qs = new URLSearchParams(params ?? {}).toString();
const r = await fetch(`${API}${method}${qs ? `?${qs}` : ''}`,
{ 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 tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`token missing set ${tokenEnv} to a token with `
+ 'tokens.basic');
process.exitCode = 2;
return;
}
const auth = await read(token, 'auth.test', {});
if (auth.ok !== true) {
console.error(`context unreadable auth.test answered `
+ `${auth.error ?? 'nothing'}`);
process.exitCode = 2;
return;
}
const enterprise = auth.enterprise_id ?? '';
if (enterprise) {
console.log(`context enterprise enterprise_id ${enterprise}, so `
+ 'workspace-local ids may have organization-level equivalents');
} else {
console.log('context standalone no enterprise_id, so nothing here has '
+ 'been renumbered yet; the stable-key count below is still worth reading');
}
const cachePath = arg(args, '--cache');
const records = cachePath
? JSON.parse(await readFile(cachePath, 'utf8'))
: arg(args, '--ids').split(',').map((u) => u.trim()).filter(Boolean)
.map((u) => ({ slack_user_id: u }));
const cached = records.map((r) => String((r ?? {}).slack_user_id ?? ''));
const present = cached.filter(Boolean);
const batches = chunkUsers(present);
console.log(`batches ${String(batches.length).padEnd(18)} ${present.length} `
+ `cached id(s) in requests of at most ${MAX_USERS_PER_REQUEST}`);
const team = arg(args, '--team');
const mapping = {};
const invalid = [];
let availability = 'nothing-checked';
for (const batch of batches) {
const params = { users: batch.join(',') };
if (team) params.team_id = team;
// eslint-disable-next-line no-await-in-loop
const body = await read(token, 'migration.exchange', params);
let why;
[availability, why] = exchangeAvailability(body);
if (availability !== 'available') {
console.warn(`exchange ${availability.padEnd(18)} ${why}`);
break;
}
Object.assign(mapping, body.user_id_map ?? {});
invalid.push(...(body.invalid_user_ids ?? []));
}
if (availability === 'available') {
console.log(`exchange ${availability.padEnd(18)} migration.exchange answered `
+ `for ${batches.length} batch(es)`);
}
const states = [];
for (const uid of present) {
const [state, why] = remapVerdict(uid, mapping, invalid);
states.push(state);
const line = `id ${uid.padEnd(18)} ${state.padEnd(11)} ${why}`;
if (state === 'unchanged') console.log(line);
else console.warn(line);
}
const [cacheState, counts] = cacheVerdict(states);
const cacheLine = `cache ${cacheState.padEnd(18)} ${counts.remapped} remapped, `
+ `${counts.invalid} invalid, ${counts.unchanged} unchanged, of ${counts.ids}`;
if (cacheState === 'clean') console.log(cacheLine);
else console.warn(cacheLine);
const [keyState, keyCounts] = stableKeyAudit(records);
const keyLine = `keys ${keyState.padEnd(18)} ${keyCounts.slack_only} of `
+ `${keyCounts.records} record(s) carry only a Slack id`;
if (keyState === 'all-stable') console.log(keyLine);
else console.warn(keyLine);
const findings = [];
if (counts.remapped) {
findings.push(`re-resolve the ${counts.remapped} remapped id(s) from the `
+ 'user_id_map before any of them is used again; a remapped id is a row '
+ 'pointing at nothing');
}
if (counts.invalid) {
findings.push(`${counts.invalid} id(s) came back in invalid_user_ids; `
+ 're-enumerate the workspace and match those records on a key of your own');
}
if (keyCounts.slack_only) {
findings.push(`${keyCounts.slack_only} record(s) carry no stable key of your own; `
+ 'store an email address or your own id alongside the Slack id so the next '
+ 'remap is a lookup rather than a loss');
}
if (!findings.length) {
console.log('verdict clean nothing cached has been superseded');
return;
}
console.warn(`verdict ${findings.length} finding(s)`);
for (const line of findings) console.warn(` repair: ${line}`);
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 fixture worth reading is the user_id_map, because it is shaped exactly like the one in the method's own reference page: several workspace ids mapping to organization ids, and one id that maps to itself. That last entry is the trap the tests exist to hold: an id which maps to itself is genuinely unchanged, whereas an id which does not appear in the map at all is unresolved, and the two must never collapse into one state.
from slack_id_remap_audit import (
MAX_USERS_PER_REQUEST, cache_verdict, chunk_users, exchange_availability, id_shape,
remap_verdict, stable_key_audit,
)
# Shaped like the example on the method's own reference page: workspace ids
# mapping to organization ids, and one id that is already its own.
ID_MAP = {"U06UBSUN5": "W06M56XJM", "U06UEB62U": "W06PTT6GH",
"W06UAZ65Q": "W06UAZ65Q"}
INVALID = ["U21ABZZXX"]
def test_a_workspace_local_id_is_the_kind_that_gets_superseded():
state, detail = id_shape("U06UBSUN5")
assert state == "workspace-local"
assert "supersedes" in detail
def test_an_organization_level_id_is_recognised():
assert id_shape("W06M56XJM")[0] == "org-global"
def test_a_bot_id_is_not_a_person():
assert id_shape("B01BOT")[0] == "bot"
def test_an_empty_id_is_absent_rather_than_unrecognised():
assert id_shape("")[0] == "absent"
def test_an_unfamiliar_shape_is_reported_not_assumed():
assert id_shape("X99")[0] == "unrecognised"
def test_a_successful_exchange_is_available():
assert exchange_availability({"ok": True, "user_id_map": {}})[0] == "available"
def test_a_workspace_outside_an_organization_has_nothing_to_exchange():
state, detail = exchange_availability({"ok": False,
"error": "not_enterprise_team"})
assert state == "not-an-enterprise"
assert "not part of an organization" in detail
def test_the_scope_the_method_needs_is_named():
state, detail = exchange_availability({"ok": False, "error": "missing_scope"})
assert state == "scope-missing"
assert "tokens.basic" in detail
def test_an_answer_taken_during_the_window_is_refused():
for error in ("team_added_to_org", "org_login_required"):
state, detail = exchange_availability({"ok": False, "error": error})
assert state == "migration-in-progress"
assert "unreliable" in detail
def test_the_documented_batch_limit_is_named_in_the_error():
state, detail = exchange_availability({"ok": False, "error": "too_many_users"})
assert state == "batch-too-large"
assert str(MAX_USERS_PER_REQUEST) in detail
def test_a_rate_limit_establishes_nothing():
assert exchange_availability({"ok": False, "error": "ratelimited"})[0] == \
"transient"
def test_an_empty_body_is_unassessable():
assert exchange_availability({})[0] == "unassessable"
def test_a_small_cache_is_one_request():
assert chunk_users(["U1", "U2", "U3"]) == [["U1", "U2", "U3"]]
def test_the_batch_size_is_the_documented_maximum():
batches = chunk_users(["U%d" % i for i in range(1174)])
assert len(batches) == 3
assert len(batches[0]) == MAX_USERS_PER_REQUEST
assert len(batches[-1]) == 1174 - 2 * MAX_USERS_PER_REQUEST
def test_an_oversized_request_is_clamped_rather_than_trusted():
batches = chunk_users(["U%d" % i for i in range(500)], size=5000)
assert len(batches[0]) == MAX_USERS_PER_REQUEST
def test_blank_ids_are_dropped_before_batching():
assert chunk_users(["U1", " ", "", "U2"]) == [["U1", "U2"]]
def test_no_ids_is_no_requests():
assert chunk_users([]) == []
def test_a_remapped_id_names_both_halves():
state, detail = remap_verdict("U06UBSUN5", ID_MAP, INVALID)
assert state == "remapped"
assert "W06M56XJM" in detail
assert "points at nothing" in detail
def test_an_id_that_maps_to_itself_is_unchanged():
state, detail = remap_verdict("W06UAZ65Q", ID_MAP, INVALID)
assert state == "unchanged"
assert "nothing keyed on it moved" in detail
def test_an_id_in_the_invalid_list_is_not_recognised_at_all():
state, detail = remap_verdict("U21ABZZXX", ID_MAP, INVALID)
assert state == "invalid"
assert "invalid_user_ids" in detail
def test_an_id_in_neither_list_is_unresolved_and_never_called_unchanged():
state, detail = remap_verdict("U00MISSING", ID_MAP, INVALID)
assert state == "not-returned"
assert "rather than as unchanged" in detail
def test_an_empty_id_is_absent():
assert remap_verdict("", ID_MAP, INVALID)[0] == "absent"
def test_a_cache_with_any_remap_is_stale():
state, counts = cache_verdict(["remapped", "unchanged", "unchanged"])
assert state == "stale-ids"
assert counts["remapped"] == 1
def test_invalid_ids_alone_are_their_own_state():
assert cache_verdict(["invalid", "unchanged"])[0] == "unrecognised-ids"
def test_unresolved_ids_alone_mean_the_check_is_incomplete():
state, counts = cache_verdict(["not-returned", "unchanged"])
assert state == "partially-assessed"
assert counts["unresolved"] == 1
def test_a_cache_where_nothing_moved_is_clean():
assert cache_verdict(["unchanged", "unchanged"])[0] == "clean"
def test_checking_no_ids_says_so():
assert cache_verdict([])[0] == "nothing-checked"
def test_a_record_with_an_email_survives_a_remap():
state, counts = stable_key_audit([{"slack_user_id": "U1",
"email": "ada@example.com"}])
assert state == "all-stable"
assert counts["stable"] == 1
def test_a_record_with_only_a_slack_id_is_the_finding():
state, counts = stable_key_audit([{"slack_user_id": "U1"}])
assert state == "slack-id-only"
assert counts["slack_only"] == 1
def test_a_mixed_cache_is_counted_rather_than_rounded():
state, counts = stable_key_audit([{"slack_user_id": "U1", "local_id": "42"},
{"slack_user_id": "U2"}])
assert state == "some-stable"
assert counts["stable"] == 1
assert counts["slack_only"] == 1
def test_a_blank_stable_key_does_not_count_as_one():
assert stable_key_audit([{"slack_user_id": "U1", "email": " "}])[0] == \
"slack-id-only"
def test_no_records_at_all_says_so():
assert stable_key_audit([])[0] == "nothing-checked"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_USERS_PER_REQUEST, cacheVerdict, chunkUsers, exchangeAvailability, idShape,
remapVerdict, stableKeyAudit,
} from './slack-id-remap-audit.mjs';
// Shaped like the example on the method's own reference page.
const ID_MAP = {
U06UBSUN5: 'W06M56XJM', U06UEB62U: 'W06PTT6GH', W06UAZ65Q: 'W06UAZ65Q',
};
const INVALID = ['U21ABZZXX'];
test('a workspace local id is the kind that gets superseded', () => {
const [state, detail] = idShape('U06UBSUN5');
assert.equal(state, 'workspace-local');
assert.match(detail, /supersedes/);
});
test('an organization level id is recognised', () => {
assert.equal(idShape('W06M56XJM')[0], 'org-global');
});
test('a bot id is not a person', () => {
assert.equal(idShape('B01BOT')[0], 'bot');
});
test('an empty id is absent rather than unrecognised', () => {
assert.equal(idShape('')[0], 'absent');
});
test('an unfamiliar shape is reported not assumed', () => {
assert.equal(idShape('X99')[0], 'unrecognised');
});
test('a successful exchange is available', () => {
assert.equal(exchangeAvailability({ ok: true, user_id_map: {} })[0], 'available');
});
test('a workspace outside an organization has nothing to exchange', () => {
const [state, detail] = exchangeAvailability({
ok: false, error: 'not_enterprise_team',
});
assert.equal(state, 'not-an-enterprise');
assert.match(detail, /not part of an organization/);
});
test('the scope the method needs is named', () => {
const [state, detail] = exchangeAvailability({ ok: false, error: 'missing_scope' });
assert.equal(state, 'scope-missing');
assert.match(detail, /tokens.basic/);
});
test('an answer taken during the window is refused', () => {
for (const error of ['team_added_to_org', 'org_login_required']) {
const [state, detail] = exchangeAvailability({ ok: false, error });
assert.equal(state, 'migration-in-progress');
assert.match(detail, /unreliable/);
}
});
test('the documented batch limit is named in the error', () => {
const [state, detail] = exchangeAvailability({ ok: false, error: 'too_many_users' });
assert.equal(state, 'batch-too-large');
assert.equal(detail.includes(String(MAX_USERS_PER_REQUEST)), true);
});
test('a rate limit establishes nothing', () => {
assert.equal(exchangeAvailability({ ok: false, error: 'ratelimited' })[0],
'transient');
});
test('an empty body is unassessable', () => {
assert.equal(exchangeAvailability({})[0], 'unassessable');
});
test('a small cache is one request', () => {
assert.deepEqual(chunkUsers(['U1', 'U2', 'U3']), [['U1', 'U2', 'U3']]);
});
test('the batch size is the documented maximum', () => {
const ids = Array.from({ length: 1174 }, (_, i) => `U${i}`);
const batches = chunkUsers(ids);
assert.equal(batches.length, 3);
assert.equal(batches[0].length, MAX_USERS_PER_REQUEST);
assert.equal(batches[batches.length - 1].length,
1174 - 2 * MAX_USERS_PER_REQUEST);
});
test('an oversized request is clamped rather than trusted', () => {
const ids = Array.from({ length: 500 }, (_, i) => `U${i}`);
assert.equal(chunkUsers(ids, 5000)[0].length, MAX_USERS_PER_REQUEST);
});
test('blank ids are dropped before batching', () => {
assert.deepEqual(chunkUsers(['U1', ' ', '', 'U2']), [['U1', 'U2']]);
});
test('no ids is no requests', () => {
assert.deepEqual(chunkUsers([]), []);
});
test('a remapped id names both halves', () => {
const [state, detail] = remapVerdict('U06UBSUN5', ID_MAP, INVALID);
assert.equal(state, 'remapped');
assert.match(detail, /W06M56XJM/);
assert.match(detail, /points at nothing/);
});
test('an id that maps to itself is unchanged', () => {
const [state, detail] = remapVerdict('W06UAZ65Q', ID_MAP, INVALID);
assert.equal(state, 'unchanged');
assert.match(detail, /nothing keyed on it moved/);
});
test('an id in the invalid list is not recognised at all', () => {
const [state, detail] = remapVerdict('U21ABZZXX', ID_MAP, INVALID);
assert.equal(state, 'invalid');
assert.match(detail, /invalid_user_ids/);
});
test('an id in neither list is unresolved and never called unchanged', () => {
const [state, detail] = remapVerdict('U00MISSING', ID_MAP, INVALID);
assert.equal(state, 'not-returned');
assert.match(detail, /rather than as unchanged/);
});
test('an empty id is absent', () => {
assert.equal(remapVerdict('', ID_MAP, INVALID)[0], 'absent');
});
test('a cache with any remap is stale', () => {
const [state, counts] = cacheVerdict(['remapped', 'unchanged', 'unchanged']);
assert.equal(state, 'stale-ids');
assert.equal(counts.remapped, 1);
});
test('invalid ids alone are their own state', () => {
assert.equal(cacheVerdict(['invalid', 'unchanged'])[0], 'unrecognised-ids');
});
test('unresolved ids alone mean the check is incomplete', () => {
const [state, counts] = cacheVerdict(['not-returned', 'unchanged']);
assert.equal(state, 'partially-assessed');
assert.equal(counts.unresolved, 1);
});
test('a cache where nothing moved is clean', () => {
assert.equal(cacheVerdict(['unchanged', 'unchanged'])[0], 'clean');
});
test('checking no ids says so', () => {
assert.equal(cacheVerdict([])[0], 'nothing-checked');
});
test('a record with an email survives a remap', () => {
const [state, counts] = stableKeyAudit([{
slack_user_id: 'U1', email: 'ada@example.com',
}]);
assert.equal(state, 'all-stable');
assert.equal(counts.stable, 1);
});
test('a record with only a slack id is the finding', () => {
const [state, counts] = stableKeyAudit([{ slack_user_id: 'U1' }]);
assert.equal(state, 'slack-id-only');
assert.equal(counts.slack_only, 1);
});
test('a mixed cache is counted rather than rounded', () => {
const [state, counts] = stableKeyAudit([{ slack_user_id: 'U1', local_id: '42' },
{ slack_user_id: 'U2' }]);
assert.equal(state, 'some-stable');
assert.equal(counts.stable, 1);
assert.equal(counts.slack_only, 1);
});
test('a blank stable key does not count as one', () => {
assert.equal(stableKeyAudit([{ slack_user_id: 'U1', email: ' ' }])[0],
'slack-id-only');
});
test('no records at all says so', () => {
assert.equal(stableKeyAudit([])[0], 'nothing-checked');
});
FAQ
What is the difference between team_added_to_org and org_login_required?
They arrive from the same event and their documented wording differs in one word that matters. team_added_to_org says platform operations will be intermittently unavailable during the transition; org_login_required says the workspace will not be available until the migration completes. Intermittent means some calls succeed, so a single health check is unreliable and a retry can hide the condition entirely. The scheduling response to both is the same and has its own note; what is specific to this one is what the migration leaves behind, which is a set of renumbered user identifiers.
Our user ids still start with U after the migration. Have they changed or not?
Both are possible and only migration.exchange can tell you. A workspace inside an organization has organization-level ids beginning with W, and the mapping is what the method returns: an id that maps to itself has not moved, an id that maps to a different one has. The shape of the id is not sufficient evidence, which is why the script asks rather than inferring. Passing to_old converts in the other direction if you need the workspace-local id back for something that still expects it.
An id came back in neither the map nor invalid_user_ids. What does that mean?
It means the method did not account for it, and the honest answer is that you do not know. The script reports it as not-returned and counts it separately rather than filing it as unchanged, because filing it as unchanged is how a cache audit comes back clean while leaving rows unaccounted for. Re-run the batch it was in, and if it is still unaccounted for, treat that record as needing re-resolution from a key of your own.
Do we need to check channel ids too?
Yes, and this script does not do it, which is a limit worth stating rather than hiding. migration.exchange maps user ids and nothing else. For conversations the answer is to re-enumerate with conversations.list after the window closes and match on name or on your own stored key, accepting that a name is not a stable identifier either. The general rule after a migration is that any Slack identifier you cached for that workspace is a hypothesis until you have re-resolved it.
How do we make the next migration a non-event?
Store a key of your own next to every Slack id, today, before anything migrates. An email address is usually enough, an internal person id is better, and either one turns a remap from a data-loss event into a lookup: re-enumerate the workspace, match on the stable key, write the new Slack id. The script counts the records that have no such key even on a workspace that is not in an organization, because that count is the whole of your exposure and it is knowable long before it matters.
Related field notes
- the scheduling half: what to do while the workspace is dark
- the same lesson about identifiers, one object over
- the installation row that also needs re-keying afterwards
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.
- migration.exchange method reference - Slack Docs
- conversations.history method reference - Slack Docs
- users.info method reference - Slack Docs
- Developing for Enterprise organizations - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.