All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.1.0 - 2026-07-15
First public release: the Ash Framework DataLayer for ArcadeDB, executing native
OpenCypher over the HTTP command API via the arcadic transport. Everything below is
the initial surface.
Added
upsert_conditionsupport (single-row + bulk). Previously the condition was silently ignored — every conditional upsert clobbered the matched row. Now: condition true → update applies; false → skipped (single-rowStaleRecord, or the existing row flaggedupsert_skippedunderreturn_skipped_upsert?: true; bulk omits skipped rows from returned records per Ash semantics); no match → plain create. Conditional upserts run a tenant-scoped three-step flow (conditional UPDATE → existence probe → CREATE), atomic under the action's transaction; condition literals are encode-gated value-free. Cross-tenant isolation mutation-proven.Keyset pagination +
Ash.stream!(Slice 11).can?(:keyset)advertised (with the requireddata_layer_keyset_by_default?/0 → falsecallback):page: [after:/before: cursor, limit:, count:]andAsh.stream!use efficient cursor pagination over any stored comparable sort attribute (:integer/:float/:boolean/:string/datetime/time incl. microsecond), with the primary key as tiebreaker. Cross-tenant isolated on both strategies;page: [count: true]tenant-scoped. A:binary/:decimalsort fails closedUnsortableField; a non-stored/calc/aggregate sort fails closed value-freeUnsupportedFilter. Perf guarantee is bounded MEMORY (streaming), not bounded time — no sort-index DSL, so add a host-side index for deep-pagination speed.:async_engine— concurrent reads/loads (Slice 11). Probe-verified pool-safe: Ash runs independent relationship/aggregate loads concurrently on a read (transactional actions stay sync) — always safe/deterministic.Concurrent bulk writes converge (Slice 11). Every autocommit write statement retries an optimistic-lock conflict (
ConcurrentModificationException— bucket contention) at two levels: ArcadeDB's server-side statement retry (arcadicretries:) + a client-side jittered-backoff retry — both idempotency-safe by construction (autocommit is all-or-nothing) and Ash hooks are never re-fired.Ash.bulk_*withtransaction: false, max_concurrency: Nconverges fully even on a default-bucket type (deterministic 80/80 × 10 runs; a batch is oneUNWINDstatement, sotransaction: falsecosts no atomicity). The defaulttransaction: :batch(session per batch) conflicts at COMMIT where no statement retry is safe — pre-create hot types with buckets ≥ concurrency and check.statusthere. Knob:config :ash_arcadic, :write_conflict_retries, N.
Fixed
- Temporal range/eq/
incomparisons no longer silently return[](Slice 11). ArcadeDB auto-coerces stored ISO8601 datetime/time strings to native temporal types; AshArcadic now wraps the bound comparison param in the matching Cypher constructor (datetime()for:utc_datetime/:naive_datetime/usec,localtime()for:time/usec) so:datetime/:timefilters and keyset cursors compare correctly.:dateis unaffected (kept a string). A COMPOUND temporal comparison (temporal attr vs a value-expression) now fails closed value-free (UnsupportedFilter) instead of silently returning[]. - Composite-typed sorts fail closed (Slice 11). A keyset/
ORDER BYover a:map/:struct/:union/{:array, _}attribute (no total order) is now rejectedUnsortableFieldinstead of silently mis-paging/mis-ordering.
Security
Read-path value-free encode-gate (Slice 11). A non-encodable value in a read filter literal (a raw non-UTF8 binary nested in a
:map/:list) now fails closed value-free (%QueryFailed{}naming the failure class, never the bytes) at every read wire site (flat, aggregate/count, combination, traversal, vector-candidate) — closing the last surface of theJason.EncodeErrorbyte-leak class (AGENTS.md Rule 4) on the read path.Vector search — dense kNN (Slice 10, Plan 1). ArcadeDB dense vector similarity search is first-class: declare
vector_index :embedding, dimensions:, similarity:in thearcadeblock (metadata + compile validation — the host creates the index viaArcadic.Vector.create_dense_index; no migration machinery), attachAshArcadic.Preparations.VectorSearchto a read action whose:query_vector/:karguments drive the search, and read records ranked closest-first with the distance onrecord.__metadata__[:vector_distance]. Executes through the ArcadeDB SQLvector.neighborspath (separate from the Cypher engine). Fail-closed multitenant::contextruns the kNN in the tenant DB;:attributeSELF-INJECTS the tenant predicate (never trusting Ash's filter — a:bypassaction cannot leak) and scopes via the native candidate-setfilter:; a no-tenant search fails closed. Cross-tenant search is a deliberate two-part opt-in (Ash actionmultitenancy :allow_global/:bypassAND preparationallow_global?: true). Candidate cardinality is bounded bymax_vector_candidates(default 10 000, config-overridable) — reject, never truncate. Params-only, value-free;can?(:vector_search)/can?({:vector_search, :dense})advertised; avector_search?read-span telemetry tag.Vector search — sparse + hybrid fusion (Slice 10, Plan 2). Extends the vector slice with sparse (learned-sparse / BM25-style) kNN and hybrid fusion. Declare a
sparse_vector_index :name, tokens:, weights:over a(tokens, weights)attribute pair (both stored, non-sensitive, array-typed — compile-verified); host-creates viaArcadic.Vector.create_sparse_index. AttachAshArcadic.Preparations.VectorSearchwithkind: :sparse(arguments:query_tokens/:query_weights/:k) orkind: :hybridwith anarms:list of{:dense, index}|{:sparse, index}|{:fulltext, property}(≥2 arms;fusion:rrf/:dbsf/:linear). Sparse and hybrid results rank byscoreonrecord.__metadata__[:vector_score](dense keepsdistance). The full-text fuse arm ships now (host-created full-text index; its DSL declaration surface is a later slice). Tenant scoping is identical to dense — the self-injecting candidate-set scopes the sparse arm AND every hybrid arm including full-text (mutation-proven live: a:bypasssparse or hybrid-full-text action is still scoped by self-injection alone). A crafted/malformed stash fails closed value-free per kind.can?({:vector_search, :sparse})/{:vector_search, :hybrid}advertised; value-freevector_kindtelemetry tag. Sparse retro-index caveat (documented): a sparse index does not cover rows created before it — create it before loading.Query-scoped bulk writes + atomics (Slice 9, Plan 1).
update_query/destroy_query(can?(:update_query)/can?(:destroy_query)/can?(:expr_error)): a query-scoped bulk update or destroy compiles to ONE parameterized Cypher statement — the tenant predicate, caller filter, and changeset filter all ANDed into the WHERE — instead of Ash's per-row:streamfallback. An empty match is a no-op ({:ok, []}), neverStaleRecord(bulk semantics differ from the single-row path). Bulk destroy withreturn_records?: truecaptures each row's properties BEFORE the delete (… WITH n, properties(n) AS p DETACH DELETE n RETURN p). Atomic expression updates (change atomic_update(:field, expr(field + 1))) push into CypherSETvia a new pureAshArcadic.Query.Writebuilder overAshArcadic.Query.Expression(can?({:atomic, :update|:create|:upsert})) — the RHS hydrated then translated, every literal bound; atomiccreate_atomicsfold into EVERY create surface (single-rowcreate, bulkcreate, and the upsertON CREATE SET) andatomicsinto the upsertON MATCH SET. A write to the multitenancy discriminator (atomic OR static), asensitive/non-stored atomic TARGET (never plaintext into an encrypted-binary column), an empty SET, and asensitive/non-stored/:binary/:decimal/relationship/ aggregate atomic RHS all fail closed value-free. Alimit/offset/combination_ofon a query-scoped bulk write fails closed (a singleMATCH … SET/DELETEcannot honor per-row paging; usestrategy: :stream), as does a conditional after-batch hook on the action. Multitenancy stays fail-closed on every path (:contextblank tenant → no statement;:attributescoped by the WHERE predicate) — a fabricated cross-tenant attacker cannot cross tenants (mutation-proven). Write-path params (static + atomic-RHS literals) are JSON-encode-gated before the wire (a poisoned value fails closed value-free, never a byte-leaking crash). Read/write-span telemetry gains a value-freematchedtag and:update_query/:destroy_queryspans. Also advertisescan?({:filter_expr, <literal>})so an all-literal filter/atomic that Ash constant-folds (expr(100 + 1)→101) is accepted (bound as a param).Heterogeneous bulk update + multi-row bulk upsert (Slice 9, Plan 2).
update_many(can?(:update_many)): a heterogeneous bulk update — each record its own changes, viaAsh.update_many/4withstrategy: :atomic— compiles to oneUNWIND … MATCH … SET n += r.set[, <shared atomics>]statement keyed by primary key (records absent from the graph are simply absent from the result). The group's sharedchangeset.filter(optimistic lock / atomic validation / policy) AND-composes onto the WHERE, fail-closed on an untranslatable filter (symmetric with the single-rowupdate/destroypath). update_many scopes by the ToTenant-normalized tenant (matching the single-row path) and fails closed if one primary key matches more than one row (ArcadeDB enforces no PK uniqueness — sibling parity with the single-row cardinality guard). Multi-row bulk upsert (lifting the prior "MERGE is single-row" rejection):UNWIND … MERGE … ON CREATE … ON MATCH …upserts many rows in one statement — the merge key including the:attributetenant discriminator so a cross-tenant primary-key collision creates a new tenant-local row instead of hijacking another tenant's (fail-closed, P4/D4-verified, mutation-proven). Atomic changes fold on BOTH upsert branches (create_atomics→ ON CREATE,atomics→ ON MATCH); the discriminator is never in the ON MATCH set (D3).:bulk_create_with_partial_successis false (D9 — ArcadeDB enforces a UNIQUE index built via SQL-DDL, but anUNWINDbatch aborts whole-batch with no per-row attribution, so a bulk write is one atomic unit, never partial success). Multitenancy stays fail-closed on every path (:contextblank tenant → no statement;:attributescoped by the discriminator); every wire value is JSON-encode-gated value-free. Telemetry gains:update_manyspans and abulk_upsert?tag.Combinations (Slice 8, Plan 2).
combination_ofsupport (can?(:combine)/can?({:combine, :base|:union|:union_all|:intersect|:except})): nativeUNION/UNION ALLCypher push-down — oneCALL { <branch> UNION[ ALL] <branch> } … RETURN nstatement — when every branch is union-family; an in-memory primary-key-keyed set-op fold when anyintersect/exceptis present (ArcadeDB has noINTERSECT/EXCEPT, live-verified parse error), which fetches each branch's full filtered result set into the app before combining. Combinations return whole vertices (no field-projectionselect); the fold keys on the primary key. Multitenancy is enforced per branch::contextrequires every branch to resolve to the SAME non-nil tenant database (else fail-closed value-free — a blank tenant or branches spanning databases are rejected);:attributescoping rides the outerquery.filters(the tenant predicate Ash injects on the outer combination query), applied by the nativeCALL-wrapWHEREand pushed into every branch on the in-memory path (so a cross-tenant PK collision can never enter the fold). An outerdistinctover a combination keeps an engine-arbitrary representative per group (does not honordistinct_sort— the union output has no stable pre-collect order). Read-span telemetry gainscombination?/combination_types/combination_strategy(:native|:in_memory) tags. A per-branchlimit/offsetroutes the combination to the in-memory strategy (so the tenant filter applies to each branch before its limit; a spuriousoffset: 0default is a no-op). Fails closed value-free on combination shapes this slice does not support: per-branchcalculations, a branch expression-calculationsort, a mid-chain:base, and — when the query runs on the in-memory path (anyintersect/exceptor per-branch paging) — an expression-calculation outersortor a lazy outer filter:expression(both are honored on the native path). Loading an aggregate or calculation ON a combination read also fails closed value-free (out of scope this slice). Documented Ash-core limitation (not data-layer-fixable): a standaloneAsh.count/Ash.sum/Ash.aggregateOVER a combination silently drops the combination in Ash core (the aggregate action rebuilds the query withoutcombination_of) and returns the un-combined base result — aggregate a combination by reading it and folding app-side.Distinct (Slice 8, Plan 1).
distinct/distinct_sortsupport (can?(:distinct)/can?(:distinct_sort)): native Cypher DISTINCT-ON-subset (WITH n.<f> AS __d0, collect(n)[0] AS n), representative row chosen bydistinct_sort(falling back to the query'ssort, Ash's documented contract; with neither, the representative is engine-arbitrary and the order stage is elided); outer sort/paging apply after the dedup. Aggregates over a distinct query (Ash.count, pagecount: true,sum/min/…) fold the deduped representatives, never the raw rows. Fails closed value-free on a non-stored,sensitive, calculation, relationship-path, or non-atom distinct entry, and on any sort direction outside Ash's six qualifiers (distinct_sortreaches the data layer with no upstream validation; the same direction clamp now also guards the direct data-layersort/3ingress);distinct_sortadditionally rejects:binary/:decimal(unsortable storage), symmetric with the record sort path. Dedup is per-tenant under both multitenancy strategies. Read-span telemetry gains adistinct?tag.Expression calculations (Slice 7). Ash expression calculations are first-class: they load (computed in Elixir over the flat
RETURN n, so sensitive fields stay app-decrypted), and filter-on-calc, sort-on-calc, and raw-attribute filter-expansion (filter(res, a + b > 5)) push down to Cypher via the newAshArcadic.Query.Expressiontranslator (WHERE / ORDER BY). Supported: arithmetic (+ - * /, division forced float to match Ash), concat (<>), comparison, boolean,if/cond(→CASE),is_nil,string_downcase/string_length/length/string_trim/round(round/1only), andcontains/string_starts_with/string_ends_with; a comparison may carry a compound value expression on either side (a + b > 5,a > b + 1). Asensitiveor non-stored field in a calc expression fails closed value-free on all paths (the data layer holds only ciphertext — use a module calc for a derived sensitive value); a relationship-path calc (expr(author.name)) fails closed value-free on all paths, including load (never routed through Ash'sauthorize?: falserelationship-load fallback); un-mapped operators/functions (date/time,fragment,type) likewise fail closed value-free. All four Ash sort nil-placement qualifiers are honored, and a raising calc eval is caught + redacted value-free. Parity boundary: pushed filter/sort matches the loaded value except at non-natural declared-type coercions,round/1of negative half-integers, and division by zero (use a module calc there). Advertises the value-operatorcan?({:filter_expr, …})set plus:expression_calculation/:expression_calculation_sort. Module calculations and standaloneAsh.calculateare unchanged.Filter-ops hardening (Slice 6). Value-comparison filters on a
sensitive(app-side-encrypted binary) field now fail closed value-free (%UnsupportedFilter{}) instead of silently returning[]—sensitiveis the "do not filter" contract, withis_nil/not is_nil(presence) allowed as a documented oracle. Value comparisons on non-stored (skip-ped/computed) fields likewise fail closed value-free (mirroring the sort rule); a%Ref{}in a non-first string-function argument fails closed value-free. A string function over a relationship path (e.g.,contains(rel.field, "x")) is documented as an upstream Ash limitation — Ash 3.29.3'sscope_refsraisesKeyErrorbefore AshArcadic sees the filter; use a flat filter or load-then-filter pending the upstream fix. This also closes the Slice-5 sensitive-destination-FK relationship-load residual at load time.Standard (attribute-FK) relationships (Slice 5).
belongs_to/has_many/has_one/many_to_manyare first-class for AshArcadic-backed resources — an attribute FK stored as a vertex property (NOT a graph edge), loaded/aggregated via Ash's core batched-INloader over the existingrun_query(no new callback). Ships: the{:filter_relationship, standard}capability (value-keyed offis_nil(rel.manual), sobelongs_to/m2m— which carry no:manualfield — are covered); aValidateRelationshipFkcompile-verifier (asensitivejoin attribute fails closed, value-free); aninternal?telemetry tag distinguishing relationship-filter nested reads; standard-rel aggregates via the Slice-4 fold (incl.%Ash.ForbiddenField{}field-policy fail-closed);exists(rel, …)and m2m loading (two-INpath), tenant-scoped. ManualTraverserelationships remain fail-closed for filtering (unchanged, regression-pinned).Fail-closed relationship-filter authorization (Slice 5, security). A source-on-related FILTER routes through Ash's separate-read IN-rewrite, which reads the destination
authorize?: false— bypassing the destination's row policy and oracling field-policy-protected values (tenant isolation is unaffected). AshArcadic now rejects ("not filterable", for every actor) filtering across a relationship whose destination carries any authorizer; loading and aggregates are unaffected. (Known limitation, Ash-core, not data-layer-fixable: theexists(rel, …)path is not gated by this guard — Ash decomposes a relatedexistsinto flat reads before the data layer sees it, and the one data-layer lever over-rejects Ash's own relationship-referencing read policies; the proper fix is an upstream Ash-core hook. Seeusage-rules.md. Filtering a source on a many_to_many related field is likewise unsupported — Ash rejects it ("cannot access multiple resources…") because AshArcadic advertises no join; m2m loading and aggregates work.)Filter-on-aggregate fails closed (Slice 5).
filter(res, <aggregate> > n)previously mis-translated to a non-existent stored property and silently returned[]; it now fails closed with a value-free%UnsupportedFilter{}(aggregate/calculation Refs are computed, not stored). A constant-folded boolean predicate (existsover an empty match set) now correctly translates to atrue/falseCypher literal instead of erroring.AshArcadic data-layer foundation (Slice 1, Plan 1) — server-free: the
arcade do … endDSL section +AshArcadic.DataLayer.Infointrospection; theAsh.DataLayerbehaviour skeleton (can?/2advertising:multitenancyonly,resource_to_query/2building%AshArcadic.Query{}); theAshArcadic.Clientbehaviour; theAshArcadic.Casttype layer (storage-class serialization + flat ArcadeDB-row decode, no$age64$tag); theAshArcadic.Multitenancytenant→ database-name encoder (injective, ≤128 bytes, fail-closed value-free); theEnsureLabelledtransformer (default label ← module name); five compile-time verifiers (label format, static database format, sensitive R1–R3, no-PK-in-skip, multitenancy discriminator not skipped/binary); the Splode error taxonomy (Create/Query/Update/UnsupportedFilter, value-free); and value-free telemetry spans with a metadata allowlist. Query/CRUD/transactions/traversal land in Plans 2–4.Project scaffold: packaging,
import_deps: [:ash]formatter,docs/CHARTER.md+AGENTS.mdcontext docs, and a documentedAshArcadic.DataLayerplaceholder. No data-layer implementation yet — seedocs/CHARTER.mdfor the architecture and the open Stage-0 decision (physical multitenancy strategy).Query + CRUD + multitenancy write path (Slice 1, Plan 2). Query compilation:
%AshArcadic.Query{}→ parameterized Cypher (filter/sort/limit/offset). Filter push-down: eq/not_eq/gt/lt/gte/lte/in/is_nil +contains/string_starts_with/string_ends_with(→CONTAINS/STARTS WITH/ENDS WITH); identifier-validated; value-freeUnsupportedFilterfor anything else. CRUD callbacks:run_query,create,upsert(nativeMERGE),update,destroy,bulk_create, plusset_tenant/set_context/filter/sort/limit/offset. Multitenancy write path::contextre-targets the database (fail-closed on a blank tenant);:attributerides Ash-core's injected discriminator filter (cross-tenant update/destroy denied asStaleRecord).Cast:time(ISO8601) and:decimal(exact string) round-trip. Transactions/traversal land in Plans 3–4.Transactions (Slice 1, Plan 3).
can?(:transact)now advertises transaction support;transaction/4/in_transaction?/1/rollback/2map Ash's data-layer transaction callbacks ontoarcadic's low-level session trio. The session is owner-process-only (a process-dictionary marker + lazily-opened session; Ash disables async inside a transaction and never transfers the marker to spawned tasks, so no data-layer op runs cross-process) and lazy write-first (the first write opens the session; a read before any write, and reads/writes on the transaction's own database, reuse it — read-own-writes). Single-database by design: a cross-database write inside a transaction fails closed with a value-free:cross_database_transactionerror (an ArcadeDB session is bound to one database); a cross-database or pre-write read runs on its own conn (a read is not an atomicity hazard). All transaction error paths are value-free (no tenant-derived database name, session id, or Cypher escapes; a begin failure surfaces as a bare:transaction_begin_failed, a commit failure as:transaction_commit_failed). Adds a:transactiontelemetry span (result:commit/:rollback/:error) and threadsin_transaction?into every per-op span's metadata. Closes the Plan-2 CV3/CV4 write-then-error-without-rollback residuals fortransaction? trueactions — a duplicate-PK multi-row update (mutate-then-UpdateFailed) now rolls the multi-SETback atomically instead of leaving the mutation committed. A rollback that itself fails or raises during unwind is logged value-free (a static line, never the transport error's database-bearing message) and never masks the original error. A failed commit now rolls the session back before returning:transaction_commit_failed: a probe against live ArcadeDB confirmed a commit that fails with an MVCCConcurrentModificationExceptionleaves the session open server-side (retryable), so rolling it back frees it immediately instead of leaking it until idle expiry. Traversal lands in Plan 4.Bounded graph traversal (Slice 1, Plan 4).
AshArcadic.ManualRelationships.Traverse, an Ash manual relationship emitting one parameterizedUNWIND $ids AS sid MATCH … RETURNstatement (bounded variable-length;direction:outgoing/:incoming/:both; requiredmax_depth, no unbounded*).:contexttraversal is physically scoped to the tenant database;:attributetraversal scopes every node on the bound path via ArcadeDB's native predicateALL(x IN nodes(p) WHERE x.<attr> = $tenant)— fail-closed on a blank tenant or on traversal between resources with different discriminators (:mixed_attribute). Params-only with identifier validation; value-free errors; a:traversetelemetry span (row_count/destination_count/depth).can?(:traverse)advertised. Completes Slice 1 (the vertex-centric data layer).Edge writes (Slice 2, Plan 1). AshArcadic-backed resources can now write graph edges. An
edgeDSL entity in thearcade do … endblock (name/label/direction/ destination/properties + amultiple?primitive selector:false→ idempotentMERGE,true→ parallelCREATE). Two Ash change modules run asafter_actionhooks inside the action's transaction:AshArcadic.Changes.CreateEdge(change {CreateEdge, edge:, to:}) andAshArcadic.Changes.DestroyEdge. Both scope both endpoints (identity + tenant discriminator) in aWHEREbefore the MERGE/CREATE/DELETE rel-pattern via one sharedEdgeCypherbuilder (anti-divergence) — a same-PK-in-both-tenants edge write/delete binds only the in-tenant node (cross-tenant hijack denied; integration-proven). CreateEdge stamps the:attributediscriminator onto:attributeedges, encode-gates the full param map (Rule 4 — a raw non-UTF8 binary nested in a:map/:listfails closed value-free before the DB touch), and enforces the R4 sensitive-property runtime guard (asensitiveedge property with no binary-storage-typed argument fails closed, value-free); a 0-row create →InvalidRelationship, a mid-list failure rolls all edges back. DestroyEdge count-decodes the<deleted>echo — a 0-row delete (already-gone or cross-tenant) fails closed asStaleRecord. Compile-time guards:ValidateEdge(edge label + property-key identifiers) and theValidateSensitiveR4 clause (an edge-property key naming a sensitive attribute requires a binary-storage-typed declared argument). AddsInfo.edges/1and:create_edge/:destroy_edgetelemetry spans (properties?added to the value-free allowlist). Plan 2 (traversal upgrade —relationships(p)edge scoping + the Option-B authorized read) is pending.Traversal upgrade (Slice 2, Plan 2 — spec §7). Makes the bounded traversal (
AshArcadic.ManualRelationships.Traverse) edge- and authorization-correct. (1)relationships(p)edge-property scoping, DEFAULT-ON for:attribute: the path predicate now scopes every edge as well as every node (ALL(r IN relationships(p) WHERE r.<attr> = $tenant), probe E4) — an out-of-band edge lacking the tenant stamp is excluded (fail-closed, not a cross-tenant reachability leak); the new manual optscope_edges: falseopts out for node-structure-only graphs. (2) Option-B two-phase authorized read (resolves the Plan-4 CV1 carry): the traversal becomes a tenant-scoped reachability primitive that returns destination PKs, then delegates all authorization/query concerns to a standard authorizedAsh.readover those PKs — applying row policy (→ CypherWHERE), field policy (redaction), and the relationship/caller filter + sort + limit by Ash's own read pipeline. A policy-denied destination is now dropped even on the PK-only load; the tenant boundary is enforced twice over (path predicate + the read's:attributefilter /:contextdatabase), both fail-closed. Destinations must have a single-attribute primary key (composite → fail-closed value-free). Adds{:simple_sat, "~> 0.1"}(the SAT solverAsh.Policy.Authorizerrequires). Slice 2 is now feature-complete (edge writes + traversal upgrade); closeout review pending.Query aggregates (Slice 3, Plan 1). The
run_aggregate_query/3data-layer callback powersAsh.count/sum/avg/min/max/first/list/exists?/aggregate(incl.uniq?) and offset-paginationcount: trueover AshArcadic resources, tenant-scoped fail-closed (:contextblank tenant → error, never a base-database read;:attributerides Ash-core's injected discriminator filter). A new pureAshArcadic.Aggregatebuilds ONE parameterized Cypher statement per aggregate — each honoring its own per-aggregate filter (ANDed onto the tenant scope; an unpushable per-agg filter fails closedUnsupportedFilter, never a silent unscoped aggregate) — reusing the Slice-1Query/Filter/Cast/read_connprimitives (no forked enforcement path).{:query_aggregate, kind}is advertised for count/sum/avg/min/max/first/list/exists (custom rejected);{:aggregate,_}/{:aggregate_relationship,_}/{:lateral_join,_}stay unsupported (ArcadeDB has no window functions and a manual traversal can't be pushed into an aggregate — use a standaloneAsh.aggregate). Empty (and all-null-field) sets decode to Ash's per-kind default, not ArcadeDB's:sum/avg/min/max/firstover a set with no non-null field values →nil(acount(n.<field>)non-null-count companion disambiguates ArcadeDB'ssum → 0, matching Ash/SQL null-skipping semantics);count → 0;list → []; a callerdefault_valueis honored. Storage-class guard (fail-closed value-free):sum/avgrequire numeric (:integer/:float) storage;min/max/firstrequire order-preserving storage (reject:binary+:decimal, per D27);listrejects:binary(an encrypted/sensitiveattribute would otherwise return ciphertext) — a rejected aggregate names only the field + kind, never a value. Adds an:aggregatetelemetry span (kinds/aggregate_count).Per-source traversal limits (Slice 3, Plan 2 — spec §7). Two static opts on the
AshArcadic.ManualRelationships.Traversemanual relationship —per_source_limit(a positive integer; defaultnil= unbounded) andper_source_offset(a non-negative integer; default0) — cap each source's reachable destinations at a per-source top-N, slicedoffset..+limitby the relationship's own sort. The slice is applied POST-authorization (over the already row-policy-authorized, already-sorted Read-B destinations inregroup, never a DB-sideCALL{}limit that would slice reachability before authz) — a policy-denied destination never consumes a slot (integration-proven on a fan-out star: denying a sibling yields the next-ranked survivor, not a short result). Ash rejects dynamiclimit/offseton manual relationships, so these are static resource-declared opts;per_source_limitandper_source_offsetare rejected value-free on a:onerelationship (a single destination cannot be a top-N or be offset into). Completes Slice 3.Traversal aggregates (Slice 4). Declared aggregates —
count/sum/avg/min/max/first/list/exists— over a manualTraverserelationship (aggregates do count :descendant_count, :descendants end) now compute over a node's reachable subtree, POST-authorization in Elixir (never a DB-side Cypher aggregate, which would count policy-denied nodes and double-count multi-path nodes).add_aggregate/3stashes the aggregate onto%AshArcadic.Query{};run_query/2computes it over the just-read parents via ONE batched authorizedAsh.load(Traverse'sUNWIND $ids— not N+1), threading the realauthorize?/actor/tenant(neverauthorize?: false), then a new thinAshArcadic.TraversalAggregatefolds each source's authorized, node-deduped, tenant-scoped destinations (guard_fieldbefore every fold; fold wrapped value-free). A policy-denied intermediate drops its entire subtree (integration-proven, mutation-proven:s→mid(denied)→deepunder a non-admin actor counts neithermidnordeep) and a cross-tenant node is not counted.include_nil?is honored for traversallist/first(a capability gain over the Slice-3 flat path, whose Cyphercollectdrops nulls). Empty/all-null-field sets decode to the aggregate's Ash default; the storage-class guard (sum/avgnumeric;min/max/firstorder-preserving;listrejects:binary) is reused value-free — keyed off the destination resource's storage types (correct for cross-resource traverses, not just self-referential). A field-policy-redacted destination value (%Ash.ForbiddenField{}from Read B) fails the aggregate closed value-free — an actor cannot aggregate a field they are not permitted to read.{:aggregate, kind}/{:aggregate_relationship, _}now advertised;{:aggregate, :unrelated}/{:aggregate, :custom}stay refused (Ash rejects flat inline aggregates upstream). StandaloneAsh.aggregateover a relationship path is rejected value-free (run_aggregate_query/3fails closed on a non-emptyrelationship_path— load the aggregate inline instead; the standalone cross-row collapse semantics are unresolved). Adds value-freetraversal_aggregate?/aggregate_kinds/aggregate_counttelemetry metadata.
Fixed
/review-autopilotcloseout (Slice 3, Plan 1, 2026-07-06). Three correctness/leak gaps between the spec's intent and the shipped query aggregates:include_nil?: truesilently dropped nulls — alist/firstaggregate withinclude_nil?: trueran plaincollect(...)(which skips nulls). Now fails closed value-free (spec §6.5); null-preserving support remains a future capability.- all-null-field aggregates bypassed the Ash default — the empty-vs-zero companion was
count(n)(counts rows), so an aggregate over a set whose field is null in every row returned ArcadeDB's raw value (sum → 0,min → nil) instead of the Ash default. The companion is nowcount(n.<field>)(non-null count), matching Ash/SQL null-skipping. :firstsort by a non-atom field could leak — an expression/calculation sort field raised a value-carryingProtocol.UndefinedError. Now rejected value-free before any identifier coercion (Rule 4).
/review-autopilotcloseout (Plan 2, 2026-07-05). Tenant-isolation and fail-closed hardening surfaced by the closeout review::attributeupsert cross-tenant hijack — the nativeMERGEmatched on the primary key alone, so a same-PK upsert from another tenant matched and mutated the victim's row. The tenant discriminator now rides the MERGE identity (tenant-local match; a cross-tenant upsert creates its own row).:contextread backstop — the staticdatabaseDSL option is now ignored for:context(it no longer pre-seedsquery.databaseand defeats the fail-closed read on a blank tenant).- JSON-encode leak — a raw non-UTF8 binary nested in a
:map/:listattribute is pre-checked on every write path and fails closed with a value-free error (previously the JSON encoder raised with the bytes in the message — a redaction breach). - Bulk upsert — a bulk
upsert? trueaction now fails closed instead of silently emittingCREATEand producing duplicate rows. - Added end-to-end coverage for composite-primary-key updates.
Notes
:decimalrange operators (gt/lt/gte/lte) are rejected (UnsupportedFilter) and:decimalis unsortable — ArcadeDB compares the exact-string wire form lexicographically; model money as integer minor units for range/sort.:contextdatabase names are operator-visible (usetenant_databaseto hash a classified tenant space). ArcadeDBCONTAINS/STARTS WITH/ENDS WITHare case-sensitive (a:ci_stringattribute's case-insensitive semantics are not preserved).