15.1 Overview
Resource features are toggleable behaviors declared in the features: block. Each feature activates a specific capability — additional endpoints, storage structures, or runtime behavior — without requiring the domain author to implement it.
resources:
Post:
features:
softDelete: true
audit: true
versioning: true
search: true
export: true
import: true
caching: true
cacheTtlSeconds: 300
transactions: true
validationMode: strict
All features default to false (or a sensible default for non-boolean features) unless explicitly enabled. A resource with no features: block has no optional features active — it is a plain CRUD resource.
Feature Summary
| Feature | Default | What It Adds |
|---|---|---|
softDelete | false | Logical deletion, restore endpoint, scope filtering |
audit | false | Change tracking table, audit endpoints |
versioning | false | Version history table, versions and revert endpoints |
search | false | Full-text search via ?search= parameter |
export | false | CSV export endpoint |
import | false | JSON import endpoint |
caching | false | In-memory read cache with TTL |
cacheTtlSeconds | 300 | Cache lifetime (only when caching: true) |
transactions | true | Wrap writes in database transactions |
validationMode | "strict" | strict rejects unknown fields; loose strips them |
15.2 Soft Delete
When softDelete: true, the DELETE operation sets a deletedAt timestamp instead of removing the record from the database. Deleted records are excluded from queries by default but can be retrieved, listed, and restored.
Activation
features:
softDelete: true
The resource MUST merge the SoftDelete object (or declare a deletedAt: { type: datetime } field) for soft delete to work:
object:
$merge:
- { $ref: '#/objects/Timestamp' }
- { $ref: '#/objects/SoftDelete' }
Behavior
| Operation | Without Soft Delete | With Soft Delete |
|---|---|---|
DELETE /:id | Record permanently removed. Returns 204. | deletedAt set to current timestamp. Returns 204. |
GET / (list) | All records. | Only records where deletedAt IS NULL (active scope). |
GET /:id | 404 if not found. | 404 if soft-deleted (unless ?scope=all or ?scope=deleted). |
Generated Endpoints
| Method | Path | Action | Description |
|---|---|---|---|
POST | /:id/restore | adl.{d}.{r}.restore | Clear deletedAt, making the record active again |
Scope Parameters
Soft delete automatically generates three scopes:
| Scope | Filter | Description |
|---|---|---|
active (default) | deletedAt IS NULL | Only non-deleted records |
deleted | deletedAt IS NOT NULL | Only soft-deleted records |
all | (no filter) | All records regardless of deletion status |
GET /api/v1/blog/posts → active only (default)
GET /api/v1/blog/posts?scope=deleted → deleted only
GET /api/v1/blog/posts?scope=all → everything
Restore
POST /api/v1/blog/posts/:id/restore
Clears the deletedAt field (sets to null), returning the record to the active scope. Returns 200 with the restored record. Returns 404 if the record does not exist or is not soft-deleted.
Hard Delete
When soft delete is enabled, there is no API endpoint for permanent deletion. Hard deletion requires direct database access or a Lua hook. A future version MAY add a purge endpoint for administrative hard deletion.
15.3 Audit Trail
When audit: true, the runtime records a JSON diff of changed fields after every create, update, and delete operation. Audit records are stored in a separate table and are queryable via dedicated endpoints.
Activation
features:
audit: true
audit:
track: [title, status, priority]
exclude: [updatedAt]
tableName: custom_audit_table
retention:
days: 90
Audit Configuration
| Key | Type | Default | Description |
|---|---|---|---|
track | string[] | all fields | If specified, only these fields are recorded in the diff. |
exclude | string[] | [] | Fields to exclude from the diff. updatedAt SHOULD always be excluded. |
tableName | string | {table}_audit | Override the default audit table name. |
retention.days | integer | unlimited | Auto-cleanup of audit records older than N days. |
If neither track nor exclude is specified, all field changes are recorded.
Audit Record Structure
Each audit record contains:
| Field | Type | Description |
|---|---|---|
id | uuid | Audit record identifier |
recordId | uuid | The resource record that was changed |
action | string | "create", "update", or "delete" |
userId | uuid | The user who performed the action |
timestamp | datetime | When the action occurred |
changes | json | Field-level diff |
The changes field records what changed:
// On create:
{ "title": { "to": "New Post" }, "status": { "to": "draft" } }
// On update:
{ "title": { "from": "Old Title", "to": "New Title" }, "status": { "from": "draft", "to": "published" } }
// On delete:
{ "_action": "delete" }
Generated Endpoints
| Method | Path | Action | Description |
|---|---|---|---|
GET | /:id/audit | adl.{d}.{r}.audit | Audit trail for a specific record |
GET | /audit | adl.{d}.{r}.audit | Audit trail for all records of this resource |
Both endpoints support pagination (?page=, ?limit=), date filtering (?after=, ?before=), and user filtering (?userId=).
Audit vs History [v0.3]
The audit trail (§15.3) and the history system (§21) serve different purposes:
| Aspect | Audit Trail | History of Change |
|---|---|---|
| Audience | Developers, DBAs | End users, compliance officers |
| Format | JSON field diffs | Human-readable narrative labels |
| Granularity | Every field change | Business-significant events only |
| Configuration | features.audit + audit: block | history: block with event declarations |
| Purpose | Debugging, forensics | Activity feeds, compliance logs |
Both can be enabled simultaneously. The audit trail records mechanical field diffs; the history system records semantic business events.
15.4 Versioning
When versioning: true, the runtime copies the full record to a version history table before each update. Versions can be listed and reverted to.
Activation
features:
versioning: true
Behavior
On every update (PATCH, PUT, or state machine transition), the runtime:
- Copies the current record (pre-update state) to
{table}_versionswith a version number. - Applies the update to the main record.
- Returns the updated record.
Version numbers are sequential integers starting from 1. Each update increments the version.
Generated Endpoints
| Method | Path | Action | Description |
|---|---|---|---|
GET | /:id/versions | adl.{d}.{r}.versions | List all versions of a record (newest first) |
POST | /:id/revert | adl.{d}.{r}.revert | Restore the record to a previous version |
Revert
The revert endpoint accepts a version number in the request body:
POST /api/v1/orders/orders/:id/revert
{ "version": 3 }
Reverting restores the record’s field values from the specified version. It creates a NEW version entry (the revert itself is a versioned change). The version history is append-only — reverting does not delete later versions.
Version Record Structure
| Field | Type | Description |
|---|---|---|
id | uuid | Version record identifier |
recordId | uuid | The resource record |
version | integer | Sequential version number |
data | json | Complete record state at that version |
userId | uuid | Who made the change |
createdAt | datetime | When the version was created |
15.5 Search
When search: true, the runtime creates a full-text search index and enables the ?search= query parameter on list endpoints.
Activation
features:
search: true
Behavior
The search index covers all text-type fields on the resource (string, text, markdown). When a ?search=term parameter is included in a list request, the query matches the term against all indexed fields simultaneously.
GET /api/v1/blog/posts?search=kubernetes
Returns all posts where any text field contains “kubernetes” (case-insensitive).
Store-Specific Implementation
| Store | Implementation | Index Type |
|---|---|---|
| SQLite | LIKE '%term%' across text fields | No special index (full scan) |
| PostgreSQL | tsvector column with GIN index | Optimized full-text search |
The search behavior is transparent to the API consumer — the same ?search= parameter works on both stores. PostgreSQL provides significantly better performance on large datasets due to the GIN index.
Search + Filter Combination
Search can be combined with filters, sorting, and pagination:
GET /api/v1/blog/posts?search=kubernetes&filter[status]=published&sort=-createdAt&page=1&limit=10
The search term filters the result set, then filters, sorting, and pagination apply on top.
15.6 Export
When export: true, a CSV export endpoint is generated for the resource.
Activation
features:
export: true
Generated Endpoint
| Method | Path | Action | Description |
|---|---|---|---|
GET | /export | adl.{d}.{r}.export | Export all records as CSV |
Behavior
The export endpoint returns all records (subject to RBAC — the user must have list permission) as a CSV file with a header row:
Content-Type: text/csv
Content-Disposition: attachment; filename="posts.csv"
id,title,status,authorId,createdAt,updatedAt
550e8400-...,My Post,published,user-1,2026-01-01T00:00:00Z,2026-05-17T14:30:00Z
The export respects the same query parameters as the list endpoint — filters, sorting, and scopes can be applied:
GET /api/v1/blog/posts/export?filter[status]=published&sort=-createdAt
Computed fields (both virtual and stored) are included in the export. Relationship data is NOT included — only the FK values.
15.7 Import
When import: true, a JSON import endpoint is generated for the resource.
Activation
features:
import: true
Generated Endpoint
| Method | Path | Action | Description |
|---|---|---|---|
POST | /import | adl.{d}.{r}.import | Import records from JSON |
Behavior
The import endpoint accepts a JSON array of record objects:
POST /api/v1/orders/products/import
Content-Type: application/json
[
{ "sku": "PROD-001", "name": "Widget A", "unitPrice": "29.99", "isActive": true },
{ "sku": "PROD-002", "name": "Widget B", "unitPrice": "49.99", "isActive": true }
]
Each record is processed individually through the normal create pipeline — type validation, constraint checking, unique enforcement, and hook execution all apply. Records that fail validation are reported in the response; records that pass are created:
{
"status": "ok",
"code": 200,
"data": {
"imported": 2,
"failed": 0,
"errors": []
}
}
If allowUserProvided: true is set on the id field and the submitted record includes an ID that already exists, the import performs an upsert (update the existing record).
15.8 Caching
When caching: true, the runtime maintains an in-memory read cache for the resource with a configurable TTL.
Activation
features:
caching: true
cacheTtlSeconds: 300
| Key | Type | Default | Description |
|---|---|---|---|
caching | boolean | false | Enable in-memory read cache. |
cacheTtlSeconds | integer | 300 | Cache entry lifetime in seconds. |
Behavior
- GET requests check the cache first. If a cache entry exists and has not expired, the cached response is returned without querying the database.
- Write operations (create, update, delete, transition) invalidate the cache for the affected record. List cache entries are invalidated on any write to the resource.
- Cache scope is per-server-instance. In a multi-instance deployment, each instance maintains its own cache. There is no cross-instance cache invalidation.
When to Use
Caching is appropriate for read-heavy reference data that changes infrequently: product catalogs, lookup tables, configuration records, geographic data. It is NOT appropriate for resources with high write frequency or where stale data is unacceptable.
15.9 Transactions
When transactions: true (the default), write operations are wrapped in database transactions.
features:
transactions: true # Default — can be omitted
A transaction wraps the entire write pipeline: validation, persistence, computed field recalculation, audit recording, and version creation. If any step fails, the entire operation is rolled back.
Setting transactions: false disables transactional wrapping. This is rarely needed and is provided for edge cases where the overhead of transaction management is unacceptable (e.g., high-throughput append-only logging resources).
15.10 Validation Mode
The validationMode feature controls how the runtime handles fields in the request body that are not declared in the resource’s object: block.
features:
validationMode: strict # Default
| Mode | Behavior |
|---|---|
strict | Unknown fields in the request body are rejected with HTTP 422. The error message identifies the unknown fields. |
loose | Unknown fields are silently stripped from the request body before processing. No error is returned. |
strict is the default and is recommended for production. It catches typos and API consumer errors early. loose is useful for forward-compatible APIs where clients may send fields that the server has not yet defined.
15.11 Feature Interactions
Features compose independently. Enabling multiple features on the same resource produces additive behavior:
| Combination | Behavior |
|---|---|
softDelete + audit | Soft-delete produces an audit entry with _action: "delete". Restore produces an audit entry with _action: "restore". |
softDelete + versioning | Soft-delete does NOT create a version entry (the record is not modified, only marked). Restore does NOT create a version entry. |
audit + versioning | Both fire on update. The audit records the diff; the version records the full pre-update state. |
softDelete + search | Soft-deleted records are excluded from search results in the active scope. ?scope=all&search=term searches across all records. |
softDelete + export | Export respects the current scope. GET /export exports active records. GET /export?scope=all exports everything. |
caching + any write feature | All write operations invalidate the cache before returning. |
Hooks and Features
Features that produce events (audit, versioning, soft delete) fire AFTER the corresponding hooks. The execution order for an update with all features enabled:
1. beforeUpdate hooks
2. Validation
3. Persist update
4. Computed field recalculation
5. Version history recorded
6. Audit trail recorded
7. afterUpdate hooks
8. Cache invalidation
9. Bus event emitted
End of Chapter 15.
Next: Chapter 16 — Query Parameters