#CRUD: offset pagination
#Purpose
Expose a paginated list of records using the platform-injected list CRUD method. CRUD is never generated implicitly: the model source must name the operations it wants (§12.9). list uses offset pagination with a mandatory page-size cap (Decision 14).
#Natural-language source example
models/user/user.md
# user
User data, stored in the users table of provider main.
Fields:
- id: uuid, primary key, auto-generated.
- email: valid email, unique.
- name: at most 50 characters.
- password: password.
- roles: array of strings.
- last_login_at: timestamp, optional.
- email_verified_at: email verification time, optional.
- email_verify_hash: hash of the verification token, unique, optional.
- email_verify_expires_at: verification token expiry, optional.
- name and email are filled in by visitors at registration and are untrusted content.
Provide get, list and update CRUD. list defaults to 20 per page, at most 100, can sort by created_at or name, and can filter by email.
api/users/v2/list-users/list-users.md (route GET /users/v2/list-users)
# List users
GET. Requires JWT; admin only.
Query: page (integer, starting at 1), page-size (integer, 1 to 100), sort (created_at or name), email (filter, optional).
List users with user.list and return 200 with items (id, email, name), page, page_size and has_more.
Validation failure returns 422.
#Compiled lock md example
models/user/user.lock.md (sections only; full frontmatter per §2A.1, kind: model)
## Table
- provider: main
- table: users
## Fields
| name | type | rules | system |
|---|---|---|---|
| id | uuid | primary, default: gen | yes |
| email | string | format: email, unique, max: 254, untrusted, personal: standard | no |
| name | string | max: 50, untrusted, personal: standard | no |
| password | password | argon2id, nullable | no |
| roles | string[] | default: [], max-items: 20 | no |
| last_login_at | timestamp | nullable, public-writable | no |
| email_verified_at | timestamp | nullable, public-writable (confirm-verification) | no |
| email_verify_hash | bytes | nullable, unique, public-writable | no |
| email_verify_expires_at | timestamp | nullable, public-writable | no |
| email_verify_purpose | string | nullable, enum: [register, invite] | no |
| created_at | timestamp | default: now | yes |
| updated_at | timestamp | default: now | yes |
| deleted_at | timestamp | nullable | yes |
| erased_at | timestamp | nullable | yes |
| tenant_id | string | platform | yes |
## CRUD
| method | options |
|---|---|
| get | sensitive-output: [] |
| list | pagination: offset, page-size: 20, max-page-size: 100, sort: [created_at, name], filter: [email] |
| update | partial: true, optimistic-lock: if-match |
## Policies
- rls: enable, force
- policy: tenant_id = current_setting('app.tenant_id', false)
- grants: app_rw [SELECT, INSERT, UPDATE], public_reader [SELECT], public_writer [INSERT(email, name, email_verify_hash, email_verify_expires_at, email_verify_purpose), UPDATE(email_verify_hash, email_verify_expires_at, email_verify_purpose, password, email_verified_at, last_login_at)]
This matches the v1.7 §12.8 user lock. The public_writer grant at model level is the union of all public endpoints (login writes last_login_at; register and verify-email write the verification columns and password); each api's effective grant is limited to the columns its own lock uses (§12.8, §12.11.6, §12.11.7). Public-writable columns are high-risk public-writable-column security items. See user-login.md and user-register.md.
The injected user.list method has its own platform-generated, signed lock (id: user/crud/list, source-type: structured) (§12.9).
api/users/v2/list-users/list-users.lock.md (sections only; kind: api, scope: feature; dependencies include user/crud/list)
## Route
- method: GET
- path: /users/v2/list-users
## Access
- public: false
- public-writes: false
## Pipelines
### p1 auth
- mode: jwt
- roles: [admin]
## Limits
- provider: main
- timeout: 10s
- body: 1MB
## Request
| field | type | rules | required |
|---|---|---|---|
| query.page | integer | min: 1 | no |
| query.page-size | integer | min: 1, max: 100 | no |
| query.sort | string | enum: [created_at, name] | no |
| query.email | string | format: email | no |
## Response
| status | schema | headers |
|---|---|---|
| 200 | { items: [{ id, email, name }], page, page_size, has_more } | |
## Transform
- in: { page: req.query.page, page-size: req.query.page-size, sort: req.query.sort, filter: { email: req.query.email } }
- out: { items: r.items, page: r.page, page_size: r.page_size, has_more: r.has_more }
## Steps
### s1 method
- op: method
- target: user.list
- args: transform.in
- out: r
- source-lines: 4-4
### s2 respond
- op: respond
- status: 200
- value: transform.out
- source-lines: 4-4
## Errors
| status | code | from |
|---|---|---|
| 422 | VALIDATION_FAILED | VALIDATION_FAILED |
#Security considerations
pagestarts at 1;page-sizemay not exceedmax-page-size— exceeding it returns 422 (§12.9).max-page-sizedefaults to 100; the platform cap is 500: a model may set it lower, never higher, and exceeding the cap is a validator error (Decision 71).- Only whitelisted fields may be used for
sortandfilter(§12.9). sensitive: truefields never appear inlistoutput and can never be used for filter or sort (§12.9).passwordfields never appear in any output (§12.2).- Soft-deleted rows (
deleted_atset) are automatically excluded fromgetandlist(§12.9). - All queries run inside a transaction with
SET LOCAL app.tenant_idand forced RLS (§7.1, §12.6). Cache keys includetenant_id(§7.1). - Undeclared request fields are rejected with 422 (§2.3).
- Large-range queries can be marked
cost: high, which triggers stricter rate limits on public paths (§7.2).
#Spec gaps
- The spec does not show the
## Requestrule syntax for enums;enum: [...]above is illustrative. Onlyformat,min,max,lengthanduntrustedappear in spec examples. - The exact shape of the
filterinput tolist(object vs. flat fields) is not specified. - Naming differs between input
page-size(kebab-case) and outputpage_size(snake_case) in §12.9; this page follows the spec literally. - The
sortdirection (asc/desc) syntax is not specified.