Chapter 4 · Volume I — Platform Architecture

REST Transport

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 ResourceGenerated 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:

MethodPathActionDescription
GET/api/v1/d/rsadl.d.r.listList records
GET/api/v1/d/rs/:idadl.d.r.getGet record by ID
POST/api/v1/d/rsadl.d.r.createCreate record
PATCH/api/v1/d/rs/:idadl.d.r.updatePartial update
PUT/api/v1/d/rs/:idadl.d.r.putFull replace
DELETE/api/v1/d/rs/:idadl.d.r.deleteDelete record
GET/api/v1/d/rs/countadl.d.r.countCount records

Feature-conditional routes:

FeatureMethodPathAction
softDelete: truePOST/api/v1/d/rs/:id/restoreadl.d.r.restore
audit: trueGET/api/v1/d/rs/:id/auditadl.d.r.audit
audit: trueGET/api/v1/d/rs/auditadl.d.r.audit
versioning: trueGET/api/v1/d/rs/:id/versionsadl.d.r.versions
versioning: truePOST/api/v1/d/rs/:id/revertadl.d.r.revert
export: trueGET/api/v1/d/rs/exportadl.d.r.export
import: truePOST/api/v1/d/rs/importadl.d.r.import

State machine transitions:

MethodPathAction
POST/api/v1/d/rs/:id/transitions/{name}adl.d.r.{name}

Relationship routes (hasMany):

MethodPathAction
GET/api/v1/d/rs/:id/{related}adl.d.r.{related}.list

Relationship routes (belongsToMany):

MethodPathAction
GET/api/v1/d/rs/:id/{related}adl.d.r.{related}.list
POST/api/v1/d/rs/:id/{related}/linkadl.d.r.{related}.link
DELETE/api/v1/d/rs/:id/{related}/unlinkadl.d.r.{related}.unlink

Custom operations:

MethodPathAction
per YAML/api/v1/d/rs/:id/{operation}adl.d.r.{operation}

History [v0.3]:

MethodPathAction
GET/api/v1/d/rs/:id/historyadl.d.r.history
GET/api/v1/d/rs/historyadl.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:

EndpointResponse
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
  }
}
FieldTypeDescription
itemsarrayArray of resource records for the current page
countintegerNumber of items in the current response
totalCountintegerTotal 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-Type header (no body)
  • CSV exportContent-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

CodeError CodeMeaningWhen
400BAD_REQUESTMalformed requestInvalid JSON, missing required header
401UNAUTHORIZEDAuthentication required or failedMissing token, expired token, invalid token
403FORBIDDENInsufficient permissionsRole not in operation permissions, field-level write restriction
404NOT_FOUNDResource or route not foundUnknown route, record does not exist, disabled operation
409DUPLICATEUniqueness constraint violationDuplicate unique field value, domain already exists
415UNSUPPORTED_MEDIA_TYPEWrong Content-TypeNot application/json on JSON endpoints
422VALIDATION_ERRORValidation failedType violation, constraint violation, cross-field rule failure, state machine guard failure
429RATE_LIMITEDRate limit exceededToo many requests in the configured window

4.6.2 Server Errors

CodeError CodeMeaningWhen
500INTERNAL_ERRORUnexpected server errorUnhandled 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

ParameterTypeDefaultDescription
pageinteger11-indexed page number
limitinteger100Records per page (maximum: 1000)
offsetinteger0Zero-indexed offset (alternative to page)

When page is used, the response includes totalCount. When offset is used, totalCount is omitted.

4.7.2 Sorting

ParameterTypeExampleDescription
sortstringsort=-createdAt,titleComma-separated field names. Prefix - for descending.

Multiple sort fields are applied in order. The default sort is createdAt ascending.

4.7.3 Filtering

SyntaxOperatorExample
filter[field]=valueEqualsfilter[status]=published
filter[field][eq]=valueEquals (explicit)filter[status][eq]=published
filter[field][ne]=valueNot equalsfilter[status][ne]=draft
filter[field][gt]=valueGreater thanfilter[price][gt]=100
filter[field][gte]=valueGreater than or equalfilter[createdAt][gte]=2026-01-01
filter[field][lt]=valueLess thanfilter[price][lt]=50
filter[field][lte]=valueLess than or equalfilter[age][lte]=65
filter[field][like]=valueSQL LIKEfilter[title][like]=%deploy%
filter[field][in]=a,b,cIn listfilter[status][in]=draft,review

Multiple filters are combined with AND. Filtering on non-existent fields is silently ignored.

ParameterTypeExampleDescription
searchstringsearch=kubernetesFull-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

ParameterTypeExampleDescription
includestringinclude=comments,tagsComma-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

ParameterTypeDefaultDescription
scopestringactiveNamed query scope

For resources with softDelete: true, three scopes are auto-generated:

ScopeFilterDescription
activedeletedAt IS NULLDefault — only non-deleted records
deleteddeletedAt IS NOT NULLOnly 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

ParameterTypeExampleDescription
fieldsstringfields=id,title,statusComma-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

MethodPathDescription
GET/api/v1/d/rs/distinct?field=statusDistinct 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

MethodPathDescription
GET/api/v1/d/rs/countCount 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:

TransportOriginMiddleware
"http"External HTTP clientFull middleware chain
"internal"Plugin-to-plugin callBypassed
"seeder"Seed executionBypassed
"lua"Lua script kalos.call()Bypassed
"workflow"Workflow step executionBypassed
"scheduler"Scheduled task executionBypassed

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):

FieldSet OnValueBehavior
idcreateUUID v4Auto-generated unless allowUserProvided: true is set on the field
createdAtcreateISO 8601 timestampSet once, never updated
updatedAtcreate, updateISO 8601 timestampUpdated on every write
deletedAtsoft deleteISO 8601 timestampSet on delete, cleared on restore
slugcreate, updateDerived from from fieldRegenerated when the source field changes
State fieldcreateinitial state valueSet to the state machine’s initial state

End of Chapter 4.

Next: Chapter 5 — Store Adapters