ADL v0.3 · Guide

Complete Reference

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 geometry type, 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. See ADL-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)

ConcernCanonical 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 errorerror_code: VALIDATION_ERROR at HTTP 422 (never VALIDATION_FAILED).
Public access symbol* = public/anonymous (no auth).
Any-logged-in symbolauthenticated.
? permission wildcardUnsupported — the parser rejects it.
Version stringsPlatform/release is 0.3.1. A domain’s own version: is an independent author semver.
Reserved domain namesauth, users, admin, domains, status, plugins, routes, version, openapi, echo.
Destructive migrationsGated by request-body field allow_destructive: true (no X-Allow-Destructive header).
Action bus id formatadl.{domain}.{resource}.{op}; domain ops are adl.domain.*.

Contents

Part I — Core Platform & Modeling

  1. 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 ICore interface.

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 dlopen failure or on_init failure (fatal).
  • MUST set the transport field on every dispatched Request; 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)

MethodPathPurpose
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 as kalosd 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.plugins by dependency (transport → store → auth → adl → seeder → scripting).
  • SHOULD set app_base_url to the external URL (behind proxies) and use SQLite’s separate db_path/app_db_path only on SQLite (ignored on PostgreSQL).
  • AVOID the legacy auth block and disable_checking: true outside development; present only one store section.

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 (OrderItemorder-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/json for bodies (else 415); GET has no body and no side effects; PATCH/PUT/DELETE are idempotent.
  • MUST use error code VALIDATION_ERROR @ 422; error_code values 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)

MethodPathPurpose
GET/api/v1/d/rslist (adl.d.r.list)
GET/api/v1/d/rs/{id}get
POST/api/v1/d/rscreate (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 · /distinctcount / 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 · /historyhistory 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=require or stronger for PostgreSQL; ensure SQLite ≥ 3.35.0 (older skips DROP 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 version so YAML does not parse it as a float ("1.0.0", not 1.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 a domain: block.
  • MUST make JSON submissions self-contained — $include is unsupported in JSON.
  • Parse failures return the error envelope at HTTP 422 with error_code: PARSE_ERROR.

Endpoints

MethodPathPurpose
POST/api/v1/domainsSubmit 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, set precision/scale) · boolean · date · datetime · uuid (auto v4 on create unless allowUserProvided: true) · email · url
  • Tier 1 — Extended (6): float · timestamp (unix epoch int) · slug (requires from, always unique, regenerated on source change, suffixes -1,-2 on collision) · json · markdown · autoincrement (read-only, never reused)
  • Tier 2 — Specialized (5): phone · time · password (Argon2id hashed, implies writeOnly) · 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 with uniqueItems enforced)
  • 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 (alias geojson) — a GeoJSON geometry value. Optional geometryType (point/linestring/polygon/multipoint/multilinestring/multipolygon/geometrycollection, case-insensitive) constrains the accepted type; srid defaults to 4326 (WGS84). Validated as well-formed GeoJSON (a type plus coordinates, or geometries for a collection); accepted as an object or a JSON string and emitted as an object. Stored as JSONB on PostgreSQL / TEXT on SQLite. On PostgreSQL with the postgis extension, each geometry field additionally gets a database-maintained geometry(<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>, and filter[geom][within]=<GeoJSON>. Using a spatial operator on a non-geometry field, or on SQLite, returns 400. For large layers: combine bbox (viewport) with level-of-detail filtering — declare a normal indexed integer like minZoom and filter filter[minZoom][lte]=<z> — and/or thin geometry server-side with ?simplify=<tolerance> on list (runs ST_Simplify against 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/money as strings in JSON to preserve precision.
  • SHOULD use string (length-bounded, VARCHAR in PG) for short values, text for long-form.
  • SHOULD use relationships (not component/repeater) for data that must be queried or shared independently — components have no id and cannot relate.
  • Auto-managed fields (autoNow, uuid PK, 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 foreignKey between the belongsTo and its inverse hasMany; only the belongsTo side generates the FK column DDL.
  • MUST give the hasMany side a foreignKey naming 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); required is enforced only at the API boundary.
  • Composite PK: declare compositePrimaryKey: [a, b] (each field required: true), and the resource MUST NOT also have a primaryKey: true id; client provides all key values (no auto-gen).

Endpoints

MethodPathPurpose
GET/api/v1/{domain}/{resource}/:id/{related}List related records (hasMany / belongsToMany)
POST/api/v1/{domain}/{resource}/:id/{related}/linkLink a record (belongsToMany; idempotent, 200)
DELETE/api/v1/{domain}/{resource}/:id/{related}/unlinkUnlink 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 string fields, 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 $ref when 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: true is a UI hint only (no API-level enforcement). Non-required enum fields accept null regardless 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 $merge and $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 $ref other 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 renamedFrom on 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

SymbolMeaningEnforcement
*Public / anonymousJWT validation skipped entirely
authenticatedAny logged-in userValid JWT required, no role check
ownerUser owns the recordJWT user_id matched against owner FK
adminAdmin roleJWT roles includes "admin"
any stringCustom roleJWT 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 omitted permissions: block as admin-only for all five operations.
  • MUST resolve owner via the owner FK by convention: createdByIduserIdauthorId → first belongsTo User; if none exist, owner never matches.
  • SHOULD strip read-restricted fields entirely from responses (absent, not null); use mask: true to return "***" instead.
  • SHOULD silently ignore a write-restricted field the caller can’t set, unless it is required on create → 403 FIELD_FORBIDDEN.
  • AVOID create: [owner] (no record exists yet — means nobody); use [authenticated]. On list, owner filters 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 403 FORBIDDEN) → guards (else 403 GUARD_FAILED) → requiredFields (else 422 REQUIRED_FIELD) → apply sets/hooks/persist.
  • MUST satisfy a requiredFields entry from either the request body or the existing record (non-null/non-empty).
  • MUST let sets override 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: true on states that need a reopen path; hook order is onExit → sets → persist → onEnter, where an onExit failure aborts (422 HOOK_FAILED) but onEnter is fire-and-forget.

Endpoints

MethodPathPurpose
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: true for 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. Use reactive sparingly.

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 bare endDate > startDate fails when null); use required: true on the field when null itself is invalid.
  • MUST return all collected errors at once as VALIDATION_ERROR at 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 them when: update; use $op != 'create' || to relax rules for edits of legacy data.
  • AVOID relying on unique (409 DUPLICATE) or immutable (422 IMMUTABLE_FIELD) for cross-field logic — those are separate field-level errors, not rule_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

FlagDefaultAdds
softDeletefalseLogical delete (deletedAt), ?scope= filtering, restore endpoint
auditfalseJSON field-diff audit table + audit endpoints
versioningfalseFull pre-update snapshots + versions/revert endpoints
searchfalse?search= full-text over text fields
exportfalseCSV export endpoint
importfalseJSON-array import endpoint
cachingfalsePer-instance in-memory read cache
cacheTtlSeconds300Cache lifetime (only with caching)
transactionstrueWrap 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 SoftDelete object (or declare deletedAt: datetime) when softDelete: true; DELETE then sets deletedAt (no hard-delete endpoint exists).
  • MUST scope list/get to deletedAt IS NULL by default; ?scope=active|deleted|all overrides.
  • MUST run each import record through the full create pipeline (validation, unique, hooks); allowUserProvided: true on id enables upsert.
  • SHOULD exclude updatedAt from audit; use audit for mechanical field diffs and the history system (§21) for business-event narratives.
  • AVOID caching on high-write or stale-intolerant resources (per-instance, no cross-instance invalidation); transactions: false only for edge-case high-throughput logging.

Endpoints (per enabled feature)

MethodPathPurpose
POST/:id/restoresoftDelete: clear deletedAt
GET/:id/audit, /auditaudit: record / resource trail (paginated, date/user filters)
GET/:id/versionsversioning: list versions (newest first)
POST/:id/revertversioning: revert to { "version": N } (appends a new version)
GET(list) ?search=termsearch: full-text over text fields
GET/exportexport: CSV (respects filters/sort/scope; computed included, relations not)
POST/importimport: 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 createdAt ASC 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

OperatorSyntaxSQL
eq (default)filter[status]=x / filter[status][eq]=x= x
nefilter[status][ne]=draft!= draft
gt / gtefilter[price][gt]=100> 100
lt / ltefilter[age][lte]=65<= 65
likefilter[title][like]=%deploy%LIKE '%deploy%'
infilter[status][in]=draft,reviewIN (...)

Endpoints

MethodPathPurpose
GET/api/v1/{domain}/{resources}List (all params above)
GET/api/v1/{domain}/{resources}/distinct?field=statusUnique 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}/bulkBulk create ({"items":[…]}; optional upsert)
PATCH/api/v1/{domain}/{resources}/bulkBulk update ({"items":[…]}, each item needs id)
DELETE/api/v1/{domain}/{resources}/bulkBulk 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 (MissionAnalysismission-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 under resource a belongsTo to 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

MethodPathPurpose
POST/api/v1/{domain}/{res}/:id/{name}Custom operation → bus action adl.{domain}.{resource}.{name}
GET/PATCH/api/v1/{domain}/{res}/:idSingleton 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, plus component/repeater operational 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> within maxSize.
  • 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

MethodPathPurpose
POST/api/v1/{domain}/media/uploadMultipart upload; validates constraints → 201 with id/url/dims
GET/api/v1/{domain}/media/orphansList 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 belongsTo FK, status/state fields, common composite query patterns, aggregation-trigger FKs, and deletedAt for 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 to active, 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 owner role 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 requires on and label.
  • SHOULD use on: fieldChange (single named occurrence) vs on: update with fields (broad modification); reserve bare on: update to avoid noise.
  • Auto-captured: from/to (transition), previousValue/newValue (fieldChange), actorId/actorName (all) — need not be in capture.
  • AVOID assuming roles prevents recording — it only filters reads on GET /:id/history.

Endpoints

MethodPathPurpose
GET/api/v1/{domain}/{resource}/:id/historyPaginated timeline for one record
GET/api/v1/{domain}/{resource}/historyCross-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 action with severity: reject/error, warn/warning, log/info. reject blocks atomically; warn writes and returns warnings[]; log writes silently to the rules log.
  • Evaluation order: reject rules first (one firing blocks all further), then warn (collected), then log.
  • SHOULD use trigger.condition and trigger.fields pre-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 to messageData.

Endpoints

MethodPathPurpose
GET/api/v1/{domain}/rulesList 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=rejectAll-rules log
GET/api/v1/{domain}/rules/statsAggregate statistics
POST/api/v1/{domain}/rules/{name}/testDry-run against a record (admin)
POST/api/v1/{domain}/rules/{name}/enable | /disableToggle at runtime
POST/api/v1/{domain}/rules/groups/{group}/enable | /disableBulk 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, and template; event is required when on: history.
  • critical priority 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 throttle for recurring reminders (24h) and none for 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

MethodPathPurpose
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+input for create, input for update, transition for transition, destination+format for archive.
  • SHOULD use skipIf to prevent duplicate actions and a tag on 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

MethodPathPurpose
GET/api/v1/{domain}/schedulesList 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}/runRun now (admin)
POST/api/v1/{domain}/schedules/{name}/enable | /disableToggle 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 a name and a routing then (or decide/parallel/timeout path) terminating at end.
  • MUST include a default in decide (or no match fails the instance) and a wait.timeout for any unbounded wait.
  • Wait steps are persisted and survive restart; parallel.all joins all branches, parallel.any advances on first and cancels the rest.
  • AVOID relying on onError for cancellation — POST .../cancel does NOT fire onError.
  • Instance status: running, waiting, completed, failed, cancelled, timed_out. Each step emits a history event on the instance and on modified resources.

Endpoints

MethodPathPurpose
GET/api/v1/{domain}/workflowsList 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}/cancelCancel (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 use name + computed, field columns use field.
  • SHOULD define views once and reuse them in report table sections and dashboard table widgets.
  • SHOULD set refresh on dashboards; the runtime caches the result and reports computedAt.
  • AVOID client-side business logic — the API response carries values, formats, thresholds, and colors.

Endpoints

MethodPathPurpose
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.generate and permissions.view; manual triggers MUST declare parameters.
  • MUST use single: true on 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; layout is ignored for html/markdown.

Endpoints

MethodPathPurpose
GET/api/v1/{domain}/templatesList template definitions
GET/api/v1/{domain}/templates/{name}Get one definition
POST/api/v1/{domain}/templates/{name}/generateManual 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}/metadataMetadata 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 schemaChange history event.
  • SHOULD prefer the ALTER TABLE storage 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)

MethodPathPurpose
GET/api/v1/{domain}/{resource}/columnsList fixed + dynamic + computed columns
POST/api/v1/{domain}/{resource}/columnsAdd 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}/historyCell-level change history

Part IV — Platform Services & Operations

29. Authentication & Identity

Built-in authd plugin 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-password regardless of email existence (prevents enumeration).
  • AVOID /api/v1/auth/users for admin user management — use /api/v1/admin/.... Reactivation is unsuspend, not activate.

Endpoints

MethodPathPurpose
POST/api/v1/auth/loginLogin (username or email); returns token pair + user
POST/api/v1/auth/registerCreate user (active, verified:false)
POST/api/v1/auth/refreshRotate token pair
GET/api/v1/auth/meCurrent user profile
POST/api/v1/auth/logout / logout-allRevoke current / all sessions
POST/api/v1/auth/change-passwordChange password (invalidates other sessions)
POST/api/v1/auth/forgot-password / reset-passwordSingle-use reset token flow
POST/api/v1/auth/verify-email / resend-verification / change-emailEmail verification flow
DELETE/api/v1/auth/accountSelf-service account deletion
GET/api/v1/admin/users / /api/v1/admin/users/:idList / get users (admin)
PUT/api/v1/admin/users/:id/rolesSet roles
POST/api/v1/admin/users/:id/verify / suspend / unsuspendAdmin user actions
GET/POST/api/v1/admin/rolesList / 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 500 MIGRATION_ERROR.
  • MUST refuse type narrowing and required-without-default with HTTP 422 DESTRUCTIVE_MIGRATION unless allow_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 renamedFrom to express renames; otherwise the differ sees an add + remove pair.
  • AVOID relying on DROP COLUMN on 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). MigrationStatussuccess / 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

MethodPathPurpose
POST/api/v1/domainsSubmit/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 kalos module.

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 from hooks/.
  • 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 use kalos.call to 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 with scripting.lua.allow_raw_sql).
  • MUST return the (false, code, error_code, message) tuple to abort — only before* hooks can abort.
  • SHOULD check resp.status after every kalos.call.
  • AVOID os.execute, io.*, require, loadfile/dofile, debug.* — all blocked by the sandbox (math, string, table, coroutine, and safe os.time/clock/date are 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

PhaseAction(s)Idempotency
1 Rolesadmin.roles.create409 if exists → skipped (admin always pre-exists)
2 Usersauth.register + admin.users.verify409 on dup username/email → skipped; Argon2 hash in authd (~250-300ms)
3 Assignmentsadmin.users.set_rolesReplaces role set; re-run is a no-op; invalidates RBAC cache
4 Datathe named action, ctx from as: userDeterministic PKs + allowUserProvided: true prevent duplicates

Rules

  • MUST order plugins with seeder last so domains and authd actions are registered first.
  • MUST set allowUserProvided: true on 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: true so subject-rights APIs can resolve records.
  • MUST return 409 from erasure when legal-hold blockers exist and dryRun: false.
  • SHOULD use dryRun: true to preview affected resources/field counts before erasing.
  • SHOULD encrypt and access-restrict the {domain}_pseudonym_map table (enables lawful re-identification).
  • AVOID withdrawing or erasing data under required: true consent or active legal holds; $preserve keeps fields needed for research/integrity.

Endpoints

MethodPathPurpose
GET/api/v1/{domain}/privacy/subject/{id}Right of access (Art. 15)
GET/api/v1/{domain}/privacy/subject/{id}/exportPortability (Art. 20)
POST/api/v1/{domain}/privacy/subject/{id}/eraseErasure (Art. 17)
PATCH/api/v1/{domain}/privacy/subject/{id}/rectifyRectification (Art. 16)
POST/api/v1/{domain}/privacy/subject/{id}/restrict · /unrestrictRestrict processing (Art. 18)
POST/api/v1/{domain}/privacy/subject/{id}/consent/grant · /withdrawConsent management
POST/api/v1/{domain}/privacy/legal-holdPlace legal hold
GET/api/v1/{domain}/privacy/data-mapArt. 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 through fallbackChain to defaultLocale.
  • MUST persist history label, label_template, and label_data at 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

MethodPathPurpose
GET/api/v1/domains/{domain}/schema (Accept-Language)Localized schema/labels/enums
GET/api/v1/{domain}/localization/coveragePer-locale translation completeness
GET/api/v1/{domain}/localization/missing?locale=esUntranslated keys
GET/api/v1/{domain}/localization/export?locale=es&format=xliff|csv|yamlExport for translation
POST/api/v1/{domain}/localization/importImport 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_signature over the raw body, or ip_whitelist); mismatches are rejected.
  • SHOULD enable replayProtection (timestamp maxAge + 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 retry with circuitBreaker and deadLetter; failed messages land in the DLQ for manual retry.

Endpoints

MethodPathPurpose
GET/api/v1/{domain}/integrationsList integrations
GET/api/v1/{domain}/integrations/{name}/status · /healthCircuit/rate/error metrics
POST/api/v1/{domain}/integrations/{name}/operations/{op}Manual trigger (dryRun supported)
GET/api/v1/{domain}/integrations/{name}/deadletterList failed messages
POST/api/v1/{domain}/integrations/{name}/deadletter/{id}/retryRetry 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 (or null on 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" in info and a single bearerAuth security scheme.
  • MUST generate the error response schema with properties status, code, data, error_code, error_message (the ValidationError response is code: 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

MethodPathPurpose
GET/healthLiveness (public, no envelope)
GET/versionPlatform version 0.3.1 (public, no envelope)
GET/api/v1/statusUptime, memory, domain/route counts (public)
GET/api/v1/pluginsLoaded plugins (public)
GET/api/v1/routesRegistered routes (public)
GET/api/v1/domainsList domains (public)
GET/api/v1/domains/{name}Domain detail + migrations (public)
GET/api/v1/domains/{domain}/schemaFull resource schema for introspection (public)
GET/api/v1/domains/{domain}/openapi.jsonPer-domain OpenAPI spec (public)
POST/api/v1/domainsSubmit domain YAML/JSON, run migration (admin)
GET/api/v1/admin/usersList users (admin)
GET/api/v1/admin/users/{username}Get user (admin)
POST/api/v1/admin/users/{username}/rolesSet roles (admin)
POST/api/v1/admin/users/{username}/verifyMark verified (admin)
POST/api/v1/admin/users/{username}/suspendSuspend user (admin)
POST/api/v1/admin/users/{username}/unsuspendReverse suspension (admin)
GET/api/v1/admin/rolesList roles (admin)
POST/api/v1/admin/rolesCreate role (admin)
GET/api/v1/admin/auditQuery 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 admin role for all /api/v1/admin/... routes and domain submission.
  • MUST set allow_destructive: true to apply destructive migrations; otherwise the submission is refused with 422 DESTRUCTIVE_MIGRATION.
  • SHOULD treat suspended users as 403 USER_SUSPENDED on 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 (with data.retryAfter + Retry-After header)
  • 500: INTERNAL_ERROR, DATABASE_ERROR, INTEGRATION_ERROR

Rules

  • MUST use exactly the five envelope keys status, code, data, error_code, error_message; status is always "error".
  • MUST use VALIDATION_ERROR @ 422 (never VALIDATION_FAILED).
  • MUST key validation data by field name (or rule name), each { code, message }.
  • MUST NOT emit top-level error/message or a details field.
  • 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

MethodPathPurpose
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}/countCount (accepts list filters)
GET/api/v1/{domain}/{resources}/distinct/{field}Distinct values
POST/api/v1/{domain}/{resources}/bulkBulk create
PATCH/api/v1/{domain}/{resources}/bulkBulk update (ids + data)
DELETE/api/v1/{domain}/{resources}/bulkBulk 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}/restoreRestore soft-deleted
GET/api/v1/{domain}/{resources}/{id}/auditPer-record audit trail
GET/api/v1/{domain}/{resources}/{id}/historyPer-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.name is lowercase and not reserved (auth, users, admin, domains, status, plugins, routes, version, openapi, echo).
  • domain.version is the domain’s own semver (e.g. "1.0.0") — not "3.0.0" and not the platform version.
  • Every resource has exactly one primaryKey field (uuid recommended).
  • Every field type is in the supported set (§7).
  • Every foreign key has an index (§19) and is immutable: true.
  • Relationships are mirrored (hasManybelongsTo; belongsToMany has through + otherKey).
  • Enum fields and their default use 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, code VALIDATION_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 validation rules are null-safe (field == null || …).
  • Seed defines roles → users → assignments → data, in that order; demo passwords Admin123! / Demo123!; users verified: true.
  • Config adl.domain_file points 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 an X-Allow-Destructive header).