Bounded variable-length graph traversal as an Ash manual relationship.
has_many :descendants, MyApp.Node do
manual {AshArcadic.ManualRelationships.Traverse,
edge_label: :PARENT_OF, direction: :outgoing, min_depth: 1, max_depth: 3,
scope_edges: true}
endload/3 runs the Option-B two-phase read with per-hop authorization:
- Phase 1 (reachability). One parameterized statement —
UNWIND $ids AS sid MATCH p=<pattern> WHERE <src-pk-match> [AND <tenant scope>] RETURN <src-pk cols>, b.<dest-pk> AS d, [n IN nodes(p) | n.<dest-pk>] AS path— returns each matched path's destination PK plus the PKs of EVERY node on that path, not the vertices. For:attribute, the tenant scope binds the path and filters BOTH nodes and edges by the discriminator —ALL(x IN nodes(p) WHERE x.<attr> = $tenant) AND ALL(r IN relationships(p) WHERE r.<attr> = $tenant)— on by default;scope_edges: falseopts out of the edge clause (probe E4). - Phase 2 (two authorized reads). Read A authorizes EVERY path node: a STANDARD
Ash.readon the destination resource over the union of every path node's PK (destinations AND intermediates), carryingactor/authorize?/tenant/domainso row policy alone decides each node's visibility (Read A does NOT carry the caller's destination filter/sort — a filtered-out but authorized intermediate is still traversable). Each source's destinations that have a fully-authorized path then form Read B: a STANDARD authorizedAsh.readnarrowingcontext.query(the relationship/caller filter + sort + domain + tenant) to those surviving dest PKs, applying row policy, field policy (redaction), and the:attributetenant filter /:contexttenant DB. This module never decodes a vertex. NOTE: Ash rejects DYNAMIClimit/offseton manual relationships; per-source paging is therefore the STATICper_source_limit/per_source_offsetopts, applied as a post-authz, post-sort top-N slice in Phase 3 (regroup), never via Ash paging. The callerfilter/sortapply to destinations by Read B. - Phase 3 (regroup). Assembles each source's records from Read B in READ order (so the
caller sort survives), keeping those whose PK survived per-hop authorization — a
destination reachable ONLY through a row-policy-denied intermediate is dropped, while a
destination with any fully-authorized path survives; deduped per source, then sliced to the
per-source
per_source_offset..+per_source_limittop-N (post-authz, post-sort;nillimit = unbounded), cardinality-aware.
It returns a source-PK-keyed map of authorized destination records; authorization is
enforced by row policy on EVERY node on the path (Read A), not only the returned
destinations. This covers self-referential traversal (the shipped norm); a multi-resource
path whose intermediates carry a different policy is a Slice-3 concern and fails closed here.
A single-attribute destination primary key is required (composite → fail-closed value-free,
mirroring the edge-write dest-PK rule). Values ride params; every interpolated identifier
is AshArcadic.Identifier.validate!-checked. Tenancy is FAIL-CLOSED twice over: Phase 1
scopes the path (or targets the per-tenant :context database), and both Phase-2 reads
re-apply the tenant filter / database.