Ownership vocabulary for Ash: uses references that can never dangle, and
ancestor keys that are not parents.
belongs_to conflates three meanings:
- Containment — the record is owned by its parent and must go down with it.
Comment belongs_to Post: archiving the post archives the comment. - Non-owning reference — the record merely uses the target. A
Documentrendered from a sharedTemplatedoes not own it, and retiring the template must never take the documents down — nor may the template vanish while documents still use it. Other examples: immutable document snapshots, shared images or files, license/reference rows. - Denormalized ancestor key — the column carries a grandparent's id for tenant filtering, policies, or indexes. The real parent is a different relationship, and the ancestor should not carry a reverse relationship back to every descendant table.
Reading a resource, you cannot tell these apart: all three are
belongs_to. That matters, because tooling that walks containment — cascade
archival above all — has to guess. AshOwnership gives each meaning its own
name.
The uses vocabulary is borrowed from Rust: a uses edge is a borrow of the
target, and the target cannot be dropped while any borrow is alive.
| Rust | AshOwnership |
|---|---|
| ownership, drop cascades | containment belongs_to (see ash_cascade_archival) |
borrow (&T) |
uses relationship |
| cannot drop while borrowed | FK restrict + archive guard |
| shared borrows | many records may use one target |
# The using side
defmodule MyApp.Document do
use Ash.Resource, extensions: [AshOwnership]
relationships do
uses :template, MyApp.Template
end
end
# The used side
defmodule MyApp.Template do
use Ash.Resource, extensions: [AshOwnership]
relationships do
used_by :documents, MyApp.Document
end
enduses compiles to a plain belongs_to and used_by to a plain
has_many, each carrying a marker — same options, same defaults, and every
Ash feature (loading, forms, policies, migrations) works unchanged. Whether
the reference is required (allow_nil?) or exposed (public?) is orthogonal
to use: a document may well require its template.
Since Ash defaults public? to false, shorthands mirroring ash_req_opt's
belongs_to variants are provided:
| entity | allow_nil? |
public? |
|---|---|---|
uses |
belongs_to defaults | |
req_uses |
false |
true |
req_priv_uses |
false |
false |
opt_uses |
true |
true |
opt_priv_uses |
true |
false |
ancestor compiles to a belongs_to carrying an :__ancestor__ marker. Use
it when the column only holds an ancestor's id — tenant filtering, policies,
indexes — and the record's real parent is a different relationship.
defmodule MyApp.Comment do
use Ash.Resource, extensions: [AshOwnership]
relationships do
belongs_to :post, MyApp.Post # the real parent
ancestor :account, MyApp.Account # denormalized tenant key
end
endThe target needs no reverse relationship: a cascade reaches this record
through its real parent, and the foreign key still keeps the ancestor from
being deleted out from under it. Without ancestor, an account with fifty
descendant tables would need fifty has_many declarations that exist only to
satisfy a containment check — and each one would be a second cascade path to
rows the real parent already covers.
ancestor carries no archive guard: an ancestor is not borrowed, it is
merely denormalized.
While live users exist, the used record can neither be deleted nor archived.
Enforced per path:
- Hard delete — the protection itself comes from the database: any real
foreign key already restricts deleting referenced rows, for
usesand plainbelongs_toalike. What AshOwnership adds is keeping it that way — a verifier rejectson_delete: :delete/:nilifyonusesedges, so the guarantee cannot be silently traded away — and the runtime guard below also rejects destroys, covering data layers without foreign key constraints (e.g.Ash.DataLayer.Ets). Deleting an unused row succeeds. - Soft delete (archive) (when the used resource also uses
ash_archival) — archival is an
archived_atupdate, which no foreign key can see. AshOwnership injects a runtime guard (AshOwnership.Changes.EnsureNotUsed) into every destroy action of a resource with the AshOwnership extension: the destroy is rejected while live users exist. Users are enumerated throughused_by(which is therefore required for every uses edge), querying with the relationship'sread_actionif declared, otherwise the user's primary read — a verifier rejects a filtered primary read as the default, so neither read policies nor action-level filters can silently hide a live user. Archival's global filter still applies, so archived users do not block — archive the users first (or let an ancestor cascade do it, see thearchive_lastoption of ash_cascade_archival) and the used resource becomes archivable. - Using a dead target — the reverse direction is guarded too:
AshOwnership.Changes.EnsureTargetLiveis injected into every create and update action of a using resource, rejecting writes that point ausesforeign key at an archived or missing target — whether the key arrives as direct input or throughmanage_relationship. Without it, a ghost reference could be created instead of left behind.
All cross-module checks run on the user side, so compile-time dependencies flow one way (using side → used side):
usesmust target anAshOwnershipresource.- A plain
belongs_totargeting a resource with the AshOwnership extension is rejected unless it is containment — that is, unless the used resource declares the reversehas_many/has_oneback. A used resource may own children of its own; those go down with it and need no guard. Anything else is a non-owning reference and must be declared withuses. - The
usesreference must create a real foreign key with restrict semantics:ignore?: trueis rejected, andon_deletemust be omitted,:restrict, or:nothing. - The used resource must declare a
used_bymatching every uses edge on both key attributes, so the destroy guard can enumerate every user — on any data layer, archival or not. - A
used_byon the used resource that matches nousesedge is rejected, as is a plainhas_manytraversing a uses foreign key. - The read actions the guards query through must be usable: a declared
read_action(standard relationship option, onusesandused_byalike) must exist, and where the default — the queried resource's primary read — is used, it must carry no action-level filters, preparations, or required arguments. A filtered primary read forces an explicitread_actionchoice instead of silently hiding live rows. When severalused_bycover one foreign key, the destroy guard ORs them, so at least one must be unfiltered: a union of filtered views is not a proof that no user is left.
Known limitations: a used_by pointing at a module that uses nothing
at all is not detectable (the check runs when the user compiles); the
guards fall back to plain application-level checks on data layers without
lock support (e.g. ETS), where a use created concurrently with a destroy is
not serialized — elsewhere they take paired FOR UPDATE/FOR SHARE locks on
the used resource's row; and custom global preparations
that filter default reads must pass guard queries through (check
query.context[:ash_ownership_guard?]) or they will hide live rows from the
guards.
def deps do
[
{:ash_ownership, "~> 0.1.0"}
]
endThe two libraries are independent and compose without knowing about each
other: cascade archival's verifier only constrains belongs_to edges whose
destination is archival with cascade in place, while a uses edge points
at a resource with the AshOwnership extension whose archival (if any) is guarded. Together they
split belongs_to cleanly into containment chains and non-owning uses.