# Rules

Source: https://dabadata.com/docs/rules/ (HTML) - https://dabadata.com/docs/rules/index.md (this page as markdown)

dabadata's access rules read like membership: who may read, create, update and delete the rows of a table. They are small enough to say out loud, and every time one is applied dabadata tells you which part decided and why.

## One rule per operation

Each table has up to four rules, one per operation:

| Op | Applies to | Checked against |
| --- | --- | --- |
| `read` | Lists, single-row reads and live subscriptions. | Each row (compiled to a filter, below). |
| `create` | Inserts. | The values being inserted, after defaults and user ids are filled in. |
| `update` | Updates. | The existing row, before the change. |
| `delete` | Deletes. | The existing row. |

A missing `create`, `update` or `delete` rule means `"nobody"`. `read` is the exception: it must be written out, even if it is `"nobody"`, so that nothing becomes readable (or unreadable) by accident.

Calls made with the project key skip the rules entirely and are recorded as `bypass`; so are reads made with a view token. See [Identity and tokens](https://dabadata.com/docs/identity/index.md).

## The grammar

```
Rule := "anyone" | "authenticated" | "nobody"
      | { "role": string | string[] }        user.role is in the list
      | { "owner": "<column>" }               row[column] == user.id
                                              (create: values[column] == user.id;
                                               if absent it is filled with user.id)
      | { "where": "<condition>" }            the condition holds (below)
      | { "via": { "table", "match", "where"? } }
                                              a matching row of another table exists
                                              (relations, below)
      | { "any": Rule[] }                     at least one branch passes
      | { "all": Rule[] }                     every branch passes
```

| Rule | Passes when |
| --- | --- |
| `"anyone"` | Always, including callers with no token. |
| `"authenticated"` | The caller has a user token (any role). |
| `"nobody"` | Never. Only the project key gets through, as a bypass. |
| `{ role: "admin" }`<br>`{ role: ["admin", "editor"] }` | The caller's role is in the list. `"anonymous"` may be listed to let callers with no token in. |
| `{ owner: "owner" }` | The named column holds the caller's id. The column must be a `userId` column. On create it is checked on the inserted values - and since a missing `userId` column is filled with the caller's id, leaving it out passes. Anonymous callers own nothing. |
| `{ where: "status = 'published'" }` | The condition holds on the row (or, on create, the values) at the instant of the call. See [Row conditions](https://dabadata.com/docs/rules/index.md#row-conditions). |
| `{ via: { table: "team_members", match: { team: "$row.team", user: "$user.id" } } }` | A row of the membership table exists whose `match` columns hold those values. See [Relations](https://dabadata.com/docs/rules/index.md#relations). |
| `{ any: [ ... ] }` | At least one branch passes. |
| `{ all: [ ... ] }` | Every branch passes. |

Rules nest. Here admins may update any row, and editors only the rows they own; a viewer who owns a row still may not:

```ts
update: {
  any: [
    { role: "admin" },
    { all: [{ role: "editor" }, { owner: "owner" }] },
  ],
},
```

## Row conditions

A rule may decide on the row's values and on the time. `{ where: "<condition>" }` is a rule node like `role` and `owner`, and combines with them inside `any` and `all`:

```ts
posts: {
  columns: { title: t.text(), status: t.text({ default: "draft" }), author: t.userId() },
  rules: {
    read: { any: [{ where: "status = 'published'" }, { owner: "author" }] },   // published, or your own
    create: "authenticated",
    update: { all: [{ role: "editor" }, { where: "status = 'draft'" }] },       // editors, and only drafts
  },
},
```

Every rule still compiles to SQL for lists, and the same semantics run in JavaScript for single rows, writes and live fan-out - the two give identical answers.

```
condition := or
or        := and ("or" and)*
and       := unary ("and" unary)*
unary     := "not" unary | "(" condition ")" | "true" | "false" | operand tail
tail      := op operand
           | "is" ["not"] "null"
           | ["not"] "in" "(" operand ("," operand)* ")"
op        := = | != | < | <= | > | >=
operand   := <column> | $user.id | $user.role | $now | $today
           | 'string' ('' is a quote) | number | true | false
```

Keywords (`and or not is null in true false`) are case-insensitive; column names are as declared. `==`, `<>`, functions, casts and subqueries do not exist. Some conditions:

```
status = 'published'
publish_at <= $now and (expires_at is null or expires_at > $now)
role_needed in ('member', 'admin')
author = $user.id or $user.role = 'admin'
due >= $today
```

| Operand | Type | Notes |
| --- | --- | --- |
| a column | its type | The table's declared columns, plus `id` (text), `created_by` (userId), `created_at` and `updated_at` (datetime). `json` columns are refused. |
| `$user.id` | text | Null for anonymous callers, so `author = $user.id` is false for them and `$user.id is null` tells them apart. |
| `$user.role` | text | `"anonymous"` without a user token. |
| `$now` | datetime | The instant of the call, from the server's clock. |
| `$today` | date | The UTC date of `$now`. |
| `'string'` | from the other side | A text, a date `'YYYY-MM-DD'` or an ISO datetime, according to what it is compared with. |
| a number | int, float | An int column takes integers only. |
| `true`, `false` | bool | With `=` and `!=` only. |

- **Types compare in families:** int and float (numbers); text, userId and ref (text); bool; date; datetime. A date column compares with `$today`, a datetime column with `$now`. `$now` and `$today` may only be compared with a column or a constant, and two constants are never compared with each other.
- **Nulls:** `a = b`, `<`, `<=`, `>`, `>=` are true only when both sides are non-null and the comparison holds. `a != b` is `not (a = b)`, so it is true when either side is null. `a in (x, y)` is true only when `a` is non-null and equals an item; `not in` is its negation (true when `a` is null). A missing value is null; test it with `is null` / `is not null` (`= null` is refused).
- **Ordering:** text by Unicode code point (the same in SQL, with `collate "C"`), numbers by value, dates as `YYYY-MM-DD`, datetimes as instants at millisecond precision (offsets honoured).
- **Which row:** on `create` the condition reads the values being inserted, after defaults and the caller's `userId` columns are filled (system columns are null - the row does not exist yet). On `read`, `update` and `delete` it reads the row as it is before the write; column `update` rules read the same row.
- **Safe SQL:** every value is a typed bound parameter; nothing from the definition is pasted into the query text, and `$now` is bound from the call's instant, never SQL `now()`.

**Roles and conditions together.** A role and a condition: `{ all: [{ role: "editor" }, { where: "status = 'draft'" }] }`. A condition or the owner: `{ any: [{ where: "status = 'published'" }, { owner: "author" }] }`. A condition alone may test the caller too: `author = $user.id or $user.role = 'admin'`.

**Rules without a row.** Some rules are decided before any row exists: `actions.<name>.allow`, a view's `allow`, `auth.users.invite.rule`, `auth.users.manage`, `auth.users.profiles.read`, `trace.read` and the storage bucket rules. A condition there may read `$user.*`, `$now` and `$today` but no column (`columns do not apply here (there is no row)`). `{ where: "$now < '2026-12-01T00:00:00Z'" }` as an action's `allow` closes it at a date.

**What writes answer.** `POST /v1/mutate`, every op of `POST /v1/transact` and the MCP write tools answer with the row only when the caller's read rule lets them read it (after the write; for a delete, before it). Otherwise the row is `{ "id": "<id>" }`. The secret key, view tokens and console sessions always get the full row; actions keep answering `null` for a step row the caller may not read.

## Time-based visibility

A condition with `$now` or `$today` makes rows appear and disappear by themselves: `reveal_at <= $now` lets a row in when `reveal_at` passes, `expires_at > $now` takes it away. dabadata follows those instants everywhere:

- **One instant per call.** A list, a single read, a write (its rule, its `expect` 409 body, the row it answers with), a whole `/v1/transact` and an action read the clock once. Lists are exact at that instant, at any table size.
- **Live.** A row that enters a subscriber's view by time arrives as a `change` with `op: "update"` (treat it as an insert when the id is new); a row that leaves arrives as `op: "delete"` with `{ id }`. They come within about a second of the instant, with no write. These pushes write no trace row; the realtime log records each pass as a `fanout` with detail `time`. See [Live subscriptions](https://dabadata.com/docs/live/index.md#time).
- **Catching up.** `GET /v1/db/<table>?since=<seq>` for a caller whose read depends on time answers `{ "reset": true, "cursor", "traceId" }` and no rows: time moves rows in and out without a new `_seq`, so no delta can say what changed. Load the rows again.
- **Testing.** `POST /v1/simulate` takes a `now`; the console's Rules page calls it **Evaluate at**. Ask "what will a player see at 20:01?" before 20:01 comes.

Column read rules may compare with `$now` as well (below): a friend's score prediction can stay hidden until the game starts and then reach every open screen at once. The full pattern is on [Access patterns](https://dabadata.com/docs/access-patterns/index.md#predictions).

## Validation

A definition with a broken rule is rejected as a whole, with one message per problem, each naming where it is:

```
tasks: no read rule (rules.read must be explicit, even "nobody")
tasks.rules.delete: unknown role "superuser"
tasks.rules.update.any[1]: owner column "author" does not exist
tasks.rules.update.any[1]: owner column "title" is not userId
tasks.rules.create: unknown rule "everyone" (expected anyone, authenticated, nobody or an object)
tasks.rules.read: malformed rule (an object rule has exactly one of role, owner, where, via, any, all)
tasks.rules: unknown op "insert" (expected read, create, update, delete)
tasks.rules.read.where: unknown column "stauts"
tasks.rules.read.any[0].where: use "is null" instead of "= null"
tasks.rules.update.where: column "due" is date; compare it with $today
tasks.rules.read.where: unknown operator "==" (use =)
tasks.notes.read: unknown role "boss"
tasks: unknown key "acess" (expected columns, rules, access, unique, checks)
tasks.title: unknown key "reed" (expected type, table, optional, default, unique, onDelete, update, ops, read)
```

Unknown keys of a table or a column are refused, because a typo like `reed` or `acess` would otherwise silently weaken a rule. A condition is checked against the table's columns and types; its messages are exact (`column "done" is bool; use = or !=`, `"n" is int; compare it with an integer`, `$now may only be compared with a column or a constant`, `compares two constants`, `functions are not allowed ("lower(")`), and a condition is at most 1000 characters, 50 comparisons and 20 levels deep.

The same checks cover the rest of the definition: unknown column types, reserved or malformed names, and a `ref` to a table that does not exist. You see these messages from `defineProject` itself, in the 400 from `POST /v1/schema/reload`, and in the server log at start.

## How read rules become a row filter

Checking a read rule row by row would mean loading rows the caller may not see. Instead, dabadata compiles the read rule, for the caller and the instant of the call, into one SQL condition:

- `anyone`, `authenticated`, `nobody` and `role` branches - and conditions that read no column, such as `$user.role = 'admin'` - depend only on who is calling and when, so they are decided right away, true or false.
- An `owner` branch becomes `column = <user id>` in the SQL query.
- A `where` branch becomes its SQL, every value a bound parameter.
- `any` becomes OR and `all` becomes AND, nested as written. A false branch contributes nothing to an `any`, and denies everything in an `all`. A demo room user's filter is the rule AND their room.

So with `read: { any: [{ role: "admin" }, { owner: "owner" }] }`, an admin gets every row with no filter, bob (an editor) gets `where owner = 'bob'`, and a caller with no token gets nothing at all - the query is not even run. With `read: { any: [{ where: "status = 'published'" }, { owner: "author" }] }`, u7 gets `where status = 'published' or author = 'u7'` and an anonymous caller `where status = 'published'`.

Every rule compiles, so lists, `?since=` catch-ups and subscriptions are exact at any table size. (Rules that mixed AND inside OR used to be checked row by row over at most 10,000 rows, explained as `rule evaluated per row`; that path is gone.)

When the rule leaves nothing a caller could ever read, a list answers with no rows (and the trace records `denied`), a live subscription is refused with a `forbidden` error, and a single-row read of an existing row answers 403.

## Explanations

Every evaluation returns a decision - `allowed`, `denied` or `bypass` - and one sentence that names the path through the rule:

```
update tasks: allowed by any[1] owner(owner): row.owner "u7" == user "u7"
delete tasks: denied, role "viewer" not in ["admin"]
read tasks: bypass (service key)
```

- An allowed decision names the branch that passed: `any[1]` is the second branch of the `any`.
- A denied `any` lists every failed branch, joined with `; `, e.g. `update tasks: denied, any[0] role "editor" not in ["admin"]; any[1] row.owner "alice" != user "bob"`.
- A denied `all` names its first failed branch.
- A list read through a filter says what the filter was: `read tasks: allowed, rows where owner == "bob"`, `read posts: allowed, rows where status = 'published' or author == "u7"`, with nested groups in parentheses (`rows where (status = 'published' or author == "u7") and demo_room == "r1"`).
- A condition prints as `where(<condition>)`, then the comparison that decided, with each operand's value: `row.status "draft"` (on create `values.status "draft"`), `user "u7"` (anonymous: `user null`), `role "member"`, `now "2026-10-16T19:00:00.000Z"`, `today "2026-10-16"`, constants as written, and `is true` / `is false` at the end. An `or` names its first true part (or every false one), an `and` its true parts (or the one that failed).
- A column the caller may not read prints `(hidden)` instead of its value: `row.internal_notes (hidden) != 'archived' is false`. The decision still uses the real value; the logic is visible, the data is not.
- A bypass says why: `(service key)` or `(view token)`.

```
read posts: allowed by any[0] where(status = 'published'): row.status "published" = 'published' is true
read posts: denied, any[0] where(status = 'published'): row.status "draft" = 'published' is false; any[1] row.author "u3" != user "u7"
read notices: denied, where(publish_at <= $now and (expires_at is null or expires_at > $now)): row.publish_at "2026-10-16T20:00:00.000Z" <= now "2026-10-16T19:00:00.000Z" is false
read memos: denied, where(internal_notes != 'archived'): row.internal_notes (hidden) != 'archived' is false
```

The explanation is in the 403 body of a denied call, in the `error` message of a denied subscription, on the client's `DabadataError`, and on the call's row in the [trace](https://dabadata.com/docs/trace/index.md).

## The simulator

To ask "would this person be allowed?" without changing anything, use `POST /v1/simulate` with the project key or a view token. Give it a user (any role, including `anonymous`), a table, an op and the row or values to judge. `row` may be a row object or the id of a real row, which is then loaded. Simulations are not recorded in the trace.

```bash
curl -X POST https://dabadata.com/v1/simulate \
  -H "Authorization: Bearer <project key>" \
  -H "Content-Type: application/json" \
  -d '{"user":{"id":"bob","role":"editor"},"table":"tasks","op":"update","row":{"owner":"alice"}}'
```

```json
{
  "decision": "denied",
  "explanation": "update tasks: denied, any[0] role \"editor\" not in [\"admin\"]; any[1] row.owner \"alice\" != user \"bob\"",
  "rule": { "any": [{ "role": "admin" }, { "owner": "owner" }] }
}
```

The viewer's **Rules** page is built on the same endpoint: a matrix of every role against every table and op, and a simulator form. The MCP server exposes it as the `simulate` tool.

With `"op": "update"` and a `values` object, the simulator applies the column rules below too.

**At another instant.** Add `"now": "2026-10-16T20:00:00Z"` (an ISO datetime with a zone) to evaluate the rules at that instant; the answer then carries `now`, the instant used. Anything else is a 400 `now must be an ISO datetime`. In the console this is the simulator's **Evaluate at** field.

**Column by column.** A `read` of a given `row` on a table with column read rules adds `columns`: one decision and explanation per such column, headed `read <table>.<column>:`. A column is visible when both the row and the column pass.

```json
{
  "decision": "allowed",
  "explanation": "read questions: allowed by role: role \"player\" in [\"player\",\"host\"]",
  "rule": { "role": ["player", "host"] },
  "columns": {
    "answer": {
      "decision": "denied",
      "explanation": "read questions.answer: denied, any[0] role \"player\" not in [\"host\"]; any[1] where(reveal_at <= $now): row.reveal_at \"2026-10-16T20:00:00.000Z\" <= now \"2026-10-16T19:00:00.000Z\" is false"
    }
  },
  "now": "2026-10-16T19:00:00.000Z"
}
```

## Column rules

A table's `update` rule may be narrowed per column. A column's own `update` rule replaces the table's for that column, and `ops` limits writes of that column to those operators:

```json
"posts": {
  "columns": {
    "title":  { "type": "text" },
    "owner":  { "type": "userId" },
    "likes":  { "type": "int", "default": 0, "update": "authenticated", "ops": ["$inc", "$dec"] },
    "pinned": { "type": "bool", "default": false, "update": { "role": "admin" } },
    "taken":  { "type": "int", "default": 0, "update": "nobody" }
  },
  "rules": { "read": "anyone", "create": "authenticated", "update": { "owner": "owner" } }
}
```

- Anyone signed in may send `{ "likes": { "$inc": 1 } }` - but not `{ "likes": 1000 }` (a plain value counts as `$set`) and not `{ "likes": { "$max": 5 } }`.
- Only the owner may change `title` (the table rule decides columns without their own rule), only admins `pinned`, and nobody `taken` - only an [action](https://dabadata.com/docs/rules/index.md#actions-protecting-counters-capacity-and-stock) can change it.
- An update touching several columns must pass the rule of every column it touches. `{ "likes": { "$inc": 1 }, "title": "x" }` from someone who does not own the post is denied as a whole.

`ops` takes the operators of the column's type: `$inc $dec $min $max $set` for int and float, `$min $max $set` for date and datetime, `$append $remove $set` for json, `$set` for the others. A column may have `ops` without its own `update` rule (the table rule then decides who, `ops` how).

The denial names the column:

```
update posts: denied, column likes: a plain value ($set) is not allowed (ops: $inc, $dec)
update posts: denied, column pinned: role "member" not in ["admin"]
update posts: denied, column title (table rule): row.owner "alice" != user "bob"
```

Column rules apply to updates through `/v1/mutate`, `/v1/transact` and the MCP `T_update` tools. Inserts take the table's `create` rule (bound the inserted values with [checks and defaults](https://dabadata.com/docs/concurrency/index.md)); the secret key and action steps skip column rules like any rule.

## Column read rules

A column may carry its own `read` rule: who may see that column's value. It uses the whole rule grammar - `role`, `owner`, `where` with `$now`, `any`, `all` - over the table's columns:

```ts
questions: {
  columns: {
    text: t.text(),
    reveal_at: t.datetime(),
    answer: t.text({ read: { any: [{ role: "host" }, { where: "reveal_at <= $now" }] } }),
    internal_notes: t.text({ optional: true, read: { role: "admin" } }),
    api_key: t.text({ optional: true, read: "nobody" }),
    draft: t.text({ optional: true, read: { owner: "author" } }),
    author: t.userId(),
  },
  rules: { read: "authenticated", create: { role: "host" } },
},
```

In JSON a column read rule sits beside `type`: `{ "type": "text", "read": { "role": "admin" } }`.

- **An extra requirement.** The table's read rule decides the row; the column rule then decides the value. It never widens access: a row the table rule hides stays hidden.
- **The key is left out.** Where a caller's column rule fails on a row, that row comes back without the key - absent, not null. `id`, `created_at`, `updated_at`, `created_by`, `_seq` and `demo_room` are always present (`demo_room` refuses a read rule).
- **Per row and per instant.** `owner` and conditions on columns are decided on each row at the call's `$now`; `role`, `anyone`, `authenticated` and conditions without a column once per caller.
- **Writes are not limited.** A caller may insert or update a column it cannot read (its write rules decide); the row the write answers with leaves it out.
- **Bypass.** The secret key, view tokens and console sessions see every column. Inside an action's steps nothing is hidden or refused; the rows an action returns are projected for the caller.

**Every exit is projected:** lists, `?since=`, single rows, the rows `/v1/mutate`, `/v1/transact` and actions answer with, 409 bodies, views, live rows and changes (both transports), the MCP table, transact and action tools, and MCP `describe_app`, which leaves out columns the caller can never see.

**No filtering on what you cannot see.** A column whose read rule is not statically true for the caller - `nobody`, another role, or one that depends on the row (`owner`, a `where` on a column), even on rows where it would pass - may not be named where the caller could learn its value from which rows come back. These answer 400 `{ "error": "validation", "messages": [...] }`:

```
where: column "answer" is not readable by this caller
orderBy: column "answer" is not readable by this caller
expect: column "answer" is not readable by this caller
ifAbsent: column "answer" is not readable by this caller
```

That covers `where` on lists, `?since=`, subscriptions and MCP `<table>_list`; `orderBy`; `expect` on mutate, transact (check ops too) and MCP `_update`; and `ifAbsent`. The check comes after the unknown-column check, and does not apply to bypass identities or inside an action's steps. A refused subscription answers `{ "type": "error", "id", "error": "where: column \"answer\" is not readable by this caller" }`.

**Explanations and the trace.** A column that is not statically readable prints `row.<column> (hidden)` in every explanation - 403 texts, trace rows and the simulator. Callers who read the trace through `trace.read` get each row without what they may not read: `values`, `before`, `after` and a check's expectations lose the hidden columns. The secret key, view tokens and console sessions read the trace unchanged.

**Live.** Each subscriber gets rows and changes in their own projection. An update that only touches columns a subscriber cannot see is not sent to them, and a column that appears or disappears by time arrives as an `update` carrying the whole visible row (replace it, do not merge). See [Live subscriptions](https://dabadata.com/docs/live/index.md#shaping).

> **Security:** A unique constraint's 409 names its column (`unique column "code": another row already has this value`), so a caller who writes a value of a hidden unique column learns that another row holds it. Declare unique constraints only on columns the writers may know about.

## Relations

A `via` rule grants access through another table: "a row of `team_members` links this task's team to the caller". It is a rule node like `role` or `where`, so it combines with `any` and `all`:

```ts
tasks: {
  columns: { team: t.ref("teams"), title: t.text() },
  rules: {
    read:   { via: { table: "team_members", match: { team: "$row.team", user: "$user.id" } } },
    create: { via: { table: "team_members", match: { team: "$row.team", user: "$user.id" } } },
    update: { via: { table: "team_members", match: { team: "$row.team", user: "$user.id" }, where: "role = 'editor'" } },
    delete: { any: [{ role: "admin" }, { via: { table: "team_members", match: { team: "$row.team", user: "$user.id" }, where: "role = 'editor'" } }] },
  },
},
```

- **`table`** is the membership table, a declared table.
- **`match`** names at least one of its columns, each with a value: `"$row.<column>"` (the row the rule is about; `$row.id` is a ref to this table), `"$user.id"`, `"$user.role"` or a literal (`"editor"`, `2`, `true`). The types must fit, as for action placeholders: a row's `ref(projects)` fits a `ref(projects)` column, a plain `text` column does not.
- **`where`** is an optional [condition](https://dabadata.com/docs/rules/index.md#row-conditions) on the membership row (its columns, `$user.*`). `$row.*` belongs in `match`, and `$now` / `$today` are not supported there yet - put time conditions on the row itself: `all: [{ via: ... }, { where: "due_at > $now" }]`.
- **On create** `$row.team` reads the value being inserted: members add tasks to their own teams only.
- **One hop.** The membership table's own rules and `access` are not evaluated: the lookup reads it with the server's rights, so a strict membership table works too. Protect who may write the membership table with its own rules, or make it strict and add members through an action.
- **Anonymous** callers have no id: a relation on `$user.id` never holds for them. In a demo room the lookup stays in the room.
- **Where it applies.** Table rules, column rules (`read` and `update`) and view `rows`. Rules without a row refuse it: `actions.go.allow: via does not apply to actions (there is no row)`.

Lists run the relation inside the query (a correlated `exists`); a single row, a write (inside its transaction, after the row lock), a transaction's check, the rows writes and actions answer with and the simulator look each relation up once per request. Declare a unique constraint on the membership table's match columns - `unique: [["team", "user"]]` - so the lookup has an index and each person is in a team once.

Explanations name the lookup:

```
read tasks: allowed by via team_members(team = row.team "t1", user = user "u7"): a matching row exists
update tasks: denied, via team_members(team = row.team "t1", user = user "u7", where role = 'editor'): no matching row
create tasks: denied, via team_members(team = values.team "t2", user = user "u7"): no matching row
read tasks: denied, via team_members(team = row.team "t1", user = user null): anonymous has no id
read tasks: allowed, rows where via team_members(team = row.team, user = "u7")
```

Validation messages carry the path: `tasks.rules.read.via.table: unknown table "x"`, `tasks.rules.read.via.match: must name at least one column`, `tasks.rules.read.via.match.team: "$row.title" is text, the column team_members.team is ref(teams)`, `tasks.rules.read.via.match: "$row.nope": "nope" is not a column of tasks`, `tasks.rules.read.via.where: $row.team belongs in match`.

**Live.** A membership change reloads only that user's subscriptions that read through the relation: they receive their rows again with `"reason": "relation"` (on the realtime tier, `{ "type": "resync", "table" }` on the user's own lane). A row moving to another team is an ordinary change. See [Access patterns: teams](https://dabadata.com/docs/access-patterns/index.md#teams) for the whole worked example.

## Actions: protecting counters, capacity and stock

A rule decides who may write a row, not what the write means. With `update: "authenticated"` on a seat counter, an honest client sends `{ "$inc": 1 }` in the same transaction as its RSVP - and a dishonest one sends `{ "taken": 0 }` with one curl, or inserts an RSVP without touching the counter. Atomic operators and transactions keep honest clients correct under concurrency; they cannot stop a client that skips them.

An **action** is a transaction declared in the definition: a fixed list of steps the client calls by name with parameters. The steps run with the server's rights (the tables' rules are not evaluated for them); the action's own `allow` rule decides who may call it. Give the counter table no client writes at all, and change it only through actions:

```json
{
  "roles": ["member", "admin"],
  "tables": {
    "events": {
      "columns": { "title": { "type": "text" }, "creator": { "type": "userId" } },
      "rules": { "read": "anyone", "update": { "owner": "creator" }, "delete": { "owner": "creator" } }
    },
    "event_stats": {
      "columns": { "capacity": { "type": "int" }, "taken": { "type": "int", "default": 0 } },
      "checks": { "taken_range": "taken >= 0 and taken <= capacity" },
      "rules": { "read": "anyone" }
    },
    "rsvps": {
      "columns": {
        "event": { "type": "ref", "table": "events", "onDelete": "cascade" },
        "user": { "type": "userId" }
      },
      "unique": [["event", "user"]],
      "rules": { "read": "anyone" }
    }
  },
  "actions": {
    "create_event": {
      "allow": "authenticated",
      "params": { "title": { "type": "text" }, "capacity": { "type": "int" } },
      "steps": [
        { "op": "insert", "table": "events", "values": { "title": "$params.title", "creator": "$user.id" }, "ref": "e" },
        { "op": "insert", "table": "event_stats", "id": { "$ref": "e" }, "values": { "capacity": "$params.capacity" } }
      ]
    },
    "rsvp": {
      "allow": "authenticated",
      "params": { "event": { "type": "ref", "table": "events" } },
      "steps": [
        { "op": "check",  "table": "event_stats", "id": "$params.event", "expect": { "taken": { "$lt": "$row.capacity" } } },
        { "op": "update", "table": "event_stats", "id": "$params.event", "values": { "taken": { "$inc": 1 } } },
        { "op": "insert", "table": "rsvps", "values": { "event": "$params.event", "user": "$user.id" } }
      ]
    }
  }
}
```

`event_stats` and `rsvps` have no `create`, `update` or `delete` rule, so no client can write them directly. `create_event` makes the event and its stats row with the same id (an action's insert step may set `id`), and `rsvp` takes a seat:

```js
const { results } = await db.action("create_event", { title: "Meetup", capacity: 30 });
await db.action("rsvp", { event: results[0].row.id });
```

The check step locks the stats row and holds while `taken < capacity`; the `$inc` and the RSVP insert happen in the same transaction. Fifty people pressing the button at once fill exactly 30 seats; the others get a 409 `conflict` with `index: 0` (the step that did not hold), and a second RSVP by the same person is a 409 at step 2 (the unique pair). A refused caller gets a 403 with the explanation (`action rsvp: denied, anonymous is not authenticated`).

Deleting an event works the same way: give `events` no `delete` rule and declare an action whose steps delete the event (its RSVPs follow through `onDelete: "cascade"`) and its stats row; `expect` keeps it to the creator:

```json
"delete_event": {
  "allow": "authenticated",
  "params": { "event": { "type": "ref", "table": "events" } },
  "steps": [
    { "op": "delete", "table": "events", "id": "$params.event", "expect": { "creator": "$user.id" } },
    { "op": "delete", "table": "event_stats", "id": "$params.event" }
  ]
}
```

**Placeholders.** Step values may be `$params.<name>`, `$user.id`, `$user.role`, `$user.name`, `$now`, and in `expect` `$row.<column>` (the locked row's own value). Any other string starting with `$` is a validation error, and so is a placeholder of the wrong type (`"$params.title" is text, the column is int`). A step is an op of [`/v1/transact`](https://dabadata.com/docs/concurrency/index.md): `check`, `insert`, `update` (operators allowed), `delete`, with `expect`, `ifAbsent` and `ref`; at most 20 steps.

**Calling.** `POST /v1/actions/<name>` with `{ "params": { ... } }` and any credential the data routes take; `db.action(name, params)` in the client; one MCP tool `action_<name>` per action. The answer is `{ ok: true, results, traceId, traceIds }`, one `{ row }` per step (null when your read rule does not let you see that row). The [trace](https://dabadata.com/docs/trace/index.md) shows one row `op: "action"` (name, params, decision) and one row per step marked with `values._action`; live subscribers get the changes after the commit.

**Many rows at once.** An `update` or `delete` step may name its rows with `where` instead of `id`: one step changes every row matching the condition, up to `limit` (1-1000, default 1000), oldest first. The condition is a [row condition](https://dabadata.com/docs/rules/index.md#row-conditions) with `$params`, `$user`, `$now` and `$today`, and - only here - relative times: `$now - 30 days`, `$now - 2 hours`, `$now - 1 month` (calendar months, UTC), `$today - 1 week`. Rows beyond the limit stay for the next call; zero matches is not an error.

```json
"expire_old_likes": {
  "allow": { "role": "maintenance" },
  "steps": [{ "op": "delete", "table": "likes", "where": "created_at < $now - 30 days" }]
},
"mark_stale": {
  "allow": { "role": "maintenance" },
  "steps": [{ "op": "update", "table": "profiles", "where": "updated_at < $now - 1 month", "values": { "stale": true }, "limit": 500 }]
}
```

Each row goes through the same path as a one-row step (values and operators, checks, `onDelete`, live updates, [webhooks](https://dabadata.com/docs/webhooks/index.md)) and gets its own trace row with `values._bulk`; one more trace row sums it up (`row_count`, `values._where`). The step's result is `{ "row": null, "count": 37 }`. Run it on a timetable with a [schedule](https://dabadata.com/docs/schedules/index.md). `/v1/transact` itself does not take `where`.

**Calling a model.** An action may end with one call to Claude (Anthropic), OpenAI or Gemini (Google), made by the server with your API key kept as a project secret: the definition fixes the model, the prompts and the token budget, the client sends only params, and the answer comes back in the response (`ai`) or is written into a row. See [AI calls in actions](https://dabadata.com/docs/ai/index.md).

## Strict tables

A strict table is one end users never reach directly: they read it through [views](https://dabadata.com/docs/rules/index.md#views) and write it through [actions](https://dabadata.com/docs/rules/index.md#actions-protecting-counters-capacity-and-stock), and nothing else. Strictness is per table, with a project default, so one project mixes strict and open tables freely:

```ts
defineProject({
  name: "trivia",
  roles: ["player", "host"],
  access: "open",                     // the project default (optional, default "open")
  tables: {
    questions: {
      access: "views",                // strict: no rules needed, end users never meet them
      columns: { text: t.text(), answer: t.text(), reveal_at: t.datetime() },
    },
    chat: {                           // open: plain rules as before
      columns: { body: t.text() },
      rules: { read: "authenticated", create: "authenticated" },
    },
  },
  views: {
    open_questions: { table: "questions", allow: { role: ["player", "host"] }, rows: "anyone", columns: ["text", "reveal_at"] },
  },
});
```

- `access` is `"open"` or `"views"` on a table and on the project; a table's own value wins, and without either it is `"open"`. Anything else: `tasks.access: must be "open" or "views"` (`access: must be "open" or "views"` for the project). A bucket's `files_<bucket>` table and the profiles `users` table are always open.
- On a strict table `rules` are optional; rules given are still validated, but they have no effect for end users.
- It applies to end users: user tokens (any issuer, hosted users, demo room users) and anonymous callers. Not to the secret key, console sessions, view tokens, action steps or views.
- Reads answer 403 `read questions: denied, questions is read through views only (access: views)`, also for an id that does not exist (no 404 says whether it does). Writes answer 403 `insert questions: denied, questions is written through actions only (access: views)` (`update` / `delete` the same), before the row is locked or the values are checked.
- The same holds on every surface: `/v1/db` lists, `?since=` and single rows, `/v1/mutate`, each op of `/v1/transact` (a check op as a read), table subscriptions on `/v1/live` and the realtime tier, presence, the simulator for end-user roles, and the MCP user toolset (no table tools for it, `describe_app` leaves it out). `GET /v1/schema` shows end users the open tables only.
- Making a table strict ends end users' open table subscriptions of it at once; view subscriptions over it reload and continue.

Use strict tables for anything where a leak costs money or fairness - answers, scores, payments, personal data. Worked examples, including a project that is strict by default with one open table, are on [Access patterns](https://dabadata.com/docs/access-patterns/index.md#predictions-strict).

## Views

A view is a declared, read-only shape of one table that clients read by name (`GET /v1/views/<name>`, `db.view(name)`, a live subscription or an MCP tool): which rows (with params the caller fills in), which columns, computed boolean fields, and a different shape per role. A view reads with the server's rights, the way an action writes with them - the table's read rule and its `access` are not evaluated, the view's `allow` and `rows` decide - while the table's column read rules still apply. Together with actions it is how end users reach a strict table. The full reference is on [Views](https://dabadata.com/docs/views/index.md).
