Skip to content

Diagnostic Twilio

a rejected regulatory bundle blocks every number purchase

A number that was available yesterday cannot be bought today, and the API is saying something about regulatory requirements rather than about the number. Nothing changed in your code. Somebody on Twilio's regulatory team opened the bundle that proves who you are to that country's regulator, read the documents attached to it, and refused them. That happened days ago, in a resource nobody on your team has a reason to open.

Read-only key Python and Node.js Tests included
Paying with a phone
Photo by CardMapr.nl on Unsplash
The short answer

Read GET https://numbers.twilio.com/v2/RegulatoryCompliance/Bundles?Status=twilio-rejected and treat every result as a country you cannot buy numbers in. Each carries sid, friendly_name, regulation_sid, status, valid_until and email, and the list filters on IsoCountry, NumberType and EndUserType.

Then GET https://numbers.twilio.com/v2/RegulatoryCompliance/Bundles/{BundleSid}/ItemAssignments for the object_sid of each End-User and Supporting Document attached to it. Those SIDs are what the repair replaces; the bundle itself holds no document, only assignments.

This is a review that failed, not an approval that lapsed. If the bundle reads twilio-approved with a date approaching, that is a bundle counting down to expiry and a different note.

The problem in plain words

The bundle is the object a regulated country's rules are satisfied against: a container of End-User and Supporting Document assignments matched to a Regulation for one ISO country and one number type. It is entirely out of band from the numbers it protects. Nothing in the phone-number API mentions it, no message or call carries its state, and the account works normally in every other country. So a rejection sits there being true and being invisible.

What makes it expensive is when it is discovered. The natural moment is a purchase, which means the discovery happens inside whatever you were trying to launch, in a country where you have already promised somebody a local number. The rejection itself may be a photograph that was cropped, an address that does not match the utility bill, or a document class the regulation does not accept, and none of that is in the failure you are looking at. It is in a resource you have to go and read, along with, on a mature account, several other bundles in the same state that nobody has looked at either.

Bundlesubmitteddocuments attachedReviewerrefusesscan illegible orwrong classNo email, nocallbacktold to nobodyWeeks passother countriesunaffectedPurchaserefusedfound inside alaunch
Nothing between the rejection and the purchase mentions the bundle. The account works normally in every other country the whole time.

Why it happens

Nothing announces a rejection unless you asked it to. A bundle carries an email and a status_callback. If both are empty, the state change is delivered to nobody. Bundles are usually created in the console during a hurried onboarding, and neither field is one anybody fills in while trying to get a number bought.

The reviewer is a person, and the objection is about documents. Illegible scans, an expired passport, a lease where a utility bill was required, a director's address where the company's registered address was required. These are not states a schema can express, which is why the bundle only carries twilio-rejected and the detail lives in the assignments and the email nobody received.

A draft looks exactly like a rejection from the outside. Both are bundles that cannot buy numbers. But a draft was never submitted, so nothing was reviewed and nothing failed — somebody started it, got interrupted, and it has been sitting at draft ever since. It needs a submission, not a correction, and a report that lumps the two together sends people looking for a rejection reason that does not exist.

Existing numbers are exposed, not just new ones. A rejected bundle does not only block purchases. The numbers already provisioned against that regulation are non-compliant while it stands, which is a reclamation risk rather than an immediate outage, and it is the reason this is worth finding before the next purchase rather than during it.

The bundle is per country and per number type. An account selling into six countries has at least six of these, often more once mobile and national numbers are counted separately. Checking the one you are about to use is how five others stay rejected for a year.

The fix, as a flow

The script classifies on status alone, because status is all the Bundle resource carries. The reason lives with the reviewer and with the objects the bundle assigns, which is why the item assignments are the second call.

GETRegulatoryCompliance/Bundlesstatus per country and number typetwilio-approvedbuyable todaypending or in reviewqueued, hold the purchasetwilio-rejectedreplace the refused objectdraftnever submitted, never reviewed
A draft blocks purchases exactly like a rejection and needs the opposite action: a submission rather than a correction.

How to fix it

List by status across the whole estate, not by the country you need today

GET https://numbers.twilio.com/v2/RegulatoryCompliance/Bundles?Status=twilio-rejected. Add SortBy=date-updated to put the most recent decisions first. The point of a status filter is that it costs the same to ask about every country as about one, and the answer for the other countries is the part nobody has.

Page the v2 way

Results arrive under results and the next page is an absolute URL in meta.next_page_url. Code carried over from the 2010-04-01 account API looks for next_page_uri and a host to prefix it with, finds neither, and silently stops after the first page.

Separate rejected from draft from in review

Three different states, three different actions: correct and resubmit, submit for the first time, or wait. Only the first has a reason to go and find. Fold them together and the report reads as a pile of failures with no next step, which is a report people stop opening.

Read the item assignments to name what has to be replaced

GET /v2/RegulatoryCompliance/Bundles/{BundleSid}/ItemAssignments lists an object_sid per assignment — the End-User (IT…) and Supporting Document (RD…) objects the reviewer actually looked at. Printing those SIDs turns "this bundle was rejected" into a repair somebody can start without opening the console.

Check email and status_callback while you are in the response

Both empty means the next transition on this bundle will also be found by an audit rather than told to anyone. It is not the cause of the rejection and it is the reason the rejection is weeks old.

Replace the object, resubmit, then re-run

POST /v2/RegulatoryCompliance/SupportingDocuments with the corrected document, assign it with POST /v2/RegulatoryCompliance/Bundles/{BundleSid}/ItemAssignments, then POST /v2/RegulatoryCompliance/Bundles/{BundleSid} with Status=pending-review. Before resubmitting, it is worth reading the machine evaluation, which will tell you for free whether the new set of documents can pass at all.

How to check it worked

Re-run the script with --all. Every bundle should read approved or in-review, and the rejected count should be zero.

python3 twilio_bundle_rejection_audit.py --all
# 7 bundle(s), 0 rejected, 0 never submitted

The full code

One paginated GET over the bundles, plus — with --items — one extra GET per finding to name the objects the reviewer refused. The classification is a pure function of status alone, which is the honest shape: the bundle resource genuinely does not carry the reason, and pretending otherwise would mean inventing detail the API never returned.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_bundle_rejection_audit.py
"""Report regulatory Bundles that failed review and cannot buy numbers.

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed,
because resubmitting a bundle starts a regulatory review you want a human
watching, and because this script holds a credential to an account that can send
messages and spend money.
"""
import argparse
import logging
import os
import sys

import requests

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

NUMBERS = "https://numbers.twilio.com/v2"

REJECTED = "twilio-rejected"
APPROVED = "twilio-approved"
DRAFT = "draft"
REVIEWING = ("pending-review", "in-review")


def verdict(bundle):
    """Classify one Bundle on status. Pure, so the states can be tested without a
    network and without a rejected bundle to hand.

    Status is all the bundle carries. There is no rejection reason on this
    resource, no error array and no free-text note: the objection lives with the
    reviewer and with the End-User and Supporting Document objects assigned to
    the bundle. A classifier that promised more than status would be inventing
    it.

    Returns (state, detail).
    """
    status = str(bundle.get("status") or "").strip().lower()

    if status == REJECTED:
        return ("rejected",
                "twilio-rejected: a reviewer read the assigned documents and "
                "refused them. No number can be bought against this regulation, "
                "and numbers already on it are non-compliant meanwhile.")

    if status == DRAFT:
        return ("draft",
                "still a draft: created, perhaps filled in, never submitted. "
                "Nothing was reviewed, so there is no rejection reason to go "
                "looking for. It needs submitting, not correcting.")

    if status in REVIEWING:
        return ("in-review",
                "%s: submitted and waiting on a human. Purchases in this country "
                "keep failing until it is approved, so this is a queue position "
                "rather than a green light." % status)

    if status == APPROVED:
        return ("approved",
                "twilio-approved: usable for purchase today. Whether it stays "
                "that way is a question about valid_until, which is a different "
                "check from this one.")

    return ("unknown",
            "status is %s, which this script does not classify. Read it rather "
            "than assuming it is healthy." % (status or "unset"))


def notification_gap(bundle):
    """The reason a rejection is weeks old when it is found, or None.

    A bundle transitions on Twilio's schedule, not yours. With no email and no
    status_callback the transition is delivered to nobody, which is how a
    rejection that happened in March is discovered by a purchase in June.
    """
    if str(bundle.get("email") or "").strip():
        return None
    if str(bundle.get("status_callback") or "").strip():
        return None
    return ("no email and no status_callback on this bundle: its state changes "
            "are announced to nobody, which is why this one is being found by an "
            "audit rather than by a message.")


def get(session, url, **params):
    r = session.get(url, params=params, timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
                         "API key belongs to that account with read access"
                         % r.status_code)
    r.raise_for_status()
    return r.json()


def list_v2(session, url, limit, **params):
    """Page a numbers v2 collection.

    Rows arrive under `results` and the next page as an absolute URL in
    `meta.next_page_url`, unlike the 2010-04-01 API's `next_page_uri` path.
    """
    params = dict(params, PageSize=50)
    out = []
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get("results", []))
        url = (page.get("meta") or {}).get("next_page_url")
        params = {}
    return out[:limit]


def assigned_objects(session, bundle_sid):
    """The End-User and Supporting Document SIDs a reviewer actually looked at."""
    url = "%s/RegulatoryCompliance/Bundles/%s/ItemAssignments" % (NUMBERS, bundle_sid)
    return [a.get("object_sid") for a in list_v2(session, url, 100)
            if a.get("object_sid")]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--all", action="store_true",
                    help="classify every bundle rather than only rejected ones")
    ap.add_argument("--items", action="store_true",
                    help="one extra GET per finding to name the assigned objects")
    ap.add_argument("--max-bundles", type=int, default=500,
                    help="stop after this many bundles")
    args = ap.parse_args()

    account = os.environ.get("TWILIO_ACCOUNT_SID")
    key = os.environ.get("TWILIO_API_KEY")
    secret = os.environ.get("TWILIO_API_SECRET")
    if not (account and key and secret):
        log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
                  "(an API Key with read access, not the auth token)")
        return 2

    session = requests.Session()
    session.auth = (key, secret)

    query = {"SortBy": "date-updated", "SortDirection": "DESC"}
    if not args.all:
        query["Status"] = REJECTED
    bundles = list_v2(session, "%s/RegulatoryCompliance/Bundles" % NUMBERS,
                      args.max_bundles, **query)
    if not bundles:
        log.info("no regulatory bundles matched")
        return 0

    rejected = drafts = 0
    for bundle in bundles:
        state, detail = verdict(bundle)
        label = "%s/%s" % (bundle.get("iso_country") or "??",
                           bundle.get("number_type") or "?")
        sid = bundle.get("sid", "?")
        log_line = "%-10s %s  %s  %s" % (state, sid, label, detail)
        if state in ("approved", "in-review"):
            log.info(log_line)
            continue
        if state == DRAFT:
            drafts += 1
        else:
            rejected += 1
        log.warning(log_line)

        note = notification_gap(bundle)
        if note:
            log.warning("  %s", note)

        if state == "rejected":
            if args.items:
                objects = assigned_objects(session, sid)
                log.warning("  assigned objects: %s",
                            ", ".join(objects) or "none assigned")
            log.warning("  repair: replace the refused End-User or Supporting "
                        "Document, assign it via %s/RegulatoryCompliance/Bundles/"
                        "%s/ItemAssignments, then send the bundle back with "
                        "Status=pending-review", NUMBERS, sid)
        elif state == DRAFT:
            log.warning("  repair: finish the assignments, then move %s/"
                        "RegulatoryCompliance/Bundles/%s to Status=pending-review",
                        NUMBERS, sid)

    log.info("%d bundle(s), %d rejected, %d never submitted",
             len(bundles), rejected, drafts)
    return 1 if (rejected or drafts) else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-bundle-rejection-audit.mjs
/**
 * Report regulatory Bundles that failed review and cannot buy numbers.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. The repair is printed, never
 * performed, because resubmitting a bundle starts a regulatory review you want a
 * human watching.
 */
const NUMBERS = 'https://numbers.twilio.com/v2';

const REJECTED = 'twilio-rejected';
const APPROVED = 'twilio-approved';
const DRAFT = 'draft';
const REVIEWING = ['pending-review', 'in-review'];

/**
 * Classify one Bundle on status. Pure, so the states can be tested without a
 * network and without a rejected bundle to hand.
 *
 * Status is all the bundle carries. There is no rejection reason on this
 * resource: the objection lives with the reviewer and with the End-User and
 * Supporting Document objects assigned to the bundle.
 *
 * Returns [state, detail].
 */
export function verdict(bundle) {
  const status = String(bundle.status ?? '').trim().toLowerCase();

  if (status === REJECTED) {
    return ['rejected',
      'twilio-rejected: a reviewer read the assigned documents and refused ' +
      'them. No number can be bought against this regulation, and numbers ' +
      'already on it are non-compliant meanwhile.'];
  }

  if (status === DRAFT) {
    return ['draft',
      'still a draft: created, perhaps filled in, never submitted. Nothing was ' +
      'reviewed, so there is no rejection reason to go looking for. It needs ' +
      'submitting, not correcting.'];
  }

  if (REVIEWING.includes(status)) {
    return ['in-review',
      `${status}: submitted and waiting on a human. Purchases in this country ` +
      'keep failing until it is approved, so this is a queue position rather ' +
      'than a green light.'];
  }

  if (status === APPROVED) {
    return ['approved',
      'twilio-approved: usable for purchase today. Whether it stays that way is ' +
      'a question about valid_until, which is a different check from this one.'];
  }

  return ['unknown',
    `status is ${status || 'unset'}, which this script does not classify. Read ` +
    'it rather than assuming it is healthy.'];
}

/** The reason a rejection is weeks old when it is found, or null. */
export function notificationGap(bundle) {
  if (String(bundle.email ?? '').trim()) return null;
  if (String(bundle.status_callback ?? '').trim()) return null;
  return 'no email and no status_callback on this bundle: its state changes are ' +
         'announced to nobody, which is why this one is being found by an audit ' +
         'rather than by a message.';
}

function authHeader(key, secret) {
  return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}

async function get(auth, url, params = {}) {
  const u = new URL(url);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
  const res = await fetch(u, { headers: { Authorization: auth } });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
                    'that the API key belongs to that account with read access');
  }
  if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
  return res.json();
}

/**
 * Page a numbers v2 collection: rows under `results`, next page as an absolute
 * URL in `meta.next_page_url`.
 */
export async function listV2(auth, url, limit = 500, query = {}) {
  let params = { ...query, PageSize: 50 };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.results ?? []));
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out.slice(0, limit);
}

async function assignedObjects(auth, bundleSid) {
  const url = `${NUMBERS}/RegulatoryCompliance/Bundles/${bundleSid}/ItemAssignments`;
  const rows = await listV2(auth, url, 100);
  return rows.map((a) => a.object_sid).filter(Boolean);
}

async function main() {
  const account = process.env.TWILIO_ACCOUNT_SID;
  const key = process.env.TWILIO_API_KEY;
  const secret = process.env.TWILIO_API_SECRET;
  if (!account || !key || !secret) {
    console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
                  '(an API Key with read access, not the auth token)');
    process.exitCode = 2;
    return;
  }
  const auth = authHeader(key, secret);
  const all = process.argv.includes('--all');
  const withItems = process.argv.includes('--items');

  const query = { SortBy: 'date-updated', SortDirection: 'DESC' };
  if (!all) query.Status = REJECTED;
  const bundles = await listV2(auth, `${NUMBERS}/RegulatoryCompliance/Bundles`,
                               500, query);
  if (bundles.length === 0) {
    console.log('no regulatory bundles matched');
    return;
  }

  let rejected = 0;
  let drafts = 0;
  for (const bundle of bundles) {
    const [state, detail] = verdict(bundle);
    const label = `${bundle.iso_country ?? '??'}/${bundle.number_type ?? '?'}`;
    const sid = bundle.sid ?? '?';
    const line = `${state.padEnd(10)} ${sid}  ${label}  ${detail}`;
    if (state === 'approved' || state === 'in-review') { console.log(line); continue; }
    if (state === DRAFT) drafts += 1; else rejected += 1;
    console.warn(line);

    const note = notificationGap(bundle);
    if (note) console.warn(`  ${note}`);

    if (state === 'rejected') {
      if (withItems) {
        const objects = await assignedObjects(auth, sid);
        console.warn(`  assigned objects: ${objects.join(', ') || 'none assigned'}`);
      }
      console.warn('  repair: replace the refused End-User or Supporting Document, ' +
                   `assign it via ${NUMBERS}/RegulatoryCompliance/Bundles/${sid}/` +
                   'ItemAssignments, then send the bundle back with ' +
                   'Status=pending-review');
    } else if (state === DRAFT) {
      console.warn('  repair: finish the assignments, then move ' +
                   `${NUMBERS}/RegulatoryCompliance/Bundles/${sid} to ` +
                   'Status=pending-review');
    }
  }

  console.log(`${bundles.length} bundle(s), ${rejected} rejected, ` +
              `${drafts} never submitted`);
  process.exitCode = (rejected || drafts) ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing credentials and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Four states that look alike from a dashboard and mean four different things to whoever picks up the ticket. The cases worth pinning are draft staying separate from rejected — one needs a correction, the other needs a submission — and a status the script has never seen reporting as unknown rather than falling through to healthy, which is the failure mode of every classifier written as an if/else with a cheerful default.

test_twilio_bundle_rejection_audit.py
from twilio_bundle_rejection_audit import notification_gap, verdict


def make(**kw):
    bundle = {"sid": "BU00000000000000000000000000000001",
              "friendly_name": "DE local business",
              "iso_country": "DE",
              "number_type": "local",
              "status": "twilio-rejected",
              "email": "compliance@example.com",
              "status_callback": "https://ops.example.com/bundle"}
    bundle.update(kw)
    return bundle


def test_a_rejected_bundle_names_the_purchase_it_blocks():
    state, detail = verdict(make())
    assert state == "rejected"
    assert "No number can be bought" in detail


def test_draft_is_not_folded_into_rejected():
    state, detail = verdict(make(status="draft"))
    assert state == "draft"
    assert "never submitted" in detail
    assert "submitting, not correcting" in detail


def test_both_review_states_read_as_waiting():
    assert verdict(make(status="pending-review"))[0] == "in-review"
    assert verdict(make(status="in-review"))[0] == "in-review"


def test_approved_defers_the_expiry_question_rather_than_answering_it():
    state, detail = verdict(make(status="twilio-approved"))
    assert state == "approved"
    assert "valid_until" in detail


def test_a_status_the_script_has_never_seen_is_not_healthy():
    state, detail = verdict(make(status="provisionally-approved"))
    assert state == "unknown"
    assert "provisionally-approved" in detail
    state, _ = verdict(make(status=None))
    assert state == "unknown"


def test_valid_until_is_deliberately_not_consulted():
    # A rejected bundle with a date years away is still rejected. The date is
    # the subject of a different check, and reading it here would soften a
    # finding that is not soft.
    state, _ = verdict(make(valid_until="2030-01-01T00:00:00Z"))
    assert state == "rejected"


def test_notification_gap_needs_both_channels_empty():
    assert notification_gap(make()) is None
    assert notification_gap(make(status_callback="")) is None
    assert notification_gap(make(email="")) is None
    assert notification_gap(make(email="", status_callback="")) is not None
    assert notification_gap(make(email="  ", status_callback=None)) is not None
twilio-bundle-rejection-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { notificationGap, verdict } from './twilio-bundle-rejection-audit.mjs';

const make = (over = {}) => ({
  sid: 'BU00000000000000000000000000000001',
  friendly_name: 'DE local business',
  iso_country: 'DE',
  number_type: 'local',
  status: 'twilio-rejected',
  email: 'compliance@example.com',
  status_callback: 'https://ops.example.com/bundle',
  ...over,
});

test('a rejected bundle names the purchase it blocks', () => {
  const [state, detail] = verdict(make());
  assert.equal(state, 'rejected');
  assert.match(detail, /No number can be bought/);
});

test('draft is not folded into rejected', () => {
  const [state, detail] = verdict(make({ status: 'draft' }));
  assert.equal(state, 'draft');
  assert.match(detail, /never submitted/);
  assert.match(detail, /submitting, not correcting/);
});

test('both review states read as waiting', () => {
  assert.equal(verdict(make({ status: 'pending-review' }))[0], 'in-review');
  assert.equal(verdict(make({ status: 'in-review' }))[0], 'in-review');
});

test('approved defers the expiry question rather than answering it', () => {
  const [state, detail] = verdict(make({ status: 'twilio-approved' }));
  assert.equal(state, 'approved');
  assert.match(detail, /valid_until/);
});

test('a status the script has never seen is not healthy', () => {
  const [state, detail] = verdict(make({ status: 'provisionally-approved' }));
  assert.equal(state, 'unknown');
  assert.match(detail, /provisionally-approved/);
  assert.equal(verdict(make({ status: null }))[0], 'unknown');
});

test('valid_until is deliberately not consulted', () => {
  assert.equal(verdict(make({ valid_until: '2030-01-01T00:00:00Z' }))[0], 'rejected');
});

test('notificationGap needs both channels empty', () => {
  assert.equal(notificationGap(make()), null);
  assert.equal(notificationGap(make({ status_callback: '' })), null);
  assert.equal(notificationGap(make({ email: '' })), null);
  assert.notEqual(notificationGap(make({ email: '', status_callback: '' })), null);
  assert.notEqual(notificationGap(make({ email: '  ', status_callback: null })), null);
});

FAQ

Why does the bundle not carry a rejection reason?

Because the objection is about documents rather than about fields. A reviewer looked at a scan and decided it was illegible, or that a lease is not a utility bill, or that the address on it is not the address on the End-User. None of that has a schema. The bundle carries the decision, the assignments carry the objects the decision was about, and the prose goes to the email address on the bundle, which is usually empty.

How do I find out which document was the problem?

Read the item assignments for the object SIDs, then fetch each End-User and Supporting Document and compare them against the regulation's requirements. If the email on the bundle was set, the reason was sent there. If it was not, Twilio Support can retrieve it, and setting the email before you resubmit means you will not need to ask twice.

Is a draft bundle really worth reporting?

Yes, and separately. It blocks purchases exactly like a rejection, but there is nothing to correct: somebody created it, got as far as attaching one document, and stopped. On accounts with more than a handful of countries this is more common than rejection, and the fix is minutes rather than a document-gathering exercise.

What happens to the numbers I already own under a rejected bundle?

They keep working, and they are non-compliant with the regulation that permitted them, which puts them at risk of reclamation. There is no published grace period to plan around. Treat a rejected bundle with live numbers as urgent even though nothing has broken yet, because when it does break the number is gone rather than degraded.

Can the script resubmit the bundle for me?

No, and this is one of the places where that restraint matters most. A resubmission enters a queue with a human at the end of it, and resubmitting the same documents produces the same rejection while consuming review time. The script prints the sequence, including the assignment step people miss, for somebody to run once the document is genuinely different.

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.