Skip to content

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.

Terminal window
name: BlogPost
fields:
- name: title
type: string
optional: false
hidden: false
permissions:
view: own
create: own
update: own
delete: own
pagination: # optional — leave it out and the search route returns every match
limits: [10, 15, 20]
default: 15
sorts: [createdAt, title]
filters: [title]

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.

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 is a URL-safe handle derived from another string field of the same resource:

Terminal window
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, and item when nothing usable is left. A client may send its own, which must match ^[a-z0-9]+(?:-[a-z0-9]+)*$; OpenAPI, the typed client and GET /describe all 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 becomes base-2, base-3 and 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 answers 409 with the key slug.taken. Inside an atomic batch 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 stable is derived again when source changes and the request sends no slug; a stable one keeps the value it got at creation. Sending a slug always wins, on either kind, and an update that leaves source alone never touches it.
  • A lookup key. List it under filters and the read route finds a record by it: POST /blog-posts/search with { "filters": [{ "field": "slug", "value": "my-first-post" }] }. The typed client types that filter like any other field.
  • Rules. source must name a required, visible, non-translatable string field of the same resource, the slug itself cannot be optional, hidden or translatable, and the resource must be routed. hery generate refuses anything else.
  • Personal data. A slug made from a personal field is personal too, without saying so, and declaring it personal: false is refused. An anonymising erasure rewrites it to deleted-<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.

richtext stores HTML a person wrote, sanitised by an allow-list:

Terminal window
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 gets rel="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, href and title on links, and the schemes http https mailto. A key you set replaces its default. Some things can never be allowed, and hery generate refuses a blueprint that names one: the tags script style iframe frame object embed applet form input button textarea select svg math link meta base template noscript, any on* attribute, style and srcdoc, and the schemes javascript data vbscript file. The sanitiser enforces the same list even for a spec edited by hand.
  • Size limit. maxLength is 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 String column. It cannot be listed in filters or sorts, cannot be translatable, and is not part of search.q.
  • Contract. OpenAPI and the typed client expose a string with format: html and the limit; GET /describe publishes richtext: { maxLength, allow } for the field with the allow-list resolved.
  • Proven. The generated suite sends eighteen XSS payloads (script tags, event handlers, javascript: and data: 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.

Seven names are the generator’s to write, and a blueprint declaring one of them is rejected outright:

Terminal window
id tenantId ownerId teamId createdAt updatedAt deletedAt

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

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:

Terminal window
softDeletes: false

With 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>/search a 404, so its children do not outlive it.
  • withTrashed / onlyTrashed on the search body — the explicit way back in, gated by canListTrashed<Name> (the delete preset, not the read one).
  • {"mode": "hard"} on the delete body — the force-delete, gated by canHardDelete<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 takes ids and an optional patch, and answers 409 for a record that was never deleted.
  • prune. Nothing in the resource ever removes a flagged row; deletedAt + tenantId is 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.

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.

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.

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.

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.

  • pagination is optional, and it is the only place pagination is decided. Declare it and limits is the exhaustive list of page sizes a client may request — anything else is a 400 — while default is 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": false in describe and meta, and rejects a caller who sends page or limit rather than ignoring them. Bounding the result set is the developer’s call, not the framework’s.
  • sorts is the allow-list of fields a client can sort by — sort in the body, prefixed with - for descending.
  • filters is the allow-list of fields a client can filter on — one entry per field under filters in 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.

Terminal window
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." } }

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

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:

Terminal window
includes:
- relation: notes
resource: BlogPostNote
type: hasMany
foreignKey: blogPostId
- relation: comments
resource: Comment
type: morphMany
aggregates:
- relation: notes
resource: BlogPostNote
type: hasMany
foreignKey: blogPostId

hasMany 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:

comment.yaml
name: Comment
routed: false
morph: commentable

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

A hasMany include may itself be nested one level further, if the resource it points at declares its own includes:

blog-post.yaml
includes:
- relation: notes
resource: BlogPostNote
type: hasMany
foreignKey: blogPostId
# blog-post-note.yaml
includes:
- relation: replies
resource: NoteReply
type: hasMany
foreignKey: noteId

A search request can then ask for notes and, inside it, replies, in one call:

Terminal window
{
"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.

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:

Terminal window
includes:
- relation: notes
resource: BlogPostNote
type: hasMany
foreignKey: blogPostId
ownRoute: true

That 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:

Terminal window
POST /blog-posts/cly8x7g9k0000abc123def456/notes/search
{ "sorts": [{ "field": "createdAt", "direction": "desc" }] }
Terminal window
{
"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.

Terminal window
relations:
- relation: tags
resource: Tag
pivotTable: BlogPostTag
foreignKey: blogPostId
relatedKey: tagId

Each entry generates an attach/detach/sync block on the update route, gated by its own pair of capabilities (canAttachTagsToBlogPost, canDetachTagsFromBlogPost) — see Update.

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: delete | anonymise opts a routed resource into privacy requests, and personal: true marks the fields an anonymising erasure rewrites. See GDPR export and erasure.