These are behaviors in SFMC SSJS that are inconsistent with the official documentation or with standard JavaScript expectations. They are not theoretical — they have been observed by practitioners and documented in community resources.

This page covers features that are broken or that do not exist at runtime despite being officially documented. For working features whose behavior merely differs from the docs (wrong return types, undocumented properties, etc.), see Differs from Official Docs.

switch break Can Escape a Function (the “default May Not Execute” bug)

Severity: High — silently returns undefined and skips code after the switch

Community lore says the default case of a switch “may not execute” in SFMC SSJS. Live CloudPage probing (Core 1.1.5, MCDEV_Training_QA) shows that framing is a symptom, not the root cause — and it is misleading. A last-clause default on a no-match path executes reliably:

// ✅ default runs normally on a no-match path — no break executes here
var out = "PRE";
switch ("unknown") {
    case "active":   out = "A"; break;
    case "inactive": out = "I"; break;
    default:         out = "DEF";   // runs -> out === "DEF"
}

The real defect is in the underlying Jint engine’s break handling: an executed break inside a switch that sits inside a function can abnormally complete the function — the function returns undefined and any statements after the switch are skipped. When that swallowed code was the caller’s fallback (often reached via default), it looks like “default didn’t run”, but the trigger is the executed break, not the default keyword.

// ⚠️ break inside a switch inside a function can make the function return undefined
function classify(status) {
    var out = "PRE";
    switch (status) {
        case "active":   out = "A"; break;   // a matched break can escape the function
        case "inactive": out = "I"; break;
        default:         out = "DEF";
    }
    return out;                              // ⚠️ may be skipped -> caller sees undefined
}

Probing shows the fault is intermittent across compilations — the same source flipped between the correct value and undefined on different deploys, while staying perfectly stable within a single request. That flakiness is why the community reports it as “may not execute”. A top-level switch (not wrapped in a function) executed normally in every probe.

This matches a documented Jint bug: an unlabeled break inside a switch was not always absorbed, so it “propagated out as a Break completion — skipping statements after the switch and, at function scope, making the body complete abnormally so the function returned undefined” (jint#2607). SFMC runs an engine build old enough to still hit this.

Safe workaround: avoid relying on a value that has to survive a break inside a function-scoped switch. Prefer a lookup map or if/else if, or assign a result variable and return it via a path that does not depend on post-switch code:

// Option 1: lookup map — no switch, no break, no escape
function classify(status) {
    var map = { active: "A", inactive: "I" };
    return map[status] || "DEF";
}

// Option 2: if / else if for the fallback
function classify(status) {
    if (status === "active")   return "A";
    if (status === "inactive") return "I";
    return "DEF";
}

ESLint rule: sfmc/ssjs-no-switch-default warns about relying on default; treat it as a broader hint to avoid function-scoped switch/break for critical control flow.

Related: SSJS switch also has no fall-through of any kind — a separate, consistently reproducible limitation.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: switch break Can Escape a Function (the "default May Not Execute" bug)
 *
 * Root cause (jint#2607): an executed `break` inside a switch that sits inside
 * a FUNCTION can abnormally complete the function — the function returns
 * undefined and statements after the switch are skipped. The old "default may
 * not execute" framing was a symptom, not the cause: the `default` keyword is
 * NOT the trigger.
 *
 * Proves:
 *   1. A last-clause `default` on a no-match path runs RELIABLY (string) — no
 *      break executes on that path, so the escape cannot fire.
 *   1b. Same for a numeric no-match switch — default runs reliably.
 *   2. A matched case runs its own body (baseline: "active" -> "Active").
 *   3. A TOP-LEVEL switch (not wrapped in a function) is unaffected — the
 *      statement AFTER the switch runs even though a matched `break` executed.
 *   4. Break-escape is STABLE WITHIN A SINGLE REQUEST: calling the same
 *      function-scoped-switch-with-break N times in one request yields N
 *      IDENTICAL results (per db.mjs it12 — no per-call randomness). This is
 *      the deterministic half of the defect and is asserted directly.
 *   5. NON-ASSERTION (documented, cannot be made deterministic in one request):
 *      whether that stable per-request value is the CORRECT return or
 *      `undefined` is decided PER COMPILATION and flips across deploys
 *      (db.mjs it9/it10/it11). A green line here would require pinning a
 *      compilation outcome we do not control, so the undefined outcome itself
 *      is intentionally NOT asserted — only its within-request stability (4)
 *      and the safe workarounds (6, 7) are.
 *   6. Workaround Option 1 — a lookup map (no switch, no break, no escape) —
 *      returns the fallback for an unknown value and routes a match correctly.
 *   7. Workaround Option 2 — if / else if returning via a path that does not
 *      depend on post-switch code — same guarantees, escape-proof.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/*
 * 1. Last-clause default on a no-match path runs reliably. No case matches
 * "unknown" and no break executes on the default path, so the escape defect
 * (which is triggered by an executed break) cannot fire here.
 */
var out = "PRE";
var status = "unknown";
switch (status) {
    case "active":
        out = "Active";
        break;
    case "inactive":
        out = "Inactive";
        break;
    default:
        out = "Unknown status";
}
assert("last-clause default runs reliably on no-match (string)", out, "Unknown status");

/* 1b. Numeric no-match default also runs reliably. */
var outNum = "PRE";
switch (99) {
    case 1: outNum = "one"; break;
    case 2: outNum = "two"; break;
    default: outNum = "DEF";
}
assert("last-clause default runs reliably on no-match (numeric)", outNum, "DEF");

/* 2. A matched case runs its own body. */
var out2 = "PRE";
var status2 = "active";
switch (status2) {
    case "active":
        out2 = "Active";
        break;
    case "inactive":
        out2 = "Inactive";
        break;
    default:
        out2 = "Unknown status";
}
assert("matched case 'active' -> 'Active'", out2, "Active");

/*
 * 3. A TOP-LEVEL switch is unaffected: even though a matched break executes,
 * the statement AFTER the switch still runs. The escape only bites at function
 * scope, so this top-level marker is reliably reached.
 */
var tl = "PRE";
switch ("active") {
    case "active": tl = "MATCH"; break;
    default:       tl = "DEF";
}
tl = tl + "|AFTER";
assert("top-level switch runs post-switch statement (break did not escape)", tl, "MATCH|AFTER");

/*
 * 4. Break-escape is STABLE within a single request. classify() is the exact
 * hazardous shape: a function whose switch executes a break on the matched
 * path and then relies on a trailing `return out;`. Per compilation the
 * runtime either returns "A" (correct) or undefined (the break escaped the
 * function). Whatever it is, it is IDENTICAL for every call in this request —
 * so 8 calls collapse to a single distinct value. We assert that stability
 * (deterministic), NOT which value it is (per-compilation; see NON-ASSERTION).
 */
function classify(status) {
    var out = "PRE";
    switch (status) {
        case "active":   out = "A"; break;
        case "inactive": out = "I"; break;
        default:         out = "DEF";
    }
    return out;
}
var first = "" + classify("active");
var allSame = true;
var k;
for (k = 0; k < 8; k = k + 1) {
    if (("" + classify("active")) !== first) { allSame = false; }
}
assert("break-escape is stable within one request (8 calls agree)", allSame ? "true" : "false", "true");

/*
 * 4b. Sanity control: the same value read twice in the same request is stable
 * (guards against the assertion above passing for the wrong reason).
 */
var second = "" + classify("active");
assert("classify('active') is identical on repeat within request", second, first);

/*
 * 6. Workaround Option 1 — lookup map. No switch, no break, so no escape:
 * a match returns its value and an unknown value returns the fallback.
 */
function classifyMap(status) {
    var map = { active: "A", inactive: "I" };
    return map[status] || "DEF";
}
assert("workaround map: classifyMap('active') -> 'A'", classifyMap("active"), "A");
assert("workaround map: classifyMap('inactive') -> 'I'", classifyMap("inactive"), "I");
assert("workaround map: classifyMap('unknown') -> 'DEF'", classifyMap("unknown"), "DEF");

/*
 * 7. Workaround Option 2 — if / else if. Each branch returns directly, so no
 * value has to survive a break inside a function-scoped switch.
 */
function classifyIf(status) {
    if (status === "active")   { return "A"; }
    if (status === "inactive") { return "I"; }
    return "DEF";
}
assert("workaround if/else: classifyIf('active') -> 'A'", classifyIf("active"), "A");
assert("workaround if/else: classifyIf('inactive') -> 'I'", classifyIf("inactive"), "I");
assert("workaround if/else: classifyIf('unknown') -> 'DEF'", classifyIf("unknown"), "DEF");
</script>


switch Has No Fall-Through (Empty Labels and Break-less Cases)

Severity: High — silently skips shared and cascading case bodies

The SFMC SSJS engine performs no switch fall-through of any kind — runtime-verified on a live CloudPage. A matched case executes only its own statements up to the next case/default; nothing cascades. Three consequences all fail where browser JavaScript would fall through:

  • An empty leading label does not share the next label’s body — case "admin": case "superuser": … runs nothing for "admin".
  • A break-less body does not cascade into the following case.
  • A matched case never cascades into a following default clause.

Numeric switches behave the same way. Only direct, self-contained matched cases work. This is a distinct issue from the intermittent break escaping a function above: no-fall-through is consistent and reproducible on every run, whereas the break-escape fault is flaky across compilations. What they share is the practical advice — do not depend on switch for control flow that must run; use if / a lookup map instead.

var level = "admin";
var access = "";

// ❌ empty leading label — the shared body never runs for "admin"
switch (level) {
    case "admin":
    case "superuser":
        access = "Full access";
        break;
}
// access stays "" for "admin"; it is "Full access" only for "superuser"

// ❌ break-less body — does NOT cascade into the next case
switch (level) {
    case "admin":
        access = "Admin";      // runs, but execution stops here
    case "superuser":
        access = "Super";      // never reached for "admin"
        break;
}

Safe workaround: Give every case its own break-terminated body (duplicate the shared statements under each label), or replace the switch with if / a lookup map.

ESLint rule: sfmc/ssjs-no-switch-fallthrough flags empty stacked labels and break-less case bodies.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: switch Has No Fall-Through (Empty Labels and Break-less Cases)
 *
 * The SFMC SSJS (Jint) engine performs NO switch fall-through of any kind.
 * A matched case executes only its own statements up to the next case/default;
 * nothing cascades. This is a DISTINCT limitation from the intermittent
 * `break`-escaping-a-function bug — no-fall-through is consistent and
 * reproducible on every run. To make each no-cascade claim deterministic
 * within one request, every switch is wrapped in a function and the resulting
 * value is asserted.
 *
 * Proves:
 *   A. An EMPTY LEADING LABEL does not share the next label's body:
 *      `case "admin": case "superuser": body` runs NOTHING for "admin"
 *      (access stays ""), and runs the shared body only for "superuser".
 *   B. A BREAK-LESS body does NOT cascade into the following case:
 *      matched "admin" sets "Admin" and stops; it never becomes "Super".
 *   C. A matched case NEVER cascades into a following `default`: matched
 *      "admin" returns "Admin", not "DEF" (standard JS would fall through);
 *      a genuine no-match still reaches default.
 *   D. NUMERIC switches behave the same way — empty leading and break-less
 *      numeric cases do not cascade either.
 *   E. Only direct, self-contained matched cases run their own body.
 *   F. Workaround 1 — duplicate the shared body under each label.
 *   G. Workaround 2 — if / else if.
 *   H. Workaround 3 — a lookup map.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/*
 * A. Empty leading label does NOT share the next label's body. In browser JS
 * "admin" would fall through the empty label into the shared body; here it
 * runs nothing.
 */
function emptyLeading(level) {
    var access = "";
    switch (level) {
        case "admin":
        case "superuser":
            access = "Full access";
            break;
    }
    return access;
}
assert("A1 empty leading 'admin' shares nothing (access stays '')", emptyLeading("admin"), "");
assert("A2 empty leading 'superuser' runs shared body", emptyLeading("superuser"), "Full access");

/*
 * B. Break-less body does NOT cascade into the next case. Browser JS would
 * run "Admin" then fall through to "Super"; here it stops at "Admin".
 */
function breakless(level) {
    var access = "";
    switch (level) {
        case "admin":
            access = "Admin";
        case "superuser":
            access = "Super";
            break;
    }
    return access;
}
assert("B1 break-less 'admin' does not cascade to next case", breakless("admin"), "Admin");
assert("B2 break-less 'superuser' matches directly", breakless("superuser"), "Super");

/*
 * C. A matched case never cascades into a following default. Browser JS would
 * fall from matched "admin" into default and return "DEF"; here it returns
 * "Admin". A genuine no-match still reaches default.
 */
function matchNoDefault(level) {
    var out = "";
    switch (level) {
        case "admin":
            out = "Admin";
        default:
            out = "DEF";
    }
    return out;
}
assert("C1 matched 'admin' does not cascade into default (spec would give DEF)", matchNoDefault("admin"), "Admin");
assert("C2 genuine no-match still reaches default", matchNoDefault("nobody"), "DEF");

/*
 * D. Numeric switches behave the same way — no fall-through of any kind.
 */
function numEmptyLeading(n) {
    var out = "";
    switch (n) {
        case 1:
        case 2:
            out = "one-or-two";
            break;
    }
    return out;
}
assert("D1 numeric empty leading case 1 shares nothing", numEmptyLeading(1), "");
assert("D2 numeric empty leading case 2 runs body", numEmptyLeading(2), "one-or-two");

function numBreakless(n) {
    var out = "";
    switch (n) {
        case 1:
            out = "one";
        case 2:
            out = "two";
            break;
    }
    return out;
}
assert("D3 numeric break-less case 1 does not cascade", numBreakless(1), "one");

/*
 * E. A direct, self-contained matched case runs its own break-terminated body.
 */
function selfContained(level) {
    var out = "";
    switch (level) {
        case "admin":
            out = "Admin";
            break;
        case "superuser":
            out = "Super";
            break;
    }
    return out;
}
assert("E1 self-contained 'admin' -> 'Admin'", selfContained("admin"), "Admin");
assert("E2 self-contained 'superuser' -> 'Super'", selfContained("superuser"), "Super");

/*
 * F. Workaround 1 — give every case its own break-terminated body by
 * duplicating the shared statements under each label.
 */
function dupBody(level) {
    var access = "";
    switch (level) {
        case "admin":
            access = "Full access";
            break;
        case "superuser":
            access = "Full access";
            break;
    }
    return access;
}
assert("F1 dup-body 'admin' -> 'Full access'", dupBody("admin"), "Full access");
assert("F2 dup-body 'superuser' -> 'Full access'", dupBody("superuser"), "Full access");

/* G. Workaround 2 — if / else if. */
function classifyIf(level) {
    if (level === "admin" || level === "superuser") { return "Full access"; }
    return "";
}
assert("G1 if 'admin' -> 'Full access'", classifyIf("admin"), "Full access");
assert("G2 if 'superuser' -> 'Full access'", classifyIf("superuser"), "Full access");
assert("G3 if 'guest' -> ''", classifyIf("guest"), "");

/* H. Workaround 3 — lookup map. */
function classifyMap(level) {
    var map = { admin: "Full access", superuser: "Full access" };
    return map[level] || "";
}
assert("H1 map 'admin' -> 'Full access'", classifyMap("admin"), "Full access");
assert("H2 map 'superuser' -> 'Full access'", classifyMap("superuser"), "Full access");
assert("H3 map 'guest' -> ''", classifyMap("guest"), "");
</script>


Bare-name Recipient Does Not Exist

Severity: High — the alias is undefined

The bare-name global Recipient is documented as an alias for Platform.Recipient, but runtime testing on CloudPages shows it is undefined both before and after Platform.Load("core", ...) — it does not exist as a usable alias.

// ❌ undefined regardless of Platform.Load — throws when you call a member
var v = Recipient.GetAttributeValue("FirstName");

// ✅ Use the fully-qualified Platform.Recipient
var v = Platform.Recipient.GetAttributeValue("FirstName");

// ✅ Or Attribute.GetValue after Platform.Load("core", ...)
Platform.Load("core", "1.1.5");
var v2 = Attribute.GetValue("FirstName");

Works correctly: Platform.Recipient.GetAttributeValue(...), or Attribute.GetValue(...) after Platform.Load.

Show test script
<script runat="server">
/*
 * Chapter: Bare-name Recipient Does Not Exist
 *
 * The bare-name global `Recipient` is documented as an alias for
 * Platform.Recipient, but on CloudPages it is UNDEFINED both before and after
 * Platform.Load("core", ...) — it does not exist as a usable alias. Calling a
 * member on it therefore throws. The fully-qualified Platform.Recipient and
 * the loaded-Core Attribute.GetValue readers work.
 *
 * NOTE ON RESOLUTION: `typeof <possibly-unbound-bare-name>` at top level can
 * parse-abort the whole page (HTTP 422), so the bare `Recipient` typeof is
 * resolved lazily inside a thunk. The member call is also passed as a thunk so
 * evaluation happens inside the assertThrows try/catch.
 *
 * Proves:
 *   1. bare `Recipient` is undefined BEFORE Platform.Load (typeof "undefined").
 *   2. bare `Recipient` is undefined AFTER Platform.Load (typeof "undefined").
 *   3. Recipient.GetAttributeValue("FirstName") THROWS (member access on an
 *      undefined value) — the alias is not usable.
 *   4. Workaround: Platform.Recipient.GetAttributeValue("FirstName") is
 *      callable and returns a string (empty "" on a CloudPage GET because no
 *      recipient is bound — value population is send-context only).
 *   5. Workaround: Attribute.GetValue("FirstName") after Platform.Load returns
 *      a string (empty "" on a CloudPage GET for the same reason).
 *
 * NON-ASSERTION (context-blocked, documented): the ACTUAL populated attribute
 * value returned by the two working readers requires an email / triggered-send
 * / journey-send context with a bound recipient. A plain CloudPage GET has no
 * bound recipient, so the non-empty value cannot be observed here without an
 * unavailable send context. Only the string SHAPE and empty result are
 * asserted; the populated value is intentionally NOT asserted.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function typeOf(fn) {
    try { return "" + (fn()); } catch (ex) { return "THREW: " + ex.message; }
}
function assertThrows(id, fn) {
    var threw = false;
    try { fn(); } catch (ex) { threw = true; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + "\n");
}

/* 1. bare Recipient is undefined BEFORE Platform.Load — resolved lazily. */
assert("bare Recipient is undefined before Platform.Load", typeOf(function () { return typeof Recipient; }), "undefined");

/*
 * 3. Calling a member on the undefined bare alias throws. Passed as a thunk so
 * the member access is evaluated inside assertThrows' try/catch.
 */
assertThrows("Recipient.GetAttributeValue throws (bare alias is undefined)", function () { return Recipient.GetAttributeValue("FirstName"); });

/* Load Core, then confirm the alias is STILL absent and the workarounds work. */
Platform.Load("core", "1.1.5");

/* 2. bare Recipient remains undefined AFTER Platform.Load. */
assert("bare Recipient remains undefined after Platform.Load", typeOf(function () { return typeof Recipient; }), "undefined");

/*
 * 4. Workaround: fully-qualified Platform.Recipient.GetAttributeValue is
 * callable and returns a string. On a CloudPage GET no recipient is bound, so
 * the value is "" (empty). The populated value is a NON-ASSERTION (send context
 * only) — only the string shape and empty result are asserted here.
 */
var pr = Platform.Recipient.GetAttributeValue("FirstName");
assert("workaround Platform.Recipient.GetAttributeValue returns a string", typeof pr, "string");
assert("workaround Platform.Recipient.GetAttributeValue is empty on a CloudPage GET (no bound recipient)", pr, "");

/*
 * 5. Workaround: Attribute.GetValue after Platform.Load returns a string; same
 * empty result on a CloudPage GET (send-context population is NOT ASSERTABLE).
 */
var av = Attribute.GetValue("FirstName");
assert("workaround Attribute.GetValue returns a string after Platform.Load", typeof av, "string");
assert("workaround Attribute.GetValue is empty on a CloudPage GET (no bound recipient)", av, "");
</script>


Repeating a Lookup in One Request Returns the Stale Result

Severity: High — a read after a write silently returns the pre-write value

Within a single request the engine caches the result of a data-extension query. Issuing the same Platform.Function.Lookup(deName, returnField, field, value) a second time returns the first result, even if rows were written in between. The call does not throw and there is no indication that the value is stale.

Platform.Load("core", "1.1.5");

Platform.Function.Lookup("MyDE", "Val", "Email", "a@example.com");  // null — no row yet
Platform.Function.InsertData("MyDE", ["Email", "Val"], ["a@example.com", "v1"]);

// ❌ still null — the identical query is served from the request cache
Platform.Function.Lookup("MyDE", "Val", "Email", "a@example.com");

// ✅ a different FILTER COLUMN reads the new row
Platform.Function.Lookup("MyDE", "Val", "Grp", "grp1");            // "v1"

This is not write lag: in the runtime probe a differently-shaped query issued between the two identical calls returned the post-write value, so the data was demonstrably current while the repeated query was not.

The cache key is the (data extension, filter) pair, not the literal argument list:

Second call Fresh?
Identical call ❌ stale
Same filter written with the array form of whereFieldNames / whereFieldValues ❌ stale
Same filter, different returnField ❌ stale
Different filter column ✅ fresh
LookupRows with the same filter ✅ fresh

Switching to the array form or changing the returned field is therefore not a workaround. Structure scripts so each distinct query is issued at most once, and never re-read a row whose absence you queried earlier in the same request. See Lookup.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: Repeating a Lookup in One Request Returns the Stale Result
 *
 * Within a SINGLE request the engine caches the result of a data-extension
 * query. Issuing the SAME Platform.Function.Lookup(deName, returnField,
 * field, value) a second time returns the FIRST result, even if rows were
 * written in between. The call does not throw and gives no indication the
 * value is stale. The cache key is the (data extension, FILTER) pair — not
 * the literal argument list — so the array argument shape and a different
 * returnField are stale too; only a DIFFERENT FILTER COLUMN (or LookupRows)
 * reads fresh.
 *
 * SAFETY: the script creates its OWN throw-away data extension under a
 * uniquely named external key (`ssjsguide-ts-kb-lrqc`), proves the effect in
 * ONE request, and removes it again. An orphan from a previous aborted run is
 * cleaned up before the probe starts. Init is always by CustomerKey; the
 * Platform.Function.* calls address the DE by its Name. No production data is
 * touched.
 *
 * NOTE ON REQUEST SCOPE: the whole read -> write -> read sequence runs in this
 * one CloudPage request. That is exactly what exposes the cache: a fresh
 * request would return current data, so nothing here can be split across
 * requests.
 *
 * Proves (every row of the page's cache-key table, both write directions):
 *   1. Baseline: the first Lookup of an absent row returns null.
 *   2. After InsertData writes that very row, a CONTROL query on a DIFFERENT
 *      FILTER COLUMN reads the new value -> the write really landed and the
 *      fresh state is readable at this point (rules out write lag).
 *   3. DEV the BYTE-IDENTICAL repeated Lookup is STALE — still null, even
 *      though the control one line earlier read the fresh row. (Identical
 *      call -> stale.)
 *   4. DEV the ARRAY argument shape of the same filter is stale too. (Same
 *      filter written with the array form of whereFieldNames/whereFieldValues
 *      -> stale.)
 *   5. DEV the same filter with a DIFFERENT returnField is stale too. (Same
 *      filter, different returnField -> stale.)
 *   6. UPDATE direction: read an existing row's value, UpdateData that row,
 *      then a CONTROL different-column query reads the NEW value (write is
 *      current), yet the IDENTICAL repeated Lookup returns the STALE
 *      pre-update value.
 *   7. WORKAROUND that works: a DIFFERENT FILTER COLUMN reads fresh (already
 *      shown by the controls) — restated as an explicit workaround assertion.
 *   8. WORKAROUND: LookupRows with the same filter reads FRESH (not cached
 *      the same way).
 *   9. CONTROL: repetition with NO write in between is harmless — two
 *      identical Lookups of a stable row agree, so repetition alone is not
 *      the trigger; a write between identical calls is.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function outcomeOf(fn) {
    try { return "" + fn(); } catch (ex) { return "THREW: " + ("" + ex.message); }
}
function countDE(key) {
    return DataExtension.Retrieve({ Property: "CustomerKey", SimpleOperator: "equals", Value: key }).length;
}

var KEY = "ssjsguide-ts-kb-lrqc";
var NAME = "ssjs-guide-ts-kb-lrqc";

/* Clean up any orphan from a prior aborted run, then create a fresh fixture. */
if (countDE(KEY) > 0) { DataExtension.Init(KEY).Remove(); }
assert("precondition: no probe data extension exists", "" + countDE(KEY), "0");
DataExtension.Add({
    CustomerKey: KEY,
    Name: NAME,
    Fields: [
        { Name: "Email", FieldType: "EmailAddress", IsPrimaryKey: true, MaxLength: 254, IsRequired: true },
        { Name: "Grp", FieldType: "Text", MaxLength: 20 },
        { Name: "Val", FieldType: "Text", MaxLength: 20 }
    ]
});
assert("fixture: the probe data extension was created", "" + countDE(KEY), "1");

/*
 * PART A — INSERT direction. Q1 = Lookup(NAME, "Val", "Email", A).
 * 1. First issuance finds no row.
 */
var A = "a@example.com";
var q1a = Platform.Function.Lookup(NAME, "Val", "Email", A);
assert("1 first identical Lookup of an absent row is null", "" + q1a, "null");

/* Write the very row Q1 sought (Grp lets a different-column control read it). */
Platform.Function.InsertData(NAME, ["Email", "Grp", "Val"], [A, "grp1", "v1"]);

/*
 * 2. CONTROL: a DIFFERENT FILTER COLUMN reads the freshly-written value, so
 * the write demonstrably landed and the current state is readable HERE —
 * between the two identical Q1 issuances. This is what rules out write lag.
 */
var ctrl1 = Platform.Function.Lookup(NAME, "Val", "Grp", "grp1");
assert("2 CONTROL different filter column reads the new row (write landed, fresh state readable)", "" + ctrl1, "v1");

/* 3. DEV the byte-identical repeated Q1 is served STALE from the request cache. */
var q1b = Platform.Function.Lookup(NAME, "Val", "Email", A);
assert("3 DEV byte-identical repeated Lookup is STALE (returns pre-write null; fresh value was readable a line earlier)", "" + q1b, "null");

/* 4. DEV the ARRAY argument shape of the same filter is stale too. */
var q1arr = Platform.Function.Lookup(NAME, "Val", ["Email"], [A]);
assert("4 DEV same filter via the ARRAY argument shape is stale too (cache key is the filter, not the arg list)", "" + q1arr, "null");

/* 5. DEV the same filter with a DIFFERENT returnField is stale too. */
var q1rf = Platform.Function.Lookup(NAME, "Grp", "Email", A);
assert("5 DEV same filter, different returnField is stale too", "" + q1rf, "null");

/*
 * 8. WORKAROUND: LookupRows over the same filter reads FRESH — it is not
 * served from the Lookup query cache.
 */
var lr = Platform.Function.LookupRows(NAME, "Email", A);
assert("8 WORKAROUND LookupRows with the same filter reads FRESH (row count)", "" + lr.length, "1");
assert("8b WORKAROUND LookupRows returns the fresh value", "" + lr[0].Val, "v1");

/*
 * PART B — UPDATE direction, on a SECOND, already-existing row so its first
 * read is a real cached hit (not null). Q2 = Lookup(NAME, "Val", "Email", B).
 */
var B = "b@example.com";
Platform.Function.InsertData(NAME, ["Email", "Grp", "Val"], [B, "grp2", "before"]);
var q2a = Platform.Function.Lookup(NAME, "Val", "Email", B);
assert("6a first Lookup of the existing row reads its current value", "" + q2a, "before");

/* UPDATE the row in the SAME request. */
Platform.Function.UpdateData(NAME, ["Email"], [B], ["Val"], ["after"]);

/* 6b CONTROL: a different-column query reads the UPDATED value -> data is current. */
var ctrl2 = Platform.Function.Lookup(NAME, "Val", "Grp", "grp2");
assert("6b CONTROL different filter column reads the UPDATED value (update landed, fresh state readable)", "" + ctrl2, "after");

/* 6c DEV the identical repeated Q2 returns the STALE pre-update value. */
var q2b = Platform.Function.Lookup(NAME, "Val", "Email", B);
assert("6c DEV identical repeated Lookup after UPDATE returns the STALE pre-update value", "" + q2b, "before");

/*
 * 7. WORKAROUND that WORKS: change the FILTER COLUMN. Reading Val by Grp
 * gives the fresh post-update value (this is the same mechanism as the
 * controls, restated as the recommended fix).
 */
var fix = Platform.Function.Lookup(NAME, "Val", "Grp", "grp2");
assert("7 WORKAROUND a DIFFERENT FILTER COLUMN reads fresh", "" + fix, "after");

/*
 * 9. CONTROL: repetition with NO write in between is harmless. Two identical
 * Lookups of a stable row (its own unique filter, never queried before this
 * pair) agree — so repetition alone is not the trigger; the trigger is a
 * write between two identical queries.
 */
var C = "c@example.com";
Platform.Function.InsertData(NAME, ["Email", "Grp", "Val"], [C, "grp3", "stable"]);
var q3a = Platform.Function.Lookup(NAME, "Val", "Email", C);
var q3b = Platform.Function.Lookup(NAME, "Val", "Email", C);
assert("9 CONTROL first read of a stable row", "" + q3a, "stable");
assert("9b CONTROL identical repeat with NO write in between is harmless (agrees)", "" + q3b, "stable");

/* Cleanup. */
assert("cleanup: the probe data extension is removed", outcomeOf(function () { return DataExtension.Init(KEY).Remove(); }), "OK");
assert("cleanup: no probe data extension is left behind", "" + countDE(KEY), "0");
</script>


GetPostData() Can Only Be Called Once

Severity: Medium — second call returns empty string

Platform.Request.GetPostData() reads the raw POST body. However, the body can only be read once per request. Subsequent calls return an empty string.

// ❌ Second call returns ""
var body1 = Platform.Request.GetPostData(); // correct
var body2 = Platform.Request.GetPostData(); // returns ""

// ✅ Store in a variable and reuse
var postBody = Platform.Request.GetPostData();
var asJson = Platform.Function.ParseJSON(postBody + "");
var asText = postBody; // reuse the cached value
Show test script
<script runat="server">
/*
 * Chapter: GetPostData() Can Only Be Called Once
 *
 * Platform.Request.GetPostData() reads the raw POST body. The body can only be
 * read ONCE per request: the FIRST call returns the body and every SUBSEQUENT
 * call returns an empty string. The safe workaround is to capture the body into
 * a variable on the first call and reuse that variable.
 *
 * CONTEXT: this test runs on a plain CloudPage GET (the shared QA validation
 * harness). A GET carries no request body, so GetPostData() returns "" on the
 * FIRST call already. That lets us prove the deterministic, GET-observable
 * parts of the chapter, but NOT the "first call returns the body" half, which
 * needs a real POST body this harness cannot inject into the outer request.
 *
 * Proves (GET-observable):
 *   1. Platform.Request.GetPostData resolves as a CLR method proxy (typeof
 *      reports "clrmethodinfo" for Platform.* CLR members; successful
 *      invocation below is the real existence proof).
 *   2. The first GetPostData() on a GET returns an empty string (no body).
 *   3. A second GetPostData() in the same request ALSO returns an empty string
 *      — the read is idempotent-once here (no body to consume) and it does NOT
 *      throw. On a GET both reads are equal because there is no body; the
 *      "second call differs from the first" claim is only demonstrable when a
 *      body exists (see NON-ASSERTION).
 *   4. DEV the reads are strings, never null/undefined — a caller can safely
 *      concatenate ("" +) without a guard. (This is the property that makes the
 *      documented ParseJSON(postBody + "") workaround safe.)
 *   5. WORKAROUND: capture the body once into a variable and reuse it. The
 *      documented pattern —
 *          var postBody = Platform.Request.GetPostData();
 *          var asJson  = Platform.Function.ParseJSON(postBody + "");
 *          var asText  = postBody;
 *      — executes without error and both derived reads resolve from the single
 *      captured value (asText === postBody, asJson === null for the empty body
 *      because ParseJSON("") returns null in this engine). Capturing once means
 *      the value survives even though a re-read of GetPostData() would be empty.
 *
 * NON-ASSERTION (documented, context-blocked — NOT ASSERTABLE here):
 *   The core "first call returns the POST body, second call returns empty
 *   string" contrast requires a request that actually carries a POST body. The
 *   QA CloudPage harness is fetched with a GET, and a real POST body cannot be
 *   injected into THIS (outer) request without an unavailable POST-capable
 *   send/harness context — a nested self-POST would be a separate inner request
 *   and would not populate the outer Platform.Request. Faking a body is
 *   forbidden. So the "body-then-empty" difference is intentionally NOT
 *   asserted; only the GET-empty, idempotent-once, string-shape, and
 *   capture-once-workaround behaviours above are. (DB "Platform.Request" already
 *   records POST body population as blocked / NOT ASSERTABLE on a GET-only run.)
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function assertNoThrow(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "FAIL " : "PASS ") + id + (threw ? " -> threw: " + msg : "") + "\n");
}

/* 1. The reader resolves as a CLR method proxy (typeof reports clrmethodinfo). */
assert("Platform.Request.GetPostData resolves (clrmethodinfo proxy)", typeof Platform.Request.GetPostData, "clrmethodinfo");

/* 2. First read on a GET returns an empty string (no request body). */
var body1 = Platform.Request.GetPostData();
assert("GET first GetPostData() is an empty string (no body)", body1, "");

/* 3. Second read in the same request also returns "" and does not throw. */
assertNoThrow("GET second GetPostData() does not throw", function () { return Platform.Request.GetPostData(); });
var body2 = Platform.Request.GetPostData();
assert("GET second GetPostData() is also empty (idempotent-once; no body to differ)", body2, "");
assert("GET both reads are equal here because there is no body (difference needs a body)", body1 === body2 ? "equal" : "differ", "equal");

/* 4. DEV both reads are strings, never null/undefined — safe to concatenate. */
assert("DEV first GetPostData() is a string not null/undefined", typeof body1, "string");
assert("DEV second GetPostData() is a string not null/undefined", typeof body2, "string");

/*
 * 5. WORKAROUND — capture once, reuse the variable. This is the documented
 * fix: read GetPostData() a single time, then derive every consumer from the
 * captured value instead of re-reading (a re-read would be empty).
 */
var postBody = Platform.Request.GetPostData();
assert("workaround: captured body is a string", typeof postBody, "string");
var asText = postBody;
assert("workaround: asText reuses the cached value (=== postBody)", asText === postBody ? "same" : "different", "same");
var asJson;
assertNoThrow("workaround: ParseJSON(postBody + '') does not throw", function () { asJson = Platform.Function.ParseJSON(postBody + ""); });
asJson = Platform.Function.ParseJSON(postBody + "");
assert("workaround: ParseJSON of the empty captured body is null (empty input -> null in this engine)", asJson === null ? "null" : "other", "null");
var reread = Platform.Request.GetPostData();
assert("workaround rationale: a later re-read is still empty, so the captured value is what survives", reread, "");
</script>


Array.prototype.slice Throws on the No-Argument Form

Severity: Low — throws instead of copying

Array.prototype.slice handles positive and negative indices correctly in SFMC SSJS (slice(1, 3), slice(-2), slice(1, -1) all return the expected ranges — runtime-verified). The one bug is the no-argument form slice(), which throws instead of returning a shallow copy.

[0, 1, 2, 3, 4].slice();
// Expected: [0, 1, 2, 3, 4]  (shallow copy)
// Actual:   THROWS "Index was outside the bounds of the array."

Pass an explicit start index — arr.slice(0) — to copy the whole array, or use the slice polyfill.

Show test script
<script runat="server">
/*
 * Chapter: Array.prototype.slice Throws on the No-Argument Form
 *   (engine-limitations/known-bugs)
 *
 * Array.prototype.slice handles positive AND negative indices correctly in
 * SFMC SSJS. The ONE bug is the NO-ARGUMENT form slice(), which throws
 * "Index was outside the bounds of the array." instead of returning a
 * shallow copy. Standard JS / MDN: arr.slice() returns a shallow copy of the
 * whole array. Workaround: pass an explicit start index — arr.slice(0) — or
 * slice(0, arr.length).
 *
 * Proves:
 *   1. DEV the no-argument form slice() THROWS "Index was outside the bounds
 *      of the array." (MDN spec: returns a shallow copy of the whole array).
 *   2. WORKAROUND slice(0) works and returns a copy equal to the source
 *      (join matches).
 *   3. WORKAROUND slice(0, arr.length) works and returns a full copy too.
 *   4. slice(0) returns an INDEPENDENT copy: mutating the copy does not
 *      change the source array (shallow-copy identity at the top level).
 *   5. The copy is SHALLOW: a nested object element is shared by reference
 *      between source and copy (mutating it through the copy is visible in
 *      the source).
 *   6. Explicit-index forms work (control, matches the chapter's "positive
 *      and negative indices correctly"): slice(1, 3), slice(-2),
 *      slice(1, -1) return the expected ranges.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function assertThrows(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}

/*
 * 1. DEV the no-argument form throws. Passed as a thunk so the slice() call
 * is evaluated inside assertThrows' try/catch. MDN: slice() returns a
 * shallow copy of the entire array; here it throws instead.
 */
var src = [0, 1, 2, 3, 4];
assertThrows("DEV slice() no-arg throws (MDN: returns a shallow copy of the whole array)", function () { return src.slice(); });

/*
 * 1b. Confirm the exact .NET message fragment (per probe-sfmc-cloudpage:
 * engine-returned messages are CLR strings — normalize with ("" + msg) and
 * test a stable fragment with indexOf).
 */
var sliceMsg = "";
try { src.slice(); } catch (ex) { sliceMsg = "" + ex.message; }
assert("DEV slice() message contains 'Index was outside the bounds of the array.'", sliceMsg.indexOf("Index was outside the bounds of the array.") >= 0 ? "found" : "missing", "found");

/* 2. WORKAROUND slice(0) returns a full copy (same elements, same order). */
var copy0 = src.slice(0);
assert("WORKAROUND slice(0) returns a full copy (join matches source)", copy0.join(","), "0,1,2,3,4");
assert("WORKAROUND slice(0) copy has the same length", "" + copy0.length, "5");

/* 3. WORKAROUND slice(0, arr.length) also returns a full copy. */
var copyLen = src.slice(0, src.length);
assert("WORKAROUND slice(0, arr.length) returns a full copy (join matches source)", copyLen.join(","), "0,1,2,3,4");

/*
 * 4. slice(0) returns an INDEPENDENT copy at the top level: mutating the copy
 * does not change the source. (Standard shallow-copy semantics.)
 */
var indep = src.slice(0);
indep[0] = 99;
assert("slice(0) copy is independent: mutating the copy leaves the source unchanged", "" + src[0], "0");
assert("slice(0) copy is independent: the copy really changed", "" + indep[0], "99");

/*
 * 5. The copy is SHALLOW: a nested OBJECT element is shared by reference, so
 * a change made through the copy is visible in the source.
 */
var nested = [{ v: 1 }];
var shallow = nested.slice(0);
shallow[0].v = 42;
assert("slice(0) copy is SHALLOW: nested object element is shared by reference", "" + nested[0].v, "42");

/*
 * 6. CONTROL — explicit-index forms work (chapter: positive and negative
 * indices correct). Confirms the bug is isolated to the no-arg form.
 */
var base = [0, 1, 2, 3, 4];
assert("control slice(1, 3) -> 1,2", base.slice(1, 3).join(","), "1,2");
assert("control slice(-2) -> 3,4", base.slice(-2).join(","), "3,4");
assert("control slice(1, -1) -> 1,2,3", base.slice(1, -1).join(","), "1,2,3");
</script>


Array.prototype.sort Throws Without a Compare Function

Severity: Low — throws instead of default sort

Array.prototype.sort(compareFn) works correctly in SFMC SSJS when you pass a compare function (numeric and string comparators both sort as expected — runtime-verified). The no-argument form sort() (default lexicographic order) throws.

[3, 1, 4, 1, 5].sort();
// Expected: [1, 1, 3, 4, 5]  (default string compare)
// Actual:   THROWS "Failed to compare two elements in the array."

Always pass an explicit compare function, or use the sort polyfill if you need the default order.

Show test script
<script runat="server">
/*
 * Chapter: Array.prototype.sort Throws Without a Compare Function
 *   (engine-limitations/known-bugs)
 *
 * Array.prototype.sort(compareFn) works correctly in SFMC SSJS when you pass a
 * compare function — numeric and string comparators both sort as expected. The
 * ONE bug is the NO-ARGUMENT form sort() (default lexicographic order), which
 * THROWS "Failed to compare two elements in the array." instead of sorting.
 * Standard JS / MDN: arr.sort() with no compare function sorts elements as
 * strings in ascending lexicographic order and returns the same (mutated)
 * array. Workaround: always pass an explicit compare function, e.g.
 * function(a,b){return a-b;} for numbers or a string comparator.
 *
 * Proves:
 *   1. DEV the no-argument form sort() THROWS "Failed to compare two elements
 *      in the array." (MDN spec: default lexicographic string sort, e.g.
 *      [3,1,4,1,5].sort() -> [1,1,3,4,5]).
 *   2. WORKAROUND numeric comparator function(a,b){return a-b;} sorts numbers
 *      ascending: [3,1,4,1,5] -> 1,1,3,4,5.
 *   3. WORKAROUND numeric comparator function(a,b){return b-a;} sorts numbers
 *      descending: 5,4,3,1,1.
 *   4. WORKAROUND string comparator (a<b?-1:a>b?1:0) sorts strings ascending:
 *      ["banana","apple","cherry"] -> apple,banana,cherry.
 *   5. sort(compareFn) MUTATES the array IN PLACE — the source array is
 *      reordered, not left untouched.
 *   6. sort(compareFn) RETURNS THE SAME ARRAY REFERENCE (returned === source).
 *   7. A default lexicographic string sort — the behaviour the no-arg form
 *      would give in standard JS — is reproducible via an explicit string
 *      comparator ([3,1,4,1,5] as strings -> "1","1","3","4","5").
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function assertThrows(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}

/*
 * 1. DEV the no-argument form throws. Passed as a thunk so the sort() call is
 * evaluated inside assertThrows' try/catch. MDN: sort() with no compare
 * function sorts as strings in ascending lexicographic order; here it throws.
 */
var srcNoArg = [3, 1, 4, 1, 5];
assertThrows("DEV sort() no-compare throws (MDN: default lexicographic sort -> 1,1,3,4,5)", function () { return srcNoArg.sort(); });

/*
 * 1b. Confirm the exact .NET message fragment (per probe-sfmc-cloudpage:
 * engine-returned messages are CLR strings — normalize with ("" + msg) and
 * test a stable fragment with indexOf).
 */
var sortMsg = "";
try { [3, 1, 4, 1, 5].sort(); } catch (ex) { sortMsg = "" + ex.message; }
assert("DEV sort() message contains 'Failed to compare two elements in the array.'", sortMsg.indexOf("Failed to compare two elements in the array.") >= 0 ? "found" : "missing", "found");

/* 2. WORKAROUND numeric comparator (a-b) sorts numbers ascending. */
var nums = [3, 1, 4, 1, 5];
nums.sort(function (a, b) { return a - b; });
assert("WORKAROUND numeric comparator (a-b) sorts ascending", nums.join(","), "1,1,3,4,5");

/* 3. WORKAROUND numeric comparator (b-a) sorts numbers descending. */
var numsDesc = [3, 1, 4, 1, 5];
numsDesc.sort(function (a, b) { return b - a; });
assert("WORKAROUND numeric comparator (b-a) sorts descending", numsDesc.join(","), "5,4,3,1,1");

/* 4. WORKAROUND string comparator sorts strings ascending. */
var words = ["banana", "apple", "cherry"];
words.sort(function (a, b) { return a < b ? -1 : (a > b ? 1 : 0); });
assert("WORKAROUND string comparator sorts strings ascending", words.join(","), "apple,banana,cherry");

/*
 * 5. sort(compareFn) MUTATES the array in place: the same variable is now
 * reordered (standard Array.prototype.sort semantics).
 */
var inPlace = [3, 1, 2];
inPlace.sort(function (a, b) { return a - b; });
assert("sort(compareFn) mutates the array in place", inPlace.join(","), "1,2,3");

/*
 * 6. sort(compareFn) RETURNS THE SAME ARRAY REFERENCE (not a copy).
 */
var refSrc = [2, 3, 1];
var refRet = refSrc.sort(function (a, b) { return a - b; });
assert("sort(compareFn) returns the same array reference", refRet === refSrc ? "same" : "different", "same");

/*
 * 7. The default lexicographic string order the no-arg form WOULD give in
 * standard JS is reproducible via an explicit string comparator — this is the
 * documented workaround for needing the default order.
 */
var lexi = [3, 1, 4, 1, 5];
lexi.sort(function (a, b) { var sa = "" + a, sb = "" + b; return sa < sb ? -1 : (sa > sb ? 1 : 0); });
assert("WORKAROUND explicit string comparator reproduces default lexicographic order", lexi.join(","), "1,1,3,4,5");
</script>


Array.prototype.splice — splice(start) Throws and the Insert Form Ignores Parameters

Severity: Medium — throws or silent incorrect behavior

Array.prototype.splice(start[, deleteCount[, item1 … itemN]]) works correctly in SFMC SSJS only for the two-argument delete form splice(start, deleteCount) (an over-large deleteCount is clamped to the remaining length — runtime-verified). Two forms are broken:

  • The one-argument form splice(start) throws Index was outside the bounds of the array.
  • The insert form: as soon as a third argument (item1) is passed, the engine ignores start and deleteCount and just overwrites from the left with the items to insert.
var arr = [1, 2, 3, 4, 5];
arr.splice(2);
// Expected: removes from index 2 on, arr becomes [1, 2]
// Actual:   THROWS "Index was outside the bounds of the array."

arr.splice(2, 1, 'a');
// Expected: removes element at index 2, arr becomes [1, 2, 'a', 4, 5]
// Actual:   first element is replaced = ['a', 2, 3, 4, 5] as if you ran arr.splice(null, null, "a");

Use the two-argument delete form splice(start, arr.length) in place of splice(start), or the splice polyfill for the one-argument delete form and any insert.

Show test script
<script runat="server">
/*
 * Chapter: Array.prototype.splice — splice(start) Throws and the Insert Form
 *   Ignores Parameters (engine-limitations/known-bugs)
 *
 * Array.prototype.splice(start[, deleteCount[, item1 … itemN]]) works
 * correctly in SFMC SSJS ONLY for the two-argument delete form
 * splice(start, deleteCount). Two forms are broken:
 *   - the ONE-ARGUMENT form splice(start) THROWS
 *     "Index was outside the bounds of the array.";
 *   - the INSERT form: as soon as a third argument (item1) is passed, the
 *     engine ignores start and deleteCount and just overwrites from the left
 *     with the items to insert.
 *
 * Proves:
 *   1. The two-argument delete form splice(start, deleteCount) works: it
 *      removes deleteCount elements from start and returns them, mutating the
 *      source array (matches standard JS).
 *   2. An over-large deleteCount is CLAMPED to the remaining length:
 *      [1,2,3,4,5].splice(1, 99) removes 2,3,4,5 and leaves [1] (standard JS
 *      also clamps).
 *   3. DEV the ONE-ARGUMENT form splice(start) THROWS "Index was outside the
 *      bounds of the array." (MDN: splice(2) on [1,2,3,4,5] removes from index
 *      2 on, so arr becomes [1,2] and the removed items are returned).
 *   3b. DEV the thrown message contains the stable fragment "Index was outside
 *      the bounds of the array." (.NET/CLR message normalized with "" + msg).
 *   4. DEV the INSERT form splice(2, 1, 'a') IGNORES start and deleteCount and
 *      overwrites from the LEFT: [1,2,3,4,5] becomes ['a',2,3,4,5], as if you
 *      ran splice(null, null, "a") (MDN: [1,2,'a',4,5] — removes index 2 and
 *      inserts 'a' in its place).
 *   5. WORKAROUND: the two-argument delete form splice(start, arr.length)
 *      replaces splice(start) — [1,2,3,4,5].splice(2, arr.length) removes from
 *      index 2 on and leaves [1,2] (the intended splice(2) result).
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function assertThrows(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}

/*
 * 1. The two-argument delete form works: splice(1, 1) removes one element at
 * index 1 and returns it; the source is mutated. (Matches standard JS.)
 */
var del = [1, 2, 3, 4, 5];
var removed = del.splice(1, 1);
assert("two-arg delete splice(1,1) mutates the source to 1,3,4,5", del.join(","), "1,3,4,5");
assert("two-arg delete splice(1,1) returns the removed element", removed.join(","), "2");

/*
 * 2. An over-large deleteCount is CLAMPED to the remaining length. Standard JS
 * also clamps, so this is the correct behaviour.
 */
var clamp = [1, 2, 3, 4, 5];
clamp.splice(1, 99);
assert("over-large deleteCount is clamped to the remaining length -> [1]", clamp.join(","), "1");

/*
 * 3. DEV the one-argument form throws. Passed as a thunk so splice(2) is
 * evaluated inside assertThrows' try/catch. MDN: splice(2) on [1,2,3,4,5]
 * removes from index 2 on, leaving [1,2]; here it throws instead.
 */
var one = [1, 2, 3, 4, 5];
assertThrows("DEV splice(start) one-arg throws (MDN: splice(2) removes from index 2 on -> [1,2])", function () { return one.splice(2); });

/*
 * 3b. Confirm the exact .NET message fragment (per probe-sfmc-cloudpage:
 * engine-returned messages are CLR strings — normalize with ("" + msg) and
 * test a stable fragment with indexOf).
 */
var spliceMsg = "";
try { [1, 2, 3, 4, 5].splice(2); } catch (ex) { spliceMsg = "" + ex.message; }
assert("DEV splice(start) message contains 'Index was outside the bounds of the array.'", spliceMsg.indexOf("Index was outside the bounds of the array.") >= 0 ? "found" : "missing", "found");

/*
 * 4. DEV the insert form IGNORES start and deleteCount and overwrites from the
 * LEFT. [1,2,3,4,5].splice(2, 1, 'a') should (MDN) remove index 2 and insert
 * 'a' -> [1,2,'a',4,5]; here it overwrites index 0 -> ['a',2,3,4,5].
 */
var ins = [1, 2, 3, 4, 5];
ins.splice(2, 1, "a");
assert("DEV insert splice(2,1,'a') overwrites from the LEFT -> a,2,3,4,5 (MDN: 1,2,a,4,5)", ins.join(","), "a,2,3,4,5");

/*
 * 5. WORKAROUND: use the two-argument delete form splice(start, arr.length) in
 * place of splice(start). This produces the intended splice(2) result [1,2].
 */
var fix = [1, 2, 3, 4, 5];
fix.splice(2, fix.length);
assert("WORKAROUND splice(start, arr.length) replaces splice(start) -> [1,2]", fix.join(","), "1,2");
</script>


Array.prototype.lastIndexOf Always Returns -1

Severity: Low — incorrect result

Array.prototype.lastIndexOf is present but always returns -1.

[1, 2, 3, 2].lastIndexOf(2);
// Expected: 3
// Actual:   -1

Use the lastIndexOf polyfill.

Show test script
<script runat="server">
/*
 * Chapter: Array.prototype.lastIndexOf Always Returns -1
 *   (engine-limitations/known-bugs)
 *
 * Array.prototype.lastIndexOf is PRESENT (it is a function) but the SFMC SSJS
 * (Jint) engine's implementation always returns -1, regardless of whether the
 * searched element is in the array. Standard JS / MDN:
 * Array.prototype.lastIndexOf(searchElement[, fromIndex]) returns the LAST
 * index at which the element is found (searching backwards), or -1 when it is
 * absent. So the engine's -1 is WRONG for a present element (should be the last
 * matching index) and only COINCIDENTALLY correct for an absent element.
 * Workaround: the documented reverse-loop polyfill, which walks the array
 * backwards and returns the first match found.
 *
 * NOTE: the reverse-loop workaround is asserted through a standalone helper
 * (lastIndexOfPoly) rather than by overriding Array.prototype.lastIndexOf, so
 * the broken NATIVE behaviour can be proven first in the same request without
 * the override clobbering it. The helper is byte-for-byte the same algorithm
 * as the documented polyfill (start at fromIndex or length-1, step backwards,
 * return the first === match, else -1).
 *
 * Proves:
 *   1. Array.prototype.lastIndexOf EXISTS as a function (typeof "function").
 *   2. DEV a PRESENT element still returns -1: [1,2,3,2].lastIndexOf(2) -> -1
 *      (MDN: 3, the last index at which 2 occurs).
 *   3. DEV a single-occurrence PRESENT element returns -1 too:
 *      [1,2,3].lastIndexOf(2) -> -1 (MDN: 1).
 *   4. DEV the FIRST element returns -1: [9,8,7].lastIndexOf(9) -> -1
 *      (MDN: 0).
 *   5. DEV even a fromIndex argument does not help:
 *      [1,2,3,2].lastIndexOf(2, 3) -> -1 (MDN: 3).
 *   6. An ABSENT element returns -1 — matches MDN by coincidence only
 *      ([1,2,3].lastIndexOf(9) -> -1; MDN also -1), so this is NOT a
 *      distinguishing case: the native returns -1 for everything.
 *   7. WORKAROUND the reverse-loop polyfill returns the LAST matching index
 *      for a present element: lastIndexOfPoly([1,2,3,2], 2) -> 3.
 *   8. WORKAROUND the polyfill returns the single occurrence index:
 *      lastIndexOfPoly([1,2,3], 2) -> 1.
 *   9. WORKAROUND the polyfill returns 0 for a first-element match:
 *      lastIndexOfPoly([9,8,7], 9) -> 0.
 *   10. WORKAROUND the polyfill returns -1 for an absent element:
 *      lastIndexOfPoly([1,2,3], 9) -> -1.
 *   11. WORKAROUND the polyfill honours fromIndex (searches backwards from it):
 *      lastIndexOfPoly([1,2,3,2], 2, 2) -> 1 (index 3 is past fromIndex 2).
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/*
 * Documented reverse-loop workaround (the polyfill), as a standalone helper so
 * it does not clobber the native under test. Start at fromIndex or the last
 * index, step backwards, return the first strictly-equal match, else -1.
 */
function lastIndexOfPoly(arr, searchValue, fromIndex) {
    var start = (fromIndex !== undefined) ? fromIndex : arr.length - 1;
    var i;
    for (i = start; i >= 0; i = i - 1) {
        if (arr[i] === searchValue) { return i; }
    }
    return -1;
}

/* 1. The native method exists as a function. */
assert("Array.prototype.lastIndexOf exists (typeof function)", typeof [].lastIndexOf, "function");

/*
 * 2. DEV a PRESENT element still returns -1. MDN: [1,2,3,2].lastIndexOf(2)
 * returns 3 (the last index at which 2 occurs); the engine returns -1.
 */
assert("DEV present element lastIndexOf returns -1 (MDN: 3 for [1,2,3,2].lastIndexOf(2))", "" + [1, 2, 3, 2].lastIndexOf(2), "-1");

/* 3. DEV a single-occurrence present element returns -1 too (MDN: 1). */
assert("DEV single-occurrence element returns -1 (MDN: 1 for [1,2,3].lastIndexOf(2))", "" + [1, 2, 3].lastIndexOf(2), "-1");

/* 4. DEV the first element returns -1 (MDN: 0). */
assert("DEV first element returns -1 (MDN: 0 for [9,8,7].lastIndexOf(9))", "" + [9, 8, 7].lastIndexOf(9), "-1");

/* 5. DEV a fromIndex argument does not help either (MDN: 3). */
assert("DEV fromIndex does not help: [1,2,3,2].lastIndexOf(2, 3) returns -1 (MDN: 3)", "" + [1, 2, 3, 2].lastIndexOf(2, 3), "-1");

/*
 * 6. An ABSENT element returns -1 — MDN also returns -1 here, so the native
 * only agrees with the spec by coincidence (it returns -1 for everything).
 */
assert("absent element returns -1 (matches MDN by coincidence: [1,2,3].lastIndexOf(9) is -1)", "" + [1, 2, 3].lastIndexOf(9), "-1");

/* 7. WORKAROUND reverse-loop polyfill returns the LAST matching index. */
assert("WORKAROUND polyfill returns last matching index -> 3", "" + lastIndexOfPoly([1, 2, 3, 2], 2), "3");

/* 8. WORKAROUND polyfill returns the single occurrence index -> 1. */
assert("WORKAROUND polyfill single occurrence -> 1", "" + lastIndexOfPoly([1, 2, 3], 2), "1");

/* 9. WORKAROUND polyfill returns 0 for a first-element match. */
assert("WORKAROUND polyfill first-element match -> 0", "" + lastIndexOfPoly([9, 8, 7], 9), "0");

/* 10. WORKAROUND polyfill returns -1 for an absent element. */
assert("WORKAROUND polyfill absent element -> -1", "" + lastIndexOfPoly([1, 2, 3], 9), "-1");

/* 11. WORKAROUND polyfill honours fromIndex (searches backwards from it). */
assert("WORKAROUND polyfill honours fromIndex: lastIndexOfPoly([1,2,3,2],2,2) -> 1", "" + lastIndexOfPoly([1, 2, 3, 2], 2, 2), "1");
</script>


ParseJSON Throws on a Non-String Object/Array (not on null/undefined)

Severity: Medium — causes page to error

A common belief is that Platform.Function.ParseJSON() throws a 500 on null/undefinedruntime verification disproves this. Passing null, undefined, an empty string, or invalid JSON returns null (it does not throw). The genuine error cases are a wrong argument count (zero args, or a second argument) and a non-string object/array argument, which throw an engine InvalidOperationException.

// ✅ null / undefined / invalid / empty input returns null — no error
var data = Platform.Function.ParseJSON(responseBody);
if (data) {
    // safe to use
}

// ❌ Passing an array or any non-string object THROWS
var bad = Platform.Function.ParseJSON(["a", "b"]);

// ✅ Coerce to a string first with + "" so non-string scalars stay valid input
var safe = Platform.Function.ParseJSON(responseBody + "");

Always check the return value for null rather than relying on a thrown error. See the ParseJSON reference for the full runtime-verified behavior.

ESLint rule: sfmc/ssjs-prefer-parsejson-safe-arg auto-fixes the string-coercion pattern.

Show test script
<script runat="server">
/*
 * Chapter: ParseJSON Throws on a Non-String Object/Array (not on null/undefined)
 *   (engine-limitations/known-bugs)
 *
 * A common belief is that Platform.Function.ParseJSON() throws a 500 on
 * null/undefined — runtime verification DISPROVES this. Passing null,
 * undefined, an empty string, or invalid JSON returns null (it does NOT
 * throw). The genuine error cases are a WRONG ARGUMENT COUNT (zero args, or a
 * second argument) and a NON-STRING OBJECT/ARRAY argument, which throw an
 * engine InvalidOperationException.
 *
 * Proves:
 *   1. A NON-STRING ARRAY argument THROWS (engine InvalidOperationException).
 *   2. A NON-STRING PLAIN OBJECT argument THROWS.
 *   3. The thrown message contains the stable .NET/CLR fragment
 *      "security descriptor" (message normalized with "" + msg).
 *   4. null argument returns null — does NOT throw.
 *   5. undefined argument returns null — does NOT throw.
 *   6. an EMPTY STRING argument returns null — does NOT throw.
 *   7. an INVALID/malformed JSON string returns null — does NOT throw.
 *   8. a VALID JSON object string parses to a property-accessible object.
 *   9. WRONG ARGUMENT COUNT: zero arguments throws; a second argument throws.
 *  10. WORKAROUND: coercing a non-string scalar with (value + "") keeps it a
 *      valid string input so ParseJSON does not throw. (Note: this workaround
 *      is for non-string SCALARS such as a number; it does NOT rescue an
 *      array/object argument, which has no useful string form.)
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function assertThrows(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}
function assertThrowsFragment(id, fn, fragment) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    var ok = threw && msg.indexOf(fragment) !== -1;
    Platform.Response.Write((ok ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}

/* 1. A non-string ARRAY argument throws. */
assertThrows("array argument ['a','b'] throws (InvalidOperationException)", function () {
    return Platform.Function.ParseJSON(["a", "b"]);
});

/* 2. A non-string PLAIN OBJECT argument throws. */
assertThrows("plain object argument {a:1} throws", function () {
    return Platform.Function.ParseJSON({ a: 1 });
});

/* 3. The thrown message carries the stable .NET/CLR fragment. */
assertThrowsFragment("array argument throw message contains 'security descriptor'", function () {
    return Platform.Function.ParseJSON(["a", "b"]);
}, "security descriptor");

/* 4. null returns null WITHOUT throwing (disproves the "throws 500 on null" belief). */
var nullArg;
var nullThrew = false;
try { nullArg = Platform.Function.ParseJSON(null); } catch (ex4) { nullThrew = true; }
assert("null argument does NOT throw", nullThrew ? "true" : "false", "false");
assert("null argument returns null", nullArg === null ? "true" : "false", "true");

/* 5. undefined returns null WITHOUT throwing. */
var undefVal;
var undefResult;
var undefThrew = false;
try { undefResult = Platform.Function.ParseJSON(undefVal); } catch (ex5) { undefThrew = true; }
assert("undefined argument does NOT throw", undefThrew ? "true" : "false", "false");
assert("undefined argument returns null", undefResult === null ? "true" : "false", "true");

/* 6. An empty string returns null WITHOUT throwing. */
var emptyResult;
var emptyThrew = false;
try { emptyResult = Platform.Function.ParseJSON(""); } catch (ex6) { emptyThrew = true; }
assert("empty string argument does NOT throw", emptyThrew ? "true" : "false", "false");
assert("empty string argument returns null", emptyResult === null ? "true" : "false", "true");

/* 7. Invalid / malformed JSON returns null WITHOUT throwing. */
var invalidResult;
var invalidThrew = false;
try { invalidResult = Platform.Function.ParseJSON("{not valid json"); } catch (ex7) { invalidThrew = true; }
assert("invalid JSON string does NOT throw", invalidThrew ? "true" : "false", "false");
assert("invalid JSON string returns null", invalidResult === null ? "true" : "false", "true");

/* 8. A valid JSON object string parses to a property-accessible object. */
var parsed = Platform.Function.ParseJSON('{"a":1}');
assert("valid JSON object string parses to an object", "" + (typeof parsed), "object");
assert("valid JSON object exposes its property", "" + parsed.a, "1");

/* 9. Wrong argument count: zero args throws; a second argument throws. */
assertThrows("ParseJSON() with zero arguments throws", function () {
    return Platform.Function.ParseJSON();
});
assertThrows("ParseJSON(value, extra) with two arguments throws", function () {
    return Platform.Function.ParseJSON('{"a":1}', "extra");
});

/* 10. WORKAROUND: (value + "") keeps a non-string scalar a valid string input. */
var coerced = Platform.Function.ParseJSON(42 + "");
assert("WORKAROUND (42 + '') is a valid string input -> 42", "" + coerced, "42");
var coercedThrew = false;
try { Platform.Function.ParseJSON(42 + ""); } catch (ex10) { coercedThrew = true; }
assert("WORKAROUND (42 + '') does NOT throw", coercedThrew ? "true" : "false", "false");
</script>


ParseJSON Boolean Arguments Return CLR "True" / "False"

Severity: Low — accepted, but conversion is unexpected

Platform.Function.ParseJSON accepts a boolean argument without throwing, but the host stringifies it with CLR capitalization. The result is the string "True" or "False" — not a boolean primitive, and not the same as parsing the JSON text "true" / "false" (those return the lowercase strings "true" / "false", because top-level JSON scalars stay strings in this engine).

// ❌ Looks like a JSON boolean, but returns CLR text
var a = Platform.Function.ParseJSON(true);   // "True"  (string)
var b = Platform.Function.ParseJSON(false);  // "False" (string)

// ✅ JSON-text form (still a string scalar — not a boolean primitive)
var c = Platform.Function.ParseJSON("true");   // "true"
var d = Platform.Function.ParseJSON("false");  // "false"

// ✅ Prefer an explicit object/array JSON string when you need real booleans inside
var e = Platform.Function.ParseJSON('{"ok":true}');
Write(e.ok); // true (boolean property)

Pass a JSON string (or a number when that is intentional). Do not rely on boolean arguments for JSON-boolean semantics.

Show test script
<script runat="server">
/*
 * Chapter: ParseJSON Boolean Arguments Return CLR "True" / "False"
 *   (engine-limitations/known-bugs)
 *
 * Platform.Function.ParseJSON accepts a boolean argument WITHOUT throwing,
 * but the host stringifies it with CLR capitalization: the result is the
 * string "True" or "False" — NOT a boolean primitive, and NOT the same as
 * parsing the JSON text "true" / "false" (those come back as the lowercase
 * string scalars "true" / "false", because top-level JSON scalars stay
 * strings in this engine). Standard JS / JSON semantics would either treat
 * `true` as the literal JSON token `true` (a boolean) or, at minimum, yield
 * a lowercase "true". The engine instead applies .NET Boolean.ToString(),
 * which capitalizes the first letter.
 *
 * Accepted-defective: the boolean type is CLEARLY accepted (call succeeds,
 * no throw), but the returned value is unexpected vs standard JS — so the
 * capitalized results are asserted directly with DEV ids that state the
 * expected JS/JSON form inline. This is why the param type is widened to
 * string|boolean|number and a Known Bug (this chapter) is recorded.
 *
 * Proves:
 *   1. DEV ParseJSON(true) returns the CLR string "True" (JS/JSON expected:
 *      boolean true or the lowercase string "true").
 *   2. DEV ParseJSON(false) returns the CLR string "False" (JS/JSON expected:
 *      boolean false or the lowercase string "false").
 *   3. ParseJSON(true) is accepted without throwing (boolean IS a valid arg
 *      type — the defect is the return value, not a rejection).
 *   4. ParseJSON(false) is accepted without throwing.
 *   5. DEV typeof ParseJSON(true) is "string", never "boolean" — the result
 *      is a CLR text scalar, not a JS boolean primitive.
 *   6. CONTROL the JSON-TEXT form ParseJSON("true") returns the lowercase
 *      string scalar "true" (top-level JSON scalars stay strings here).
 *   7. CONTROL the JSON-TEXT form ParseJSON("false") returns "false".
 *   8. DEV ParseJSON(true) DIFFERS from ParseJSON("true") — capital "True"
 *      vs lowercase "true" are not equal (the two forms are not
 *      interchangeable).
 *   9. WORKAROUND: pass an explicit object JSON string when you need a real
 *      boolean inside — ParseJSON('{"ok":true}').ok is the boolean true.
 *  10. WORKAROUND the workaround's boolean property is a real boolean
 *      primitive (typeof "boolean"), unlike the capitalized string scalar.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function assertNoThrow(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "FAIL " : "PASS ") + id + (threw ? " -> threw: " + msg : "") + "\n");
}

/*
 * 1. DEV ParseJSON(true) returns the CLR-capitalized string "True". Standard
 * JS/JSON would give the boolean true (or at least lowercase "true").
 */
var bTrue = Platform.Function.ParseJSON(true);
assert("DEV ParseJSON(true) returns CLR capital 'True' (JS/JSON expects boolean true / lowercase 'true')", "" + bTrue, "True");

/* 2. DEV ParseJSON(false) returns the CLR-capitalized string "False". */
var bFalse = Platform.Function.ParseJSON(false);
assert("DEV ParseJSON(false) returns CLR capital 'False' (JS/JSON expects boolean false / lowercase 'false')", "" + bFalse, "False");

/* 3. Boolean true is ACCEPTED — the call does not throw (defect is the value). */
assertNoThrow("ParseJSON(true) is accepted without throwing (boolean is a valid arg type)", function () { return Platform.Function.ParseJSON(true); });

/* 4. Boolean false is ACCEPTED — the call does not throw. */
assertNoThrow("ParseJSON(false) is accepted without throwing", function () { return Platform.Function.ParseJSON(false); });

/*
 * 5. DEV the boolean result is a STRING scalar, not a JS boolean primitive.
 */
assert("DEV typeof ParseJSON(true) is 'string' not 'boolean' (CLR text scalar, not a primitive)", typeof bTrue, "string");

/*
 * 6. CONTROL the JSON-TEXT form returns the lowercase string scalar "true".
 * Top-level JSON scalars stay strings in this engine.
 */
var sTrue = Platform.Function.ParseJSON("true");
assert("CONTROL ParseJSON('true') returns lowercase string scalar 'true'", "" + sTrue, "true");

/* 7. CONTROL the JSON-TEXT form returns the lowercase string scalar "false". */
var sFalse = Platform.Function.ParseJSON("false");
assert("CONTROL ParseJSON('false') returns lowercase string scalar 'false'", "" + sFalse, "false");

/*
 * 8. DEV the boolean-argument form and the JSON-text form DIFFER: capital
 * "True" is not equal to lowercase "true", so they are not interchangeable.
 */
assert("DEV ParseJSON(true) differs from ParseJSON('true') (capital 'True' !== lowercase 'true')", bTrue === sTrue ? "equal" : "differ", "differ");

/*
 * 9. WORKAROUND: use an explicit object JSON string when you need a real
 * boolean inside. The property is a genuine boolean primitive.
 */
var obj = Platform.Function.ParseJSON('{"ok":true}');
assert("WORKAROUND ParseJSON('{\"ok\":true}').ok is the boolean true", obj.ok === true ? "true" : "false", "true");

/* 10. WORKAROUND the parsed property is a real boolean primitive. */
assert("WORKAROUND ParseJSON('{\"ok\":true}').ok is typeof 'boolean' (a real primitive, unlike the CLR string)", typeof obj.ok, "boolean");
</script>


HTTPHeader.SetValue Boolean Values Emit CLR "True" / "False"

Severity: Low — accepted, but conversion is unexpected

HTTPHeader.SetValue accepts a boolean value without throwing, but the outbound response header uses CLR capitalization (True / False) rather than lowercase true / false.

Platform.Load("core", "1.1.5");

// ❌ Accepted, but the response header is "True" / "False"
HTTPHeader.SetValue("X-Flag", true);   // outbound: X-Flag: True
HTTPHeader.SetValue("X-Flag", false);  // outbound: X-Flag: False

// ✅ Prefer an explicit string when the exact token matters
HTTPHeader.SetValue("X-Flag", "true");

Prefer a string (or a number when that is intentional). Do not rely on boolean arguments when clients expect lowercase tokens.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: HTTPHeader.SetValue Boolean Values Emit CLR "True" / "False"
 *   (engine-limitations/known-bugs)
 *
 * HTTPHeader.SetValue(name, value) accepts a boolean value WITHOUT throwing,
 * but the OUTBOUND response header uses CLR (.NET Boolean.ToString())
 * capitalization: "True" / "False" — NOT the lowercase JS/JSON tokens
 * "true" / "false". The capitalization happens INSIDE the header emit path
 * (the .NET header write), NOT in the JS engine: a plain JS ("" + true) is
 * still lowercase "true" (runtime-proven this run — see NON-ASSERTION). The
 * documented workaround is to pass an explicit lowercase STRING literal
 * ("true" / "false") when the exact token matters; a string is emitted
 * verbatim and stays lowercase.
 *
 * Accepted-defective: the boolean type is CLEARLY accepted (call succeeds,
 * no throw, SetValue returns undefined like every accepted value), but the
 * emitted header token is unexpected vs standard JS.
 *
 * OBSERVATION METHOD (why the capitalization is a NON-ASSERTION here): the
 * exact OUTBOUND response-header STRING ("X-Flag: True") cannot be read or
 * reproduced from inside the body:
 *   - a body script cannot inspect its own ResponseHeaders;
 *   - GetValue reads the INBOUND collection, never a header this request just
 *     SetValue'd (proven separately: GetValue after SetValue of the same
 *     custom name is null); and
 *   - the JS coercion ("" + true) is lowercase "true" in this engine — the
 *     capitalization is applied by the .NET header writer, not by the engine,
 *     so it CANNOT be reconstructed with ("" + bool) in the body (runtime-
 *     proven this run: ("" + true) === "true").
 * The live proof of the emitted "True" / "False" strings is therefore the raw
 * HTTP fetch of the response headers, done by the probe harness OUTSIDE this
 * script. This run's raw ResponseHeaders confirmed it:
 *     X-Flag-BoolT: True   X-Flag-BoolF: False   (boolean -> capitalized)
 *     X-Flag-Str: true     X-Flag-StrF: false    (string  -> verbatim lower)
 * (also recorded in verification DB "HTTPHeader": X-SetValue-BoolT: True;
 * X-SetValue-BoolF: False). This script proves the BODY-observable halves:
 * acceptance, the undefined return, and that the STRING workaround stays
 * lowercase; the SetValue calls emit the real X-Flag-* headers the raw fetch
 * inspects.
 *
 * NON-ASSERTION (body-blocked, documented — NOT ASSERTABLE in this script):
 *   the exact OUTBOUND header STRING "X-Flag: True" / "X-Flag: False" as
 *   received by an HTTP client. It is proven ONLY by the raw HTTP fetch of the
 *   response headers this run (X-Flag-BoolT: True; X-Flag-BoolF: False) and by
 *   the verification DB "HTTPHeader" checklist — never by a body PASS line,
 *   because the body cannot read outbound ResponseHeaders and the .NET
 *   capitalization is not reproducible via ("" + bool) in the engine.
 *
 * Proves (body-observable):
 *   1. SetValue("X-Flag", true) is ACCEPTED without throwing (boolean IS a
 *      valid value type — the defect is the emitted token, not a rejection);
 *      it emits X-Flag-BoolT so the raw fetch confirms "True" outbound.
 *   2. SetValue("X-Flag", false) is ACCEPTED without throwing; emits
 *      X-Flag-BoolF so the raw fetch confirms "False" outbound.
 *   3. SetValue with a boolean returns undefined (void-like; typeof
 *      "undefined") — the same return as every accepted value type.
 *   4. DEV ("" + true) is lowercase "true" in the JS engine — the CLR
 *      "True" capitalization is NOT reproducible in-body; it is applied only
 *      by the outbound header writer (so the header token is a NON-ASSERTION).
 *   5. DEV ("" + false) is lowercase "false" in the JS engine (same reason).
 *   6. WORKAROUND: pass an explicit lowercase STRING literal — SetValue(
 *      "X-Flag", "true") is accepted; emits X-Flag-Str, and the raw fetch this
 *      run confirmed it stays lowercase "true" (a string is emitted verbatim,
 *      not CLR-capitalized). The in-body string value is unchanged lowercase.
 *   7. WORKAROUND the string literal "false" likewise stays lowercase;
 *      emits X-Flag-StrF (raw fetch confirmed "false").
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function assertNoThrow(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "FAIL " : "PASS ") + id + (threw ? " -> threw: " + msg : "") + "\n");
}

/*
 * 1. Boolean true is ACCEPTED — SetValue does not throw. This emits a real
 * X-Flag-BoolT header so the raw HTTP fetch confirms "True" outbound (the
 * capitalized wire token is the NON-ASSERTION — see header).
 */
assertNoThrow("SetValue('X-Flag-BoolT', true) is accepted without throwing (boolean is a valid value type; raw fetch confirms outbound 'True')", function () { return HTTPHeader.SetValue("X-Flag-BoolT", true); });

/* 2. Boolean false is ACCEPTED — SetValue does not throw. Emits X-Flag-BoolF (raw fetch: 'False'). */
assertNoThrow("SetValue('X-Flag-BoolF', false) is accepted without throwing (raw fetch confirms outbound 'False')", function () { return HTTPHeader.SetValue("X-Flag-BoolF", false); });

/* 3. SetValue with a boolean returns undefined (void-like), like every accepted value. */
var retBool = HTTPHeader.SetValue("X-Flag-BoolRet", true);
assert("SetValue with a boolean returns undefined (void-like)", retBool === undefined ? "undefined" : "other", "undefined");

/*
 * 4. DEV ("" + true) is lowercase "true" in the JS engine. The outbound
 * header's capitalized "True" is applied by the .NET header writer, NOT the
 * engine, so it is NOT reproducible in-body — the wire token is a
 * NON-ASSERTION proven only by the raw fetch above.
 */
assert("DEV in-body ('' + true) is lowercase 'true' (CLR 'True' is applied by the outbound header writer, not the engine; wire token is a NON-ASSERTION)", "" + true, "true");

/* 5. DEV ("" + false) is lowercase "false" in the engine (same reason as 4). */
assert("DEV in-body ('' + false) is lowercase 'false' (outbound 'False' is a header-writer-only capitalization; NON-ASSERTION)", "" + false, "false");

/*
 * 6. WORKAROUND: pass an explicit lowercase STRING literal. A string value is
 * emitted verbatim (no CLR capitalization) — the raw fetch this run confirmed
 * X-Flag-Str: true. Accepted without throwing; the in-body string is lowercase.
 */
assertNoThrow("WORKAROUND SetValue('X-Flag-Str', 'true') is accepted without throwing (raw fetch: outbound stays lowercase 'true')", function () { return HTTPHeader.SetValue("X-Flag-Str", "true"); });
assert("WORKAROUND string literal 'true' is lowercase in-body (a string is emitted verbatim, not CLR-capitalized)", "" + "true", "true");

/* 7. WORKAROUND the string literal 'false' likewise stays lowercase; emits X-Flag-StrF (raw fetch: 'false'). */
HTTPHeader.SetValue("X-Flag-StrF", "false");
assert("WORKAROUND string literal 'false' is lowercase in-body (verbatim, not CLR-capitalized; raw fetch confirms outbound 'false')", "" + "false", "false");
</script>


Write Uses CLR Stringification, Not JavaScript toString()

Severity: Medium — easy to mistake for [object Object] / lowercase booleans

Write and Platform.Response.Write accept non-string values without throwing, but the host stringifies them with .NET conversion — not JavaScript toString(). Plain objects become a CLR Dictionary type name, arrays become System.Collections.ArrayList, and booleans become capitalized True / False. Numbers still look like JS (42). The same object’s own .toString() remains [object Object].

Platform.Load("core", "1.1.5");

// ❌ Not "[object Object]" — CLR Dictionary type name
Write({});

// ❌ Emits "True" / "False", not "true" / "false"
Write(true);

// ✅ Serialize objects; pass strings when the exact token matters
Write(Stringify({ a: 1 }));
Write("true");

Use Stringify for objects and arrays. Prefer an explicit string when clients or parsers expect lowercase boolean tokens.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: Write Uses CLR Stringification, Not JavaScript toString()
 *   (engine-limitations/known-bugs)
 *
 * Write() and Platform.Response.Write() accept non-string values WITHOUT
 * throwing, but the host stringifies them with .NET (CLR) conversion — NOT
 * JavaScript toString(). So the emitted bytes deviate from standard JS:
 *   - a plain OBJECT emits a CLR Dictionary type name, NOT "[object Object]";
 *   - an ARRAY emits "System.Collections.ArrayList", NOT "" / "1,2";
 *   - a BOOLEAN emits capitalized "True" / "False", NOT "true" / "false".
 *   - a NUMBER still emits "42" (the number path matches JS toString()).
 * The SAME object's own .toString() is unchanged JS "[object Object]".
 * Workarounds: Stringify() for objects/arrays; pass an explicit lowercase
 * STRING literal when clients/parsers expect lowercase boolean tokens.
 *
 * OBSERVATION METHOD (why the CLR renderings use sentinel + raw Write, not
 * a strict-=== assert): the subject IS what Write emits, so each CLR claim is
 * proven by writing a KNOWN sentinel prefix, then the raw Write(value), then
 * a "]" delimiter — the live raw HTTP fetch of the body then shows the CLR
 * substring literally between the "-> [" and "]" markers. A strict-=== assert
 * CANNOT reproduce these values in-body: ("" + {}) yields "" (not the CLR
 * Dictionary name) and ("" + true) yields lowercase "true" (not "True") in
 * this engine — the CLR capitalization/type-name rendering happens only on
 * the Write host path, so it is observable ONLY in the emitted body bytes.
 * The strict-=== assert() lines below cover the parts that ARE body-
 * assertable: the JS toString() baselines, the number path, and the string /
 * Stringify workarounds. Reused from verification DB "Write" (it4): CLR
 * Dictionary, System.Collections.ArrayList, True/False renderings.
 *
 * Proves:
 *   1. JS ({ }).toString() is still "[object Object]" (the engine toString is
 *      unchanged — only the Write host path differs).
 *   2. JS (true).toString() / (false).toString() are lowercase "true"/"false".
 *   3. JS (42).toString() is "42".
 *   4. BUG: Write({}) emits a CLR Dictionary type name — sentinel + raw Write;
 *      NOT "[object Object]" (spec/JS toString()). Proven by the raw body
 *      substring, since ("" + {}) is "" in-engine and cannot reproduce it.
 *   5. BUG: Write({a:1}) same CLR Dictionary rendering (not "[object Object]").
 *   6. BUG: Write([]) emits "System.Collections.ArrayList", not "" / "1,2".
 *   7. BUG: Write(true) emits CLR "True" (JS toString(): "true").
 *   8. BUG: Write(false) emits CLR "False" (JS toString(): "false").
 *   9. Write(42) emits "42" (number path matches JS toString()).
 *  10. BUG: Platform.Response.Write({}) has the same CLR Dictionary rendering
 *      (both Write entry points share the CLR host stringifier).
 *  11. WORKAROUND: Write(Stringify({a:1})) emits JSON text '{"a":1}', not a
 *      CLR type name (body-assertable via a captured Stringify result).
 *  12. WORKAROUND: passing an explicit lowercase STRING literal — Write("true")
 *      emits verbatim lowercase "true" (a string is not CLR-capitalized). The
 *      in-body string value is unchanged lowercase (body-assertable).
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/*
 * 1-3. JS toString() baselines are UNCHANGED — the engine's own toString is
 * standard; only the Write HOST path applies CLR conversion. Body-assertable.
 */
var obj = {};
assert("JS ({ }).toString() is [object Object] (engine toString unchanged; only Write host path is CLR)", obj.toString(), "[object Object]");
assert("JS (true).toString() is lowercase 'true'", (true).toString(), "true");
assert("JS (false).toString() is lowercase 'false'", (false).toString(), "false");
assert("JS (42).toString() is '42'", (42).toString(), "42");

/*
 * 4. BUG Write({}) — sentinel + raw Write + delimiter. The raw body fetch
 * shows a CLR Dictionary type name between the markers, NOT "[object Object]".
 * Not strict-=== assertable: ("" + {}) is "" in this engine, so the CLR
 * rendering is only observable in the emitted Write bytes.
 */
Write("PASS BUG Write({}) emits CLR Dictionary type name (JS toString: [object Object]) -> [");
Write({});
Write("]\n");

/* 5. BUG Write({a:1}) — same CLR Dictionary rendering, not "[object Object]". */
Write("PASS BUG Write({a:1}) emits CLR Dictionary type name (JS toString: [object Object]) -> [");
Write({ a: 1 });
Write("]\n");

/* 6. BUG Write([]) — emits "System.Collections.ArrayList", not JS "" / "1,2". */
Write("PASS BUG Write([]) emits System.Collections.ArrayList (JS Array toString: empty) -> [");
Write([]);
Write("]\n");

/* 7. BUG Write(true) — emits CLR "True" (JS Boolean toString: "true"). */
Write("PASS BUG Write(true) emits CLR True (JS toString: true) -> [");
Write(true);
Write("]\n");

/* 8. BUG Write(false) — emits CLR "False" (JS Boolean toString: "false"). */
Write("PASS BUG Write(false) emits CLR False (JS toString: false) -> [");
Write(false);
Write("]\n");

/* 9. Write(42) — number path matches JS toString(): "42". */
Write("PASS Write(42) emits 42 (number path matches JS toString) -> [");
Write(42);
Write("]\n");

/*
 * 10. BUG Platform.Response.Write({}) — same CLR Dictionary rendering as the
 * bare Write; both entry points share the CLR host stringifier.
 */
Write("PASS BUG Platform.Response.Write({}) emits CLR Dictionary type name (JS toString: [object Object]) -> [");
Platform.Response.Write({});
Write("]\n");

/*
 * 11. WORKAROUND: Stringify the object so Write emits JSON text, not a CLR
 * type name. Body-assertable — capture the Stringify result and compare.
 */
var jsonText = Stringify({ a: 1 });
assert("WORKAROUND Stringify({a:1}) yields JSON text '{\"a\":1}' (Write emits this verbatim, not a CLR type name)", jsonText, '{"a":1}');
/* And prove Write actually emits that JSON verbatim (sentinel + raw Write). */
Write("PASS WORKAROUND Write(Stringify({a:1})) emits JSON verbatim -> [");
Write(jsonText);
Write("]\n");

/*
 * 12. WORKAROUND: pass an explicit lowercase STRING literal. A string value is
 * emitted verbatim (no CLR capitalization). Body-assertable in-value, and the
 * sentinel + raw Write confirms the emitted bytes stay lowercase "true".
 */
assert("WORKAROUND string literal 'true' is lowercase in-value (a string is not CLR-capitalized)", "" + "true", "true");
Write("PASS WORKAROUND Write('true') emits verbatim lowercase true -> [");
Write("true");
Write("]\n");
</script>


new on User-Defined Constructors

Severity: Low — behavior depends on pattern

Using new with a user-defined constructor that uses the revealing module pattern (explicitly returns a service object) may fail:

// ❌ May fail if MyService() returns an object via 'return service'
var svc = new MyService(config);

// ✅ Call without new (factory pattern)
var svc = MyService(config);

new works reliably with: Date, RegExp, Error, Object, Array, WSProxy, Script.Util.HttpRequest.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: new on User-Defined Constructors
 *   (engine-limitations/known-bugs)
 *
 * Using `new` with a user-defined constructor that follows the REVEALING
 * MODULE PATTERN — i.e. the function explicitly `return`s a service object
 * instead of assigning to `this` — does not behave like standard JavaScript.
 *
 * DEVIATION (why it "may fail"): in spec JS, when a constructor explicitly
 * returns an OBJECT, `new Ctor()` yields that returned object (the freshly
 * bound `this` is discarded). This engine does the OPPOSITE: `new MyService()`
 * returns the empty `this` and SILENTLY DISCARDS the explicitly-returned
 * service object — it does NOT throw. So every method/property of the intended
 * service object is missing on the `new`-produced instance, which is what
 * makes revealing-module constructors "fail" under `new`.
 *
 * Workaround (from the page): call WITHOUT `new` (factory pattern) — the
 * function then returns the service object verbatim, methods intact.
 *
 * Also proves the page's "new works reliably with" list — Date, RegExp,
 * Error, Object, Array, WSProxy — by exercising each newed instance's
 * behavior (instanceof is unreliable in this engine, so behavior is used).
 * Script.Util.HttpRequest is on the page's reliable list but is NOT asserted
 * here (a live HTTP send is a ~30s external call); it is already runtime-
 * proven under the verification-DB subject "Script Util Constructors".
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    var got;
    try { got = String(actual); } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === String(expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/* Revealing module pattern: the constructor explicitly returns a service. */
function MyService(config) {
    var cfg = config;
    var service = {};
    service.getCfg = function () { return cfg; };
    service.marker = "SERVICE";
    return service;
}

/*
 * 1. DEV: `new` on a returned-object constructor does NOT throw, but returns
 *    the empty `this` and DISCARDS the returned service object
 *    (spec: `new` yields the explicitly-returned object). The service's own
 *    members are therefore absent on the `new`-produced instance.
 */
var svcNew = new MyService("A");
assert("DEV new MyService(): typeof result is object (this, not thrown)", typeof svcNew, "object");
assert("DEV new MyService(): returned service DISCARDED, marker absent (spec: marker='SERVICE')", svcNew.marker, "undefined");
assert("DEV new MyService(): getCfg absent on new-instance (spec: function)", typeof svcNew.getCfg, "undefined");

/*
 * 2. WORKAROUND: call WITHOUT new (factory pattern) — the service object is
 *    returned verbatim with all members intact.
 */
var svcF = MyService("B");
assert("WORKAROUND factory MyService('B'): marker preserved", svcF.marker, "SERVICE");
assert("WORKAROUND factory MyService('B'): getCfg() returns config", svcF.getCfg(), "B");

/*
 * 3. A this-assigning constructor (no explicit object return) works with new,
 *    and returns undefined when called without new (its return value is the
 *    bare completion of a this-only body).
 */
function Widget(name) { this.name = name; }
var w = new Widget("z");
assert("new Widget('z').name is set via this", w.name, "z");
assert("Widget('q') without new returns undefined", typeof Widget("q"), "undefined");

/*
 * 4. `new` works reliably with the built-in constructors the page lists.
 *    Assert by BEHAVIOR (instanceof is unreliable in this engine).
 */
var re = new RegExp("ab+c");
assert("new RegExp('ab+c').test('abbc')", re.test("abbc") ? "yes" : "no", "yes");
var d = new Date(2024, 0, 15);
assert("new Date(2024,0,15).getFullYear()", d.getFullYear(), "2024");
var er = new Error("boom");
assert("new Error('boom') is a usable object", (typeof er === "object") ? "yes" : "no", "yes");
var o = new Object();
o.k = 7;
assert("new Object() holds a property", o.k, "7");
var arr = new Array(1, 2, 3);
assert("new Array(1,2,3).length", arr.length, "3");
var wsp = new Script.Util.WSProxy();
assert("new Script.Util.WSProxy() reads typeof clr", ("" + (typeof wsp)).indexOf("clr") > -1 ? "yes" : "no", "yes");
</script>


DataExtension.Init Requires the External Key

Severity: Medium — Name binding looks like a success but Fields/Rows reads fail

DataExtension.Init(key) resolves by External Key (CustomerKey), matching the official docs. Passing the display Name when it differs from CustomerKey still returns an instance stub (Init never throws), but Fields.Retrieve returns an empty array, Fields.Add returns "Error", and Rows.Retrieve does not see rows — even though a Rows.Add on that stub can still write into the real DE. Prefer the External Key for every Core DataExtension call.

Platform.Load("core", "1.1.5");

// ❌ display Name when it differs from CustomerKey — Fields/Rows reads fail
var broken = DataExtension.Init("My Display Name");
broken.Fields.Retrieve(); // length 0

// ✅ External Key
var de = DataExtension.Init("MyDE_ExternalKey");
de.Fields.Retrieve(); // real columns

Note: Platform.Function.LookupRows looks up by Data Extension name, not External Key — the opposite of Core DataExtension.Init.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: DataExtension.Init Requires the External Key
 *   (engine-limitations/known-bugs)
 *
 * DataExtension.Init(key) resolves by EXTERNAL KEY (CustomerKey), matching the
 * official docs. Init never throws for a wrong identifier — it always returns
 * an instance stub — but reads only bind when the External Key was supplied.
 *
 * CloudPage GET context, QA BU. A single self-owned fixture DE is created with
 * a CustomerKey that DIFFERS from its display Name, so the two identifiers can
 * be told apart, then removed at the end.
 *
 * Proves:
 *   1. Init(External Key) binds: Fields.Retrieve returns the real columns,
 *      Rows.Add writes (returns 1), Rows.Retrieve(filter) sees the written row.
 *   2. BUG Init(display Name) — when Name differs from CustomerKey — still
 *      returns an object stub (Init never throws) but Fields.Retrieve is an
 *      EMPTY array, Fields.Add returns "Error", and Rows.Retrieve misses the
 *      row (expected: the real columns / the row, as with the External Key).
 *   3. BUG yet Rows.Add on that display-Name stub STILL writes into the real
 *      DE — confirming the stub is bound to the DE for writes but not reads.
 *   4. Init(wrong/missing key) is lazy: it does not throw; Fields.Retrieve is
 *      empty; Fields.Add returns "Error"; and it creates NO data extension.
 *   5. Note (page): Platform.Function.LookupRows resolves by Data Extension
 *      NAME, not External Key — the opposite of Core DataExtension.Init. So
 *      LookupRows(display Name, ...) reads the row while LookupRows(External
 *      Key, ...) does not resolve here.
 *   6. WORKAROUND (page): prefer the External Key for every Core DataExtension
 *      call — the External-Key instance is the one whose reads bind, so it is
 *      re-asserted green as the recommended path.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function outcomeOf(fn) {
    try { return "" + fn(); } catch (ex) { return "THREW: " + ("" + ex.message); }
}
function countDE(key) {
    return DataExtension.Retrieve({ Property: "CustomerKey", SimpleOperator: "equals", Value: key }).length;
}

var KEY = "ssjsguide-ts-kb-init";
var NAME = "ssjs-guide-ts-kb-init-display";

/* Orphan-cleanup preamble so a prior aborted run does not contaminate. */
if (countDE(KEY) > 0) { DataExtension.Init(KEY).Remove(); }
assert("precondition: no probe data extension exists", "" + countDE(KEY), "0");
DataExtension.Add({
    CustomerKey: KEY,
    Name: NAME,
    Fields: [
        { Name: "SubKey", FieldType: "Text", IsPrimaryKey: true, MaxLength: 50, IsRequired: true },
        { Name: "Active", FieldType: "Text", MaxLength: 10 }
    ],
    SendableInfo: { Field: { Name: "SubKey", FieldType: "Text" }, RelatesOn: "Subscriber Key" }
});
assert("fixture: the probe data extension was created", "" + countDE(KEY), "1");

/* 1. Init by External Key binds Fields and Rows. */
var de = DataExtension.Init(KEY);
assert("Init(External Key) Fields.Retrieve length is 2", "" + de.Fields.Retrieve().length, "2");
assert("Init(External Key) Rows.Add returns 1", outcomeOf(function () { return de.Rows.Add([{ SubKey: "k1", Active: "1" }]); }), "1");
assert("Init(External Key) Rows.Retrieve(filter) sees the row", outcomeOf(function () {
    return de.Rows.Retrieve({ Property: "SubKey", SimpleOperator: "equals", Value: "k1" }).length;
}), "1");

/* 2. BUG — display Name (differs from CustomerKey) returns a stub but reads fail. */
var byName = DataExtension.Init(NAME);
assert("BUG Init(display Name) still returns an object stub (Init never throws)", typeof byName, "object");
assert("BUG Init(display Name) Fields.Retrieve is empty (expected the real columns)", "" + byName.Fields.Retrieve().length, "0");
assert("BUG Init(display Name) Fields.Add returns Error (expected OK)", outcomeOf(function () {
    return byName.Fields.Add({ Name: "Extra", FieldType: "Text", MaxLength: 10 });
}), "Error");
assert("BUG Init(display Name) Rows.Retrieve(filter) misses the row (expected the row)", outcomeOf(function () {
    return byName.Rows.Retrieve({ Property: "SubKey", SimpleOperator: "equals", Value: "k1" }).length;
}), "0");

/* 3. BUG — yet Rows.Add on the display-Name stub still writes into the real DE. */
assert("BUG Init(display Name) Rows.Add can still write (returns 1)", outcomeOf(function () {
    return byName.Rows.Add([{ SubKey: "k2", Active: "1" }]);
}), "1");
assert("BUG the display-Name write landed on the real DE (read via External Key)", outcomeOf(function () {
    return DataExtension.Init(KEY).Rows.Retrieve({ Property: "SubKey", SimpleOperator: "equals", Value: "k2" }).length;
}), "1");

/* 4. Wrong/missing key is lazy — never throws, reads empty, creates nothing. */
var GHOST = "ssjsguide-ts-kb-init-ghost";
assert("precondition: nothing exists under the unbound key", "" + countDE(GHOST), "0");
assert("Init(missing key) does not throw", outcomeOf(function () { DataExtension.Init(GHOST); return "returned"; }), "returned");
var ghost = DataExtension.Init(GHOST);
assert("Init(missing key) Fields.Retrieve length is 0", "" + ghost.Fields.Retrieve().length, "0");
assert("Init(missing key) Fields.Add returns Error", outcomeOf(function () {
    return ghost.Fields.Add({ Name: "X", FieldType: "Text", MaxLength: 5 });
}), "Error");
assert("Init(missing key) created NO data extension", "" + countDE(GHOST), "0");

/* 5. Note — Platform.Function.LookupRows resolves by NAME, the opposite of Init. */
assert("LookupRows(display Name, ...) reads the row (LookupRows resolves by Name)", outcomeOf(function () {
    return Platform.Function.LookupRows(NAME, "SubKey", "k1").length;
}), "1");
assert("LookupRows(External Key, ...) does not read the row (opposite of Init)", (function () {
    var r = outcomeOf(function () { return Platform.Function.LookupRows(KEY, "SubKey", "k1").length; });
    return (r === "1") ? "read-the-row" : "did-not-read-the-row";
})(), "did-not-read-the-row");

/* 6. WORKAROUND — prefer the External Key: its reads bind (re-asserted green). */
assert("WORKAROUND Init(External Key) Fields.Retrieve binds the real columns", "" + DataExtension.Init(KEY).Fields.Retrieve().length, "2");
assert("WORKAROUND Init(External Key) Rows.Retrieve sees the k1 row", outcomeOf(function () {
    return DataExtension.Init(KEY).Rows.Retrieve({ Property: "SubKey", SimpleOperator: "equals", Value: "k1" }).length;
}), "1");

/* Cleanup — remove the self-owned fixture by its stable External Key. */
assert("cleanup: the probe data extension is removed", outcomeOf(function () { return DataExtension.Init(KEY).Remove(); }), "OK");
assert("cleanup: no probe data extension is left behind", "" + countDE(KEY), "0");
</script>


An ENT.-Prefixed Key Silences Fields.Retrieve and Rows.Retrieve

Severity: High — reads silently return nothing while writes on the very same instance succeed

The ENT. prefix is what makes a parent-owned, shared Data Extension addressable from a child Business Unit: with it, DataExtension.Init Rows.Add writes land in the real DE and Platform.Function.InsertDE / LookupRows / Lookup / DeleteDE all resolve. Without it, a child BU cannot reach the DE at all — LookupRows reports A Data Extension of this name does not exist.

The bug is that Core’s two read methods do not understand that prefix. On an instance built from an ENT.-prefixed key, Fields.Retrieve() and Rows.Retrieve() both return an empty array — not an error, not null — even while Rows.Add on that same instance is writing successfully and LookupRows reads the written row straight back.

This is not a cross-BU restriction and not an artefact of the DE being empty. Run on the DE’s own owning Business Unit, the unprefixed key returns the real 3 fields and 2 rows while the ENT.-prefixed key — same DE, same request — still returns [] for both. And a DE with zero rows returns its field definitions correctly, so Fields.Retrieve() never depended on row count. The prefix alone is the trigger. From a child BU the consequence is total: the prefixed spelling reads empty and the unprefixed one does not resolve, so there is no spelling of the key that makes Core Retrieve work there.

Platform.Load("core", "1.1.5");

// On the OWNING Business Unit — the same DE, two spellings, one request
DataExtension.Init("MyDE_ExternalKey").Fields.Retrieve();     // ✅ 3 fields
DataExtension.Init("MyDE_ExternalKey").Rows.Retrieve();       // ✅ 2 rows
DataExtension.Init("ENT.MyDE_ExternalKey").Fields.Retrieve(); // ❌ length 0
DataExtension.Init("ENT.MyDE_ExternalKey").Rows.Retrieve();   // ❌ length 0

// From a CHILD Business Unit the prefix is mandatory — and reads are still empty
var de = DataExtension.Init("ENT.MyDE_ExternalKey");
de.Rows.Add({ FieldName1: "abc", FieldName2: "d", FieldName3: "s" }); // 1 — the write really lands
de.Rows.Retrieve();                                  // ❌ length 0

// ✅ read it back through Platform.Function instead
Platform.Function.LookupRows("ENT.MyDE_ExternalKey", "FieldName1", "abc"); // the row

Use Platform.Function.LookupRows / Lookup for every read against an ENT.-prefixed key, and never treat an empty Rows.Retrieve() or Fields.Retrieve() there as proof that the DE is empty or column-less.

Two further quirks on the ENT. path, both observed with a same-schema local Data Extension as the control:

  • A write that omits a column is rejected. Rows.Add and InsertDE succeeded when every field of the shared DE was supplied and failed when one was left out, while the local control accepted the same partial shapes. Whether the shared DE’s columns are flagged required was not established, so treat “always supply every column” as the safe rule rather than a proven engine difference.
  • Platform.Function.DeleteDE returns null whether or not it deleted anything. Naming only the primary key left the row in place; naming every column with its value removed it. Always verify a delete with a follow-up LookupRows.
Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: An ENT.-Prefixed Key Silences Fields.Retrieve and Rows.Retrieve
 *   (engine-limitations/known-bugs)
 *
 * The ENT. prefix is what makes a parent-owned, shared Data Extension
 * addressable from a CHILD Business Unit. This runs on the QA child BU
 * against the pre-existing parent-owned shared DE 'ssjs-shared-de' (fields
 * asd (Text, PK) / dsaf (Text) / asdsad (Text); it holds fixed fixture rows
 * asd='asd' and asd='gfgdf'). The DE is a REUSED shared fixture — this script
 * never creates or removes it; it only adds and then removes ONE probe row it
 * owns (a per-run unique key prefixed ssjsguide-ts-).
 *
 * CloudPage GET context, QA child BU.
 *
 * Proves:
 *   1. On the child BU the ENT.-prefixed key is REQUIRED to reach the shared
 *      DE: Platform.Function.LookupRows('ENT.ssjs-shared-de', ...) resolves and
 *      reads the fixed fixture row, while the un-prefixed 'ssjs-shared-de' is
 *      rejected by name ("A Data Extension of this name does not exist.").
 *   2. BUG — DataExtension.Init('ENT....').Fields.Retrieve() returns an EMPTY
 *      array (length 0), silently: it does not throw and does not return null.
 *      (expected: the real 3 field definitions.)
 *   3. BUG — DataExtension.Init('ENT....').Rows.Retrieve() returns an EMPTY
 *      array (length 0), silently. (expected: the fixture rows.)
 *   4. Init('ENT....') still returns an object stub (typeof "object"): the empty
 *      reads are not an error path, so an empty result must NOT be read as proof
 *      that the DE is empty or column-less.
 *   5. BUG — writes on that very same instance succeed while reads stay empty:
 *      Rows.Add({all 3 fields}) returns 1 and the row is read straight back by
 *      Platform.Function.LookupRows('ENT....'), yet Rows.Retrieve() is STILL
 *      empty after the successful write. (expected: reads would see the write.)
 *   6. Further ENT.-path quirk (page): a write that OMITS a column is rejected —
 *      Rows.Add supplying every field returns 1; omitting one field throws.
 *   7. Further ENT.-path quirk (page): Platform.Function.DeleteDE returns null
 *      whether or not it deleted anything, so a delete must be verified with a
 *      follow-up LookupRows.
 *   8. WORKAROUND (page): use Platform.Function.LookupRows / Lookup for every
 *      read against an ENT.-prefixed key — re-asserted green as the recommended
 *      read path that actually returns the fixture row and the written row.
 *
 * NON-ASSERTION: the page also documents the OWNING-BU same-request control
 * (un-prefixed key returns 3 fields / 2 rows while ENT. returns [] for both) and
 * the zero-row Fields.Retrieve control. Those require the parent/owning BU and a
 * BU-local control DE, so they are proven in the verification DB
 * (DataExtension.Init, r1-r5 2026-08-05) rather than from this child-BU QA
 * script. This script proves the child-BU consequence, which is the scenario a
 * reader hits in practice.
 *
 * The write-lands proof (step 5) uses a per-run UNIQUE key (never DeleteDE'd
 * earlier in the request) so the request-scoped query cache does not mask the
 * just-written row; the cleanup DeleteDE returns null on success (proven in
 * step 7 against the -full row, which the same path removes for an un-cached
 * key). A same-request read-back of the just-deleted unique key is deliberately
 * NOT asserted because the earlier positive read caches it (the separate
 * lookup-request-scoped-query-cache quirk).
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function outcomeOf(fn) {
    try { return "" + fn(); } catch (ex) { return "THREW: " + ("" + ex.message); }
}
function threwOf(fn) {
    try { fn(); return "did-not-throw"; } catch (ex) { return "threw"; }
}

var SHARED = "ssjs-shared-de";
var ENT = "ENT." + SHARED;
var PROBE = "ssjsguide-ts-entprefix";             // fixed probe PK base this run owns
var WKEY = PROBE + "-w" + (new Date()).getTime();  // per-run UNIQUE write-proof key

/* Orphan-cleanup preamble: remove any fixed-key probe rows left by a prior
 * aborted run. WKEY is unique per run so it is never pre-cached and is NOT
 * deleted here — DeleteDE on a key negatively caches it for the rest of the
 * request (the separate lookup-request-scoped-query-cache quirk), which would
 * mask the write-lands read below. */
Platform.Function.DeleteDE(ENT, ["asd", "dsaf", "asdsad"], [PROBE + "-full", "d2", "s2"]);
assert("precondition: no -full probe row exists (empty LookupRows -> undefined length)", outcomeOf(function () {
    return Platform.Function.LookupRows(ENT, "asd", PROBE + "-full").length;
}), "undefined");

/* 1. Child BU: the ENT. prefix is required to reach the shared DE. */
assert("child: ENT. LookupRows reads the fixed fixture row asd=asd (len 1)", outcomeOf(function () {
    return Platform.Function.LookupRows(ENT, "asd", "asd").length;
}), "1");
assert("child: un-prefixed name is rejected — a DE of this name does not exist", (function () {
    var r = outcomeOf(function () { return Platform.Function.LookupRows(SHARED, "asd", "asd").length; });
    return (r.indexOf("does not exist") > -1) ? "rejected-by-name" : r;
})(), "rejected-by-name");

/* 2. BUG — Fields.Retrieve on the ENT. instance is silently empty. */
assert("BUG ENT. Init(...).Fields.Retrieve() is empty (expected the 3 field defs)", outcomeOf(function () {
    return DataExtension.Init(ENT).Fields.Retrieve().length;
}), "0");
assert("BUG ENT. Fields.Retrieve does not throw (silent empty, not an error)", threwOf(function () {
    DataExtension.Init(ENT).Fields.Retrieve();
}), "did-not-throw");

/* 3. BUG — Rows.Retrieve on the ENT. instance is silently empty. */
assert("BUG ENT. Init(...).Rows.Retrieve() is empty (expected the fixture rows)", outcomeOf(function () {
    return DataExtension.Init(ENT).Rows.Retrieve().length;
}), "0");
assert("BUG ENT. Rows.Retrieve does not throw (silent empty, not an error)", threwOf(function () {
    DataExtension.Init(ENT).Rows.Retrieve();
}), "did-not-throw");

/* 4. Init(ENT.) still returns an object stub — empty reads are not an error. */
assert("ENT. Init(...) returns an object stub (typeof object)", typeof DataExtension.Init(ENT), "object");

/* 5. BUG — writes on the SAME ENT. instance land while reads stay empty.
 *    The write-lands proof uses a FRESH key (WKEY) that no DeleteDE has touched
 *    in this request, so the request-scoped query cache does not mask the
 *    just-written row (a key that was DeleteDE'd earlier in the same request is
 *    negatively cached — that is the separate lookup-request-scoped-query-cache
 *    quirk, not the ENT.-prefix bug). */
var entDE = DataExtension.Init(ENT);
assert("ENT. Rows.Add({all 3 fields}) returns 1 (the write lands)", outcomeOf(function () {
    return entDE.Rows.Add([{ asd: WKEY, dsaf: "d1", asdsad: "s1" }]);
}), "1");
assert("ENT. LookupRows reads the just-written row straight back (len 1)", outcomeOf(function () {
    return Platform.Function.LookupRows(ENT, "asd", WKEY).length;
}), "1");
assert("BUG ENT. Rows.Retrieve() STILL empty after the successful write", outcomeOf(function () {
    return DataExtension.Init(ENT).Rows.Retrieve().length;
}), "0");

/* 6. ENT.-path quirk — a write that omits a column is rejected. */
assert("ENT. Rows.Add supplying every field returns 1", outcomeOf(function () {
    return DataExtension.Init(ENT).Rows.Add([{ asd: PROBE + "-full", dsaf: "d2", asdsad: "s2" }]);
}), "1");
assert("ENT. Rows.Add omitting a column throws (all columns must be supplied)", threwOf(function () {
    DataExtension.Init(ENT).Rows.Add([{ asd: PROBE + "-partial", dsaf: "d3" }]);
}), "threw");
assert("ENT. the partial write did NOT land (row absent -> LookupRows length undefined)", outcomeOf(function () {
    return Platform.Function.LookupRows(ENT, "asd", PROBE + "-partial").length;
}), "undefined");

/* 7. ENT.-path quirk — DeleteDE returns null regardless of what it removed. */
assert("ENT. DeleteDE returns null even on a real delete (verify with a read)", outcomeOf(function () {
    return Platform.Function.DeleteDE(ENT, ["asd", "dsaf", "asdsad"], [PROBE + "-full", "d2", "s2"]);
}), "null");
assert("ENT. the -full probe row is gone after the delete (LookupRows length undefined)", outcomeOf(function () {
    return Platform.Function.LookupRows(ENT, "asd", PROBE + "-full").length;
}), "undefined");

/* 8. WORKAROUND — read ENT.-prefixed keys through Platform.Function, not Core. */
assert("WORKAROUND LookupRows(ENT., pk, fixtureVal) returns the fixture row", outcomeOf(function () {
    return Platform.Function.LookupRows(ENT, "asd", "gfgdf").length;
}), "1");
assert("WORKAROUND Lookup(ENT., col, pk, val) reaches the fixture row (non-empty value)", (function () {
    var r = outcomeOf(function () {
        var v = Platform.Function.Lookup(ENT, "dsaf", "asd", "asd");
        return (v !== null && ("" + v).length > 0) ? "non-empty" : "empty-or-null";
    });
    return r;
})(), "non-empty");

/* Cleanup — remove the write-proof probe row this run owns. DeleteDE returns
 * null on success (proven above). A same-request LookupRows(WKEY) read-back is
 * deliberately NOT asserted: WKEY was positively cached by the earlier read, so
 * the request-scoped query cache masks the delete within this same request (the
 * separate lookup-request-scoped-query-cache quirk). The delete lands — the
 * -full row above proved the same DeleteDE path removes an un-cached row. */
assert("cleanup: DeleteDE of the write-proof probe row returns null (delete lands)", outcomeOf(function () {
    return Platform.Function.DeleteDE(ENT, ["asd", "dsaf", "asdsad"], [WKEY, "d1", "s1"]);
}), "null");
</script>


Platform.Load Must Come Before Any Core Usage

Severity: High — runtime error

Platform.Load("core", "1.1.5") must be called before any Core library object (DataExtension, Subscriber, Email, etc.) is referenced — not just before it’s used.

// ❌ Error — DataExtension referenced before Platform.Load
var de = DataExtension.Init("MyDE");
Platform.Load("core", "1.1.5");

// ✅ Load first, always
Platform.Load("core", "1.1.5");
var de = DataExtension.Init("MyDE");

Even declaring a variable that holds a Core object before Platform.Load can fail.

ESLint rules: sfmc/ssjs-require-platform-load, sfmc/ssjs-require-platform-load-order, sfmc/ssjs-prefer-platform-load-version

Show test script
<script runat="server">
/*
 * Chapter: Platform.Load Must Come Before Any Core Usage
 *
 * Core library objects (DataExtension, Subscriber, Email, ...) are exposed as
 * bare-name globals ONLY after Platform.Load("core", ...) has run in the
 * request. Referencing/using one before the load fails; using it after the
 * load works. Platform.Function.* is the exception — it is always available
 * with NO Platform.Load at all.
 *
 * HARNESS: the "before load" checks run at the very TOP of this block, BEFORE
 * any Platform.Load, so the pre-load state is genuinely observed (Core loading
 * is request-scoped, so once loaded it stays loaded for the rest of the block).
 * A `typeof <possibly-unbound-bare-name>` at top level can parse-abort the whole
 * page (HTTP 422), so the bare DataExtension typeof is resolved lazily inside a
 * thunk, and every pre-load call is passed as a thunk so it is evaluated inside
 * the assertThrows/catch try/catch.
 *
 * Proves:
 *   1. BEFORE Platform.Load the bare Core alias `DataExtension` is undefined
 *      (typeof "undefined") — resolved lazily in a thunk.
 *   2. BEFORE Platform.Load, calling a Core alias throws, and the message is the
 *      engine's "Object expected: <member>" naming the member (Init) — NOT the
 *      "Object doesn't support this property or method" string an older revision
 *      claimed.
 *   3. "Even declaring a variable that holds a Core object before Platform.Load
 *      can fail": `var de = DataExtension.Init("MyDE")` throws before the load,
 *      because the initializer invokes the missing alias.
 *   4. Platform.Function.* is CALLABLE with NO Platform.Load
 *      (Platform.Function.Stringify), so it is exempt from this rule.
 *   5. AFTER Platform.Load the bare alias `DataExtension` becomes defined
 *      (typeof "object" / its .Init is a function) and the SAME call now works —
 *      DataExtension.Init("MyDE") returns an object and exposes a Rows namespace.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function typeOf(fn) {
    try { return "" + (fn()); } catch (ex) { return "THREW: " + ex.message; }
}
function assertThrows(id, fn) {
    var threw = false;
    try { fn(); } catch (ex) { threw = true; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + "\n");
}
function messageOf(fn) {
    try { fn(); return "did-not-throw"; } catch (ex) { return "" + ex.message; }
}

/* 1. BEFORE any Platform.Load — the bare Core alias is undefined (lazy thunk). */
assert("bare DataExtension is undefined before Platform.Load", typeOf(function () { return typeof DataExtension; }), "undefined");

/* 2. BEFORE any Platform.Load — calling the Core alias throws, and the message
 *    is "Object expected: Init" (member named), not the older claimed string. */
assertThrows("DataExtension.Init throws before Platform.Load (Core alias not yet loaded)", function () { return DataExtension.Init("MyDE"); });
assert("DEV pre-load message is 'Object expected: <member>' (older page claimed 'Object doesn't support this property or method')", (function () {
    var m = messageOf(function () { return DataExtension.Init("MyDE"); });
    return (m.indexOf("Object expected") > -1 && m.indexOf("Init") > -1) ? "object-expected-init" : m;
})(), "object-expected-init");

/* 3. BEFORE any Platform.Load — even DECLARING a var whose initializer builds a
 *    Core object fails, because the initializer invokes the missing alias. */
assertThrows("var de = DataExtension.Init('MyDE') throws before Platform.Load (declaration initializer invokes the alias)", function () { var de = DataExtension.Init("MyDE"); return de; });

/* 4. Platform.Function.* is CALLABLE with NO Platform.Load — exempt from the rule. */
assert("Platform.Function.Stringify is callable with NO Platform.Load", Platform.Function.Stringify({ a: 1 }), "{\"a\":1}");

/* --- Load Core; from here the request has Core loaded. --- */
Platform.Load("core", "1.1.5");

/* 5. AFTER Platform.Load — the bare alias exists and the SAME call now works. */
assert("bare DataExtension is defined after Platform.Load (typeof object)", typeof DataExtension, "object");
assert("bare DataExtension.Init is a function after Platform.Load", typeof DataExtension.Init, "function");
assert("DataExtension.Init('MyDE') returns an object after Platform.Load", typeof DataExtension.Init("MyDE"), "object");
assert("the returned instance exposes a Rows namespace after Platform.Load", typeof DataExtension.Init("MyDE").Rows, "object");
</script>


Date.prototype.getMilliseconds Is Off by One

Severity: Low — incorrect sub-second value

Date.prototype.getMilliseconds() frequently reads back one less than the value the date was constructed with.

new Date(2020, 0, 1, 0, 0, 0, 123).getMilliseconds();
// Expected: 123
// Actual:   122

new Date(2020, 0, 1, 0, 0, 0, 555).getMilliseconds(); // 554
new Date(2020, 0, 1, 0, 0, 0, 666).getMilliseconds(); // 665
new Date(2020, 0, 1, 0, 0, 0, 777).getMilliseconds(); // 776
// Some values are exact: 0, 111, 222, 333, 444, 888, 999

Never compare or store sub-second precision from a Date. Round to whole seconds, or read milliseconds from getTime() arithmetic if you must. The UTC variant getUTCMilliseconds() was accurate at the epoch in testing, but treat local millisecond precision as unreliable.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: Date.prototype.getMilliseconds Is Off by One
 *
 * getMilliseconds() frequently reads back ONE LESS than the millisecond
 * component the Date was constructed with. All inputs are FIXED constructed
 * Dates (never `new Date()` now), so the result is deterministic.
 *
 * Proves:
 *   1. DEVIATIONS ("DEV") — these constructed ms values report one LESS
 *      than standard JS, where getMilliseconds() should return the exact ms:
 *        123 -> 122, 555 -> 554, 666 -> 665, 777 -> 776.
 *   2. Some ms values ARE exact: 0, 111, 222, 333, 444, 888, 999.
 *   3. The returned value is always a number within 0..999.
 *   4. WORKAROUND — rounding to the nearest 10 makes the off-by-one value
 *      (122) and the intended value (123) compare equal, so sub-second data
 *      must be rounded (or read from getTime() arithmetic) rather than trusted.
 *   5. The UTC variant getUTCMilliseconds() was accurate at the epoch:
 *      new Date(0).getUTCMilliseconds() === 0.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. NOTE: if
 * an off-by-one input instead returns the EXACT ms set, that is a new
 * discrepancy for the maintainer to review.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/* 1. DEV — documented off-by-one inputs report one LESS than standard JS. */
assert("DEV new Date(2020,0,1,0,0,0,123).getMilliseconds() (spec: 123)", new Date(2020, 0, 1, 0, 0, 0, 123).getMilliseconds(), 122);
assert("DEV new Date(2020,0,1,0,0,0,555).getMilliseconds() (spec: 555)", new Date(2020, 0, 1, 0, 0, 0, 555).getMilliseconds(), 554);
assert("DEV new Date(2020,0,1,0,0,0,666).getMilliseconds() (spec: 666)", new Date(2020, 0, 1, 0, 0, 0, 666).getMilliseconds(), 665);
assert("DEV new Date(2020,0,1,0,0,0,777).getMilliseconds() (spec: 777)", new Date(2020, 0, 1, 0, 0, 0, 777).getMilliseconds(), 776);

/* 2. Some values ARE exact. */
assert("new Date(2020,0,1,0,0,0,0).getMilliseconds() is exact", new Date(2020, 0, 1, 0, 0, 0, 0).getMilliseconds(), 0);
assert("new Date(2020,0,1,0,0,0,111).getMilliseconds() is exact", new Date(2020, 0, 1, 0, 0, 0, 111).getMilliseconds(), 111);
assert("new Date(2020,0,1,0,0,0,222).getMilliseconds() is exact", new Date(2020, 0, 1, 0, 0, 0, 222).getMilliseconds(), 222);
assert("new Date(2020,0,1,0,0,0,333).getMilliseconds() is exact", new Date(2020, 0, 1, 0, 0, 0, 333).getMilliseconds(), 333);
assert("new Date(2020,0,1,0,0,0,444).getMilliseconds() is exact", new Date(2020, 0, 1, 0, 0, 0, 444).getMilliseconds(), 444);
assert("new Date(2020,0,1,0,0,0,888).getMilliseconds() is exact", new Date(2020, 0, 1, 0, 0, 0, 888).getMilliseconds(), 888);
assert("new Date(2020,0,1,0,0,0,999).getMilliseconds() is exact", new Date(2020, 0, 1, 0, 0, 0, 999).getMilliseconds(), 999);

/* 3. The result is always a number within 0..999. */
var msVal = new Date(2020, 0, 1, 0, 0, 0, 123).getMilliseconds();
assert("typeof getMilliseconds() is number", typeof msVal, "number");
assert("getMilliseconds() is within 0..999", (msVal >= 0 && msVal <= 999) ? "true" : "false", "true");

/* 4. WORKAROUND — rounding to the nearest 10 makes 122 and 123 compare equal. */
function roundTo10(n) { return Math.round(n / 10) * 10; }
var readBack = new Date(2020, 0, 1, 0, 0, 0, 123).getMilliseconds();
assert("workaround: round(122/10)*10 === round(123/10)*10 (sub-second data must be rounded)", (roundTo10(readBack) === roundTo10(123)) ? "true" : "false", "true");

/* 5. The UTC variant was accurate at the epoch. */
assert("new Date(0).getUTCMilliseconds() is accurate at the epoch", new Date(0).getUTCMilliseconds(), 0);
</script>


Date.now() Returns a Date Object, Not a Number

Severity: Medium — type mismatch breaks numeric code

Date.now() returns a Date object in the SFMC engine, not the numeric timestamp the spec requires.

typeof Date.now();   // "object"  (spec: "number")

// ❌ math on the result is wrong unless you coerce
// ✅ use getTime() for a clean number
var ms = new Date().getTime();   // number of ms since epoch

Numeric coercion (Date.now() + 0) does recover the epoch milliseconds, but prefer new Date().getTime(). See Differs from Official Docs.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: Date.now() Returns a Date Object, Not a Number
 *
 * In the SFMC (Jint) engine Date.now() returns a DATE OBJECT, not the
 * numeric epoch-millisecond timestamp the ECMAScript spec requires.
 *
 * Proves:
 *   1. DEVIATION ("DEV") — typeof Date.now() is "object" (spec: "number").
 *   2. The result is NOT a plain number: stringifying it yields a date string,
 *      not a run of digits, and it is a real Date (getFullYear() works on it).
 *   3. Numeric coercion still recovers a number: typeof (Date.now() + 0) is
 *      "number", and Date.now() + 0 equals the result's getTime()
 *      (arithmetic behaves per the chapter).
 *   4. WORKAROUND — new Date().getTime() is a plain number (typeof "number")
 *      whose stringification is a pure-digit string.
 *
 * DETERMINISM: the current clock value is never asserted — only the TYPE /
 * shape of Date.now() and that the workaround produces a number. The two
 * clock reads used for the coercion cross-check are captured ONCE so a tick
 * between reads cannot cause a spurious FAIL.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. NOTE: if
 * typeof Date.now() is instead "number" (i.e. the bug does NOT reproduce),
 * that is a NEW discrepancy for the maintainer to review.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/* 1. DEV — typeof is "object", not the spec's "number". */
assert("DEV typeof Date.now() is object (spec: number)", typeof Date.now(), "object");

/* 2. The result is NOT a plain number — it is a real Date object. */
var nowVal = Date.now();
assert("DEV Date.now() is not typeof number (spec: number)", (typeof nowVal === "number") ? "true" : "false", "false");
assert("Date.now() stringifies to a NON-digit date string (not epoch ms digits)", (/^[0-9]+$/.test("" + nowVal)) ? "true" : "false", "false");
assert("Date.now() is a real Date: getFullYear() is a number", typeof nowVal.getFullYear(), "number");

/* 3. Numeric coercion recovers a number and matches the object's getTime(). */
var coerced = nowVal + 0;
assert("typeof (Date.now() + 0) is number (coercion recovers epoch ms)", typeof coerced, "number");
assert("Date.now() + 0 equals its own getTime() (arithmetic per chapter)", (coerced === nowVal.getTime()) ? "true" : "false", "true");

/* 4. WORKAROUND — new Date().getTime() is a plain number. */
var wa = new Date().getTime();
assert("workaround: typeof new Date().getTime() is number", typeof wa, "number");
assert("workaround: new Date().getTime() stringifies to a pure-digit string", (/^[0-9]+$/.test("" + wa)) ? "true" : "false", "true");
</script>


Date.parse() Returns 0 (Never NaN) for Invalid Strings

Severity: Medium — invalid dates silently become 1970-01-01

Date.parse() returns 0 (the Unix epoch) for any unparseable string instead of NaN, so isNaN() cannot detect a bad date.

Date.parse("garbage");     // 0   (spec: NaN)
Date.parse("");            // 0   (spec: NaN)
Date.parse("2021-13-45");  // 0   (spec: NaN)
isNaN(Date.parse("garbage")); // false — bad input looks like 1970-01-01

Validate date strings yourself before calling Date.parse(); do not rely on NaN for error detection. Note also that date-only strings parse as local midnight, not UTC. See Differs from Official Docs.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: Date.parse() Returns 0 (Never NaN) for Invalid Strings
 *
 * In the SFMC (Jint) engine Date.parse() returns 0 (the Unix epoch) for any
 * unparseable string instead of the NaN the ECMAScript spec requires, so
 * isNaN() cannot be used to detect a bad date string.
 *
 * Proves:
 *   1. DEVIATION ("DEV") — Date.parse("garbage") === 0 (spec: NaN).
 *   2. DEV — Date.parse("") === 0 (spec: NaN).
 *   3. DEV — Date.parse("2021-13-45") === 0 (spec: NaN).
 *   4. DEV — isNaN(Date.parse("garbage")) is false (spec: true), so bad input
 *      looks like 1970-01-01: new Date(Date.parse("garbage")) has UTC year 1970.
 *   5. A VALID date string still parses to the correct epoch-millisecond
 *      number: Date.parse("2021-01-01T00:00:00Z") === 1609459200000, and the
 *      result is typeof "number".
 *   6. WORKAROUND — because NaN detection is impossible, validate the string
 *      yourself first (here a simple regex) BEFORE calling Date.parse(); the
 *      guard rejects "garbage" and accepts the valid ISO string.
 *
 * DETERMINISM: every input string is a fixed literal and every expected value
 * is a fixed number, so the assertions are timezone- and clock-independent
 * (the valid string carries an explicit "Z" UTC offset).
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. NOTE: if
 * Date.parse(<invalid>) instead returns NaN (i.e. the "returns 0" bug does NOT
 * reproduce), that is a NEW discrepancy for the maintainer to review.
 */

function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/* 1-3. DEV — invalid strings all parse to 0, never NaN. */
assert("DEV Date.parse('garbage') is 0 (spec: NaN)", Date.parse("garbage"), 0);
assert("DEV Date.parse('') is 0 (spec: NaN)", Date.parse(""), 0);
assert("DEV Date.parse('2021-13-45') is 0 (spec: NaN)", Date.parse("2021-13-45"), 0);

/* 4. DEV — isNaN() cannot detect the bad date; it looks like 1970-01-01. */
assert("DEV isNaN(Date.parse('garbage')) is false (spec: true)", isNaN(Date.parse("garbage")) ? "true" : "false", "false");
assert("DEV bad input looks like 1970: new Date(Date.parse('garbage')) UTC year is 1970", new Date(Date.parse("garbage")).getUTCFullYear(), 1970);

/* 5. A VALID date string still parses to the correct epoch ms number. */
assert("valid string Date.parse('2021-01-01T00:00:00Z') is 1609459200000", Date.parse("2021-01-01T00:00:00Z"), 1609459200000);
assert("valid string Date.parse(...) result is typeof number", typeof Date.parse("2021-01-01T00:00:00Z"), "number");

/* 6. WORKAROUND — validate the string yourself before Date.parse(). */
function isValidDateString(s) {
    var shapeOk = /^\d{4}-\d{2}-\d{2}([Tt].*)?$/.test(s);
    if (!shapeOk) { return false; }
    return !isNaN(new Date(s).getUTCFullYear());
}
assert("workaround: guard rejects 'garbage' before Date.parse()", isValidDateString("garbage") ? "true" : "false", "false");
assert("workaround: guard accepts a valid ISO string", isValidDateString("2021-01-01T00:00:00Z") ? "true" : "false", "true");
</script>


Function.prototype.length Throws

Severity: Medium — reading a function’s arity crashes the page

Reading fn.length (the declared argument count) throws Object reference not set to an instance of an object. in the SFMC engine instead of returning a number. fn.hasOwnProperty("length") is false.

function sum(a, b) { return a + b; }
sum.length;
// Expected: 2
// Actual:   THROWS "Object reference not set to an instance of an object."

Track expected arity yourself (a plain variable or constant) rather than reading fn.length. Related missing/altered Function.prototype members: fn.name and fn.caller are undefined, fn.toString() returns [object Function] not the source, and fn.constructor === Function is false — see Function Methods and Differs from Official Docs.

Show test script
<script runat="server">
/*
 * Chapter: Function.prototype.length Throws (engine-limitations/known-bugs)
 *
 * In the SFMC (Jint) engine, reading fn.length (the declared argument count)
 * THROWS "Object reference not set to an instance of an object." instead of
 * returning the number the ECMAScript spec requires (the count of declared
 * formal parameters). fn.hasOwnProperty("length") is also false. The
 * workaround is to track the expected arity yourself in a plain variable or
 * constant rather than reading fn.length.
 *
 * Proves:
 *   1. DEV reading sum.length THROWS "Object reference not set to an instance
 *      of an object." (spec: 2, the declared parameter count of sum(a, b)).
 *   2. DEV the thrown message contains the exact .NET fragment
 *      "Object reference not set to an instance of an object." (CLR string,
 *      normalized with ("" + msg) and tested with indexOf per
 *      probe-sfmc-cloudpage).
 *   3. DEV reading a zero-parameter function's length also THROWS
 *      (spec: 0) — the throw is not specific to a particular arity.
 *   4. DEV sum.hasOwnProperty("length") is false (spec: true).
 *   5. WORKAROUND track the arity yourself in a plain constant: ARITY_SUM is
 *      2, matching the declared parameter count the spec would have returned.
 *   6. WORKAROUND the function itself remains fully callable — sum(1, 2) is 3
 *      — so only the .length read is affected, not invocation.
 *
 * DETERMINISM: sum(a, b) and noArgs() are fixed literals with fixed declared
 * arities (2 and 0), so every expected value is clock- and locale-independent.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. NOTE: if
 * reading fn.length does NOT throw but instead returns the numeric arity
 * (standard-JS behaviour, e.g. sum.length === 2), that is a NEW discrepancy
 * for the maintainer to review — the "throws" bug would no longer reproduce.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function assertThrows(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}

function sum(a, b) { return a + b; }
function noArgs() { return 1; }

/*
 * 1. DEV reading sum.length throws. Passed as a thunk so the .length read is
 * evaluated inside assertThrows' try/catch. Spec: sum(a, b).length === 2.
 */
assertThrows("DEV reading sum.length throws (spec: 2, declared param count)", function () { return sum.length; });

/*
 * 2. DEV confirm the exact .NET message fragment (CLR string — normalize with
 * ("" + msg) and test a stable fragment with indexOf).
 */
var lenMsg = "";
try { var probe = sum.length; } catch (ex) { lenMsg = "" + ex.message; }
assert("DEV sum.length message contains 'Object reference not set to an instance of an object.'", lenMsg.indexOf("Object reference not set to an instance of an object.") >= 0 ? "found" : "missing", "found");

/* 3. DEV a zero-parameter function's length also throws (spec: 0). */
assertThrows("DEV reading noArgs.length throws (spec: 0, declared param count)", function () { return noArgs.length; });

/* 4. DEV sum.hasOwnProperty("length") is false (spec: true). */
assert("DEV sum.hasOwnProperty('length') is false (spec: true)", sum.hasOwnProperty("length") ? "true" : "false", "false");

/*
 * 5. WORKAROUND track the arity yourself in a plain constant rather than
 * reading fn.length. ARITY_SUM matches the declared parameter count the spec
 * would have returned.
 */
var ARITY_SUM = 2;
assert("WORKAROUND tracked arity constant is 2 (matches declared param count)", "" + ARITY_SUM, "2");

/*
 * 6. WORKAROUND the function itself is still fully callable — only the
 * .length read is broken, not invocation.
 */
assert("WORKAROUND sum(1, 2) is still callable and returns 3", "" + sum(1, 2), "3");
</script>


Math.max / Math.min Throw with 3+ Arguments

Severity: Medium — the variadic form crashes the page

Math.max and Math.min are variadic in standard JavaScript, but in the SFMC engine they only accept exactly two arguments. Passing three or more throws. Passing fewer than two does not throw — the engine fills every missing slot with 0, which silently corrupts the result. The two-argument form is runtime-verified correct.

Math.max(1, 5);       // 5   — safe
Math.max(1, 5, 3);    // THROWS "Index was outside the bounds of the array."
Math.max();           // 0   — expected -Infinity

Math.min(1, 5);       // 1   — safe
Math.min(1, 5, 3);    // THROWS "Index was outside the bounds of the array."
Math.min();           // 0   — expected +Infinity

A missing argument becomes 0

The no-argument result is not a special case — it follows from a single rule: Math.min(x) behaves as Math.min(x, 0) and Math.max(x) as Math.max(x, 0). The one-argument form therefore returns the wrong value whenever 0 is the more extreme operand, and it does so without throwing:

Math.min(5);          // 0    — expected 5
Math.max(5);          // 5    — correct only because 5 > 0
Math.min(-7);         // -7   — correct only because -7 < 0
Math.max(-7);         // 0    — expected -7
Math.min(3.5);        // 0    — expected 3.5
Math.max(-3.5);       // 0    — expected -3.5

The negative-argument cases are what prove the rule: the argument is not discarded (Math.min(-7) is -7, not 0), and a 0 really is taking part (Math.max(-7) is 0, not -7). Never call Math.max or Math.min with a single argument, even when it looks safe — pass both operands explicitly, or use the polyfill.

Compare two values at a time — Math.max(Math.max(a, b), c) — fold with a loop, or use the Math.max / Math.min polyfill. See Math Object for the full list of Math members and which ES6 methods are missing.

Show test script
<script runat="server">
/*
 * Chapter: Math.max / Math.min Throw with 3+ Arguments (engine-limitations/known-bugs)
 *
 * In standard JavaScript Math.max / Math.min are variadic and return the
 * largest / smallest of ALL their arguments. In the SFMC (Jint) engine they
 * accept EXACTLY TWO arguments: the two-argument form is runtime-verified
 * correct, three OR MORE arguments THROW "Index was outside the bounds of
 * the array.", and FEWER than two does NOT throw — every missing slot is
 * filled with 0, so Math.min(x) behaves as Math.min(x, 0) and Math.max(x)
 * as Math.max(x, 0), silently corrupting the result.
 *
 * Proves:
 *   1. Two-argument form is correct: Math.max(1, 5) === 5, Math.min(1, 5) === 1.
 *   2. DEV Math.max(1, 5, 3) THROWS (spec: 5 — largest of all three args).
 *   3. DEV Math.min(1, 5, 3) THROWS (spec: 1 — smallest of all three args).
 *   4. DEV Math.max(1, 5, 3, 7) THROWS with 4 args (spec: 7).
 *   5. DEV Math.min(1, 5, 3, 0) THROWS with 4 args (spec: 0).
 *   6. DEV each throw carries the exact .NET fragment
 *      "Index was outside the bounds of the array." (CLR string, normalized
 *      with ("" + msg) and tested with indexOf per probe-sfmc-cloudpage).
 *   7. DEV no-argument form does NOT throw: Math.max() === 0 (spec: -Infinity),
 *      Math.min() === 0 (spec: +Infinity).
 *   8. DEV one-argument form does NOT throw but is corrupted by the 0 fill —
 *      Math.min(5) === 0 (spec: 5), Math.max(-7) === 0 (spec: -7),
 *      Math.min(3.5) === 0 (spec: 3.5), Math.max(-3.5) === 0 (spec: -3.5).
 *      MECHANISM controls prove a real 0 participates rather than the arg
 *      being discarded: Math.min(-7) === -7 (not 0), Math.max(5) === 5,
 *      Math.max(-7) === 0 (not -7).
 *   9. WORKAROUND compare two values at a time: nested 2-arg
 *      Math.max(Math.max(1, 5), 3) === 5 and Math.min(Math.min(1, 5), 3) === 1.
 *  10. WORKAROUND fold with a loop over an array — max of [3,1,4,1,5] === 5,
 *      min of [3,1,4,1,5] === 1 — without ever calling Math.max/min variadically.
 *
 * DETERMINISM: all inputs are fixed numeric literals, so every expected value
 * is clock- and locale-independent.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. NOTE: if
 * the 3+ argument forms do NOT throw but instead return the extreme of all
 * arguments (standard-JS variadic behaviour), that is a NEW discrepancy for
 * the maintainer to review — the "throws" bug would no longer reproduce.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
function assertThrows(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}

/* 1. Two-argument form is runtime-verified correct. */
assert("Math.max(1, 5) === 5 (two-arg form is safe)", Math.max(1, 5), 5);
assert("Math.min(1, 5) === 1 (two-arg form is safe)", Math.min(1, 5), 1);

/*
 * 2-5. DEV three OR MORE arguments throw. Passed as thunks so the call is
 * evaluated inside assertThrows' try/catch.
 */
assertThrows("DEV Math.max(1, 5, 3) throws (spec: 5)", function () { return Math.max(1, 5, 3); });
assertThrows("DEV Math.min(1, 5, 3) throws (spec: 1)", function () { return Math.min(1, 5, 3); });
assertThrows("DEV Math.max(1, 5, 3, 7) throws with 4 args (spec: 7)", function () { return Math.max(1, 5, 3, 7); });
assertThrows("DEV Math.min(1, 5, 3, 0) throws with 4 args (spec: 0)", function () { return Math.min(1, 5, 3, 0); });

/*
 * 6. DEV confirm the exact .NET message fragment (CLR string — normalize with
 * ("" + msg) and test a stable fragment with indexOf).
 */
var maxMsg = "";
try { var mx = Math.max(1, 5, 3); } catch (ex1) { maxMsg = "" + ex1.message; }
assert("DEV Math.max 3-arg message contains 'Index was outside the bounds of the array.'", maxMsg.indexOf("Index was outside the bounds of the array.") >= 0 ? "found" : "missing", "found");
var minMsg = "";
try { var mn = Math.min(1, 5, 3); } catch (ex2) { minMsg = "" + ex2.message; }
assert("DEV Math.min 3-arg message contains 'Index was outside the bounds of the array.'", minMsg.indexOf("Index was outside the bounds of the array.") >= 0 ? "found" : "missing", "found");

/* 7. DEV no-argument form does not throw; every missing slot becomes 0. */
assert("DEV Math.max() === 0 (spec: -Infinity)", Math.max(), 0);
assert("DEV Math.min() === 0 (spec: +Infinity)", Math.min(), 0);

/*
 * 8. DEV one-argument form does not throw but is corrupted: Math.min(x) is
 * Math.min(x, 0) and Math.max(x) is Math.max(x, 0). The negative-argument
 * cases are the discriminating controls — they prove a real 0 participates
 * rather than the argument being discarded.
 */
assert("DEV Math.min(5) === 0 (spec: 5 — 0 fill corrupts it)", Math.min(5), 0);
assert("DEV Math.max(-7) === 0 (spec: -7 — 0 fill corrupts it)", Math.max(-7), 0);
assert("DEV Math.min(3.5) === 0 (spec: 3.5)", Math.min(3.5), 0);
assert("DEV Math.max(-3.5) === 0 (spec: -3.5)", Math.max(-3.5), 0);
assert("MECHANISM Math.min(-7) === -7 (arg NOT discarded — 0 is the larger slot)", Math.min(-7), -7);
assert("MECHANISM Math.max(5) === 5 (correct only because 5 > 0)", Math.max(5), 5);
assert("MECHANISM Math.min(1, 5) still === 1 (control, two-arg safe)", Math.min(1, 5), 1);

/*
 * 9. WORKAROUND compare two values at a time with nested 2-arg calls. This
 * never passes 3+ arguments, so it does not trigger the bug.
 */
assert("WORKAROUND Math.max(Math.max(1, 5), 3) === 5", Math.max(Math.max(1, 5), 3), 5);
assert("WORKAROUND Math.min(Math.min(1, 5), 3) === 1", Math.min(Math.min(1, 5), 3), 1);

/*
 * 10. WORKAROUND fold with a loop over an array — never calls Math.max/min
 * variadically, so it is safe for any number of values.
 */
function foldMax(nums) { var m = nums[0]; for (var i = 1; i < nums.length; i++) { if (nums[i] > m) { m = nums[i]; } } return m; }
function foldMin(nums) { var m = nums[0]; for (var i = 1; i < nums.length; i++) { if (nums[i] < m) { m = nums[i]; } } return m; }
assert("WORKAROUND loop-fold max of [3,1,4,1,5] === 5", foldMax([3, 1, 4, 1, 5]), 5);
assert("WORKAROUND loop-fold min of [3,1,4,1,5] === 1", foldMin([3, 1, 4, 1, 5]), 1);
</script>


Infinity Has an Inverted Sign and Broken Comparisons

Severity: Medium — silent wrong results in numeric edge cases

The global Infinity identifier exists (typeof Infinity is "number"), but the SFMC Jint engine mishandles it. When stringified it shows the wrong sign: String(Infinity) and (1/0) render as "-infinity", while -Infinity and (-1/0) render as "infinity". Worse, comparisons are also broken — (Infinity > 0) and (1/0 > 0) both return false instead of true (runtime-verified). Number.POSITIVE_INFINITY / Number.NEGATIVE_INFINITY do exist (both are numbers), but they carry the same inverted-sign stringification as Infinity itself, so they cannot be used to work around the sign bug (see Number Methods).

typeof Infinity;    // "number"
String(Infinity);   // "-infinity"  — inverted sign
(1 / 0);            // "-infinity"  — inverted sign
(Infinity > 0);     // false        — expected true
isFinite(Infinity); // false        — this one is correct

Avoid relying on Infinity semantics. Use isFinite(x) to detect non-finite values — it answers correctly for an actual number, though not for a non-numeric string (see below) — and never branch on the sign or ordering of an Infinity value. See Number Methods.

Show test script
<script runat="server">
/*
 * Chapter: Infinity Has an Inverted Sign and Broken Comparisons (engine-limitations/known-bugs)
 *
 * The global Infinity identifier exists (typeof Infinity is "number"), but the
 * SFMC (Jint) engine mishandles it. When stringified it shows the WRONG SIGN,
 * and comparisons against it are broken. Number.POSITIVE_INFINITY /
 * Number.NEGATIVE_INFINITY do not exist to work around this (both undefined).
 *
 * Proves:
 *   1. typeof Infinity is "number" (correct).
 *   2. DEV String(Infinity) is "-infinity" (spec: "Infinity" — inverted sign).
 *   3. DEV String(1/0) is "-infinity" (spec: "Infinity" — inverted sign).
 *   4. DEV String(-Infinity) is "infinity" (spec: "-Infinity" — inverted sign).
 *   5. DEV String(-1/0) is "infinity" (spec: "-Infinity" — inverted sign).
 *   6. DEV (Infinity > 0) is false (spec: true — broken comparison).
 *   7. DEV (1/0 > 0) is false (spec: true — broken comparison).
 *   8. isFinite(Infinity) is false (correct — this one works).
 *   9. DEV Number.POSITIVE_INFINITY exists as a number but is inverted:
 *      typeof is "number" and String is "-infinity" (spec: the +Infinity
 *      number stringifying to "Infinity"). The page's "does not exist /
 *      undefined" claim does NOT reproduce — QUEUED ISSUE for the maintainer.
 *  10. DEV Number.NEGATIVE_INFINITY exists as a number but is inverted:
 *      typeof is "number" and String is "infinity" (spec: the -Infinity
 *      number stringifying to "-Infinity"). The page's "does not exist /
 *      undefined" claim does NOT reproduce — QUEUED ISSUE for the maintainer.
 *  11. WORKAROUND isFinite(x) answers correctly for actual numbers:
 *      isFinite(1/0) is false, isFinite(42) is true, isFinite(-3.5) is true.
 *
 * DETERMINISM: all inputs are fixed numeric literals, so every expected value
 * is clock- and locale-independent.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. NOTE: if
 * any documented broken behaviour (inverted sign, false comparison) does NOT
 * reproduce — e.g. String(Infinity) returns "Infinity" or (Infinity > 0) is
 * true — that is a NEW discrepancy for the maintainer to review.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/* 1. The identifier exists and is typed as a number. */
assert("typeof Infinity is 'number'", typeof Infinity, "number");

/* 2-5. Stringification shows the INVERTED sign. */
assert("DEV String(Infinity) is '-infinity' (spec: 'Infinity')", String(Infinity), "-infinity");
assert("DEV String(1/0) is '-infinity' (spec: 'Infinity')", String(1 / 0), "-infinity");
assert("DEV String(-Infinity) is 'infinity' (spec: '-Infinity')", String(-Infinity), "infinity");
assert("DEV String(-1/0) is 'infinity' (spec: '-Infinity')", String(-1 / 0), "infinity");

/* 6-7. Comparisons against Infinity are broken. */
assert("DEV (Infinity > 0) is false (spec: true)", (Infinity > 0) ? "true" : "false", "false");
assert("DEV (1/0 > 0) is false (spec: true)", (1 / 0 > 0) ? "true" : "false", "false");

/* 8. isFinite(Infinity) is correct. */
assert("isFinite(Infinity) is false (correct)", isFinite(Infinity) ? "true" : "false", "false");

/*
 * 9-10. QUEUED ISSUE: the page claims Number.POSITIVE_INFINITY /
 * NEGATIVE_INFINITY do NOT exist (undefined), but at runtime they exist as
 * numbers with the same inverted sign as Infinity itself. Encode the honest
 * runtime behaviour green; the doc "undefined" claim is flagged for review.
 */
assert("DEV typeof Number.POSITIVE_INFINITY is 'number' (page claims: undefined)", typeof Number.POSITIVE_INFINITY, "number");
assert("DEV String(Number.POSITIVE_INFINITY) is '-infinity' (spec: 'Infinity'; page claims: undefined)", String(Number.POSITIVE_INFINITY), "-infinity");
assert("DEV typeof Number.NEGATIVE_INFINITY is 'number' (page claims: undefined)", typeof Number.NEGATIVE_INFINITY, "number");
assert("DEV String(Number.NEGATIVE_INFINITY) is 'infinity' (spec: '-Infinity'; page claims: undefined)", String(Number.NEGATIVE_INFINITY), "infinity");

/* 11. WORKAROUND — isFinite(x) answers correctly for real numbers. */
assert("WORKAROUND isFinite(1/0) is false", isFinite(1 / 0) ? "true" : "false", "false");
assert("WORKAROUND isFinite(42) is true", isFinite(42) ? "true" : "false", "true");
assert("WORKAROUND isFinite(-3.5) is true", isFinite(-3.5) ? "true" : "false", "true");
</script>


isFinite Returns true for a Non-Numeric String

Severity: Medium — silently accepts unparseable input as a finite number

The global isFinite(value) is specified to apply ToNumber first and return false when the conversion yields NaN. In the SFMC Jint engine a non-numeric string comes back as true instead (runtime-verified) — and so does Number("abc"), which is a real NaN value. isFinite(NaN) written as a literal is correct, so the defect sits in the argument handling, not in the NaN comparison itself.

isFinite("abc");            // true   — expected false
isFinite(Number("abc"));    // true   — expected false

isFinite(NaN);              // false  — correct
isFinite(undefined);        // false  — correct
isFinite(0 / 0);            // false  — correct
isFinite(Infinity);         // false  — correct
isFinite(42);               // true   — correct
isFinite("42");             // true   — correct
isFinite("");               // true   — but Number("") is NaN here (see workaround caveat below)
isFinite(null);             // true   — correct (ToNumber(null) is 0)

Guard untrusted input by coercing explicitly and testing the result:

function isFiniteNumber(value) {
    var n = Number(value);
    return !isNaN(n) && isFinite(n);
}

This rejects non-numeric strings correctly (isFiniteNumber("abc") is false). One engine caveat: Number("") is NaN here (not 0 as the spec requires), so isFiniteNumber("") returns false even though the empty string coerces to a finite 0 in standard JavaScript. Treat "" explicitly before coercing if an empty string must count as 0.

See Number Methods.

Show test script
<script runat="server">
/*
 * Chapter: isFinite Returns true for a Non-Numeric String (engine-limitations/known-bugs)
 *
 * The global isFinite(value) is specified to apply ToNumber first and return
 * false when the conversion yields NaN. In the SFMC (Jint) engine a
 * NON-NUMERIC STRING comes back as true instead, and so does Number("abc")
 * (a real NaN value). isFinite(NaN) written as a literal is correct, so the
 * defect sits in the argument handling, not in the NaN comparison itself.
 *
 * Proves:
 *   1. DEV isFinite("abc") is true (spec: false — non-numeric string).
 *   2. DEV isFinite(Number("abc")) is true (spec: false — Number("abc") is NaN).
 *   3. isFinite(NaN) is false (correct).
 *   4. isFinite(undefined) is false (correct).
 *   5. isFinite(0/0) is false (correct — 0/0 is NaN).
 *   6. isFinite(Infinity) is false (correct).
 *   7. isFinite(42) is true (correct).
 *   8. isFinite("42") is true (correct — ToNumber("42") is 42).
 *   9. isFinite("") is true (correct — ToNumber("") is 0).
 *  10. isFinite(null) is true (correct — ToNumber(null) is 0).
 *  11. WORKAROUND isFiniteNumber(value) coerces explicitly and tests the
 *      result, which is unaffected: false for "abc" (Number("abc") is NaN),
 *      true for 42 and "42".
 *  12. DEV QUEUED ISSUE: in this engine Number("") is NaN, not the spec's 0,
 *      so isFiniteNumber("") is false here (standard JS: true). The page's
 *      isFinite("") is true claim (ToNumber("") is 0) still holds directly,
 *      so isFinite("") and Number("") disagree about "" — flagged for the
 *      maintainer; the honest runtime behaviour is encoded green below.
 *
 * DETERMINISM: all inputs are fixed literals, so every expected value is
 * clock- and locale-independent.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/* Documented workaround: coerce explicitly, then test the coerced number. */
function isFiniteNumber(value) {
    var n = Number(value);
    return !isNaN(n) && isFinite(n);
}

/* 1-2. DEVIATIONS: a non-numeric string (and Number("abc") = NaN) is true. */
assert("DEV isFinite('abc') is true (spec: false)", isFinite("abc") ? "true" : "false", "true");
assert("DEV isFinite(Number('abc')) is true (spec: false)", isFinite(Number("abc")) ? "true" : "false", "true");

/* 3-6. Genuinely non-finite inputs answer false (correct). */
assert("isFinite(NaN) is false (correct)", isFinite(NaN) ? "true" : "false", "false");
var undef;
assert("isFinite(undefined) is false (correct)", isFinite(undef) ? "true" : "false", "false");
assert("isFinite(0/0) is false (correct)", isFinite(0 / 0) ? "true" : "false", "false");
assert("isFinite(Infinity) is false (correct)", isFinite(Infinity) ? "true" : "false", "false");

/* 7-10. Genuinely finite inputs answer true (correct). */
assert("isFinite(42) is true (correct)", isFinite(42) ? "true" : "false", "true");
assert("isFinite('42') is true (correct)", isFinite("42") ? "true" : "false", "true");
assert("isFinite('') is true (correct — ToNumber('') is 0)", isFinite("") ? "true" : "false", "true");
assert("isFinite(null) is true (correct — ToNumber(null) is 0)", isFinite(null) ? "true" : "false", "true");

/* 11. WORKAROUND — coerce explicitly, then test the result. */
assert("WORKAROUND isFiniteNumber('abc') is false", isFiniteNumber("abc") ? "true" : "false", "false");
assert("WORKAROUND isFiniteNumber(42) is true", isFiniteNumber(42) ? "true" : "false", "true");
assert("WORKAROUND isFiniteNumber('42') is true", isFiniteNumber("42") ? "true" : "false", "true");

/*
 * 12. QUEUED ISSUE: this engine's Number("") is NaN (spec: 0), so the
 * workaround treats "" as non-finite here — isFiniteNumber("") is false
 * (standard JS: true). Encoded green as the honest runtime behaviour; the
 * Number("") is 0 / isFinite("") is true mismatch is flagged for review.
 */
assert("DEV Number('') is NaN here (spec: 0)", isNaN(Number("")) ? "true" : "false", "true");
assert("DEV isFiniteNumber('') is false here (standard JS: true, since Number('') is 0)", isFiniteNumber("") ? "true" : "false", "false");
</script>


Object.prototype.isPrototypeOf Hangs the Engine

Severity: High — the page never renders (request times out)

Object.prototype.isPrototypeOf exists in the SFMC Jint engine (typeof obj.isPrototypeOf is "function"), but calling it hangs the engine indefinitely — the CloudPage times out and never returns any output (runtime-verified: a probe that called it produced a request timeout, and removing the call let the same script render). There is no argument form that is safe.

typeof ({}).isPrototypeOf;        // "function"  — it appears to exist
// Ctor.prototype.isPrototypeOf(obj);  // HANGS — never call it (request times out)

Never call isPrototypeOf. To test prototype/instance relationships, compare the constructor directly (obj.constructor === Ctor) or walk the prototype chain manually. See Object Methods.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: Object.prototype.isPrototypeOf Hangs the Engine (engine-limitations/known-bugs)
 *
 * Object.prototype.isPrototypeOf exists in the SFMC (Jint) engine
 * (typeof obj.isPrototypeOf is "function"), but CALLING it hangs the engine
 * indefinitely — the CloudPage times out and never returns output. There is
 * no argument form that is safe. Standard JS specifies a normal method that
 * returns a boolean.
 *
 * Proves (SAFE, terminating parts only):
 *   1. typeof ({}).isPrototypeOf is "function" — the member appears to exist.
 *   2. isPrototypeOf is inherited by a plain object (typeof "function").
 *   3. WORKAROUND — compare the constructor directly: obj.constructor === Ctor
 *      is true for the matching constructor and false for a non-matching one.
 *   4. WORKAROUND — walk the prototype chain manually: for a direct instance
 *      Object.getPrototypeOf(inst) === Ctor.prototype is true and
 *      === Other.prototype is false. (A multi-hop while-loop is avoided here:
 *      this engine's Object.getPrototypeOf is only reliable for the first hop
 *      — getPrototypeOf(Ctor.prototype) mis-reports and a further call throws.)
 *
 * NOT ASSERTED (NON-ASSERTION): the actual behaviour of CALLING isPrototypeOf.
 * Per the chapter, calling it hangs the engine and the CloudPage times out
 * with no output. It cannot be executed live — the documented behaviour is an
 * engine hang; running it would time out the CloudPage. It can therefore never
 * appear as a PASS/FAIL line, only as a page that never returns. This script
 * asserts PRESENCE + the workarounds only and NEVER invokes isPrototypeOf.
 *
 * DETERMINISM: all inputs are fixed literals / freshly constructed instances,
 * so every expected value is clock- and locale-independent.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison of a THUNK result, no coercion; captures throws. */
function assert(id, fn, expected) {
    var got;
    try { got = fn(); } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === expected ? "PASS " : "FAIL ") + id + " => " + got + "\n");
}

/* 1-2. PRESENCE only — the member is READ, never called. */
assert("isPrototypeOf is present on a plain object literal, typeof function (NEVER call it)", function () { return "" + (typeof ({}).isPrototypeOf); }, "function");
var o = { a: 1 };
assert("isPrototypeOf is inherited by o, typeof function (NEVER call it)", function () { return "" + (typeof o.isPrototypeOf); }, "function");

/* 3. WORKAROUND — compare the constructor directly (safe, terminating). */
function Ctor() {}
function Other() {}
var inst = new Ctor();
assert("workaround: inst.constructor === Ctor is true", function () { return inst.constructor === Ctor; }, true);
assert("workaround: inst.constructor === Other is false", function () { return inst.constructor === Other; }, false);

/* 4. WORKAROUND — walk the prototype chain manually (safe, terminating).
 * Single hop only: this engine's Object.getPrototypeOf is reliable for the
 * first hop but mis-reports beyond it, so a multi-hop while-loop is avoided. */
assert("workaround: Object.getPrototypeOf(inst) === Ctor.prototype is true", function () { return Object.getPrototypeOf(inst) === Ctor.prototype; }, true);
assert("workaround: Object.getPrototypeOf(inst) === Other.prototype is false", function () { return Object.getPrototypeOf(inst) === Other.prototype; }, false);
</script>


Object.prototype.propertyIsEnumerable Always Returns false

Severity: Low — incorrect result

Object.prototype.propertyIsEnumerable(prop) exists in the SFMC Jint engine but is broken: it returns false even for own enumerable properties (runtime-verified). Use hasOwnProperty for own-property checks instead.

var o = { a: 1 };
o.propertyIsEnumerable("a"); // false — WRONG, should be true
o.hasOwnProperty("a");       // true  — use this instead

See Object Methods and Differs from Official Docs.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: Object.prototype.propertyIsEnumerable Always Returns false
 *          (engine-limitations/known-bugs)
 *
 * Object.prototype.propertyIsEnumerable(prop) exists in the SFMC (Jint)
 * engine but is BROKEN: it returns false even for an object's own enumerable
 * property. Standard JavaScript returns true for an own enumerable property.
 * hasOwnProperty is the working replacement for own-property checks.
 *
 * Proves:
 *   1. typeof o.propertyIsEnumerable is "function" — the member exists and
 *      returns normally (it does NOT hang like its sibling isPrototypeOf).
 *   2. DEV: o.propertyIsEnumerable("a") for an OWN ENUMERABLE property returns
 *      false here (spec: true).
 *   3. o.propertyIsEnumerable("missing") is false for an absent property
 *      (this happens to match the spec, but is asserted so the false result
 *      is not mistaken for a working method).
 *   4. WORKAROUND — o.hasOwnProperty("a") returns true for the own property
 *      (the documented replacement).
 *   5. WORKAROUND — hasOwnProperty combined with a guarded for-in loop finds
 *      the own enumerable key, giving the true/present answer that
 *      propertyIsEnumerable fails to provide.
 *
 * DETERMINISM: the fixture is a fixed object literal { a: 1 }, so every
 * expected value is clock- and locale-independent.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

/* assert: strict === comparison of a THUNK result, no coercion; captures throws. */
function assert(id, fn, expected) {
    var got;
    try { got = fn(); } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === expected ? "PASS " : "FAIL ") + id + " => " + got + "\n");
}

var o = { a: 1 };

/* 1. PRESENCE — the member exists and returns normally (does not hang). */
assert("propertyIsEnumerable is present, typeof function", function () { return "" + (typeof o.propertyIsEnumerable); }, "function");
assert("calling propertyIsEnumerable returns a boolean (does not hang)", function () { return typeof o.propertyIsEnumerable("a"); }, "boolean");

/* 2. DEV — own enumerable property returns false here (spec: true). */
assert("DEV o.propertyIsEnumerable('a') is false for an OWN enumerable property (spec: true)", function () { return o.propertyIsEnumerable("a"); }, false);

/* 3. Absent property is false too (matches spec, but proves it never returns true). */
assert("o.propertyIsEnumerable('missing') is false for an absent property", function () { return o.propertyIsEnumerable("missing"); }, false);

/* 4. WORKAROUND — hasOwnProperty is the working own-property check. */
assert("workaround: o.hasOwnProperty('a') is true", function () { return o.hasOwnProperty("a"); }, true);
assert("workaround: o.hasOwnProperty('missing') is false", function () { return o.hasOwnProperty("missing"); }, false);

/* 5. WORKAROUND — hasOwnProperty + a guarded for-in loop finds the own enumerable key. */
assert("workaround: hasOwnProperty + for-in finds own enumerable key 'a'", function () {
    var found = false;
    for (var k in o) {
        if (o.hasOwnProperty(k) && k === "a") { found = true; }
    }
    return found;
}, true);
</script>


Bitwise Operators Throw on a Negative Operand

Severity: High — every bitwise expression aborts the script as soon as a value goes negative

Every bitwise operator in the SFMC Jint engine fails when either operand is negative (runtime-verified). The value’s sign, not the operator, is what breaks — a negative value held in a variable behaves exactly like a negative literal. &, |, ^ and ~ throw Arithmetic operation resulted in an overflow.; <<, >> and >>> throw that same message for a negative left operand and Value was either too large or too small for a UInt16. for a negative right operand.

5 & 3;        // 1  — non-negative operands are fine
(-1) | 0;     // THROWS "Arithmetic operation resulted in an overflow."
(-1) >>> 0;   // THROWS "Arithmetic operation resulted in an overflow."
5 & (-1);     // THROWS "Arithmetic operation resulted in an overflow."
5 << (-1);    // THROWS "Value was either too large or too small for a UInt16."

Test the sign before applying any bitwise operator. This also limits the ES6 Math emulations built on bitwise ops: clz32 throws on its first line for a negative argument, and imul accepts non-negative operands only. << additionally does not truncate to 32 bits (0x80000000 << 1 returns 4294967296, not 0), so bitwise code cannot rely on 32-bit wrap-around.

See Operators.

Show test script
<script runat="server">
/*
 * Chapter: Bitwise Operators Throw on a Negative Operand
 *          (engine-limitations/known-bugs)
 *
 * In standard JavaScript the bitwise operators & | ^ << >> >>> compute via
 * two's complement and work for negative operands. In the SFMC (Jint) engine
 * every one of them THROWS as soon as an operand is negative — the value's
 * SIGN, not the operator, is what breaks, and a negative value held in a
 * variable behaves exactly like a negative literal (runtime-verified).
 *
 * Proves:
 *   1. Non-negative operands compute the correct result for all six operators:
 *        5 & 3 === 1, 5 | 3 === 7, 5 ^ 3 === 6, 5 << 1 === 10, 5 >> 1 === 2,
 *        5 >>> 1 === 2.
 *   2. DEV negative LEFT operand throws "Arithmetic operation resulted in an
 *      overflow." for ALL six operators (spec: a two's-complement result):
 *        (-1) & 1, (-1) | 0, (-1) ^ 1, (-1) << 1, (-1) >> 1, (-1) >>> 0.
 *   3. DEV negative RIGHT operand also throws, but the message DIFFERS by
 *      operator family:
 *        &, |, ^  -> "Arithmetic operation resulted in an overflow."
 *        <<, >>, >>> -> "Value was either too large or too small for a UInt16."
 *      (spec: the shift count is taken mod 32, so 5 << (-1) would be 5 << 31.)
 *   4. DEV a negative value held in a VARIABLE throws identically to a negative
 *      literal — it is not a parser / unary-minus artifact.
 *   5. DEV the exact .NET message fragments are asserted with indexOf on the
 *      normalized ("" + msg) CLR string (per probe-sfmc-cloudpage).
 *   6. DEV << does NOT truncate to 32 bits: 0x80000000 << 1 === 4294967296
 *      (spec: 0), so bitwise code cannot rely on 32-bit wrap-around.
 *   7. WORKAROUND: test the sign before applying a bitwise operator — the
 *      non-negative branch computes normally and never throws.
 *
 * NON-ASSERTION: the chapter also cross-references Math.clz32 (throws on its
 * first line for a negative argument) and Math.imul (non-negative operands
 * only). Those are proved by the Math chapter script
 * (ecmascript-builtins--math.yml); they are not re-asserted here to keep this
 * chapter scoped to the six operators the heading names. Bitwise NOT (~) has
 * its own dedicated chapter (#bitwise-not-broken) and is not asserted here.
 *
 * DETERMINISM: every input is a fixed numeric literal, so all expected values
 * are clock- and locale-independent.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. NOTE: if a
 * documented negative-operand case does NOT throw (e.g. the engine gained
 * two's-complement bitwise support), that is a NEW discrepancy for the
 * maintainer to review — the "throws" bug would no longer reproduce.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}
/* assertThrows: the risky expression is a THUNK, evaluated inside the try. */
function assertThrows(id, fn) {
    var threw = false, msg = "";
    try { fn(); } catch (ex) { threw = true; msg = "" + ex.message; }
    Platform.Response.Write((threw ? "PASS " : "FAIL ") + id + " -> " + (threw ? "threw: " + msg : "did NOT throw") + "\n");
}
/* threwMsg: return the normalized ("" + msg) CLR message a thunk throws, or "" if it did not throw. */
function threwMsg(fn) {
    try { fn(); } catch (ex) { return "" + ex.message; }
    return "";
}

var OVERFLOW = "Arithmetic operation resulted in an overflow.";
var UINT16 = "Value was either too large or too small for a UInt16.";

/* 1. Non-negative operands compute the correct result for all six operators. */
assert("5 & 3 === 1 (non-negative operands are fine)", 5 & 3, 1);
assert("5 | 3 === 7 (non-negative operands are fine)", 5 | 3, 7);
assert("5 ^ 3 === 6 (non-negative operands are fine)", 5 ^ 3, 6);
assert("5 << 1 === 10 (non-negative operands are fine)", 5 << 1, 10);
assert("5 >> 1 === 2 (non-negative operands are fine)", 5 >> 1, 2);
assert("5 >>> 1 === 2 (non-negative operands are fine)", 5 >>> 1, 2);

/* 2. DEV negative LEFT operand throws for all six operators (spec: two's-complement result). */
assertThrows("DEV (-1) & 1 throws (spec: 1)", function () { return (-1) & 1; });
assertThrows("DEV (-1) | 0 throws (spec: -1)", function () { return (-1) | 0; });
assertThrows("DEV (-1) ^ 1 throws (spec: -2)", function () { return (-1) ^ 1; });
assertThrows("DEV (-1) << 1 throws (spec: -2)", function () { return (-1) << 1; });
assertThrows("DEV (-1) >> 1 throws (spec: -1)", function () { return (-1) >> 1; });
assertThrows("DEV (-1) >>> 0 throws (spec: 4294967295)", function () { return (-1) >>> 0; });

/* 3. DEV negative RIGHT operand throws too; message differs by operator family. */
assertThrows("DEV 5 & (-1) throws (spec: 5)", function () { return 5 & (-1); });
assertThrows("DEV 5 | (-1) throws (spec: -1)", function () { return 5 | (-1); });
assertThrows("DEV 5 ^ (-1) throws (spec: -6)", function () { return 5 ^ (-1); });
assertThrows("DEV 5 << (-1) throws (spec: 5 << 31 = -2147483648)", function () { return 5 << (-1); });
assertThrows("DEV 5 >> (-1) throws (spec: 5 >> 31 = 0)", function () { return 5 >> (-1); });
assertThrows("DEV 5 >>> (-1) throws (spec: 5 >>> 31 = 0)", function () { return 5 >>> (-1); });

/* 4. DEV a negative value held in a VARIABLE throws identically to a negative literal. */
assertThrows("DEV var n=-1; n | 0 throws (not a parser artifact)", function () { var n = -1; return n | 0; });
assertThrows("DEV var n=0-1; n >>> 0 throws (computed negative, still throws)", function () { var n = 0 - 1; return n >>> 0; });

/* 5. DEV exact .NET message fragments (CLR strings normalized with ("" + msg), tested with indexOf). */
assert("DEV (-1) | 0 message contains overflow fragment", threwMsg(function () { return (-1) | 0; }).indexOf(OVERFLOW) >= 0 ? "found" : "missing", "found");
assert("DEV 5 & (-1) message contains overflow fragment (negative right, logical op)", threwMsg(function () { return 5 & (-1); }).indexOf(OVERFLOW) >= 0 ? "found" : "missing", "found");
assert("DEV (-1) << 1 message contains overflow fragment (negative left, shift op)", threwMsg(function () { return (-1) << 1; }).indexOf(OVERFLOW) >= 0 ? "found" : "missing", "found");
assert("DEV 5 << (-1) message contains UInt16 fragment (negative right, shift op)", threwMsg(function () { return 5 << (-1); }).indexOf(UINT16) >= 0 ? "found" : "missing", "found");
assert("DEV 5 >> (-1) message contains UInt16 fragment (negative right, shift op)", threwMsg(function () { return 5 >> (-1); }).indexOf(UINT16) >= 0 ? "found" : "missing", "found");
assert("DEV 5 >>> (-1) message contains UInt16 fragment (negative right, shift op)", threwMsg(function () { return 5 >>> (-1); }).indexOf(UINT16) >= 0 ? "found" : "missing", "found");

/* 6. DEV << does not truncate to 32 bits: 0x80000000 << 1 === 4294967296 (spec: 0). */
assert("DEV 0x80000000 << 1 === 4294967296 (spec: 0 — no 32-bit wrap-around)", 0x80000000 << 1, 4294967296);

/* 7. WORKAROUND: test the sign first — the non-negative branch computes normally and never throws. */
assert("workaround: sign guard applies & only when non-negative (n=5 -> 5 & 3 = 1)", (function () { var n = 5; return n >= 0 ? (n & 3) : "skip"; })(), 1);
assert("workaround: sign guard skips the bitwise op for a negative value (n=-1 -> 'skip', no throw)", (function () { var n = -1; return n >= 0 ? (n & 3) : "skip"; })(), "skip");
</script>


Bitwise NOT (~) Returns a Constant

Severity: High — silently wrong result, no error raised

~x never computes -(x + 1) in the SFMC Jint engine. ~0, ~1, ~2, ~5 and ~255 all return the same constant 1.84467440737096e+19 (264) — runtime-verified. Unlike the other bitwise operators this fails silently, so ~5 === -6 is false and even ~5 < 0 is false.

~5;          // 1.84467440737096e+19 — expected -6
~5 === -6;   // false
~5 < 0;      // false
-(5 + 1);    // -6 — use this instead

Never use ~indexOf(…) as a truthiness idiom; compare against -1 explicitly. See Operators.

Show test script
<script runat="server">
/*
 * Chapter: Bitwise NOT (~) Returns a Constant
 *          (engine-limitations/known-bugs)
 *
 * In standard JavaScript ~x computes -(x + 1) via two's complement, so
 * ~0 === -1, ~5 === -6, and ~255 === -256. In the SFMC (Jint) engine ~x
 * NEVER computes -(x + 1): every operand returns the same constant
 * 1.84467440737096e+19 (2^64) regardless of x, and it fails SILENTLY — no
 * error is raised (runtime-verified). This makes the common ~indexOf(...)
 * truthiness idiom silently wrong.
 *
 * Proves:
 *   1. DEV ~x returns the constant 1.84467440737096e+19 for the documented
 *      inputs 0, 1, 2, 5 and 255 (spec: -(x+1), i.e. -1, -2, -3, -6, -256).
 *   2. DEV the constant is IDENTICAL across operands, so ~0 === ~5 (spec:
 *      -1 !== -6 -> false).
 *   3. DEV ~5 === -6 is false (spec: true) — the sign-flip identity is gone.
 *   4. DEV ~5 < 0 is false (spec: true) — the result is a huge POSITIVE
 *      number, so it is not even negative.
 *   5. WORKAROUND: compute -(x + 1) manually — it yields the correct
 *      two's-complement value the operator should have produced
 *      (-(5+1) === -6, -(0+1) === -1, -(255+1) === -256).
 *   6. WORKAROUND: never use ~indexOf(...) as a truthiness idiom; compare
 *      against -1 explicitly. ~(-1) would spec to 0 (falsy) but here it is
 *      the constant, so the idiom is unusable; the explicit -1 compare works.
 *
 * DETERMINISM: every input is a fixed numeric literal, so all expected
 * values are clock- and locale-independent. The constant is compared as a
 * normalized ("" + value) string to avoid float-formatting ambiguity.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. NOTE: if
 * ~x computes correctly (i.e. it does NOT return the constant and instead
 * yields -(x+1)), that is a NEW discrepancy for the maintainer to review —
 * the "returns a constant" bug would no longer reproduce.
 */

/* assert: strict === comparison, no coercion; captures throws as THREW. */
function assert(id, actual, expected) {
    var got;
    try { got = "" + actual; } catch (ex) { got = "THREW: " + ("" + ex.message); }
    Platform.Response.Write((got === ("" + expected) ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/* The documented constant every ~x collapses to in the SFMC engine. */
var CONST = "1.84467440737096e+19";

/* 1. DEV ~x returns the constant for every documented input (spec: -(x+1)). */
assert("DEV ~0 is the constant (spec: -1)", "" + (~0), CONST);
assert("DEV ~1 is the constant (spec: -2)", "" + (~1), CONST);
assert("DEV ~2 is the constant (spec: -3)", "" + (~2), CONST);
assert("DEV ~5 is the constant (spec: -6)", "" + (~5), CONST);
assert("DEV ~255 is the constant (spec: -256)", "" + (~255), CONST);

/* 2. DEV the constant is identical across operands, so ~0 === ~5 (spec: -1 !== -6). */
assert("DEV ~0 === ~5 is true (spec: false — different operands, different results)", (~0) === (~5) ? "true" : "false", "true");

/* 3. DEV ~5 === -6 is false (spec: true — the sign-flip identity is gone). */
assert("DEV ~5 === -6 is false (spec: true)", (~5) === -6 ? "true" : "false", "false");

/* 4. DEV ~5 < 0 is false (spec: true — the result is a huge positive number). */
assert("DEV ~5 < 0 is false (spec: true)", (~5) < 0 ? "true" : "false", "false");

/* 5. WORKAROUND: compute -(x + 1) manually to get the correct two's-complement value. */
assert("workaround: -(5 + 1) === -6 (the value ~5 should have produced)", -(5 + 1), -6);
assert("workaround: -(0 + 1) === -1 (the value ~0 should have produced)", -(0 + 1), -1);
assert("workaround: -(255 + 1) === -256 (the value ~255 should have produced)", -(255 + 1), -256);

/* 6. WORKAROUND: compare against -1 explicitly instead of the broken ~indexOf idiom. */
assert("workaround: found-idiom via ('abc'.indexOf('b') != -1) is true (not ~indexOf)", "abc".indexOf("b") != -1 ? "true" : "false", "true");
assert("workaround: not-found via ('abc'.indexOf('z') != -1) is false (not ~indexOf)", "abc".indexOf("z") != -1 ? "true" : "false", "false");
</script>


The in Operator Is Unreliable

Severity: High — silent wrong results in property-existence checks

The in operator does not produce a usable boolean in the SFMC Jint engine. Its result has typeof "undefined" — it is neither === true nor === false — so it cannot be stored, compared, or stringified. Used directly as an if condition it is also wrong: an absent key on an empty object takes the true branch (runtime-verified false positive). Use typeof obj[key] != "undefined" (or hasOwnProperty for own properties only) instead.

var empty = {};
var r = ("zzz" in empty);
typeof r;                             // "undefined" — not a boolean
if ("zzz" in empty) { /* TAKEN */ }   // WRONG — the key does not exist

typeof empty["zzz"] != "undefined";   // false — use this instead
empty.hasOwnProperty("zzz");          // false — own properties only

See Reflection and Keyed Collections.

Show test script
<script runat="server">
/*
 * Chapter: The in Operator Is Unreliable
 *          (engine-limitations/known-bugs)
 *
 * In standard JavaScript `key in obj` is a boolean-valued property-existence
 * check: `"zzz" in {}` is false, and the result is always === true or
 * === false. In the SFMC (Jint) engine the `in` operator does NOT produce a
 * usable boolean (runtime-verified): its result has typeof "undefined" — it
 * is neither === true nor === false, so it cannot be stored, compared, or
 * stringified. Used directly as an `if` condition it is also WRONG: an absent
 * key on an empty object takes the TRUE branch (a false positive). Use
 * `typeof obj[key] != "undefined"` (or `hasOwnProperty` for own properties
 * only) instead.
 *
 * SAFETY NOTE: this chapter uses `in` only against a plain LOCAL object ({}),
 * which runs and returns (typeof "undefined"). That is NOT the separately
 * documented fatal form `"name" in this` (in against top-level/global scope),
 * which aborts the page and is deliberately never probed here.
 *
 * Proves:
 *   1. DEV typeof ("zzz" in {}) is "undefined" (spec: "boolean").
 *   2. DEV ("zzz" in {}) is neither === true nor === false (spec: a real
 *      boolean, so exactly one of those comparisons is true).
 *   3. DEV `if ("zzz" in {})` takes the TRUE branch for an ABSENT key
 *      (spec: false, so the branch is NOT taken).
 *   4. WORKAROUND: `typeof empty["zzz"] != "undefined"` is correctly false
 *      for the absent key.
 *   5. WORKAROUND: `empty.hasOwnProperty("zzz")` is correctly false for the
 *      absent key (own properties only).
 *
 * DETERMINISM: the fixture is a fresh empty object literal and a fixed absent
 * key "zzz" — no clock, locale, or external state is involved.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, fn, expected) {
    var got;
    try { got = fn(); } catch (ex) { got = "THREW: " + ex.message; }
    Platform.Response.Write((got === expected ? "PASS " : "FAIL ") + id + " -> [" + got + "]\n");
}

/* Deterministic fixtures: a fresh empty object and a fixed absent key. */
var empty = {};
var kAbsent = "zzz";

/* 1. DEV — the `in` result is not a boolean; its typeof is "undefined". */
assert("DEV typeof ('zzz' in {}) is undefined (spec: boolean)", function () { var r = (kAbsent in empty); return String(typeof r); }, "undefined");

/* 2. DEV — the result is neither === true nor === false. */
assert("DEV ('zzz' in {}) is neither === true nor === false (spec: a boolean)", function () { var r = (kAbsent in empty); return (r === true || r === false) ? "boolean" : "not-a-boolean"; }, "not-a-boolean");

/* 3. DEV — used as an if condition, an absent key WRONGLY takes the true branch. */
assert("DEV if ('zzz' in {}) takes the TRUE branch for an ABSENT key (spec: not-taken)", function () { if (kAbsent in empty) { return "taken"; } return "not-taken"; }, "taken");

/* 4. WORKAROUND — typeof empty['zzz'] != 'undefined' is correctly false. */
assert("workaround typeof empty['zzz'] != 'undefined' is correctly false", function () { var r = (typeof empty[kAbsent] != "undefined"); return r ? "true" : "false"; }, "false");

/* 5. WORKAROUND — hasOwnProperty is correctly false for the absent key. */
assert("workaround empty.hasOwnProperty('zzz') is correctly false", function () { var r = empty.hasOwnProperty(kAbsent); return r ? "true" : "false"; }, "false");
</script>


TriggeredSend.Add Has No Working Invocation

Severity: High — the documented way to create a triggered send definition never succeeds

TriggeredSend.Add(properties) is officially documented and the name resolves at runtime (typeof TriggeredSend.Add is "function"), but no working invocation was found. Every call throws the plain string Error adding TSD., and TriggeredSend.LastMessage afterwards is An error occurred when attempting to evaluate a SetObjectProperty function call. See inner exception for details. — including flat-only payloads, where TriggeredSend.LastErrorCode is left undefined rather than a numeric code. A string argument or a two-argument call also throws Error adding TSD. (the Invalid cast from 'Char' to 'Double'. cast still appears on <TriggeredSendInstance>.Update("x"), not on these Add forms).

Payload shapes swept without a single success: the nested SOAP shape, the documented flat shape (EmailID, ListID, SendClassificationID), dotted keys ("Email.ID"), scalar-only payloads, typed Core Library objects from Email.Init() / List.Init() / SendClassification.Init(), and the CLR object returned by TriggeredSend.Retrieve with a mutated CustomerKey.

// ❌ throws the STRING "Error adding TSD." for every payload shape
var tsd = TriggeredSend.Add({
    CustomerKey: "my_tsd", Name: "my_tsd",
    Email: { ID: 769268 }, List: { ID: 72164 },
    SendClassification: { CustomerKey: "Default Transactional" }
});

// ✅ the identical payload via WSProxy succeeds
var api = new Script.Util.WSProxy();
var res = api.createItem("TriggeredSendDefinition", {
    CustomerKey: "my_tsd", Name: "my_tsd",
    Email: { ID: 769268 }, List: { ID: 72164 },
    SendClassification: { CustomerKey: "Default Transactional" },
    TriggeredSendType: "Continuous", FromName: "Sender", FromAddress: "me@example.com",
    EmailSubject: "Subject", IsWrapped: true
});
// res.Status === "OK", ErrorCode 0, StatusMessage "TriggeredSendDefinition created"
var tsd = TriggeredSend.Init("my_tsd");

The definition created through WSProxy then publishes, starts, sends, pauses, and updates normally through the Core Library instance methods.

Works correctly: WSProxy createItem, then TriggeredSend.Init. See also Differs from Official Docs.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: TriggeredSend.Add Has No Working Invocation
 *          (engine-limitations/known-bugs)
 *
 * TriggeredSend.Add(properties) is officially documented and its NAME
 * resolves at runtime (typeof TriggeredSend.Add === "function"), but NO
 * working invocation was found (runtime-verified): every documented call
 * signature FAILS, so the documented way to create a triggered send
 * definition never succeeds. Use WSProxy createItem("TriggeredSendDefinition",
 * ...) then TriggeredSend.Init(key) instead.
 *
 * SIDE-EFFECT SAFETY: TriggeredSend.Add would create/send only if it
 * SUCCEEDED. The whole point of this chapter is that NONE of the documented
 * forms succeed — every call below throws the STRING "Error adding TSD."
 * BEFORE any definition is created, so nothing is added and nothing is sent.
 * All payloads use obviously-fake ids and the fake address
 * ssjsguide-ts@example.com. No real subscriber email is used and no send is
 * triggered. Each risky call is wrapped in try/catch and asserted on the
 * thrown string / LastMessage the chapter documents.
 *
 * Proves (each a SAFE, non-sending claim from the chapter):
 *   1. TriggeredSend.Add resolves — typeof is "function".
 *   2. DEV flat payload (EmailID / ListID / SendClassificationID) throws the
 *      STRING "Error adding TSD.".
 *   3. DEV/QUEUED-ISSUE: after the flat throw (asserted FIRST, before any
 *      nested call mutates the sticky globals) the RUNTIME (this QA BU, GET
 *      context, 2026-08) leaves TriggeredSend.LastMessage set to the
 *      SetObjectProperty-evaluation error and TriggeredSend.LastErrorCode
 *      undefined — NOT the "Error adding TSD." / LastErrorCode 17014 / 2 the
 *      chapter prose claims for the flat-only path. The chapter's CENTRAL
 *      claim (flat Add THROWS the string "Error adding TSD.") still holds
 *      (claim 2). Only the flat-specific LastMessage/LastErrorCode prose is
 *      currently disproven; logged in the verification DB and flagged as a
 *      queued issue for the user. Encoded here as green DEV assertions on the
 *      honest runtime values, with the doc claim stated inline.
 *   4. DEV nested-object payload (Email/List/SendClassification) throws the
 *      STRING "Error adding TSD." (docs: returns a TriggeredSendInstance).
 *   5. After the nested throw, TriggeredSend.LastMessage mentions
 *      "SetObjectProperty" (the SetObjectProperty-evaluation error the chapter
 *      documents for any payload with a nested object).
 *   6. DEV string-argument form Add("x") throws the STRING "Error adding TSD."
 *      (the chapter notes the "Invalid cast from 'Char' to 'Double'." cast
 *      appears on <TriggeredSendInstance>.Update("x"), NOT on these Add forms).
 *   7. DEV two-argument form Add(obj, "x") throws the STRING
 *      "Error adding TSD.".
 *   8. Workaround is correctly SHAPED — a Script.Util.WSProxy() instance
 *      exposes createItem (typeof reports the CLR proxy tag "clrmethodinfo";
 *      the documented replacement is WSProxy createItem then TriggeredSend.Init).
 *
 * NON-ASSERTIONS (documented workaround NOT executed here, with reasons):
 *   - Actually calling WSProxy createItem("TriggeredSendDefinition", ...) to
 *     create a live triggered send definition is NOT asserted in this chapter:
 *     it creates a real (sendable) definition + fixture email/list and is
 *     conservatively avoided here. Its SUCCESS is already proven green by the
 *     core-library/triggeredsend "add" chapter
 *     (ssjs.guide/_data/test_scripts/core-library--triggeredsend.yml, key
 *     "add": "workaround WSProxy createItem Status is OK"). This chapter only
 *     asserts that the workaround entry point is correctly shaped (claim 8).
 *
 * CATCH-KIND HELPER: catchKind runs the call inside try/catch and returns
 * "<typeof-of-thrown>|<string-or-message>" so an assertion can prove the
 * thrown value is the STRING "Error adding TSD." (not an Error object).
 * LastMessage is a CLR value: normalize with ("" + value) and test a stable
 * fragment with indexOf.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. If a
 * documented-broken Add form ever SUCCEEDS (or sends), STOP calling it and
 * treat it as a real discrepancy — do not keep invoking a sending call.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function catchKind(fn) {
    try { fn(); return "did-not-throw"; }
    catch (ex) {
        var t = typeof ex;
        var s = (t === "string") ? ("" + ex) : ("" + (ex && ex.message));
        return t + "|" + s;
    }
}

/* 1. The Add name resolves at runtime. */
assert("typeof TriggeredSend.Add is function", typeof TriggeredSend.Add, "function");

/* 2. DEV — documented flat payload throws the STRING "Error adding TSD.".
 *    Run FIRST so its sticky LastMessage/LastErrorCode are not overwritten by
 *    the later nested call. Fake ids only; the throw happens before any
 *    definition is created. */
assert("DEV flat Add throws string|Error adding TSD. (docs: returns TriggeredSendInstance)", catchKind(function () {
    return TriggeredSend.Add({
        Name: "ssjsguide-ts-nofire-flat", CustomerKey: "ssjsguide-ts-nofire-flat",
        FromName: "SSJS Guide", FromAddress: "ssjsguide-ts@example.com",
        EmailID: 1, ListID: 1, SendClassificationID: 1
    });
}), "string|Error adding TSD.");

/* 3. DEV / QUEUED ISSUE — runtime-proven: after the flat throw the sticky
 *    LastMessage is the SetObjectProperty error and LastErrorCode is undefined
 *    (page prose claims "Error adding TSD." / LastErrorCode 17014 / 2 for the
 *    flat-only path). The throw itself (claim 2) is unaffected; only this
 *    flat-specific LastMessage/LastErrorCode prose is currently disproven. */
assert("DEV flat Add LastMessage is SetObjectProperty error (page claims 'Error adding TSD.')", (("" + TriggeredSend.LastMessage).indexOf("SetObjectProperty") >= 0) ? "matched" : ("" + TriggeredSend.LastMessage), "matched");
assert("DEV flat Add LastErrorCode is undefined (page claims 17014 / 2)", "" + TriggeredSend.LastErrorCode, "undefined");

/* 4. DEV — nested-object payload also throws the STRING "Error adding TSD.".
 *    Fake ids only; the throw happens before any definition is created. */
assert("DEV nested-object Add throws string|Error adding TSD. (docs: returns TriggeredSendInstance)", catchKind(function () {
    return TriggeredSend.Add({
        Name: "ssjsguide-ts-nofire", CustomerKey: "ssjsguide-ts-nofire",
        FromName: "SSJS Guide", FromAddress: "ssjsguide-ts@example.com",
        Email: { ID: 1 }, List: { ID: 1 },
        SendClassification: { CustomerKey: "Default Transactional" }
    });
}), "string|Error adding TSD.");

/* 5. DEV — after the nested throw, LastMessage mentions SetObjectProperty. */
assert("DEV nested Add LastMessage mentions SetObjectProperty", (("" + TriggeredSend.LastMessage).indexOf("SetObjectProperty") >= 0) ? "matched" : ("" + TriggeredSend.LastMessage), "matched");

/* 6. DEV — string argument throws "Error adding TSD." (NOT the Invalid-cast
 *    seen on <TriggeredSendInstance>.Update("x")). */
assert("DEV Add('x') throws string|Error adding TSD. (Update('x') cast note does NOT apply)", catchKind(function () {
    return TriggeredSend.Add("x");
}), "string|Error adding TSD.");

/* 7. DEV — two-argument form also throws "Error adding TSD.". */
assert("DEV Add(obj, 'x') throws string|Error adding TSD.", catchKind(function () {
    return TriggeredSend.Add({ Name: "ssjsguide-ts-nofire-2arg" }, "x");
}), "string|Error adding TSD.");

/* 8. WORKAROUND (shape only, non-sending): the documented replacement entry
 *    point WSProxy.createItem resolves (typeof is the CLR proxy tag
 *    "clrmethodinfo"). Actually creating the definition is a NON-ASSERTION
 *    here (see header) — proven in core-library/triggeredsend. */
var api = new Script.Util.WSProxy();
assert("workaround Script.Util.WSProxy().createItem resolves (clrmethodinfo)", typeof api.createItem, "clrmethodinfo");
</script>


<PortfolioInstance>.Update Has No Working Invocation

Severity: Medium — the documented way to edit a portfolio item never succeeds

<PortfolioInstance>.Update(properties) is officially documented and resolves at runtime, but no working invocation was found — while Init, Add, Retrieve and Remove all succeed on the very same item. Every attempt either returned the plain string Error or threw Error Updating Portfolio, and the stored record never changed.

Shapes swept without a single success: instances from Init(CustomerKey) and from Init(ObjectID); single-field payloads ({DisplayName}, {Description}); payloads repeating the identifying fields ({CustomerKey, DisplayName, CategoryID}); payloads carrying the ObjectID; the full Add-shaped payload including FileName + FileLocation; an array-wrapped payload; and a no-op update writing the current DisplayName back onto a pre-existing (non-probe) item. There is no static Portfolio.Update either — that identifier is undefined.

Platform.Load("core", "1.1.5");

// ❌ returns "Error" (or throws "Error Updating Portfolio") for every payload shape
var portObj = Portfolio.Init("myPortfolioCK");
var status = portObj.Update({ DisplayName: "Updated name" });

// ✅ delete and re-create instead
Portfolio.Init("myPortfolioCK").Remove();
Portfolio.Add({
    DisplayName: "Updated name",
    CustomerKey: "myPortfolioCK",
    CategoryID: 12345,
    FileName: "logo.png",
    FileLocation: "https://www.example.com/logo.png"
});

Portfolio is a retired Classic Content feature in any case — for new work use the Content Builder Asset REST endpoints instead.

Works correctly: <PortfolioInstance>.Remove() followed by Portfolio.Add(). See also Differs from Official Docs.

Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: <PortfolioInstance>.Update Has No Working Invocation
 *          (engine-limitations/known-bugs)
 *
 * <PortfolioInstance>.Update(properties) is officially documented and
 * resolves at runtime, but NO working invocation was found (runtime-
 * verified): every documented signature either returns the plain STRING
 * "Error" or throws the STRING "Error Updating Portfolio", and the stored
 * record never changes. Init, Add, Retrieve and Remove all work on the same
 * item. There is NO static Portfolio.Update — that identifier is undefined.
 * Workaround: Remove the item and Add it again, or use the Content Builder
 * Asset REST endpoints (Portfolio is a retired Classic Content feature).
 *
 * SIDE-EFFECT SAFETY: The whole point of this chapter is that NO Update form
 * works, so none should mutate. To be conservative this script never targets
 * a real Portfolio asset: every Update call is made on an instance created
 * from an obviously-fake CustomerKey ("ssjsguide-ts-fake") that does not
 * resolve to any stored item, so a call cannot mutate real data. Each risky
 * call is wrapped in try/catch and asserted on the "Error" string / thrown
 * "Error Updating Portfolio" the chapter documents. No real Portfolio asset
 * is created, updated or removed by this script.
 *
 * Proves (each a SAFE, non-mutating claim from the chapter):
 *   1. Portfolio.Init resolves and the returned instance exposes .Update
 *      (typeof "function").
 *   2. There is NO static Portfolio.Update — the identifier is undefined.
 *   3. DEV single-field {DisplayName} Update returns the plain STRING "Error"
 *      (or throws "Error Updating Portfolio") — never succeeds
 *      (docs: returns "OK" on success). Fake key, so nothing is mutated.
 *   4. DEV single-field {Description} Update likewise returns "Error"
 *      (or throws "Error Updating Portfolio").
 *   5. DEV full Add-shaped payload ({DisplayName, CustomerKey, CategoryID,
 *      FileName, FileLocation}) likewise returns "Error" (or throws
 *      "Error Updating Portfolio") — no payload shape succeeds.
 *   6. Workaround is correctly SHAPED — the same instance exposes Remove
 *      (typeof "function") and Portfolio.Add is a static function; the
 *      documented replacement is Remove() then Portfolio.Add().
 *
 * NON-ASSERTIONS (documented workaround NOT executed here, with reasons):
 *   - Actually calling <PortfolioInstance>.Remove() + Portfolio.Add() to
 *     recreate a portfolio item is NOT asserted here: it MUTATES a real
 *     Portfolio asset (deletes then re-creates a stored record). It is
 *     conservatively avoided in this Known-Bugs chapter. Its SUCCESS is
 *     already proven green by the core-library/portfolio chapters
 *     (ssjs.guide/_data/test_scripts/core-library--portfolio.yml). This
 *     chapter only asserts that the workaround entry points are correctly
 *     shaped (claim 6).
 *
 * OUTCOME HELPER: updateOutcome runs the Update inside try/catch and returns
 * a normalized string: the returned status coerced with ("" + status) when
 * the call returns, or "threw|<message-fragment>" when it throws. Both the
 * documented "Error" return and the documented "Error Updating Portfolio"
 * throw are accepted as a non-success outcome (the chapter documents both as
 * possible), so the assertion proves the call NEVER succeeded. CLR values are
 * normalized with ("" + value) and tested with indexOf.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised. If a
 * documented-broken Update form ever SUCCEEDS (returns "OK" / mutates), STOP
 * calling it and treat it as a real discrepancy — do not keep invoking a
 * mutating call.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
// Run an Update call and classify the outcome as a non-success token.
// Returns "no-success" when the call returned "Error" OR threw
// "Error Updating Portfolio" (both documented). Returns the literal
// returned status (or "threw|<msg>") otherwise, so a real success is visible.
function updateOutcome(fn) {
    var status;
    try { status = fn(); }
    catch (ex) {
        var msg = (typeof ex === "string") ? ("" + ex) : ("" + (ex && ex.message));
        return (msg.indexOf("Error Updating Portfolio") >= 0) ? "no-success" : ("threw|" + msg);
    }
    var s = "" + status;
    return (s.indexOf("Error") >= 0) ? "no-success" : ("returned|" + s);
}

// Obviously-fake key that resolves to no stored Portfolio item — a call on
// this instance cannot mutate real data.
var FAKE = "ssjsguide-ts-fake";

/* 1. Portfolio.Init resolves; the instance exposes .Update. */
var portObj = Portfolio.Init(FAKE);
assert("typeof Portfolio.Init(fake).Update is function", typeof portObj.Update, "function");

/* 2. No static Portfolio.Update — the identifier is undefined. */
assert("static Portfolio.Update is undefined", typeof Portfolio.Update, "undefined");

/* 3. DEV single-field {DisplayName} Update never succeeds — returns "Error"
 *    or throws "Error Updating Portfolio" (docs: returns "OK"). Fake key. */
assert("DEV Update({DisplayName}) never succeeds (docs: returns 'OK')", updateOutcome(function () {
    return Portfolio.Init(FAKE).Update({ DisplayName: "ssjsguide-ts-fake name" });
}), "no-success");

/* 4. DEV single-field {Description} Update likewise never succeeds. */
assert("DEV Update({Description}) never succeeds (docs: returns 'OK')", updateOutcome(function () {
    return Portfolio.Init(FAKE).Update({ Description: "ssjsguide-ts-fake desc" });
}), "no-success");

/* 5. DEV full Add-shaped payload likewise never succeeds — no shape works. */
assert("DEV Update(full Add-shaped payload) never succeeds (docs: returns 'OK')", updateOutcome(function () {
    return Portfolio.Init(FAKE).Update({
        DisplayName: "ssjsguide-ts-fake name",
        CustomerKey: FAKE,
        CategoryID: 1,
        FileName: "ssjsguide-ts-fake.png",
        FileLocation: "https://www.example.com/ssjsguide-ts-fake.png"
    });
}), "no-success");

/* 6. WORKAROUND (shape only, non-mutating): the documented replacement entry
 *    points resolve — instance Remove and static Portfolio.Add are both
 *    functions. Actually executing Remove()+Add() MUTATES a real asset and is
 *    a NON-ASSERTION here (see header) — proven in core-library/portfolio. */
assert("workaround <PortfolioInstance>.Remove resolves (function)", typeof portObj.Remove, "function");
assert("workaround Portfolio.Add resolves (function)", typeof Portfolio.Add, "function");
</script>


<ContentAreaObjInstance>.Update Creates a Content Area When It Fails

Severity: Medium — a failed update silently leaves a new, empty record behind

Calling <ContentAreaObjInstance>.Update(properties) on an instance whose external key does not resolve returns the plain string "Error" — and still creates an empty content area under that key. The failure is therefore not a no-op: repeated failed updates accumulate junk records that only an explicit Remove clears.

<ContentAreaObjInstance>.Remove() fails cleanly on the same unbound key: it returns "Error" and creates nothing.

Platform.Load("core", "1.1.1");

// ❌ returns "Error" — but a new, empty content area now exists under that key
var status = ContentAreaObj.Init("keyThatDoesNotExist").Update({ Name: "New name" });

// ✅ confirm the key resolves first
var rows = ContentAreaObj.Retrieve({
    Property: "CustomerKey",
    SimpleOperator: "equals",
    Value: "keyThatDoesNotExist"
});
if (rows && rows.length) {
    ContentAreaObj.Init("keyThatDoesNotExist").Update({ Name: "New name" });
}

ContentAreaObj is a retired Classic Content feature in any case — for new work use the Content Builder Asset REST endpoints instead.

Show test script
<script runat="server">
Platform.Load("core", "1.1.1");

/*
 * Chapter: <ContentAreaObjInstance>.Update Creates a Content Area When It Fails
 *
 * Proves (Known Bug — a failed Update silently leaves a new, empty record behind):
 *   1. <ContentAreaObjInstance>.Update on an instance bound to a key that
 *      does NOT resolve returns the plain string "Error" — it returns rather
 *      than throws (callers must compare against "OK").
 *   2. BUG: that failing Update is NOT a no-op — it CREATES an empty content
 *      area under the unbound key (count goes 0 -> 1). A clean implementation
 *      would leave nothing behind.
 *   3. Contrast: <ContentAreaObjInstance>.Remove on the same kind of unbound
 *      key also returns "Error" but creates NOTHING (count stays 0) — the
 *      clean-failure counterpart, and therefore the safe way to clear ghosts.
 *   4. WORKAROUND: ContentAreaObj.Retrieve on the unbound key returns an empty
 *      array (.length 0), and the guarded pattern (rows && rows.length) is
 *      falsy — so gating the Update on a successful Retrieve avoids the ghost.
 *
 * SAFETY / SIDE-EFFECT HANDLING: reproducing claim 2 necessarily creates a
 * real (ghost) Content Area asset. The probe therefore creates that ghost
 * ONLY under a uniquely named "ssjsguide-ts-"-prefixed key, proves the
 * creation, then REMOVES it in the same request and proves via Retrieve that
 * it is gone (final green assertion "no ghost content area is left behind").
 * Any orphan from an aborted earlier run is cleaned up first. No pre-existing
 * or production content area is ever touched.
 *
 * EXPECTED OUTPUT: every line starts with PASS.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function outcomeOf(fn) {
    try { return "" + fn(); } catch (ex) { return "THREW: " + ("" + ex.message); }
}
function countByKey(key) {
    return ContentAreaObj.Retrieve({ Property: "CustomerKey", SimpleOperator: "equals", Value: key }).length;
}

var GHOST = "ssjsguide-ts-cao-ghost-upd";
var REM = "ssjsguide-ts-cao-ghost-rem";

/* Orphan cleanup from any aborted earlier run. */
if (countByKey(GHOST) > 0) { ContentAreaObj.Init(GHOST).Remove(); }
if (countByKey(REM) > 0) { ContentAreaObj.Init(REM).Remove(); }

/* WORKAROUND part 1: Retrieve on an unbound key returns an empty array. */
var rows = ContentAreaObj.Retrieve({ Property: "CustomerKey", SimpleOperator: "equals", Value: GHOST });
assert("workaround: Retrieve on an unbound key returns .length 0", "" + rows.length, "0");
assert("workaround: (rows && rows.length) is falsy on no match", (rows && rows.length) ? "truthy" : "falsy", "falsy");
assert("precondition: nothing exists under the unbound Update key", "" + countByKey(GHOST), "0");

/* 1. Update on the unbound key returns "Error" and does not throw. */
var upd = outcomeOf(function () { return ContentAreaObj.Init(GHOST).Update({ Name: "New name" }); });
assert("Update on an unbound key returns \"Error\"", upd, "Error");
assert("it returns rather than throws", upd.indexOf("THREW") === 0 ? "true" : "false", "false");

/* 2. BUG: the failing Update CREATED an empty content area (0 -> 1).
 *    A correct implementation would leave nothing (expected 0). */
assert("BUG the failing Update CREATED a ghost content area (expected: none)", "" + countByKey(GHOST), "1");

/* 3. Contrast: Remove on an unbound key returns "Error" and creates nothing. */
assert("precondition: nothing exists under the unbound Remove key", "" + countByKey(REM), "0");
var rem = outcomeOf(function () { return ContentAreaObj.Init(REM).Remove(); });
assert("Remove on an unbound key returns \"Error\"", rem, "Error");
assert("it returns rather than throws", rem.indexOf("THREW") === 0 ? "true" : "false", "false");
assert("unlike Update, the failing Remove created NO content area", "" + countByKey(REM), "0");

/* Cleanup: delete the ghost Update created, and prove it is gone. */
assert("cleanup: the ghost content area is removed", outcomeOf(function () { return ContentAreaObj.Init(GHOST).Remove(); }), "OK");
assert("cleanup: no ghost content area is left behind", "" + countByKey(GHOST), "0");
</script>


<DataExtensionInstance>.Fields.UpdateSendableField Reports “OK” for a No-Argument Call

Severity: Low — a call that changes nothing reports success

<DataExtensionInstance>.Fields.UpdateSendableField(deFieldName, subscriberField) returns the string "Error" only when the data extension field is unknown — a single-argument call or an unknown subscriber attribute against a valid field still returns "OK" and applies the mapping (defaulting the missing attribute). But calling it with no arguments at all also returns "OK", while leaving the existing sendable mapping untouched.

A "OK" return therefore does not prove a mapping was applied. Pass both arguments explicitly, and read the mapping back through the SOAP API (DataExtension.SendableDataExtensionField.Name) when the result matters.

Platform.Load("core", "1.1.5");
var de = DataExtension.Init("sendableDataExtension");

// ❌ returns "OK" — but nothing changed
var status = de.Fields.UpdateSendableField();

// ✅ both arguments, and a real change
var status = de.Fields.UpdateSendableField("DifferentSubKey", "Subscriber Key");
Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: <DataExtensionInstance>.Fields.UpdateSendableField Reports "OK"
 *          for a No-Argument Call
 *
 * Proves (Known Bug — Severity Low: a call that changes nothing reports
 * success). Every assertion below was RUNTIME-PROVEN on the live CloudPage;
 * the inline "(page says …)" notes flag where the page's current wording is
 * broader than what the runtime actually does (a QUEUED verification issue —
 * see the DEV lines):
 *   1. A ZERO-ARGUMENT call returns the string "OK" although it applies no
 *      change — the documented false-success bug. A caller therefore cannot
 *      treat "OK" as proof that a mapping was written.
 *   2. The zero-argument call leaves the existing sendable mapping untouched
 *      (read back through WSProxy SendableDataExtensionField.Name).
 *   3. What actually triggers "Error" is an UNKNOWN DATA EXTENSION FIELD, not
 *      arity or a bad subscriber attribute:
 *        - single-arg with an unknown field   -> "Error";
 *        - two-arg with an unknown field       -> "Error".
 *   4. DEV — the page states "even a single-argument call" and "an unknown
 *      subscriber attribute" return "Error". Runtime DISPROVES both when the
 *      DATA EXTENSION FIELD is valid: a single-arg call with a known field
 *      returns "OK" and actually applies that field (defaulting the
 *      subscriberField); an unknown subscriber attribute on a known field
 *      also returns "OK" and applies the field. "Error" is gated on the DE
 *      field being unknown, not on arity or the attribute. (QUEUED ISSUE.)
 *   5. WORKAROUND: pass BOTH arguments explicitly for a real change — the
 *      two-argument documented form returns "OK" AND actually rewrites the
 *      sendable field (proven here against a throw-away fixture DE, then read
 *      back). Exercised only against this script's own fixture, never a
 *      production DE.
 *
 * The fixture is a throw-away sendable data extension (key
 * ssjsguide-ts-def-usf) created and removed by this script — the only DE this
 * script ever mutates. It is orphan-cleaned on entry and Removed at the end.
 *
 * NOT ASSERTABLE: none for this chapter — every claim above is deterministic
 * and side-effect-safe against the throw-away fixture.
 *
 * EXPECTED OUTPUT: every line starts with PASS.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function outcomeOf(fn) {
    try { return "" + fn(); } catch (ex) { return "THREW: " + ("" + ex.message); }
}
function countDE(key) {
    return DataExtension.Retrieve({ Property: "CustomerKey", SimpleOperator: "equals", Value: key }).length;
}
var api = new Script.Util.WSProxy();
function sendableField(key) {
    var r = api.retrieve("DataExtension", ["Name", "SendableDataExtensionField.Name"], { Property: "CustomerKey", SimpleOperator: "equals", Value: key });
    if (!r.Results || r.Results.length === 0) { return "no-de"; }
    return "" + r.Results[0].SendableDataExtensionField.Name;
}

var KEY = "ssjsguide-ts-def-usf";

/* Orphan cleanup from a previous aborted run, then create the fixture. */
if (countDE(KEY) > 0) { DataExtension.Init(KEY).Remove(); }
assert("precondition: no probe data extension exists", "" + countDE(KEY), "0");
DataExtension.Add({
    CustomerKey: KEY,
    Name: KEY,
    Fields: [
        { Name: "SubKey", FieldType: "Text", IsPrimaryKey: true, MaxLength: 50, IsRequired: true },
        { Name: "DifferentSubKey", FieldType: "Text", MaxLength: 50 },
        { Name: "SubId", FieldType: "Number" }
    ],
    SendableInfo: { Field: { Name: "SubKey", FieldType: "Text" }, RelatesOn: "Subscriber Key" }
});
assert("fixture: the probe data extension was created", "" + countDE(KEY), "1");

var de = DataExtension.Init(KEY);

/* Baseline: the fixture is mapped on SubKey. */
assert("the fixture starts out mapped on SubKey", sendableField(KEY), "SubKey");

/* 1 + 2. THE BUG: a zero-argument call returns "OK" but changes nothing. */
var noArg = outcomeOf(function () { return de.Fields.UpdateSendableField(); });
assert("BUG a zero-argument call returns \"OK\" (expected: \"Error\" — the call is a no-op)", noArg, "OK");
assert("it returns rather than throws", noArg.indexOf("THREW") === 0 ? "true" : "false", "false");
assert("BUG yet the sendable mapping is UNCHANGED after the no-arg call", sendableField(KEY), "SubKey");
assert("workaround: \"OK\" alone does NOT prove a mapping was applied", noArg === "OK" && sendableField(KEY) === "SubKey" ? "misleading" : "trustworthy", "misleading");

/* 3. "Error" is gated on an UNKNOWN data extension field. */
var badFieldOne = outcomeOf(function () { return de.Fields.UpdateSendableField("NoSuchField"); });
assert("single-arg with an UNKNOWN field returns \"Error\"", badFieldOne, "Error");
assert("the mapping survived the unknown-field single-arg call", sendableField(KEY), "SubKey");

var badFieldTwo = outcomeOf(function () { return de.Fields.UpdateSendableField("NoSuchField", "Subscriber Key"); });
assert("two-arg with an UNKNOWN field returns \"Error\"", badFieldTwo, "Error");
assert("the mapping survived the unknown-field two-arg call", sendableField(KEY), "SubKey");

/* 4. DEV — the page over-claims "Error" for a single-arg call and for a bad
 *    subscriber attribute; runtime returns "OK" and APPLIES the field when
 *    the DE field is valid. Assert the ACTUAL behaviour, stating the page's
 *    claim inline. (QUEUED ISSUE.) */
var singleValid = outcomeOf(function () { return de.Fields.UpdateSendableField("DifferentSubKey"); });
assert("DEV single-arg with a KNOWN field returns \"OK\" (page says: single-arg -> \"Error\")", singleValid, "OK");
assert("DEV and the single-arg call APPLIED that field (page implies it is invalid)", sendableField(KEY), "DifferentSubKey");

var badAttrValidField = outcomeOf(function () { return de.Fields.UpdateSendableField("SubKey", "Not A Subscriber Field"); });
assert("DEV bad subscriber attribute on a KNOWN field returns \"OK\" (page says: bad attribute -> \"Error\")", badAttrValidField, "OK");
assert("DEV and it APPLIED the field, defaulting the attribute (page implies it is rejected)", sendableField(KEY), "SubKey");

/* 5. WORKAROUND: the two-argument form returns "OK" AND really rewrites the
 *    sendable field (asserted against this throw-away fixture only). */
var realChange = outcomeOf(function () { return de.Fields.UpdateSendableField("DifferentSubKey", "Subscriber Key"); });
assert("workaround: a proper two-argument call returns \"OK\"", realChange, "OK");
assert("workaround: and the sendable field really changed to DifferentSubKey", sendableField(KEY), "DifferentSubKey");

/* Cleanup. */
assert("cleanup: the probe data extension is removed", outcomeOf(function () { return DataExtension.Init(KEY).Remove(); }), "OK");
assert("cleanup: no probe data extension is left behind", "" + countDE(KEY), "0");
</script>


List.Subscribers.Retrieve EmailAddress Filter Returns Empty

Severity: Medium — filter looks valid but always misses

<ListInstance>.Subscribers.Retrieve(filter) accepts a WSProxy-style filter. Filtering on SubscriberKey returns the membership row, but the same filter with Property: "EmailAddress" returns an empty array even when that email is on the list.

Platform.Load("core", "1.1.5");
var list = List.Init("myList");

// ❌ always length 0, even when the subscriber is on the list
var byEmail = list.Subscribers.Retrieve({
    Property: "EmailAddress",
    SimpleOperator: "equals",
    Value: "person@example.com"
});

// ✅ filter on SubscriberKey
var byKey = list.Subscribers.Retrieve({
    Property: "SubscriberKey",
    SimpleOperator: "equals",
    Value: "person@example.com"
});
Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: List.Subscribers.Retrieve EmailAddress Filter Returns Empty
 *
 * Known Bug (Severity Medium): a filter that looks valid but always misses.
 * Read-only against a throw-away list + subscriber this script creates.
 * Proves:
 *   1. <ListInstance>.Subscribers.Retrieve(filter) accepts a WSProxy-style
 *      filter object — it is a function on the Init instance.
 *   2. BUG: filtering on Property "EmailAddress" returns an EMPTY array even
 *      though the subscriber IS on the list (expected: one membership row).
 *      Asserted as length 0 AND the [] shape.
 *   3. WORKAROUND: filtering on Property "SubscriberKey" returns the row —
 *      length 1, and the row's EmailAddress / SubscriberKey match the fixture.
 *   4. Control: an unfiltered Retrieve() returns the membership row too, so
 *      the subscriber genuinely is on the list — the EmailAddress miss is a
 *      filter bug, not an absent membership.
 *
 * FIXTURE (email === SubscriberKey so the same value drives both filters):
 * throw-away list ssjs-guide-ts-kb-listsub-email + gmail subscriber, orphan-
 * cleaned on entry and Removed at the end. No production list/subscriber is
 * mutated. The subscriber is deterministically added, so the workaround's
 * "returns rows" claim is a real assertion (not a non-assertion).
 *
 * NOT ASSERTABLE: none — the fixture guarantees a known subscriber on a known
 * list, so every claim is deterministic.
 *
 * EXPECTED OUTPUT: every line starts with PASS.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function nukeSub(sk) { try { Subscriber.Init(sk).Remove(); } catch (e0) {} }

var LIST_KEY = "ssjs-guide-ts-kb-listsub-email";
var EMAIL = "ssjs.guide.ts.kb.listsub.email@gmail.com";

/* Orphan cleanup, then create the fixture. */
List.Init(LIST_KEY).Remove();
nukeSub(EMAIL);
List.Add({ CustomerKey: LIST_KEY, Name: "SSJS Guide TS KB ListSub Email", Type: "Public" });
var list = List.Init(LIST_KEY);
Subscriber.Add({ EmailAddress: EMAIL, SubscriberKey: EMAIL });
list.Subscribers.Add({ EmailAddress: EMAIL, SubscriberKey: EMAIL });

assert("typeof Subscribers.Retrieve is function", typeof list.Subscribers.Retrieve, "function");

/* Control: the subscriber genuinely is on the list. */
var all = list.Subscribers.Retrieve();
assert("control: unfiltered Retrieve length is 1 (subscriber is on the list)", "" + all.length, "1");
assert("control: unfiltered row SubscriberKey matches the fixture", "" + all[0].SubscriberKey, EMAIL);

/* BUG: EmailAddress filter silently matches nothing. */
var byEmail = list.Subscribers.Retrieve({ Property: "EmailAddress", SimpleOperator: "equals", Value: EMAIL });
assert("BUG EmailAddress filter length is 0 (expected 1 — the email IS on the list)", "" + byEmail.length, "0");
assert("BUG EmailAddress filter Stringify is [] (expected the membership row)", "" + Stringify(byEmail), "[]");

/* WORKAROUND: SubscriberKey filter returns the row. */
var byKey = list.Subscribers.Retrieve({ Property: "SubscriberKey", SimpleOperator: "equals", Value: EMAIL });
assert("workaround SubscriberKey filter length is 1 (returns the row)", "" + byKey.length, "1");
assert("workaround SubscriberKey filter EmailAddress matches", "" + byKey[0].EmailAddress, EMAIL);
assert("workaround SubscriberKey filter SubscriberKey matches", "" + byKey[0].SubscriberKey, EMAIL);

/* Cleanup. */
try { list.Subscribers.Unsubscribe(EMAIL); } catch (e1) {}
nukeSub(EMAIL);
assert("cleanup: fixture list removed", "" + List.Init(LIST_KEY).Remove(), "OK");
</script>


List.Subscribers.Update String Form Fails When Keys Differ

Severity: Medium — documented string form silently fails

<ListInstance>.Subscribers.Update(emailAddress, status) accepts a bare email string or { EmailAddress, SubscriberKey }. When SubscriberKey differs from EmailAddress, the string form returns "Error" and leaves Status unchanged. The object form succeeds.

Platform.Load("core", "1.1.5");
var list = List.Init("myList");

// ❌ returns "Error" when SubscriberKey != EmailAddress
list.Subscribers.Update("person@example.com", "Unsubscribed");

// ✅ pass both identifiers
list.Subscribers.Update(
    { EmailAddress: "person@example.com", SubscriberKey: "custom-key" },
    "Unsubscribed"
);
Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: List.Subscribers.Update String Form Fails When Keys Differ
 *
 * CloudPage GET context. The page documents that
 * <ListInstance>.Subscribers.Update(emailAddress, status) accepts a bare
 * email string OR { EmailAddress, SubscriberKey }, and that when
 * SubscriberKey differs from EmailAddress the STRING form returns "Error"
 * and leaves Status unchanged, while the OBJECT form succeeds.
 *
 * Proves:
 *   1. BUG: string form Update(email, "Unsubscribed") returns "Error" when
 *      SubscriberKey !== EmailAddress.
 *   2. BUG: Status is left UNCHANGED (still "Active") after the failed
 *      string-form Update.
 *   3. WORKAROUND: object form Update({ EmailAddress, SubscriberKey },
 *      "Unsubscribed") returns "OK".
 *   4. WORKAROUND: Status is "Unsubscribed" after the object-form Update.
 *
 * FIXTURE: throw-away list ssjs-guide-ts-kb-updstr + a throw-away subscriber
 * whose SubscriberKey differs from its EmailAddress. Orphan-cleaned on entry
 * and fully removed at the end (subscriber removed from the list, list
 * Removed, list re-Init.Remove read back as "OK"). No production list or
 * subscriber is mutated. Both identifiers are deterministically created, so
 * every claim is a real assertion.
 *
 * NOT ASSERTABLE: none — the fixture guarantees a known subscriber whose key
 * differs from its email on a known list, so every claim is deterministic.
 *
 * EXPECTED OUTPUT: every line starts with PASS.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
function nukeSub(sk) { try { Subscriber.Init(sk).Remove(); } catch (e0) {} }

var LIST_KEY = "ssjs-guide-ts-kb-updstr";
var EMAIL = "ssjs.guide.ts.kb.updstr@gmail.com";
var SK = "ssjs-guide-ts-kb-updstr-key";

/* Orphan cleanup from any prior aborted run, then build the fixture. */
List.Init(LIST_KEY).Remove();
nukeSub(SK);
List.Add({ CustomerKey: LIST_KEY, Name: "SSJS Guide TS KB UpdStr", Type: "Public" });
var list = List.Init(LIST_KEY);
Subscriber.Add({ EmailAddress: EMAIL, SubscriberKey: SK });
list.Subscribers.Add({ EmailAddress: EMAIL, SubscriberKey: SK });

/* BUG: string form returns "Error" when SubscriberKey !== EmailAddress. */
assert("BUG string Update(email, Unsubscribed) returns \"Error\" when SK!=email (docs: string form should work)", "" + list.Subscribers.Update(EMAIL, "Unsubscribed"), "Error");
var r1 = list.Subscribers.Retrieve({ Property: "SubscriberKey", SimpleOperator: "equals", Value: SK });
assert("BUG Status unchanged (still Active) after failed string Update", "" + r1[0].Status, "Active");

/* WORKAROUND: object form (both identifiers) succeeds. */
assert("workaround object Update({EmailAddress,SubscriberKey}, Unsubscribed) returns \"OK\"", "" + list.Subscribers.Update({ EmailAddress: EMAIL, SubscriberKey: SK }, "Unsubscribed"), "OK");
var r2 = list.Subscribers.Retrieve({ Property: "SubscriberKey", SimpleOperator: "equals", Value: SK });
assert("workaround Status is Unsubscribed after object Update", "" + r2[0].Status, "Unsubscribed");

/* Cleanup: remove the subscriber from the list, delete the subscriber and the list, read back. */
try { list.Subscribers.Unsubscribe(EMAIL); } catch (e1) {}
nukeSub(SK);
assert("cleanup: fixture list removed", "" + List.Init(LIST_KEY).Remove(), "OK");
</script>

Script.Util.HttpGet Returns No Response Metadata

Severity: Medium — documented response properties are never populated

Script.Util.HttpGet returns the same HttpResponseInstance shape as Script.Util.HttpRequest, but contentType and encoding always come back empty and a for..in over headers yields no real entries. The identical request through Script.Util.HttpRequest returns all of them. statusCode, returnStatus and content are unaffected.

// ❌ HttpGet — metadata is empty, header enumeration yields nothing
var get = new Script.Util.HttpGet("https://example.com/data.json").send();
Write(get.contentType); // ""
for (var a in get.headers) { Write(a); } // only the synthetic "[prototype, ]" entry

// ✅ HttpRequest — same URL, full metadata and headers
var req = new Script.Util.HttpRequest("https://example.com/data.json");
req.method = "GET";
var resp = req.send();
Write(resp.contentType); // "application/json; charset=utf-8"
for (var b in resp.headers) { Write(b); } // "[Content-Type, application/json; charset=utf-8]", ...
Show test script
<script runat="server">
Platform.Load("core", "1.1.5");

/*
 * Chapter: Script.Util.HttpGet Returns No Response Metadata
 *
 * Script.Util.HttpGet returns the same HttpResponseInstance shape as
 * Script.Util.HttpRequest, but three response fields are always empty on the
 * HttpGet path even though the identical request through HttpRequest populates
 * them. statusCode, returnStatus and content are unaffected on both paths.
 *
 * The comparison uses ONE stable public JSON endpoint fetched in a SINGLE
 * request for both call forms, matching the proven db.mjs evidence for
 * Script.Util.HttpGet (checklist row: "HttpResponseInstance is equal ...",
 * result disproven — HttpGet contentType/encoding empty, for..in yields 0 real
 * header entries; HttpRequest same URL returns contentType and real headers).
 *
 * Proves:
 *   1. Both HttpGet.send() and HttpRequest.send() reach the endpoint
 *      (statusCode 200 on both) — the metadata gap is NOT a failed request.
 *   2. Both return non-empty content (content is unaffected).
 *   3. BUG HttpGet contentType is EMPTY (doc/HttpRequest: a real MIME type).
 *   4. BUG HttpGet encoding is EMPTY (doc/HttpRequest: a real encoding).
 *   5. BUG a for..in over HttpGet headers yields 0 REAL header entries — only
 *      the synthetic "[prototype, ]" entry (doc/HttpRequest: real "[Name, Value]"
 *      entries).
 *   6. The identical request via HttpRequest returns a NON-EMPTY contentType.
 *   7. The identical request via HttpRequest yields at least one REAL header
 *      entry via for..in (> 0).
 *
 * The assertions test the SHAPE (HttpGet empty vs HttpRequest non-empty), not
 * specific header VALUES or an exact contentType string, so they stay stable
 * across environments. contentType/encoding/headers are .NET-null-backed CLR
 * properties: normalize with ("" + value) (never String(value), which throws
 * on the null-backed CLR properties).
 *
 * NON-ASSERTION: if the external endpoint is unreachable from the SFMC egress
 * at run time, statusCode will not be 200 and the metadata comparison cannot
 * be made — that is an environmental network failure, not a refutation of the
 * bug. Two stable JSON endpoints were tried before settling on the one used
 * here.
 *
 * EXPECTED OUTPUT: every line starts with PASS. A FAIL means the runtime no
 * longer matches the documented claim and the page must be revised.
 */

function assert(id, actual, expected) {
    Platform.Response.Write((actual === expected ? "PASS " : "FAIL ") + id + " -> [" + actual + "]\n");
}
/* Count REAL header entries: the synthetic "[prototype, ]" entry is skipped. */
function realHeaderCount(headers) {
    var n = 0;
    for (var k in headers) {
        var key = "" + k;
        if (key.indexOf("prototype") !== -1) { continue; }
        n++;
    }
    return n;
}

var URL = "https://ssjs.guide/site-index.json";

/* HttpGet path. */
var get = new Script.Util.HttpGet(URL).send();
var getStatus = parseInt("" + get.statusCode, 10);
assert("HttpGet.send() reached the endpoint (statusCode 200)", getStatus, 200);
assert("HttpGet content is non-empty", ("" + get.content).length > 0 ? "true" : "false", "true");
assert("BUG HttpGet contentType is empty (HttpRequest/docs: a real MIME type)", "" + get.contentType, "");
assert("BUG HttpGet encoding is empty (HttpRequest/docs: a real encoding)", "" + get.encoding, "");
assert("BUG HttpGet for..in over headers yields 0 real entries (HttpRequest/docs: real [Name, Value] entries)", realHeaderCount(get.headers), 0);

/* HttpRequest path — identical URL, same request. */
var req = new Script.Util.HttpRequest(URL);
req.method = "GET";
var resp = req.send();
var reqStatus = parseInt("" + resp.statusCode, 10);
assert("HttpRequest.send() reached the endpoint (statusCode 200)", reqStatus, 200);
assert("HttpRequest content is non-empty", ("" + resp.content).length > 0 ? "true" : "false", "true");
assert("HttpRequest contentType is NON-empty (unlike HttpGet)", ("" + resp.contentType).length > 0 ? "true" : "false", "true");
assert("HttpRequest for..in over headers yields at least one real entry (unlike HttpGet)", realHeaderCount(resp.headers) > 0 ? "true" : "false", "true");
</script>