Data policies
Decide which rows may be seen, touched or produced — with targets that select the datasets and READ, TOUCH and RESULT policies that grant the rows, compiled to SQL your application enforces.
Data policies are the half of the access model that decides which rows an operation may see, touch or produce. This page covers the data-specific pieces: how tables become datasets, how targets scope them, what READ, TOUCH and RESULT policies express, and what enforcement actually receives — rendered SQL, not policies. The shared anatomy lives in the Access model, and the action side in Action policies.
Datasources and datasets
A datasource registers a database. A dataset registers one protected table under it and gives it the canonical code every data feature keys on: datasource.schema.table, three lowercase dot-separated segments. The seeded demo ships one datasource, demo_billing, with two datasets — demo_billing.public.invoice and demo_billing.public.payment.
A dataset's registration carries three things policies build on:
- Identity — the datasource it belongs to, the database schema and the table name, immutable after creation.
- The primary key — column name and type (
UUID,LONG, orSTRING). Write checks cast id lists with it. - A data schema — a JSON Schema naming the columns filters may read as
data.<column>, flat scalar types only for now.
References are validated when a policy is saved: an unknown column or a nested path fails at authoring, not at runtime. Editing a data schema re-validates every attached policy in the same transaction and aborts the edit if any filter would break, naming the offending policies.
Data targets
- INDIVIDUAL — exactly one dataset, by code. The demo's Invoices and Payments targets.
- CUSTOM — a group selected by a condition over the dataset's identity. This is where the
dataset.*namespace lives —dataset.datasource,dataset.schema,dataset.table,dataset.code, holding the canonical lowercase segments.dataset.table = "invoice"covers the invoice dataset of every datasource, anddataset.code startsWith "demo_billing.public."covers one schema of one datasource. - ALL — every dataset in the workspace. The demo's data-side Admin access target.
Row filters follow one rule: a row predicate is only meaningful when the protected table is known, so real filters exist on INDIVIDUAL targets only. On CUSTOM and ALL targets the filter is the literal true — those policies are whole-dataset grants, gated by their condition. Omitting the filter is not a shortcut for true: a policy with no filter contributes no rows to any grant.
The three policy types
A data policy has two formula slots with distinct jobs. The condition decides whether the policy applies to this request — it reasons over user.* and context.*. The filter decides which rows it opens — it adds data.*. The policy's type decides which question that filter answers:
| Type | Question | Operations | Judged |
|---|---|---|---|
| READ | which existing rows may the user see | — | at read time |
| TOUCH | which existing rows may a mutation touch | a subset of UPDATE and DELETE | before the mutation, on the current state |
| RESULT | which row states may the user produce | a subset of CREATE and UPDATE | after the mutation, in the same transaction |
TOUCH and RESULT policies also declare the write operations they govern. The set is required and nothing is pre-selected — a policy's blast radius is chosen deliberately, and it stays visible in the grant itself. "Who can delete" remains a trivial audit: filter policies by type and operation. The split is also what makes minimal grants expressible — a grant no wider than the task it serves: delete-only cleanup, create-only import, update-only reconciliation.
The seeded demo shows the full range on the invoice dataset, plus one workspace-wide admin grant:
| Policy | Type | Operations | Condition | Filter |
|---|---|---|---|---|
| Own invoice visibility | READ | — | true | data.owner_id = user.id |
| Regional manager visibility | READ | — | user.role = "manager" | data.region = user.region |
| Finance sees all invoices | READ | — | user.department = "finance" | true |
| Own draft editing | TOUCH | UPDATE, DELETE | true | data.owner_id = user.id and data.status = "draft" |
| Regional manager draft editing | TOUCH | UPDATE, DELETE | user.role = "manager" | data.region = user.region and data.status = "draft" |
| Finance draft editing | TOUCH | UPDATE, DELETE | user.department = "finance" | data.status = "draft" |
| Own draft authoring | RESULT | CREATE, UPDATE | true | data.owner_id = user.id and data.status = "draft" |
| Own invoice issuing | RESULT | UPDATE | true | data.owner_id = user.id and data.status = "issued" |
| Admin edits all data | TOUCH | UPDATE, DELETE | user.role = "admin" | true |
Read the columns separately and the model falls into place. A condition of true means the policy applies to everyone — the filter still constrains what it opens. A filter of true is a full-access wildcard — the condition still decides who gets it. The last row shows both at once: it is one of an admin trio (READ, TOUCH, RESULT) on the data-side Admin access target, whose true filters open every dataset for administrators.
Filters of the applicable policies OR-combine — per type, and for the mutation types per operation. Another TOUCH policy widens what may be touched, the way another READ policy widens what may be seen. An operation whose union has no applicable policy is denied whole — deny by default, data-side.
Two demo rows repay a second look. Own invoice issuing governs UPDATE only: an owner may move their own invoice into the issued state, but cannot create an invoice already issued, because no RESULT policy opens that state for CREATE. And an UPDATE draws on two unions at once — TOUCH policies admit the existing rows into the mutation, RESULT policies admit the states it leaves behind. A user holding only a TOUCH grant cannot update at all. The missing RESULT half denies — neither half ever implies the other.
What enforcement receives
Your application never receives policies. It receives SQL, rendered per request.
Read conditions are one boolean expression to AND into your query's WHERE clause, columns qualified by the table or by the alias you pass. FALSE without a reason is a normal answer meaning "no applicable policy — no rows", not an error. The CLI renders the same thing for inspection:
arkveil eval explain-dataset -d demo_billing.public.invoice --operation READ \
--user '{"id":"u-42","role":"manager","region":"EU"}'dataset: demo_billing.public.invoice
operation: READ
read condition: ("public"."invoice"."owner_id" = 'u-42' OR "public"."invoice"."region" = 'EU')
applied by
policy 94c7e9b0-… "public"."invoice"."owner_id" = 'u-42'
policy 5b27e7a5-… "public"."invoice"."region" = 'EU'
not applied
policy b0b047dc-… (condition false)
policy 48f8e615-… (condition false)Two policies applied to this manager, and their filters OR-combine. The ownership filter opens the invoices this user owns. The regional filter opens every invoice in the user's region. The finance and admin policies stayed out, because their conditions are false for a manager.
Attribute values are typed by their schemas. A value that does not match its declared type counts as absent. If the user schema typed id as uuid, the ownership filter above would contribute no rows. The condition would narrow to the regional filter alone. Such a value is not dropped silently. The read condition your application receives then carries reason: ATTRIBUTE_INCOMPATIBLE.
Write checks are SQL statements returning a single boolean — one per phase the mutation has. The TOUCH check runs before the mutation, against the rows as they are. The RESULT check runs after it, in the same transaction, against the state the mutation produced. true means every checked row is admitted by that phase's union. The SDK executes both inside the mutation's own transaction and rolls back on false. Which checks exist, and when each runs, is part of the contract:
| Operation | TOUCH check | RESULT check |
|---|---|---|
| CREATE | — | after the insert, over the inserted rows |
| UPDATE | before, over the requested rows | after, over the same rows |
| DELETE | before, over the requested rows | — |
An absent check means the operation has no such phase — never "allow". The TOUCH check gates the pre-image, so a permitted update may legitimately move a row out of the touchable set, the way a draft becomes issued. What states may come into existence is the RESULT check's question, answered separately. The two phases follow the same proven pre-state/post-state distinction used by PostgreSQL row-level security — with one deliberate difference. On named rows Arkveil never narrows a mutation silently: if any requested row fails a check, the whole mutation is denied and rolled back.
The checks answer about named rows. Ids travel as strings and are cast using the dataset's primary-key type. For UPDATE and DELETE the id list is required, and the SQL comes back with the ids inlined. The ids of created rows exist only after the insert, so a CREATE check comes back with an {{ids}} placeholder the SDK fills once the insert has run. Rows that don't exist are not a violation — deleting an already-deleted id stays idempotent. And a well-formed code naming an unregistered dataset answers with a constant-false check and reason: METADATA_MISSING — a configuration gap to surface, not a policy denial.
Together the two phases add up to complete row-level mutation authorization for synchronous SQL writes — every insert, update and delete answers to the model.
Bulk mutations by predicate
A bulk statement names a condition, not rows — UPDATE … WHERE status = 'stale' knows its rows only after it runs. For that shape the platform serves the mutation's touch condition: the TOUCH union as a bare boolean fragment, aliased like a read condition. Your application composes it into the bulk statement's WHERE clause, so rows outside the touchable set are simply not touched. That narrowing is deliberate and visible in the query — the same trust tier as read filtration.
A composed bulk UPDATE still owes the RESULT check over the rows it actually changed: collect their ids from the statement, then run the write check's RESULT statement in the same transaction. A bulk DELETE is complete with the filter alone — a delete has no result phase. When the ids are known upfront, prefer the named-rows write checks: deny-whole over the enumerated rows is the stricter promise.
The SDK wraps the three calls as buildReadCondition, buildWriteChecks and buildTouchCondition, and all of them fail closed: a network failure or a degraded mode narrows access to nothing rather than widening it. arkveil abac read, arkveil abac write and arkveil abac touch are the CLI equivalents of the same three calls.
Why read and write integrate differently
The asymmetry is the model, not an accident. A read condition must be woven into the application's own query construction — only the application knows its query shapes, joins, and aliases, so the SDK hands it a condition and stays out of the query. A write check wraps one concrete mutation transaction, so the SDK can own its execution entirely — same transaction, defined timing, automatic rollback.
Runtime mechanics
The conditions endpoints never connect to your database. They render SQL, and every piece of enforcement SQL runs in your application's own database session — read conditions inside your queries, write checks inside your transactions. The arkveil-runtime sidecar serves the same endpoints from a local mirror of the model and keeps answering even if Arkveil Cloud becomes unreachable. It needs connections to your databases only for one thing, and read-only: evaluating dataset lookups inside permission conditions. Rendered SQL is PostgreSQL-flavored today.
Next steps
-
Action policies — the action half of the model.
-
Access model — the shared anatomy, including the formula language filters use.
-
SDK —
buildReadCondition,buildWriteChecks,buildTouchCondition, and the helpers around them. -
Policy tests — dataset tests assert exactly these row sets over fixtures.