Data policies
Protecting data — datasets, their targets, read and write policies, and the SQL your application enforces.
Data policies are the half of the access model that decides which rows an operation may see or change. This page covers the data-specific pieces: how tables become datasets, how targets scope them, what READ and WRITE policies express, and what enforcement actually receives — rendered SQL, not policies. The shared anatomy lives in the Access model, and the operation 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 — database schema and 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 absent or the literal true — those policies are whole-dataset grants, gated by their condition.
Read and write policies
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 seeded demo shows the full range:
| Policy | Type | 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 | WRITE | true | data.owner_id = user.id and data.status = "draft" |
| Regional manager draft editing | WRITE | user.role = "manager" | data.region = user.region and data.status = "draft" |
| Finance draft editing | WRITE | user.department = "finance" | data.status = "draft" |
| Admin sees all data | READ, ALL target | user.role = "admin" | true |
| Admin edits all data | WRITE, ALL target | 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. WRITE policies mirror their READ counterparts with tighter filters: managers see their region but edit only its drafts.
Filters of the applicable policies OR-combine. No applicable policy means no rows — deny by default, data-side.
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 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 --user '{"id":"u-42","role":"manager","region":"EU"}'dataset: demo_billing.public.invoice
impact: READ
condition: "public"."invoice"."region" = 'EU'
applied by
policy 51ddda43-… FALSE
policy ea0e28bd-… "public"."invoice"."region" = 'EU'
not applied
policy 8638f33e-… (condition false)
policy a92c8d4c-… (condition false)Two policies applied to this manager. The ownership filter rendered FALSE for this user and dissolved in the OR — the regional filter carried the condition.
Write checks are one SQL statement returning a single boolean: true means the mutation touches no forbidden row. The SDK executes it inside the mutation's own transaction and rolls back on false. When it runs is part of the contract:
| Mutation | Check runs | Why |
|---|---|---|
| CREATE | after the insert | the new rows must exist to be evaluated — a forbidden post-image rolls back |
| UPDATE | before | only rows currently in the writable set may be touched |
| DELETE | before | the rows must still exist to be evaluated |
The check gates the pre-image — which rows the mutation may touch. A row may legitimately leave the writable set as a result of the update, the way a draft becomes issued.
Ids travel as strings and are cast using the dataset's primary-key type. Omitting them leaves a {{ids}} placeholder to substitute later. 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.
The SDK wraps both calls as buildReadCondition and buildWriteChecks, and both fail closed: a network failure or a degraded mode narrows access to nothing rather than widening it. arkveil abac read and arkveil abac write are the CLI equivalents of the same two 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 touch 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 two endpoints from a local mirror of the model and keeps answering while Arkveil Cloud is 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 operation half of the model.
-
Access model — the shared anatomy, including the formula language filters use.
-
SDK —
buildReadCondition,buildWriteChecks, and the helpers around them. -
Policy tests — dataset tests assert exactly these row sets over fixtures.