11. Capabilities, filters & stamps
A capability is a pure typed mixin — a named bundle of fields + a query filter + lifecycle stamps that an aggregate (or every aggregate in a context) opts into with with <Cap> / implements <Cap>. The two building blocks are also usable directly on an aggregate or context: filter <expr> AND-s a predicate into every read of the host, and stamp onCreate|onUpdate { … } runs assignments at the persistence boundary. Five capabilities ship built in — auditable, softDeletable, tenantOwned, tenantRegistry, and versioned (applied to every aggregate by default) — alongside the softDelete / softDeleteByDefault / crudish operation macros. Reach for this chapter when you want a cross-cutting read rule, automatic audit/tenant stamping, or to bundle either into a reusable opt-in.
Grammar:
Capability,CapabilityMember,FilterDecl,StampDecl,StampEvent,ImplementsDecl,WithClause,SelfType(Self id),IgnoringClause· Validators:loom.unknown-capability,loom.unknown-macro,loom.capability-host-invalid,loom.self-outside-capability,loom.criterion-not-selectable,loom.context-filter-no-principal,loom.stamp-principal-without-auth,loom.stamp-on-event-sourced-invalid,loom.stamp-read-before-flush,loom.softdelete-field-collision,loom.version-field-collision,loom.filter-bypass-unknown-capability/-no-filter,loom.ignoring-clause-placement,loom.tenant-owned-without-tenancy/loom.tenant-owned-claim-type/loom.tenant-registry-without-tenancy,loom.audit-history-ungated· Docs:../capabilities.md,../tenancy.md,../scaffold-macros.md
A capability is a pure mixin: its body is only Property / FilterDecl / StampDecl — never operations or structure (those stay macros, §Relationship to macros). Applying it is a pre-link, AST→AST splice in the macro expander, so everything downstream (scope, lower, enrich, validate, codegen) sees the spliced members as if hand-written. All examples are generated from one scratch system (user { id tenantId }, auth: required, a Sales.Order and an Inventory.Item) once per backend pin; output is excerpted.
capability — a typed mixin
capability <Name> { <field>* filter <expr>? stamp <event> {…}* } declares a reusable bundle. Resolution of a with/implements <Name> is by the expander's document-wide inventory (built-ins + every capability declaration in the workspace), not a Langium cross-reference — so a capability is globally visible by name.
system Shop {
user { id: string tenantId: string }
capability tenantScoped {
tenantId: string
createdAt: datetime managed
touchedAt: datetime managed
filter this.tenantId == currentUser.tenantId // every read scoped to the caller's tenant
stamp onCreate { createdAt := now() } // stamped at persist time
stamp onUpdate { touchedAt := now() }
}
subdomain D {
context Sales {
aggregate Order with tenantScoped, crudish { // gains the fields, the filter, AND the stamps
subject: string
total: decimal
}
}
}
}
The capability contributes three things to Order: the tenantId / createdAt / touchedAt fields, the tenancy filter, and the two stamps. The next two sections show the filter and stamp halves in real generated output; the rest of the chapter covers application, the built-ins, and the validator-gated cases.
filter <expr> — a predicate AND-ed into every read
filter <expr> declares a query-filter predicate the backend applies to every read of the host aggregate (by-id, by-ids, list, every declared find, every projection sourced from it). The expression has this in scope and must lower to the queryable subset (loom.criterion-not-selectable otherwise). At context scope it propagates to every aggregate inside (see Propagation). The principal-referencing filter this.tenantId == currentUser.tenantId above lands at the per-backend read site — on all five backends:
Drizzle has no global query filter, so the repository builder AND-s the predicate into every root-table read, resolving the principal through the ambient requireCurrentUser():
// db/repositories/order-repository.ts
import { requireCurrentUser } from "../../auth/middleware";
const rootRows = await tx.select().from(schema.orders).where(and(eq(schema.orders.id, id), eq(schema.orders.tenantId, requireCurrentUser().tenantId))); // findById
const countRows = await this.db.select({ value: count() }).from(schema.orders).where(eq(schema.orders.tenantId, requireCurrentUser().tenantId)); // all()
EF Core named query filters — one HasQueryFilter("<Name>", …) per filter. The name is derived from the column the predicate touches (tenantId → "TenantIdFilter"), so multiple capability filters are additive and ignoring can bypass just one (IgnoreQueryFilters(["TenantIdFilter"])). A principal filter is registered on the DbContext (it needs the DI-scoped principal); a non-principal one lives in the entity configuration:
// Infrastructure/Persistence/AppDbContext.cs
modelBuilder.Entity<Order>().HasQueryFilter("TenantIdFilter", x => x.TenantId == _currentUser.User.TenantId);
// Infrastructure/Persistence/Configurations/ItemConfiguration.cs (non-principal, softDeletable)
builder.HasQueryFilter("IsDeletedFilter", x => !x.IsDeleted);
A principal predicate AND-s a SpEL-principal JPQL clause into every read of the JPA repository (the non-principal case instead rides a static @SQLRestriction on the entity — see softDeletable below):
// features/orders/OrderJpaRepository.java
@Query("select e from Order e where (e.tenantId = :#{@currentUserAccessor.user()?.tenantId()})")
Page<Order> findAllPaged(Pageable pageable);
@Query("select e from Order e where e.id = :id and (e.tenantId = :#{@currentUserAccessor.user()?.tenantId()})")
Optional<Order> findById(@Param("id") OrderId id);
# app/db/repositories/order_repository.py
from app.auth.user import require_current_user
row = (await self._session.execute(select(OrderRow).where(and_(OrderRow.id == id, (OrderRow.tenant_id == require_current_user().tenant_id))))).scalars().first()
The principal is threaded as a current_user argument into every repository read:
# lib/api/sales/order_repository.ex
def list(page \\ 1, page_size \\ 20, sort \\ "id", dir \\ "asc", current_user \\ nil) do
query = from(record in Api.Sales.Order, where: record.tenant_id == ^(current_user && current_user.tenant_id))
# …
end
def find_by_id(id, current_user \\ nil) when is_binary(id) do
case Repo.one(from(record in Api.Sales.Order, where: record.id == ^id and (record.tenant_id == ^(current_user && current_user.tenant_id)))) do
Every backend family wires capability filters on every persistence shape (relational, shape: document, shape: embedded); there is no per-backend deferral table. (The one shape-specific consequence: a query-time projection that aggregates in SQL over a filtered shape: document aggregate has no column to name — loom.projection-document-source-capability-filtered; per-row reads still apply the filter on hydration.) The one gate is shape- and backend-independent: a principal-referencing filter needs a request principal, so a deployable without auth: required (and a system user {} block) hosting one is loom.context-filter-no-principal. Under TPH inheritance .NET/EF hosts every filter on the hierarchy root (chapter 8).
Reifying a named criterion
A filter that is exactly one named criterion (capability activeOnly { filter Active }) reifies on node — a module-level <name>Criterion predicate function, shared with every find / retrieval consumer of the same criterion — while the other backends inline the predicate at each read. Behaviour-identical to the inline form; only the code organisation differs.
// db/repositories/order-repository.ts — one shared predicate, called at every read
const activeCriterion = () => not(eq(schema.orders.archived, true));
// findById: .where(and(eq(schema.orders.id, id), activeCriterion()))
// byRegion: .where(and(eq(schema.orders.region, r), activeCriterion()))
# lib/api/sales/order_repository.ex — inlined into each query
query = from(record in Api.Sales.Order, where: not record.archived)
# byRegion:
query = from(record in Api.Sales.Order, where: (record.region == ^r) and (not record.archived))
stamp onCreate|onUpdate { … } — lifecycle assignments
stamp <event> { <assign>* } runs assignments on every create (onCreate) or update (onUpdate) of the host. The body is the same statement shape as an operation body, with this in scope; now() and currentUser (bare, or a claim currentUser.tenantId) resolve normally. The createdAt := now() / touchedAt := now() stamps from §capability land at each backend's persistence boundary — the aggregate class stays pure:
Persist-time, in the Drizzle upsert: db/audit-stamp.ts holds one stampInsert<Agg> / stampUpdate<Agg> pair per stamping aggregate, so each save applies only its own aggregate's stamps. The insert branch stamps the create and update fields; the update branch overlays only the update fields (create-only columns stay immutable). A bare currentUser becomes the ambient ctx.actorId; a save outside a request scope (seed / system) is left unstamped:
// db/audit-stamp.ts
export function stampInsertOrder<T extends Record<string, unknown>>(row: T): T {
const ctx = requestContext();
if (!ctx) return row;
return { ...row, createdAt: new Date(), touchedAt: new Date(), createdBy: ctx.actorId, updatedAt: new Date(), updatedBy: ctx.actorId };
}
export function stampUpdateOrder<T extends Record<string, unknown>>(row: T): Partial<T> {
const ctx = requestContext();
if (!ctx) return row;
const { createdAt: _createdAt, createdBy: _createdBy, ...rest } = row;
return { ...rest, touchedAt: new Date(), updatedAt: new Date(), updatedBy: ctx.actorId } as unknown as Partial<T>;
}
// db/repositories/order-repository.ts: tx.insert(schema.orders).values(stampInsertOrder({ … })) / tx.update(schema.orders).set(stampUpdateOrder({ … }))
A SaveChangesInterceptor (AuditableInterceptor) on the DbContext, one switch arm per stamping aggregate, keyed on entry.State (onUpdate also fires on Added, so a NOT-NULL touched_at is filled on create). now() → DateTime.UtcNow:
// Infrastructure/Persistence/AuditableInterceptor.cs
case Order e:
if (entry.State == EntityState.Added)
{
ctx.Entry(e).Property(x => x.CreatedAt).CurrentValue = DateTime.UtcNow;
}
if (entry.State == EntityState.Added || entry.State == EntityState.Modified)
{
ctx.Entry(e).Property(x => x.TouchedAt).CurrentValue = DateTime.UtcNow;
}
break;
Spring Data JPA auditing: bare now() stamps map to @CreatedDate / @LastModifiedDate, bare currentUser stamps to @CreatedBy / @LastModifiedBy fed by an AuditorAware<String> over the CurrentUserAccessor (config/JpaAuditingConfig.java). Any other value — a claim (currentUser.tenantId) or a computed one (now() + hours(slaHours())) — is assigned in an explicit @PrePersist _stampOnCreate() / @PreUpdate _stampOnUpdate() hook on the entity:
// features/orders/Order.java
@EntityListeners(AuditingEntityListener.class)
public class Order {
@CreatedDate Instant createdAt;
@LastModifiedDate Instant touchedAt;
A _stamp_on_create / _stamp_on_update method on the aggregate, called by the route right before persist (current_user passed only when a stamp reads it):
# app/domain/order.py
def _stamp_on_create(self) -> None:
self._created_at = datetime.now(UTC)
def _stamp_on_update(self) -> None:
self._touched_at = datetime.now(UTC)
# app/http/order_routes.py: created._stamp_on_create() … found._stamp_on_update()
Ecto.Changeset.put_change in the repository's insert / update (the insert also applies the onUpdate stamps); a principal stamp reads the threaded current_user:
# lib/api/sales/order_repository.ex
|> Ecto.Changeset.put_change(:created_at, DateTime.utc_now() |> DateTime.truncate(:second))
|> Ecto.Changeset.put_change(:touched_at, DateTime.utc_now() |> DateTime.truncate(:second))
Stamps that read the aggregate
A stamp belongs to its host: one declared on an aggregate (directly or through a capability it implements) stamps only that aggregate's rows, and a context-level stamp stamps every aggregate in the context. Because this is in scope, a stamp value may read the host's fields, derived values and functions; it is evaluated against the instance being persisted:
aggregate Ticket {
priority: Priority
dueAt: datetime managed
function slaHours(): int = match { priority == Urgent => 4, else => 72 }
stamp onCreate { dueAt := now() + hours(slaHours()) }
}
// node — db/audit-stamp.ts: the save site passes the domain instance
export function stampInsertTicket<T extends Record<string, unknown>>(row: T, aggregate: Ticket): T {
const ctx = requestContext();
if (!ctx) return row;
return { ...row, dueAt: new Date((new Date()).getTime() + (((aggregate.slaHours()) * 3600000))) };
}
# elixir — lib/api/ticketing/ticket_repository.ex: the changeset with its changes applied
|> then(fn cs ->
record = Ecto.Changeset.apply_changes(cs)
Ecto.Changeset.put_change(cs, :due_at, Loom.Datetime.normalize(DateTime.add(DateTime.utc_now(), ((Api.Ticketing.sla_hours(record)) * 3600000), :millisecond)))
end)
.NET reads it off the tracked entity (e.SlaHours()), Java and Python off this / self in the stamp hook.
Validator-gated stamp cases
Three model-level refusals (each reads only the model — the old per-backend loom.<plat>-stamp-unsupported codes are gone), plus one backend gap:
loom.stamp-reads-aggregate-unsupported— the backend gap: a stamp value that reads the aggregate (see above) on a document-shaped aggregate hosted by an elixir deployable. That write seam merges stamps into the request attributes before any record exists; compute the value in the create/operation body, or store the aggregate relationally.loom.stamp-principal-without-auth— acurrentUserstamp on a deployable withoutauth: required/ a systemuser {}(no principal to stamp from; usenow()-only stamps or add auth).loom.stamp-on-event-sourced-invalid— any stamp on apersistedAs: eventLogaggregate (there is no row to stamp; the truth is the stream).loom.stamp-read-before-flush— on a stamping aggregate, acreatebody that reads any of the four audit columns (createdAt/createdBy/updatedAt/updatedBy), or anoperationbody that readsupdatedAt/updatedBy: the value lands only when the unit of work flushes, so the body would observe the prior value (an operation may readcreatedAt/createdBy— the create flush already set them).
with <Cap> / implements <Cap> — applying a capability
with and implements are synonyms for applying a capability: the expander deep-clones the capability's members (fields + filter + stamp) into the host. They differ only in that with also drives macros, so a with clause can name a mix of capabilities and macros.
aggregate Order with softDeletable, auditable { subject: string }
// equivalent, capabilities only:
aggregate Order { subject: string implements softDeletable implements auditable }
An implements naming no declared capability is loom.unknown-capability; a with naming neither a capability nor a macro is loom.unknown-macro. A capability may only be applied to an aggregate or a context — ui W with tagged { } is loom.capability-host-invalid.
Propagation — context vs aggregate scope
filter / stamp / with / implements are admissible at both aggregate and context scope. At context scope they fan out to every aggregate in the context (filters/stamps propagate at lowering, src/ir/lower/lower-capabilities.ts; capability application splices at expansion):
| Declaration | Applies to |
|---|---|
filter <expr> at aggregate scope |
that aggregate |
stamp <event> {…} at aggregate scope |
that aggregate |
filter <expr> / 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 |
Inheritance does not propagate: an abstract base's capability contributes its columns to the subtypes, but the stamp / filter / tenancy stance stay on the aggregate that declared them (chapter 8).
Self id — self-reference inside a capability
A capability that needs to reference its own implementor uses Self id; the expander rewrites Self id → <Host> id when it splices the capability into each aggregate. Self id outside a capability is loom.self-outside-capability.
capability hierarchical {
parent: Self id? // becomes `parent: Order id?` when applied to Order
}
aggregate Order with hierarchical, crudish { subject: string }
// domain/order.ts (node) — the concrete self-FK the backends see
static create(input: { subject: string; /* … */ parent?: Ids.OrderId | null }): Order {
// … parent: input.parent ?? null
}
ignoring — bypassing a filter at a read site
The clause has exactly three homes: a repository find … ignoring …, a query-time projection's where slot (before join / group by / select), and an inline read bound by a let (let xs = Repo.findAll(…) ignoring …, Repo.run(…) ignoring …). ignoring * drops every capability filter on the aggregate; ignoring A, B drops exactly those capabilities'. The let form rides an ordinary postfix chain, so group by o.status ignoring softDeletable also parses — it would bind the clause to the grouping expression, where nothing reads it back, so any position other than the three above is loom.ignoring-clause-placement; naming a capability the aggregate lacks is loom.filter-bypass-unknown-capability, naming one that contributes no filter (ignoring auditable) is loom.filter-bypass-no-filter. All five backends honour the bypass (EF IgnoreQueryFilters, a dropped Drizzle / SQLAlchemy / Ecto conjunct, a Hibernate @FilterDef/@Filter the read disables) — the per-backend output is in chapter 10. One Java gap to know about: a principal filter (currentUser in the predicate) rides the JPQL @Query, and a declared find … ignoring currently keeps that conjunct (select e from Order e where (e.tenantId = …)) — only the non-principal, @SQLRestriction-resident filters are actually bypassed there.
repository Orders for Order {
find all(): Order[] ignoring * // skips every capability filter
}
// db/repositories/order-repository.ts (node) — no tenant term
async all(): Promise<Order[]> {
const rootRows = await this.db.select().from(schema.orders);
// …
}
The built-in capabilities
Five capabilities ship in the toolchain prelude (src/macros/prelude.ts) — usable by name with nothing declared. A user capability of the same name wins (the prelude is a default, not an override). Four are opt-in; versioned is default-on.
| Capability | Fields it contributes | Filter / stamps |
|---|---|---|
auditable |
createdAt / updatedAt (managed datetime), createdBy / updatedBy (managed User id — the principal-id type; prelude-only, a hand-written User id does not resolve, and the columns emit as string) |
stamp onCreate { createdAt := now() createdBy := currentUser }, stamp onUpdate { updatedAt := now() updatedBy := currentUser } |
softDeletable |
isDeleted (internal bool), deletedAt (managed datetime?) |
filter !this.isDeleted — the softDelete() / restore() operations are the separate softDelete macro; compose with softDeletable, softDelete. A user field named isDeleted of another type is loom.softdelete-field-collision |
tenantOwned |
tenantId (internal string), dataKey (internal string? — the materialized ancestor path; internal, so it is out of every API read/create payload, though in-system UI reads still see it) |
stamp onCreate { tenantId := currentUser.<claim> dataKey := currentUser.orgPath }, filter this.tenantId == currentUser.<claim> — the principal side is rebound to the system's tenancy by user.<claim> declaration in enrichment; without one it is loom.tenant-owned-without-tenancy, and a non-string claim is loom.tenant-owned-claim-type (../tenancy.md) |
tenantRegistry |
parent: Self id? (immutable), dataKey: string? (managed) |
none — the tree fields of the registry aggregate named by tenancy by … of <Registry>; the registry is self-scoped, never tenantOwned (loom.tenant-registry-without-tenancy outside a tenancy by system) |
versioned |
version: int token = 1 |
none — the optimistic-concurrency marker, echoed by the client on update (If-Match; a mismatch is a 409). Applied by default to every aggregate that is not persistedAs: eventLog (the event stream is its own concurrency control) — the expander splices it last, so an explicit with versioned is an idempotent no-op. A user-declared version: int is accepted as the counter; version of any other type is loom.version-field-collision |
context Inventory {
aggregate Item with auditable, softDeletable, softDelete, versioned, crudish { // `versioned` is redundant — it is on by default
name: string
}
}
The softDeletable filter !this.isDeleted is non-principal, so no auth is needed and all five backends emit it:
// db/repositories/item-repository.ts — !isDeleted AND-ed into every read
const rootRows = await this.db.select().from(schema.items).where(not(eq(schema.items.isDeleted, true)));
// Infrastructure/Persistence/Configurations/ItemConfiguration.cs — named query filter
builder.HasQueryFilter("IsDeletedFilter", x => !x.IsDeleted);
// features/items/Item.java — a static restriction on the entity (+ a Hibernate filter `ignoring` can disable)
@SQLRestriction("not (is_deleted)")
public class Item { … }
# app/db/repositories/item_repository.py
rows = (await self._session.execute(select(ItemRow).where(not_(ItemRow.is_deleted)).order_by(_order).limit(page_size).offset(offset))).scalars().all()
# lib/api/inventory/item_repository.ex — Ecto where clause threaded onto every read
query = from(record in Api.Inventory.Item, where: not record.is_deleted)
The auditable stamps thread currentUser — each backend through its own stamping seam (§stamp):
// db/audit-stamp.ts — createdBy / updatedBy from the ambient actor id
return { ...row, createdAt: new Date(), createdBy: ctx.actorId, updatedAt: new Date(), updatedBy: ctx.actorId };
// Infrastructure/Persistence/AuditableInterceptor.cs
case Item e:
if (entry.State == EntityState.Added)
{
ctx.Entry(e).Property(x => x.CreatedAt).CurrentValue = DateTime.UtcNow;
ctx.Entry(e).Property(x => x.CreatedBy).CurrentValue = RequestContext.Current!.CurrentUser!.Id;
}
if (entry.State == EntityState.Added || entry.State == EntityState.Modified)
{
ctx.Entry(e).Property(x => x.UpdatedAt).CurrentValue = DateTime.UtcNow;
// … UpdatedBy
}
break;
// features/items/Item.java
@CreatedDate Instant createdAt;
@LastModifiedDate Instant updatedAt;
@CreatedBy String createdBy;
@LastModifiedBy String updatedBy;
# app/domain/item.py
def _stamp_on_create(self, current_user: User) -> None: …
def _stamp_on_update(self, current_user: User) -> None: …
# app/http/item_routes.py: created._stamp_on_create(current_user) / found._stamp_on_update(current_user)
# lib/api/inventory/item_repository.ex — insert applies both stamp sets
|> Ecto.Changeset.put_change(:created_at, DateTime.utc_now() |> DateTime.truncate(:second))
|> Ecto.Changeset.put_change(:created_by, current_user && current_user.id)
|> Ecto.Changeset.put_change(:updated_at, DateTime.utc_now() |> DateTime.truncate(:second))
|> Ecto.Changeset.put_change(:updated_by, current_user && current_user.id)
The softDelete macro contributes the operations the capability deliberately omits:
// domain/item.ts (node)
public softDelete(): void {
this._isDeleted = true;
this._deletedAt = new Date();
this._assertInvariants();
}
public restore(): void {
this._isDeleted = false;
this._deletedAt = null;
this._assertInvariants();
}
Relationship to macros
A capability subsumes the field/filter/stamp surface; operations and structure stay macros — softDelete (the softDelete()/restore() ops), softDeleteByDefault (context-wide application), crudish (canonical create(...) / destroy {} plus an update(...), built from the host's writable fields; crudish(updateOnly: true) leaves deletion to softDelete), and the scaffold* family. crudish builds its parameters from the host's fields minus three sets: fields another macro contributed (origin-tagged), fields a stamp assigns (createdAt, touchedAt), and fields whose access modifier keeps them off the payload (managed, token, internal; update also drops immutable) — so auditable's columns, softDeletable's isDeleted, tenantOwned's internal tenantId and the default version token are all out. A plain field a user capability contributes is in: tenantScoped's tenantId: string above lands in create(input: { subject; total; tenantId; … }). The aggregate-header audited modifier (an audit_records trail plus GET /<aggs>/{id}/history; under denyByDefault the history read inherits the list gate — loom.audit-history-ungated) is a sibling facility, not a capability — see chapter 20. See ../scaffold-macros.md.
Validation rules
implements <X>naming no capability →loom.unknown-capability;with <X>naming neither a capability nor a macro →loom.unknown-macro; a capability on aui/apihost →loom.capability-host-invalid.- A capability with no implementors is allowed (declared but unused).
- A capability's
filter/stampbody type-checks against each implementing aggregate — e.g. astamp onCreate { createdBy := currentUser }requires every implementor to carry acreatedByfield; a missing field is an IR-validation error. Afilteroutside the queryable subset →loom.criterion-not-selectable. Self idoutside a capability →loom.self-outside-capability.- A principal-referencing
filterorstampon a deployable with no auth →loom.context-filter-no-principal/loom.stamp-principal-without-auth; any stamp on an event-sourced aggregate →loom.stamp-on-event-sourced-invalid; reading a stamp field in the stamped body →loom.stamp-read-before-flush. - A user field colliding with a built-in's flag →
loom.softdelete-field-collision(a non-boolisDeletedundersoftDeletable),loom.version-field-collision(a non-intversionon any non-event-sourced aggregate —versionedis default-on, so this fires without anywith). tenantOwnedoutside atenancy bysystem →loom.tenant-owned-without-tenancy; a non-stringtenancy claim →loom.tenant-owned-claim-type;tenantRegistryoutside one →loom.tenant-registry-without-tenancy.ignoringplacement / target →loom.ignoring-clause-placement,loom.filter-bypass-unknown-capability,loom.filter-bypass-no-filter.