Execution semantics, constraints, and security

This page summarizes how a Script behaves at runtime (order, transactions, errors, locking), what static constraints it is subject to at save time, and its security model. For syntax, see the Statement catalog and value expressions; for practical combinations, see the cookbook.

Execution order

  • statements run sequentially from top to bottom. When execution reaches a Return, it stops at that point.
  • Execution happens inline, on the path that handles the calling request. The response to the call is the execution result (for the shape of the response, see Request and response in the Script overview), and the time allowed for a single execution is covered in Time budget below.

Execution semantics

Guard (preconditions)

There is no dedicated guard statement. You express one with If and then:[Return]. When the condition is violated, it returns a result and does not run the subsequent statements (a Script with no guard is of course also possible).

{ "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
  "then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "statusCode": 402 } ] }

No transactions and best-effort compensation

A Script is not a transaction. On failure, the engine attempts to compensate for the work done so far and returns the cause of the error, but with the following limits (accepted as a design trade-off).

  • Undoing a delete creates a new sys.id, so references that pointed to it break.
  • External effects (Http) are irreversible (a call that has already gone out, and its charges, cannot be undone).
  • The compensation may not run at all, leaving an uncompensated state.

If you need true atomicity, write the compensation into the Script yourself, or put irreversible operations (such as external calls) last. The most dangerous ordering is the kind that "chains through but cannot roll back, yet looks safe."

Optimistic locking

You narrow update/patch contention with the version on ResourceUpdate and ResourcePatch. If you supply version (a value expression, Int), the update proceeds only when it matches the target's current sys.version; a mismatch aborts with a version-conflict error (which you can handle locally with Try/catch). If you omit it, it is last-write-wins with no check. Typically you read first with ResourceRead or ResourceFind and pass that sys.version (see Optimistic-locking CAS in the cookbook).

Writes go to origin

Writes always land in origin (draft), and exposure on delivery (CDA/ACDA) is controlled by publish (the publish on ResourceCreate/ResourceUpdate/ResourcePatch, or ResourcePublish/ResourceUnpublish).

What counts as a failure

  • A real failure is a statement runtime error: an Http final status of 400 or higher (4xx·5xx; not a failure when ignoreStatusCode: true) or a timeout, a response body exceeding 10MiB, or a failed resource operation (no such target, a version conflict, an unsupported operation, and so on). For such failures the engine aborts and compensates, and you can handle them locally with Try/catch/finally.
  • A Return is not an error but a normal early exit. It is not a catch target (there is no user-throw concept).
  • Inside a catch, you reference { message } via /error. It does not carry which statement failed.

Server-side aggregation: counts only

Counts are done on the server by ResourceCount. It does not read the items, so it is not subject to the processed-item ceiling.

Sum and group-by have no dedicated server operation. You have to compute such an aggregation yourself, by iterating with ResourceForEach and using SetVar and JsonLogic, so it is bound by the processed-item ceiling (unsuitable for aggregating millions of records). When all you need is a count, use ResourceCount instead of iterating.

No waiting or delay

A Script has no Delay statement. A Script runs once and is done, and does not wait or poll internally for an external job to finish.

Static constraints (validated at save time)

The following are checked when a Script is saved (on create/update). If any is violated, the save is rejected (this fails at authoring time, not at runtime). Which violation is rejected with which code is covered in Errors.

ConstraintValue
Maximum external calls (Http, EmailSend) per definitionPer plan (see Pricing)
ResourceForEach maximum total items processed (without a declared limit, iterates up to this value; fails if it is hit with matches remaining)10,000
Maximum SetVar per definition (nested included)10
Maximum Cache per definition (nested included, summed regardless of action)5. Beyond that, the save is rejected
A Cache inside a Loop or ResourceForEach blockSave rejected
Cache.keyLiteral only, at most 128 characters. A value expression means the save is rejected
Cache.ttlBetween 1 and 30 seconds; 5 when omitted. Outside that range, the save is rejected
Maximum total statements per definition (nested included)Per plan (see Pricing)
Http.retry cap2
Regex.pattern length128 characters
A statement that mutates a ServiceUserSave rejected. Only the three read statements accept this resource
createdBy: ":self" in a where when anonymousCallEnabled is trueSave rejected

The fixed limits in the table above are values the platform sets, so they are the same regardless of plan. The maximum total statements and maximum external calls per definition, by contrast, are per-plan limits. These two are not validation errors but plan limits, so exceeding them causes the save/update to be rejected as a plan-limit overage (the same definition is allowed on a higher plan) and is lifted by upgrading. The per-plan numbers are in Pricing.

Media file ingestion, unlike external calls such as Http and EmailSend, does not count toward the per-definition external-call limit.

ResourceForEach is a composite statement that owns children, so it itself does not count toward the number of external calls. The external-call statements inside onEach (Http, EmailSend) are what count (statically counted as 1, but actually executed per item during iteration). onEach can hold external calls or Media file ingestion, and the same is true of a Loop body. How many laps an iteration actually runs does not enter this count; it enters the time budget below as a multiplication instead.

Value length limits (runtime)

The signing and text-processing statements, and Cache, cap the size of the value they handle. The cap applies to what the expression resolves to, not to the length of the expression (the sixteen characters of { /rawPayload } can point at tens of kilobytes), which is why it is checked while running rather than at save time.

TargetCapOn exceeding
value of Signature65,536 charactersThat statement fails (status 422)
value of Hash128 charactersThat statement fails (status 422)
value of Regex10,240 characters (10KiB)That statement fails (status 400)
value of Cache10,240 bytes (10KiB)That statement fails (status 422)
  • All four behave like any other runtime failure, so Try/catch can handle them locally.
  • Signature's cap is sized for the body sizes real providers send (a payment event runs to a few KB, an order webhook to tens of KB). Hash is far narrower because it is the place where a handful of concatenated fields go.
  • The 128 characters of Regex.pattern is the save-time check in Static constraints above. That length is not a safety device ((a+)+$ is dangerous in six characters). What bounds a runaway match is the literal-only rule for patterns plus the time budget below; what a length can honestly promise is a size a person can read and review.

Time budget (runtime)

The time allowed for a single execution is set by one formula: min(30s + the sum of the times the statements declare, 180s).

  • The budget is computed from that Script. Only the times the definition declares are added to the base budget. The one declared time there is the timeoutMs of Http and EmailSend. Http spends its own timeoutMs again on every retry, so it counts as timeoutMs × (1 + retry), and EmailSend does not retry, so it counts once. When timeoutMs is not written, the default is counted (30 seconds for Http, 10 seconds for EmailSend).
  • Work with no declared time comes out of the 30-second base budget. Resource reads and writes, Media file ingestion, and whatever an iteration does inside it belong here. That is why the base budget is a real allowance rather than a formality.
  • How the times combine follows the structure of the statements. Statements laid out in sequence add up, an If takes the larger of its two branches, and a Parallel takes the largest of its branches. A Loop multiplies its body by the number of laps (maxIterations, 10,000 when not declared), and a ResourceForEach multiplies its onEach by the number of items processed (limit, 10,000 when not declared).
  • An iteration with no external call declares zero time. The 30-second base budget therefore becomes its effective limit, and that is exactly where a Script containing an iteration gets caught in practice.
  • The 180-second ceiling does not block a save; it cuts execution off. Even when the computed result exceeds the ceiling, that Script is saved and runs, and it stops there once it reaches 180 seconds.

Per-plan count limits

For Script, the number per Organization is limited by plan.

PlanScript count
Free10
Basic30
Pro100
EnterpriseUnlimited

Separately from this, the number of statements and the number of external calls (Http, EmailSend) a single Script definition can contain are also limited by plan. When you save or update a definition, exceeding that plan's limit rejects it; for the concrete numbers, see Pricing.

When the limit is reached, creating a new Script is rejected.

Security model

Secret headers

An item in Http.headers with secret:true is CMA (administrator) only: it is not exposed to the end user (ServiceUser) and is decrypted only immediately before transmission. Put secrets such as an LLM API key here (even when packed into an App Bundle, the secret value is masked and never leaves the source Space).

The secret of Signature does not get this treatment inside the Space. It is not encrypted; it stays in the definition exactly as written, so anyone in a role that can read that Script can see the value. Members (ServiceUser) cannot read a Script definition (reading and authoring are CMA-only, and ACMA has no Script API). For a Script that holds a verification key, keeping the set of roles that can read it narrow is the safer arrangement.

Leaving the Space is a different matter. When that Script is packed into an App Bundle, the secret of Signature is masked and never leaves the source Space. For Http.headers, what gets hidden is the items carrying the secret flag plus the Authorization header, whereas the secret of Signature is hidden unconditionally, because that field is itself the signing key. A Signature nested inside an If, a Loop, or a Try is hidden along with it.

Execution identity and authorization

  • Execution identity: during execution, every resource operation is performed under the identity of the user who called /execute. The createdBy/updatedBy of any resource that is created or updated is the caller, and a createdBy: ":self" scope also resolves against the caller. Anonymous calls are the exception. An execution that arrived through /execute/anonymous has no caller, so both resolve against the author instead (Anonymous calls).
  • There are two authorization boundaries, and at runtime the engine does not re-check resource permissions per statement.
    1. At authoring time (save): when a Script is saved, it checks whether the author actually holds the resource and action permissions that its statements use. If even one is missing, the save is rejected. In other words, a Script that contains an unauthorized operation is never saved in the first place. Every statement that selects a resource is subject to this check, whether it is a leaf or the block-owning ResourceForEach. A definition you already saved is checked again when you update it, so once a permission has been revoked you can no longer save an edit to that definition.
      • The member directory (ServiceUser) is checked on the settings axis, not through a permission map. To write resource: "ServiceUser" in one of the three read statements, the author's SpaceRole settings must contain SETTING_SERVICE_LOGIN (or SETTING_ALL); see settings on SpaceRole. The member directory is a Space setting on every other path through the system as well.
      • A statement that mutates a member cannot be saved under any role. There is no path at all for a Script to create, update, or delete a member, so it is rejected as a mis-written statement (400) rather than as missing permission (403). That is to say it is not a gap the author could close by adding a role.
    2. At call time (/execute): only the caller's Script Execute permission is checked. Without it, the result is 403. Once it passes, per-statement resource permissions are not checked again at runtime; execution proceeds. This works like function-execution permission in programming. If you have permission to run the function, the permission for each individual operation inside it is not asked again. The anonymous call path has no such check. There is no caller to check, which is why opening that path amounts to publishing one Script without authentication.
  • Direct-call blocking (directCallEnabled): if a Script's directCallEnabled is false, the /execute direct call itself is rejected. This gate is applied after the Execute permission check has passed, so the call is blocked even when the caller holds the Execute permission. A caller without that permission receives a 403 before reaching this gate. The gate exists only on that endpoint, so a Webhook's linked action (script) and a Scheduler run it as before. The default is true (direct calls allowed).
  • Anonymous calls (anonymousCallEnabled): the default is false. Setting it to true makes that one Script runnable through a dedicated unauthenticated path (/execute/anonymous) as well, and there the execution identity is the author, not the caller. Of the two boundaries above, the call-time check (Execute permission) does not exist on that path, so the real authentication is what the Script does for itself (verifying the signature on what it received). The conditions for turning it on and its save-time rules are covered in Anonymous calls.
  • Ownership scope: createdBy: ":self" in a where filter means "only what the current caller created" (for example, reading only your own wallet). This filter cannot be used in a Script that allows anonymous calls. With no caller it resolves to the author, so its original meaning as an ownership scope does not hold.
  • Delegated permissions (author beware): combining the two boundaries above, running a Script is equivalent to acting with the author's permissions delegated to it. The caller needs only Execute, and the statements inside the Script run exactly within the scope the author was authorized for at save time. As a result, a resource operation the caller could not perform on their own can still happen through the Script. Because the permissions granted to the author are the effective reach of that Script, decide carefully what operations you put in a Script.

Summary checklist

Before saving, verify the following.

  • If you turned on anonymous calls (anonymousCallEnabled), no where carries createdBy: ":self", and a statement that verifies what was received (such as Signature) comes first.
  • The number of external calls (Http, EmailSend) and the total number of statements are within your plan's limits, SetVar is 10 or fewer, and Cache is 5 or fewer.
  • If you used Cache, you wrote key as a literal and did not place it inside a Loop or a ResourceForEach.
  • If you traverse a large set with ResourceForEach, you declared a limit or verified the size can complete.
  • If you included an iteration (Loop, ResourceForEach), you checked that it enters the time budget as a multiplication (with no external call, the 30-second base budget is the limit).
  • Secret values were placed only via secret:true in Http.headers (Signature.secret is not stored encrypted, so you checked which roles can read that Script).
  • The message for signature verification is taken from { /rawPayload }, not from /payload.
  • If a statement reads members (ServiceUser), the author holds SETTING_SERVICE_LOGIN and you included no statement that mutates that resource.
  • Irreversible operations (external calls) are placed as late as possible.
  • If you are worried about update/patch contention, use the version on ResourceUpdate or ResourcePatch.
  • If you want to return a result, you specified Return.value.

Errors

These are the codes that come back when the shape of a definition breaks a static constraint and the save is rejected. The codes for breaking the value expression rules are in the errors of Value expressions, and the codes that come back when you call or delete a Script are in the errors of Script resource and endpoints. For codes that are common to every resource, see common errors.

CodeCondition
WGL400066A single definition holds more than 5 Cache statements.
WGL400068A Cache statement was placed inside a Loop or ResourceForEach block.
WGL400067The key of a Cache statement was written as a { /pointer } reference instead of a literal.
WGL400065The ttl of a Cache statement is outside the allowed range.
WGL400063A Cache statement carries a field that does not belong to its action (ttl on Get, defaultValue on Set).
WGL400060A write statement (ResourceCreate, ResourceUpdate, ResourcePatch, ResourceDelete, and the publish and archive statements) has "ServiceUser" in its resource.
WGL400061A Script that allows anonymous calls (anonymousCallEnabled) has createdBy: ":self" in the where of a read statement.
WGL400023A single definition holds more than 10 SetVar statements.
WGL400026The retry on an Http statement is over the upper bound of 2.
WGL400036A ResourceForEach goes over the upper bound on the number of items it can process.
WGL429005The total number of statements a single definition holds is over the plan limit.
WGL429006The number of external calls (Http, EmailSend) a single definition holds is over the plan limit.
WGL403015The author does not hold the permissions for the resources and actions that the statements in the definition operate on. Even when the author holds the permission, a contentType, createdBy, or tag filter attached to that allowance gets the save rejected. The allowance has to be an unconditional one. The single exception is Content Create, where an allowance scoped to a contentType also counts and is checked against the contentType written in the statement (Media Create has no such exception). A read statement with "ServiceUser" in its resource while the author lacks SETTING_SERVICE_LOGIN also falls under this code.