Chapter 18 · Volume II — The ADL Domain Language

Content & Media Types

18.1 Media Type and Media Library

The media type stores references to files managed in a centralized media library. The library handles uploads, storage, usage tracking, and orphan detection.

Field Declaration

featuredImage:
  type: media
  accept: [image/jpeg, image/png, image/webp]
  maxSize: 5mb
  minWidth: 800
  minHeight: 600
  aspectRatio: "16:9"

attachments:
  type: array
  items:
    type: media
    accept: [application/pdf, .docx, .xlsx]
    maxSize: 50mb

Constraints

KeyTypeDescription
acceptstring[]Allowed MIME types or extensions. Wildcards supported: image/*.
maxSizestringMaximum file size with unit: 5mb, 500kb, 1gb.
minWidthintegerMinimum image width in pixels (images only).
maxWidthintegerMaximum image width in pixels.
minHeightintegerMinimum image height in pixels.
maxHeightintegerMaximum image height in pixels.
aspectRatiostringRequired aspect ratio: "16:9", "1:1", "4:3".

Endpoints

The media library exposes six endpoints per domain (base = the domain API prefix, e.g. /api/v1/academy):

MethodPathActionPurpose
GET{base}/mediamedia.listList the library. Query: search, mime_type (e.g. image/*), sort (default -created_at), orphans (bool), limit (1–500, default 50), offset.
POST{base}/media/uploadmedia.uploadCatalogue an asset (and store its bytes when a file part is present).
GET{base}/media/:idmedia.getOne asset plus its references[]. 404 if unknown.
DELETE{base}/media/:idmedia.deleteDelete an asset. 409 if still referenced.
POST{base}/media/bulk-deletemedia.bulk_deleteBody { "ids": [...] }. Deletes unreferenced ids, skips referenced ones.
GET{base}/media/orphansmedia.orphansAssets with zero references (safe-to-prune set).

media.list returns a paginated envelope:

{
  "data": {
    "items": [ /* media rows */ ],
    "meta": { "total": 42, "page": 1, "per_page": 50,
              "total_pages": 1, "has_next": false, "has_prev": false }
  }
}

Upload

filename and mime_type are required. Because the REST multipart parser forwards only the file part (text form fields are dropped), pass them as query parameters, with the binary in the request body:

POST /api/v1/{domain}/media/upload?filename=hero.png&mime_type=image/png
Content-Type: multipart/form-data

(binary file data)

Do not send size — the handler types it as a number and a query-string value is rejected; the server derives the true size from the stored bytes.

Response (201):

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "filename": "hero.png",
    "mime_type": "image/png",
    "size": 245760,
    "path": "/api/v1/files/2026/06/550e8400-e29b-41d4-a716-446655440000.png"
  }
}

path is the public URL for the bytes, served by the file store backing (see §18.8). It resolves only when a file store is loaded and bytes were supplied; otherwise the row is catalogued with a placeholder /media/{id}/{filename} path that does not serve.

Validation scope (current behaviour). media.upload itself validates only that filename/mime_type are present (400 MISSING_FIELDS otherwise). Byte-level limits — files.max_file_size and files.allowed_types — are enforced by the file store (§18.8) at write time; if the store rejects the bytes, the metadata row is still catalogued without bytes rather than failed. The field-level accept/maxSize/dimension constraints below are checked when the asset is assigned to a type: media field on a record (create/update), not at upload.

Status: per-field content validation is Extension-tier — the generic create path currently skips it, so a bogus reference can be stored unvalidated. The constraint table is the intended contract.

Storage Format

A media field stores the media item’s UUID as its value, on both write and read:

{ "featuredImage": "550e8400-e29b-41d4-a716-446655440000" }

To obtain the full metadata (filename, MIME, size, path), the client resolves the id via GET {base}/media/:id.

Status: automatic expansion of a media field into an inline metadata object in record responses is Extension-tier (not currently performed) — responses return the raw UUID string. Clients should resolve metadata explicitly, or batch via media.list.

Array of Media

Multiple media items are stored as a JSON array of UUIDs:

gallery:
  type: array
  items:
    type: media
    accept: [image/*]
  minItems: 1
  maxItems: 20
{
  "gallery": [
    "550e8400-...",
    "660f9500-...",
    "770a0600-..."
  ]
}

Each UUID in the array is tracked individually in the references table.

Reference Tracking

The media library maintains a {domain}_media_references table that records which resources reference which media items:

ColumnTypeDescription
mediaIduuidThe media item
resourceNamestringThe resource that references it
recordIduuidThe specific record
fieldNamestringThe field that holds the reference

References are updated automatically on create and update. When a record is created with featuredImage: "550e8400-...", a reference row is inserted. When the record is updated with a different image, the old reference is removed and the new one is inserted.

Orphan Detection

The media library provides an endpoint to find unreferenced media items:

GET /api/v1/{domain}/media/orphans

Returns media items that exist in the library but are not referenced by any resource field. Orphans may be safe to delete — they are files that were uploaded but never used, or files whose referencing records have been deleted.

Safe Deletion

Deleting a media item that is still referenced returns HTTP 409:

{
  "status": "error",
  "code": 409,
  "error_code": "MEDIA_REFERENCED",
  "error_message": "Cannot delete media referenced by 3 record(s)"
}

The response reports the count of referencing records. The media item MUST be unreferenced before it can be deleted. media.bulk_delete applies the same rule per id — referenced ids are skipped (reported in skipped) rather than failing the batch.

Permissions & Validation

Two independent controls govern media — keep them distinct:

1. Assignment (governed today). Setting a record’s type: media field is an ordinary field write, so it inherits Chapter 11 field-level permissions. Gate who may attach which asset to which record on the owning resource:

Article:
  permissions:
    update: [author, editor, admin]
    fields:
      featuredImage:
        write: [editor, admin]   # only editors/admins may (re)assign the cover
        read:  ["*"]             # anyone may see which asset id is referenced

A caller without featuredImage.write has the field stripped from their create/update; featuredImage.read controls whether the id is exposed. Reference rows are written in the same path, so resource RBAC transitively governs what gets referenced.

2. Library management. The media.* endpoints are gated per domain by a media.permissions block grouping the six actions into three tiers. Tokens use the resource-RBAC vocabulary (§11): * (public), authenticated, owner (treated as authenticated until per-asset ownership lands), and concrete roles (any-match):

media:
  permissions:
    browse: [authenticated]          # media.list, media.get
    upload: [author, editor, admin]  # media.upload
    manage: [admin]                  # media.delete, media.bulk_delete, media.orphans

When the block — or a tier — is omitted, each tier defaults to authenticated: the endpoints are never anonymously accessible unless an operator opts in with ["*"]. Tighten by naming roles; a tier containing a role list requires the caller to hold one of them (403 otherwise; 401 when unauthenticated).

TierEndpointsDefaultTighten with
browsemedia.list, media.getauthenticatedrole list
uploadmedia.uploadauthenticatedrole list
managemedia.delete, media.bulk_delete, media.orphansauthenticatede.g. [admin]

The file-store endpoints have their own server-config gate (§18.8): files.upload_roles (roles allowed to POST /files/upload; [] = any authenticated) and files.serve_public (default true — set false to require auth on GET /files/*).

Uploading only catalogues an asset; it never grants the right to attach it (that stays governed by field-level write, above). Field-level read hides the id, not the bytes — for private assets set files.serve_public: false or issue signed URLs.


18.2 SVG Type and Sanitization

The svg type stores inline SVG markup. Content is sanitized on write to prevent script injection and other security risks.

Field Declaration

logo:
  type: svg
  maxSize: 50kb

Sanitization Rules

On every write (create and update), the SVG content passes through a sanitization pipeline:

Removed elements:

ElementReason
<script>JavaScript execution
<iframe>External content embedding
<object>External content embedding
<embed>External content embedding
<foreignObject>Arbitrary HTML embedding

Removed attributes:

Attribute PatternReason
on* (onclick, onload, onerror, etc.)JavaScript event handlers
href with javascript: schemeJavaScript execution via link
xlink:href to external URLsExternal resource loading

Optionally removed (configurable):

ContentDefaultReason
XML comments (<!-- -->)StrippedReduce payload size
Metadata elements (<metadata>, <desc>, <title>)PreservedMay contain accessibility info

Preserved elements:

All structural SVG elements are preserved: <svg>, <g>, <path>, <rect>, <circle>, <ellipse>, <line>, <polyline>, <polygon>, <text>, <tspan>, <defs>, <use>, <clipPath>, <mask>, <pattern>, <linearGradient>, <radialGradient>, <stop>, <filter>, <image> (with inline data URIs only).

Validation

CheckError
Content is valid XML422: “Invalid SVG: malformed XML”
Root element is <svg>422: “Invalid SVG: root element must be <svg>
Content size ≤ maxSize422: “SVG exceeds maximum size of 50kb”
Content is not empty422: “SVG content is required”

Storage

Sanitized SVG is stored as a TEXT column in the database. The full SVG markup is returned in API responses:

{
  "logo": "<svg viewBox=\"0 0 100 100\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"50\" cy=\"50\" r=\"40\" fill=\"#3B82F6\"/></svg>"
}

18.3 Blocks Type and Block Editor

The blocks type stores structured content as a JSON array of typed block objects. It enables Notion-style rich content editing with a controlled palette of block types.

Field Declaration

content:
  type: blocks
  required: true
  maxBlocks: 200
  allowedBlocks:
    - paragraph
    - heading
    - image
    - code
    - quote
    - list
    - table
    - embed
    - divider
    - callout

Built-In Block Types

Block TypeHas ContentPropsDescription
paragraphText paragraph
headinglevel (1–6)Section heading
imagemediaId, alt, captionImage from media library
codelanguage, lineNumbers, filenameCode block
quotecitationBlock quote
listtype (bullet/numbered/checkbox)Ordered or unordered list
tablerows, columns, dataData table
embedurl, providerExternal embed (YouTube, etc.)
dividerHorizontal rule
calloutstyle (info/warning/tip/danger)Highlighted callout box

Block JSON Structure

Each block is a JSON object with type, optional content, and optional props:

[
  {
    "type": "heading",
    "content": "Getting Started",
    "props": { "level": 2 }
  },
  {
    "type": "paragraph",
    "content": "This guide walks you through the setup process."
  },
  {
    "type": "code",
    "content": "const app = new Kalos();",
    "props": { "language": "javascript", "lineNumbers": true }
  },
  {
    "type": "image",
    "props": {
      "mediaId": "550e8400-...",
      "alt": "Architecture diagram",
      "caption": "The Kalos microkernel architecture"
    }
  },
  {
    "type": "callout",
    "content": "Remember to set your JWT secret in production.",
    "props": { "style": "warning" }
  }
]

AllowedBlocks Constraint

The allowedBlocks array restricts which block types can appear in the content. Blocks not in the list are rejected with HTTP 422 on create and update.

If allowedBlocks is omitted, all built-in block types are allowed.

MaxBlocks Constraint

The maxBlocks constraint limits the total number of blocks in the array. Exceeding the limit produces HTTP 422.

Block Validation

Each block is validated individually:

  1. type must be a recognized block type (built-in or custom).
  2. type must be in the allowedBlocks list (if specified).
  3. content must be present for blocks that require it.
  4. props values must match the block type’s prop definitions.
  5. Media references in block props (e.g., image.mediaId) are tracked in the references table.

18.4 Custom Block Definitions

Domains MAY define custom block types in the top-level blocks: block. Custom blocks extend the built-in set with domain-specific content structures.

blocks:
  Callout:
    label: "Callout"
    hasContent: true
    props:
      style:
        type: enum
        values: [info, warning, tip, danger]
        default: info
      title:
        type: string
        maxLength: 100
        required: false

  PromoCard:
    label: "Promo Card"
    hasContent: false
    props:
      heading: { type: string, required: true }
      subtext: { type: text, required: false }
      image: { type: media, accept: [image/*] }
      ctaText: { type: string, default: "Learn More" }
      ctaUrl: { type: url, required: true }
      theme: { type: enum, values: [light, dark], default: light }

Custom Block Declaration

KeyTypeRequiredDescription
labelstringyesHuman-readable name for the block toolbar.
hasContentbooleannoIf true, the block has a text content area. Default true.
propsobjectnoNamed properties with typed field definitions.

Prop Types

Block props use the same type system as resource fields (§7). Common prop types: string, text, integer, boolean, enum (with inline values), media, url. Component and repeater types within block props are supported for complex block structures.

Referencing Custom Blocks

Custom blocks are referenced in allowedBlocks using $ref:

content:
  type: blocks
  allowedBlocks:
    - paragraph
    - heading
    - $ref: '#/blocks/Callout'
    - $ref: '#/blocks/PromoCard'

Or by name directly:

allowedBlocks: [paragraph, heading, callout, promoCard]

The parser matches block names case-insensitively against both built-in and custom definitions.

Block Storage

Custom blocks are stored using the same JSON structure as built-in blocks:

{
  "type": "promoCard",
  "props": {
    "heading": "Try Kalos Today",
    "subtext": "Get started with our quick-start guide.",
    "ctaText": "Read the Docs",
    "ctaUrl": "https://kalos.dev/docs",
    "theme": "dark"
  }
}

18.5 Icon Type and Icon Sets

The icon type stores references to icons from configured icon sets.

Field Declaration

categoryIcon:
  type: icon
  iconSets: [lucide, custom]

Value Format

Icon values use the format set:name:

{
  "categoryIcon": "lucide:cog"
}

Icon Set Configuration

Icon sets are configured in the top-level icons: block:

icons:
  lucide:
    source: cdn
    url: "https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/"
    prefix: lucide
    format: svg

  custom:
    source: local
    path: ./icons/
    prefix: app
    format: svg
KeyTypeDescription
sourcestringcdn (external URL) or local (filesystem path).
urlstringCDN URL for the icon set (when source is cdn).
pathstringLocal filesystem path (when source is local).
prefixstringNamespace prefix used in icon references.
formatstringIcon format: svg, png, font.

Validation

If iconSets is specified on the field, the value’s set prefix must match one of the allowed sets. An icon value with an unrecognized set returns HTTP 422.


18.6 Font Type and Font Sources

The font type stores font family references for typography configuration.

Field Declaration

headingFont:
  type: font
  sources: [google, system]

Value Format

{
  "headingFont": "Inter"
}

Font Source Configuration

Font sources are configured in the top-level fonts: block:

fonts:
  google:
    source: google-fonts
    preload: [Inter, Roboto, "Fira Code"]

  system:
    source: system
    families: [Arial, Helvetica, "Times New Roman", Menlo, Monaco, monospace]
KeyTypeDescription
sourcestringgoogle-fonts, system, or custom.
preloadstring[]Font families to preload (for CDN sources).
familiesstring[]Available font families (for system/custom sources).

Validation

If sources is specified on the field, the font family must be available in one of the allowed sources. An unrecognized font family returns HTTP 422.


18.7 Component and Repeater — Operational Detail

Component and repeater types are specified in §7.6. This section covers their operational behavior beyond the type definition.

Component — Nested Object

Components store a single JSON object as one database column. The nested fields are validated individually on create and update using the same constraint system as top-level fields.

shippingAddress:
  type: component
  object: { $ref: '#/objects/Address' }

Partial Update: PATCH requests to a component field accept a partial object. Unspecified nested fields retain their existing values:

// PATCH — only update city, keep other address fields unchanged
{
  "shippingAddress": { "city": "New York" }
}

Null Component: Setting a non-required component to null clears the entire nested object:

{
  "shippingAddress": null
}

Querying: Component fields are stored as JSON. Filtering and sorting on nested fields within a component requires JSON path queries, which are store-dependent. On SQLite, JSON path queries use json_extract(). On PostgreSQL, they use ->>/->> operators. Direct filtering via the standard filter[field] syntax is NOT supported for nested component fields.

Repeater — Ordered Array of Objects

Repeaters store a JSON array of objects. Each element is validated against the repeater’s field definitions.

lineItems:
  type: repeater
  sortable: true
  minItems: 1
  maxItems: 50
  object:
    productId: { type: uuid, required: true }
    quantity: { type: integer, min: 1, required: true }
    unitPrice: { type: decimal, required: true }
    notes: { type: text }

Full Replacement: Update requests to a repeater field replace the entire array. There is no partial update for individual array elements — the client sends the complete array:

{
  "lineItems": [
    { "productId": "abc", "quantity": 3, "unitPrice": "29.99" },
    { "productId": "def", "quantity": 1, "unitPrice": "49.99" }
  ]
}

Ordering: When sortable: true, each element receives an implicit _order field (integer) that records its position in the array. The UI uses this for drag-and-drop reordering. The _order field is managed by the runtime — client-provided _order values are accepted and used as the explicit order.

Validation: Each element is validated independently. If element 2 of 5 fails validation, the error response identifies the failing element by index:

{
  "errors": [
    { "field": "lineItems[1].quantity", "message": "Must be at least 1", "rule": "min" }
  ]
}

18.8 File Store Backing

The media library catalogues assets; the file store holds their bytes. It is a separate infrastructure plugin (handler.files) that registers a file store with the kernel (core->register_file_store()), paralleling the record store adapters of Chapter 5. The media library writes and reads bytes through whatever store is registered; if none is loaded, media.upload still catalogues metadata but no bytes are stored and the serve URL will not resolve.

Enabling

The file store is not loaded by default. Add it to core.plugins, ordered before adl so the store is registered before the media upload action looks it up:

{ "name": "handler.files", "path": "kalos_files.__EXT__", "enabled": true }

Configuration (files.*)

KeyDefaultMeaning
files.root./uploadsFilesystem root for stored bytes (relative to the server working directory).
files.base_url/api/v1/filesURL prefix prepended to a stored object’s relative path to form its public URL.
files.max_file_size10485760 (10 MiB)Hard byte cap; larger uploads are rejected by the store.
files.allowed_typesimage/*,application/pdfComma-separated MIME allow-list; image/* wildcards are supported.

These are enforced at store time: a file exceeding max_file_size or whose MIME is not in allowed_types is rejected (the calling media.upload catalogues metadata without bytes — see §18.1).

Storage layout

LocalFileStore writes to a date-bucketed path under files.root:

<files.root>/{YYYY}/{MM}/{uuid}{.ext}

and the public URL is files.base_url + that relative path, e.g. /api/v1/files/2026/06/<uuid>.png. This is the value returned as the media row’s path.

Endpoints

MethodPathActionAuthNotes
POST/api/v1/files/uploadfiles.uploadAuthenticated (+ files.upload_roles)Generic upload (independent of any domain). Returns the stored object’s url/path.
GET/api/v1/files/:idfiles.servefiles.serve_public (default public)Flat single-segment id.
GET/api/v1/files/:id/:filenamefiles.servefiles.serve_publicId + suggested download filename.
GET/api/v1/files/*pathfiles.servefiles.serve_publicTrailing-splat route that serves the multi-segment store layout (2026/06/uuid.png).

Two files.* config keys gate these (see §18.1): files.upload_roles (array; [] = any authenticated user) and files.serve_public (bool, default true; false requires auth on every GET /files/*).

Why the splat route. The REST router (Chapter 4) matches by exact segment count, so the 3-segment storage path did not match a :id route and bytes 404’d. A route whose last pattern segment is *name captures the entire remainder of the path into that parameter. The splat is matched at lowest priority — only after exact and fixed-arity parameter routes miss, and only for routes that opt into * — so it never shadows a more specific route. files.serve sets Content-Type from the file extension; ?download=true adds Content-Disposition: attachment.

Privacy. files.serve is public by default, suitable for a CMS. For private assets, gate the serve route or issue short-lived signed URLs — a field’s Chapter 11 read permission hides the id, not the bytes.

This layer is intentionally minimal (a single local-disk store). A richer backend — S3/object storage, signed URLs, or multiple named stores — would graduate the file store into its own chapter; until then it is documented here, beside its primary consumer. See also Chapter 5 (Store Adapters) for the record-store counterpart.


18.9 Schema Endpoint

The schema introspection endpoint returns full metadata for content types:

{
  "fields": {
    "featuredImage": {
      "type": "media",
      "accept": ["image/jpeg", "image/png", "image/webp"],
      "maxSize": "5mb",
      "minWidth": 800,
      "aspectRatio": "16:9"
    },
    "content": {
      "type": "blocks",
      "maxBlocks": 200,
      "allowedBlocks": ["paragraph", "heading", "image", "code", "callout"]
    },
    "logo": {
      "type": "svg",
      "maxSize": "50kb"
    },
    "categoryIcon": {
      "type": "icon",
      "iconSets": ["lucide", "custom"]
    }
  },
  "blocks": {
    "Callout": {
      "label": "Callout",
      "hasContent": true,
      "props": {
        "style": { "type": "enum", "values": ["info", "warning", "tip", "danger"], "default": "info" },
        "title": { "type": "string", "maxLength": 100 }
      }
    }
  },
  "icons": {
    "lucide": { "source": "cdn", "prefix": "lucide" }
  }
}

A UI generator uses this data to render the correct editor for each field: file picker with MIME filter for media, block editor with constrained toolbar for blocks, code editor with preview for SVG, icon picker for icon, and font selector for font.


End of Chapter 18.

Next: Chapter 19 — Indexes