The sharp edges specific to running Ash on Apache AGE. Each entry is symptom → cause → fix.

Row-Level Security appears to do nothing

Symptom. You enabled rls_guc and ran enable_tenant_rls/2, but every tenant still sees every row.

Cause. PostgreSQL silently skips RLS — including FORCE ROW LEVEL SECURITY — for superusers and for any role with the BYPASSRLS attribute. No error is raised; the policy simply never applies.

Fix. Run the application under a non-superuser role without BYPASSRLS. RLS is a defense-in-depth backstop, not the primary tenant filter — the :attribute app-layer scoping (Ash core's tenant filter + force-set) is the actual isolation boundary; RLS only adds a DB-enforced read barrier beneath it.

cypher() writes crash the connection (signal 11)

Symptom. A create/update against a label table crashes Postgres with a segfault and connection recovery.

Cause. A GENERATED ALWAYS ... STORED generated column on an AGE label table segfaults every cypher() write on the supported AGE build.

Fix. Never add a stored generated column to a label table. ash_age's RLS migration deliberately uses a live expression policy over properties (current_setting(guc, true) <> '' AND <discriminator> = current_setting(guc, true)), never a generated column — keep it that way if you hand-edit RLS DDL.

A cross-tenant edge/vertex was created despite RLS

Symptom. RLS is on, yet a CREATE wrote a row scoped to another tenant.

Cause. AGE cypher() CREATE bypasses WITH CHECK — RLS is read/target-side only. A cross-tenant INSERT is not denied at the database.

Fix. This is expected. The write barrier for :attribute multitenancy is Ash core's force-set of the tenant attribute, not RLS. Do not rely on RLS to reject cross-tenant writes; rely on it to hide cross-tenant reads.

Every update/destroy returns StaleRecord

Symptom. A resource's mutations always fail with Ash.Error.Changes.StaleRecord, even for rows that exist.

Cause. The primary-key attribute is listed in age do skip [...] end, so the PK is never written as a graph property and the WHERE matches zero rows. As of the current version this is a compile-time verifier error (ValidateSkip), but on an older build it fails silently at runtime.

Fix. Remove the primary key from skip. Compile with --warnings-as-errors so the verifier is build-blocking (see below).

A misconfiguration compiles anyway

Symptom. A sensitive attribute that isn't binary-storage-typed, a PK in skip, or a binary multitenancy discriminator compiles without failing.

Cause. Spark surfaces verifier DslErrors as compiler warnings, not hard failures — a plain mix compile prints the warning and still builds the module. This is ecosystem-wide Spark behavior.

Fix. Build with mix compile --warnings-as-errors (standard CI practice) to make every verifier rule build-blocking.

Jason.EncodeError / bytes in an error message

Symptom. A create/update raised Jason.EncodeError, or an error message appeared to contain raw value bytes.

Cause. Raw non-UTF-8 bytes nested inside a :map/:list attribute value are not JSON-encodable (the AGE property substrate is JSON; AshPostgres jsonb has the same limit). Top-level :binary attributes are fine — ash_age $age64$-tags them automatically.

Fix. Encode nested bytes app-side (Base.encode64/1) or store them in a top-level :binary attribute. On the current version this fails closed with a value-free error naming the attribute rather than raising.

A :binary field won't sort or range-filter

Symptom. sort(field: :asc) raises Ash.Error.Query.UnsortableField, and >/</>=/<= return UnsupportedFilter.

Cause. Binary-storage values are stored as $age64$-tagged base64, which is not byte-order-preserving — sorting or range-comparing the stored form would return silently wrong results, so both are rejected by design.

Fix. eq/not_eq/in work (deterministic-encryption search). If you need ordering or ranges, keep a separate plaintext/orderable companion attribute.

Legacy binary rows are readable but not matchable

Symptom. Rows whose :binary values were written before this format (or out-of-band) read back fine, but filters and updates against them match nothing.

Cause. Match params (filters, PK match, traversal, edge endpoints) send the tagged $age64$ form; untagged stored values are returned verbatim on read (read-only grace) but never re-tagged, so they don't match.

Fix. Rewrite the property through ash_age (a create/update re-tags it), or store such values as :string.

datetime() fails in raw Cypher

Symptom. AshAge.cypher/5 with RETURN datetime() errors with function datetime does not exist.

Cause. The supported AGE build (1.6.0) does not implement datetime().

Fix. Use timestamp() (epoch milliseconds) in Cypher, or handle date/datetime app-side — ash_age serializes %Date{}/%DateTime{} to ISO8601 on write and coerces them back on read (including Ash.Type.NewType wrappers).

Index or property access fails with an operator error

Symptom. A migration index or a hand-written query using ->> against a label table errors or silently reads nothing.

Cause. public precedes ag_catalog in the default search_path, so the bare ->> operator can resolve to the wrong function.

Fix. Use the fully-qualified ag_catalog.agtype_access_operator(...) in index SQL (the create_vertex_index/3 helper does this for you).