Status: Normative · Platform version: 0.3.1 · Display label: ADL v0.3
Audience: An LLM (or developer) generating ADL domains, configs, seeds, and verification flows for the Kalos application server.
This document is the single source of truth for generation tasks. It is derived from the shipped Kalos runtime (core/, plugins/adl/, plugins/authd/, plugins/seeder/), not from prose intent. Where this document and the 41-chapter specification ever disagree, the runtime wins and this document tracks the runtime.
Convention used throughout:
- MUST — required for a valid, loadable ADL document. Violations are rejected by the parser (HTTP 422) or fail at runtime.
- SHOULD — strongly preferred convention. Generators should follow it unless there is a concrete reason not to.
- EXAMPLE — copyable, known-good pattern.
- AVOID — invalid or actively discouraged; do not emit.
1. The contract in one screen
These are the decisions most often gotten wrong. Memorize them.
| Concern | Canonical rule |
|---|---|
| Resource route prefix | /api/v1/{domain}/{resource} — there is no /adl/ segment. |
| Domain management | /api/v1/domains (list/submit), /api/v1/domains/{name}/{schema|source|openapi.json|preview|export}. |
| Auth (self-service) | /api/v1/auth/... and /api/v1/users/.... |
| Admin (user/role mgmt) | /api/v1/admin/.... User management is never under /api/v1/auth/users. |
| Success envelope | { "status": "ok", "code": <int>, "data": <payload> }. |
| Error envelope | { "status": "error", "code": <int>, "data": {…}, "error_code": "UPPER_SNAKE", "error_message": "…" }. |
| Validation error code | VALIDATION_ERROR at HTTP 422. (Never VALIDATION_FAILED — that string is reserved for response-validation internals: RESPONSE_VALIDATION_FAILED.) |
| Public access symbol | * = public/anonymous (no auth). |
| Any-logged-in symbol | authenticated. |
? permission wildcard | Unsupported. The parser rejects it. Never emit ?. |
| Version strings | Platform/release is 0.3.1. Never emit "3.0.0". A domain’s own version: is an independent semver chosen by the author (e.g. "1.0.0"). |
| Reserved domain names | auth, users, admin, domains, status, plugins, routes, version, openapi, echo (case-insensitive). |
| Action bus id format | adl.{domain}.{resource}.{op} (e.g. adl.blog.post.create); domain ops are adl.domain.*. |
2. Document structure (MUST)
An ADL document is a single YAML (or JSON) file with a top-level domain: key.
domain:
name: <identifier> # MUST. Lowercase, not a reserved name (§1). Becomes the route prefix.
version: "1.0.0" # MUST. The DOMAIN's own semver — author's choice, NOT the platform version.
description: > # SHOULD.
Human-readable summary.
api:
prefix: /api/v1/<name> # SHOULD omit — defaults to /api/v1/{name}. Only set to override.
objects: { ... } # OPTIONAL. Reusable field bundles for $merge.
enums: { ... } # OPTIONAL. Reusable enumerations for $ref.
properties: { ... } # OPTIONAL. Reusable single-field definitions for $ref.
resources: # MUST. At least one resource.
<ResourceName>: { ... }
domain.nameMUST NOT be a reserved name (§1). The check is case-insensitive.domain.api.prefixSHOULD be omitted. If omitted, the runtime uses/api/v1/{name}. Only set it to deviate (rare).- Resource names SHOULD be
PascalCasesingular (Post,Comment,OrderLine). Generated route segments are derived from the resource (e.g.posts).
3. Type system (MUST use a supported type)
Every field’s type MUST be one of these (aliases shown after /):
Core: string, text, integer/int, decimal/number, float/double, boolean/bool, date, datetime, time, timestamp, uuid, json.
Semantic: email, url, slug, markdown/md, phone, password, token, autoincrement/auto_increment, array, set.
Domain/locale: money, currency, percentage/percent, country, language/lang, timezone/tz, color/colour, bytes/filesize.
Spatial (v0.3.1): geometry/geojson — a GeoJSON geometry value. Optional geometryType (point/linestring/polygon/multipoint/multilinestring/multipolygon/geometrycollection) and srid (default 4326). Accepted as an object or JSON string, emitted as an object; validated as well-formed GeoJSON. Stored as JSON everywhere; on PostgreSQL+PostGIS it also gets a GiST-indexed geometry column enabling spatial filters. See Chapter 41.
geom: { type: geometry, required: true, geometryType: point, srid: 4326 } # EXAMPLE
A bare string value is shorthand for { type: <value> }:
title: string # EXAMPLE — shorthand, equivalent to: title: { type: string }
Field attributes (all OPTIONAL except type)
| Attribute | Applies to | Meaning |
|---|---|---|
type | all | MUST. See above. |
primaryKey: true | one field | Marks the primary key. Every resource SHOULD have exactly one (uuid recommended). |
required: true | any | Field must be present on create. |
unique: true | any | Enforced unique. |
immutable: true | any | Cannot change after create (e.g. foreign keys, authorId). |
default: <v> | any | Default value when omitted. For enums, use a valid enum key. |
minLength / maxLength | string-like | Length bounds. |
min / max | numeric | Value bounds. |
pattern: '<regex>' | string-like | Regex constraint. |
autoNow: create | datetime | Set on create; never modified after. |
autoNow: update | datetime | Set on every update; not set on create. |
autoNow: always | datetime | Set on create and on every update. |
from: <field> | slug | Derive slug from another field. |
description | any | Documentation; surfaced in schema/OpenAPI. |
$ref | any | Reference a shared property/enum (§5). |
AVOID: unknown type names; multiple primaryKey: true fields; numeric bounds on string fields.
4. Resources (MUST)
resources:
Post:
description: > # SHOULD
...
object: # MUST — the field set.
id: { type: uuid, primaryKey: true }
title: { type: string, required: true, minLength: 3, maxLength: 200 }
status: { $ref: '#/enums/PostStatus', default: draft }
$merge: # OPTIONAL composition (§5)
- { $ref: '#/objects/Timestamp' }
relationships: { ... } # OPTIONAL (§6)
indexes: [ ... ] # OPTIONAL but MUST index every foreign key (§7)
features: { ... } # OPTIONAL (§8)
audit: { ... } # OPTIONAL — only meaningful if features.audit: true
validation: { ... } # OPTIONAL (§9)
permissions: { ... } # SHOULD always declare explicitly (§10)
- Each resource MUST have an
object:block with at least one field. - Each resource SHOULD have a
primaryKeyfield —id: { type: uuid, primaryKey: true }is the default convention.
5. Composition: objects, enums, properties, $merge, $ref (OPTIONAL)
Define reusable fragments at domain level, reference them inside resources.
objects: # bundles of fields, mixed in via $merge
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: always }
SoftDelete:
deletedAt: { type: datetime }
enums: # referenced via $ref under a field
PostStatus:
values:
draft: { label: "Draft" }
review: { label: "In Review" }
published: { label: "Published" }
archived: { label: "Archived", final: true } # final: terminal state
properties: # single reusable field definitions
slug: { type: slug, unique: true, maxLength: 255 }
Usage inside a resource’s object::
object:
slug: { $ref: '#/properties/slug', from: title } # $ref a property, add/override attrs
status: { $ref: '#/enums/PostStatus', default: draft } # $ref an enum
$merge: # mix in shared objects
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
$refpaths are JSON-pointer style:#/objects/X,#/enums/X,#/properties/X.- Enum field values MUST be one of the enum keys;
defaultMUST be a valid key.
6. Relationships (OPTIONAL)
relationships:
comments: # hasMany — the "one" side
type: hasMany
target: Comment
foreignKey: postId # FK lives on the target (Comment.postId)
onDelete: cascade # cascade | setNull | restrict
post: # belongsTo — the "many" side
type: belongsTo
target: Post
foreignKey: postId # FK lives on this resource
tags: # belongsToMany — via junction
type: belongsToMany
target: Tag
through: PostTag # junction resource/table
foreignKey: postId
otherKey: tagId
- Supported
type:hasMany,belongsTo,belongsToMany. - Relationships MUST be mirrored: if
Post hasMany Comment, thenComment belongsTo Post. - The
foreignKeyfield MUST exist on the resource that physically holds it, and SHOULD beimmutable: true. - Related data is fetched with
?include=<relationshipName>.
7. Indexes (MUST index every foreign key)
indexes:
- fields: [status]
description: "Filter posts by status"
- fields: [status, publishedAt] # composite index
description: "Published posts by date"
- Every foreign-key field MUST have an index.
- Fields commonly used in
filter/sortSHOULD be indexed.
8. Resource features (OPTIONAL)
features:
softDelete: true # adds deletedAt semantics; rows hidden from default list scope
audit: true # records change history (pair with an `audit:` block)
search: true # enables ?search=
versioning: false
export: false
import: false
caching: false
transactions: true # default true
audit: block (only meaningful when features.audit: true):
audit:
track: [title, status, body] # fields whose changes are recorded
exclude: [updatedAt, deletedAt] # fields explicitly not recorded
9. Validation rules (OPTIONAL)
Cross-field rules expressed as boolean expressions (AEL):
validation:
publishedAtRequiredWhenPublished:
rule: "status != 'published' || publishedAt != null"
message: "A publish date is required when setting status to published"
- A failing rule yields error_code
VALIDATION_ERRORat HTTP 422 (§11).
10. Permissions (SHOULD always declare)
permissions:
ownerField: authorId # which field identifies the owner (enables `owner`)
list: ["*"] # operation-level
get: ["*"]
create: [authenticated]
update: [owner, editor, admin]
delete: [admin]
fields: # field-level read/write overrides
status:
read: ["*"]
write: [editor, admin]
transitions: { ... } # state-machine transition guards (see Ch. 12)
Operation keys
list, get, create, update, delete. The alias read expands to both list and get. Field-level blocks use read / write.
Permission symbols (MUST use only these forms)
| Symbol | Meaning |
|---|---|
* | Public / anonymous — no authentication required. |
authenticated | Any valid logged-in user. |
owner | The user matching ownerField on the record. |
admin | Users with the admin role. |
<custom> | Any other string = a custom role name; the user’s roles must include it. |
- AVOID
?— it is not supported and the parser rejects it. Use*for public access. - If
permissions:is omitted entirely, the default is[admin]for all operations. Any omitted single operation also defaults to[admin]. - Permission values are arrays:
create: [authenticated],list: ["*"].
11. Response & error envelopes (the runtime contract)
Success:
{ "status": "ok", "code": 200, "data": { ... } }
List payloads place rows under data.items with a count (data.totalCount / data.count / data.meta.total).
Error (canonical — exact keys):
{
"status": "error",
"code": 422,
"data": { "fields": [ { "field": "title", "code": "required", "message": "Title is required" } ] },
"error_code": "VALIDATION_ERROR",
"error_message": "Validation failed"
}
- The only top-level keys are
status,code,data,error_code,error_message. - There is no top-level
errorormessage, and nodetailskey. Per-field detail goes indata. error_codeisUPPER_SNAKE_CASE. Request/field validation isVALIDATION_ERRORat HTTP 422.
12. Configuration file (config-*.json) (MUST to run)
A runnable POC needs a config JSON that points at the ADL file, a store, and the plugin set.
{
"transport": { "rest": { "host": "127.0.0.1", "port": 8081 } },
"auth": { "jwt_secret": "CHANGE-ME", "token_expiry_minutes": 60, "refresh_expiry_days": 7 },
"authd": { "store": "default", "password": { "disable_checking": false } },
"store": { "sqlite": { "path": "./dbs/auth.db", "name": "default", "pool_size": 4 } },
"adl": {
"db_path": "./dbs/adl.db",
"app_db_path": "./dbs/app.db",
"domain_file": "./adl-<name>.yaml"
},
"seeder": { "enabled": true, "run_on_start": true, "seed_file": "./seeds/seed-<name>.yaml" },
"core": {
"plugin_dirs": ["./build/bin/Debug/plugins", "./build/bin/Release/plugins"],
"plugins": [
{ "name": "transport.rest", "path": "kalos_rest.dll", "enabled": true },
{ "name": "store.sqlite", "path": "kalos_sqlite.dll", "enabled": true },
{ "name": "handler.authd", "path": "kalos_authd.dll", "enabled": true },
{ "name": "adl", "path": "kalos_adl.dll", "enabled": true },
{ "name": "seeder", "path": "kalos_seeder.dll", "enabled": true },
{ "name": "scripting.lua", "path": "kalos_lua.dll", "enabled": true },
{ "name": "codegen", "path": "kalos_codegen.dll", "enabled": true }
]
},
"rate_limit": { "enabled": true, "max_requests": 60, "window_seconds": 60 }
}
adl.domain_fileMUST point at the ADL YAML. If omitted, the domain must be submitted at runtime viaPOST /api/v1/domains.- Plugin filenames are
.dllon Windows and.so/.dylibon Linux/macOS — match your platform. - For Postgres, replace the
store.sqliteblock with the Postgres equivalent and adjustadl.*accordingly.
13. Seed manifest (seeds/seed-*.yaml) (SHOULD)
The seeder runs in four ordered phases. Roles and users MUST load before any data that depends on them.
roles: # Phase 1
- { name: admin, description: "Full access" }
- { name: editor, description: "Can publish" }
users: # Phase 2
- { username: admin, email: admin@example.com, password: "Admin123!", verified: true }
- { username: alice, email: alice@example.com, password: "Demo123!", verified: true }
assignments: # Phase 3
- { username: admin, roles: [admin] }
- { username: alice, roles: [editor] }
data: # Phase 4 — dispatched as action-bus calls
- action: adl.blog.tag.create # adl.{domain}.{resource}.{op}
data: { name: "tech", color: "#3b82f6" }
- action: adl.blog.post.create
as: alice # dispatch as this user (for owner/role-gated creates)
data: { title: "Hello", slug: "hello", status: "published", authorId: "alice", publishedAt: "2026-02-10T09:00:00Z" }
- Convention: admin user password
Admin123!, demo usersDemo123!, allverified: true. - Seeds SHOULD be idempotent — the seeder skips records whose unique identity field already exists.
- M2M links and records whose foreign keys are server-generated UUIDs generally cannot be seeded (the UUIDs are unknown at authoring time); wire those up via the API after start.
14. Endpoints you get for free
For a domain blog with resource Post (→ segment posts):
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/blog/posts | List (supports filter[field]=, sort=-field, page=&limit=, search=, include=; on PostGIS, spatial filter[geom][bbox|intersects|within]=… and ?simplify=<tol> — Chapter 41). |
| GET | /api/v1/blog/posts/{id} | Get one. |
| POST | /api/v1/blog/posts | Create (auth per permissions). |
| PATCH | /api/v1/blog/posts/{id} | Update. |
| DELETE | /api/v1/blog/posts/{id} | Delete (soft if features.softDelete). |
Domain management & introspection:
| Method | Path | Purpose |
|---|---|---|
| GET/POST | /api/v1/domains | List / submit domains. |
| GET | /api/v1/domains/{name} | Domain status. |
| GET | /api/v1/domains/{name}/schema | Schema introspection (JSON). |
| GET | /api/v1/domains/{name}/openapi.json | Per-domain OpenAPI 3.1 spec. |
| GET | /api/v1/domains/{name}/source | Original ADL source. |
| POST | /api/v1/domains/{name}/preview | Migration preview. |
| GET | /api/v1/domains/{name}/export | Export. |
Identity (always available via authd):
- Self-service:
POST /api/v1/auth/{register,login,refresh,logout,logout-all,password-change,verify-email,resend-verification,forgot-password,reset-password},GET /api/v1/auth/me, and/api/v1/users/{profile,me,sessions,preferences}. - Admin:
GET /api/v1/admin/users,GET /api/v1/admin/users/{username},GET|PUT /api/v1/admin/users/{username}/roles,POST /api/v1/admin/users/{username}/{suspend,unsuspend,verify},GET /api/v1/admin/audit,GET|POST /api/v1/admin/roles,PUT /api/v1/admin/roles/{name}/permissions,GET /api/v1/admin/permissions.
15. Verification flow (curl)
BASE=http://127.0.0.1:8081
API=$BASE/api/v1/blog
AUTH=$BASE/api/v1/auth
# 1. Log in, capture the JWT from data.access_token
TOKEN=$(curl -s -X POST "$AUTH/login" \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"Demo123!"}' | jq -r '.data.access_token')
# 2. Public list (no auth)
curl -s "$API/posts" | jq '.data.items | length'
# 3. Query: filter + sort + paginate
curl -s "$API/posts?filter[status]=published&sort=-publishedAt&page=1&limit=2" | jq '.data.items'
# 4. Authenticated create
curl -s -X POST "$API/posts" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"New Post","slug":"new-post","authorId":"alice","authorName":"Alice"}' | jq '.data'
# 5. Expected validation error shape (note canonical keys)
# { "status":"error","code":422,"data":{...},"error_code":"VALIDATION_ERROR","error_message":"..." }
16. Generation checklist (run before emitting)
-
domain.nameis lowercase and not reserved (auth, users, admin, domains, status, plugins, routes, version, openapi, echo). -
domain.versionis the domain’s own semver (e.g."1.0.0") — not"3.0.0"and not the platform version. - Every resource has exactly one
primaryKeyfield (uuidrecommended). - Every field
typeis in the supported list (§3). - Every foreign key has an
indexand isimmutable: true. - Relationships are mirrored (
hasMany↔belongsTo;belongsToManyhasthrough+otherKey). - Enum fields and their
defaultuse valid enum keys. - Permissions are explicit per resource; values are arrays; symbols are only
*,authenticated,owner,admin, or custom role names. - No
?anywhere in permissions. - Routes referenced in docs/examples use
/api/v1/{domain}/...(resources) and/api/v1/domains/...(management) — never/api/v1/adl/.... - Error examples use the canonical envelope (
data/error_code/error_message, codeVALIDATION_ERROR@ 422). - Seed defines roles → users → assignments → data, in that order; demo passwords
Admin123!/Demo123!; usersverified: true. - Config
adl.domain_filepoints at the ADL file; plugin extensions match the target OS.
17. Never do this (AVOID)
- ❌
/api/v1/adl/{domain}/...— theadl/segment does not exist. - ❌
permissions: { create: ["?"] }—?is rejected by the parser. - ❌
"version": "3.0.0"orx-adl-version: "3.0.0"— the platform version is0.3.1. - ❌ Error bodies with top-level
error/messageor adetailskey — useerror_code/error_message/data. - ❌
error_code: "VALIDATION_FAILED"for request validation — it isVALIDATION_ERROR. - ❌ Admin user management under
/api/v1/auth/users— it lives under/api/v1/admin/users. - ❌ A domain named
users,admin,auth,openapi, etc. (reserved). - ❌ A foreign key without an index, or an unmirrored relationship.
- ❌ Unknown field types, or numeric bounds (
min/max) on string fields.