The blueprint contract
A blueprint is not just a list of fields — it is the full contract for what a resource exposes over HTTP, resolved once at generation time.
name: BlogPostfields: - name: title type: string optional: false hidden: falsepermissions: view: own create: own update: own delete: ownpagination: # optional — leave it out and the search route returns every match limits: [10, 15, 20] default: 15sorts: [createdAt, title]filters: [title]Naming
Section titled “Naming”name must be PascalCase (ProductCategory, not product_category or productCategory) and every field name must be camelCase (scheduledAt, not scheduled_at). Both are enforced by the same regex the schema loads and validates against — a blueprint that breaks either is rejected before generation runs, and hery create:blueprint rejects the resource name even earlier, at the command line.
This is the one casing convention in the project, and it is deliberately the only one: the resource name is PascalCase because it becomes a class name (ProductCategoryController, ProductCategoryService), a field name is camelCase because it becomes a TypeScript property and, with no @map anywhere in schema.prisma, the Postgres column name too — there is no separate snake_case layer at the database boundary to keep in sync with the one everything else already uses.
Fields
Section titled “Fields”Each field has a type (string, int, boolean, datetime, file, encrypted, vector, slug, richtext), whether it’s optional, and whether it’s hidden — a hidden field is stripped from every API response by the generated <name>.view.ts, no matter which endpoint returns the record.
file is a plain string column underneath — the storage key POST /storage/upload already returned, not the bytes themselves. See Storage for the upload flow it pairs with.
encrypted is a text column sealed with AES-256-GCM, hidden from responses unless the field says reveal: true, and refused in filters and sorts. See Secrets.
vector is not data a client sends: it is the embedding of another string field, written by a queued job and read only by search: { mode: 'similar' }. It takes a dimensions and a source, and is never returned or writable. See Vector search.
Slug fields
Section titled “Slug fields”slug is a URL-safe handle derived from another string field of the same resource:
fields: - name: title type: string - name: slug type: slug source: title stable: false # the default: the slug follows title; true freezes it once created
filters: - slug- Generated, never required. Left out of a create, the slug is made from
source: accents folded, lowercase ASCII words joined by single dashes, at most 100 characters, anditemwhen nothing usable is left. A client may send its own, which must match^[a-z0-9]+(?:-[a-z0-9]+)*$; OpenAPI, the typed client andGET /describeall say the field is optional on the way in and always present on the way out. - Unique per tenant, collision-safe. The column carries
@@unique([tenantId, slug]). A derived slug that is taken becomesbase-2,base-3and so on. Trashed rows keep their slug and still count, so a slug is never handed out twice before prune frees the row. Two concurrent writes that read the same neighbours are settled by the unique index: the loser retries once with a random suffix. A slug the caller chose that is already taken answers409with the keyslug.taken. Inside anatomicbatch a lost race has already aborted the transaction, so the conflict reaches the caller instead of retrying. - Stable or following. On update, a slug that is not
stableis derived again whensourcechanges and the request sends no slug; astableone keeps the value it got at creation. Sending a slug always wins, on either kind, and an update that leavessourcealone never touches it. - A lookup key. List it under
filtersand the read route finds a record by it:POST /blog-posts/searchwith{ "filters": [{ "field": "slug", "value": "my-first-post" }] }. The typed client types that filter like any other field. - Rules.
sourcemust name a required, visible, non-translatablestringfield of the same resource, the slug itself cannot beoptional,hiddenortranslatable, and the resource must be routed.hery generaterefuses anything else. - Personal data. A slug made from a
personalfield is personal too, without saying so, and declaring itpersonal: falseis refused. An anonymising erasure rewrites it todeleted-<id>, never to anything derived from the erased source, so the row keeps a unique slug and holds nothing of what identified the person. - Written past the service. The column defaults to a generated id, so a seeder or a script that inserts without a slug still produces a valid, unique one; only the service derives readable ones.
The admin shows a slug as a text input that suggests the slug of its source while you type it (as the placeholder, and a “Use suggestion” button that fills it in) and explains whether it follows the source. Leave it empty on create to let the API number it.
Rich text fields
Section titled “Rich text fields”richtext stores HTML a person wrote, sanitised by an allow-list:
fields: - name: body type: richtext optional: true maxLength: 20000 # characters of HTML a client may send, 20000 by default allow: # optional, each key replaces its default tags: [p, strong, em, ul, ol, li, a] attributes: a: [href, title] schemes: [https, mailto]- Sanitised on write, never trusted on read. The service sanitises every create, update and restore, and the view sanitises again on every read, so a row that reached the table another way (an import, a script, a column that was text before, an allow-list narrowed since) still comes out clean. A tag outside the list is dropped but its text stays, except script, style and the like, whose content goes with them. A link whose scheme is not listed loses its
href, and every link getsrel="noopener noreferrer nofollow". - The allow-list. Left out, it keeps
p br hr strong b em i u s code pre blockquote ul ol li h1 h2 h3 h4 a,hrefandtitleon links, and the schemeshttp https mailto. A key you set replaces its default. Some things can never be allowed, andhery generaterefuses a blueprint that names one: the tagsscript style iframe frame object embed applet form input button textarea select svg math link meta base template noscript, anyon*attribute,styleandsrcdoc, and the schemesjavascript data vbscript file. The sanitiser enforces the same list even for a spec edited by hand. - Size limit.
maxLengthis checked on the HTML as sent, before sanitising, and refused with a 400. The sanitised value is what is stored. - A column, not a search target. The value is plain text in a
Stringcolumn. It cannot be listed infiltersorsorts, cannot betranslatable, and is not part ofsearch.q. - Contract. OpenAPI and the typed client expose a
stringwithformat: htmland the limit;GET /describepublishesrichtext: { maxLength, allow }for the field with the allow-list resolved. - Proven. The generated suite sends eighteen XSS payloads (script tags, event handlers,
javascript:anddata:links in several spellings, SVG, math, iframes, forms, split tags) on create and on update and checks none survives in the response or on a later read, checks the formatting the list names is kept, that the limit is enforced, and that a row written past the service is cleaned on read.
The admin shows the field as a textarea with a live preview of what the API will keep, drawn from the published allow-list, and a count against the limit. The preview is rebuilt from DOM nodes in an inert document, never from the markup, so it cannot run script in the admin; the API stays the authority.
Reserved fields
Section titled “Reserved fields”Seven names are the generator’s to write, and a blueprint declaring one of them is rejected outright:
id tenantId ownerId teamId createdAt updatedAt deletedAtThe reason is narrow and worth stating: declaring one of these would put a client-writable field on top of a column the framework decides. A blueprint with an ownerId field would generate a DTO accepting it, which is how a caller ends up choosing its own owner — or its own team, or its own tenant. Refusing at load time is cheaper than discovering it in review.
teamId in particular is added for you, automatically, as soon as any permission preset is team.
Soft deletes
Section titled “Soft deletes”On by default. Every routed resource gets a nullable deletedAt, a @@index([tenantId, deletedAt]) so the “not deleted” filter stays cheap, and a delete route that flags the row instead of removing it. One key turns it off:
softDeletes: falseWith soft deletes on — the default — the generated resource has:
- every default read filtered. The search route, its totals, its includes and aggregates, and the single-record loader the detail-style routes and the parent-scoped child route load through all exclude trashed rows. A soft-deleted record is not reachable through a relation either: soft-deleting a parent makes
POST /<parent>/:id/<relation>/searcha 404, so its children do not outlive it. withTrashed/onlyTrashedon the search body — the explicit way back in, gated bycanListTrashed<Name>(thedeletepreset, not the read one).{"mode": "hard"}on the delete body — the force-delete, gated bycanHardDelete<Name>in addition to the delete preset. Admin only.POST /<resource>/restore— a route, not just a service method, because a bin nobody can reach over HTTP is storage nobody audits. It takesidsand an optionalpatch, and answers409for a record that was never deleted.- prune. Nothing in the resource ever removes a flagged row;
deletedAt+tenantIdis exactly what makes a model prunable, so the retention sweep is what eventually hard-deletes it. See Prune.
With softDeletes: false, there is no deletedAt column, no restore route, no mode on delete, no bin (withTrashed/onlyTrashed are refused with query.invalid rather than silently answering with live rows), and nothing for prune to sweep — DELETE removes the row. Declaring the key on a routed: false resource, or naming deletedAt in sorts/filters while turning it off, is refused at load time.
Versioned resources
Section titled “Versioned resources”Off by default. versioned: true adds an integer version column that every update, delete and restore increments, exposes it on the record, and makes the update route refuse an entry that does not say which version it read, or that read a stale one (409, or 412 through If-Match). version is reserved on a versioned resource and the key is refused on a routed: false one. See Optimistic concurrency.
Off by default. sharing: true lets the owner grant a user or a team read or write access to one record, on top of the permission presets: it adds the shares routes and a share capability, and folds the shares into the search scope without a query per row. It is refused on a routed: false resource and on one with a vector field. See Capabilities.
History
Section titled “History”Off by default. history: true stores a snapshot of the record after every write and adds three routes to read, diff and restore them. Hidden and encrypted fields are never stored. See Row history.
Workflow
Section titled “Workflow”Off unless a workflow block is declared. It names a state machine for the resource: an initial state and the transitions between states, each with a permission preset (default own) and optional requires guards. The resource gains a state column, which no client can write, and POST /<resource>/transition is the only way it moves. See Workflow.
Permissions
Section titled “Permissions”One preset per action, each resolved through the same capabilities engine described in the capabilities guide.
view is the one worth pausing on: it drives the detail route and the where clause of the collection query, so both answer the same question from a single declaration. There is deliberately no separate list preset — two presets could diverge, and a record hidden from one route while handed out by the other is the exact bug this shape exists to make unwriteable. view: all with update: own gives the common case: everyone in the tenant reads, only the owner edits.
Choosing team anywhere changes the generated resource structurally: the Prisma model gains a teamId column and a relation, the create path stamps that column from the session and refuses with a 409 when the caller has no current team, and the view exposes it. See Teams.
Pagination, sorts and filters — the search contract
Section titled “Pagination, sorts and filters — the search contract”This is the part that goes beyond validating input: it bounds output too.
paginationis optional, and it is the only place pagination is decided. Declare it andlimitsis the exhaustive list of page sizes a client may request — anything else is a 400 — whiledefaultis the size used when the caller names none. Leave the block out and the search route does not paginate: it returns every match, reports"paginated": falseindescribeandmeta, and rejects a caller who sendspageorlimitrather than ignoring them. Bounding the result set is the developer’s call, not the framework’s.sortsis the allow-list of fields a client can sort by —sortin the body, prefixed with-for descending.filtersis the allow-list of fields a client can filter on — one entry per field underfiltersin the body.
The generated controller enforces this through a single shared helper, parseSearchRequest, so every resource validates its search body the same way, and no resource can silently expose a field for filtering that was never meant to be queryable.
POST /blog-posts/search{ "limit": 20, "sort": "-title", "filters": { "title": "foo" } }
POST /blog-posts/search{ "limit": 999 }→ 400 { "error": { "key": "query.invalid", "message": "Invalid value for \"limit\". Allowed: 10, 15, 20." } }The fields that need no declaration
Section titled “The fields that need no declaration”Three more are understood by every collection route, and none of them appears in the blueprint because none of them names a field:
search.q— free-text search across the resource’s string fields. See Full-text search.withTrashed— include soft-deleted rows alongside live ones.onlyTrashed— the bin, and nothing else.
The two trashed parameters are not just filters: asking for either one is checked against canListTrashed<Name>, which follows the delete preset. Opening the bin is treated as a moderation action rather than a read, so a user who may see a record is not automatically allowed to browse deleted ones. The rows that come back are still narrowed by the view scope — the gate answers “may I look at the bin”, the scope answers “which rows I may see in it”.
Relations
Section titled “Relations”Three more keys describe what a resource is attached to, and they split by what a request may do with the relation rather than by how Prisma models it.
includes and aggregates are the read side — a relation a search request may embed, or count. Each entry names the relation, the resource on the other end, and how they are linked:
includes: - relation: notes resource: BlogPostNote type: hasMany foreignKey: blogPostId - relation: comments resource: Comment type: morphManyaggregates: - relation: notes resource: BlogPostNote type: hasMany foreignKey: blogPostIdhasMany is a real Prisma relation, so foreignKey is the column the related model points back with.
morphMany has no Prisma-level relation at all — Prisma does not model polymorphic associations — so the columns have to be declared somewhere. The child declares them once:
name: Commentrouted: falsemorph: commentablemorph: commentable means the columns are commentableId and commentableType, and every parent pointing at this resource gets its own name as the discriminator value. A parent then only names the relation.
That last part is why this exists. The discriminator value is always the parent’s own name, and it used to be written by hand in every parent — so a copy-paste that kept the previous parent’s value was both silent and wrong: the include returned nothing, or worse, someone else’s rows.
Declaring it in both places is refused, not merged. Two sources for one fact is exactly how the fact ends up disagreeing with itself.
A child you do not own — a table whose columns are named something else entirely — still works the old way: spell out foreignKey, discriminator and discriminatorValue on the link, and declare no morph on the child. A morphMany that names neither is rejected.
The referenced resource’s own blueprint is what supplies the nested contract: the filters, sorts and selects a request may name inside an include come from there rather than being retyped on every parent that includes it. That is what routed: false is for — a resource generated with no controller, no service and no capabilities of its own, existing only to describe the shape of a relation once.
Nested includes
Section titled “Nested includes”A hasMany include may itself be nested one level further, if the resource it points at declares its own includes:
includes: - relation: notes resource: BlogPostNote type: hasMany foreignKey: blogPostId
# blog-post-note.yamlincludes: - relation: replies resource: NoteReply type: hasMany foreignKey: noteIdA search request can then ask for notes and, inside it, replies, in one call:
{ "includes": [{ "relation": "notes", "includes": [{ "relation": "replies" }] }]}Two levels total — a relation of a relation — is the fixed bound, the same fixed-constant idiom MAX_FILTER_DEPTH already uses rather than a per-blueprint depth to configure. Only hasMany nests: morphMany has no Prisma relation to attach a nested include to, so it stays reachable at the top level only. A nested include’s rows are never renamed by an alias — they come back nested under their real relation name, exactly where Prisma put them.
A route scoped to the parent
Section titled “A route scoped to the parent”An include embedded inside the parent’s own record is not the only way to reach it — a hasMany include can also opt into its own collection route, scoped to the parent by id:
includes: - relation: notes resource: BlogPostNote type: hasMany foreignKey: blogPostId ownRoute: trueThat generates POST /blog-posts/:id/notes/search, the same search contract shape as any resource’s own — filters, sorts, selects, pagination, all still declared on BlogPostNote’s own blueprint — reached without asking for the whole parent record just to get at its notes:
POST /blog-posts/cly8x7g9k0000abc123def456/notes/search{ "sorts": [{ "field": "createdAt", "direction": "desc" }] }{ "data": [ { "id": "n1", "body": "first note", "rating": 4, "createdAt": "2026-01-01T00:00:00.000Z" } ], "meta": { "page": 1, "limit": 5, "total": 1, "last_page": 1 }}There is no separate capability for BlogPostNote to check — it is routed: false, same as any other include target. The route gates on the parent’s own record-level view capability instead: seeing a blog post’s notes is exactly seeing the blog post they belong to, checked the same way stream’s per-resource routes already check it before acting on something the resource owns. ownRoute only applies to hasMany — a morphMany link has no Prisma relation to scope a route to — and it means nothing on an aggregate, which resolves to one number, not a collection to page through.
relations is the write side, and it is specifically the belongsToMany case includes cannot express: neither side owns the other, so attaching or detaching never touches the related row, only a row in the pivot table.
relations: - relation: tags resource: Tag pivotTable: BlogPostTag foreignKey: blogPostId relatedKey: tagIdEach entry generates an attach/detach/sync block on the update route, gated by its own pair of capabilities (canAttachTagsToBlogPost, canDetachTagsFromBlogPost) — see Update.
Why YAML, not decorators
Section titled “Why YAML, not decorators”The blueprint is consumed once, at generation time — never read by the running application. Decorators like @Capability exist for behavior that genuinely needs to run per request; a static contract like pagination limits doesn’t need that, and giving it decorator-based runtime metadata would just be indirection for something a YAML file already expresses clearly and diffs cleanly in review.
Erasure
Section titled “Erasure”erasure: delete | anonymise opts a routed resource into privacy requests, and personal: true marks the fields an anonymising erasure rewrites. See GDPR export and erasure.