← Back to home

Capabilities — capability, filter, stamp, implements

A capability is a first-class, pure-mixin declaration — a named bundle of fields + filter + stamp that an aggregate opts into with with <Cap> / implements <Cap>. It is the typed successor to the stringly-typed surface (implements "X" / filter for "X" / stamp for "X"), which was removed — see proposals/typed-capabilities.md and plans/typed-capabilities-implementation.md.

capability softDeletable {
  isDeleted: bool internal
  deletedAt: datetime? managed
  filter !this.isDeleted            // behaviour the capability provides
}

aggregate Order with softDeletable { subject: string }   // gains the field + filter

softDeletable, auditable, tenantOwned, and versioned ship built in (the toolchain prelude, src/macros/prelude.ts) — available by name with nothing declared. The softDelete()/restore() operations stay in the softDelete macro (operations aren't part of a pure-mixin capability): with softDeletable, softDelete. Because the capability adds no operation, it does not change crudish's destroy either: on with softDeletable, crudish, DELETE /{id} stays a hard delete (the advisory loom.crudish-hard-delete says so; see scaffold-macros.md). tenantOwned (tenant column + claim stamp + tenant read filter) additionally requires a system-level tenancy by declaration — see tenancy.md.

auditable requires a principal, for the same reason: it stamps createdBy/updatedBy from currentUser, so the system needs a user { ... } block and the hosting deployable needs auth: required. Without them there is nothing to stamp from and the model is rejected (loom.stamp-principal-without-auth):

system Shop {
  user { id: string  email: string }                       // the principal …
  subdomain S { context S {
    aggregate Widget with crudish, auditable { code: string }
    repository Widgets for Widget { }
  } }
  deployable api { platform: node  contexts: [S]  …  auth: required }   // … and auth
}

createdBy: User id names that principal, not a domain aggregate — there is no aggregate User and none is needed. The two fields are managed, so they never appear as form inputs, and a scaffolded list/detail page renders them as read-only stamped values rather than as links to an aggregate detail route.

versioned (optimistic concurrency) adds a single version: int token = 1 field. Every backend's save path emits a guarded write (UPDATE … WHERE id = $1 AND version = $2, bumping version) and returns HTTP 409 Conflict when zero rows match — a lost-update guard with no explicit version handling in the domain body. The counter counts persisted commands, not changed columns: a command that only mutates a contains child, or that re-assigns a value the row already holds, still bumps it (RS-20, conformance-semantics.md) — which is why java maps the column plainly and drives the bump itself rather than delegating to JPA @Version:

aggregate Order with versioned { subject: string }

The predicate aggregateIsVersioned() (src/ir/util/versioned-capability.ts) is the shared gate the five backend repositories and the migrations builder read.

Two guards, not one. The UPDATE … WHERE version = $2 above is write-time CAS: it catches an interleave between the load and the save inside one request. The guard that catches the user-visible lost update — two people open the same record, both save, the second overwrites — needs the version the client was looking at, and the client sends that as an If-Match entity-tag. The tag is quoted (RFC 9110 §8.8.3): If-Match: "3", byte-identical to the ETag the read answered with. All five backends parse it; with the header absent each falls back to the version it just loaded, so the write-time guard still runs and the think-time one silently does not.

Which write paths check which version is one derived fact, checksVersion (src/ir/enrich/version-check.ts), that the emitters render rather than decide:

Write path checksVersion Guard
any named operation of a versioned aggregate — update, every other command, value-returning or not, any saving shape if-match (the header, falling back to the loaded version) the row's version column
an operation of a persistedAs: eventLog aggregate handle (the stream version the fold reached) the (stream_id, version) key (RS-41)
a workflow / handler save of a row or stream the body loaded handle as above
a create, or a workflow save of a row the body created none (an insert) the primary key
the canonical destroy none today; ruled if-match for a versioned row (recorded as pending, implemented by #4341) the row's version column, once implemented

A write that loses its check answers 409 whether or not it carried a tag; the client decides whether to retry, and the server never re-runs the command on its own. Two simultaneous writers carrying the same tag yield exactly one 2xx and one 409 (RS-46). Node renders the fact (a census, node-version-check-census.test.ts, keeps it that way); the other backends' gaps are tracked expected failures of the concurrency-two-writers corpus case.

The generated React, Vue, Svelte and Angular clients send it on the update operation, reading the version out of the by-id query cache — the row the user is looking at, under the key the mutation already invalidates on success. Feliz and Flutter do not yet: neither keeps the loaded record where the mutation can reach it, so a hand-written client on those targets should send the header itself. Which frontends send it is pinned by test/generator/if-match-client-parity.test.ts, and that the five backends accept the same grammar by test/generator/if-match-grammar-parity.test.ts.

Surface

filter / stamp are also usable directly (the building blocks a capability body is made of); implements <Cap> / with <Cap> apply a capability:

Where What you write Effect
Inside a capability <field>, filter <expr>, stamp <event> { … } Provided to every implementor.
Inside an aggregate filter <expr>, stamp <event> { … }, with <Cap>, implements <Cap> Applies to that aggregate.
Inside a context filter <expr>, stamp <event> { … }, with <Cap>, implements <Cap> filter/stamp propagate to every aggregate in the context; with/implements <Cap> apply the capability to every aggregate.

The grammar rules are Capability, FilterDecl, StampDecl, and ImplementsDecl in src/language/ddd.langium.

filter <expr>

Declares a query-filter predicate the backend applies to every read of the host aggregate. The expression has this in scope and is type-checked exactly like an invariant body.

aggregate Order {
  isDeleted: bool
  subject: string

  filter !this.isDeleted          // every read excludes soft-deleted rows
}

To apply a filter to some aggregates in a context but not others, bundle it in a capability and opt in per aggregate — the typed replacement for the old filter for "<name>" string-group form:

capability softDeletable {
  isDeleted: bool
  filter !this.isDeleted
}

context Sales {
  aggregate Order  with softDeletable { subject: string }  // gains the filter
  aggregate Public { name: string }                        // no filter
}

Principal terms

A filter may also test the principal alone: a == / != between currentUser.<claim> and another claim, a literal or an enum value, with no column on either side. This is how a filter branches on role — "agents see every ticket, customers see their own":

aggregate Ticket {
  customer: Customer id
  filter currentUser.role == "agent" || this.customer == currentUser.customerId
}

The term's truth depends only on who is asking, so each backend decides it against the request principal in the host language and drops the result into the query as a constant:

Backend currentUser.role == "agent" renders as
node / Drizzle (requireCurrentUser().role === "agent" ? sql\true` : sql`false`)`
node / MikroORM { [raw(requireCurrentUser().role === "agent" ? "1 = 1" : "1 = 0")]: [] }, inside the entity's default schema filter (cond: () => (…), evaluated per query)
.NET / EF Core _currentUser.User.Role == "agent" inside HasQueryFilter (EF binds it as a parameter)
.NET / Dapper @__pt_<hash>, bound to (RequestContext.Current!.CurrentUser!.Role == "agent")
Java / JPA :#{@currentUserAccessor.user() != null and (@currentUserAccessor.user().role() == 'agent')} = true (cb.conjunction() / cb.disjunction() in a Criteria Specification)
Python / SQLAlchemy literal(user.role == "agent"), built in the read-scope hook from the request principal
Phoenix / Ecto ^(not is_nil(current_user) and current_user.role == "agent")

Null semantics are Loom's on every backend (a null claim equals null and differs from every value — which a SQL $1 <> 'agent' over a null claim would not give, so no backend leaves the comparison to SQL), and the term is never true without a principal. Because a capability filter is installed on every read of the aggregate, the synthesised GET /<plural>/{id} included, a principal term is the way to scope single-record reads by role today (the by-id route has no requires surface; see loom.default-deny-by-id-ungated in auth.md). A filtered-out row reads 404 there.

Only == / != qualify — an ordering between two host values has no single meaning across the host languages — and only in a capability filter (inline, or through a named criterion installed as filter <Criterion>). A find / retrieval where still refuses a column-free comparison (loom.find-where-not-queryable / loom.retrieval-where-not-queryable): a per-read principal test is what requires is for.

Backend emission

All five backends — .NET, Hono, Java, Phoenix, and Python — reify relational capability filters, both non-principal and principal-referencing (tenancy). Principal predicates and non-relational shapes have since landed (DEBT-01 / DEBT-02): Python's relational principal case landed (supportsPrincipalFilter now returns true for it, system-checks.ts) and Python's shape: embedded filters landed too (#1571 — supportsNonRelationalFilter/supportsPrincipalNonRelationalFilter now include python for embedded). Principal filters on non-relational shapes ship on node/Java (document + embedded), elixir + Python (embedded), and .NET (all shapes). What stays gated is narrow: a capability filter on a shape: document aggregate hosted on Python (Python wires relational + embedded only; document is a single jsonb blob it doesn't filter in-app) — and elixir has no document shape at all. See Deferred cases below.

A filter that is exactly one named criterion (filter NotDeleted) reifies rather than inlining: the IR carries the reference in contextFilterRefs (index-aligned with contextFilters), and Hono calls the module-level <name>Criterion predicate fn while Phoenix references a shared Ecto query fragment (a <name>_criterion/0 dynamic, e.g. dynamic([r], r.active) / dynamic([r], r.region == "EU")) — deduped with any find/retrieval consumers of the same criterion. Behaviour-identical to the inline form; only the generated code is organised around the criterion as a first-class, reusable predicate.

Deferred cases

One narrow case remains gated by the IR validator (validateContextFilterSupport, code loom.context-filter-no-principal):

Everything else now ships (per the validator gate): a principal predicate on a relational aggregate on all five backends (DEBT-01); a non-principal predicate on a non-relational aggregate on node/Java (document + embedded), elixir + Python (embedded), and .NET (all); and the principal × non-relational intersection on node/Java (document + embedded), elixir + Python (embedded), and .NET — supportsPrincipalNonRelationalFilter accepts it, and the embedded case is gate-verified by embedded-tenancy.ddd in the Java build corpus. Host a document aggregate that needs a filter on any non-Python backend, or hand-write the predicate inside individual repository find bodies, in the meantime.

stamp <event> { … }

Declares lifecycle assignments to run on every create or update of the host aggregate. <event> is onCreate or onUpdate; the body is the same AssignOrCallStmt* shape as an operation body, with this in scope.

aggregate Order {
  createdAt: datetime
  updatedAt: datetime
  createdBy: User id
  updatedBy: User id

  stamp onCreate {
    createdAt := now()
    createdBy := currentUser
  }
  stamp onUpdate {
    updatedAt := now()
    updatedBy := currentUser
  }
}

To apply stamps to some aggregates and not others, bundle them in a capability (the built-in auditable is exactly this) and opt in per aggregate with with auditable.

Stamp targets are server-owned wire fields

A field assigned by any stamp block is server-populated at persist time, so it is never client input — on create or update — while staying readable everywhere:

Read surfaces (responses, views, detail/list pages) keep the field — managed is readable. Pinned cross-backend by test/conformance/stamp-request-no-leak-parity.test.ts.

Backend emission

implements <Cap> / with <Cap>

Apply a capability to the host: the expander deep-clones the capability's members (fields + filter + stamp) into the aggregate (or, at context scope, into every aggregate in the context). with and implements are synonyms for this; with additionally drives macros.

aggregate Order with softDeletable, auditable { subject: string }
// or, equivalently for capabilities:
aggregate Order { subject: string  implements softDeletable  implements auditable }

A with/implements naming neither a macro nor a declared capability is an error — the existence check the old free-string form lacked. References resolve against the expander's document-wide capability inventory (built-ins + user capability declarations), not a Langium cross-reference.

Propagation rules

At expansion time the typed with/implements <Cap> is spliced into the host (src/macros/expander.ts); at lowering time (src/ir/lower/lower-capabilities.ts) context-level filter/stamp members propagate to every aggregate in the context:

Declaration Applies to
filter <expr> at context scope every aggregate in the context
stamp <event> { … } at context scope every aggregate in the context
with <Cap> / implements <Cap> at context scope the capability is applied to every aggregate in the context
with <Cap> / implements <Cap> at aggregate scope the capability is applied to that aggregate
any of the above on an abstract base every concrete that extends it, transitively (enrich pass, src/ir/enrich/inherited-capabilities.ts)

A base's capabilities apply to every subtype (D-INHERITED-CAPABILITIES): the concrete inherits the base's filters (ancestors' first, then its own), stamps, capability tags and crossTenant stance, so ignoring <baseCap> is valid on a subtype read. A subtype repeating a capability its base applies gets that filter once. See inheritance.md.

The read-scope fact

What a read of an aggregate applies is one derived IR fact, readScopeFor(agg, pool, { ignoring }) (src/ir/enrich/read-scope.ts). It lists, in order: the TPH kind discriminator, then every filter — inherited first, then the aggregate's own. Each filter is tagged with its kind (tenancy / capability / deny / bare filter), the capability that contributed it, whether ignoring can drop it, where it is declared, and, for tenancy, its stance (tenant / deep / global / self). For a TPH root, the fact also gives each concrete's own filters in root-hosted form, guarded by the discriminator (kind <> 'Car' OR <pred>). A read that declares no order is ordered by id (readOrderFor). Today the node/drizzle read paths take their restrictions and their default order from this fact, and Java/Hibernate takes its entity restrictions from it. The other backends still derive them from the IR arrays.

Relationship to macros

A capability subsumes the field/filter/stamp macros: auditable and softDeletable are built-in capabilities, not macros. Macros remain for operations and structure — softDelete (operations), crudish, scaffold*. See scaffold-macros.md.

Validation rules