Protocols
11 composable behaviors that add columns and logic to content types without writing code.
Protocols are composable behavior modules. Add them to a content type and they automatically inject database columns, validation rules, and API logic — no code required.
Quick Example
[content_type.implements]
protocols = ["timestampable", "ownable", "soft_deletable", "sortable"]
[[content_type.implements.protocols]]
name = "statusable"
values = "draft,published,archived"
default = "draft"All 11 Protocols
timestampable
Auto-managed created_at and updated_at timestamps.
| Column | Type | Behavior |
|---|---|---|
created_at | TEXT | Set on create |
updated_at | TEXT | Set on create, updated on every update |
protocols = ["timestampable"]ownable
Auto-track who created and last modified a record.
| Column | Type | Behavior |
|---|---|---|
created_by | INTEGER | Set from auth user on create |
updated_by | INTEGER | Set from auth user on every update |
protocols = ["ownable"]soft_deletable
Logical delete — records are marked as deleted instead of being removed. List queries automatically filter deleted_at IS NULL.
| Column | Type | Behavior |
|---|---|---|
deleted_at | TEXT | Set to current timestamp on delete |
deleted_by | INTEGER | Set from auth user on delete |
protocols = ["soft_deletable"]versionable
Revision tracking with snapshot and restore API.
| Column | Type | Behavior |
|---|---|---|
version | INTEGER | Incremented on every update (starts at 1) |
Auto-registers revision endpoints:
GET /admin/cms/{plural}/{id}/revisions List revisions
GET /admin/cms/{plural}/{id}/revisions/{revision_id} Get revision
POST /admin/cms/{plural}/{id}/revisions/{revision_id}/restore Restore revision
GET /admin/cms/{plural}/{id}/revisions/{rev_a}/diff/{rev_b} Diff two revisionsBefore each update, the current record is snapshotted to content_revisions. On record delete, all revisions are cleaned up.
protocols = ["versionable"]lockable
Optimistic locking — prevents concurrent write conflicts.
| Column | Type | Behavior |
|---|---|---|
lock_version | INTEGER | Starts at 1, incremented on update |
On update, the system checks WHERE lock_version = {expected}. If no row matches (another client modified it), returns 409 Conflict.
protocols = ["lockable"]sortable
Default ordering with a sort key.
| Column | Type | Behavior |
|---|---|---|
sort_key | INTEGER | Default 0, used for ordering |
Default sort: sort_key ASC.
protocols = ["sortable"]Configurable — change the sort field and direction:
[[content_type.implements.protocols]]
name = "sortable"
field = "priority"
direction = "desc"statusable
State machine with allowed status values.
| Column | Type | Behavior |
|---|---|---|
status | VARCHAR | Validated against allowed values |
String mode (default):
[[content_type.implements.protocols]]
name = "statusable"
values = "draft,published,archived"
default = "draft"Numeric mode — maps labels to numbers in the database:
[[content_type.implements.protocols]]
name = "statusable"
values = "pending=1,paid=10,shipped=20,completed=30"
default = "1"
mode = "numeric"In numeric mode, the API accepts labels ("paid") but stores numbers (10).
expirable
Auto-filter expired records.
| Column | Type | Behavior |
|---|---|---|
expires_at | TEXT | List queries filter expires_at IS NULL OR expires_at > now |
protocols = ["expirable"]nestable
Parent-child tree structure.
| Column | Type | Behavior |
|---|---|---|
parent_id | INTEGER | Reference to parent record |
depth | INTEGER | Tree depth (0 = root) |
position | INTEGER | Sibling ordering |
protocols = ["nestable"]tenantable
Multi-tenant isolation.
| Column | Type | Behavior |
|---|---|---|
tenant_id | VARCHAR | Auto-filtered by current tenant (default: "default") |
All queries automatically add WHERE tenant_id = ? based on the auth context.
protocols = ["tenantable"]metaable
Arbitrary JSON metadata.
| Column | Type | Behavior |
|---|---|---|
__meta | JSON | Default {}, filterable via __meta.{path} query params |
protocols = ["metaable"]Filter in queries:
curl "http://localhost:9898/api/v1/cms/courses?__meta.featured=true"Protocol Summary
| Protocol | Columns Added | Key Behavior |
|---|---|---|
timestampable | created_at, updated_at | Auto timestamps |
ownable | created_by, updated_by | Auto user tracking |
soft_deletable | deleted_at, deleted_by | Logical delete |
versionable | version | Snapshot + restore + diff API |
lockable | lock_version | Optimistic lock (409 on conflict) |
sortable | sort_key | Default ordering |
statusable | status | State machine |
expirable | expires_at | Auto-filter expired |
nestable | parent_id, depth, position | Tree structure |
tenantable | tenant_id | Multi-tenant isolation |
metaable | __meta | JSON metadata |
Combining Protocols
Protocols are designed to compose freely. A typical blog post:
[content_type.implements]
protocols = ["timestampable", "ownable", "soft_deletable", "versionable", "sortable"]
[[content_type.implements.protocols]]
name = "statusable"
values = "draft,published,archived"
default = "draft"This gives you: auto timestamps, author tracking, safe delete, revision history, default ordering, and a state machine — all from 6 lines of TOML.
Execution Order
Protocols execute in priority order (lower number = runs first):
| Priority | Protocols |
|---|---|
| -600 | tenantable |
| -500 | ownable |
| -400 | timestampable |
| -300 | soft_deletable |
| -200 | expirable, nestable |
| -150 | statusable |
| -100 | lockable, sortable |
| 500 | versionable |
| 1000 | metaable |
This ensures tenant isolation runs first and versioning snapshots run last.
