Chapter 20 · Volume II — The ADL Domain Language

Scopes

20.1 Overview

Scopes are named query filters that narrow list endpoint results to a specific subset of records. They are applied via the ?scope= query parameter and provide a declarative alternative to repeating complex filter combinations on every request.

Every resource has at least one scope. Resources with softDelete: true receive three auto-generated scopes (§15.2). All resources support custom scope declarations.

scopes:
  published:
    filter: "status = 'published' AND deletedAt IS NULL"
    default: true

  recent:
    filter: "createdAt > datetime('now', '-7 days')"

  all:
    filter: ""

20.2 Scope Declaration

Scopes are declared in the scopes: block of a resource:

resources:
  Article:
    scopes:
      live:
        filter: "status = 'published' AND publishedAt <= datetime('now')"
        default: true
        description: "Published articles with a past or current publish date"

      scheduled:
        filter: "status = 'scheduled' AND publishedAt > datetime('now')"
        description: "Articles scheduled for future publication"

      drafts:
        filter: "status = 'draft'"
        description: "Work-in-progress articles"

      archived:
        filter: "status = 'archived'"
        description: "Articles removed from publication"

      all:
        filter: ""
        description: "All articles regardless of status"

Declaration Keys

KeyTypeRequiredDefaultDescription
filterstringyesSQL WHERE clause fragment applied to the query. Empty string means no filter.
defaultbooleannofalseIf true, this scope is applied when no ?scope= parameter is provided.
descriptionstringno""Human-readable description of what records this scope includes.

Default Scope

At most one scope MAY be marked default: true. When a list request has no ?scope= parameter, the default scope’s filter is applied automatically.

If no scope is marked as default:

  • Resources with softDelete: true → the auto-generated active scope is the default
  • Resources without soft delete and without a declared default → no filter is applied (all records returned)

Filter Syntax

The filter value is a raw SQL WHERE clause fragment. It is appended to the query’s WHERE clause with AND:

-- Request: GET /articles?scope=live&filter[authorId]=user-1
-- Generated SQL:
SELECT * FROM cms_articles
WHERE (status = 'published' AND publishedAt <= datetime('now'))  -- scope
  AND authorId = 'user-1'                                        -- filter
ORDER BY createdAt ASC
LIMIT 100

The scope filter and request filters are combined with AND — the scope narrows the base set, then filters narrow further.

Empty Filter

A scope with filter: "" (empty string) applies no filter — all records are returned regardless of status, deletion state, or any other field value:

all:
  filter: ""
  description: "All records without filtering"

This is useful as an explicit “show everything” option, particularly alongside scopes that filter by default.


20.3 Auto-Generated Scopes

When softDelete: true is set on a resource, three scopes are auto-generated:

# Auto-generated — do not declare these manually
scopes:
  active:
    filter: "deletedAt IS NULL"
    default: true

  deleted:
    filter: "deletedAt IS NOT NULL"

  all:
    filter: ""
ScopeFilterDescription
activedeletedAt IS NULLNon-deleted records only (default)
deleteddeletedAt IS NOT NULLSoft-deleted records only
all(no filter)All records regardless of deletion status

Override Auto-Generated Scopes

Custom scopes with the same names (active, deleted, all) override the auto-generated ones. This allows customization of the default soft delete behavior:

features:
  softDelete: true

scopes:
  active:
    filter: "deletedAt IS NULL AND status != 'archived'"
    default: true
    description: "Non-deleted, non-archived records"

This replaces the auto-generated active scope with a stricter filter that also excludes archived records.


20.4 Scope Usage

Basic Usage

GET /api/v1/blog/posts?scope=drafts

Returns only records matching the drafts scope filter.

Scope + Filters

Scopes and request filters combine with AND:

GET /api/v1/blog/posts?scope=live&filter[authorId]=user-1&sort=-publishedAt

The scope filter runs first (published articles with past publish dates), then the authorId filter narrows to one author, then results are sorted by publish date descending.

GET /api/v1/blog/posts?scope=live&search=kubernetes

Search operates within the scoped result set — only published, live articles are searched.

Scope + Count

GET /api/v1/blog/posts/count?scope=drafts

Returns the count of records matching the scope filter.

Scope + Export

GET /api/v1/blog/posts/export?scope=live

Exports only the records matching the scope filter.

Invalid Scope

If a ?scope= parameter references a scope name that does not exist, the parameter is silently ignored and the default scope (or no scope) is applied. No error is returned.


20.5 Scope Design Patterns

Status-Based Scopes

The most common pattern — one scope per status value:

scopes:
  draft:
    filter: "status = 'draft'"
  pending:
    filter: "status = 'pending'"
  active:
    filter: "status = 'active'"
    default: true
  archived:
    filter: "status = 'archived'"
  all:
    filter: ""

Time-Based Scopes

Filter by temporal relevance:

scopes:
  upcoming:
    filter: "startDate > datetime('now')"
    default: true
    description: "Future events"
  past:
    filter: "endDate < datetime('now')"
    description: "Completed events"
  ongoing:
    filter: "startDate <= datetime('now') AND endDate >= datetime('now')"
    description: "Currently active events"
  all:
    filter: ""

Ownership Scopes

Filter by the current user’s records. These use a placeholder that the runtime substitutes:

scopes:
  mine:
    filter: "createdById = :currentUserId"
    description: "Records created by the current user"
  team:
    filter: "departmentId = :currentUserDepartmentId"
    description: "Records from the current user's department"
  all:
    filter: ""
    description: "All records (admin view)"

The :currentUserId and :currentUserDepartmentId placeholders are resolved at query time from the authenticated user’s identity. This scope pattern combines with operation-level permissions — a user must have list permission AND the scope filter determines which records they see.

Compound Scopes

Combine multiple conditions for business-meaningful subsets:

scopes:
  actionRequired:
    filter: "status = 'pending' AND assigneeId IS NOT NULL AND dueDate <= datetime('now', '+3 days')"
    description: "Assigned items due within 3 days"

  overdue:
    filter: "status NOT IN ('completed', 'cancelled') AND dueDate < datetime('now')"
    description: "Past-due items that are not completed or cancelled"

  unassigned:
    filter: "assigneeId IS NULL AND status = 'pending'"
    description: "Pending items with no assignee"

20.6 Store-Specific Filter Syntax

Scope filters are SQL fragments and are therefore store-dependent. Date/time functions differ between SQLite and PostgreSQL:

OperationSQLitePostgreSQL
Current timestampdatetime('now')NOW()
Date arithmeticdatetime('now', '-7 days')NOW() - INTERVAL '7 days'
Date comparisondate(field) = date('now')field::date = CURRENT_DATE

For cross-store compatibility, scope filters SHOULD use ANSI SQL where possible and avoid store-specific functions. When store-specific syntax is unavoidable, the filter value can use the ${STORE} variable to branch:

scopes:
  recent:
    filter: "${STORE:sqlite}createdAt > datetime('now', '-7 days')${STORE:postgres}createdAt > NOW() - INTERVAL '7 days'"

However, this syntax is complex and rarely needed. Most scope filters use simple comparisons (=, !=, IN, IS NULL) that work identically on both stores. The recommendation is to write filters for the primary store and accept that switching stores may require scope filter updates.


20.7 Scopes and Permissions

Scopes do not bypass RBAC. A scope narrows the query result set, but the user must still have list permission to execute the list query at all. Scopes and permissions are independent mechanisms:

MechanismControlsApplied At
PermissionsWho can access the endpointMiddleware (§4.2.5)
ScopesWhich records are returnedQuery builder

A user with list: [authenticated] permission and a ?scope=all parameter sees all records matching the scope — but only if they have list permission. A user without list permission receives 403 regardless of scope.

Scope Visibility

All declared scopes are visible to all users via the schema endpoint. There is no per-scope permission control — any user with list permission can request any scope. If certain record subsets should be hidden from certain users, use field-level permissions (§11.3) or the owner role pattern (§11.4) instead of scopes.


20.8 Schema Endpoint

The schema introspection endpoint returns scope definitions:

{
  "scopes": {
    "live": {
      "filter": "status = 'published' AND publishedAt <= datetime('now')",
      "default": true,
      "description": "Published articles with a past or current publish date"
    },
    "scheduled": {
      "filter": "status = 'scheduled' AND publishedAt > datetime('now')",
      "default": false,
      "description": "Articles scheduled for future publication"
    },
    "drafts": {
      "filter": "status = 'draft'",
      "default": false,
      "description": "Work-in-progress articles"
    },
    "all": {
      "filter": "",
      "default": false,
      "description": "All articles regardless of status"
    }
  }
}

A UI generator uses this data to render scope tabs or a scope dropdown above the data table. The default flag identifies which tab is pre-selected. The description provides tooltip or help text.


End of Chapter 20.

End of Volume II — The ADL Domain Language.

Next: Volume III — The Intelligence Layer

Chapter 21: Declarative History of Change