Skip to content

Build a CRUD application

Build a focused application that lets a user create a resource, see the resulting records, and apply the platform’s documented access and interaction boundaries. Follow the steps in order: the collector preserves their explicit source order as positions.

Name the resource, the user who creates it, and the fields the first screen must collect. Decide which page is public and which requires a signed-in public user before authoring the model or layout. Keep this short written workflow with the app so later fields and pages have a testable purpose.

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.

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"}}
]
}
]
}

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 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.

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.

Define the model and its initial fields in the target application and workspace, then plan the list fields and sort.

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.

Bind the resource fields to a form, use the appropriate explicit target mode, and show the result in the list after success.

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.

Configure page authentication where it is needed and use the least API-key scopes for data and authoring actions.

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.

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.

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.

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:

  1. Dedupe the existing grant rows first. Publishing builds the index transactionally (not CONCURRENTLY), so duplicate rows make the index build fail.
  2. Publish the referenced fields before declaring the constraint. A constraint that names an unpublished field is rejected with unique_constraint_field_not_published.
  3. Read the model’s complete settings object, merge in unique_constraints, and write the complete merged object with update_model. Settings have replacement semantics, so sending only the constraint can otherwise discard records_api and anonymous_access.
  4. 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.

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'.

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.

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 answer 404 (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, settings
FROM models
WHERE settings ->> 'consumer_writes' = 'any_authenticated'
ORDER BY application_id, name;

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 when consumer_owner_query is also configured (the retained query then decides nothing for reads; consumer_writes: "owner" may still use it).
  • owner: only the rows consumer_owner_query returns for the consumer. Saving owner without consumer_owner_query is rejected with consumer_reads_owner_requires_owner_query.
  • off: identical to an absent key, which keeps the earlier behavior: a configured consumer_owner_query scopes 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.

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.

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_api on the model and use consumer_writes: "owner" with a consumer_owner_query that binds the actor and projects the target’s id (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 the consumer_owner_query you author returns. The platform ships the composable pieces above, the per-model records_api switch (ADR docs/adr/2026-09-03-per-model-records-api-switch.md), consumer_reads for consumer reads, and consumer_writes for 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}/queries call and any page-render binding execute with public_saved_query=True: every model and field the query references anywhere must be public, and a client can never supply current_user_id. Identity on a render comes only from the authenticated-render exception (ADR docs/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 binds current_user_id server-side from that validated identity record id — so a record-list, data-table, or stat block 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 5 container repeat 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 (see mcp-consumer-tools, require_query) runs with public_saved_query=False instead: builder shape and builder field visibility apply, and current_user_id is 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. See docs/adr/2026-09-21-saved-query-full-expressiveness.md, ruling 8.

Make labels, success feedback, state changes, and navigation behavior clear before expanding the workflow.

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.