Build a multi-user SaaS portal
Build a portal that three kinds of visitor use: an anonymous visitor who sees a public home page, a signed-in user who sees a dashboard of only their own records, and an administrator who sees an elevated view. This is the shape most SaaS applications share, and it is composed from platform primitives rather than switched on. Each step reuses a recipe for the mechanism and adds only the connective decisions the recipe does not cover.
Decide the boundary before you build. Name the resource users own, who may read and write it, and which pages are public. The public home page is unauthenticated, the user dashboard requires a signed-in user and shows only granted records, and the admin dashboard is an application-role decision layered on the same ownership pieces. Follow the steps in order; the collector preserves their explicit order as positions.
1. Model the resource and its ownership grant
Section titled “1. Model the resource and its ownership grant”Define the resource users will own and a grant model that records which user may see which record. Plan the list fields and sort for the dashboard while you are here, and keep the grant model private.
Start a CRUD app with one model for the resource users will create and manage. In MeroFoundry, application data is stored dynamically: models describe the resource and its fields, while records carry the resource values. Keep the first model small—only the fields needed for the create form and list—so the page and form have a clear, shared contract.
Place the model in the intended application and workspace before creating records. Applications, models, fields, and records are workspace-scoped by tenant_id and workspace_id; that scope is part of the data boundary, not a display-only label.
For the list view, use a data block with the model id. The documented record-list example accepts model_id, page_size, sort, and order, and shows selected record fields through content.columns. Begin with an explicit sort such as created_at descending so the newest created record appears predictably.
2. Build the public home page
Section titled “2. Build the public home page”Author the landing page every anonymous visitor sees. Leave its authentication gate off, keep it free of any protected record binding, and give it a clear path to sign in or sign up. Treat it as marketing plus a door, not as a data surface.
Make each interaction state visible in the page itself: label inputs, use a submit control for the write, and provide success feedback with on_success_toast. These are supported form-block configuration points, so the feedback does not depend on an undocumented custom flow.
Keep the create form and its list close together when the user needs to see the result. Form submissions update the client cache, and record-list and data-table blocks re-render when records change, allowing the interface to show the completed action without navigation.
Use local state deliberately. Expression comparisons treat both sides of == and != as dotted paths rather than literals, so seed a comparison value into local state when a block needs to compare against a fixed value.
Avoid path navigation for minor in-page state changes: ? and # links are shallowly routed, while a same-application path-changing href uses client-side navigation. Reserve redirects for a genuine change of page or workflow.
3. Let users sign in
Section titled “3. Let users sign in”Add authentication so a visitor becomes a known application user. Verify that a real user can sign in and that their identity, not the builder’s, is the actor the runtime sees. Under the default registration_email_verification: required, registering issues no session: the user confirms the mailed link, which signs nobody in, and then signs in. Authentication establishes who the user is; it does not by itself decide which records they may read. Then follow Secure authentication for your app (secure-authentication) to choose the verification mode, password length and Turnstile, and to wire password reset, change-password and sign-out-everywhere.
Authenticate the caller before evaluating permissions. Keep identity verification separate from authorization, use narrowly scoped credentials, and fail closed when the principal or required scope is missing.
What a signed-in consumer may read
Section titled “What a signed-in consumer may read”Signing in establishes who the consumer is; it does not by itself open a private model. A model
whose anonymous_access.read is false grants a signed-in consumer of the same application only
what its settings.consumer_reads says:
any_authenticated: every eligible row, even whenconsumer_owner_queryis also set.owner: only the rows the model’sconsumer_owner_queryreturns for the consumer;ownerwithout that query is rejected at save time (consumer_reads_owner_requires_owner_query).- absent or
off: unchanged legacy behavior. A configuredconsumer_owner_querystill scopes the consumer to its rows; without one the consumer reads no private rows.
The same decision applies on reference validation, edit-form loading, model-qualified record get
and list, public action targets, page bindings, and record pickers. A consumer signed into a
different application is treated as anonymous, and anonymous callers follow only
anonymous_access.read, so any_authenticated never opens a model to signed-out visitors and
never restricts a model that is already publicly readable. Reading grants nothing else:
consumer_writes alone decides consumer create, update, and delete, and a public record action
still needs the model’s public_record_actions permission. Model settings use replacement
semantics, so send the complete merged settings object. See row-level-security, “Set the
consumer read policy”, for the owner-query contract.
Registration outcomes
Section titled “Registration outcomes”Self-registration proves email ownership by default (identity_settings.registration_email_verification
is required). Handle all three modes and the duplicate case in every registration form:
required: the registration POST answers202withregistered: trueandemailVerification: "pending"and no session. Show “check your email”; do not redirect to a signed-in page. The user follows the mailed link, lands onverification_redirect_path(default/) withemail_verification=verified, and then signs in.optional: the usual session response withemailVerification: "sent"; the user is signed in andactor.email_verifiedstays false until the link is followed.off: the usual session response; the identity starts verified.- An email that already has an account gets the
requiredanswer underrequiredandoptional, and a value-free409underoff.
Page-builder form blocks on the identity model already present these outcomes. Turning on
required or optional needs an outbound sender; without one the settings save is refused.
Secure the sign-in
Section titled “Secure the sign-in”Email normalisation and uniqueness, enumeration-safe answers, rate limits, the per-account login
backoff, refresh rotation and revocation on password change are automatic. Choose the
verification mode, password_min_length and registration Turnstile, and wire forgot-password,
reset-password, change-password and sign-out-everywhere as described in
Secure authentication for your app (secure-authentication).
Never build reset or verification tokens from rules and records, and never list a password
field in a public record action.
4. Build the signed-in user dashboard
Section titled “4. Build the signed-in user dashboard”Gate the dashboard page on a signed-in user and bind its list to the saved ownership query so each user sees only the records granted to them. This is the row-scope boundary: the server binds the current user on query execution, the creator-grant rule writes the grant on create, and closing the generic record routes removes the side doors. Apply the recipe to the resource from step 1.
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.
5. Add create and edit on the dashboard
Section titled “5. Add create and edit on the dashboard”Give the dashboard an authenticated form so a user can add and update their own records, and show the result in their list after a successful write. The creator-grant rule from the previous step is what makes a newly created record visible to its creator and no one else.
Use a form block for the create operation and set source.target_mode to "create" (the documented default) with the target model_id. Add a clear on_success_toast so the user receives an immediate confirmation after the write.
Inside the form, bind each input to its model field with binding.field and save_mode: "submit", then include a button whose display type is submit. The documented example uses this shape to create a record from the submitted values.
Keep create and edit intent explicit: the form source uses target_mode: "edit" for PATCH behavior and needs the target record_id; use target_mode: "delete" only for an intentional delete control.
After a successful form write, the client cache is updated and data blocks re-render without a page reload unless on_success_redirect is configured. Pair the form with a model-backed list on the same page to make the create result visible in the normal CRUD flow.
6. Build the admin dashboard
Section titled “6. Build the admin dashboard”An administrator is an application role, not a platform superuser, so build the admin view from the same pieces with a wider grant. Choose how an admin gains reach: an admin-scoped saved query that omits the per-user ownership join, or an explicit admin grant the role receives. Gate the admin page on the admin role and keep the generic record routes closed for everyone; an unauthenticated or broadly readable admin surface undoes the boundary the user dashboard depends on.
Recipe: Compose a shared site shell and landing page
Section titled “Recipe: Compose a shared site shell and landing page”Use the same shell on every page so navigation, identity, and the way visitors reach the footer remain stable. The shell has a header composition followed by page content and a footer composition. Give the outer containers the site-header and site-footer classes: these are useful semantic hooks for a Style’s layout CSS, while the blocks themselves remain canonical base blocks.
Shared navigation and header
Section titled “Shared navigation and header”The header is a full-width container with a brand heading and a canonical navigation-menu. Keep the primary links short and predictable; use shallow links for pages in the same site and reserve external URLs for destinations that genuinely leave the site.
Annotated copyable composition:
{ "blocks": [ { "id": "site-header", "type": "container", "display": {"layout": "flex", "direction": "row", "gap": 24, "full_width": true}, "style": {"class": "site-header items-center justify-between border-b px-6 py-4"}, "blocks": [ {"id": "site-brand", "type": "heading", "content": {"text": "Acme", "level": "h2"}}, {"id": "site-navigation", "type": "navigation-menu", "content": {"items": [ {"label": "Home", "href": "/"}, {"label": "About", "href": "/about"}, {"label": "Contact", "href": "/contact"} ]}}, {"id": "site-cta", "type": "button", "content": {"label": "Get started", "href": "/start"}, "display": {"variant": "default"}} ] } ]}Shared footer
Section titled “Shared footer”Put the footer after page-specific content. A short text block and a second navigation menu are enough for a small site; add only links visitors need for trust, support, or legal requirements.
Annotated copyable composition:
{ "blocks": [ { "id": "site-footer", "type": "container", "display": {"layout": "flex", "direction": "column", "gap": 12, "full_width": true}, "style": {"class": "site-footer border-t px-6 py-10"}, "blocks": [ {"id": "footer-copy", "type": "text", "content": {"text": "© 2026 Acme. Built with care."}}, {"id": "footer-navigation", "type": "navigation-menu", "content": {"items": [ {"label": "Privacy", "href": "/privacy"}, {"label": "Terms", "href": "/terms"} ]}} ] } ]}Theme contract
Section titled “Theme contract”Theme the whole site through shadcn CSS custom properties in a Style’s custom_css. Define tokens such as --primary, --card, --radius, and their related foreground, surface, border, input, ring, and background variables with oklch(...) values. This makes every page using the Style consistent. Do not target component classes and do not invent a data-block-type attribute: the renderer does not emit that attribute, so either approach matches nothing and fails silently. Use style.class on the shell and sections for layout and spacing only; token declarations are the theme boundary.
LANDING-PAGE COMPOSITION
Section titled “LANDING-PAGE COMPOSITION”Assemble a simple landing page in this order: shared shell -> layout-hero -> layout-feature-grid -> layout-contact-form -> shared footer. Start with the header composition, then append each referenced recipe’s top-level blocks in that order, and finish with the footer. Fetch a recipe by slug from /llmrecipes/<slug>.txt and adapt its IDs, copy, and tenant-specific references before publishing. The page-level skeleton is intentionally only an ordering guide:
{"blocks": ["shared header", "layout-hero", "layout-feature-grid", "layout-contact-form", "shared footer"]}Keep one consistent section rhythm, normally using comparable vertical padding on each referenced section and enough gap for the content to scan as separate bands. Drop the feature grid when the offer needs one focused explanation, or drop the contact form when the page’s only conversion is a link. Reorder only when the visitor’s decision path demands it—for example, put the contact form before the feature grid for a service request. Do not copy the referenced recipes’ JSON trees into this page; their slugs are the source of truth and prevent the assembled guidance from drifting.
7. Expose each user’s data to their own agent over MCP
Section titled “7. Expose each user’s data to their own agent over MCP”Let a signed-in user point their own AI agent at the portal and get back only their rows. Serve an MCP endpoint for the application, publish a tool over the same ownership saved query the dashboard uses, and bind it with a tool_called rule. The row scope is not re-derived for MCP: the runtime binds the calling user as current_user_id on the same server-bound join, so the tool is scoped to whoever authenticated the call and to no one else. This is the machine-facing twin of the user dashboard, and it inherits that boundary rather than adding a new one.
Recipe: Serve row-scoped tools to consumers over MCP
Section titled “Recipe: Serve row-scoped tools to consumers over MCP”Give a signed-in consumer’s own AI agent a tool that returns only that consumer’s rows. The row scope is the same server-bound ownership boundary the dashboard already uses; MCP does not add a second authorization path. This recipe binds an existing catalog tool to the existing ownership saved query so tools/call is scoped to the calling consumer.
Preconditions: an ownership saved query with a current_user_id-bound inner join (row-level-security), a catalog tool for it (mcp-tool-catalog), and a served, enabled endpoint (mcp-endpoint). The consumer query guard itself permits any join shape and ON predicate as long as every referenced model and field is public, and identity is available only through the server-bound current_user_id parameter — this recipe’s row scope depends on the inner ownership join specifically, so keep it.
This recipe’s require_query action executes the ownership query with public_saved_query=False (rule_saved_query_executor.py), not the public HTTP route’s public_saved_query=True. Under this execution context the query is checked against builder shape and builder field visibility rather than the public-field walk, and current_user_id is server-bound to the event actor — the consumer the rule is acting on behalf of, not a caller-suppliable value. The ownership join compiles under this flag exactly as it does under the public flag, so the same saved query serves both this recipe and a page-render binding. See docs/adr/2026-09-21-saved-query-full-expressiveness.md, ruling 8.
1. Bind the tool to the ownership query
Section titled “1. Bind the tool to the ownership query”Create two application-scoped rules (model_id null) triggered by tool_called, each scoped to the one tool by name so unrelated tools do not fire them:
{"==": [{"var": "event.tool_name"}, "list_my_contacts"]}Query rule, trigger tool_called, phase post_save. It runs the ownership saved query and stops the tool if the caller has no rows:
{ "type": "require_query", "params": { "saved_query_id": "<ownership-saved-query-id>", "message": "No visible contacts for this consumer", "template": {} }}Leave template empty. current_user_id is server-bound and cannot be supplied; naming it in the template is rejected. The runtime binds it from the calling consumer’s identity, exactly as the dashboard’s query execution does.
Response rule, trigger tool_called, phase response. It shapes the tool’s result body from the rows the query returned:
{ "type": "patch_response", "patches": [ {"op": "replace", "path": "/body", "value": {"contacts": {"var": "query_rows"}}} ]}query_rows is the output of the query rule. The two rules cooperate: the post_save rule produces the rows, the response rule renders them into the tool result.
2. Where the identity comes from
Section titled “2. Where the identity comes from”The calling consumer authenticates the MCP request with the consumer OAuth bearer their MCP client obtained through the application host’s consent page: Authorization: Bearer <consumer OAuth access token>. The client adds https://<app host>/mcp as a remote connector, the consumer signs in as an application user in the browser tab it opens, reviews the client and scopes on https://<app host>/oauth/authorize, and approves; the client exchanges the code at https://<app host>/token. The grant’s subject is the consumer’s app_user record, and the runtime binds it as current_user_id for the ownership join. The consumer never supplies an identity, and a tool argument can never set it.
The bearer is issued for the endpoint’s one audience, the inbound URL of the service in mcp_settings.endpoint_service_id, so it is accepted on both the /mcp route and the inbound route. A consumer session token sent as a bearer, the consumer session cookie and the endpoint service’s API key are not MCP credentials: each gets the 401 that starts the client’s sign-in (mcp-endpoint, section 4). See the customer guide Connect an agent to your application.
Two guarantees follow, both fail closed:
- Unauthenticated call. No consumer bearer, so the call is refused with
401before any tool runs; with anonymous access opted in (mcp-endpoint, section 5), an anonymous call of a non-anonymous tool gets the same401, and an anonymous tool runs as the service actor, so the ownership join binds nothing and the tool returns its miss message rather than another consumer’s rows. - Cross-application bearer. A bearer issued for a different application has a different audience and is refused; it never scopes this application’s rows.
3. Prove it with three consumers
Section titled “3. Prove it with three consumers”Serve the tool and connect three MCP clients, one per consumer. For each, run the browser consent journey on the application host as that consumer: open https://<app host>/oauth/authorize with the client’s registered redirect_uri and an S256 code_challenge, sign in on the application’s sign-in page when it redirects with returnTo, approve on the consent page, and exchange the returned code with its code_verifier at https://<app host>/token. That yields three consumer bearers, each bound to its own app_user record.
Call the tool with each bearer. Each receives only their granted rows, for example four, one, and zero, and none sees another’s. Run initialize then tools/list then tools/call per bearer; do not reuse one consumer’s Mcp-Session-Id for another. A fourth call with no bearer gets 401 and no rows.
8. Wire the portal to external services
Section titled “8. Wire the portal to external services”Reach beyond the database: notify a channel when a record changes, enrich a record with an LLM, add semantic search, email your users, or take payment. Each is an outbound integration built from the same pieces — a service config, an allow-listed destination host, and a rule or function that fires it. Start with the foundational call, then layer on the specific integrations your portal needs; the ones that must store a result (an LLM answer, a Stripe id) run through a function so the response lands back on the record.
Post to Slack, notify a customer’s webhook, or hit any HTTP API when a record changes. This is the base outbound pattern the other integration recipes build on: define the endpoint once as a service, allow-list its host, and fire it from a call_webservice rule action.
1. Register the outbound service
Section titled “1. Register the outbound service”Create a service config for the endpoint. The base_url is the host (the request path is supplied per call); credentials go in auth_config and are encrypted at rest.
POST /api/v1/applications/{application_id}/services{ "name": "Slack Notifier", "service_type": "webhook_out", "base_url": "https://hooks.slack.com", "auth_type": "none", "headers": {"Content-Type": "application/json"}}auth_type is one of none, bearer (auth_config: {"token": "..."} → Authorization: Bearer ...), or api_key (auth_config: {"header_name": "X-API-Key", "api_key": "..."}). The base URL must resolve to a public host: the platform blocks private, loopback, and link-local addresses, so an internal service must be reached through a publicly routable name or tunnel.
2. Allow-list the destination host
Section titled “2. Allow-list the destination host”Outbound calls are denied unless the host is on the application’s allow-list. Set it per governance scope:
PUT /api/v1/applications/{application_id}/outbound-destination-governance{ "service_invoke": {"allowed_hosts": ["hooks.slack.com"]}, "webhook_out": {"allowed_hosts": []}}An empty list allows nothing on that path once governance is set; entries are exact hosts or a single-label wildcard (*.example.com). A call to any other host fails with service_destination_not_allowed.
3. Fire it from a rule
Section titled “3. Fire it from a rule”Add a rule on the triggering model (for example record_create, phase post_save) with a call_webservice action. The body is a template resolved against the event, so record fields, the actor, and any require_query rows are available:
{ "type": "call_webservice", "params": { "service_config_id": "<service-uuid>", "method": "POST", "path": "/services/T000/B000/XXXX", "mode": "sync", "body": {"text": {"concat": ["New contact added: ", {"var": "after.first_name"}, " ", {"var": "after.last_name"}]}} }}path is the request path appended to base_url (the webhook-specific part for Slack). mode: "sync" calls the service inline and records the response on the rule’s side effects; omit it to enqueue the call as a background job. Inline url, auth, or headers are rejected: always reference a service_config_id.
The response is written to the outbound service log but is not fed back into later rule actions. To use an API’s response (store it on the record, branch on it), call the service from a function instead (see the LLM recipe), whose return value is merged into the record.
SMS follows the same shape: a form-encoded POST to the provider’s API. Twilio authenticates with HTTP Basic, which the service auth layer does not yet inject, so pass its credentials via a header until basic auth lands.
