# 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](/api/reference/script/statements.md) and [value expressions](/api/reference/script/value-expressions.md); for practical combinations, see the [cookbook](/api/reference/script/cookbook.md).

## Execution order {#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](/api/reference/script.md#request-response-envelope)), and the time allowed for a single execution is covered in [Time budget](#time-budget) below.

## Execution semantics {#execution-semantics}

### Guard (preconditions) {#guard}

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).

```jsonc
{ "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 {#no-transaction}

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 {#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](/api/reference/script/cookbook.md#optimistic-cas)).

### Writes go to origin {#origin-writes}

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 {#error-handling}

- **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 {#no-server-aggregation}

Counts are done on the server by [`ResourceCount`](/api/reference/script/statements.md#resource-count). 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](#static-constraints)** (unsuitable for aggregating millions of records). When all you need is a count, use `ResourceCount` instead of iterating.

### No waiting or delay {#no-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) {#static-constraints}

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](#errors).

| Constraint | Value |
|---|---|
| **Maximum external calls** (`Http`, `EmailSend`) per definition | Per plan (see [Pricing](/pricing/pricing.md)) |
| `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` block | Save rejected |
| `Cache.key` | Literal only, at most 128 characters. A value expression means the save is rejected |
| `Cache.ttl` | Between 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](/pricing/pricing.md)) |
| `Http.retry` cap | 2 |
| `Regex.pattern` length | 128 characters |
| A statement that **mutates** a *ServiceUser* | Save rejected. Only the three read statements accept this resource |
| `createdBy: ":self"` in a `where` when `anonymousCallEnabled` is `true` | Save 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](/pricing/pricing.md).

> *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](#time-budget) below as a multiplication instead.

### Value length limits (runtime) {#value-length-limits}

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.

| Target | Cap | On exceeding |
|---|---|---|
| `value` of [`Signature`](/api/reference/script/statements.md#signature) | 65,536 characters | That statement fails (status 422) |
| `value` of [`Hash`](/api/reference/script/statements.md#hash) | 128 characters | That statement fails (status 422) |
| `value` of [`Regex`](/api/reference/script/statements.md#regex) | 10,240 characters (10KiB) | That statement fails (status 400) |
| `value` of [`Cache`](/api/reference/script/statements.md#cache) | 10,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](#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) {#time-budget}

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 {#plan-limits}

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

| Plan | Script count |
|---|---|
| Free | 10 |
| Basic | 30 |
| Pro | 100 |
| Enterprise | Unlimited |

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](/pricing/pricing.md).

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

## Security model {#security-model}

### Secret headers {#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`](/api/reference/script/statements.md#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-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](/api/reference/script/endpoints.md#anonymous-call)).
- **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](/api/reference/cma/space-role.md#settings-space-settings-access). 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](/api/reference/script/endpoints.md#anonymous-call).
- **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 {#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](#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 {#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](/api/reference/script/value-expressions.md#errors), and the codes that come back when you call or delete a *Script* are in [the errors of Script resource and endpoints](/api/reference/script/endpoints.md#errors). For codes that are common to every resource, see [common errors](/api/reference/common/errors.md).

| Code | Condition |
|---|---|
| `WGL400066` | A single definition holds more than 5 `Cache` statements. |
| `WGL400068` | A `Cache` statement was placed inside a `Loop` or `ResourceForEach` block. |
| `WGL400067` | The `key` of a `Cache` statement was written as a `{ /pointer }` reference instead of a literal. |
| `WGL400065` | The `ttl` of a `Cache` statement is outside the allowed range. |
| `WGL400063` | A `Cache` statement carries a field that does not belong to its `action` (`ttl` on `Get`, `defaultValue` on `Set`). |
| `WGL400060` | A write statement (`ResourceCreate`, `ResourceUpdate`, `ResourcePatch`, `ResourceDelete`, and the publish and archive statements) has `"ServiceUser"` in its `resource`. |
| `WGL400061` | A *Script* that allows anonymous calls (`anonymousCallEnabled`) has `createdBy: ":self"` in the `where` of a read statement. |
| `WGL400023` | A single definition holds more than 10 `SetVar` statements. |
| `WGL400026` | The `retry` on an `Http` statement is over the upper bound of 2. |
| `WGL400036` | A `ResourceForEach` goes over the upper bound on the number of items it can process. |
| `WGL429005` | The total number of statements a single definition holds is over the plan limit. |
| `WGL429006` | The number of external calls (`Http`, `EmailSend`) a single definition holds is over the plan limit. |
| `WGL403015` | The 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. |

## Related documents {#related-documents}

- [Value expressions](/api/reference/script/value-expressions.md): value and condition rules.
- [Statement catalog](/api/reference/script/statements.md): the fields and results of each statement.
- [Cookbook](/api/reference/script/cookbook.md): a collection of complete examples.
- [Script resources and endpoints](/api/reference/script/endpoints.md): the `Script` resource structure and HTTP endpoints such as `/execute`.
- [Script overview](/api/reference/script.md): the top-level structure and the time allowed for a single execution.
