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.
- .NET / EF Core 10 — every aggregate that has any propagated
contextFiltersgets one namedb.HasQueryFilter("<Name>", x => …)per filter inside itsEntityConfiguration.Configure(...)method (EF Core 10 named query filters). Names are derived from the reifiedcriterionname (activeOnly→"ActiveOnlyFilter") or, for anonymous predicates, from the single column they touch (!this.isDeleted→"IsDeletedFilter"), falling back to a positional"Filter<n>". Naming matters: pre-EF-10 a secondHasQueryFiltercall silently overwrote the first, so an aggregate carrying two capability filters (e.g.softDelete+ a tenancyfilter) lost one at runtime — named filters make all of them additive again, and let a query selectively bypass just one viaIgnoreQueryFilters(["<Name>"]). Emission is per-entity-type; the filter applies to every query of that aggregate (capability application was resolved earlier, at expansion + lowering). UndersharedTable(TPH) inheritance the entity type is the hierarchy ROOT — EF accepts a filter nowhere else. A filter the root declares is registered there once and reaches every subtype, so a subtype'signoring <rootCap>names the root's filter; a subtype's own filter is registered on the root config, type-guarded (!(x is Car) || (!((Car)x).IsDeleted)) — so it may read a column only that subtype declares — and prefixed ("Car_IsDeletedFilter") so two subtypes carrying the same capability keep distinct keys. Every read of a TPH concrete starts from the root set (_db.Vehicles.OfType<Car>()), the source over which EF translates the guard (docs/inheritance.md). EF Core resolves the DI-scoped principal and queries jsonb transparently, so .NET has no other deferred cases. Seesrc/generator/dotnet/emit/efcore.ts. - Hono / Drizzle — Drizzle has no global query filter, so the
repository builder AND-s each predicate into every root-table read
site (
src/generator/typescript/repository-find-predicate.ts). A principal predicate renders against the ambientrequireCurrentUser()accessor (the analog of EF Core's DI-resolved principal); a non-relational aggregate filters in-app over the rehydrateddocument, or AND-s into the SQL read for anembeddedroot (whose scalars are real columns). - Python / SQLAlchemy — SQLAlchemy's native global-criteria hook: one
do_orm_executelistener (app/db/read_scope.py,src/generator/python/read-scope-builder.ts) adds each restriction to every ORM SELECT aswith_loader_criteria(<Row>, …), so no repository read spells one.ignoringis theloom_ignoringexecution option. A principal predicate is built from the request principal on each execution (false()with none); a TPH subtype's own filter is hosted on the root row class, guarded bykind; by-id reads run aselectwithpopulate_existingso the identity map cannot answer for the hook. Adocumentaggregate filters in-app over the rehydrated instance. - Java / Hibernate — a static
@SQLRestriction("…")on the entity for the non-principal predicate (src/generator/java/emit/entity.ts); a principal predicate (tenancy) is an auto-enabled@Filterwhose claims are@ParamDefparameters, each resolved from the request principal per query (CurrentUserAccessor.currentOrNull(); no principal binds null and matches no row) — so it scopes every query of the entity, Spring Data'sfindAll/existsByIdand thebumpVersionupdate included, and no@Queryspells it. A write scope narrower than the read scope is a<Agg>_writeScopefilter the command load enables. A non-relational aggregate filters in-app viafindAll().stream()fordocument, or rides the same@SQLRestrictionfor anembeddedroot. A capability some readignorings is instead an auto-enabled, by-key named filter (@FilterDef(autoEnabled = true, applyToLoadByKey = true)+@Filter) that the bypassing read disables. Under TPH every restriction is hosted on the ROOT entity: the root's own unguarded, each concrete's own guarded bykind(a subtype's filter is named<Concrete>_<cap>), because Hibernate refuses@SQLRestrictionon a SINGLE_TABLE subclass and applies a subclass@Filteronly to queries of that subclass, never tofrom <Base>orfind(<Base>.class, id)(src/generator/java/capability-filter.tsrestrictionHostFor). A restricted aggregate'sfindByIdis a JPQL query: a primary-keyfindof a row the session already manages runs no SQL, so no restriction applies. - Phoenix / Ecto — a
whereclause AND-ed into each generated read in the context module — the platform analog ofHasQueryFilter(src/generator/elixir/domain-emit.ts). A principal predicate renders aswhere: r.<field> == ^current_user.<field>with the current user threaded onto every read. Anembeddedroot rides the samewhere(its root attributes are real columns;documentis not an elixir shape).
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):
- A capability filter on a
shape: documentaggregate hosted on Python. Python wires relational +shape: embeddedfilters (principal and non-principal, DEBT-01/DEBT-02, #1571) but does not filter adocumentblob in-app, sosupportsNonRelationalFilter/supportsPrincipalNonRelationalFilter(system-checks.ts) includepythonforembeddedonly. (Elixir has nodocumentshape at all, so the case can't arise there.)
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:
- Enrichment promotes a stamp target that is still create-writable to
access: managed(promoteStampTargets), which drops it from the create-input contract (forCreateInput) every backend's Create request DTO, the frontend api-module schema, the scaffolded create-form inputs, and the Playwright page-object fill derive from. - The
crudishupdateoperation excludes stamp targets from its params (writableUpdateFields), so the Update request DTO — which is shaped from those params on every backend — cannot mass-assign a stamped column (often the very one a row-securityfilterreads).
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
- .NET / EF Core — context stamps are emitted as a
SaveChangesInterceptor(AuditableInterceptor) registered on theDbContext. The interceptor fires duringSaveChangesAsyncwith a per-entity-type switch — one arm per aggregate that has any stamping rules — and applies theonCreate/onUpdateassignments based onentry.State. The interceptor body renders through the same expression machinery operation bodies use, socurrentUser/now()/ etc. resolve normally. Seesrc/generator/dotnet/emit/auditable-interceptor.tpl.ts. Note that this means stamps fire at save time, after the operation body has already run. - Java / JPA — a bare
now()/ barecurrentUserstamp rides Spring Data auditing (@CreatedDate/@LastModifiedDate,@CreatedBy/@LastModifiedBy); every other value (a claim read, or a computed value such asnow() + hours(slaHours())) is assigned in the entity's@PrePersist _stampOnCreate()/@PreUpdate _stampOnUpdate()hooks againstthis. - Hono (
node) — context stamps are applied persist-time bydb/audit-stamp.ts, which holds onestampInsert<Agg>/stampUpdate<Agg>pair per stamping aggregate; the repositorysave()wraps its insert row and update set in its own aggregate's pair. Anow()value renders tonew Date(); a barecurrentUservalue resolves to the ambientctx.actorId; a value reading the aggregate renders against the domain instance the save site passes (aggregate.slaHours()). - Python / FastAPI — context stamps are applied right before the
repository persist: the domain class gets
_stamp_on_create/_stamp_on_updatemethods (now()→datetime.now(UTC); acurrentUservalue →current_user.id, threaded fromrequest.state.current_user) that the route handler calls before save. The in-process save sites — workflow command routes, explicit command handlers, event reactors — call the same methods (src/generator/python/stamp-call.ts): alet x = X.create(…)binding takes the create stamp, a loaded one the update stamp, and a principal stamp reads the ambient principal (app.auth.user.current_user()); a save with no principal in scope (an outbox-relayed reactor) persists unstamped, as Hono's does. Support is gated byvalidatePythonStampSupport(a principal stamp without auth, or any stamp on an event-sourced aggregate, stays a fail-fastloom.python-stamp-unsupported). - Phoenix (
elixir, plain-Ecto foundation) — context stamps are applied asEcto.Changeset.put_changepipe lines on the changeset right beforeRepo.insert/Repo.update(threaded through the contextcreate_<agg>/update_<agg>delegate into the repository).now()renders toDateTime.utc_now(), and acurrentUservalue resolves to the principal id read off the threadedcurrent_usermap (current_user.<idKey>, nil-safe) — the controller pulls it fromconn.assigns.current_user(the Auth plug populates it) and threads it on the write call.onCreatestamps apply on insert only andonUpdatestamps apply on BOTH insert and update, so a NOT-NULLupdated_*audit column is filled on the initial insert. The auditcreatedAt/updatedAtbecome real Ecto schema fields (replacing the bundledtimestamps()), and the managedcreatedBy/updatedByare excluded from the changeset cast. Seesrc/generator/elixir/vanilla/stamp-emit.ts. - The elixir backend keeps the same two fail-fast cases
(
loom.elixir-stamp-unsupported,validateElixirStampSupport): acurrentUserstamp on a deployable WITHOUT auth (no actor to thread), and stamps on an event-sourced aggregate.
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
- A
with/implementsnaming no macro and no declared capability is an error. - A capability with no implementors is allowed (declared but unused).
- Field references inside a capability's
filter/stampbody type-check against each implementing aggregate, so a capability whosestamp onCreate { createdBy := currentUser }assignscreatedByrequires every implementor to have acreatedBy: User idfield — a missing field is an IR-validation error.