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
2.0.0 - 2026-08-12
A major release: atomic-aware updates (the headline), plus the breaking edges the new dispatch path introduces. Existing 1.x builds are unaffected until you upgrade; see Upgrading from 1.x below.
Added
Atomic-aware updates.
Ash.Expr-based atomic updates now translate to Cypher and run as single statements instead of falling back to per-record read-modify-write.AshAge.Cypher.Expr(Expr.translate/2) translates an Ash.Expr AST into a Cypher expression + positional params: arithmetic (+ - * /with precedence parenthesization), comparisons (= <> > < >= <= IN IS [NOT] NULL), boolean (AND/OR/NOT), control (Function.If → CASE WHEN), string (toLower/trim/STARTS|ENDS WITH/CONTAINS,Basic.Concat → +). Refs are backtick-quoted + identifier-validated; literals use collision-free$pN. Unsupported nodes fail closed asUnsupportedExpression.update_query/4(can?(:update_query),:expr_error,:action_select,{:atomic, :update}): single-recordAsh.update/2carrying atomics andAsh.bulk_updateroute here. LIMIT/SKIP honored via aWITH npass-through (verified valid AGE 1.6.0 Cypher).update_many/3(can?(:update_many)):Ash.update_many(distinct changes per record) dispatches here. Re-groups by{atomics, filter, attributes}, synthesizes a per-group query carrying the group's filter + the:attributetenant discriminator (built fromopts[:tenant], run through the resource'smultitenancy_parse_attribute) + a PK scope, then delegates to theupdate_querymachinery. Single-attr PK →n.pkIN $pks; composite PK → OR-disjunction (AGE has no row-valued IN). Fails closed on a blank tenant.
Telemetry:
:update_queryand:update_manyspan ops join the[:ash_age, <op>, :start|:stop|:exception]list, with a new value-free:atomic?metadata flag (true when the SET carried an expr-based atomic).Aggregates:
count/sum/avg/min/max/existsover a resource's own records (no relationship path), with optional per-aggregate sub-filters. AGE ships these natively;first/list/customand relationship-pathed aggregates remain unsupported. Aggregates honor RLS (with_rls) and:attributemultitenancy, ignore querylimit/offset/sort(full filtered set), and the result map is atom-keyed by aggregate name.min/max/sum/avgover a binary-storage field are rejected (the$age64$base64 wire form isn't byte-order-preserving).Upsert (
can?(:upsert)) via a non-MERGE two-statement path: an existence MATCH on the identity, then a CREATE branch (absent) or a SET branch (present). AGE enforces no PK uniqueness and MERGE is banned, so this provides create-or-update by a declared identity without MERGE. The cross-tenant match comes from Ash'sidentity_fields(which prepends the multitenancy attribute for per-tenant identities and excludes it forall_tenants?global identities). Not atomic across concurrent upserts of the same identity (inherent AGE constraint); documented.Bulk destroy by query (
can?(:destroy_query)):Ash.bulk_destroy/3against a query runs as a singleMATCH ... WHERE <translated filter> DETACH DELETE ninstead of N per-record destroys, honoring the query'slimit/offset. The WHERE matches the read path — including the tenant predicate for:attributemultitenancy.
Security
- Bump
ashto 3.31.2 — clearsEEF-CVE-2026-69659(memory exhaustion via unbounded deserialization of keyset pagination cursors inAsh.Page.Keyset) andEEF-CVE-2026-70395(predicate injection inmanage_relationshipbelongs_to lookup disclosing secret lookup keys), both fixed upstream in ash 3.31.1. - Bump
postgrexto 0.22.4 — clearsEEF-CVE-2026-58225(SQL injection via unescaped dollar-quote inPostgrex.Notificationsreconnect replay) andEEF-CVE-2026-66838(SQL injection via the:commentoption inPostgrex.stream/4).
⚠ Changed (breaking — the major bump)
These are consequences of advertising :update_query + :expr_error so atomic
updates can dispatch. Existing 1.x code that hits one of these edges needs the
noted adjustment; code that doesn't is unaffected.
Ash.bulk_destroy/3(andbulk_update/3) with a query now require the tenant on the QUERY (Ash.Query.set_tenant/2), not just via thetenant:option. Ash's atomic-bulk dispatch (do_atomic_destroy/do_atomic_update) readsquery.tenant; the stream/per-record path toleratedtenant:via opts, the atomic path does not. A pre-validated query (Resource |> Ash.Query.for_read(:read) |> ...) passedtenant:via opts now needs|> Ash.Query.set_tenant(tenant). (Per the Ash bulk-op contract; this is the same constraint every:update_query/:destroy_query-capable data layer has.)- WHERE-clause and SET property refs are now backtick-quoted
(
n.`attr`instead ofn.attr). Fixes a syntax error when filtering or setting a Cypher-keyword-named attribute (count,label, …). Backtick- quoting a non-keyword name is a no-op at the AGE level, so results are unchanged; only the generated Cypher string shape changes. - Single-record updates on a duplicate-PK-in-graph row now fail closed via
update_query/4(UpdateFailed"matched N rows for one primary key") instead of via the per-recorddecode_update_resultguard. AGE enforces no PK uniqueness; the rerouted path detects the anomaly at the same point.
Known issue (Ash upstream)
Ash.update_many/4withoutreturn_records?: trueraisesBadBooleanErrorin Ash 3.31.2 (update_many.ex:162uses strictorover a nilopts[:return_records?]). Affects allupdate_many-capable data layers, not ash_age. Workaround: passreturn_records?: true.
Known limitations (atomic-expr edge cases, follow-up)
Identified by the cross-vendor closeout; each needs attribute-type threading through the translator or AGE-verified semantics, deferred to a follow-up:
type(expr, :type)casts are dropped in the translator (AGE is dynamically typed — there is no general cast function in Cypher, so the cast is treated as a no-op). A user-authored cast that changes comparison semantics (e.g.type(1, :string)vs a stored string) diverges silently. The Ash-generatedallow_nil?wrapper cast is unaffected. Inherent AGE limitation (no cast fn).- Atomic-update atomic validations (e.g.
validate compare(...)) now fail closed instead of corrupting (see Fixed), which means a changeset carrying such a validation is not translated to an atomic write. Run those updates per-record (the validation fires in Elixir there) — AGE's SET expression cannot raise, so atomic-side validation enforcement is inherently impossible until AGE adds one.
Changed
- Dependency bumps:
ash3.29.3 → 3.31.2,postgrex0.22.2 → 0.22.4,ecto3.14.0 → 3.14.1,credo1.7.16 → 1.7.19 (dev),ex_doc0.40.1 → 0.40.3 (dev), plus transitive resolutions (ymlr,reactor,splode,db_connection,makeup_erlang, others). Nomix.exsversion bounds were widened. Supersedes dependabot PRs #26–#30, whose own proposed targets (ash3.31.0,postgrex0.22.3) were themselves affected by the advisories above and could not clear themix hex.auditCI gate.
Fixed
- Atomic-validation bypass (closeout, blocking). The expr translator turned
Ash.Query.Function.Error{}into literalnull; an atomic validation (validate compare(...), etc. — which Ash expands toif violation, do: error(...), else: ref(attr)) would writenulland succeed on a violation.Errornow fails closed (UnsupportedExpression); theallow_nil?wrapper still strips correctly. AGE cannot raise in a SET expression, so atomic validations must run per-record (documented limitation). update_manysilently dropped untranslatable changeset filters (closeout, blocking). The synthesized query putchangeset.filterintoquery.expression, whose translationbuild_whereswallows on error — a filter with an unsupported operator (optimistic-lock, ref-to-ref policy filter, fragment) was dropped and the SET fell back to PK+tenant scope, updating rows the filter excluded. The filter is now translated eagerly and fail-closed before any write.- Policy-authorized atomic updates hard-errored. Advertising
:expr_errorflipped the single-record/bulk reroute toauthorize_changeset_with: :error, which attaches Ash.Policy'sif policy_filter do true else error(...) endto the query; the filter translator had noIfclause and rejected it. The wrapper is now stripped to the policy condition (in a WHERE, "authorized if filter holds" IS the filter). End-to-end proven against a realAsh.Policy.Authorizerfilter-producing policy (the policy filter gates the update — only authorized rows are touched);:simple_satis now a dev/test dep so the suite can exercise Ash.Policy (host apps bring their own SAT solver). $paramN/ SET-attribute name collision (silent wrong-write). A filter/PK/ tenant$paramNref could share its name with a SET attribute literally namedparam<N>, binding one value to both the WHERE and the SET. Every attribute name is now reserved (reserve_attr_params) before scoping-param allocation — infilter/3(coversupdate_query/reads) andupdate_many_group(covers the synthesized query) — so$paramNallocations skip attr names; the seeds are dropped from WHERE params so the real SET values win. Tamper-proven (disabling the reservation turns the tripwire red).- No-op
update_manyreported existing records as stale. A no-op group (no atomics AND no attrs) returned:okwith no records, so Ash classified every input as stale. It now runs a scoped READ (filter + tenant + PK gate it) and returns the matched records unchanged — excluded/missing inputs go stale correctly, matching records do not. (An earlier short-circuit returned the inputs directly and bypassedchangeset.filter; reverted.) - Atomic-expr equality/IN against a binary-storage attribute never matched:
the literal was stored raw instead of
$age64$-tagged. Eq/NotEq/In now serialize the literal operand with the Ref's attr type (binary → tagged, same encoder the read path uses). - Duplicate-PK-in-graph anomaly (AGE enforces no PK uniqueness) is now handled consistently: single-record updates fail closed (Ash's per-record wrapper requires exactly one record); the bulk write + no-op read paths dedup by primary key (one record per input PK — returning all would multi-fire Ash's after-action/notification hooks). Same-tenant only; the tenant discriminator prevents any cross-tenant effect.
- Sorted+limited bulk updates picked arbitrary rows.
update_cypher/delete_cypherapplied SKIP/LIMIT without the query's sort clauses.ORDER BYis now emitted before SKIP/LIMIT when a sort is present. Sort fields are backtick-quoted the field name is backtick-quoted, and any:desc*direction (:desc_nils_first/_last) maps to DESC — AGE has no NULLS FIRST/LAST syntax, so the prior:desc-only check selected the reversed slice, and a bare keyword field collided with thecount()aggregate. update_manyblank-tenant guard over-rejectedglobal? true:attributeresources; it now consultsmultitenancy_global?/1(a nil tenant is allowed for global resources).scope_to_tenantnow gates the discriminator on TENANT PRESENCE (matching Ash'shandle_attribute_multitenancy), not onglobal?— a global resource WITH a tenant still gets the discriminator (an earlier revision skipped it for all global resources, a cross-tenant write on a duplicate-PK row).- The catch-all
node_labelleaked the bare unsupported value intoUnsupportedExpression.node(whose message inspects it); it now carries a structural:valueatom, never the value. - WHERE-clause filter translation emitted bare
n.<attr>for every comparison / IN / IS NULL clause; for a Cypher-keyword attribute name (count,label, …) the bare form collided with thecount()aggregate and AGE rejected the query with a syntax error. Property refs are now backtick-quoted via aprop_ref/1helper, mirroring the SET-side expr translator. Pre-existing — affected the read path too. - The getting-started notebook now re-runs cleanly against a persistent
database. Its graph-setup ran a versioned Ecto migration (a no-op once
recorded) while the demo cells created
Alice/Bobunconditionally, so seed data accumulated across runs and the single-element match ({:ok, [only_alice]}) failed on the second run. Each setup now tears the graph down before creating it (Ecto.Migrator.down/4,:already_downon a pristine DB), so the notebook is idempotent. CI is unaffected (fresh container per job).
[1.0.1] - 2026-07-03
Added
- Getting-started Livebook guide (
notebooks/getting_started.livemd), rendered in the docs and executed against Apache AGE in CI.
1.0.0 - 2026-07-03
The first stable release. A large, mostly additive expansion over 0.2.x:
multitenancy (:attribute and :context),
graph edges, bounded traversal, DB-enforced RLS, sensitive-data classification,
raw Cypher, bulk create, and composite primary keys. Existing 0.2.x builds are
unaffected until you upgrade.
Upgrading from 0.2.x
A few behavior changes to check before upgrading (the rest is new capability):
update/destroyon a missing or filter-excluded row now returnAsh.Error.Changes.StaleRecord(previouslyAsh.Error.Query.NotFound;destroypreviously returned:okon a no-match). Update any code that pattern-matches on the old error.update/destroynow honorchangeset.filter— mutations are scoped by the tenant/policy filter Ash attaches, not matched by primary key alone. This closes a cross-tenant write/delete gap; behavior changes only if you relied on the previous unscoped matching.AshAge.DataLayer.Info.attribute_types/1now returns{type, constraints}tuples (was bare types). Only affects code calling that introspection helper directly.- New compile-time checks reject a primary key listed in
age skipand a binary-typed multitenancy discriminator — both were already silently broken (perpetualStaleRecord/ inconsistent scoping).
Binary attribute storage ($age64$), range/sort rejection on binary storage,
and sensitive classification are new and do not affect existing non-binary
resources.
Added
- Sensitive classification (S7).
age do sensitive [:attrs] end+AshAge.DataLayer.Info.sensitive/1: fail-closed compile-time verifier (ValidateSensitive) — each sensitive attribute must be binary-storage-typed (app-side-encrypted bytes) or skipped; the multitenancy discriminator cannot be sensitive; sensitive-named edge properties require binary-storage-typed declared arguments (verified again at runtime on the edge write path). Spark surfaces verifier errors as compiler diagnostics; build with--warnings-as-errorsto make them blocking. ValidateSkipverifier: a primary-key attribute inage skipis now a verifier error (previously: every update/destroy silently returned StaleRecord).- usage-rules/README "Sensitive Data" guidance: searchable-vs-encrypted, erasure/crypto-shred, AshPaperTrail, plaintext-discriminator rationale.
- DB-enforced RLS (S6). Opt-in
:attribute-only PostgreSQL Row-Level Security, a defense-in-depth read-confidentiality backstop beneath Ash's app-layer tenant filter:age do graph :my_graph repo MyApp.Repo rls_guc "ash_age.tenant_id" endAshAge.DataLayer.Info.rls_guc/1reads the DSL option; a compile-time verifier (AshAge.DataLayer.Verifiers.ValidateMultitenancyAttr) requires:attributemultitenancy and rejectsglobal? true(a global read sets no GUC, so RLS would hide every row).AshAge.Migration.enable_tenant_rls/2(resource-derived) and/5(explicit graph/label/tenant-property/GUC) emitENABLE/FORCE ROW LEVEL SECURITY, a functional btree index on the tenant discriminator, and a fail-closed expression policy overproperties(current_setting(guc, true) <> '' AND <discriminator> = current_setting(guc, true)) — never aGENERATED ALWAYS ... STOREDcolumn.- All five CRUD callbacks (
read,create,update,destroy,bulk_create) and traversal (AshAge.ManualRelationships.Traverse) route through a newwith_rls/4wrapper: off (norls_guc) is a no-op; on, itset_configs the GUC insiderepo.transaction(pinning one connection) before running the operation, and fails closed with:rls_tenant_requiredon a blank/nil tenant before any query runs.unwrap_rls/2normalizes the result back to the data-layer contract. A newrls?key joins the value-free telemetry metadata allowlist. AshAge.with_tenant_rls/4— the auditable way to tenant-scope rawAshAge.cypher/5calls: runsfuninside a transaction with the GUCset_config'd on the same pinned connection.mix ash_age.verify --resource MyApp.Docdetects RLS drift (RLS not enabled, or a policy that doesn't reference both the tenant property and the GUC) and now exits non-zero on any failing check (previously always exited 0).- Three load-bearing AGE constraints this design is built around (each
verified live against
apache/age:release_PG16_1.6.0):- A
GENERATED ALWAYS ... STOREDcolumn on an AGE label table segfaults everycypher()write (signal 11, crash + recovery) — the RLS predicate must be a live expression overproperties, never a generated column. - AGE
cypher()CREATE bypassesWITH CHECK— a cross-tenant INSERT is not RLS-denied at the database. RLS is read/target-side only; the:attributeapp-layer force-set (Ash core) remains the actual write barrier. - RLS (including
FORCE ROW LEVEL SECURITY) is silently skipped for superusers and roles withBYPASSRLS— the application's DB role must be a non-superuser withoutBYPASSRLS, or the policy never applies with no error signal.
- A
- For
:attributeresources, RLS's row-scoping is redundant to (jointly enforced with) Ash's own tenant WHERE filter — RLS's distinct value is the DB-enforced read-confidentiality backstop beneath the app layer, not a replacement for it.
- Traversal (S5).
AshAge.ManualRelationships.Traverse— bounded variable-length graph traversal exposed as an Ash manual relationship:has_many :descendants, MyApp.Node do manual {AshAge.ManualRelationships.Traverse, edge_label: :LINK, direction: :outgoing, min_depth: 1, max_depth: 3} end- Returns a source-primary-key-keyed map of materialized destination records,
deduped per source (in Elixir, by destination PK — no SQL
DISTINCT, sorow_counttelemetry stays a genuine pre-dedup fan-out signal) and cardinality-aware (has_one→ one record,has_many→ list). - All three directions —
:outgoing,:incoming, and undirected:both. max_depthis required and bounded (integer>= 1); an unbounded*is forbidden.min_depthdefaults to1. Both single and composite primary keys on source and destination are supported.- Fail-closed tenancy.
:contextresolves the per-tenant graph (nil/blank tenant fails closed);:attributescopes every node on the path to$tenantvia a fixed-length UNION expansion — one basicMATCHbranch per length inmin_depth..max_depth, each node AND-scoped to the discriminator,UNION ALL-joined. (This AGE build's Cypher parser rejects theALL(n IN nodes(p) WHERE …)per-hop predicate, so the UNION expansion is the shipped mechanism — see probes below.) - Emits a value-free
:traversetelemetry span withdestination_count(post-dedup),row_count(pre-dedup fan-out),depth(max_depth),direction, andresult.
- Returns a source-primary-key-keyed map of materialized destination records,
deduped per source (in Elixir, by destination PK — no SQL
- Raw Cypher (S5).
AshAge.cypher(repo, graph, cypher, params \\ %{}, return_types)— a parameterized escape hatch for queries the Ash DSL cannot express:AshAge.cypher(MyApp.Repo, "my_graph", "MATCH (n:Person)-[:KNOWS*1..2]->(m) WHERE n.id = $id RETURN m", %{"id" => person_id}, [{:m, :agtype}]) #=> {:ok, [%{m: %AshAge.Type.Vertex{...}}, ...]}- Values reach AGE only as
$parameters; thegraphname isvalidate_identifier!-checked and a$$break-out in the body is rejected. - Returns
{:ok, [%{column_atom => decoded}]}(each cell a%AshAge.Type.Vertex{}/Edge{}/Path{}or a scalar) or{:error, %AshAge.Errors.QueryFailed{}}. - Decode boundary: a bare agtype aggregate (
collect(n),{k: v}) is returned as its raw agtype string — aggregate decoding is out of scope; useUNWIND. - Tenancy is explicit: the
graphyou pass IS the isolation boundary;cypher/5opens no transaction of its own (wrap in your own tenant-GUC transaction for RLS defense-in-depth). - Emits a value-free
:cyphertelemetry span withrow_countandresult;:depthwas added to the telemetry value-free metadata allowlist this slice.
- Values reach AGE only as
- Feasibility probes verifying AGE 1.6.0 behavior this slice depends on:
UNWIND+ variable-lengthMATCH(P-S5a = supported); a bound path variable withALL(n IN nodes(p) WHERE …)(P-S5b = rejected by this AGE build); the fixed-lengthUNION ALLexpansion as its equivalent (P-S5b-UNION = supported); andIN $paramlist binds (P-S5c = supported). Data-layer telemetry. Every operation emits a
:telemetry.span—[:ash_age, :read | :create | :bulk_create | :update | :destroy | :create_edge | :destroy_edge, :start | :stop | :exception]:- Metadata is value-free — schema identifiers, counts, booleans, and DSL enums only (
resource,multitenancy,tenant?,stale?,properties?,direction,row_count,batch_size,group_count,destination_count,result). Never a PK/property value, error reason, Cypher/filter string, or the tenant-derived graph name. AshAge.Telemetry(a new dependency-free module) owns the metadata allowlist and raises on any off-allowlist key — the single enforcement point for the value-free contract.:exceptionfires only on a programmer/config error (e.g. an undeclarededge:); DB errors are returned as redacted{:error, _}tuples and surface as:stopwithresult: :error.:telemetryis now a declared runtime dependency (already resolved transitively viaash/ecto).
- Metadata is value-free — schema identifiers, counts, booleans, and DSL enums only (
- Edge CRUD (S4). Two Ash
Resource.Changemodules for creating and destroying graph edges:AshAge.Changes.CreateEdge— creates edges viachange {AshAge.Changes.CreateEdge, edge: :name, to: :arg}, parameterized endpoint matching, optional edge properties (values from same-named action arguments, type-serialized as vertex attributes), atomic write inside the action's transaction (0-row match or DB error rolls the vertex back). Tenant-isolated::contextedges are same-graph fail-closed;:attributeedges scope both endpoints by the tenant discriminator.AshAge.Changes.DestroyEdge— destroys edges symmetrically, returningAsh.Error.Changes.StaleRecordon 0-row match (already gone or out of scope).- Edge
propertiesDSL option — a list of property keys, values sourced from same-named action arguments (declared argument type governs serialization: binary →$age64$-tagged base64, DateTime/Date → ISO8601). Unset properties are sparse (not written as null). :bothdirection — stored as:outgoing, readable via undirected Cypher match from either endpoint (contract for S5 traversal, pinned by integration test).- Constraint: edge destinations must have a single-attribute primary key.
- Edge-label auto-creation: AGE auto-creates edge labels on first
CREATE(verified live by probe P4); no provisioning required. Edge labels are provisioned like vertex labels: viacreate_edge_label/2in migrations or:elabelsinprovision_tenant/3.
- Bulk create (S4).
can?(:bulk_create)is nowtrue.Ash.bulk_createemitsUNWIND $rows AS row CREATE (n:Label) SET n.key = row.key … RETURN nper key-set group:- Key-set grouping — rows are grouped by which attributes are present, so a row with an optional attribute missing is NOT null-filled to match other rows. Each group's
UNWINDemits SET clauses for exactly that group's keys, preserving single-create's sparse stored shape. - Order-preserving — with
return_records?: truerecords come back mapped to input changesets viabulk_create_index; positional order matches input. - Binary/date round-trip — values serialize identically to single-create (
$age64$/ISO8601) and survive nested in the$rowsparameter. - Atomic-per-batch — one
UNWINDstatement, so any row's DB error fails the whole batch ({:error, …}, not:partial_success). Ontransaction: :batch(default, Ash wraps the batch), a later-group failure rolls back earlier groups; ontransaction: falsepartial writes are possible (same contract single-create and AshPostgres carry).:contextbatches with a nil tenant fail closed.
- Key-set grouping — rows are grouped by which attributes are present, so a row with an optional attribute missing is NOT null-filled to match other rows. Each group's
- Multitenancy (S3). Both Ash multitenancy strategies are now supported
(
can?(:multitenancy) → true)::attribute— works through Ash core (reads inject a tenant filter, writes force-change the tenant). ash_age adds a fail-closed compile verifier (AshAge.DataLayer.Verifiers.ValidateMultitenancyAttr): the multitenancy attribute must not appear inage do skip [...], or the tenant discriminator would never be written and the core-injected filter would silently match nothing.:context— graph-per-tenant physical isolation.set_tenant/3resolves a per-tenant AGE graph and threads it through reads; writes resolve it from the changeset tenant and fail closed on a missing tenant (there is no global graph). The graph name comes from a collision-free encoder (AshAge.Multitenancy.graph_name/2): identifier-clean tenants (ULID, integer, slug) pass through ast_<tenant>; others (e.g. a UUID) are base32-encoded asg<...>. The two prefixes are disjoint, so distinct tenants never collide. Overridable per resource via atenant_graphMFA in theageDSL block. Tenants longer than the 63-byte identifier limit fail closed with a value-free error (usetenant_graph).AshAge.tenant_graph/2— public helper so a host derives the same graph name query time uses.AshAge.Migration.provision_tenant/3— idempotent, runtime-safe (and migration-safe) helper the host app calls to create a tenant's graph + labels. Every graph/label is validated as an AGE identifier before use.- A missing tenant graph fails closed: a query against an unprovisioned
:contextgraph surfaces a redacted database error, never silent empty results.
- Live-AGE integration-test harness: a test
Ecto.Repo, a Postgrex agtype types module,AshAge.TestDomain, andAshAge.DataCase(Ecto SQL Sandbox + AGE session with awith_graph/3helper). Integration tests are tagged:integrationand run only whenAGE_DATABASE_URLis set; the pure-unit suite still runs with no database. - Feasibility probes verifying AGE 1.6.0 behavior later work depends on: bulk
UNWIND … SET n.k = row.k(supported), parameterizedMATCH (a),(b) … CREATE (a)-[:REL]->(b)(supported), and thatag_catalog.cypher()honorsFORCErow-level security under a non-superuser role with a GUC-keyed policy (supported — confirms DB-enforced tenant isolation on AGE is viable). - CI pins the Apache AGE service image by digest (
release_PG16_1.6.0, AGE 1.6.0 / PostgreSQL 16) and runs the integration lane against it. - Unit test coverage for the previously untested query-building path:
AshAge.Cypher.Parameterized,AshAge.Query,AshAge.Query.Filter,AshAge.Type.Agtype,AshAge.Type.Cast, andAshAge.DataLayer.set_clauses/1(47 new tests, no database required). AshAge.DataLayerdeclarescan?(_, :composite_primary_key) == true, so resources with a composite primary key now compile and CRUD correctly.
Fixed
- Sensitive data / binary-storage fixes (S7). Filter
eq/not_eq/in, primary-key match (update/destroy), traversal ids, and edge endpoint params now encode binary-storage values to the stored$age64$wire form — equality search on encrypted (binary) attributes and binary primary keys previously never matched (or raisedJason.EncodeError). - Non-JSON-encodable values (raw bytes nested in
:map/:list, or a struct with noJason.Encoderimpl nested in a param) now fail closed with a value-free error naming the attribute; previouslyJason.EncodeError/Protocol.UndefinedErrorleaked the raw bytes or inspected value into the exception message. StaleRecorderrors no longer carry primary-key/endpoint values in theirfilter(Ash inspects it into log messages); the filter keeps field names and replaces each value with"<redacted>".- An update whose primary-key
WHEREmatches more than one row (duplicate-keyed vertices are creatable outside Ash — AGE enforces no PK uniqueness) now fails closed with a value-freeUpdateFailedinstead of raisingFunctionClauseErroracross the data-layer callback boundary. - Attribute-to-attribute filter comparisons (
attr1 == attr2, and aRefnested in aninlist) now returnUnsupportedFilterinstead of binding theRefstruct as a parameter and surfacing downstream as a misleading "not JSON-encodable" error. Ash.Type.NewTypewrappers over date/datetime types now coerce stored ISO8601 values back to%Date{}/%DateTime{}/%NaiveDateTime{}on read (previously the raw string was returned, silently breaking traversal key-matching for wrapped date primary keys). Coercion dispatches on the resolved Ash STORAGE class (AshAge.Type.Cast.storage_class/2), the same resolution the binary predicate uses.- Attribute constraints now reach every wire path (encoder, decode gate, filter cast,
edge property guard) — a custom type whose
storage_type/1depends on instance constraints can no longer pass the sensitive-classification verifier yet store untagged. :attributetraversal scopes off the source strategy too (S5 closeout).AshAge.ManualRelationships.Traversenow applies per-node tenant scoping when either the source or the destination resource is:attribute-multitenant. Previously it keyed only on the destination, so a source-:attribute/ non-:attribute-destination traversal ran an unscoped query against the shared multi-tenant graph (fail-open); it is now fail-closed on that config too.- Date/DateTime source primary keys associate correctly in traversal (S5
closeout). The traversal F3 source key now coerces the decoded agtype scalar
to the source attribute type (via
AshAge.Type.Cast.coerce_value/2), so a:date/:utc_datetime/:naive_datetimeprimary key (stored as an ISO8601 string, held as a struct in the record) matches Ash's manual-result lookup. Previously the raw string key never matched and the source was silently dropped (returned[]/nil). UUID/integer/string PKs were unaffected. Infilter handles Ash'sMapSetright side (S5).AshAge.Query.Filternow normalizes theMapSetthatAsh.Query.Operator.Instores as its right side to a list before emittingn.attr IN $param, sofilter(x in ^list)— and nested loads that flow through traversal — work. Previously theMapSetshape fell through to{:error, UnsupportedFilter}.AshAge.Type.Pathdecodes AGE's inline-tagged path wire format (S5). A::pathbody is an array of individually::vertex/::edge-tagged agtype elements, not plain JSON; the decoder now splits at top-level commas (depth- and string-literal-aware) and recursively decodes each element. Previously it fed the body toJason.decode!and raisedJason.DecodeError(first exercised bycypher/5returning a path,RETURN p).AshAge.DataLayer.update/2no longer risks a parameter collision when a resource has an attribute literally namedmatch_id; the internal match parameter now uses a name guaranteed not to clash with a changed attribute.- Setup docs no longer show a Postgrex types snippet that fails to compile:
Postgrex.Types.define/3defines the module itself, so it is now called at the top level (adefmodulewrapper of the same name raises "cannot define module … currently being defined"). Themix ash_age.installoutput also referenced a non-existentAshAge.Type.Agtype.Extension; the correct module isAshAge.Postgrex.AgtypeExtension. Fixed in the install task, theAshAgemoduledoc, and the README. AshAge.DataLayer.update/2anddestroy/2derive their match predicate fromAsh.Resource.Info.primary_key/1, so resources with a composite primary key or a single non-:idprimary key match the correct rows. Both previously hardcodedWHERE n.id = $…, silently matching the wrong rows or nothing.:binaryattributes (including AshCloak-encrypted fields) no longer crashJason.encode!on create/update. Binary values are stored in a self-identifying wire format ("$age64$" <> base64(value)) and decoded back on read; plaintext strings are untouched. The$age64$tag makes decoding deterministic — a value ash_age did not encode (legacy or out-of-band data) is returned verbatim, never guess-decoded, even if it is syntactically valid base64.
Changed
- Binary-storage behavior changes (S7). Range filters (
>,<,>=,<=) on binary-storage attributes returnUnsupportedFilter(previously compared the tagged-base64 stored form — silently wrong results). Sort on binary storage is rejected at query build (can?({:sort, :binary})is false). - Stored binary values not written by ash_age (untagged/legacy/external) are readable
verbatim but no longer matchable through Ash filters or mutations (read-only grace):
match params now send the tagged form. Migrate such rows or store them as
:string. - A binary-storage-typed multitenancy discriminator is now rejected by a verifier (it would scope vertex filters, edge tenant params, traversal, and RLS paths inconsistently).
AshAge.DataLayer.Info.attribute_types/1now returns{type, constraints}tuples (previously bare types) so constraints reach the encode/decode paths;AshAge.Type.Cast.serialize_value/2andcoerce_value/2accept both bare types and{type, constraints}specs.update/2anddestroy/2now returnAsh.Error.Changes.StaleRecord(wasAsh.Error.Query.NotFound) when the primary-key + scoping-filterWHEREmatches no row — the Ash data-layer contract for a record-based mutation whose row is gone or excluded by a filter (NotFoundis for identifier lookups;StaleRecordis what the reference ETS data layer and Ash core return, and what Ash's bulk update/destroy paths pattern-match).destroy/2previously returned:okunconditionally on a no-match (DETACH DELETEgives no matched/unmatched signal); it now detects the 0-row case (viaRETURN n) and surfacesStaleRecord, so a scoping-denied or already-deleted destroy is observable and consistent withupdate/2.
Security
- Cross-tenant write/delete closed. ash_age now advertises
can?(:changeset_filter) → trueand honorschangeset.filterinupdate/destroy, translating it into the CypherWHERE(AND-ed with the primary-key match). Previously the data layer matched mutations by primary key only and silently dropped the tenant/policy scoping filter Ash attaches, so a fabricated or non-tenant-scoped changeset carrying another tenant's primary key could modify or delete that tenant's rows. Untranslatable filters fail closed (the mutation is rejected, never silently unscoped). This also makesAsh.Policyfilters apply to mutations. - Defense-in-depth against Cypher/SQL injection at every identifier interpolation
site. All dynamic values were already parameterized; this hardens the identifiers
that are interpolated into the query text:
AshAge.Query.to_cypher/1now validates the vertexlabeland everysortfield as an AGE identifier before interpolation, and requiresoffset/limitto be non-negative integers (raisingArgumentErrorotherwise).AshAge.DataLayercreate/update validate every property key (SET n.key = $key) and the label as AGE identifiers.AshAge.Cypher.Parameterized.build/*now reject any Cypher body containing a$$sequence — a final centralized guard against breaking out of AGE's dollar-quoted literal.AshAge.Cypher.Parameterizednow validatesreturn_typescolumn names and types as AGE identifiers before interpolating them into the outerAS (...)record clause (which sits outside AGE's$$dollar-quote). On the publicAshAge.cypher/5surface these are caller-controlled, so an unvalidated column name was a SQL-injection vector (S5 closeout).- The
$$-body rejection no longer echoes the Cypher body in itsArgumentErrormessage; that raise bypasses the redaction boundary, so a body carrying an interpolated value would otherwise leak it into logs (S5 closeout).
- Error messages no longer leak filtered values or database row contents.
AshAge.Errors.UnsupportedFilternow reports only the operator and referenced field name (never the filtered value).CreateFailed/QueryFailed/UpdateFailedsurface only the PostgreSQL SQLSTATE code (and constraint name), never the PostgresDETAILline that echoes offending values. A regression test pins the never-interpolate guarantee: values reach Cypher only as the$1parameter.
0.2.6 - 2026-02-14
Fixed
- Removed all phantom references to non-existent modules and features from documentation
usage-rules.md: Corrected Postgrex extension module name (AshAge.Type.Agtype.Extension→AshAge.Postgrex.AgtypeExtension)usage-rules.md: Removed phantomtraverse(),neighbors(),find_path()examples (not implemented)usage-rules.md: Replaced phantom depth limits section with actual supported capabilities listAGENTS.md: Removed phantomtelemetryandtraversalmodules from dependency levels and Key FilesAGENTS.md: Removed "Adding a New Traversal Pattern" section referencing non-existent filesAGENTS.md: Removed phantom "Breaking depth limits" from Common Pitfalls- Cleaned up all merged fix branches (local and remote)
0.2.5 - 2026-02-14
Fixed
- README installation section showed
~> 0.1.0instead of current version
0.2.4 - 2026-02-13
Fixed
- UUID primary key overwritten by AGE internal integer ID —
maybe_put_idusedMap.putwhich unconditionally replaced the UUID extracted from vertex properties; now usesMap.put_newto preserve UUID when present - Static Cypher queries passed
NULLas third argument toag_catalog.cypher()— AGE rejects this withinvalid_parameter_value; now omits the params argument entirely for parameterless queries
0.2.3 - 2026-02-13
Fixed
AgtypeExtension.encode/1missing<<byte_size(value)::int32()>>length prefix required by Postgrex wire protocolAgtypeExtension.decode/1missing<<len::int32(), value::binary-size(len)>>extraction — Postgrex sends length-prefixed data on the wire
0.2.2 - 2026-02-13
Fixed
AgtypeExtension.encode/1returned{:ok, value}tuple instead of raw binary — Postgrex expects iodata, causingArgumentErroron all parameterized queries- Added missing
rollback/2callback toAshAge.DataLayer— Ash calls this to roll back transactions on failure but it was undefined, causingUndefinedFunctionError
0.2.1 - 2026-02-13
Fixed
- Error struct field mismatches in
data_layer.ex—:message/:detailfields were silently dropped at runtime because the Splode error structs only define:reason(and:queryforQueryFailed) QueryFailedconstruction inrun_query/2anddestroy/2used non-existent:resourcefield instead of:query- Removed phantom
AshAge.Errors.TraversalDepthExceededreference fromusage-rules.md(module does not exist) - Updated
AGENTS.mdversion history to include v0.2.0 and v0.2.1 changes
0.2.0 - 2026-02-13
Added
AshAge.Type.Edgestruct for AGE edge data (id,label,start_id,end_id,properties)- Real agtype parser in
AshAge.Type.Agtype— decodes::vertex,::edge,::pathsuffixes and scalar values - Vertex-to-resource attribute mapping in
AshAge.Type.Castwith type coercion (ISO8601 → Date/DateTime) - DSL transformer validation:
ValidateGraph,EnsureLabelled,ValidateLabelFormat - Idempotent migration helpers —
create_age_graph/1,create_vertex_label/2,create_edge_label/2now check catalog before creating
Changed
AshAge.DataLayer.Inforeads all config dynamically from the resource's Spark DSL instead of hard-coded valuesAshAge.Cypher.Parameterizedwraps Cypher inag_catalog.cypher()with JSON-encoded$1parameterAshAge.Querygenerates properMATCH/WHERE/RETURN/ORDER BY/SKIP/LIMITCypher and accumulates params in a mapAshAge.Query.add_param/2correctly accumulates parameters with$paramNreferences
Fixed
- Postgrex extension module reference:
AshAge.Type.Agtype.Extension→AshAge.Postgrex.AgtypeExtensionin docs and README - Removed non-existent
AshAge.Cypher.Traversalfrom doc groups inmix.exs
0.1.2 - 2026-02-13
Added
- Add
AshAgeroot module with complete setup documentation - Add
mix ash_age.installtask for printing setup instructions - Add
mix ash_age.gen.migrationtask for generating AGE migrations - Add
mix ash_age.verifytask for runtime AGE configuration verification - Implement
AshAge.Sessionmodule withsetup/1forafter_connecthook - Implement
AshAge.Migrationmodule with graph, label, and index helpers - Add unit tests for Session, Migration, and Mix task modules
0.1.1 - 2026-02-13
Fixed
- Add missing
:filtersand:sortfields toAshAge.Querystruct - Fix pattern match arity in
AshAge.Query.to_cypher/1 - Add module aliases to satisfy Credo strict checks
- Replace
condwithif/elseinAshAge.Type.Agtype - Fix
mix docsCI step to use correct MIX_ENV
0.1.0 - 2025-01-01
Added
- Initial release of AshAge DataLayer for Apache AGE
- Cypher query generation from Ash queries
- Vertex and Edge resource support
- Custom Ash types:
Agtype,Vertex,Edge,Path - Graph creation and management via
AshAge.Migration - Session-based AGE graph binding via
AshAge.Session - Parameterized Cypher queries for safe value interpolation
- Query filtering with Ash filter translation