# AI sandbox — the safety contract

Alvera accepts AI-authored fragments from external tools (Claude
Code, Cursor, ChatGPT, in-tenant AI agents, direct API clients)
and runs them against tenant data. **None of those fragments
ever reach arbitrary code execution.** Every author surface — a
Liquid template body, a SQL fragment, an agent prompt — passes
through a three-layer sandbox that bounds blast radius.

This page names the three layers, the contract each one
enforces, and where each one's wire-level details live in the
rest of the corpus. It's the consumer-side landing for "why is
it safe to put my AI-authored body here".

The three layers are independent — any one alone would bound
the worst case — and they compose into defense-in-depth.

## Layer 1 — Compliance gates (who sees what)

Two consumer-set fields decide whether a request reaches the raw
lake or only the tokenized projection. Both must authorize raw reach
for real values to flow:

```
   session role           AI agent data_access     →  outcome
   ──────────────         ───────────────────         ───────
   member or admin        raw                         raw reachable
   member or admin        tokenized                   tokenized only
   member or admin        (no agent on path)          raw reachable
   researcher             any value                   tokenized only
```

- **Session role** is pinned on the membership at invitation
  time (vocabulary A — `'member' | 'researcher' | 'admin'`; the
  resulting session carries the corresponding role-record name
  — see `account_management.md` §3).
- **AI agent `data_access`** is `'raw' | 'tokenized'`, set at agent
  creation. **The platform does not enforce it as immutable** — one
  `changeset/2` serves create and update, and nothing refuses a PUT
  that flips the tier, so treat it as immutable by your own
  change-control rather than by the platform's (see `ai_agents.md`
  §2). An
  agent is **never pointed at the redacted lake**: that copy is for a
  person to glance at, and its values are unjoinable by design.

  **There is no default — the field is required.** Omitting it is a
  422 at create, not a quiet fall-back to the safer tier. That is a
  deliberate change from an older design that defaulted to the
  tokenized side: a default meant an author could get a compliance
  tier without ever deciding on one, and the reviewer reading the
  spec could not tell an intentional choice from an omission. Being
  made to say it is the point.

A `researcher` session can never reach real values regardless of how
the agent is configured. A `'tokenized'` agent can never reach them
regardless of session role. Tenant isolation (every row carries a
tenant id; queries filter on the session's tenant) AND-composes with
both, so a request only sees the slice that *all three* allow.

## Layer 2 — Liquid sandbox (what can execute)

Every consumer-authored template body — interop contract
`template_config.body`, AI agent `prompt_config.body`, workflow
filter / decision / action bodies, action `trigger_template`,
data activation client `row_filter`, tool call `body` templates
— is a Liquid template. Not arbitrary code. Liquid is the trust
boundary between consumer fragments and the platform runtime.

### What Liquid bodies CANNOT do

- Invoke runtime code. There is no `{% eval %}` or equivalent.
- Read the filesystem, open sockets, or reach the network.
- Mutate state outside the render's own local variable scope.
- Call any platform capability beyond the explicit custom-filter
  allowlist (below).

Liquid's stock tag set (`{% if %}`, `{% for %}`, `{% assign %}`,
`{% unless %}`, `{% capture %}`, etc.) is preserved as-is.
`{% capture %}` writes to a Liquid-scope variable; it is not a
runtime eval escape hatch.

### The 13-filter custom allowlist

Beyond stock Liquid, the platform exposes exactly **thirteen**
custom filters. Adding new ones requires a platform release —
consumers cannot extend the set:

```
   e164          Phone string → E.164 format for SMS.
                 Example:  {{ patient.phone | e164 }}  →  +15555550201

   age           Years from a birth date.
                 Example:  {{ patient.birth_date | age }}  →  66

   date          Format a datetime, optional add/subtract.
                 Example:  {{ start | date: '%Y-%m-%d', 'subtract', '24 hours' }}
                           →  2026-03-14

   to_json       Encode a map / array to a JSON string.
                 Example:  {{ event_dataset | to_json }}  →  {"name":"…"}

   json_escape   Escape a value for safe interpolation inside a
                 JSON string literal (quotes, backslashes,
                 control chars).
                 Example:  "name": "{{ p.name | json_escape }}"
                           →  "name": "O\"Brien"

   now           Current UTC datetime, ISO 8601.
                 Example:  {{ '' | now }}  →  2026-03-15T14:30:00Z

   minutes_ago   Unix ms N minutes before a unix-ms input. Bounds
                 the start of an ActionStatusUpdater poll window.
                 `now_msec` is the injected unix-ms variable (the
                 bare `now` is a DateTime — feeding it here raises).
                 Example:  {{ now_msec | minutes_ago: 45 }}  →  1773585000000

   hours_ago     Unix ms N hours before a unix-ms input.
                 Example:  {{ now_msec | hours_ago: 2 }}  →  1773578400000

   uuid          Generate a new v4 UUID.
                 Example:  {{ '' | uuid }}  →  a1b2c3d4-…

   parse_date    Parse a date string with a format spec.
                 Example:  {{ '31/12/2023' | parse_date: '{D}/{0M}/{YY}' }}
                           →  2023-12-31

   convert_time  Convert a time between formats.
                 Example:  {{ '10:00 pm' | convert_time: '{h12}:{m} {am}' }}
                           →  22:00:00

   random        Random integer in a range.
                 Example:  {{ 10 | random }}  →  0..10

   tz_offset     Current UTC offset for an IANA timezone string.
                 Example:  {{ timezone | tz_offset }}  →  -04:00
```

A common trap: `| json` (Shopify / Jekyll spelling) is NOT in
the allowlist. The lookalike falls through to a generic
stringification that produces unparseable output. Always use
`| to_json`. See `workflows.md` gotcha 9 for the diagnostic
trail this trap leaves.

### Write-time and render-time validation

- **Parse on write.** Every body is parsed before storage.
  Syntactically invalid Liquid is rejected with a 422 on the
  body field — bad templates never reach a render site.
- **Schema on render.** When the embed declares an
  `output_schema` (most often a JSON Schema describing the
  expected shape of the rendered JSON), the rendered output is
  validated against the schema before reaching the downstream
  consumer. Shape drift surfaces at the point of production, not
  at the point of use.

## Layer 3 — SQL fragment boundary (what queries can do)

SQL is the second consumer-author surface: WHERE clauses on
dataset searches (`api.datasets.createUserSearch.search_query`,
see `data_activation_clients.md` §6.6). Liquid renders some of
these from templates; others arrive directly as raw SQL
fragments. Either path lands at the same boundary.

### What SQL fragments CAN do

- SELECT against the tables exposed by the dataset search aliases
  (`le` for legal entities, `m` for messages, `al` for action logs
  — see `data_activation_clients.md` §6.6 SQL aliases).
- Filter results into the platform's `search_results`
  accumulator scoped to the materialised search id. This is the
  one write the query path performs, and the platform builds it
  on the consumer's behalf — the consumer authors the WHERE
  clause, not the INSERT.

### What SQL fragments CANNOT do

- Read PHI/PII/PCI if Layer 1 disallows it. The dedicated
  query-connection pool runs with reader-tier credentials
  scoped to the session's tenant; cross-tenant reach is
  physically blocked at the connection.
- INSERT, UPDATE, or DELETE against any table other than the
  materialised search-results accumulator the platform manages.
- Invoke functions or extensions outside the read-only set
  Postgres grants the pool. A consumer-authored fragment that
  tries to call a write-grant function gets a permission denied
  error from the database, not from the application.

### Parameterisation

Values that get interpolated into the SQL (typically via Liquid
filters that render a value into the WHERE clause) are bound as
parameters, not concatenated into the SQL string. SQL injection
via consumer-authored fragments is structurally impossible: the
fragment is the WHERE *expression*, with values supplied via
positional bindings the platform builds.

### Datalake "talk to data" — `textToSql` + `executeSql`

The datalake BI surface (`api.datalakes.textToSql` /
`api.datalakes.executeSql`, see `datalakes.md` "Talk to data") is
the **full-statement** sibling of the WHERE-clause lane above, and
it sits inside the same Layer-3 boundary:

- **Generation is data-free by construction.** `textToSql` sends
  only the natural-language `prompt` plus the datalake **schema**
  to the LLM — never rows. The model returns SQL for review; no
  datalake data ever crosses the LLM boundary, so generation is
  safe in every mode. The two-call
  split (generate, then execute) exists so a human or agent can
  inspect and edit the SQL before any data is read.
- **Execution is read-only and mode-routed.** `executeSql` runs the
  statement against the mode-appropriate schema with a read-only
  connection: INSERT/UPDATE/DELETE/DDL are rejected (app-layer
  `@deny` + a read-only DB transaction + an EXPLAIN preflight), and
  `mode` (`raw`, `tokenized` or `redacted`) plus the session's tenant
  (RLS) decide which lake and which rows are reachable — the same
  Layer-1 compliance gates that govern every other read.
- **Pagination caps blast radius.** `page` / `page_size` map to a
  server-side `LIMIT/OFFSET` window capped at the platform default,
  so a single call cannot exfiltrate an unbounded result set.

## How the three layers compose

```
   ┌─────────────────────────────────────────────┐
   │  Layer 1 — compliance gates                 │
   │    session role  AND  agent data_access     │
   │    AND  tenant id (RLS)                     │
   │  Decides: which lake (raw / tokenized /     │
   │  redacted) and which tenant the query hits. │
   │                                             │
   └────────────────────┬────────────────────────┘
                        │
   ┌────────────────────▼────────────────────────┐
   │  Layer 2 — Liquid sandbox                   │
   │    13-filter allowlist                      │
   │    + parse-on-write                         │
   │    + render-time output_schema check        │
   │  Decides: what the consumer-authored body   │
   │  can express. No VM reach, no FS/network,   │
   │  no eval escape hatch.                      │
   └────────────────────┬────────────────────────┘
                        │
   ┌────────────────────▼────────────────────────┐
   │  Layer 3 — SQL fragment boundary            │
   │    parameterised bindings                   │
   │    + reader-tier connection pool            │
   │    + INSERT-only on search_results          │
   │  Decides: what SQL fragments the WHERE      │
   │  clause path can execute. Read-most,        │
   │  write-one.                                 │
   └─────────────────────────────────────────────┘
```

Each layer is independently sufficient: bypassing any single
layer still leaves the other two enforcing the contract. The
compound rule is "all three must permit" — reading real values
requires an authorising session role AND a `raw` agent (when an
agent is on the path) AND a tenant id match.

## Auditability

Every LLM call the platform makes on behalf of a consumer is
recorded with cloud-storage pointers to the exact input payload
the agent received and the exact response the provider
returned. The audit record outlives the host run that produced
it — a compliance auditor can later replay any agent's input
and verify which lake (raw vs tokenized) actually fed the model. The audit surface is admin-facing today; the
guarantees it captures back-stop Layer 1.

## Consumer takeaways

1. **Reach for `'tokenized'` agent `data_access` first.** The field
   is required, so this is a decision you must make rather than
   inherit; choose `'raw'` only when the prompt genuinely needs real
   values, and pair it with a session role that is authorised. The
   tier should follow what the prompt can actually reach — an agent
   whose prompt carries only an account number and a type has no
   identity to protect, and one reading free-text patient messages
   has plenty.

2. **Use only the 13 custom filters** named in Layer 2. Bodies
   that reference other filter names will silently fall through
   to stock Liquid behavior and produce malformed output.

3. **Anchor JSON bodies on `to_json`.** When a template body
   must produce valid JSON (most contexts of MDM input,
   workflow context_mapping, action metadata), wrap nested
   values with `| to_json` and never with `| json`.

4. **Treat SQL WHERE fragments as expressions, not statements.**
   The dataset search surface owns the SELECT and the INSERT
   into `search_results`. Consumer authoring is constrained to
   the WHERE clause; everything else is platform-built.

5. **Pin output shapes with `output_schema`.** Where the embed
   supports it (most TemplateConfig surfaces), populating
   `output_schema` turns render-time shape drift into a clean
   422 instead of a downstream JSON-decode error two queues
   later.

## Related

- `ai_agents.md` §2 — `data_access` mode (Layer 1)
- `account_management.md` §3 — session role vocabularies (Layer 1)
- `interoperability_contracts.md` §2 — template_config body shape (Layer 2)
- `workflows.md` gotcha 9 — the `| to_json` vs `| json` trap (Layer 2)
- `data_activation_clients.md` §6.6 — dataset search SQL aliases (Layer 3)
- `mdm.md` — verification surface that composes with Layer 1
