4.1 Route Registration and Dispatch
The REST transport plugin maps HTTP requests to bus actions. Routes are registered during the on_start() phase by other plugins calling ICore::register_route(method, path, action).
URL Structure
ADL domain resource routes follow the pattern:
/api/v1/{domain}/{resources}
Resource names are automatically pluralized and kebab-cased from the YAML resource name. Examples:
| YAML Resource | Generated Path |
|---|---|
Post | /api/v1/blog/posts |
OrderItem | /api/v1/orders/order-items |
Category | /api/v1/cms/categories |
SiteSettings | /api/v1/cms/site-settings |
Auth endpoints use the prefix configured in authd.prefix (default /api/v1):
/api/v1/auth/login
/api/v1/auth/register
/api/v1/auth/me
Infrastructure endpoints use fixed paths:
/health
/version
/api/v1/status
/api/v1/plugins
/api/v1/routes
/api/v1/openapi.json
Route Matching
When an HTTP request arrives, the transport matches it against the registered route table using the HTTP method and the URL path. Path parameters (:id, :orderId, :productId) are extracted and placed in Request.params.
If no route matches, the transport returns HTTP 404 with:
{
"status": "error",
"code": 404,
"error_code": "NOT_FOUND",
"error_message": "Route not found"
}
Complete Route Patterns
Given a domain d with resource R (pluralized as rs), the following routes are generated:
Core CRUD:
| Method | Path | Action | Description |
|---|---|---|---|
GET | /api/v1/d/rs | adl.d.r.list | List records |
GET | /api/v1/d/rs/:id | adl.d.r.get | Get record by ID |
POST | /api/v1/d/rs | adl.d.r.create | Create record |
PATCH | /api/v1/d/rs/:id | adl.d.r.update | Partial update |
PUT | /api/v1/d/rs/:id | adl.d.r.put | Full replace |
DELETE | /api/v1/d/rs/:id | adl.d.r.delete | Delete record |
GET | /api/v1/d/rs/count | adl.d.r.count | Count records |
Feature-conditional routes:
| Feature | Method | Path | Action |
|---|---|---|---|
softDelete: true | POST | /api/v1/d/rs/:id/restore | adl.d.r.restore |
audit: true | GET | /api/v1/d/rs/:id/audit | adl.d.r.audit |
audit: true | GET | /api/v1/d/rs/audit | adl.d.r.audit |
versioning: true | GET | /api/v1/d/rs/:id/versions | adl.d.r.versions |
versioning: true | POST | /api/v1/d/rs/:id/revert | adl.d.r.revert |
export: true | GET | /api/v1/d/rs/export | adl.d.r.export |
import: true | POST | /api/v1/d/rs/import | adl.d.r.import |
State machine transitions:
| Method | Path | Action |
|---|---|---|
POST | /api/v1/d/rs/:id/transitions/{name} | adl.d.r.{name} |
Relationship routes (hasMany):
| Method | Path | Action |
|---|---|---|
GET | /api/v1/d/rs/:id/{related} | adl.d.r.{related}.list |
Relationship routes (belongsToMany):
| Method | Path | Action |
|---|---|---|
GET | /api/v1/d/rs/:id/{related} | adl.d.r.{related}.list |
POST | /api/v1/d/rs/:id/{related}/link | adl.d.r.{related}.link |
DELETE | /api/v1/d/rs/:id/{related}/unlink | adl.d.r.{related}.unlink |
Custom operations:
| Method | Path | Action |
|---|---|---|
| per YAML | /api/v1/d/rs/:id/{operation} | adl.d.r.{operation} |
History [v0.3]:
| Method | Path | Action |
|---|---|---|
GET | /api/v1/d/rs/:id/history | adl.d.r.history |
GET | /api/v1/d/rs/history | adl.d.r.history.list |
4.2 Middleware Chain
Every external HTTP request passes through a middleware chain before reaching the action handler. Middleware executes in fixed order. If any middleware rejects the request, execution stops and the rejection response is returned to the client.
Request → Rate Limit → Request Validation → Endpoint Enabled
→ JWT Auth → RBAC → Audit → Action Handler → Response
4.2.1 Rate Limiting
The first middleware checks whether the client has exceeded the configured request rate. Clients are identified by IP address. If the rate limit is exceeded, the middleware returns HTTP 429 (see §3.8 for configuration, §4.6.4 for response format).
Rate limiting is skipped when rate_limit.enabled is false in the configuration.
4.2.2 Request Validation
The transport validates that the request body is well-formed JSON (for requests that include a body). Malformed JSON results in HTTP 400:
{
"status": "error",
"code": 400,
"error_code": "BAD_REQUEST",
"error_message": "Invalid JSON in request body"
}
4.2.3 Endpoint Enabled
The transport checks whether the target operation is enabled for the resource. Operations can be disabled in the domain YAML (see §17.2). A disabled operation returns HTTP 404 as if the route does not exist.
4.2.4 JWT Authentication
For routes that require authentication, the transport extracts the JWT from the Authorization header:
Authorization: Bearer <access_token>
The transport validates the token signature, expiration, and issuer. If the token is valid, the user identity (user ID, username, roles) is extracted from the claims and placed in Request.identity.
If the Authorization header is missing on a route that requires authentication, the transport returns HTTP 401:
{
"status": "error",
"code": 401,
"error_code": "UNAUTHORIZED",
"error_message": "Authentication required"
}
If the token is present but invalid (expired, malformed, bad signature), the transport returns HTTP 401:
{
"status": "error",
"code": 401,
"error_code": "TOKEN_INVALID",
"error_message": "Invalid or expired token"
}
Routes that allow public access (permissions: ["*"]) skip JWT validation. The * wildcard allows public/anonymous access (no authentication required). The authenticated value allows any valid logged-in user.
4.2.5 RBAC Authorization
After authentication, the transport checks whether the authenticated user has permission to perform the requested operation on the target resource.
RBAC operates at two levels:
Operation-level: Does the user’s role appear in the resource’s permission list for this operation? If not, HTTP 403:
{
"status": "error",
"code": 403,
"error_code": "FORBIDDEN",
"error_message": "Insufficient permissions"
}
Field-level: On create and update operations, do any of the submitted fields have write restrictions that exclude the user’s role? If so, the restricted fields are silently ignored unless the field is required, in which case HTTP 403:
{
"status": "error",
"code": 403,
"error_code": "FIELD_FORBIDDEN",
"error_message": "Field 'status' is not writable by role 'author'"
}
See §11 for the full permission model.
4.2.6 Audit Logging
If audit is enabled (configuration or domain-level), the middleware records the request metadata (user, action, timestamp, IP address, status code) to the audit log. Audit logging occurs after the action handler completes, so the response status code is captured.
4.2.7 Response Validation (Development Mode)
In development mode, the transport validates that the action handler’s response conforms to the endpoint’s declared schema. This is a developer aid and SHOULD be disabled in production for performance.
4.3 Request/Response Envelope
All ADL API responses use a standard JSON envelope.
Success Responses
200 OK — Successful retrieval or update:
{
"status": "ok",
"code": 200,
"data": { ... }
}
201 Created — Successful resource creation:
{
"status": "ok",
"code": 201,
"data": { ... }
}
204 No Content — Successful deletion. Empty body, no JSON.
Error Responses
{
"status": "error",
"code": 422,
"error_code": "VALIDATION_ERROR",
"error_message": "Validation failed",
"errors": [
{
"field": "email",
"message": "Invalid email format",
"rule": "type"
},
{
"field": "title",
"message": "Title is required",
"rule": "required"
}
]
}
Direct Responses (No Envelope)
The following endpoints return raw JSON without the envelope wrapper:
| Endpoint | Response |
|---|---|
GET /health | { "status": "healthy" } |
GET /version | { "version": "0.3.1", "build": "..." } |
List Response Shape
All list endpoints return a data object containing items, count, and optionally totalCount:
{
"status": "ok",
"code": 200,
"data": {
"items": [ ... ],
"count": 10,
"totalCount": 42
}
}
| Field | Type | Description |
|---|---|---|
items | array | Array of resource records for the current page |
count | integer | Number of items in the current response |
totalCount | integer | Total number of matching records across all pages (included when page parameter is used) |
4.4 Content-Type Handling
Request Content-Type
All requests with a body MUST include the header:
Content-Type: application/json
The exception is media upload (§18.1), which uses:
Content-Type: multipart/form-data
Requests with an unrecognized Content-Type receive HTTP 415:
{
"status": "error",
"code": 415,
"error_code": "UNSUPPORTED_MEDIA_TYPE",
"error_message": "Content-Type must be application/json"
}
Response Content-Type
All responses return Content-Type: application/json, except:
- 204 No Content — no
Content-Typeheader (no body) - CSV export —
Content-Type: text/csv - Media download — the MIME type of the stored media file
4.5 HTTP Method Semantics
GET
Retrieve a resource or list of resources. GET requests MUST NOT have side effects. GET requests MUST NOT include a request body.
POST
Create a new resource, execute a state machine transition, link a M2M relationship, trigger a restore, execute a custom operation, or submit a domain. The request body contains the data for the operation.
POST is not idempotent. Repeating a POST request may create duplicate resources (unless prevented by unique constraints, which return 409).
PATCH
Partial update of an existing resource. The request body contains only the fields to update. Fields not included in the body are left unchanged. PATCH is idempotent — repeating the same PATCH produces the same result.
// PATCH /api/v1/blog/posts/:id
{
"title": "Updated Title"
}
// Only title is changed. body, status, authorId, etc. are unchanged.
PUT
Full replacement of an existing resource. The request body MUST contain all required fields. Fields not included in the body are set to their default values or null. PUT is idempotent.
// PUT /api/v1/blog/posts/:id
{
"title": "Replaced Post",
"body": "New content",
"status": "draft",
"authorId": "alice"
}
// All fields are set to the values in the body.
// Fields not included revert to defaults or null.
DELETE
Delete a resource. For resources with softDelete: true, this sets the deletedAt timestamp and returns HTTP 204. For resources without soft delete, this permanently removes the record and returns HTTP 204.
DELETE is idempotent. Deleting an already-deleted resource returns HTTP 204 (not 404).
4.6 Error Taxonomy
The transport uses the following HTTP status codes. Each code has a specific meaning that clients can rely on.
4.6.1 Client Errors
| Code | Error Code | Meaning | When |
|---|---|---|---|
| 400 | BAD_REQUEST | Malformed request | Invalid JSON, missing required header |
| 401 | UNAUTHORIZED | Authentication required or failed | Missing token, expired token, invalid token |
| 403 | FORBIDDEN | Insufficient permissions | Role not in operation permissions, field-level write restriction |
| 404 | NOT_FOUND | Resource or route not found | Unknown route, record does not exist, disabled operation |
| 409 | DUPLICATE | Uniqueness constraint violation | Duplicate unique field value, domain already exists |
| 415 | UNSUPPORTED_MEDIA_TYPE | Wrong Content-Type | Not application/json on JSON endpoints |
| 422 | VALIDATION_ERROR | Validation failed | Type violation, constraint violation, cross-field rule failure, state machine guard failure |
| 429 | RATE_LIMITED | Rate limit exceeded | Too many requests in the configured window |
4.6.2 Server Errors
| Code | Error Code | Meaning | When |
|---|---|---|---|
| 500 | INTERNAL_ERROR | Unexpected server error | Unhandled exception, database error, plugin failure |
4.6.3 Validation Error Details
HTTP 422 responses include an errors array with per-field details:
{
"status": "error",
"code": 422,
"error_code": "VALIDATION_ERROR",
"error_message": "Validation failed",
"errors": [
{ "field": "email", "message": "Invalid email format", "rule": "type" },
{ "field": "title", "message": "Title is required", "rule": "required" },
{ "field": "price", "message": "Price must be greater than 0", "rule": "min" }
]
}
State machine transition rejections also return 422:
{
"status": "error",
"code": 422,
"error_code": "TRANSITION_DENIED",
"error_message": "Transition 'close' is not available from state 'open'"
}
Guard failures on transitions:
{
"status": "error",
"code": 422,
"error_code": "GUARD_FAILED",
"error_message": "Transition 'resolve' requires field 'resolution'"
}
4.6.4 Rate Limit Response
{
"status": "error",
"code": 429,
"error_code": "RATE_LIMITED",
"error_message": "Rate limit exceeded. Retry after 45 seconds."
}
With headers:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1716000000
Retry-After: 45
4.7 Query Parameters
All list endpoints (GET /api/v1/{domain}/{resources}) accept the following query parameters. Parameters can be freely combined.
4.7.1 Pagination
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | 1-indexed page number |
limit | integer | 100 | Records per page (maximum: 1000) |
offset | integer | 0 | Zero-indexed offset (alternative to page) |
When page is used, the response includes totalCount. When offset is used, totalCount is omitted.
4.7.2 Sorting
| Parameter | Type | Example | Description |
|---|---|---|---|
sort | string | sort=-createdAt,title | Comma-separated field names. Prefix - for descending. |
Multiple sort fields are applied in order. The default sort is createdAt ascending.
4.7.3 Filtering
| Syntax | Operator | Example |
|---|---|---|
filter[field]=value | Equals | filter[status]=published |
filter[field][eq]=value | Equals (explicit) | filter[status][eq]=published |
filter[field][ne]=value | Not equals | filter[status][ne]=draft |
filter[field][gt]=value | Greater than | filter[price][gt]=100 |
filter[field][gte]=value | Greater than or equal | filter[createdAt][gte]=2026-01-01 |
filter[field][lt]=value | Less than | filter[price][lt]=50 |
filter[field][lte]=value | Less than or equal | filter[age][lte]=65 |
filter[field][like]=value | SQL LIKE | filter[title][like]=%deploy% |
filter[field][in]=a,b,c | In list | filter[status][in]=draft,review |
Multiple filters are combined with AND. Filtering on non-existent fields is silently ignored.
4.7.4 Search
| Parameter | Type | Example | Description |
|---|---|---|---|
search | string | search=kubernetes | Full-text search across indexed text fields |
Search requires features.search: true on the resource. The search implementation is store-dependent — SQLite uses LIKE matching across text fields; PostgreSQL uses tsvector when available.
4.7.5 Relationship Loading
| Parameter | Type | Example | Description |
|---|---|---|---|
include | string | include=comments,tags | Comma-separated relationship names to eager-load |
Included relationships are nested in the response:
{
"id": "post-1",
"title": "My Post",
"comments": [
{ "id": "comment-1", "body": "Great post!" }
],
"tags": [
{ "id": "tag-1", "name": "engineering" }
]
}
4.7.6 Scopes
| Parameter | Type | Default | Description |
|---|---|---|---|
scope | string | active | Named query scope |
For resources with softDelete: true, three scopes are auto-generated:
| Scope | Filter | Description |
|---|---|---|
active | deletedAt IS NULL | Default — only non-deleted records |
deleted | deletedAt IS NOT NULL | Only soft-deleted records |
all | (no filter) | All records regardless of deletion status |
Custom scopes defined in the domain YAML (§20) are also available.
4.7.7 Field Selection
| Parameter | Type | Example | Description |
|---|---|---|---|
fields | string | fields=id,title,status | Comma-separated field names to include in the response |
When fields is specified, only the listed fields are returned. The id field is always included regardless of the selection. Field selection reduces response payload size and database query scope.
4.7.8 Distinct
| Method | Path | Description |
|---|---|---|
GET | /api/v1/d/rs/distinct?field=status | Distinct values for a field |
Returns an array of unique values for the specified field:
{
"status": "ok",
"code": 200,
"data": {
"field": "status",
"values": ["draft", "published", "archived"]
}
}
4.7.9 Count
| Method | Path | Description |
|---|---|---|
GET | /api/v1/d/rs/count | Count matching records |
Returns the number of records matching the current filters:
{
"status": "ok",
"code": 200,
"data": {
"count": 42
}
}
Count supports the same filter, search, and scope parameters as the list endpoint.
4.8 Internal Transport Bypass
Requests originating from within the server — Lua scripts via kalos.call(), workflow steps, scheduled tasks, and seeder operations — bypass the middleware chain. The bus dispatch path for internal requests does not execute rate limiting, JWT authentication, RBAC authorization, or audit logging.
Internal requests are identified by the transport field on the Request object:
| Transport | Origin | Middleware |
|---|---|---|
"http" | External HTTP client | Full middleware chain |
"internal" | Plugin-to-plugin call | Bypassed |
"seeder" | Seed execution | Bypassed |
"lua" | Lua script kalos.call() | Bypassed |
"workflow" | Workflow step execution | Bypassed |
"scheduler" | Scheduled task execution | Bypassed |
Internal requests carry an Identity that determines what the request can do:
__system__identity: Full access to all resources and operations. Used by the seeder and by Lua scripts that need elevated privileges.- Actor identity: The identity of the user or process that initiated the chain. Used by workflow steps to preserve the audit trail — “Alice started this workflow, so the workflow’s actions are attributed to Alice.”
The decision of which identity to use is made by the calling plugin, not by the kernel. See §2.8 for the security implications.
4.9 Auto-Generated Fields
The following fields are managed by the server and MUST NOT be set by the client (submitted values are silently ignored or overwritten):
| Field | Set On | Value | Behavior |
|---|---|---|---|
id | create | UUID v4 | Auto-generated unless allowUserProvided: true is set on the field |
createdAt | create | ISO 8601 timestamp | Set once, never updated |
updatedAt | create, update | ISO 8601 timestamp | Updated on every write |
deletedAt | soft delete | ISO 8601 timestamp | Set on delete, cleared on restore |
slug | create, update | Derived from from field | Regenerated when the source field changes |
| State field | create | initial state value | Set to the state machine’s initial state |
End of Chapter 4.
Next: Chapter 5 — Store Adapters