Status: Normative · Platform version: 0.3.1 · Display label: ADL v0.3
Audience: LLMs and humans authoring or operating Kalos / ADL applications.
This is the single complete reference for ADL v0.3. It consolidates specification chapters 1–41 into one document — distilled to grammar, rules, endpoints, and examples — and reconciled against the shipped Kalos runtime (core/, plugins/adl, plugins/authd, plugins/seeder). Where this document and the per-chapter spec ever disagree, the runtime wins and this document tracks it. Chapters 42–44 (UI Pass-Thru Mode, User Interface Extensions, and pgvector / RAG) postdate this consolidation and are maintained separately in the per-chapter reference — including their individual Normative / Informative status.
v0.3.1 adds GIS / spatial support (the
geometrytype, PostGIS storage, and spatial query operators) — see Chapter 41 — GIS & Spatial Data — plus migration history/rollback, scalable bulk ingest with upsert, and auth roles exposed to clients. SeeADL-v0.3.1-Release-Notes.md.
Companion documents (kept alongside this one): ADL-v0.3-LLM-Authoring-Spec.md (condensed generation contract with a gold-standard POC) and the per-chapter ADL-v0.3-Specification-Chapter-NN-*.md files (full prose and rationale).
How to read this document
- MUST — required for a valid, loadable ADL document; violations are rejected (parser 422) or fail at runtime.
- SHOULD — strongly preferred convention.
- AVOID — invalid or discouraged; do not emit.
Canonical Contract (memorize)
| Concern | Canonical rule |
|---|---|
| Resource route prefix | /api/v1/{domain}/{resources} — 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 | error_code: VALIDATION_ERROR at HTTP 422 (never VALIDATION_FAILED). |
| Public access symbol | * = public/anonymous (no auth). |
| Any-logged-in symbol | authenticated. |
? permission wildcard | Unsupported — the parser rejects it. |
| Version strings | Platform/release is 0.3.1. A domain’s own version: is an independent author semver. |
| Reserved domain names | auth, users, admin, domains, status, plugins, routes, version, openapi, echo. |
| Destructive migrations | Gated by request-body field allow_destructive: true (no X-Allow-Destructive header). |
| Action bus id format | adl.{domain}.{resource}.{op}; domain ops are adl.domain.*. |
Contents
Part I — Core Platform & Modeling
- Introduction & Architecture · 2. Microkernel & Plugins · 3. Configuration · 4. REST Transport · 5. Store Adapters · 6. Document Structure · 7. Type System · 8. Relationships · 9. Enumerations · 10. Shared Objects & Composition
Part II — Access, Behavior & Data Rules 11. Permissions & RBAC · 12. State Machines · 13. Computed Fields · 14. Validation · 15. Resource Features · 16. Query Parameters · 17. API Configuration · 18. Content & Media Types · 19. Indexes · 20. Scopes
Part III — Runtime Subsystems 21. History of Change · 22. Rules Engine · 23. Notifications · 24. Scheduled Tasks · 25. Workflows · 26. Reports, Views & Dashboards · 27. Templates & Documents · 28. Sheet Mode
Part IV — Platform Services & Operations 29. Authentication & Identity · 30. Migration System · 31. Lua Scripting · 32. Seeder System · 33. Privacy & GDPR · 34. Localization (i18n) · 35. External Integrations · 36. AEL · 37. OpenAPI Generation · 38. Admin Endpoints · 39. Error Contract · 40. Complete API Reference
Appendix A — Complete worked example (blog domain) · Appendix B — Generation checklist
Part I — Core Platform & Modeling
1. Introduction & Architecture
Kalos is a C++20 microkernel application server that runs declarative ADL domain files as complete backend applications with no generated code.
The kernel provides only seven services (config, plugin loading, message bus, lifecycle, logging, plugin registry, management socket) and knows nothing of HTTP, SQL, or auth. All behavior comes from four plugin archetypes: Transport (REST/TLS/CORS), Store (SQLite, PostgreSQL), Handler (ADL engine, authd, seeder), and Scripting (Lua via sol2). ADL is a YAML language describing data models, relationships, permissions, state machines, validation, computed fields, plus the v0.3 features (history, rules, notifications, schedules, workflows, reports, templates, integrations, privacy, localization). The ADL engine parses the YAML, generates the schema, registers CRUD actions on the bus, maps REST routes, and produces OpenAPI — the YAML is the application. Platform version is 0.3.1; each domain carries its own author semver in version:.
Six design principles, applied in order: Domain-Driven (model is source of truth), Convention Over Configuration (no permissions: block ⇒ admin-only), Type Safety (closed type system, validated at the API boundary ⇒ 422), Incremental Complexity (every feature is additive), Declarative First / Imperative via Lua, Database Agnostic (same YAML on SQLite and PostgreSQL).
Grammar / Config
domain:
name: tasks
version: "1.0.0" # author semver, NOT platform version
resources:
Task:
object:
id: { type: uuid, primaryKey: true }
title: { type: string, required: true }
done: { type: boolean, default: false }
$merge: Timestamp
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: always }
Rules
- MUST treat the domain model as authoritative — schema, API, permissions, validation, and docs all derive from it.
- MUST use only types defined in this specification (the type system is closed).
- SHOULD rely on conventions (UUID PK auto-gen, auto timestamps, English pluralization) and override only when needed.
- AVOID database-specific constructs in the YAML; write types, not columns.
Example
Submitting the file above to a running instance yields a tasks table, six REST endpoints (GET/POST /api/v1/tasks, GET/PATCH/PUT/DELETE /api/v1/tasks/{id}), full query support, OpenAPI 3.1.0 at /api/v1/domains/tasks/openapi.json, and schema introspection at /api/v1/domains/tasks/schema. Request lifecycle: HTTP → REST transport → middleware chain (rate limit, validation, endpoint-enabled, JWT, RBAC, audit) → ADL action handler → store → response. Internal callers (Lua kalos.call(), workflows, scheduled tasks) dispatch on the bus directly, bypassing transport middleware.
2. Microkernel & Plugins
The kernel orchestrates plugin lifecycle and a synchronous message bus; plugins do all the work and communicate only through the
ICoreinterface.
The kernel offers exactly seven services and MUST implement nothing more. Plugins are loaded via dlopen/LoadLibrary in config order (no dependency resolution — order matters). Every plugin gets an ICore* and uses it to register_action(name, handler), handle_request(request), register_route(method, path, action), get_store/get_plugin, and emit/subscribe. Action registration is append-only — names cannot be replaced or unregistered, so domains cannot be unloaded without a restart and Lua hot-reload may leave stale handlers (which return 500). The bus dispatches synchronously (unknown action ⇒ 404); events (emit/subscribe) are fire-and-forget with undefined ordering, used for history, notifications, workflow advancement, Lua hooks, and integrations.
Each plugin runs a five-phase lifecycle in config order: on_register (store the ICore pointer only) → on_init (open connections, ADL restores domains + boot schema verify) → on_start (register actions/routes, begin listening); shutdown calls on_stop then on_destroy in reverse order. A plugin MAY call actions from earlier plugins during init but MUST NOT call later ones. The four archetypes are Transport, Store, Handler, Scripting (classification is for introspection; not enforced).
Grammar / Config
{ "core": { "plugins": [
{ "name": "transport.rest", "path": "kalos_rest.so", "enabled": true },
{ "name": "store.sqlite", "path": "kalos_sqlite.so", "enabled": true },
{ "name": "handler.authd", "path": "kalos_authd.so", "enabled": true },
{ "name": "adl", "path": "kalos_adl.so", "enabled": true },
{ "name": "seeder", "path": "kalos_seeder.so", "enabled": true },
{ "name": "scripting.lua", "path": "kalos_lua.so", "enabled": true }
] } }
Request carries action, params, body, headers, identity, transport; Response carries code, data, error_message, headers. Action names: adl.{domain}.{resource}.{operation} for ADL, {plugin}.{entity}.{operation} for system (e.g. auth.login, admin.users.list). Events: adl.{domain}.{resource}.{event}.
Rules
- MUST load plugins ordered transport → store → auth → ADL → seeder → scripting; the seeder MUST be the last handler.
- MUST terminate on
dlopenfailure oron_initfailure (fatal). - MUST set the
transportfield on every dispatchedRequest; transport-originated requests run the full middleware chain. - AVOID relying on
__system__identity for trust boundaries — it is convention-enforced and bypasses RBAC.
Endpoints (management socket — local only, line-delimited JSON, never network-exposed)
| Method | Path | Purpose |
|---|---|---|
| cmd | {"cmd":"status"} | uptime + plugins |
| cmd | {"cmd":"plugins"} | loaded plugin inventory |
| cmd | {"cmd":"actions"} | registered action inventory |
| cmd | {"cmd":"shutdown"} | graceful shutdown |
3. Configuration
A single JSON file (passed as
kalosd --config config.json, or positionally askalosd config.json) configures the kernel and every plugin via top-level sections, with${VAR}environment substitution.
The file MUST be valid JSON (no comments); each plugin reads its own section and validates it. Sections: logging, transport.rest (host/port, tls, cors, security), authd (jwt, password, store, prefix), store (sqlite or postgres), adl, seeder, rate_limit, email, notifications.driver, rbac, core, app_base_url, audit. JWT uses HS256 with a ≥32-char secret; access TTL 900s, refresh 604800s. Passwords use argon2id; complexity is enforced unless disable_checking. Rate limiting is off by default and identifies clients by IP, with stricter limits on auth endpoints.
Grammar / Config
{
"logging": { "level": "info", "format": "json",
"file": { "path": "/var/log/kalosd/server.log", "max_size_mb": 50, "max_files": 10 } },
"transport": { "rest": {
"host": "0.0.0.0", "port": 443,
"tls": { "cert_file": "/etc/ssl/app.crt", "key_file": "/etc/ssl/app.key",
"redirect_http": true, "redirect_http_port": 80 },
"cors": { "origins": "https://app.example.com" },
"security": { "hsts_max_age": 31536000, "x_frame_options": "DENY" } } },
"authd": { "jwt": { "algorithm": "HS256", "secret": "${JWT_SECRET!}", "issuer": "myapp",
"access_token_ttl_seconds": 900, "refresh_token_ttl_seconds": 604800 },
"password": { "algorithm": "argon2id", "disable_checking": false },
"store": "default", "prefix": "/api/v1" },
"store": { "postgres": { "conninfo": "host=${DB_HOST!} dbname=${DB_NAME!} user=${DB_USER!} password=${DB_PASSWORD!} sslmode=require",
"name": "default", "pool_size": 8 } },
"adl": { "domain_file": "/opt/app/domain.yaml" },
"seeder": { "enabled": true, "run_on_start": true, "seed_file": "/opt/app/seed.yaml" },
"rate_limit": { "enabled": true, "max_requests": 120, "window_seconds": 60,
"auth_endpoints": { "max_attempts": 5, "window_seconds": 300 } },
"app_base_url": "https://app.example.com/api/v1",
"core": { "management_socket": "/var/run/kalosd.sock",
"plugin_dirs": ["/usr/lib/kalos/plugins"], "plugins": [ /* ordered */ ] }
}
Env substitution: ${VAR} (empty if unset), ${VAR:default}, ${VAR!} (fatal if unset) — strings only, applied by the kernel before plugins see config.
Rules
- MUST use absolute paths and a ≥32-char JWT secret in production; prefer
${VAR!}for secrets. - MUST order
core.pluginsby dependency (transport → store → auth → adl → seeder → scripting). - SHOULD set
app_base_urlto the external URL (behind proxies) and use SQLite’s separatedb_path/app_db_pathonly on SQLite (ignored on PostgreSQL). - AVOID the legacy
authblock anddisable_checking: trueoutside development; present only onestoresection.
4. REST Transport
The REST transport maps HTTP requests to bus actions, runs every external request through a fixed middleware chain, and wraps all ADL responses in a standard envelope.
Resource routes are /api/v1/{domain}/{resources} where the resource name is auto-pluralized and kebab-cased (OrderItem ⇒ order-items). Auth lives under /api/v1/auth/... and /api/v1/users/..., admin under /api/v1/admin/..., and domain management/introspection under /api/v1/domains/.... Middleware order: rate limit → request validation → endpoint-enabled → JWT auth → RBAC → audit → handler; any rejection short-circuits. Disabled operations return 404. Permissions: * = public/anonymous (skips JWT), authenticated = any logged-in user, plus owner/admin/custom roles; the ? wildcard is NOT supported. Internal transports (internal, seeder, lua, workflow, scheduler) bypass the entire chain; only http runs it.
Grammar / Config
Error envelope is exactly:
{ "status": "error", "code": 422, "data": {},
"error_code": "VALIDATION_ERROR", "error_message": "Validation failed" }
Success: { "status":"ok", "code":200, "data": {...} }; lists return data: { items, count, totalCount } (totalCount only when page is used); 204 has no body. Query params on list endpoints: page/limit(≤1000)/offset, sort=-createdAt,title, filter[field][op]= (eq/ne/gt/gte/lt/lte/like/in, AND-combined), search, include=, scope= (auto active/deleted/all for soft delete), fields= (id always returned).
Rules
- MUST send
Content-Type: application/jsonfor bodies (else 415); GET has no body and no side effects; PATCH/PUT/DELETE are idempotent. - MUST use error code
VALIDATION_ERROR@ 422;error_codevalues are UPPER_SNAKE. - MUST NOT let clients set auto-generated fields (
id,createdAt,updatedAt,deletedAt,slug, state field) — submitted values are ignored/overwritten. - AVOID enabling dev-mode response-schema validation in production.
Endpoints (per domain d, resource R pluralized rs)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/d/rs | list (adl.d.r.list) |
| GET | /api/v1/d/rs/{id} | get |
| POST | /api/v1/d/rs | create (201) |
| PATCH | /api/v1/d/rs/{id} | partial update |
| PUT | /api/v1/d/rs/{id} | full replace |
| DELETE | /api/v1/d/rs/{id} | delete (204) |
| GET | /api/v1/d/rs/count · /distinct | count / distinct values |
| POST | /api/v1/d/rs/{id}/transitions/{name} | state transition |
| GET/POST/DELETE | /api/v1/d/rs/{id}/{related}[/link|/unlink] | relationship load / M2M link |
| GET | /api/v1/d/rs/{id}/history · /history | history timeline [v0.3] |
Feature-gated: /restore (softDelete), /audit, /versions+/revert (versioning), /export+/import. Infrastructure: /health, /version (raw, no envelope), /api/v1/status, /api/v1/plugins, /api/v1/routes, /api/v1/openapi.json.
Example
curl -H "Authorization: Bearer $TOKEN" \
"https://app.example.com/api/v1/blog/posts?filter[status]=published&sort=-createdAt&page=1&limit=20&include=comments,tags"
# 422 on bad body:
# { "status":"error","code":422,"data":{},"error_code":"VALIDATION_ERROR",
# "error_message":"Validation failed",
# "errors":[{ "field":"email","message":"Invalid email format","rule":"type" }] }
5. Store Adapters
Store adapters encapsulate all database differences behind a common query interface, so the same domain YAML runs identically on SQLite and PostgreSQL.
The ADL engine generates store-appropriate SQL; authors never write SQL or reference DB constructs. Only one adapter SHOULD be active. SQLite uses a connection pool (default 4) and mandatory PRAGMAs journal_mode=WAL, foreign_keys=ON, busy_timeout=5000; it stores most types as TEXT (uuid, decimal/money as strings for precision, boolean as 0/1) and uses separate DB files for store/registry/app data. PostgreSQL uses libpq with conninfo (prefer sslmode=require+), native types (UUID, NUMERIC, BOOLEAN, TIMESTAMP WITH TIME ZONE, JSONB, SERIAL), and one database for all tables (db_path/app_db_path ignored; table-name prefixing namespaces). Type-specific validation always runs at the API boundary, not in the DB.
Grammar / Config
{ "store": { "sqlite": { "path": "/data/app.db", "name": "default", "pool_size": 4 } } }
{ "store": { "postgres": { "conninfo": "host=localhost dbname=app user=pg password=secret sslmode=require",
"name": "default", "pool_size": 4 } } }
Migration DDL differs by adapter. PostgreSQL emits single ALTER TABLE statements (change type, set/drop NOT NULL/default, add/drop FK constraint). SQLite supports ADD COLUMN (all versions), RENAME COLUMN (3.25+), DROP COLUMN (3.35+) natively; type/constraint/cascade changes require the 4-step column rebuild (add temp column → copy data, COALESCE(col,'default') for optional→required → drop old → rename), all in one transaction.
Rules
- MUST apply SQLite PRAGMAs on every connection — without
foreign_keys=ON, FK/cascade behavior is silently ignored. - MUST execute every migration plan in a single transaction; a failure leaves the pre-migration state intact.
- MUST run boot-time schema verification — missing tables/columns block activation; type mismatches/extra columns only warn.
- SHOULD use
sslmode=requireor stronger for PostgreSQL; ensure SQLite ≥ 3.35.0 (older skipsDROP COLUMN, aborting mid-rebuild and requiring manual repair).
Example
-- SQLite optional → required, via 4-step rebuild (single transaction)
ALTER TABLE posts ADD COLUMN title_new TEXT NOT NULL DEFAULT '';
UPDATE posts SET title_new = COALESCE(title, '');
ALTER TABLE posts DROP COLUMN title; -- requires SQLite >= 3.35.0
ALTER TABLE posts RENAME COLUMN title_new TO title;
-- PostgreSQL equivalent, single statement
ALTER TABLE posts ALTER COLUMN title SET NOT NULL;
6. Document Structure
Defines the YAML/JSON file layout, top-level keys, and the directives that assemble a domain definition into a single resolved model.
An ADL domain is YAML 1.2 (JSON accepted; auto-detected by leading {/[). Everything lives under a single top-level domain key. Large domains split across files via $include (glob-aware); string values support ${VAR} environment substitution. The parser runs a fixed seven-stage pipeline — format detection → $include → ${VAR} → build lookup tables → $ref/$merge → validation → build DomainModel — and emits fully resolved fields with no directive traces.
Grammar / Config
domain:
name: Blog # Required. Identifier for tables/routes/actions/events.
version: "1.0.0" # Required. Author's semver, quoted. Advisory only.
description: ... # Optional.
config: # Optional. Domain-level settings; supports ${VAR}.
api_url: "${API_BASE_URL:https://api.example.com}"
properties: {} # Optional. Reusable atomic types.
objects: {} # Optional. Reusable field groups.
enums: {} # Optional. Enumerations.
resources: {} # Required. Business entities.
blocks: {} # Optional. Custom block types.
icons: {} # Optional.
fonts: {} # Optional.
$include: # Optional. Merged in order; later wins on conflict.
- ./types/*.yaml
- ./resources/*.yaml
${VAR} forms: ${VAR} (empty if unset), ${VAR:default}, ${VAR!} (error if unset).
Rules
- MUST quote
versionso YAML does not parse it as a float ("1.0.0", not1.0.0). - MUST NOT use a reserved domain name (case-insensitive):
auth,users,admin,domains,status,plugins,routes,version,openapi,echo→ rejected with 422. - MUST keep
domain:metadata only in the root file; included files contain bare top-level fragments (properties,objects,enums,resources,blocks,icons,fonts), never adomain:block. - MUST make JSON submissions self-contained —
$includeis unsupported in JSON. - Parse failures return the error envelope at HTTP 422 with
error_code: PARSE_ERROR.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/domains | Submit a domain ({content, format}; format auto-detected) |
Example
# domain.yaml
domain:
name: Orders
version: "2.1.0"
description: E-commerce order management.
config:
secret_key: "${APP_SECRET!}"
$include:
- ./types/shared-types.yaml # contributes properties/objects/enums
- ./resources/*.yaml # contributes resources
7. Type System
A closed, four-tier type system (plus content types) — 37 fixed types, no custom types — declaring how each field is stored, validated, and rendered.
Every field MUST declare a type from this fixed set. Tiers ascend in semantic specialization: Tier 0 Core (DB primitives), Tier 1 Extended (format-specific), Tier 2 Specialized (validation/security), Tier 3 Domain (international standards), and Content (rich structured JSON). Constraints are declared inline beside the type and enforced at the API boundary on every create/update.
Full supported type list (37)
- Tier 0 — Core (10):
string·text·integer·decimal(string in JSON, setprecision/scale) ·boolean·date·datetime·uuid(auto v4 on create unlessallowUserProvided: true) ·email·url - Tier 1 — Extended (6):
float·timestamp(unix epoch int) ·slug(requiresfrom, always unique, regenerated on source change, suffixes-1,-2on collision) ·json·markdown·autoincrement(read-only, never reused) - Tier 2 — Specialized (5):
phone·time·password(Argon2id hashed, implieswriteOnly) ·token(auto-generated, read-only) ·array(items.type,minItems,maxItems,uniqueItems) - Tier 3 — Domain (9):
money(string, implicit precision 19 scale 4) ·percentage·currency(ISO 4217) ·country(ISO 3166-1 alpha-2) ·language(ISO 639-1) ·timezone(IANA) ·color(#RRGGBB) ·bytes(base64,maxSize) ·set(array withuniqueItemsenforced) - Content (7):
media(accept,maxSize, dimensions,aspectRatio) ·svg(auto-sanitized) ·blocks(allowedBlocks,maxBlocks) ·icon(set:name,iconSets) ·font(sources) ·component(single nested object, one JSON column) ·repeater(ordered array of objects,sortable/minItems/maxItems) - Spatial (1):
geometry(aliasgeojson) — a GeoJSON geometry value. OptionalgeometryType(point/linestring/polygon/multipoint/multilinestring/multipolygon/geometrycollection, case-insensitive) constrains the accepted type;sriddefaults to4326(WGS84). Validated as well-formed GeoJSON (atypepluscoordinates, orgeometriesfor a collection); accepted as an object or a JSON string and emitted as an object. Stored asJSONBon PostgreSQL /TEXTon SQLite. On PostgreSQL with thepostgisextension, each geometry field additionally gets a database-maintainedgeometry(<type>,<srid>)sidecar column (<field>__geom, generated from the GeoJSON) plus a GiST index — the JSONB column stays the source of truth, so the GeoJSON API is unchanged. Without postgis it falls back to JSONB-only (warned). Spatial query operators (PostgreSQL+PostGIS only) filter list/count against the GiST-indexed column:filter[geom][bbox]=minLon,minLat,maxLon,maxLat(bounding-box overlap, ideal for map viewports),filter[geom][intersects]=<GeoJSON>, andfilter[geom][within]=<GeoJSON>. Using a spatial operator on a non-geometry field, or on SQLite, returns400. For large layers: combinebbox(viewport) with level-of-detail filtering — declare a normal indexed integer likeminZoomand filterfilter[minZoom][lte]=<z>— and/or thin geometry server-side with?simplify=<tolerance>on list (runsST_Simplifyagainst the geometry column; PostgreSQL+PostGIS only).
Grammar / Config
price: { type: decimal, precision: 10, scale: 2, min: 0 }
slug: { type: slug, from: title, unique: true }
tags: { type: array, items: { type: string }, minItems: 1, uniqueItems: true }
address: { type: component, object: { $ref: '#/objects/Address' }, collapsed: true }
createdAt:{ type: datetime, autoNow: create } # create | always
Constraints
- Universal:
required,unique,default,immutable,nullable,description. - String:
maxLength(default 255),minLength,pattern,trim,lowercase,uppercase,enum. - Numeric:
min,max,precision,scale. Date/time:min,max(ISO 8601). - Security:
writeOnly(never read back),internal(never API-exposed, 403 on write),hidden(omitted unless?fields=),requestable.
Rules
- MUST declare a type from the fixed set; no user-defined types.
- MUST store
decimal/moneyas strings in JSON to preserve precision. - SHOULD use
string(length-bounded, VARCHAR in PG) for short values,textfor long-form. - SHOULD use relationships (not
component/repeater) for data that must be queried or shared independently — components have noidand cannot relate. - Auto-managed fields (
autoNow,uuidPK,autoincrement,token) silently ignore client-submitted values.
Example
resources:
Article:
object:
id: { type: uuid, primaryKey: true }
title: { type: string, required: true, maxLength: 200, trim: true }
slug: { type: slug, from: title, unique: true }
body: { type: markdown }
price: { type: money, min: 0 }
currency: { type: currency, default: USD }
cover: { type: media, accept: [image/*], maxSize: 5mb, aspectRatio: "16:9" }
createdAt: { type: datetime, autoNow: create }
8. Relationships
Three relationship types that fix foreign-key placement, generated endpoints, and cascade behavior between resources.
belongsTo (many-to-one) owns the FK column on this resource and generates the DDL REFERENCES clause. hasMany (one-to-many) is its inverse — it generates no DDL, only the nested list endpoint and ?include= eager loading; the FK lives on the target. belongsToMany (many-to-many) uses a junction table holding both FKs. A belongsTo/hasMany pair are two sides of one relationship and MUST mirror — both name the same foreignKey, but only the FK-owning (belongsTo) side produces the column.
Grammar / Config
# belongsTo — owns FK on this resource
relationships:
author:
type: belongsTo
target: User
foreignKey: authorId # default: {relationshipName}Id
onDelete: restrict # cascade | restrict | nullify | none (default none)
required: true # enforced at API only; DB column stays nullable
# hasMany — inverse; FK is on the target. No DDL.
posts:
type: hasMany
target: Post
foreignKey: authorId # FK column on the TARGET (required)
orderBy: { createdAt: desc }
where: { status: published }
# belongsToMany — junction table
tags:
type: belongsToMany
target: Tag
through: PostTag # default: two names sorted alphabetically
foreignKey: postId # default: {thisResource}Id
otherKey: tagId # default: {targetResource}Id
timestamps: false
onDelete: cascade=delete children · restrict=block delete, 409 if children exist · nullify=SET NULL · none=no enforcement.
Rules
- MUST mirror
foreignKeybetween thebelongsToand its inversehasMany; only thebelongsToside generates the FK column DDL. - MUST give the
hasManyside aforeignKeynaming the column on the target. - SHOULD mark permanent FK fields
immutable: true(update with a changed value → 422). - The FK DB column is always nullable (so column-add migrations don’t fail on existing rows);
requiredis enforced only at the API boundary. - Composite PK: declare
compositePrimaryKey: [a, b](each fieldrequired: true), and the resource MUST NOT also have aprimaryKey: trueid; client provides all key values (no auto-gen).
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/{resource}/:id/{related} | List related records (hasMany / belongsToMany) |
| POST | /api/v1/{domain}/{resource}/:id/{related}/link | Link a record (belongsToMany; idempotent, 200) |
| DELETE | /api/v1/{domain}/{resource}/:id/{related}/unlink | Unlink a record (belongsToMany; idempotent, 204) |
Eager loading on any relationship: GET /api/v1/{domain}/{resource}?include={relationship}.
Example
resources:
Category: # self-referential hierarchy
object:
id: { type: uuid, primaryKey: true }
name: { type: string, required: true }
parentId: { type: uuid }
relationships:
parent: { type: belongsTo, target: Category, foreignKey: parentId, onDelete: restrict }
children: { type: hasMany, target: Category, foreignKey: parentId }
9. Enumerations
Fixed allowed-value sets for
stringfields, in simple (list) or rich (metadata-bearing) form.
Enums are a constraint on string fields, not a standalone type. Define them in the top-level enums: block and apply via inline list, enum: Name reference, or $ref: '#/enums/Name' — all three give identical, exact-match, case-sensitive validation (422 on a bad value). Simple enums are a plain value list; rich enums attach per-value label/description/color/icon/final metadata that surfaces in the schema endpoint and OpenAPI x-enum-* extensions but never in stored data. The parser exposes is_rich for UI generators.
Grammar / Config
enums:
Priority: # simple
values: [low, medium, high, critical]
OrderStatus: # rich
values:
pending: { label: "Pending", color: "#F97316" }
shipped: { label: "Shipped", color: "#22C55E" }
delivered: { label: "Delivered", color: "#008000", final: true }
# Apply to a field (three equivalent forms):
priority: { type: string, enum: [low, medium, high, critical], default: medium }
status: { type: string, enum: OrderStatus, default: pending }
status: { $ref: '#/enums/OrderStatus', default: pending } # preferred for rich enums
Rules
- MUST match values exactly and case-sensitively (
"Draft"≠"draft"). - MUST provide a value in the set on create/PUT unless the field has a
default; on PATCH an omitted field keeps its value. - SHOULD use
$refwhen the enum is rich, so the field inherits labels/colors/descriptions. - SHOULD promote an enum to
enums:when shared by 2+ resources or carrying metadata; keep it inline for single-field, metadata-free use. final: trueis a UI hint only (no API-level enforcement). Non-required enum fields acceptnullregardless of the set.- For lifecycle values with guarded transitions and side effects, use a state machine (§12), not a bare enum.
Example
enums:
TicketStatus:
values:
open: { label: "Open", color: "#3B82F6" }
inProgress: { label: "In Progress", color: "#F59E0B" }
resolved: { label: "Resolved", color: "#22C55E", final: true }
closed: { label: "Closed", color: "#6B7280", final: true }
resources:
Ticket:
object:
status: { $ref: '#/enums/TicketStatus', default: open }
10. Shared Objects & Composition
Reusable field groups (
objects) and single-field definitions (properties) composed into resources via$mergeand$ref, all resolved at parse time.
properties define one reusable typed field (referenced by $ref on a field); objects define a named group of fields (incorporated by $merge inside a resource’s object: block). $ref replaces itself with the referenced definition (recursive, with circular-chain detection); sibling keys override the resolved content. $merge adds all of an object’s fields to the enclosing resource. All directives are resolved at parse time, so every downstream consumer — API, schema endpoint, OpenAPI, migrations, hooks — sees only fully expanded fields and cannot tell a directly-declared field from a merged one.
Grammar / Config
properties: # single reusable field
email: { type: email, required: true, lowercase: true, maxLength: 255 }
objects: # reusable field group
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: always }
SoftDelete: { deletedAt: { type: datetime } }
AuditMeta: { createdById: { type: uuid }, updatedById: { type: uuid } }
resources:
Post:
object:
id: { type: uuid, primaryKey: true }
title: { type: string, required: true }
email: { $ref: '#/properties/email', required: false } # $ref + override
$merge: # multiple groups
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
- { $ref: '#/objects/AuditMeta' }
$ref targets: #/properties/{name}, #/objects/{name}, #/enums/{name}, #/blocks/{name}, {file}#/{path} (external, requires prior $include). Single-merge shorthand: $merge: { $ref: '#/objects/Timestamp' }.
Rules
- MUST NOT nest composition: objects cannot contain
$merge, and properties cannot$refother properties (no chaining). - MUST NOT put relationships, permissions, or hooks in an
object— they are strictly field groups; object names PascalCase, property names camelCase. - Precedence: explicit fields on the resource always beat merged fields; among multiple merges, later array entries win on name conflict.
- SHOULD treat shared objects as stable interfaces — adding fields is safe, but a change/rename/removal fans out as a migration across every merging table (apply
renamedFromon the field in the object itself). - SHOULD promote to a shared object/property when a group/field recurs in 3+ resources; an unreferenced definition is harmless dead code flagged by the linter.
Example
resources:
Customer:
object:
id: { type: uuid, primaryKey: true }
contactEmail: { $ref: '#/properties/email' }
# Merge: address fields become top-level, individually queryable columns
$merge: { $ref: '#/objects/Address' }
# Component: address stored/queried as a single nested JSON object
shippingAddress:
type: component
object: { $ref: '#/objects/Address' }
Part II — Access, Behavior & Data Rules
11. Permissions & RBAC
Role-based access control governing which roles may perform operations and access fields on a resource, enforced on every external HTTP request.
Permissions live in a resource’s permissions: block at three levels: operation (list/get/create/update/delete), field (read/write per field), and transition (per state-machine transition). Each value is either a role array ([owner, admin], fast direct comparison) or an AEL expression string ("isOwner() || hasRole('admin')"); the parser picks the mode from the YAML type, and both may coexist. If permissions: is omitted, or any single operation is omitted, the default is [admin]. At the operation level get covers single reads and list covers collections; read is an alias that fills both get and list when they are not given explicitly (verified in the runtime parser — adl_parser_permissions.cpp). Field-level blocks use read/write. The enforcement order is: public → JWT check → authenticated → role match → owner match → AEL → 403.
Role symbols
| Symbol | Meaning | Enforcement |
|---|---|---|
* | Public / anonymous | JWT validation skipped entirely |
authenticated | Any logged-in user | Valid JWT required, no role check |
owner | User owns the record | JWT user_id matched against owner FK |
admin | Admin role | JWT roles includes "admin" |
| any string | Custom role | JWT roles includes that string |
?(optional auth) is NOT supported.
Grammar / Config
permissions:
list: ["*"]
get: ["*"]
create: [authenticated]
update: [owner, editor, admin] # or AEL: "isOwner() || hasRole('editor')"
delete: [admin]
fields:
salary: { read: [hr, admin, owner], write: [hr, admin] }
ssn: { read: [owner], write: [admin], mask: true }
conditional:
update:
draft: [owner, editor]
published: [admin]
archived: [] # [] = nobody
transitions:
publish: [admin]
Rules
- MUST pass the operation-level check before any field-level check applies (field perms narrow, never widen).
- MUST treat an omitted operation as
[admin]; an omittedpermissions:block as admin-only for all five operations. - MUST resolve
ownervia the owner FK by convention:createdById→userId→authorId→ firstbelongsTo User; if none exist,ownernever matches. - SHOULD strip read-restricted fields entirely from responses (absent, not null); use
mask: trueto return"***"instead. - SHOULD silently ignore a write-restricted field the caller can’t set, unless it is
requiredon create → 403FIELD_FORBIDDEN. - AVOID
create: [owner](no record exists yet — means nobody); use[authenticated]. Onlist,ownerfilters to the caller’s own rows rather than denying.
AEL functions: hasRole(r), hasAnyRole(...), isOwner(), isAuthenticated(), userId(), inState(s), field(name), hasField(name); context vars user, record, op. Conditional entries override operation perms by current state; transitions perms take precedence over inline state-machine guards and, if absent, inherit update.
Example
resources:
Article:
permissions:
list: ["*"]
get: "isAuthenticated() && (hasRole('admin') || field('deptId') == user.deptId)"
create: [authenticated]
update: [owner, editor, admin]
delete: [admin]
fields:
internalNotes: { read: [admin, manager], write: [admin] }
conditional:
update:
published: [admin]
The schema endpoint GET /api/v1/domains/:name/schema returns the raw permission declaration for UI display; the server always enforces authoritatively.
12. State Machines
A declared, role-guarded lifecycle controlling the legal values and movements of one field on a resource.
A stateMachine: block names the controlled field (must exist in object:, typically status), an initial state auto-set on create, and a states map. Each transition declares a target to, optional guards (role array, OR-evaluated) or guard (singular AEL string), requiredFields, and sets. States may declare final: true (no outgoing transitions), onEnter/onExit hooks. Use a state machine when value progression has rules, role-gated transitions, or side effects; use a plain enum field when any value may be set anytime.
Grammar / Config
stateMachine:
field: status
initial: open
states:
open:
description: "New ticket"
transitions:
assign: { to: assigned, guards: [authenticated] }
in_progress:
transitions:
resolve:
to: resolved
guards: [owner, admin]
requiredFields: [resolution]
escalate:
to: escalated
guard: "hasRole('agent') && field('priority') == 'critical'" # AEL = singular key
resolved:
onEnter: [notifyReporter]
transitions:
close: { to: closed, guards: [owner, admin], sets: { closedAt: "now()", closedById: "$user.id" } }
cancelled:
final: true
Rules
- MUST evaluate a transition in order: exists from current state (else 422
TRANSITION_DENIED) → transition permission (else 403FORBIDDEN) → guards (else 403GUARD_FAILED) → requiredFields (else 422REQUIRED_FIELD) → apply sets/hooks/persist. - MUST satisfy a
requiredFieldsentry from either the request body or the existing record (non-null/non-empty). - MUST let
setsoverride client-supplied values for the same field. Supported tokens:now()(ISO-8601 UTC),null, and the authenticated-caller tokens$user.id,$user.username,$user.email,$user.role,$user.roles(comma-joined); anything else is a literal.$user.*tokens are substituted wherever they appear in the value (so"approved by $user.username"works); an unauthenticated caller resolves them to empty. - MUST intercept direct PATCH/PUT writes to the controlled field — they execute the matching transition or are rejected
TRANSITION_DENIED; never bypass. - SHOULD use transition buttons (the transition endpoint) over a status dropdown.
- AVOID
final: trueon states that need a reopen path; hook order is onExit → sets → persist → onEnter, where an onExit failure aborts (422HOOK_FAILED) but onEnter is fire-and-forget.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/{domain}/{resources}/:id/transitions/{name} | Execute a named transition; body supplies requiredFields/extra fields; returns 200 with the full record |
Example
POST /api/v1/tickets/tickets/t-1/transitions/resolve
{ "resolution": "Upgraded to 2.3.1" }
The schema endpoint returns the full state-machine definition; the UI combines it with the record’s state and the user’s roles to render available transitions.
13. Computed Fields
Read-only derived values, declared in
computed:, that the runtime maintains automatically and excludes from input schemas.
Three kinds, auto-detected by the parser: virtual (stored: false/omitted — evaluated every read, no column, not filterable/sortable), materialized (stored: true, non-aggregation formula — real column, recalculated when updateOn same-resource fields change), and aggregation (stored: true, formula begins count(/sum(/avg(/min(/max( — recalculated on related-resource events). All appear in responses like normal fields and silently ignore client-submitted values. Formulas use the computed-field AEL subset: arithmetic, string/math/conditional/date functions, and aggregations — but NOT auth, state, or query()/exists() functions (use a Lua hook if those are needed).
Grammar / Config
computed:
fullName: # virtual
type: string
formula: "concat(firstName, ' ', lastName)"
subtotal: # materialized
type: decimal
formula: "quantity * unitPrice"
stored: true
updateOn: [quantity, unitPrice]
orderTotal: # aggregation
type: money
formula: "sum(orderItems.subtotal)"
stored: true
updateOn: [orderItems.create, orderItems.update, orderItems.delete]
categoryBudgetTotal: # reactive [v0.3]
type: money
formula: "sum(BudgetLine, 'total', category == $this.category)"
stored: true
reactive: true
recomputeOn: [BudgetLine.create, BudgetLine.update, BudgetLine.delete]
Rules
- MUST set
stored: truefor any field used in filtering, sorting, or indexing — virtual fields support none of these. - MUST recalculate stored fields inside the same transaction as the triggering write, so create/update responses carry the correct value.
- MUST recalculate both old and new parent when an aggregation child’s FK changes on update.
- SHOULD prefer materialized/aggregation over virtual for aggregations or expensive cross-resource lookups (virtual runs the subquery on every row of every list).
- AVOID circular dependencies (parse-time 422
CIRCULAR_DEPENDENCY) and chains deeper than 10; dependency order is resolved by topological sort. Usereactivesparingly.
Aggregation functions: count(rel), sum(rel.field), avg(rel.field), min(rel.field), max(rel.field). updateOn uses {relationship}.{event} (create/update/delete). Migration: adding a stored field runs ALTER TABLE ADD COLUMN + backfill; formula changes need no DDL.
Example
computed:
isOverdue:
type: boolean
formula: "status != 'closed' && dueDate != null && dueDate < now()"
total:
type: money
formula: "subtotal + taxAmount + shippingCost"
stored: true
updateOn: [subtotal, taxAmount, shippingCost]
14. Validation
Two-layer data integrity — automatic field constraints plus AEL cross-field rules — enforced at the API boundary on every write before data reaches the database.
Field-level constraints (declared on the field: required, unique, immutable, min/max, minLength/maxLength, pattern, enum, transforms) are enforced automatically in a fixed order (type → required → null → transform → format → enum → length → range → pattern → immutable → unique). Cross-field rules go in the validation: block as named entries with an AEL rule (must be true), a message, and an optional when (always default / create / update). Rules evaluate against the merged record state, so a PATCH that touches one field still sees the others.
Grammar / Config
validation:
dateOrder:
rule: "endDate == null || endDate > startDate"
message: "End date must be after start date"
noDowngrade:
rule: "$old == null || priority >= $old.priority"
message: "Priority cannot be downgraded"
when: update
uniqueSlug:
rule: "!exists('Page', { slug: $this.slug })"
message: "Slug already used by a Page"
Rules
- MUST write null-safe rules:
field == null || condition(a bareendDate > startDatefails when null); userequired: trueon the field when null itself is invalid. - MUST return all collected errors at once as
VALIDATION_ERRORat HTTP 422; field-level constraints run before cross-field rules, and a type/required failure short-circuits the rules. - MUST restrict
query(resource, filter)/exists(resource, filter)to the validation context (compile-time error elsewhere): read-only, ≤100 rows, no nesting, 50ms timeout. - SHOULD guard
$old-based rules with$old == null ||and scope themwhen: update; use$op != 'create' ||to relax rules for edits of legacy data. - AVOID relying on
unique(409DUPLICATE) orimmutable(422IMMUTABLE_FIELD) for cross-field logic — those are separate field-level errors, notrule_failed.
Context vars: field names directly, $this (merged state), $old (update only, else null), $op. On state-machine transitions, sets values apply before validation so rules see post-set state.
Error envelope
{ "status": "error", "code": 422, "error_code": "VALIDATION_ERROR", "error_message": "Validation failed",
"errors": [ { "field": "dateOrder", "message": "End date must be after start date", "rule": "rule_failed" } ] }
Per-error rule is one of required, type, min, max, minLength, maxLength, pattern, enum, immutable, rule_failed.
Example
validation:
excerptOnPublish:
rule: "status != 'published' || (excerpt != null && len(excerpt) >= 50)"
message: "Published articles require an excerpt of at least 50 characters"
managerInSameDept:
rule: "managerId == null || query('Employee', { id: managerId })[0].departmentId == departmentId"
message: "Manager must be in the same department"
15. Resource Features
Toggleable per-resource behaviors, declared in
features:, that activate endpoints, storage, and runtime capabilities without custom code.
Every flag defaults off (or a sensible non-boolean default); a resource without a features: block is plain CRUD. Features compose additively and fire after their corresponding hooks.
Feature flags
| Flag | Default | Adds |
|---|---|---|
softDelete | false | Logical delete (deletedAt), ?scope= filtering, restore endpoint |
audit | false | JSON field-diff audit table + audit endpoints |
versioning | false | Full pre-update snapshots + versions/revert endpoints |
search | false | ?search= full-text over text fields |
export | false | CSV export endpoint |
import | false | JSON-array import endpoint |
caching | false | Per-instance in-memory read cache |
cacheTtlSeconds | 300 | Cache lifetime (only with caching) |
transactions | true | Wrap the write pipeline in a DB transaction |
validationMode | "strict" | strict rejects unknown fields (422); loose strips them |
Grammar / Config
features:
softDelete: true
audit: true
versioning: true
search: true
export: true
import: true
caching: true
cacheTtlSeconds: 300
transactions: true
validationMode: strict
audit:
track: [title, status, priority]
exclude: [updatedAt]
retention: { days: 90 }
Rules
- MUST merge the
SoftDeleteobject (or declaredeletedAt: datetime) whensoftDelete: true; DELETE then setsdeletedAt(no hard-delete endpoint exists). - MUST scope list/get to
deletedAt IS NULLby default;?scope=active|deleted|alloverrides. - MUST run each
importrecord through the full create pipeline (validation, unique, hooks);allowUserProvided: trueonidenables upsert. - SHOULD exclude
updatedAtfromaudit; useauditfor mechanical field diffs and the history system (§21) for business-event narratives. - AVOID
cachingon high-write or stale-intolerant resources (per-instance, no cross-instance invalidation);transactions: falseonly for edge-case high-throughput logging.
Endpoints (per enabled feature)
| Method | Path | Purpose |
|---|---|---|
| POST | /:id/restore | softDelete: clear deletedAt |
| GET | /:id/audit, /audit | audit: record / resource trail (paginated, date/user filters) |
| GET | /:id/versions | versioning: list versions (newest first) |
| POST | /:id/revert | versioning: revert to { "version": N } (appends a new version) |
| GET | (list) ?search=term | search: full-text over text fields |
| GET | /export | export: CSV (respects filters/sort/scope; computed included, relations not) |
| POST | /import | import: JSON array → { imported, failed, errors } |
Example
resources:
Post:
features:
softDelete: true
audit: true
versioning: true
search: true
audit:
exclude: [updatedAt]
Interactions: soft-delete emits audit entries (_action: delete/restore) but no version entry; on update both audit (diff) and versioning (full snapshot) fire. Update pipeline order: beforeUpdate hooks → validation → persist → computed recalc → version → audit → afterUpdate hooks → cache invalidation → bus event.
16. Query Parameters
Standard, zero-config query parameters on every list endpoint for pagination, sorting, filtering, search, relationship loading, scopes, field selection, distinct, and count.
Every GET /api/v1/{domain}/{resources} automatically supports the full parameter set, generated from field and relationship definitions. Pagination, sorting, filtering, include, field selection, distinct, count, and bulk are always on; search requires features.search: true; custom scopes require declaration (soft-delete scopes are auto-generated). All stored fields are filterable and sortable; virtual computed and JSON/component/repeater fields are not sortable. List responses wrap rows in data.items with count (and totalCount for page-based pagination).
Grammar / Config
?page=2&limit=25 # page-based → returns totalCount
?offset=50&limit=25 # offset-based → no totalCount
?sort=-orderTotal,createdAt # '-' prefix = descending; comma = tie-break
?filter[status]=published # equals (default)
?filter[price][gte]=100&filter[status][in]=draft,review
?search=kubernetes # requires features.search: true
?include=author,tags # eager-load declared relationships
?scope=drafts # named query filter
?fields=id,title,status # field selection (id always included)
Rules
- MUST keep page size ≤ 1000 (default 100); default sort is
createdAtASC for stable pagination. - SHOULD index frequently-filtered fields (§19); use
?include=on detail views, not large lists (N+1). - AVOID filtering/sorting on fields the user can’t read or that don’t exist — both are silently ignored, not errored.
Filter operators
| Operator | Syntax | SQL |
|---|---|---|
| eq (default) | filter[status]=x / filter[status][eq]=x | = x |
| ne | filter[status][ne]=draft | != draft |
| gt / gte | filter[price][gt]=100 | > 100 |
| lt / lte | filter[age][lte]=65 | <= 65 |
| like | filter[title][like]=%deploy% | LIKE '%deploy%' |
| in | filter[status][in]=draft,review | IN (...) |
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/{resources} | List (all params above) |
| GET | /api/v1/{domain}/{resources}/distinct?field=status | Unique values for a stored field (respects scope/filters/perms) |
| GET | /api/v1/{domain}/{resources}/count?filter[...] | Count matching records (respects filters/search/scope) |
| POST | /api/v1/{domain}/{resources}/bulk | Bulk create ({"items":[…]}; optional upsert) |
| PATCH | /api/v1/{domain}/{resources}/bulk | Bulk update ({"items":[…]}, each item needs id) |
| DELETE | /api/v1/{domain}/{resources}/bulk | Bulk delete ({"ids":[…]}) |
Bulk operations run in a single transaction — any item failing rolls the whole batch back and returns that item’s error with _bulk_index. The per-request item cap defaults to 1000 and is configurable via adl.bulk.max_items (raise it to load large reference/geo datasets — #54). Sub-operations dispatch internally, so per-row auth/RBAC/audit middleware is not re-run (the bulk request’s own permission check still applies). A successful response carries {"items":[…], "count": N} (delete returns {"deleted": N}).
Upsert (idempotent reload). POST …/bulk accepts "upsert": true to perform INSERT … ON CONFLICT (<keys>) DO UPDATE (SQLite and PostgreSQL). The conflict target defaults to the primary key; override with "conflict": ["col", …] (the columns must carry a unique/PK constraint). Because upsert can update existing rows, it also requires the update permission. Example:
POST /api/v1/atlas/points/bulk
{ "upsert": true,
"items": [ { "id": "…", "name": "A", "geom": {…} }, … ] }
Bulk uses transactional row-by-row inserts (preserving validation, defaults, PK generation, and string transforms), not Postgres
COPY. That’s the right path for ADL-governed data; for raw COPY-scale ingestion of static reference geography, bulk-load PostGIS directly. Per-row hooks/history/notifications still fire for resources that declare them.
Example
curl "https://api.example.com/api/v1/blog/posts?scope=live\
&filter[authorId]=user-1&filter[publishedAt][gte]=2026-01-01\
&search=kubernetes&sort=-publishedAt&include=author,tags\
&fields=id,title,status&page=2&limit=25"
{ "status": "ok", "code": 200,
"data": { "items": [ ... ], "count": 25, "totalCount": 142 } }
17. API Configuration
The
api:block controls a resource’s URL path, which CRUD operations are exposed, and any custom or nested routes.
A resource with no api: block gets a default kebab-case pluralized path and all five CRUD operations. Pluralization handles regular nouns plus Greek/Latin -is (MissionAnalysis ⇒ mission-analyses, not mission-analysises). To override the collection segment explicitly, set api: { collection: <segment> } (alias: api: { path: ... }). Either way, the effective collection path is published per resource in /schema as collectionPath (and collectionSegment), so clients don’t have to reverse-engineer it from /routes. Operations can be disabled (returns 404 — no route, stronger than a [] permission which returns 403). Custom operations add non-CRUD endpoints; nested paths expose a child under its parent. The domain-level api: block sets the shared URL prefix and aliases. Resource routes live under /api/v1/{domain}/{resource-path}.
Grammar / Config
domain:
name: blog
version: "1.0.0" # author's own semver (platform version is 0.3.1)
api:
prefix: /api/v1/blog # default /api/v1/{name}
aliases: [/api/blog, /blog]
resources:
Post:
api:
collection: articles # explicit segment; default PascalCase→kebab→plural (posts)
# path: /articles # `path` is an accepted alias for `collection`
disable_delete: true # or: disable: [list, create, delete]
operation_overrides:
update: { method: PUT, path: /:id/edit }
customOperations:
- name: publish
method: POST # default POST
path: /:id/publish # default /:id/{name}
permissions: [editor, admin] # default [admin]
fields: { status: published, publishedAt: "NOW()" }
Comment:
api:
under: Post
path: /posts/:postId/comments
directAccess: [get] # also expose /comments/:id
Rules
- MUST give a
underresource abelongsToto the parent; the FK is auto-filled from the path param on create. - MUST keep domain prefix as
/api/v1/{domain}for resources and/api/v1/domains/...for domain management; never use reserved names (auth, users, admin, domains, status, plugins, routes, version, openapi, echo). - SHOULD use
disable_*when an operation is conceptually invalid (e.g. singleton); restrict permissions when it merely needs limiting. - AVOID operation overrides except for legacy/compat — defaults are preferred.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/{domain}/{res}/:id/{name} | Custom operation → bus action adl.{domain}.{resource}.{name} |
| GET/PATCH | /api/v1/{domain}/{res}/:id | Singleton access (list/create/delete disabled) |
| * | /api/v1/{domain}/posts/:postId/comments... | Nested CRUD when under set |
Disable keys: disable_list, disable_get, disable_create, disable_update (PATCH+PUT), disable_delete (all default false).
Example
# Singleton config:
# SiteSettings.api: { disable_list: true, disable_create: true, disable_delete: true }
curl -X POST https://api.example.com/api/v1/orders/orders/abc-123/publish \
-H "Authorization: Bearer $JWT"
# → sets status=published, publishedAt=NOW(); fires adl.orders.order.publish
18. Content & Media Types
Rich content field types —
media,svg,blocks,icon,font, pluscomponent/repeateroperational behavior — for files, structured editing, and design tokens.
Media fields store a media-library UUID (or array of UUIDs) and expand to full metadata in responses; uploads go through a multipart endpoint that enforces accept/maxSize/dimension constraints. The library tracks references in {domain}_media_references and blocks deletion of in-use items (409 MEDIA_IN_USE). SVG content is sanitized on every write (scripts/event-handlers/external refs stripped). blocks stores a JSON array of typed blocks from a controlled palette; domains extend the palette via the top-level blocks: definitions. icon and font reference configured sets/sources by set:name / family name.
Grammar / Config
fields:
featuredImage:
type: media
accept: [image/jpeg, image/png, image/webp] # MIME or .ext or image/*
maxSize: 5mb
minWidth: 800; aspectRatio: "16:9"
logo: { type: svg, maxSize: 50kb }
content:
type: blocks
maxBlocks: 200
allowedBlocks: [paragraph, heading, image, code, callout, $ref: '#/blocks/PromoCard']
categoryIcon: { type: icon, iconSets: [lucide, custom] }
headingFont: { type: font, sources: [google, system] }
blocks:
PromoCard:
label: "Promo Card"
hasContent: false
props:
heading: { type: string, required: true }
ctaUrl: { type: url, required: true }
icons:
lucide: { source: cdn, url: "https://.../icons/", prefix: lucide, format: svg }
fonts:
google: { source: google-fonts, preload: [Inter, Roboto] }
Rules
- MUST send a media field as a bare UUID (or array of UUIDs) on write; reference rows are inserted/swapped automatically.
- MUST unreference a media item before deleting it (else 409); SVG must be valid XML rooted at
<svg>withinmaxSize. - SHOULD constrain blocks with
allowedBlocks/maxBlocks; reject out-of-palette or over-limit with 422. - AVOID filtering nested component fields via
filter[field]— not supported (needs store-specific JSON-path queries).
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/{domain}/media/upload | Multipart upload; validates constraints → 201 with id/url/dims |
| GET | /api/v1/{domain}/media/orphans | List unreferenced media items |
Built-in blocks: paragraph, heading(level), image(mediaId/alt/caption), code(language/lineNumbers), quote, list, table, embed, divider, callout(style). SVG: removes <script>/<iframe>/<object>/<embed>/<foreignObject>, on* handlers, javascript: and external xlink:href. Component PATCH = partial merge (null clears); repeater update = full array replace (_order on sortable: true; per-element errors indexed as lineItems[1].quantity).
Example
curl -X POST https://api.example.com/api/v1/blog/media/upload \
-F "file=@hero.jpg"
# → { "data": { "id":"550e8400-...", "mimeType":"image/jpeg",
# "width":1920, "height":1080, "url":"/media/550e8400-.../hero.jpg" } }
# then on the record: { "featuredImage": "550e8400-e29b-41d4-a716-446655440000" }
19. Indexes
Database indexes that accelerate filtering, sorting, and uniqueness — some auto-generated, others declared in a resource’s
indexes:block.
Indexes never change API behavior, only performance, so the author declares them to match expected query patterns. ADL auto-creates indexes for primary keys, unique: true fields, composite primary keys, and unique slugs — but NOT for foreign keys, which the author should index explicitly. Composite indexes are most effective when the leftmost (most selective) fields are filtered. Unique indexes enforce single-field or combination uniqueness; violations return 409 DUPLICATE. Index changes are always non-destructive migrations (no allow_destructive needed).
Grammar / Config
resources:
Post:
indexes:
- fields: [authorId]
description: "FK lookup — posts by author"
- fields: [status, publishedAt] # composite: most selective first
description: "Published posts sorted by date"
- fields: [email]
unique: true
- name: idx_post_search # optional custom name
fields: [title]
Keys: fields (required, ordered), unique (default false), name (default idx_{table}_{f1}_{f2}), description.
Rules
- MUST resolve duplicate values before adding a unique index to existing data (else
MIGRATION_ERROR@ 422). - SHOULD index every
belongsToFK, status/state fields, common composite query patterns, aggregation-trigger FKs, anddeletedAtfor soft-delete resources. - SHOULD put the most selective field first in composite indexes.
- AVOID over-indexing write-heavy resources — each index adds write cost; the linter warns on unused indexes and FK fields without indexes.
Example
indexes:
- fields: [orderId]
description: "FK + aggregation trigger — orderTotal recalculation"
- fields: [customerId, createdAt]
description: "This customer's orders, newest first"
- fields: [externalSystem, externalId]
unique: true
CREATE INDEX idx_blog_posts_status_publishedAt ON blog_posts (status, publishedAt);
CREATE UNIQUE INDEX idx_blog_posts_email ON blog_posts (email);
20. Scopes
Named, reusable query filters applied via
?scope=that narrow list results to a defined subset of records.
Scopes are declared in a resource’s scopes: block, each with a raw SQL WHERE-fragment filter (empty string = no filter). At most one scope may be default: true, applied when no ?scope= is given. Resources with softDelete: true auto-generate active (default), deleted, and all; redeclaring those names overrides them. Scope filters combine with request filters, search, count, and export via AND. Scopes do NOT bypass RBAC — the user still needs list permission, and all scopes are visible to anyone with list access.
Grammar / Config
resources:
Article:
scopes:
live:
filter: "status = 'published' AND publishedAt <= datetime('now')"
default: true
description: "Published, past/current publish date"
drafts: { filter: "status = 'draft'" }
mine: { filter: "createdById = :currentUserId" } # runtime placeholder
all: { filter: "" } # explicit show-everything
Keys: filter (required SQL fragment), default (default false), description. Placeholders :currentUserId / :currentUserDepartmentId resolve from the authenticated identity.
Rules
- MUST mark at most one scope
default: true; absent that, soft-delete resources default toactive, others apply no filter. - SHOULD write filters for the primary store using ANSI SQL (
=,!=,IN,IS NULL) for cross-store portability. - AVOID using scopes to hide data from users — they offer no per-scope permissions; use field-level permissions or the
ownerrole instead. - Invalid/unknown
?scope=is silently ignored (default applied), not errored.
Example
curl "https://api.example.com/api/v1/blog/posts?scope=live\
&filter[authorId]=user-1&sort=-publishedAt" -H "Authorization: Bearer $JWT"
Store note — current time: SQLite datetime('now') vs PostgreSQL NOW(); date math datetime('now','-7 days') vs NOW() - INTERVAL '7 days'. The schema endpoint exposes each scope’s filter/default/description for UI scope tabs.
Part III — Runtime Subsystems
21. History of Change
A declarative, human-readable activity timeline that records business-significant events on a resource as narrative entries.
History coexists with the audit trail (§15): audit records mechanical field diffs for developers, history records semantic events (“Resolved by Sarah”) for end users and compliance. Presence of a history: block activates it — there is no features.history flag. Each declared event names a trigger, the fields to capture, and a label with {field} interpolation resolved and stored as plain text at write time. After each event is written the runtime emits a bus event (adl.{domain}.{resource}.history.{event}), which is the foundation Notifications, Workflows, and Schedules subscribe to.
Grammar / Config
resources:
Ticket:
history:
events:
<eventName>:
on: create|update|delete|restore|transition|fieldChange
transition: <name> # if on: transition
field: <name> # if on: fieldChange
fields: [a, b] # if on: update (fire only when these change)
capture: [field, ...] # snapshot into data column
label: "Text with {field}"
detail: "{resolution}"
icon: lucide:check-circle
severity: info|success|warning|error
roles: [agent, admin] # read visibility; event still recorded
condition: "AEL expr" # fire only when true
display: { endpoint: true, defaultLimit: 50, maxLimit: 200, order: desc, includeActor: true, groupBy: date }
retention: { days: 365 } # omit = permanent
Rules
- MUST declare a
history:block to activate; each event requiresonandlabel. - SHOULD use
on: fieldChange(single named occurrence) vson: updatewithfields(broad modification); reserve bareon: updateto avoid noise. - Auto-captured:
from/to(transition),previousValue/newValue(fieldChange),actorId/actorName(all) — need not be incapture. - AVOID assuming
rolesprevents recording — it only filters reads onGET /:id/history.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/{resource}/:id/history | Paginated timeline for one record |
| GET | /api/v1/{domain}/{resource}/history | Cross-record event stream |
Query params: page, limit, event, severity, after, before, actorId, recordId.
Example
history:
events:
resolved:
on: transition
transition: resolve
capture: [resolution]
label: "Resolved"
detail: "{resolution}"
icon: lucide:check-circle
severity: success
slaBreached:
on: fieldChange
field: status
condition: "status == 'in_progress' && dueAt != null && dueAt < now()"
capture: [dueAt, assigneeId]
label: "SLA breached — past due date"
severity: error
22. Rules Engine
Domain-level business policies that evaluate cross-resource constraints before a write commits and can reject, warn, or log.
Rules complement validation (single-record field shape) and state-machine guards (transition eligibility) by enforcing policies that span multiple records and aggregates (effort caps, enrollment limits, budget ceilings). They evaluate at pipeline position 6 — after validation and guards pass, before the write — so reject rules block atomically with HTTP 422 (error_code: RULE_VIOLATION). Conditions use the full AEL engine with cross-resource functions (sum, count, avg, min, max, exists, findOne, findMany) over $record/$previous. Every fired rule is recorded in {domain}_rules_log as the compliance evidence trail.
Grammar / Config
domain:
rules:
<ruleName>:
description: "..."
trigger:
resource: EffortCommitment
on: [create, update]
fields: [committedPercent] # update: only when these change
condition: "$record.status == 'pending'" # fast pre-filter
condition: >
sum(EffortCommitment, 'committedPercent',
personnelId == $record.personnelId && id != $record.id) + $record.committedPercent > 100
action: reject|warn|log
severity: error|warning|info
message: "Effort for {personnelName} would be {totalEffort}%"
messageData: { totalEffort: "<AEL expr>" }
regulation: "2 CFR §200.430"
priority: 100 # lower evaluates first
dependsOn: <rule> # skip if prerequisite fired
enabled: true
Rules
- MUST pair
actionwithseverity:reject/error,warn/warning,log/info.rejectblocks atomically;warnwrites and returnswarnings[];logwrites silently to the rules log. - Evaluation order: reject rules first (one firing blocks all further), then warn (collected), then log.
- SHOULD use
trigger.conditionandtrigger.fieldspre-filters, and index fields used in conditions; identical aggregation queries are cached within one evaluation cycle. - AVOID putting computed display values in
condition— keep it boolean and move computations tomessageData.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/rules | List rules with fire counts |
| GET | /api/v1/{domain}/rules/{name} | One rule + stats |
| GET | /api/v1/{domain}/rules/{name}/log ?fired=true&since= | Evaluation log |
| GET | /api/v1/{domain}/rules/log ?action=reject | All-rules log |
| GET | /api/v1/{domain}/rules/stats | Aggregate statistics |
| POST | /api/v1/{domain}/rules/{name}/test | Dry-run against a record (admin) |
| POST | /api/v1/{domain}/rules/{name}/enable | /disable | Toggle at runtime |
| POST | /api/v1/{domain}/rules/groups/{group}/enable | /disable | Bulk toggle a _groups set |
Example
rules:
enrollmentCap:
description: "Study enrollment cannot exceed target sample size"
trigger: { resource: Participant, on: [create], condition: "$record.status == 'enrolled'" }
condition: >
count(Participant, studyId == $record.studyId && status == 'enrolled')
>= findOne(Study, id == $record.studyId).targetSampleSize
action: reject
severity: error
message: "Study {studyId} has reached its enrollment target"
regulation: "21 CFR §312"
23. Notifications
Event-driven messages that connect history events to recipients across email, Slack, SMS, push, and webhook channels.
Notifications require History (§21) — every instant notification reacts to a history event on the bus. The engine resolves recipients (from roles, record fields, static addresses, with deduplication and exclusion), interpolates templates with captured event data, checks the throttle window, dispatches to channel plugins (INotificationChannel), and logs delivery to {domain}_notifications_log. Escalation chains fire successive tiers if the triggering condition stays unresolved; digest notifications batch matching events on a schedule.
Grammar / Config
# Domain-level channel credentials (secrets via ${ENV}):
domain:
channels:
email: { provider: smtp, host: ${SMTP_HOST}, port: ${SMTP_PORT:587}, from: "noreply@co.com" }
slack: { provider: slack, webhookUrl: ${SLACK_WEBHOOK_URL}, channelOverrides: { critical: "#critical" } }
resources:
Ticket:
notifications:
<name>:
on: history # or: schedule
event: resolved # history event name
type: instant # or: digest
channels: [email, slack]
condition: "$record.orderTotal > 10000" # optional
throttle: 24h # none | 1h | 24h | <duration>; per name+record
priority: low|normal|high|urgent|critical
recipients:
roles: [admin]
field: reporterId
additional: [piId]
static: ["compliance@co.com"]
exclude: { field: originatorId }
template:
subject: "Ticket resolved — {title}"
body: "Resolved by {assigneeName}. {$recordUrl}"
escalation:
- after: 24h
recipients: { roles: [bsaOfficer] }
priority: urgent
template: { subject: "ESCALATION — {caseNumber}", body: "..." }
Rules
- MUST declare
channels,recipients, andtemplate;eventis required whenon: history. criticalpriority overrides ALL user preferences and cannot be muted; use only for safety/regulatory alerts.- A channel is silently skipped for a recipient lacking the required contact field (email/phone/slackId).
- SHOULD set
throttlefor recurring reminders (24h) andnonefor critical alerts; escalation tiers inherit unspecified fields from the parent and are cancelled once the triggering condition no longer matches. - Template vars:
{capturedField},{$actor.name},{$record.field},{$now},{$recordUrl}; digests support{#each}...{/each}and{errorCount}/{warningCount}.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/notifications/log ?channel=&status=&since= | Delivery audit log |
Example
Notifications may be declared under a resource (target implied) or at the
domain level (each entry names its target with resource:). Both are parsed
and wired.
# Domain-level form — `resource:` names the target whose history events fire it.
notifications:
highValueOrder:
resource: Order
on: history
event: created
condition: "$record.orderTotal > 10000"
channels: [email, slack]
recipients: { roles: [financeManager] }
priority: high
template:
subject: "High-value order — ${orderTotal}"
body: "Order {$record.id} totaling ${orderTotal} requires review. {$recordUrl}"
24. Scheduled Tasks
Time-driven declarations that create, update, transition, notify on, archive, or delete records on a recurrence or relative to a record date.
Scheduled tasks are a domain-level schedules: block (no feature flag) that replaces cron jobs and manual checklists. Recurring schedules repeat on a calendar/interval; relative (and tiered) schedules fire at offsets from a record date field. The scheduler runs on a heartbeat loop (default 60s), querying source records, applying skipIf, executing the action as the system actor, and logging results. Tiered and relative schedules deduplicate via (schedule_name, record_id, tag) tuples so each tier fires once per record. Scheduled writes are subject to the rules engine (§22) and produce history events.
Grammar / Config
domain:
schedules:
<name>:
description: "..."
recurrence: every 90 days # daily at 06:00 | weekly on Monday at 08:00 | monthly on 1st at 02:00 | ...
type: recurring # or: relative (+ relativeTo, offset)
source:
resource: Asset # or "*"
filter: "status == 'operational'"
action: create|update|transition|notify|archive|delete|report|workflow
target: Inspection # if action: create
transition: revert # if action: transition
input: { assetId: $record.id, scheduledDate: $nextOccurrence }
skipIf: "exists(Inspection, assetId == $record.id && status != 'approved')"
batched: false # one notification for all source records
notification: { channels: [email], recipients: { roles: [inspector] }, template: {...} }
enabled: true
# Tiered relative schedule:
certExpiry:
type: relative
source: { resource: Certification, filter: "status == 'active'" }
relativeTo: expirationDate
tiers:
- { daysUntil: 90, action: notify, tag: 90day, channels: [email], recipients: {...}, template: {...} }
- { daysUntil: 0, action: transition, transition: expire, tag: expire }
Rules
- MUST provide
target+inputforcreate,inputforupdate,transitionfortransition,destination+formatforarchive. - SHOULD use
skipIfto prevent duplicate actions and atagon each tier for dedup. - Actions run as actor
system(actorName = schedule name); a single record failure is logged and does not halt the run (status: partial_failure). - Input vars:
$record.field,$nextOccurrence,$currentQuarter,$currentYear,$now.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/schedules | List with next run + last result |
| GET | /api/v1/{domain}/schedules/{name} | One schedule + stats |
| GET | /api/v1/{domain}/schedules/{name}/log ?since=&status= | Execution history |
| POST | /api/v1/{domain}/schedules/{name}/run | Run now (admin) |
| POST | /api/v1/{domain}/schedules/{name}/enable | /disable | Toggle at runtime |
Example
schedules:
expireTemporaryMocs:
description: "Expire temporary MOCs past their expiry date"
recurrence: daily at 06:00
source:
resource: ManagementOfChange
filter: "status == 'temporary_approved' && temporaryExpiry < now()"
action: transition
transition: revert
25. Workflows
Named, multi-step, cross-resource processes that orchestrate actions and waits in response to events, with persisted instance state.
Workflows are a domain-level workflows: block; where a state machine governs one resource, a workflow choreographs transitions and actions across many. A trigger (a resource event) creates an instance that executes steps: action (create/update/transition/notify/noop), wait (pause until a matching bus event, with timeout), decide (ordered conditional routing), parallel (all/any branches), and forEach. Steps reference accumulated data via $trigger, $event, $steps.{name}, $workflow, and $item, all evaluated by AEL. Instances are persisted after each step and reloaded on restart; waiting instances resume when a matching event arrives. Workflow actions are subject to the rules engine (§22), and failures invoke onError.
Grammar / Config
domain:
workflows:
<name>:
description: "..."
trigger:
resource: LoanApplication
event: create|transition|fieldChange|history
transition: submitForReview # if event: transition
condition: "$trigger.record.amount > 50000"
steps:
- name: orderCreditReport
action: create
resource: CreditReport
input: { applicationId: $trigger.record.id }
then: evaluateCredit
- name: evaluateCredit
wait:
resource: CreditReport
event: transition
transition: complete
filter: { applicationId: $trigger.record.id }
timeout: { duration: 5d, then: escalate }
condition: "$event.record.creditScore >= 680"
then: orderAppraisal
else: routeToManualReview
- name: routeByScore
decide:
- { condition: "$steps.evaluateCredit.event.record.creditScore >= 720", then: autoApprove }
- { default: true, then: decline }
- name: complianceChecks
parallel: { all: true, steps: [ {name: irb, wait: {...}}, {name: coi, wait: {...}} ] }
then: submitProposal
timeout: { duration: 30d, action: transition, target: $trigger.record.id, transition: expire }
onError: { action: transition, target: $trigger.record.id, transition: errorState, notification: {...} }
Rules
- MUST declare a
trigger(resource + event); each step needs anameand a routingthen(ordecide/parallel/timeout path) terminating atend. - MUST include a
defaultindecide(or no match fails the instance) and await.timeoutfor any unbounded wait. - Wait steps are persisted and survive restart;
parallel.alljoins all branches,parallel.anyadvances on first and cancels the rest. - AVOID relying on
onErrorfor cancellation —POST .../canceldoes NOT fireonError. - Instance status:
running,waiting,completed,failed,cancelled,timed_out. Each step emits a history event on the instance and on modified resources.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/workflows | List definitions |
| GET | /api/v1/{domain}/workflows/{name}/instances ?status=&since= | List instances |
| GET | /api/v1/{domain}/workflows/{name}/instances/{id} | Instance detail + timeline |
| POST | /api/v1/{domain}/workflows/{name}/instances/{id}/cancel | Cancel (no onError) |
Example
workflows:
emergencyInspection:
description: "Create and await an emergency inspection on asset failure"
trigger: { resource: Asset, event: transition, transition: reportFailure }
steps:
- name: createInspection
action: create
resource: Inspection
input: { assetId: $trigger.record.id, inspectionType: emergency }
then: awaitInspection
- name: awaitInspection
wait:
resource: Inspection
event: transition
transition: complete
filter: { assetId: $trigger.record.id }
timeout: { duration: 48h, then: escalate }
then: end
onError: { action: transition, target: $trigger.record.id, transition: flagError }
26. Reports, Views & Dashboards
Declarative analytics: saved queries (views), formatted documents (reports), and KPI widget collections (dashboards).
Three constructs build on each other. A View is a named, parameterized query with computed columns, sorting, and access control, returning a tabular dataset. A Report references views and adds multi-section formatting, parameters, totals, and PDF/CSV/JSON output. A Dashboard aggregates real-time metric widgets with threshold coloring. All are declared in YAML, evaluated server-side, and served pre-computed so the frontend renders without business logic.
Grammar / Config
views:
activeGrants:
source: Award # required: resource to query
filter: "status == 'active'" # AEL filter
permissions: [spoOfficer, admin] # default [admin]
columns:
- field: awardNumber # existing field
label: "Award #"
- name: utilizationRate # computed column
label: "Utilization %"
computed: "expendedToDate / totalAmount * 100"
format: percentage # see format system below
highlight: { red: "< 30", yellow: "< 90", green: ">= 90" }
sort: daysRemaining asc
defaultLimit: 50
reports:
budgetStatusReport:
permissions: [grantAccountant, admin]
header: { title: "Budget Status", subtitle: "FY {$params.fiscalYear}" }
parameters:
- { name: fiscalYear, type: integer, default: "$currentYear" }
- { name: venue, type: string, optional: true, enum: "distinct(Trade,'venue')" }
sections:
- { name: summary, type: metrics, metrics: [{ label: "Total Budget", value: "sum(Award,'totalAmount',status=='active')", format: money }] }
- { name: byCategory, type: table, view: activeGrants, groupBy: sponsor, totals: [totalAmount] }
footer: { text: "Generated {$now} by {$actor.name}", confidentiality: "INTERNAL" }
dashboards:
operationsOverview:
permissions: [operationsManager, admin]
refresh: 5m # none | 1m | 5m | 1h
layout: { columns: 3 }
widgets:
- name: assetAvailability
type: gauge # counter|gauge|scorecard|breakdown|timeSeries|table|timeline
query: { value: "count(Asset,status=='operational') / count(Asset,status!='decommissioned') * 100" }
format: percentage
thresholds: { green: ">= 95", yellow: ">= 85", red: "< 85" }
target: 98
Rules
- MUST give every column a
label; computed columns usename+computed, field columns usefield. - SHOULD define views once and reuse them in report
tablesections and dashboardtablewidgets. - SHOULD set
refreshon dashboards; the runtime caches the result and reportscomputedAt. - AVOID client-side business logic — the API response carries values, formats, thresholds, and colors.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/views/{name} | Run view; ?format=json|csv&sort=-col&page=1&limit=25 |
| GET | /api/v1/{domain}/reports/{name} | Run report; ?format=json|csv|pdf&<param>=<value> |
| GET | /api/v1/{domain}/dashboards/{name} | All widget values, pre-computed |
| GET | /api/v1/{domain}/dashboards/{name}/widgets/{widget} | Single widget refresh |
Formats: money, percentage, integer, decimal2, decimal4, date, datetime, duration, boolean. highlight/thresholds are AEL conditions on the value; results appear in _highlights (views) or threshold (widgets). Scheduled tasks (§24) deliver reports via action: report; timeline/timeSeries widgets read history (§21).
27. Templates & Documents
Declaratively generate business documents (award notices, certificates, invoices) — trigger, data, formatting, storage, and delivery in one YAML block.
A template binds a trigger (history event, manual API call, or schedule) to a data context, a Markdown body with interpolation, an output format, and optional auto-attachment and notification. The runtime assembles the data, interpolates the body, renders the file, stores it in the documents table, links it to a record, and emits a history event. Format pipes reuse the report format system (§26).
Grammar / Config
domain:
templates:
awardNotice:
trigger: # or trigger: manual
resource: Award
event: transition
transition: activate
condition: "$record.amount > 0" # optional AEL gate
format: pdf # pdf | docx | html | markdown
filename: "Award-{awardNumber}-{$date}.pdf"
permissions:
generate: [spoOfficer, admin] # required
view: [authenticated] # required
parameters: # required when trigger: manual
- { name: assetId, type: uuid, required: true }
data:
award: $record # triggering record
pi: { source: Researcher, filter: "id == $record.piId", single: true }
items:{ source: BudgetCategory, filter: "awardId == $record.id", sort: category asc }
inst: { static: { name: "${INSTITUTION_NAME}" } }
spent:{ computed: "sum(Expenditure,'amount',awardId==$record.id)" }
log: { history: true, resource: $record, limit: 100 }
layout: # PDF/DOCX only
pageSize: letter
margins: { top: 1in, right: 1in, bottom: 1in, left: 1in }
header: { logo: "${LOGO}", text: "Office of Sponsored Programs" }
footer: { center: "Page {$page} of {$pages}", right: "CONFIDENTIAL" }
watermark: { text: "DRAFT", opacity: 0.1 }
body: |
# Notice of Award
**Award:** {award.awardNumber} **Issued:** {$date}
{#each items}
| {category} | {budgetedAmount | money} |
{/each}
**Total:** {award.totalAmount | money}
attachTo: # or attachTo: none
resource: Award
recordId: $record.id
field: awardNoticeDocId
notification:
channels: [email]
recipients: { field: piId, additional: [departmentChairId] }
template: { subject: "Award {award.awardNumber}", body: "Attached." }
attachDocument: true
Body syntax: {var.field}, format pipes {amount | money}, iteration {#each list}…{/each} (with {$index}, {$item}), conditionals {if(cond, a, b)}, collection pipes {list | count}, {list | sum:field}, {list | avg:field}. Special vars: {$date}, {$datetime}, {$actor.name}, {$actor.email}, {$page}, {$pages}, {$params.name}.
Rules
- MUST declare both
permissions.generateandpermissions.view; manual triggers MUST declareparameters. - MUST use
single: trueon resource queries that feed scalar{var.field}access (else returns an array for{#each}). - SHOULD record generation under the current domain version — documents are version-stamped and unaffected by later template edits.
- AVOID putting layout under non-PDF/DOCX formats;
layoutis ignored forhtml/markdown.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/templates | List template definitions |
| GET | /api/v1/{domain}/templates/{name} | Get one definition |
| POST | /api/v1/{domain}/templates/{name}/generate | Manual generation; returns document |
| GET | /api/v1/{domain}/documents?resource=&recordId=&template= | List generated documents |
| GET | /api/v1/{domain}/documents/{id} | Download (binary) |
| GET | /api/v1/{domain}/documents/{id}/metadata | Metadata only |
Storage backend is filesystem or s3 under domain.documentStorage. The PDF renderer is a Kalos plugin (IDocumentRenderer). Schedules (§24) generate via action: template.
28. Sheet Mode
Turn a resource into a typed, access-controlled spreadsheet: runtime-added columns, per-cell formula overrides, and reactive computed fields.
Setting mode: sheet on a resource layers three schemas: fixed columns (the object: block — typed, immutable structure, full ADL validation), dynamic columns (added at runtime via API, typed but flexible, schema-change history events), and per-cell formula overrides (a single record overrides a computed field’s formula via {field}_formula). Reactive computed fields maintain a dependency graph and auto-recalculate, including cross-record SUMIF-style aggregates. All standard ADL features (permissions, state machines, hooks, validation) still apply.
Grammar / Config
resources:
Budget:
mode: sheet
object:
id: { type: uuid, primaryKey: true }
lineItem: { type: string, required: true }
q1: { type: money, default: 0 }
computed:
total:
type: money
formula: "q1 + q2 + q3 + q4"
stored: true
reactive: true
categoryTotal: # cross-record aggregate
type: money
formula: "sum(BudgetLine,'total',category == $this.category)"
stored: true
reactive: true
recomputeOn: [BudgetLine.create, BudgetLine.update, BudgetLine.delete]
dynamicFields:
enabled: true
allowTypes: [string, money, integer, decimal, date, boolean, text]
maxColumns: 50
permissions:
addColumn: [admin, editor]
renameColumn: [admin, editor]
deleteColumn: [admin]
computedFields:
overridable: true # enables per-cell {field}_formula
reactive: true # same-record dependency graph
crossRecord: true # allow cross-resource formulas
history:
events:
columnAdded: { on: schemaChange, change: addColumn, capture: [columnName, columnType], severity: info }
cellEdited: { on: fieldChange, field: "*", label: "{fieldName} changed", severity: info }
Formulas use AEL (§36): if(...), sum(Res,'field',cond) (=SUMIF), lookup(Res,'field',cond) (=VLOOKUP), concat(...), networkdays(a,b). Escalate to lua:fn(args) for PMT/IRR/NPV/statistical logic.
Rules
- MUST validate override formulas at write time: parseable AEL, references only existing fields, no circular deps, result type-compatible — else HTTP 422.
- MUST permission-gate every column operation; each emits a
schemaChangehistory event. - SHOULD prefer the
ALTER TABLEstorage strategy (native filter/sort) over the JSON-column fallback. - AVOID dependency chains beyond the configured max depth (default 10); circular deps are rejected at parse time. Clear an override by setting
{field}_formula: null.
Endpoints (standard CRUD plus)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/{resource}/columns | List fixed + dynamic + computed columns |
| POST | /api/v1/{domain}/{resource}/columns | Add dynamic column {name,type,default,description} |
| PATCH | /api/v1/{domain}/{resource}/columns/{name} | Rename / update dynamic column |
| DELETE | /api/v1/{domain}/{resource}/columns/{name} | Remove dynamic column |
| PATCH | /api/v1/{domain}/{resource}/{id} | Set/clear override via {"total_formula": "..."} |
| GET | /api/v1/{domain}/{resource}/{id}/history | Cell-level change history |
Part IV — Platform Services & Operations
29. Authentication & Identity
Built-in
authdplugin providing JWT-based registration, login, token rotation, password lifecycle, email verification, and role management.
Stateless auth uses signed JWT bearer tokens carrying sub, username, email, and roles (consumed by the permission system, §11). Short-lived access tokens (15 min) authenticate requests; longer refresh tokens (7 days) rotate single-use to mint new pairs. Self-service lives under /api/v1/auth/... and /api/v1/users/...; admin user/role management lives under /api/v1/admin/.... The only built-in role is admin; *, authenticated, and owner are middleware identifiers, not roles.
Grammar / Config
{
"auth": {
"jwt_secret": "${JWT_SECRET}", // required, >= 32 chars, HMAC-SHA256
"access_token_ttl": 900, // seconds (15 min)
"refresh_token_ttl": 604800, // seconds (7 days)
"issuer": "kalos"
},
"email": {
"enabled": true,
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"from_address": "noreply@example.com",
"use_tls": true
}
}
Seeder (§32) creates roles + users idempotently under the __system__ identity:
roles: [{ name: editor, description: "Content editor" }]
users:
- { username: alice, email: alice@example.com, password: Demo123!, roles: [editor], verified: true }
Rules
- MUST send the access token as
Authorization: Bearer <token>on authenticated requests. - MUST treat refresh tokens as single-use — refresh rotates and invalidates the old token (reuse → 401).
- MUST enforce the password policy (≥8 chars, upper, lower, digit, special) →
WEAK_PASSWORD@ 422. - SHOULD return success on
forgot-passwordregardless of email existence (prevents enumeration). - AVOID
/api/v1/auth/usersfor admin user management — use/api/v1/admin/.... Reactivation isunsuspend, notactivate.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/auth/login | Login (username or email); returns token pair + user |
| POST | /api/v1/auth/register | Create user (active, verified:false) |
| POST | /api/v1/auth/refresh | Rotate token pair |
| GET | /api/v1/auth/me | Current user profile |
| POST | /api/v1/auth/logout / logout-all | Revoke current / all sessions |
| POST | /api/v1/auth/change-password | Change password (invalidates other sessions) |
| POST | /api/v1/auth/forgot-password / reset-password | Single-use reset token flow |
| POST | /api/v1/auth/verify-email / resend-verification / change-email | Email verification flow |
| DELETE | /api/v1/auth/account | Self-service account deletion |
| GET | /api/v1/admin/users / /api/v1/admin/users/:id | List / get users (admin) |
| PUT | /api/v1/admin/users/:id/roles | Set roles |
| POST | /api/v1/admin/users/:id/verify / suspend / unsuspend | Admin user actions |
| GET/POST | /api/v1/admin/roles | List / create roles |
Errors: INVALID_CREDENTIALS/TOKEN_EXPIRED/TOKEN_INVALID @401, FORBIDDEN/USER_SUSPENDED @403, USER_EXISTS/ALREADY_VERIFIED @409, WEAK_PASSWORD @422, RATE_LIMITED @429.
30. Migration System
Automatic, content-aware, atomic schema migration: diff the new domain model against the stored snapshot, generate DDL, and apply it in one transaction.
On domain submission the pipeline runs Parser → SchemaDiffer → MigrationGenerator → MigrationExecutor, then stores the new snapshot and activates the domain. Migrations are atomic: all DDL runs in a single transaction and any failure rolls back fully — there is no half-applied state and no partial status. Detection is content-aware via a SHA-256 checksum of the serialized model, so a resubmit of the same version with a changed schema still migrates.
Grammar / Config
POST /api/v1/domains
Content-Type: application/json
{
"content": "<ADL YAML or JSON string>",
"format": "yaml", // "yaml" | "json" (auto-detected if omitted)
"allow_destructive": false // gate for destructive changes (NOT a header)
}
Field rename hint (one-time directive, dropped from the snapshot after migration):
resources:
Book:
object:
pageCount: { type: integer, renamedFrom: pages } # → RENAME COLUMN, O(1)
Rules
- MUST apply all statements in one transaction; on any statement failure, ROLLBACK, set status
failed, return HTTP 500MIGRATION_ERROR. - MUST refuse type narrowing and required-without-default with HTTP 422
DESTRUCTIVE_MIGRATIONunlessallow_destructive: true— no DDL runs, no snapshot stored, no log entry, domain unchanged. - MUST defer (skip, don’t refuse) removed columns/FKs/join tables by default: the drop is not generated, a warning is logged, the rest commits, and the domain ends active (HTTP 201).
- SHOULD use
renamedFromto express renames; otherwise the differ sees an add + remove pair. - AVOID relying on
DROP COLUMNon SQLite < 3.35.0 — that single statement is skipped (warning + skipped-count) while the migration still commits atomically.
Status & outcomes: on success the domain is active (HTTP 201). MigrationStatus ∈ success / failed / rolled_back. The migration log records from_version, to_version, executed_at (exposed as applied_at), status, detail (DDL summary or error), and steps_applied/steps_skipped.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/domains | Submit/update domain; runs migration |
| GET | /api/v1/domains/{name} | Domain + migration status (steps_applied, steps_skipped, ddl_count) |
Example
# v1.8.0 → v1.9.0: cascade rule change triggers the 4-step rebuild
relationships:
book: { type: belongsTo, target: Book, onDelete: cascade } # was: restrict
-- generated, all in one transaction:
ALTER TABLE library_loans ADD COLUMN bookId_new TEXT REFERENCES library_books(id) ON DELETE CASCADE;
UPDATE library_loans SET bookId_new = bookId;
ALTER TABLE library_loans DROP COLUMN bookId;
ALTER TABLE library_loans RENAME COLUMN bookId_new TO bookId;
The same 4-step rebuild handles type widening and optional→required (with COALESCE). Refused errors: required-without-default, type narrowing without opt-in, invalid index SQL, circular computed-field deps.
31. Lua Scripting
Sandboxed Lua hooks implement custom business logic at resource lifecycle points, exposing all platform capabilities through the global
kalosmodule.
Hooks are declared per-resource and implemented in hooks/{resource}/{hookName}.lua, each registered with kalos.action(name, fn). before* hooks run prior to the SQL write and may abort or return computed fields; after* hooks are fire-and-forget. The sandbox blocks filesystem, shell, and unrestricted I/O — all interaction goes through kalos. Lua functions may also back AEL computed fields via the lua: prefix once registered with kalos.function(...).
Grammar / Config
resources:
GrantApplication:
hooks:
beforeCreate: [generateApplicationNumber, validateOpportunityIsOpen]
afterCreate: [createDefaultComplianceChecklist]
beforeUpdate: [enforceLockedApplicationRule]
beforeDelete: [checkDependentReferences]
-- hooks/GrantApplication/generateApplicationNumber.lua
kalos.action("generateApplicationNumber", function(req)
local opp = kalos.call("adl.heras.funding_opportunities.get", { id = req.data.fundingOpportunityId })
if opp.status ~= 200 then
return false, 422, "INVALID_OPPORTUNITY", "Funding opportunity not found"
end
return { applicationNumber = "APP-2026-0042" } -- computed field merged into write
end)
Return values
nil(or no return) — success, no changes.{ field = value }— success; merges computed fields into the write.false, http_code, error_code, error_message— abort. The tuple maps onto the error envelope{status, code, data, error_code, error_message}; e.g.false, 422, "VALIDATION_ERROR", "Application is locked".
kalos module surface
kalos.log.{debug,info,warn,error}— structured JSON logging ([lua]prefix).kalos.json.{encode,decode};kalos.require("shared.utils")— load modules fromhooks/.kalos.call("adl.domain.resource.op", {...})— dispatch another action; returns{status, data, error_message, ...}.kalos.store(name?):query(sql, params)— read-only direct SQL (SELECT/WITH/EXPLAIN only); writes MUST usekalos.callto preserve audit/validation/hooks.- Helpers:
kalos.uuid(),kalos.str.*,kalos.date.*.
Rules
- MUST route all writes through
kalos.call;kalos.store()is read-only (override withscripting.lua.allow_raw_sql). - MUST return the
(false, code, error_code, message)tuple to abort — onlybefore*hooks can abort. - SHOULD check
resp.statusafter everykalos.call. - AVOID
os.execute,io.*,require,loadfile/dofile,debug.*— all blocked by the sandbox (math,string,table,coroutine, and safeos.time/clock/dateare allowed).
Example
-- hooks/shared/financial.lua — registered for AEL use
kalos.function("calculateAmortization", function(principal, rate, term)
local m = rate / 12 / 100
local n = term * 12
return math.floor(principal * (m * (1+m)^n) / ((1+m)^n - 1) * 100 + 0.5) -- cents
end)
computed:
amortization: { type: money, formula: "lua:calculateAmortization(principal, rate, term)", stored: true }
32. Seeder System
A trusted, idempotent plugin that populates the database at startup from a YAML manifest, dispatching through the message bus under a
__system__identity that bypasses auth and hooks.
The seeder loads last (after adl and authd) so domain tables and user-management actions exist before it runs. It executes the manifest in four strict phases — roles → users → assignments → data — each dispatched on the bus. Running it repeatedly yields the same state. Because it uses __system__, it skips token/permission checks, does not fire Lua hooks, and can create records in non-initial states.
Grammar / Config
version: "1.0"
domain: library
roles: # Phase 1 → admin.roles.create (409 = skip)
- { name: librarian, description: "Library staff" }
users: # Phase 2 → auth.register, then admin.users.verify if verified
- { username: admin, email: admin@library.test, password: "Admin123!", verified: true }
assignments: # Phase 3 → admin.users.set_roles (replaces role set)
- { username: admin, roles: [admin] }
data: # Phase 4 → arbitrary action, impersonating `as:`
- action: adl.library.book.create
as: admin
data:
id: "b0000000-0000-0000-0000-000000000001" # needs allowUserProvided: true
title: "1984"
isbn: "978-0451524935"
{ "seeder": { "seed_file": "seeds/seed-library.yaml", "run_on_start": false } }
Phases & idempotency
| Phase | Action(s) | Idempotency |
|---|---|---|
| 1 Roles | admin.roles.create | 409 if exists → skipped (admin always pre-exists) |
| 2 Users | auth.register + admin.users.verify | 409 on dup username/email → skipped; Argon2 hash in authd (~250-300ms) |
| 3 Assignments | admin.users.set_roles | Replaces role set; re-run is a no-op; invalidates RBAC cache |
| 4 Data | the named action, ctx from as: user | Deterministic PKs + allowUserProvided: true prevent duplicates |
Rules
- MUST order plugins with
seederlast so domains and authd actions are registered first. - MUST set
allowUserProvided: trueon a primary key to supply explicit IDs (otherwise create rejects client UUIDs and auto-generates v4). - SHOULD use deterministic UUIDs in
data:so tests can reference records and re-seeding stays idempotent. - AVOID relying on hooks/history firing for seed data — they don’t; field validation still applies (invalid data → 422). The
as:user must exist for Phase 4 impersonation.
Example
Request handled action=admin.roles.create code=409 transport=seeder → role skipped name=admin
Request handled action=admin.roles.create code=201 transport=seeder → role created name=librarian
Seed operations are audited with actor: "__system__"; the trusted transport also bypasses state-machine initial-state enforcement, letting test data start in any state.
33. Privacy & GDPR
Field-level privacy classification plus runtime-enforced data-subject rights, consent, retention, and encryption for GDPR/CCPA/HIPAA/FERPA compliance.
Privacy is enabled per-domain with named classifications (each with a retention) and a subjectIdentifier; individual fields carry privacy: flags (pii, sensitive, phi, encrypted, subjectIdentifier). The runtime traverses all resources by subject identifier to serve access/erasure/portability/rectification/restriction requests. An anonymization block defines erasure behavior, and every privacy operation is recorded in {domain}_privacy_log. The auto-generated data map is the GDPR Art. 30 record of processing.
Grammar / Config
domain:
name: clinicalTrial
version: "1.0.0"
privacy:
enabled: true
classifications:
personalData: { description: "Standard personal data", retention: 7 years }
sensitiveData: { description: "Special category (Art. 9)", retention: 30 years }
subjectIdentifier: { primary: email, alternates: [subjectId, ssn] }
anonymization:
method: anonymize # anonymize | pseudonymize | delete
rules:
default: "Redacted"
email: "{$pseudonym}@redacted.local"
dateOfBirth: "$fuzzyDate(5 years)"
diagnosis: "$preserve"
encryption: { provider: aes256gcm, keySource: environment, keyVariable: "${ENCRYPTION_KEY}", keyRotation: 365 days }
consent:
- { purpose: secondary_research, required: false, withdrawable: true, resources: [Participant] }
retention:
- { category: financialData, duration: 10 years, action: delete, from: lastActivity }
resources:
Participant:
object:
email: { type: string, privacy: { classification: personalData, pii: true, subjectIdentifier: true } }
ssn: { type: string, privacy: { classification: financialData, pii: true, encrypted: true } }
Anonymization functions: Redacted, $fuzzyDate(offset), $pseudonym, $hash6, $preserve.
Rules
- MUST mark at least one field
subjectIdentifier: trueso subject-rights APIs can resolve records. - MUST return 409 from erasure when legal-hold blockers exist and
dryRun: false. - SHOULD use
dryRun: trueto preview affected resources/field counts before erasing. - SHOULD encrypt and access-restrict the
{domain}_pseudonym_maptable (enables lawful re-identification). - AVOID withdrawing or erasing data under
required: trueconsent or active legal holds;$preservekeeps fields needed for research/integrity.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/privacy/subject/{id} | Right of access (Art. 15) |
| GET | /api/v1/{domain}/privacy/subject/{id}/export | Portability (Art. 20) |
| POST | /api/v1/{domain}/privacy/subject/{id}/erase | Erasure (Art. 17) |
| PATCH | /api/v1/{domain}/privacy/subject/{id}/rectify | Rectification (Art. 16) |
| POST | /api/v1/{domain}/privacy/subject/{id}/restrict · /unrestrict | Restrict processing (Art. 18) |
| POST | /api/v1/{domain}/privacy/subject/{id}/consent/grant · /withdraw | Consent management |
| POST | /api/v1/{domain}/privacy/legal-hold | Place legal hold |
| GET | /api/v1/{domain}/privacy/data-map | Art. 30 processing record |
Example
POST /api/v1/clinicalTrial/privacy/subject/alice@example.com/erase
{ "method": "anonymize", "reason": "GDPR Art. 17", "retainAudit": true, "dryRun": false }
Execution anonymizes matching records in a transaction, anonymizes actor references in audit/history (retaining change data and event labels), and writes a subjectErasure privacy-log event.
34. Localization (i18n)
Declarative multi-language support: every human-readable string is translatable inline or via satellite locale files, and the runtime localizes API labels, validation, history, notifications, and formatting per request.
Enable localization on the domain with defaultLocale, supportedLocales, a fallbackChain, and locale-specific dateFormat/numberFormat/textDirection. Translations live inline (label:/plural: maps, enum value labels) or in locales/{code}.yaml satellite files merged at parse time. Locale is resolved from Accept-Language (exact → dialect base → defaultLocale) and echoed in _meta.locale. History events store a label_template + label_data so past events render in newly-added languages at read time.
Grammar / Config
domain:
name: awards
localization:
defaultLocale: en
supportedLocales: [en, es, fr, de, ja, ar]
fallbackChain: [requestedLocale, en]
dateFormat: { en: "MMM d, yyyy", es: "d 'de' MMM 'de' yyyy" }
numberFormat: { es: { thousands: ".", decimal: ",", currencySymbol: "€", currencyPosition: after } }
textDirection: { en: ltr, ar: rtl }
resources:
Award:
label: { en: "Award", es: "Premio" }
plural: { en: "Awards", es: "Premios" }
object:
awardNumber: { type: string, label: { en: "Award Number", es: "Número de Premio" } }
enums:
AwardStatus:
values:
active: { label: { en: "Active", es: "Activo" }, color: "#10b981" }
Satellite file locales/es.yaml (locale: es) provides resources.*, enums.*, history.events.*, validation.messages.*, notifications.templates.*.
Rules
- MUST list every locale in
supportedLocales; unmatched requests fall back throughfallbackChaintodefaultLocale. - MUST persist history
label,label_template, andlabel_dataat write time to enable retroactive localization. - SHOULD extract large translation sets to satellite files; use XLIFF export/import for professional translation tools.
- AVOID client-side i18n frameworks — the schema endpoint already returns localized labels, enum options, and transition names.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/domains/{domain}/schema (Accept-Language) | Localized schema/labels/enums |
| GET | /api/v1/{domain}/localization/coverage | Per-locale translation completeness |
| GET | /api/v1/{domain}/localization/missing?locale=es | Untranslated keys |
| GET | /api/v1/{domain}/localization/export?locale=es&format=xliff|csv|yaml | Export for translation |
| POST | /api/v1/{domain}/localization/import | Import translated XLIFF (multipart) |
Example
GET /api/v1/awards/Award Accept-Language: es-MX,es;q=0.9,en;q=0.8
→ { "code": 200, "_meta": { "locale": "es", "localeResolved": true } }
// Localized validation error (envelope error_code VALIDATION_ERROR @ 422)
{ "code": 422, "error_code": "VALIDATION_ERROR",
"errors": [{ "field": "title", "label": "Título del Proyecto",
"message": "Título del Proyecto es obligatorio", "rule": "required" }] }
35. External Integrations
Declarative, event-driven connectors that call out to or receive from external systems (REST, webhook, S3, SMTP, AMQP, MQTT) with managed auth, data mapping, and resilience.
Integrations are triggered by history events, transitions, workflow steps, schedules, or manual API calls. Each declares a type, auth, and operations/events with request/response or inbound mapping, plus onSuccess/onError actions (create/update/transition against a resource). AEL expressions and transform functions ($record, $payload, $message, $mapped, $now(), $findOne, $formatDate, …) shape data; ${ENV} injects deployment secrets. The runtime handles token refresh, retries, circuit breaking, rate limiting, and a dead-letter queue, logging everything (with secrets redacted) to {domain}_integration_log.
Grammar / Config
integrations:
grantsGovSubmission:
type: rest
baseUrl: "https://api.grants.gov/v2"
auth: { type: oauth2_client_credentials, tokenUrl: "...", clientId: "${ID}", clientSecret: "${SECRET}", scopes: [read, write], tokenCache: true }
retry: { maxAttempts: 3, backoff: exponential, baseDelay: 1s, maxDelay: 30s, retryOn: [500,502,503,504,429] }
circuitBreaker: { enabled: true, failureThreshold: 5, successThreshold: 2, timeout: 60s } # closed→open→half_open→closed
rateLimit: { requestsPerSecond: 10, burstSize: 20, onExceeded: queue } # queue | reject | backoff
deadLetter: { enabled: true, maxRetries: 3, notifyOn: [integration_failure] }
operations:
submitProposal:
trigger: { resource: Proposal, event: transition, transition: submitToSponsor }
request:
method: POST
path: "/submissions"
body: { title: $record.title, pi_email: $record.piEmail }
onSuccess:
action: update
resource: Proposal
target: $record.id
input: { submissionId: $response.submission_id, submittedAt: $now() }
history: { event: proposalSubmitted, label: "Proposal submitted — ID {submissionId}" }
onError: { action: transition, resource: Proposal, target: $record.id, transition: submissionFailed }
Auth types: api_key, bearer, oauth2_client_credentials, oauth2_authorization_code, basic, hmac_signature, mtls, ip_whitelist, aws_sigv4.
Rules
- MUST verify inbound webhooks (
hmac_signatureover the raw body, orip_whitelist); mismatches are rejected. - SHOULD enable
replayProtection(timestampmaxAge+ nonce) on webhooks to block duplicate delivery. - SHOULD inject all credentials via
${ENV}— secrets are auto-redacted from the integration log. - AVOID unbounded retries — pair
retrywithcircuitBreakeranddeadLetter; failed messages land in the DLQ for manual retry.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/integrations | List integrations |
| GET | /api/v1/{domain}/integrations/{name}/status · /health | Circuit/rate/error metrics |
| POST | /api/v1/{domain}/integrations/{name}/operations/{op} | Manual trigger (dryRun supported) |
| GET | /api/v1/{domain}/integrations/{name}/deadletter | List failed messages |
| POST | /api/v1/{domain}/integrations/{name}/deadletter/{id}/retry | Retry a dead-lettered message |
Example
# Inbound webhook → create an Award
integrations:
sponsorNotifications:
type: webhook
path: /webhooks/sponsor-notifications
auth: { type: hmac_signature, secret: "${WEBHOOK_SECRET}", header: X-Webhook-Signature, algorithm: sha256 }
events:
awardNotification:
filter: { field: event_type, value: award_notification }
mapping:
awardNumber: $payload.award_id
awardedAmount: $payload.total_amount
startDate: "$parseDate($payload.period_start, 'yyyy-MM-dd')"
action: create
resource: Award
input: $mapped
history: { event: awardReceived, label: "Award received — {awardNumber}" }
36. AEL (ADL Expression Language)
A sandboxed, pure-functional expression language for validation, permissions, computed fields, and state-machine guards.
AEL evaluates expressions against a context object — there is no assignment, looping, mutation, or I/O. Every expression is a pure function of its inputs and returns a single value. The same syntax powers validation rules (§14), RBAC permissions (§11), computed-field formulas (§13), state guards (§12), and conditions in rules/workflows/scopes.
Grammar / Config
# Validation
rule: "endDate > startDate"
rule: "$op != 'create' || startDate > now()"
# Permission
update: "user.id == record.authorId || hasRole('admin')"
# Guard
guard: "hasRole('editor') && record.reviewedBy != null"
# Computed
formula: "concat(firstName, ' ', lastName)"
Operator precedence (high→low): access . ?. [] () → unary ! - → * / % → + - → < <= > >= in → == != → && → || → ?? → ternary ?:. Optional chaining ?., nullish coalescing ??, and lambdas (x => x*2, (a,b) => a+b) are supported.
Context variables
- Validation: field names,
$this(merged state),$old(ornullon create),$op("create"/"update"). - Permission:
user(id,username,email,roles),record. - Guard:
record,user,transition. - Computed:
$this, field names.
Built-in functions (selected; ~80 total)
- String:
len upper lower trim substring startsWith endsWith contains indexOf replace split join concat padStart padEnd capitalize camelCase snakeCase - Array:
len first last at slice concat reverse sort unique flatten includes map filter find every some reduce sum avg min max - Math:
abs ceil floor round sqrt pow log exp sin cos tan min max random randomInt - Date:
now today dateAdd dateSub dateDiff year month day hour minute second dayOfWeek isWeekend formatDate parseDate - Type:
type isString isNumber isBoolean isNull isArray isObject isDefined - Context:
hasRole isOwner isAuthenticated inState canTransition currentState fieldChanged oldValue newValue isCreate isUpdate query exists(last two: validation context only)
Rules
- MUST keep expressions side-effect free — no assignment, loops, or I/O.
- MUST stay within limits: ≤10,000 chars, ≤32 nesting levels, ≤10,000 array elements; queries cap at 5s / 1,000 rows.
- SHOULD use
?.and??to guard against null traversal (e.g.user?.address?.city ?? "n/a"). - AVOID
query()/exists()outside validation context.
Example
validation:
budgetCheck:
rule: "sum(map(lineItems, x => x.amount)) <= 100000"
permissions:
delete: "hasRole('admin') || (isOwner() && $op == 'create')"
37. OpenAPI Generation
ADL auto-generates a complete OpenAPI 3.1.0 specification per domain for documentation and client code generation.
Each domain emits a spec at GET /api/v1/domains/{domain}/openapi.json documenting all CRUD, custom, transition, and relationship paths plus request/response schemas, query parameters, and auth. The info block carries x-adl-version: "0.3.1". The spec is consumable by openapi-typescript, OpenAPI Generator, Swagger UI, and Redoc.
Grammar / Config
{
"openapi": "3.1.0",
"info": { "title": "Blog API", "version": "1.0.0", "x-adl-version": "0.3.1" },
"servers": [{ "url": "https://api.example.com/api/v1/blog" }],
"security": [{ "bearerAuth": [] }],
"paths": { "...": {} },
"components": { "schemas": {}, "securitySchemes": {}, "parameters": {}, "responses": {} }
}
Each resource generates three schemas: {Resource} (read, all fields), {Resource}Input (writable fields only), {Resource}List (items + count + PaginationMeta). Standard query params are reusable components: PageParam, LimitParam (max 1000, default 50), SortParam, SearchParam, IncludeParam, ScopeParam, FieldsParam; per-field filters appear as filter[field] / filter[field][op]. Auth uses a single bearerAuth HTTP/JWT scheme.
Type mapping (selected): string→string, text→string, integer→integer, decimal/money→number(double), boolean→boolean, date→string(date), datetime→string(date-time), uuid→string(uuid), email→string(email), url→string(uri), json→object, enum→string+enum, media→string(uuid), blocks/repeater→array, component→object.
Extension fields: x-adl-version (info), x-enum-labels / x-enum-colors / x-enum-descriptions (schema), x-displayName (tag), x-guard / x-required-fields / x-from-states / x-to-state (transition operations).
Rules
- MUST emit
x-adl-version: "0.3.1"ininfoand a singlebearerAuthsecurity scheme. - MUST generate the error response schema with properties
status,code,data,error_code,error_message(theValidationErrorresponse iscode: 422,error_code: VALIDATION_ERROR). - SHOULD exclude computed and auto-generated fields from
{Resource}Input. - AVOID documenting public endpoints as requiring
bearerAuth.
Example
npx openapi-typescript \
http://localhost:8087/api/v1/domains/blog/openapi.json \
--output ./src/api/blog.types.ts
38. Admin Endpoints
Administrative HTTP routes for system introspection, domain management, user/role administration, and audit logging.
Introspection routes (/status, /plugins, /routes, /version, /health, domain listing/schema) are public; management routes (/api/v1/admin/..., domain submission) require the admin role. The schema endpoint is the primary discovery mechanism for admin UIs. Domain submission triggers the migration pipeline. /health and /version return raw JSON (no envelope); all other routes use the standard envelope.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /health | Liveness (public, no envelope) |
| GET | /version | Platform version 0.3.1 (public, no envelope) |
| GET | /api/v1/status | Uptime, memory, domain/route counts (public) |
| GET | /api/v1/plugins | Loaded plugins (public) |
| GET | /api/v1/routes | Registered routes (public) |
| GET | /api/v1/domains | List domains (public) |
| GET | /api/v1/domains/{name} | Domain detail + migrations (public) |
| GET | /api/v1/domains/{domain}/schema | Full resource schema for introspection (public) |
| GET | /api/v1/domains/{domain}/openapi.json | Per-domain OpenAPI spec (public) |
| POST | /api/v1/domains | Submit domain YAML/JSON, run migration (admin) |
| GET | /api/v1/admin/users | List users (admin) |
| GET | /api/v1/admin/users/{username} | Get user (admin) |
| POST | /api/v1/admin/users/{username}/roles | Set roles (admin) |
| POST | /api/v1/admin/users/{username}/verify | Mark verified (admin) |
| POST | /api/v1/admin/users/{username}/suspend | Suspend user (admin) |
| POST | /api/v1/admin/users/{username}/unsuspend | Reverse suspension (admin) |
| GET | /api/v1/admin/roles | List roles (admin) |
| POST | /api/v1/admin/roles | Create role (admin) |
| GET | /api/v1/admin/audit | Query audit log (admin) |
Rules
- MUST NOT use reserved names as domain names (case-insensitive):
auth, users, admin, domains, status, plugins, routes, version, openapi, echo. - MUST require the
adminrole for all/api/v1/admin/...routes and domain submission. - MUST set
allow_destructive: trueto apply destructive migrations; otherwise the submission is refused with422 DESTRUCTIVE_MIGRATION. - SHOULD treat suspended users as
403 USER_SUSPENDEDon every authenticated request.
Example
POST /api/v1/domains
Content-Type: application/json
{ "content": "<domain YAML>", "format": "yaml", "allow_destructive": false }
Audit query params: username, action, status_code, after, before, limit (1-500, default 100), offset.
39. Error Contract
Every ADL error response uses one stable envelope with a machine-readable code and per-error details.
The envelope is stable across versions; clients switch on code (HTTP status) and error_code (uppercase snake_case). There is NO top-level error or message field and NO details field — error specifics live in data. For VALIDATION_ERROR, data is keyed by field name (or rule name for cross-field rules), each value { code, message }.
Grammar / Config
{
"status": "error",
"code": 422,
"data": { },
"error_code": "VALIDATION_ERROR",
"error_message": "Validation failed"
}
HTTP status codes: 400 malformed · 401 auth · 403 authorization · 404 not found · 409 conflict · 422 validation · 423 locked · 429 rate limit · 500 server · 502 bad gateway · 503 unavailable.
Error codes by category
- 401:
INVALID_CREDENTIALS,TOKEN_EXPIRED,TOKEN_INVALID,TOKEN_MISSING - 403:
FORBIDDEN,USER_SUSPENDED,GUARD_FAILED,FIELD_RESTRICTED - 422:
VALIDATION_ERROR,REQUIRED_FIELD,FIELD_TYPE_MISMATCH,FIELD_TOO_LONG,FIELD_TOO_SHORT,PATTERN_MISMATCH,VALUE_OUT_OF_RANGE,INVALID_ENUM_VALUE,RULE_VIOLATION - State machine (422/403):
INVALID_TRANSITION,FINAL_STATE,NO_STATE,UNKNOWN_STATE,REQUIRED_FIELD_MISSING,GUARD_FAILED - 400/404:
NOT_FOUND,MISSING_ID,MISSING_PK - 409:
DUPLICATE,CONCURRENT_MODIFICATION - 429:
RATE_LIMITED(withdata.retryAfter+Retry-Afterheader) - 500:
INTERNAL_ERROR,DATABASE_ERROR,INTEGRATION_ERROR
Rules
- MUST use exactly the five envelope keys
status,code,data,error_code,error_message;statusis always"error". - MUST use
VALIDATION_ERROR@ 422 (neverVALIDATION_FAILED). - MUST key validation
databy field name (or rule name), each{ code, message }. - MUST NOT emit top-level
error/messageor adetailsfield. - SHOULD retry only 5xx and 429 (respecting
retryAfter); never retry other 4xx.
Example
{
"status": "error",
"code": 422,
"error_code": "VALIDATION_ERROR",
"error_message": "Validation failed",
"data": {
"email": { "code": "pattern", "message": "Invalid email format" },
"dateOrder": { "code": "rule_failed", "message": "End date must be after start date" }
}
}
40. Complete API Reference
The canonical route table for every endpoint ADL generates per resource, with the standard envelope and disable flags.
All resource routes live under /api/v1/{domain}/{resources}. Each resource generates up to 11 standard CRUD endpoints plus relationship, transition, custom, soft-delete, audit, and history routes; a fully-featured resource yields 22–27 endpoints. List responses wrap items, count, and a meta pagination block. Permissions use * (public/anonymous) and authenticated (any logged-in user); ? is NOT supported.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/{domain}/{resources} | List (filter/sort/page/search/include/scope/fields) |
| GET | /api/v1/{domain}/{resources}/{id} | Get one |
| POST | /api/v1/{domain}/{resources} | Create → 201 |
| PATCH | /api/v1/{domain}/{resources}/{id} | Partial update |
| PUT | /api/v1/{domain}/{resources}/{id} | Full replace |
| DELETE | /api/v1/{domain}/{resources}/{id} | Delete → 204 (soft sets deletedAt) |
| GET | /api/v1/{domain}/{resources}/count | Count (accepts list filters) |
| GET | /api/v1/{domain}/{resources}/distinct/{field} | Distinct values |
| POST | /api/v1/{domain}/{resources}/bulk | Bulk create |
| PATCH | /api/v1/{domain}/{resources}/bulk | Bulk update (ids + data) |
| DELETE | /api/v1/{domain}/{resources}/bulk | Bulk delete (ids) |
| GET | /api/v1/{domain}/{resources}/{id}/{relationship} | List related/linked |
| POST | /api/v1/{domain}/{resources}/{id}/{relationship} | Link (belongsToMany) |
| DELETE | /api/v1/{domain}/{resources}/{id}/{relationship}/{relatedId} | Unlink |
| POST | /api/v1/{domain}/{resources}/{id}/transitions/{name} | State transition |
| POST | /api/v1/{domain}/{resources}/{id}/restore | Restore soft-deleted |
| GET | /api/v1/{domain}/{resources}/{id}/audit | Per-record audit trail |
| GET | /api/v1/{domain}/{resources}/{id}/history | Per-record history events |
| * | /api/v1/{domain}/{resources}/{id}/<custom> | Custom operation (§17) |
Rules
- MUST place all resource routes under
/api/v1/{domain}/...; introspection/admin/auth live under/api/v1/{domains,admin,auth,users}/.... - MUST return the standard envelope:
{ status:"ok", code, data }on success; errors per §39. - SHOULD prefer nested access
/{parentResource}/{parentId}/{childResource}for clearer semantics (equivalent to?filter[parentId]=...but validates parent existence). - Disable individual endpoints via
api.disable: [list, get, create, update, put, delete, count, distinct, bulk].
Query parameters (list): filter[field], filter[field][op] (ops: eq ne gt gte lt lte like in), sort (- prefix = desc), limit (1-1000, default 50), page/offset, search, include, scope, fields.
Example
GET /api/v1/blog/posts?filter[status]=published&filter[price][gte]=10&sort=-createdAt&include=author,tags&limit=25
{ "status": "ok", "code": 200,
"data": { "items": [ { "id": "uuid", "title": "Post 1" } ], "count": 15,
"meta": { "total": 42, "page": 1, "per_page": 50, "total_pages": 1, "has_next": false, "has_prev": false } } }
Appendix A — Complete Worked Example (blog domain)
A minimal but complete domain exercising fields, enums, composition, relationships, indexes, features, validation, and permissions — end to end.
adl-blog.yaml
domain:
name: blog
version: "1.0.0"
description: "Blog platform — posts, tags, comments."
# api.prefix defaults to /api/v1/blog — omit unless overriding.
objects:
Timestamp:
createdAt: { type: datetime, autoNow: create }
updatedAt: { type: datetime, autoNow: always }
SoftDelete:
deletedAt: { type: datetime }
enums:
PostStatus:
values:
draft: { label: "Draft" }
review: { label: "In Review" }
published: { label: "Published" }
archived: { label: "Archived", final: true }
properties:
slug: { type: slug, unique: true, maxLength: 255 }
resources:
Post:
object:
id: { type: uuid, primaryKey: true }
title: { type: string, required: true, minLength: 3, maxLength: 200 }
slug: { $ref: '#/properties/slug', from: title }
body: { type: markdown }
status: { $ref: '#/enums/PostStatus', default: draft }
authorId: { type: string, required: true, immutable: true, maxLength: 100 }
publishedAt: { type: datetime }
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
relationships:
comments: { type: hasMany, target: Comment, foreignKey: postId, onDelete: cascade }
tags: { type: belongsToMany, target: Tag, through: PostTag, foreignKey: postId, otherKey: tagId }
indexes:
- { fields: [status] }
- { fields: [authorId] }
- { fields: [status, publishedAt] }
features: { softDelete: true, audit: true, search: true }
validation:
publishedRequiresDate:
rule: "status != 'published' || publishedAt != null"
message: "A publish date is required when status is published"
permissions:
ownerField: authorId
list: ["*"]
get: ["*"]
create: [authenticated]
update: [owner, editor, admin]
delete: [admin]
fields:
status: { read: ["*"], write: [editor, admin] }
publishedAt: { read: ["*"], write: [editor, admin] }
Tag:
object:
id: { type: uuid, primaryKey: true }
name: { type: string, required: true, unique: true, maxLength: 50 }
slug: { $ref: '#/properties/slug', from: name }
$merge: { $ref: '#/objects/Timestamp' }
permissions:
list: ["*"]
get: ["*"]
create: [admin]
update: [admin]
delete: [admin]
Comment:
object:
id: { type: uuid, primaryKey: true }
body: { type: text, required: true, minLength: 1, maxLength: 5000 }
postId: { type: uuid, required: true, immutable: true }
authorId: { type: string, required: true, immutable: true, maxLength: 100 }
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
relationships:
post: { type: belongsTo, target: Post, foreignKey: postId, onDelete: cascade }
indexes:
- { fields: [postId] }
- { fields: [authorId] }
features: { softDelete: true }
permissions:
list: ["*"]
get: ["*"]
create: [authenticated]
update: [owner, admin]
delete: [owner, admin]
config-blog.json (SQLite, single instance)
{
"transport": { "rest": { "host": "127.0.0.1", "port": 8081 } },
"auth": { "jwt_secret": "CHANGE-ME-32-CHARS-MINIMUM-SECRET", "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-blog.yaml" },
"seeder": { "enabled": true, "run_on_start": true, "seed_file": "./seeds/seed-blog.yaml" },
"core": {
"plugin_dirs": ["./build/bin/Release/plugins"],
"plugins": [
{ "name": "transport.rest", "path": "kalos_rest.so", "enabled": true },
{ "name": "store.sqlite", "path": "kalos_sqlite.so", "enabled": true },
{ "name": "handler.authd", "path": "kalos_authd.so", "enabled": true },
{ "name": "adl", "path": "kalos_adl.so", "enabled": true },
{ "name": "seeder", "path": "kalos_seeder.so", "enabled": true }
]
}
}
seeds/seed-blog.yaml
roles:
- { name: admin, description: "Full access" }
- { name: editor, description: "Can publish" }
- { name: author, description: "Can write own posts" }
users:
- { username: admin, email: admin@blog.test, password: "Admin123!", verified: true }
- { username: alice, email: alice@blog.test, password: "Demo123!", verified: true }
- { username: diana, email: diana@blog.test, password: "Demo123!", verified: true }
assignments:
- { username: admin, roles: [admin] }
- { username: alice, roles: [author] }
- { username: diana, roles: [editor] }
data:
- action: adl.blog.tag.create
as: admin
data: { name: "tech" }
- action: adl.blog.post.create
as: diana # editor — may write status/publishedAt
data:
title: "Getting Started with Kalos"
slug: "getting-started-with-kalos"
body: "# Hello"
status: "published"
authorId: "alice"
publishedAt: "2026-02-10T09:00:00Z"
Verification flow (curl)
BASE=http://127.0.0.1:8081
API=$BASE/api/v1/blog
AUTH=$BASE/api/v1/auth
# 1. Log in → 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) → data.items
curl -s "$API/posts?filter[status]=published&sort=-publishedAt" | jq '.data.items | length'
# 3. Authenticated create (status omitted → defaults to draft; alice is author)
curl -s -X POST "$API/posts" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"My Draft","slug":"my-draft","authorId":"alice"}' | jq '.data'
# 4. Expected validation error (publishing without a date) → 422
# { "status":"error","code":422,"data":{...},
# "error_code":"VALIDATION_ERROR","error_message":"..." }
Generated surface: tables blog_posts, blog_tags, blog_comments, blog_posttag; resource routes under /api/v1/blog/{posts,tags,comments}; per-domain OpenAPI at /api/v1/domains/blog/openapi.json; schema at /api/v1/domains/blog/schema.
Appendix B — Generation Checklist
Run through this before emitting an ADL document.
-
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 set (§7). - Every foreign key has an
index(§19) and 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 or AEL strings; symbols are only
*,authenticated,owner,admin, or custom role names. - No
?anywhere in permissions. - Routes referenced in 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). - State-machine fields use enum-compatible values; transitions have guards where access must be limited.
- Computed fields used in filter/sort/index are
stored: true. - Cross-field
validationrules are null-safe (field == null || …). - 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 (.so/.dylib/.dll). - Destructive migrations are opted into with request-body
allow_destructive: true(never anX-Allow-Destructiveheader).