Skip to content

Row access

Show each signed-in person only the rows that are theirs, decided on the server by a policy module the App never imports.

examples/apps/lintel/policy.ts
export default defineAppRowPolicy<Rows>({
appId: 'lintel',
// …
async resolveRows(principal, { db }) {
// …
if (principal.roles.some((role) => OFFICE.includes(role))) return all(true);
if (principal.roles.includes('resident')) return household(db, principal.email);
return all(false);
},

See it in the sample (examples/apps/lintel/policy.ts:89); household finds the one resident whose email is the account’s and returns their lease, charges, payments and repairs, and nothing else. The App says auth: { rows: 'policy', … }, and the server loads the module with --policy ./policy.ts.

principal.roles holds the roles the account was given, by name: a role that extends another is not also given the other, so test for each by name, as Lintel’s OFFICE list does. (A view’s or page’s roles are different: they open to a role that extends one they name.)

  • A manager at two dealerships sees both dealerships’ leads and cars together, and no other dealer’s, in every list, record, count, search and export. A record outside the person’s rows answers as one that does not exist.
  • An edit that would point a row at a parent the person cannot read is refused. A generated column is read like any other and written by nobody; tablewalk check warns ([write-generated]) where a role’s fields lists one to write.
  • A refused save says which rule refused it, in the answer and on the server’s terminal: This account's role does not write "note" of "job"., The App's row policy does not let this account change this "job" record., "total" is generated by the database and cannot be written. The answer is { code, message, error, errors, outcome }, the code and message at the top as a command’s are.
  • Use a row policy when one organization’s people see different rows of the same tables: their own accounts, their region, their dealerships.
  • Use a tenant App instead when each customer is a separate organization with its own members, roles and administration.
  • Use roles alone when people differ only in which tables and columns they reach.
Part What it does Default Status
resolveRows(principal, { db }) Each resource’s rows for the verified account: a filter, true (all) or false (none) required Stable
db The App’s own pooled connections, read-only and typed by Rows, across all rows — Stable
authorizeDeletedRecord(principal, { table, key, row }) Whether this person may delete or restore this row deletes refused Stable
resultReferences One extractor per signed-in command, naming the rows its result draws on required with --commands Stable
publicForms Enrolls a fixed public form under the policy none Stable

A resource the policy leaves out, or answers false for, has no rows for that person. A filter is ANDed with every query the person makes. The resolver receives only the verified account id, the App id and the roles. Policy changes need a restart.

  • Reads. Lists, records, related lists, searches, counts and exports. A path through a reference is scoped like one through a key.
  • Writes. With access: 'write' and role write grants, Save, inline edits, fixed actions and New work on a writable SQLite or PostgreSQL source. A write may point a row only at a parent the person reads; only the references it sets are checked. A row the same write made inside the person’s rows counts as theirs for the rest of that write: a child rule that names its parents by key (request_id in [...]) admits a line on the new parent, every other condition of the rule still holds, and a rule of false stays closed.
  • Commands. --commands works beside --policy; a conflicting row the person cannot see is refused as temporarily_unavailable, never revealed.
  • Jobs and services. Jobs, timers, inbound endpoints, integrations and effects run as a service the policy’s services: ['clock'] names, and get the rows resolveRows gives service:clock (events and jobs). A command’s events are recorded under the policy too.
  • Mail. A notify rule’s email: true mails a recipient only while the policy gives them that record, the same rows a session of theirs would get. send: { channel: 'email' } mails an address the record holds (email).
  • Refused. Raw SQL, MCP, the CLI’s export modes and the App API’s event feed. The server serves exactly one --app with a policy.
Terminal window
tablewalk check --app ./dealer --policy ./dealer/policy.ts
tablewalk --config server.json --app ./dealer --policy ./dealer/policy.ts
  • A person sees no rows at all → the policy left the resource out or answered false; name it, with true or a filter.
  • The App will not start under its policy → Policy startup error: is the policy module’s own fault, said with its cause. When the App or a connection refused, the line is the App’s: App error:, its cause, and the file and line that wrote it, as any App error is. The start is refused over resultReferences → it names each command to add and any to leave out; add an extractor for each, () => [] when its result names no other row, and none for a public: true command.
  • A delete is refused → add authorizeDeletedRecord to the policy; without it, deleting under a row policy is off.
  • [role-cannot-read] warnings still apply → a policy narrows rows, never columns; narrow columns with the role’s fields.
  • Roles and field access: tables and columns, before rows.
  • Tenant Apps: one App serving several organizations.
  • Commands: conflicts a hidden row may cause.
  • The authoring guide (docs/authoring.md): the policy contract in full.