[object Object]

Cross-scope script include calls fail with messages so generic they could mean anything. “Cannot find script include,” “access denied,” or worse, a silent return of null that propagates four functions deep before anyone notices. The cause is almost always a boundary you forgot you crossed. Here is the debug protocol.

The four boundaries in play

When code in scope A calls a script include in scope B, four checks happen in sequence. Any one of them can fail and produce a confusing error:

  1. Caller-side accessibility — scope A must have a cross-scope-access record allowing it to read script includes from scope B.
  2. Script Include accessibility — the script include’s “Accessible from” property must permit cross-scope calls.
  3. API record for the function — for scoped apps, each callable script include needs an accompanying API record granting external scopes the right to invoke it.
  4. Inside-the-function table ACLs — once the call lands, any GlideRecord operation inside the function is checked against the caller’s effective ACLs, not the script include’s home scope.

The error you see depends on which one fails first. The error you see is almost never the layer the actual problem lives in.

The diagnostic script

When a cross-scope call fails, stop guessing and run this:

// Background script — diagnose cross-scope access
function diagnoseCrossScope(callerScope, targetScope, scriptIncludeName, functionName) {
    var out = {};

    // 1. Cross-scope access record from caller to target
    var csa = new GlideRecord('sys_scope_privilege');
    csa.addQuery('source_scope.scope', callerScope);
    csa.addQuery('target_scope.scope', targetScope);
    csa.addQuery('target_name', 'sys_script_include');
    csa.addQuery('target_type', 'Table');
    csa.addQuery('status', 'allowed');
    csa.query();
    out.cross_scope_access_to_si_table = csa.hasNext();

    // 2. Script include itself
    var si = new GlideRecord('sys_script_include');
    si.addQuery('name', scriptIncludeName);
    si.addQuery('sys_scope.scope', targetScope);
    si.query();
    if (si.next()) {
        out.script_include_exists = true;
        out.script_include_accessible_from = si.access.toString();
        out.script_include_active = si.active.toString();
        out.script_include_client_callable = si.client_callable.toString();
    } else {
        out.script_include_exists = false;
    }

    // 3. API record for the specific function
    if (functionName) {
        var api = new GlideRecord('sys_script_include_api');
        api.addQuery('script_include.name', scriptIncludeName);
        api.addQuery('api', functionName);
        api.query();
        out.function_api_record_exists = api.hasNext();
    }

    return out;
}

gs.info(JSON.stringify(diagnoseCrossScope('x_acme_a', 'x_acme_b', 'PaymentUtil', 'calculateFee'), null, 2));

Run this first, every time. Three lines of output and you usually know which boundary failed. The remaining mystery — boundary 4, the inside-function ACL check — needs a different approach because it only manifests at runtime.

Boundary 4: the silent ACL ambush

If boundaries 1, 2, and 3 are clean and the call still returns null, the most likely cause is an ACL inside the function denying the caller from reading or writing the table the function operates on. The call succeeded; the work the function tried to do was blocked by an ACL the caller does not satisfy.

The diagnostic for this case is to instrument the function:

var PaymentUtil = Class.create();
PaymentUtil.prototype = {
    initialize: function() {},

    calculateFee: function(invoiceSysId) {
        gs.info('[PaymentUtil] called by scope: ' + gs.getCurrentApplicationId());
        gs.info('[PaymentUtil] running as user: ' + gs.getUserName());

        var inv = new GlideRecord('x_acme_b_invoice');
        inv.addQuery('sys_id', invoiceSysId);
        inv.query();
        gs.info('[PaymentUtil] query rowCount: ' + inv.getRowCount());
        if (!inv.next()) {
            gs.warn('[PaymentUtil] invoice not found OR ACL denied read');
            return null;
        }
        // ... rest of function
    },

    type: 'PaymentUtil'
};

The single most useful log line is inv.getRowCount(). If it returns zero when you know the record exists, the ACL denied the read. If it returns one and inv.next() still returns false, you have a different problem — usually a domain separation issue.

The “client callable” flag is not what most people think

The client_callable flag on a script include controls whether the script include can be invoked from client-side glide ajax calls. It does not control cross-scope server-side accessibility. People conflate these constantly. If your cross-scope problem is from one server-side scope to another, client_callable is irrelevant. If your problem is a portal widget calling a script include in a different scope, client_callable matters.

Get this distinction wrong and you will spend an hour toggling a flag that has no effect on the actual failure.

The “Accessible from” property

A script include’s access field has three values: “Public”, “Same application scope only”, and “All application scopes”. The names are clearer than they appear:

  • Public — callable from anywhere, including non-scoped code.
  • Same application scope only — invocations from any other scope return null. No error, no log, just null.
  • All application scopes — invocations from other scopes proceed to the cross-scope access record check.

“Same application scope only” is the trap. It produces a silent null that callers misread as “function returned no data.” Always default to “All application scopes” for any script include you intend to be reusable, and gate access through the cross-scope access records — which give you visibility into what is calling what.

The cross-scope access record table

Cross-scope grants live in sys_scope_privilege. The records are flat and tedious — one row per source-scope/target-scope/target-table/target-record combination. The discipline that prevents debugging hell:

  • Grant at the table level, not the record level, unless you have a specific reason. Per-record grants multiply faster than you can audit them.
  • Name the grants. Use the target_name field consistently — actual table names, never aliases.
  • Review the grants quarterly. Grants made for a one-off task that nobody removed are how scopes leak.

Domain separation makes everything worse

If your tenant uses domain separation, every cross-scope call also crosses a domain check. A user in domain X calling a script include that queries table records in domain Y will get an empty result even if every scope-level permission is correct, because the domain filter is applied to the GlideRecord query.

The diagnostic: log the effective domain inside the function and compare it to the domain of the records you expect to find. If they differ, the issue is domain, not scope.

gs.info('[PaymentUtil] effective domain: ' + gs.getSession().getDomain());
gs.info('[PaymentUtil] record domain: ' + inv.sys_domain.toString());

For the broader patterns of scope discipline, see our piece on ACL deny-by-default patterns.

Logging hygiene for scoped apps

Logging in scoped apps writes to syslog with a source of the scope name. Build a saved system log list filtered to your application’s scopes only, sorted descending by time. Pin it. When users report “the integration is broken,” you can confirm or deny in 10 seconds without sifting through cross-scope noise.

List filter:
  Table: syslog
  Source IN (x_acme_a, x_acme_b, x_acme_c)
  Created on >= today - 1 hour
  Order by: created on DESC

Use gs.info for the happy path, gs.warn for recoverable conditions, gs.error for failures the caller will see. Do not log payload contents to syslog — they appear in the platform audit and can leak PII. Log identifiers, log timing, log decisions; do not log data.

UI nudges for cross-scope clarity

When a script include is intended for cross-scope use, set its description to start with “[CROSS-SCOPE]” and document the calling scopes in the description body. The list view shows description on hover; the cue prevents a developer in another scope from “fixing” the script include in a way that breaks its callers. Conventions cost nothing and save hours.

The “works in dev, fails in prod” pattern

This is the classic cross-scope incident report. Dev works; prod fails. The cause almost always:

  • Dev allows cross-scope access by default (or your dev instance has looser settings)
  • Prod enforces stricter cross-scope access
  • The grant records were never captured in an update set

The fix is process, not code. Every cross-scope grant must be created in an update set and travel through your normal deployment path. Manual grants in production are a leading cause of “works in dev, fails in prod” cycles. The discipline to enforce: prod admins cannot create cross-scope grants directly; the grant must arrive via update set.

Application files and the lifecycle question

When you delete a script include from one scope, callers in other scopes still hold their cross-scope grants pointing at the deleted record. The grants become orphans. They do not break anything — they simply allow access to nothing — but they accumulate.

Build a periodic cleanup that finds orphan grants:

// Background script — find orphan cross-scope grants
var p = new GlideRecord('sys_scope_privilege');
p.addQuery('target_type', 'Record');
p.query();
while (p.next()) {
    var tgt = new GlideRecord(p.target_name);
    if (!tgt.get(p.target_id)) {
        gs.warn('Orphan grant: ' + p.sys_id + ' targets missing ' + p.target_name + '/' + p.target_id);
    }
}

Quarterly cleanup is enough. The grants do not actively cause problems; they just clutter the audit surface and complicate scope-boundary review.

Tradeoffs to name out loud

Strict scope discipline costs flexibility. A developer who wants to “just read one record” from another scope has to file a grant, get it approved, and add the grant to their update set. This friction is the point. Without it, scope boundaries dissolve and you lose the modularity that scoped apps were meant to provide.

Do not “Public” all your script includes to make the friction go away. That converts the problem from “permission denied” to “I have no idea who is calling this function.” The first is solvable; the second is not.

Key takeaways

  • Four boundaries: caller-side access, script include access, function API record, inside-function ACLs. Diagnose each one in order.
  • Run the diagnostic script first, every time. The error message will not tell you which boundary failed.
  • A silent null return from a cross-scope call is almost always “Same application scope only” misconfigured as the access level.
  • client_callable controls client-side AJAX, not server-side cross-scope. Stop touching it when debugging server-to-server.
  • Domain separation is the fifth check most teams forget. Log the effective domain inside the function.
[object Object]
Share