Compose record access boundaries
Consumer reads follow one declarative model-level policy, consumer_reads. It decides what
a signed-in consumer of the application may read on a private model, on every consumer read path:
reference validation, edit-form loading, model-qualified record get and list, public action
targets, page bindings, and record pickers. Its row scope comes from the model’s
consumer_owner_query, a saved ownership query you author; there is no generic owner field or
per-record ACL table. Consumer update/delete ownership is the separate consumer_writes policy.
The application still composes the grant itself from generic pieces: workspace scoping, page
authentication, a grant model of your own, rules that write grants, and the ownership query. This
recipe covers those pieces and is explicit about where each one stops.
Treat tenant, workspace, and application ownership as the first record-access boundary. The platform documents applications, models, fields, and records as workspace-scoped by both tenant_id and workspace_id; create the CRUD resource in the intended application rather than treating a client-side filter as isolation.
Require authentication on a page that should only render to signed-in public users by setting Page.settings.requires_auth = true. The runtime requires both a valid public JWT and an active persisted session for protected access, and invalid, expired, revoked, cross-application, or mismatched sessions fail closed.
Authenticate a caller before evaluating its permissions, and keep identity verification separate from authorization. A valid public app session establishes the principal for the page-render surface; it does not grant generic record access. When no valid principal or active session is present, deny the protected request rather than continuing with an anonymous or stale identity.
Do not mistake page authentication for generic records permission. The documented public consumer JWT does not authorize generic record list, get, update, delete, or reorder endpoints; public pages must use the render/runtime data contract or another explicitly allowlisted public surface.
For machine or authoring access, grant only the API-key scope family the operation needs. The API reference defines separate pages:* scopes for page authoring, while the interactive-page guide distinguishes data access from authoring access and names records:* and model/application reads for the data tier. A request whose key lacks the required permission is denied with 403 api_key_permission_denied; do not treat a missing scope as a fallback to broader access.
Auto-add each signup to a default group
Section titled “Auto-add each signup to a default group”To add every new identity record to a default group_membership, create an active rule on the application’s identity model with trigger record_create and phase post_save. Apply this literal action JSON, replacing only the two UUID placeholders:
{ "type": "create_record", "target_model_id": "<group_membership-model-id>", "trigger_rules": false, "data": { "member": {"var": "record_id"}, "role": "<role_group-id>", "source": "signup" }}<role_group-id> is the UUID of the default role_group record in that application’s data, not a model UUID and not a role name. Re-bind it in every environment: rebuilding or re-importing an application creates new record IDs, and a copied rule can otherwise write memberships that point to a dead role-group reference.
This is deliberately a rule on identity-record creation, not a signup-only hook. It fires for every identity record create, including an administrator or API-key authored create. The member value uses record_id, which is the new app_user record ID; it is not the application creator or the acting administrator. Also treat this membership write as required: if the post-save create_record action fails and the rule is configured to abort on failure, signup fails and the originating identity record is not retained.
Compose the row-scope boundary
Section titled “Compose the row-scope boundary”This is the worked pattern for the common case: ordinary users share one application and each should see only the records granted to them. It was verified on the hosted runtime on 2026-09-05 against a two-user application (Contact plus Contact Permission). The blocks below use the model names resource, resource_perm, and app_user; if you substitute your own, substitute them consistently in every block. Replace each <...> placeholder with the generated id it names and {application_id} with your application’s id.
The technique has three cooperating parts. Each read or write path uses its own part, because there is no single row-scope switch.
| Operation | Path that enforces it | Where the identity decision comes from |
|---|---|---|
| Create a record | Authenticated public form, then record create, then a creator-grant rule | The validated form session supplies actor.id; the rule writes the grant |
| List records | Published page, then a saved ownership query joining resource to grant | The server binds current_user_id when the query executes |
| Direct record access | Closed on the resource model and on the grant model | records_api: "off" removes the generic list, get, update, and delete routes |
“Forms to saved queries” is the whole composition: the form writes, and the page’s data binding reads through the saved query. There is no form.saved_query_id setting in the working example. An authenticated page, or a hidden field holding a user id, is not a boundary by itself.
1. Model the resource and its grant
Section titled “1. Model the resource and its grant”app_user below is the application’s identity model (see authentication-basics). Create the protected model resource with its business fields; the saved query in step 3 selects title:
| Field | Type | Meaning |
|---|---|---|
title |
required text | the record’s display name |
Create the grant model resource_perm with one row per granted (record, user) pair:
| Field | Type | Meaning |
|---|---|---|
resource_id |
required reference to resource |
the protected record |
user_id |
required reference to app_user |
the grantee |
perm_bits |
required integer | stored permission mask; a full-access creator receives 15 |
granted_by |
required reference to app_user |
the granting actor |
granted_at |
required timestamp | time of the grant |
source |
required text | provenance, such as creator |
A reference field stores the referenced record’s id; set its settings.target_model_id to the
referenced model. Publish both models, then merge these keys into the grant model’s settings and
republish it. Merge only anonymous_access into the resource model’s settings: disable anonymous
read and create on both models, because the authenticated form is the intended write path.
{ "anonymous_access": {"read": false, "create": false}, "unique_constraints": [{"name": "resource_user", "fields": ["resource_id", "user_id"]}]}Add a composite constraint to a rule-written grant safely
Section titled “Add a composite constraint to a rule-written grant safely”For a grant model that a rule writes, add the (resource_id, user_id) constraint in this order:
- Dedupe the existing grant rows first. Publishing builds the index transactionally (not
CONCURRENTLY), so duplicate rows make the index build fail. - Publish the referenced fields before declaring the constraint. A constraint that names an
unpublished field is rejected with
unique_constraint_field_not_published. - Read the model’s complete settings object, merge in
unique_constraints, and write the complete merged object withupdate_model. Settings have replacement semantics, so sending only the constraint can otherwise discardrecords_apiandanonymous_access. - Republish the model to materialize the physical composite index.
Apply the same sequence to group_membership for (member, role) once its signup rule writes
one membership row per signup.
2. Declare the relationship explicitly
Section titled “2. Declare the relationship explicitly”A reference field on its own does not register a query relationship. Create it before you execute the join, or the query fails with Unknown relationship.
POST /api/v1/public/applications/{application_id}/relationships{ "name": "permissions", "type": "one_to_many", "sourceModelId": "<resource-model-id>", "targetModelId": "<grant-model-id>", "targetFieldId": "<schema field id of resource_perm.resource_id>"}targetFieldId is the grant model’s resource_id schema field id, not a resource record id. The compiler uses this declaration to generate the resource_perm.resource_id = resource record id equality. Do not add an id FieldRef to reproduce that equality: id is the record’s metadata UUID, not a published business field, so a public saved query that references it anywhere is rejected at execution with 422 Unknown public field: 'id'.
3. Define the saved ownership query
Section titled “3. Define the saved ownership query”POST /api/v1/public/applications/{application_id}/saved-queries{ "name": "Visible records", "isPublic": true, "ast": { "select": [ {"modelAlias": "resource", "fieldName": "title"} ], "fromModel": "resource", "fromAlias": "resource", "joins": [{ "type": "inner", "modelName": "resource_perm", "alias": "perm", "relationshipName": "permissions", "onPredicate": {"terms": [{ "left": {"modelAlias": "perm", "fieldName": "user_id"}, "right": {"parameter": "current_user_id"} }]} }] }}Use the reserved operand {"parameter": "current_user_id"} in the JOIN predicate. Do not replace it with a client parameter, a hidden form field, a template string, or a literal. When the query executes, the server substitutes the caller’s identity for that operand: the signed-in consumer’s identity (app_user) record id. The inner join therefore keeps only resources with a grant row whose user_id is the caller, so the row restriction lives in the ON predicate even though there is no top-level WHERE. Keep that inner join: the public query guard accepts other join shapes too, so it does not enforce ownership for you. Every model and field the query references must be published, and aggregates are rejected; docs/api.md, “Server-bound current_user_id and consumer ownership joins”, lists each restriction and the response it returns.
4. Write the creator grant with a server-owned rule
Section titled “4. Write the creator grant with a server-owned rule”Create an active record_create, post_save rule scoped to the resource model. Its action creates the grant from the session actor, and on_failure: "abort" makes the grant required:
POST /api/v1/public/applications/{application_id}/rules{ "name": "Grant the creator", "modelId": "<resource-model-id>", "trigger": "record_create", "phase": "post_save", "isActive": true, "condition": {"and": []}, "settings": {"on_failure": "abort"}, "actions": [{ "type": "create_record", "target_model_id": "<grant-model-id>", "trigger_rules": false, "data": { "resource_id": {"var": "record_id"}, "user_id": {"var": "actor.id"}, "perm_bits": 15, "granted_by": {"var": "actor.id"}, "granted_at": {"var": "time.utc_instant"}, "source": "creator" } }]}The rule reads the authenticated actor, never data.user_id. Submit through a model-bound public form whose settings are {"access": "authenticated"}; a direct model-bound page form can attribute creation to the application owner, which is why it must not be the creation surface.
5. Close generic access and bind the page to the query
Section titled “5. Close generic access and bind the page to the query”Read each model’s settings, merge in records_api: "off", write the complete merged settings back, and republish both models. The switch moves a model to draft, so query execution and form writes fail until you republish. Merge, do not replace: sending only {"records_api": "off"} to a full-settings interface can drop the uniqueness constraint and the anonymous-access policy.
Set the list page’s requires_auth: true and bind its list to the saved query rather than a direct model source:
{ "id": "visible-records", "type": "container", "binding": { "item_var": "record", "repeat_over": { "source": "saved_query", "saved_query_id": "<saved-query-id>" } }, "blocks": [ {"id": "visible-record-title", "type": "text", "content": {"text": "{{ record.title }}"}} ]}The page binding never names the user. The authenticated render resolves this container repeat on the server, the same way it resolves a record-list, data-table, or stat block whose source is {"source": "saved_query", ...}: the rows arrive in the block’s runtime.records, and the server binds current_user_id from the validated session exactly as in step 7 (see “Where each piece stops”). The browser does not execute the query itself. The render never forwards page or URL parameters as current_user_id: a URL such as ?current_user_id=<another user's id> does not change the rows.
Every block that exposes protected data must read through this constrained path. records_api: "off" does not retrofit a row filter onto a direct model binding elsewhere in the application.
6. Set the consumer update/delete policy
Section titled “6. Set the consumer update/delete policy”settings.consumer_writes gates consumer update and delete, and off also refuses consumer
create. Any other value leaves create governed by records_api and the existing scope checks;
ownership only applies to update and delete. Its values are:
off: consumer create, update, and delete answer404(Record not found).any_authenticated: any authenticated consumer in the application’s tenant, workspace, and application scope may update or delete. Existing models were migrated to this value so operators can tighten access deliberately.owner: only the owner may update or delete.
A model whose settings do not contain consumer_writes behaves as off: consumer create,
update, and delete answer 404 until the setting names a policy.
The policy applies only while the model’s records_api is on. This recipe’s step 5 sets
records_api: "off", which already answers consumer create, update, and delete on both models
with 404, so consumer_writes decides nothing until you enable records_api on a model whose
records consumers may edit.
Use owner with settings.consumer_owner_query for new protected models; it is the recommended
setting. consumer_owner_query is the UUID of a builder-authored saved query that references
current_user_id somewhere (a JOIN parameter, as in this recipe’s ownership join, or a WHERE
placeholder ":current_user_id") and projects the target record id as id, for example
{"modelAlias": "resource", "fieldName": "id"}. The update or delete is permitted only when a
returned row’s id equals the target record’s id, so the step 3 query, which projects only
title, is not itself a valid owner query. The runtime supplies current_user_id from the
authenticated consumer’s record ID and passes no other parameters, so the client cannot choose
the owner. The
query runs with builder shape: any number of joins, any join type, compound ON predicates, CTEs
and subqueries are fine, so a “walk-up” owner query (the entity, its parent, the parent’s grant
table with scope = "subtree") is a valid owner query. If no ownership query is configured,
owner instead permits only records where created_by == actor.record_id.
The ownership query must reference current_user_id (a query that never binds the actor would
grant every consumer the same rows, so it is rejected before execution) and belong to the target
record’s tenant, workspace, and application. A missing query, a query without current_user_id,
an out-of-scope query, or an execution failure denies the update or delete. Model settings use
replacement semantics:
an update_model settings update that omits consumer_writes resolves to off, so an omitted
key fails closed. Invalid values return invalid_consumer_writes; an invalid non-UUID
consumer_owner_query returns invalid_consumer_owner_query.
Before changing migrated models, list the ones that still allow any authenticated consumer to write and review each model’s intended ownership relationship:
SELECT id, application_id, name, settingsFROM modelsWHERE settings ->> 'consumer_writes' = 'any_authenticated'ORDER BY application_id, name;Set the consumer read policy
Section titled “Set the consumer read policy”settings.consumer_reads decides which rows of a private model (anonymous_access.read: false)
a signed-in consumer of the same application may read. Every consumer read path consumes the same
decision: submitted reference validation on create, update, and public actions; edit-form loading
(GET /api/v1/public/apps/{application_id}/records/{record_id}); model-qualified get and list
(/api/v1/public/apps/{application_id}/models/{model_id}/records); public action targets;
ordinary page bindings (record-list, repeats, record_detail, drag-and-drop lists); and
record pickers. Its values are:
any_authenticated: every eligible row, even whenconsumer_owner_queryis also configured (the retained query then decides nothing for reads;consumer_writes: "owner"may still use it).owner: only the rowsconsumer_owner_queryreturns for the consumer. Savingownerwithoutconsumer_owner_queryis rejected withconsumer_reads_owner_requires_owner_query.off: identical to an absent key, which keeps the earlier behavior: a configuredconsumer_owner_queryscopes the consumer to its rows (grants and exclusions alike), and a private model without one grants consumers no rows.
Any other value is rejected with invalid_consumer_reads, and a non-UUID owner query still
returns invalid_consumer_owner_query. The owner query follows the step 6 contract: it binds
current_user_id, projects the target record id as id, belongs to the model’s application, and
fails closed on any doubt. For example, merged into the resource model’s complete settings:
{ "records_api": "on", "anonymous_access": {"read": false, "create": false}, "consumer_reads": "owner", "consumer_owner_query": "<owner-query-id>", "consumer_writes": "owner"}The grant never widens anything else. Application, tenant, and workspace scope, records_api: "off", and published-schema and published-field governance apply first; a consumer signed into
another application is treated as anonymous. Owner-scoped list rows are filtered in the query
itself, before pagination and total, so excluded rows are never counted. Anonymous callers are
governed only by anonymous_access.read: with read: false they are denied on every path even
under any_authenticated, and with read: true the public rows stay public whatever
consumer_reads says. A page stat block over an owner-scoped model resolves no value, because an
aggregate cannot be narrowed to the granted rows.
consumer_reads grants no writes and no actions. consumer_writes alone authorizes consumer
create, update, and delete; a public record action needs both a readable target and the model’s
public_record_actions ACT permission, so a readable target does not permit an undeclared action
and ACT never executes against a row the read policy hides (404). A public profile action on the
identity model therefore needs a read grant for the consumer’s own identity record, such as
consumer_reads: "owner" with an owner query that selects it. Settings use replacement semantics,
so an update_model settings update that omits consumer_reads or consumer_owner_query
removes it.
7. Prove it with two users
Section titled “7. Prove it with two users”Execute the saved query in each user’s own consumer session at POST /api/v1/applications/{application_id}/queries with body {"savedQueryId": "<saved-query-id>"}. Do not send current_user_id; the server binds it. Each user receives only the records they created, and a signed-in user with no grants receives zero rows. A client that supplies params.current_user_id is rejected with 422 whose validation message is current_user_id is server-bound and cannot be supplied in params, and an anonymous caller receives no rows: 400 Missing query parameter: current_user_id. With the switch off, a direct record list or get on either model through /api/v1/public/apps/{application_id}/models/{model_id}/records returns 404 naming settings.records_api. After a form submission, read the grant back and confirm user_id and granted_by are the submitting user’s identity record id. Verify in a real browser with two separate sessions, not only through the API.
Where each piece stops
Section titled “Where each piece stops”The pattern above is a real, server-enforced boundary for the paths it covers. These are its edges, stated once:
- A bound filter is presentation, not a boundary. A
record-list,data-table, or repeat bound to a model with a filter such as{{current_user.id}}shows the viewer the right rows, but nothing stops a different read path from returning other rows. The server-bound saved query in step 3 is the boundary; the bound filter is not. - It checks that a grant exists, not what it permits. The join qualifies any matching grant row, including a zero-bit row, because it does not inspect
perm_bits. Keep grants limited to full-access creator rows until bit-specific enforcement is built and tested. - It is verified for create and list. Search, detail, edit, delete, and self-service sharing each need their own composition and their own acceptance tests. For search and detail, preserve the server-bound ownership join and let a caller-supplied selector narrow the result, never replace the actor restriction. For update and delete, enable
records_apion the model and useconsumer_writes: "owner"with aconsumer_owner_querythat binds the actor and projects the target’sid(step 6). - The record and its grant are two writes. Configure required grant rules to abort on failure; the create path compensates by removing the originating record when an aborting post-save action fails. Test this behavior whenever you add further rule actions.
- The read policy scopes rows; it does not create grants.
consumer_reads(see “Set the consumer read policy”) applies one decision on every consumer read path, but which rows a consumer owns is whatever theconsumer_owner_queryyou author returns. The platform ships the composable pieces above, the per-modelrecords_apiswitch (ADRdocs/adr/2026-09-03-per-model-records-api-switch.md),consumer_readsfor consumer reads, andconsumer_writesfor consumer update/delete; the application composes its grants from them. - Who executes the saved query decides its shape and visibility, not who reads the result. Step 7’s
POST /api/v1/applications/{application_id}/queriescall and any page-render binding execute withpublic_saved_query=True: every model and field the query references anywhere must be public, and a client can never supplycurrent_user_id. Identity on a render comes only from the authenticated-render exception (ADRdocs/adr/2026-09-21-saved-query-full-expressiveness.md, ruling 8, amended 2026-09-27): when the published page’s session validation established a consumer identity, the render executor bindscurrent_user_idserver-side from that validated identity record id — so arecord-list,data-table, orstatblock bound to this recipe’s ownership query resolves in the render itself, with zero follow-up query requests, and each consumer receives only their granted rows. The step 5containerrepeat is not resolved in the render; the browser executes it under the same session (step 5). URL or binding parameters cannot supply or override the id; cross-application, revoked, expired, or stale-version sessions fail closed. Anonymous renders are unchanged: no identity is bound, the anonymous visibility walk applies, and an identity-scoped query stays unresolved rather than executing for the wrong (or no) caller. This is equivalence, not new access: the render executes exactly what the same consumer could execute on the public query route, under the same public-field visibility, shape, and query-budget rules. A rule or service executing the same ownership query on the consumer’s behalf (seemcp-consumer-tools,require_query) runs withpublic_saved_query=Falseinstead: builder shape and builder field visibility apply, andcurrent_user_idis server-bound to the event actor. The practical consequence: a page reads public-field data by binding a public saved query directly, and a signed-in consumer’s identity-scoped public rows resolve through that same binding during an authenticated render; private fields remain reachable only through a rule or service executing a builder query on the consumer’s behalf, never by loosening the public path. Seedocs/adr/2026-09-21-saved-query-full-expressiveness.md, ruling 8.
