ADL v0.3 · Guide

LLM Authoring Spec

Status: Normative · Platform version: 0.3.1 · Display label: ADL v0.3 Audience: An LLM (or developer) generating ADL domains, configs, seeds, and verification flows for the Kalos application server.

This document is the single source of truth for generation tasks. It is derived from the shipped Kalos runtime (core/, plugins/adl/, plugins/authd/, plugins/seeder/), not from prose intent. Where this document and the 41-chapter specification ever disagree, the runtime wins and this document tracks the runtime.

Convention used throughout:

  • MUST — required for a valid, loadable ADL document. Violations are rejected by the parser (HTTP 422) or fail at runtime.
  • SHOULD — strongly preferred convention. Generators should follow it unless there is a concrete reason not to.
  • EXAMPLE — copyable, known-good pattern.
  • AVOID — invalid or actively discouraged; do not emit.

1. The contract in one screen

These are the decisions most often gotten wrong. Memorize them.

ConcernCanonical rule
Resource route prefix/api/v1/{domain}/{resource} — there is no /adl/ segment.
Domain management/api/v1/domains (list/submit), /api/v1/domains/{name}/{schema|source|openapi.json|preview|export}.
Auth (self-service)/api/v1/auth/... and /api/v1/users/....
Admin (user/role mgmt)/api/v1/admin/.... User management is never under /api/v1/auth/users.
Success envelope{ "status": "ok", "code": <int>, "data": <payload> }.
Error envelope{ "status": "error", "code": <int>, "data": {…}, "error_code": "UPPER_SNAKE", "error_message": "…" }.
Validation error codeVALIDATION_ERROR at HTTP 422. (Never VALIDATION_FAILED — that string is reserved for response-validation internals: RESPONSE_VALIDATION_FAILED.)
Public access symbol* = public/anonymous (no auth).
Any-logged-in symbolauthenticated.
? permission wildcardUnsupported. The parser rejects it. Never emit ?.
Version stringsPlatform/release is 0.3.1. Never emit "3.0.0". A domain’s own version: is an independent semver chosen by the author (e.g. "1.0.0").
Reserved domain namesauth, users, admin, domains, status, plugins, routes, version, openapi, echo (case-insensitive).
Action bus id formatadl.{domain}.{resource}.{op} (e.g. adl.blog.post.create); domain ops are adl.domain.*.

2. Document structure (MUST)

An ADL document is a single YAML (or JSON) file with a top-level domain: key.

domain:
  name: <identifier>          # MUST. Lowercase, not a reserved name (§1). Becomes the route prefix.
  version: "1.0.0"            # MUST. The DOMAIN's own semver — author's choice, NOT the platform version.
  description: >              # SHOULD.
    Human-readable summary.

  api:
    prefix: /api/v1/<name>    # SHOULD omit — defaults to /api/v1/{name}. Only set to override.

  objects:    { ... }         # OPTIONAL. Reusable field bundles for $merge.
  enums:      { ... }         # OPTIONAL. Reusable enumerations for $ref.
  properties: { ... }         # OPTIONAL. Reusable single-field definitions for $ref.

  resources:                  # MUST. At least one resource.
    <ResourceName>: { ... }
  • domain.name MUST NOT be a reserved name (§1). The check is case-insensitive.
  • domain.api.prefix SHOULD be omitted. If omitted, the runtime uses /api/v1/{name}. Only set it to deviate (rare).
  • Resource names SHOULD be PascalCase singular (Post, Comment, OrderLine). Generated route segments are derived from the resource (e.g. posts).

3. Type system (MUST use a supported type)

Every field’s type MUST be one of these (aliases shown after /):

Core: string, text, integer/int, decimal/number, float/double, boolean/bool, date, datetime, time, timestamp, uuid, json.

Semantic: email, url, slug, markdown/md, phone, password, token, autoincrement/auto_increment, array, set.

Domain/locale: money, currency, percentage/percent, country, language/lang, timezone/tz, color/colour, bytes/filesize.

Spatial (v0.3.1): geometry/geojson — a GeoJSON geometry value. Optional geometryType (point/linestring/polygon/multipoint/multilinestring/multipolygon/geometrycollection) and srid (default 4326). Accepted as an object or JSON string, emitted as an object; validated as well-formed GeoJSON. Stored as JSON everywhere; on PostgreSQL+PostGIS it also gets a GiST-indexed geometry column enabling spatial filters. See Chapter 41.

geom: { type: geometry, required: true, geometryType: point, srid: 4326 }   # EXAMPLE

A bare string value is shorthand for { type: <value> }:

title: string            # EXAMPLE — shorthand, equivalent to: title: { type: string }

Field attributes (all OPTIONAL except type)

AttributeApplies toMeaning
typeallMUST. See above.
primaryKey: trueone fieldMarks the primary key. Every resource SHOULD have exactly one (uuid recommended).
required: trueanyField must be present on create.
unique: trueanyEnforced unique.
immutable: trueanyCannot change after create (e.g. foreign keys, authorId).
default: <v>anyDefault value when omitted. For enums, use a valid enum key.
minLength / maxLengthstring-likeLength bounds.
min / maxnumericValue bounds.
pattern: '<regex>'string-likeRegex constraint.
autoNow: createdatetimeSet on create; never modified after.
autoNow: updatedatetimeSet on every update; not set on create.
autoNow: alwaysdatetimeSet on create and on every update.
from: <field>slugDerive slug from another field.
descriptionanyDocumentation; surfaced in schema/OpenAPI.
$refanyReference a shared property/enum (§5).

AVOID: unknown type names; multiple primaryKey: true fields; numeric bounds on string fields.


4. Resources (MUST)

resources:
  Post:
    description: >                 # SHOULD
      ...
    object:                        # MUST — the field set.
      id:    { type: uuid, primaryKey: true }
      title: { type: string, required: true, minLength: 3, maxLength: 200 }
      status: { $ref: '#/enums/PostStatus', default: draft }
      $merge:                      # OPTIONAL composition (§5)
        - { $ref: '#/objects/Timestamp' }

    relationships: { ... }         # OPTIONAL (§6)
    indexes: [ ... ]               # OPTIONAL but MUST index every foreign key (§7)
    features: { ... }              # OPTIONAL (§8)
    audit: { ... }                 # OPTIONAL — only meaningful if features.audit: true
    validation: { ... }            # OPTIONAL (§9)
    permissions: { ... }           # SHOULD always declare explicitly (§10)
  • Each resource MUST have an object: block with at least one field.
  • Each resource SHOULD have a primaryKey field — id: { type: uuid, primaryKey: true } is the default convention.

5. Composition: objects, enums, properties, $merge, $ref (OPTIONAL)

Define reusable fragments at domain level, reference them inside resources.

objects:                                 # bundles of fields, mixed in via $merge
  Timestamp:
    createdAt: { type: datetime, autoNow: create }
    updatedAt: { type: datetime, autoNow: always }
  SoftDelete:
    deletedAt: { type: datetime }

enums:                                    # referenced via $ref under a field
  PostStatus:
    values:
      draft:     { label: "Draft" }
      review:    { label: "In Review" }
      published: { label: "Published" }
      archived:  { label: "Archived", final: true }   # final: terminal state

properties:                               # single reusable field definitions
  slug: { type: slug, unique: true, maxLength: 255 }

Usage inside a resource’s object::

object:
  slug:   { $ref: '#/properties/slug', from: title }   # $ref a property, add/override attrs
  status: { $ref: '#/enums/PostStatus', default: draft } # $ref an enum
  $merge:                                                # mix in shared objects
    - { $ref: '#/objects/Timestamp' }
    - { $ref: '#/objects/SoftDelete' }
  • $ref paths are JSON-pointer style: #/objects/X, #/enums/X, #/properties/X.
  • Enum field values MUST be one of the enum keys; default MUST be a valid key.

6. Relationships (OPTIONAL)

relationships:
  comments:                       # hasMany — the "one" side
    type: hasMany
    target: Comment
    foreignKey: postId            # FK lives on the target (Comment.postId)
    onDelete: cascade             # cascade | setNull | restrict

  post:                           # belongsTo — the "many" side
    type: belongsTo
    target: Post
    foreignKey: postId            # FK lives on this resource

  tags:                           # belongsToMany — via junction
    type: belongsToMany
    target: Tag
    through: PostTag              # junction resource/table
    foreignKey: postId
    otherKey: tagId
  • Supported type: hasMany, belongsTo, belongsToMany.
  • Relationships MUST be mirrored: if Post hasMany Comment, then Comment belongsTo Post.
  • The foreignKey field MUST exist on the resource that physically holds it, and SHOULD be immutable: true.
  • Related data is fetched with ?include=<relationshipName>.

7. Indexes (MUST index every foreign key)

indexes:
  - fields: [status]
    description: "Filter posts by status"
  - fields: [status, publishedAt]      # composite index
    description: "Published posts by date"
  • Every foreign-key field MUST have an index.
  • Fields commonly used in filter/sort SHOULD be indexed.

8. Resource features (OPTIONAL)

features:
  softDelete:   true     # adds deletedAt semantics; rows hidden from default list scope
  audit:        true     # records change history (pair with an `audit:` block)
  search:       true     # enables ?search=
  versioning:   false
  export:       false
  import:       false
  caching:      false
  transactions: true     # default true

audit: block (only meaningful when features.audit: true):

audit:
  track:   [title, status, body]    # fields whose changes are recorded
  exclude: [updatedAt, deletedAt]   # fields explicitly not recorded

9. Validation rules (OPTIONAL)

Cross-field rules expressed as boolean expressions (AEL):

validation:
  publishedAtRequiredWhenPublished:
    rule: "status != 'published' || publishedAt != null"
    message: "A publish date is required when setting status to published"
  • A failing rule yields error_code VALIDATION_ERROR at HTTP 422 (§11).

10. Permissions (SHOULD always declare)

permissions:
  ownerField: authorId         # which field identifies the owner (enables `owner`)
  list:   ["*"]                # operation-level
  get:    ["*"]
  create: [authenticated]
  update: [owner, editor, admin]
  delete: [admin]

  fields:                      # field-level read/write overrides
    status:
      read:  ["*"]
      write: [editor, admin]

  transitions: { ... }         # state-machine transition guards (see Ch. 12)

Operation keys

list, get, create, update, delete. The alias read expands to both list and get. Field-level blocks use read / write.

Permission symbols (MUST use only these forms)

SymbolMeaning
*Public / anonymous — no authentication required.
authenticatedAny valid logged-in user.
ownerThe user matching ownerField on the record.
adminUsers with the admin role.
<custom>Any other string = a custom role name; the user’s roles must include it.
  • AVOID ? — it is not supported and the parser rejects it. Use * for public access.
  • If permissions: is omitted entirely, the default is [admin] for all operations. Any omitted single operation also defaults to [admin].
  • Permission values are arrays: create: [authenticated], list: ["*"].

11. Response & error envelopes (the runtime contract)

Success:

{ "status": "ok", "code": 200, "data": { ... } }

List payloads place rows under data.items with a count (data.totalCount / data.count / data.meta.total).

Error (canonical — exact keys):

{
  "status": "error",
  "code": 422,
  "data": { "fields": [ { "field": "title", "code": "required", "message": "Title is required" } ] },
  "error_code": "VALIDATION_ERROR",
  "error_message": "Validation failed"
}
  • The only top-level keys are status, code, data, error_code, error_message.
  • There is no top-level error or message, and no details key. Per-field detail goes in data.
  • error_code is UPPER_SNAKE_CASE. Request/field validation is VALIDATION_ERROR at HTTP 422.

12. Configuration file (config-*.json) (MUST to run)

A runnable POC needs a config JSON that points at the ADL file, a store, and the plugin set.

{
  "transport": { "rest": { "host": "127.0.0.1", "port": 8081 } },
  "auth": { "jwt_secret": "CHANGE-ME", "token_expiry_minutes": 60, "refresh_expiry_days": 7 },
  "authd": { "store": "default", "password": { "disable_checking": false } },
  "store": { "sqlite": { "path": "./dbs/auth.db", "name": "default", "pool_size": 4 } },
  "adl": {
    "db_path":     "./dbs/adl.db",
    "app_db_path": "./dbs/app.db",
    "domain_file": "./adl-<name>.yaml"
  },
  "seeder": { "enabled": true, "run_on_start": true, "seed_file": "./seeds/seed-<name>.yaml" },
  "core": {
    "plugin_dirs": ["./build/bin/Debug/plugins", "./build/bin/Release/plugins"],
    "plugins": [
      { "name": "transport.rest", "path": "kalos_rest.dll",    "enabled": true },
      { "name": "store.sqlite",   "path": "kalos_sqlite.dll",  "enabled": true },
      { "name": "handler.authd",  "path": "kalos_authd.dll",   "enabled": true },
      { "name": "adl",            "path": "kalos_adl.dll",     "enabled": true },
      { "name": "seeder",         "path": "kalos_seeder.dll",  "enabled": true },
      { "name": "scripting.lua",  "path": "kalos_lua.dll",     "enabled": true },
      { "name": "codegen",        "path": "kalos_codegen.dll", "enabled": true }
    ]
  },
  "rate_limit": { "enabled": true, "max_requests": 60, "window_seconds": 60 }
}
  • adl.domain_file MUST point at the ADL YAML. If omitted, the domain must be submitted at runtime via POST /api/v1/domains.
  • Plugin filenames are .dll on Windows and .so/.dylib on Linux/macOS — match your platform.
  • For Postgres, replace the store.sqlite block with the Postgres equivalent and adjust adl.* accordingly.

13. Seed manifest (seeds/seed-*.yaml) (SHOULD)

The seeder runs in four ordered phases. Roles and users MUST load before any data that depends on them.

roles:                                  # Phase 1
  - { name: admin,  description: "Full access" }
  - { name: editor, description: "Can publish" }

users:                                  # Phase 2
  - { username: admin, email: admin@example.com, password: "Admin123!", verified: true }
  - { username: alice, email: alice@example.com, password: "Demo123!",  verified: true }

assignments:                            # Phase 3
  - { username: admin, roles: [admin] }
  - { username: alice, roles: [editor] }

data:                                   # Phase 4 — dispatched as action-bus calls
  - action: adl.blog.tag.create         # adl.{domain}.{resource}.{op}
    data: { name: "tech", color: "#3b82f6" }
  - action: adl.blog.post.create
    as: alice                           # dispatch as this user (for owner/role-gated creates)
    data: { title: "Hello", slug: "hello", status: "published", authorId: "alice", publishedAt: "2026-02-10T09:00:00Z" }
  • Convention: admin user password Admin123!, demo users Demo123!, all verified: true.
  • Seeds SHOULD be idempotent — the seeder skips records whose unique identity field already exists.
  • M2M links and records whose foreign keys are server-generated UUIDs generally cannot be seeded (the UUIDs are unknown at authoring time); wire those up via the API after start.

14. Endpoints you get for free

For a domain blog with resource Post (→ segment posts):

MethodPathPurpose
GET/api/v1/blog/postsList (supports filter[field]=, sort=-field, page=&limit=, search=, include=; on PostGIS, spatial filter[geom][bbox|intersects|within]=… and ?simplify=<tol> — Chapter 41).
GET/api/v1/blog/posts/{id}Get one.
POST/api/v1/blog/postsCreate (auth per permissions).
PATCH/api/v1/blog/posts/{id}Update.
DELETE/api/v1/blog/posts/{id}Delete (soft if features.softDelete).

Domain management & introspection:

MethodPathPurpose
GET/POST/api/v1/domainsList / submit domains.
GET/api/v1/domains/{name}Domain status.
GET/api/v1/domains/{name}/schemaSchema introspection (JSON).
GET/api/v1/domains/{name}/openapi.jsonPer-domain OpenAPI 3.1 spec.
GET/api/v1/domains/{name}/sourceOriginal ADL source.
POST/api/v1/domains/{name}/previewMigration preview.
GET/api/v1/domains/{name}/exportExport.

Identity (always available via authd):

  • Self-service: POST /api/v1/auth/{register,login,refresh,logout,logout-all,password-change,verify-email,resend-verification,forgot-password,reset-password}, GET /api/v1/auth/me, and /api/v1/users/{profile,me,sessions,preferences}.
  • Admin: GET /api/v1/admin/users, GET /api/v1/admin/users/{username}, GET|PUT /api/v1/admin/users/{username}/roles, POST /api/v1/admin/users/{username}/{suspend,unsuspend,verify}, GET /api/v1/admin/audit, GET|POST /api/v1/admin/roles, PUT /api/v1/admin/roles/{name}/permissions, GET /api/v1/admin/permissions.

15. Verification flow (curl)

BASE=http://127.0.0.1:8081
API=$BASE/api/v1/blog
AUTH=$BASE/api/v1/auth

# 1. Log in, capture the JWT from data.access_token
TOKEN=$(curl -s -X POST "$AUTH/login" \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"Demo123!"}' | jq -r '.data.access_token')

# 2. Public list (no auth)
curl -s "$API/posts" | jq '.data.items | length'

# 3. Query: filter + sort + paginate
curl -s "$API/posts?filter[status]=published&sort=-publishedAt&page=1&limit=2" | jq '.data.items'

# 4. Authenticated create
curl -s -X POST "$API/posts" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"New Post","slug":"new-post","authorId":"alice","authorName":"Alice"}' | jq '.data'

# 5. Expected validation error shape (note canonical keys)
#   { "status":"error","code":422,"data":{...},"error_code":"VALIDATION_ERROR","error_message":"..." }

16. Generation checklist (run before emitting)

  • domain.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 list (§3).
  • Every foreign key has an index 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; symbols are only *, authenticated, owner, admin, or custom role names.
  • No ? anywhere in permissions.
  • Routes referenced in docs/examples use /api/v1/{domain}/... (resources) and /api/v1/domains/... (management) — never /api/v1/adl/....
  • Error examples use the canonical envelope (data / error_code / error_message, code VALIDATION_ERROR @ 422).
  • 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.

17. Never do this (AVOID)

  • /api/v1/adl/{domain}/... — the adl/ segment does not exist.
  • permissions: { create: ["?"] }? is rejected by the parser.
  • "version": "3.0.0" or x-adl-version: "3.0.0" — the platform version is 0.3.1.
  • ❌ Error bodies with top-level error/message or a details key — use error_code/error_message/data.
  • error_code: "VALIDATION_FAILED" for request validation — it is VALIDATION_ERROR.
  • ❌ Admin user management under /api/v1/auth/users — it lives under /api/v1/admin/users.
  • ❌ A domain named users, admin, auth, openapi, etc. (reserved).
  • ❌ A foreign key without an index, or an unmirrored relationship.
  • ❌ Unknown field types, or numeric bounds (min/max) on string fields.