# ash_arcadic usage rules
_An Ash DataLayer for ArcadeDB (native OpenCypher over HTTP)._
> The full 0.1.0 surface is live: the `arcade do ... end` DSL section, query
> compilation (filter/sort/distinct/combinations), CRUD + upserts + atomics,
> bulk writes, offset + keyset pagination, aggregates, calculations,
> relationships + traversal + edge writes, vector search (dense/sparse/hybrid),
> transactions, `:async_engine`, and telemetry — all fail-closed multitenant.
> The binding facts, per feature:
## What ash_arcadic owns (and what it does not)
- **Owns:** the physical mechanism that makes an ArcadeDB store Ashy —
`set_tenant/3` / `can?({:multitenancy, …})`, sensitive-attribute verifiers,
Cypher generation, and traversal as an Ash manual relationship.
- **Does not own:** transport (that is `arcadic`, tenant-blind) or the
`multitenancy` DSL and tenant concept (that is Ash core, which passes the
tenant down).
## Non-negotiable rules (inherited design)
- **Parameters only.** Every value reaches ArcadeDB as a bound `$param` via
`arcadic`; identifiers (labels, db names) are allowlist-validated. No string
interpolation into Cypher.
- **Wire-encodable values only.** Written property values must be JSON-encodable.
Scalars, dates/times, decimals, and top-level binaries (base64'd) are handled;
a **raw non-UTF8 binary nested inside a `:map`/`:list`** value is not — encode it
app-side (`Base.encode64`) or use a `:binary`-typed attribute. The write path
**pre-checks and fails closed** with a value-free error naming the attribute,
rather than letting the JSON encoder raise with the bytes in the message.
- **Sensitive means encrypted-binary.** A `sensitive` attribute must be
app-side-encrypted binary (e.g. AshCloak) or `skip`ped; the data layer verifies
the type shape, not the ciphertext. The multitenancy discriminator is never
`sensitive` (it is a plaintext selector).
- **`MERGE` is used** for idempotent upsert (ArcadeDB-verified) — unlike the
`ash_age` sibling. Do not import AGE's "never MERGE" rule.
## Vector search — dense, sparse & hybrid (Slice 10)
- **Declare the index in the `arcade` block; the HOST creates it.** `vector_index :embedding,
dimensions: 384, similarity: :cosine` is metadata only (the `Type[property]` reference + distance
semantics + compile validation) — ash_arcadic has no migration/DDL machinery. Create the actual
index in your app: `Arcadic.Vector.create_dense_index!(conn, "Label", "embedding", 384,
similarity: :cosine)`. A `vector_index` attribute must be a STORED, NON-`sensitive`, array-typed
property (verified at compile — a float-array index cannot be encrypted-binary, and encryption would
break the index).
- **Search is a normal read action + a preparation** (dense kNN is ArcadeDB SQL `vector.neighbors`, a
separate path from the Cypher engine — not a filter/sort):
```elixir
read :semantic_search do
argument :query_vector, {:array, :float}, allow_nil?: false
argument :k, :integer, allow_nil?: false
prepare {AshArcadic.Preparations.VectorSearch, index: :embedding}
end
```
`Ash.Query.for_read(Resource, :semantic_search, %{query_vector: v, k: 10}) |> Ash.Query.set_tenant(t)
|> Ash.read()`. Results are records ranked closest-first; the distance rides
`record.__metadata__[:vector_distance]`. Pass `ef_search`/`max_distance` as preparation options.
- **Sparse (learned-sparse / BM25-style) kNN** — declare a `sparse_vector_index` over a `(tokens,
weights)` attribute PAIR (an integer array + a float array; BOTH stored, non-`sensitive`,
array-typed — verified at compile), and attach the preparation with `kind: :sparse`:
```elixir
arcade do
sparse_vector_index :sparse_embedding, tokens: :tokens, weights: :weights
end
read :sparse_search do
argument :query_tokens, {:array, :integer}, allow_nil?: false
argument :query_weights, {:array, :float}, allow_nil?: false
argument :k, :integer, allow_nil?: false
prepare {AshArcadic.Preparations.VectorSearch, kind: :sparse, index: :sparse_embedding}
end
```
Host-creates the index: `Arcadic.Vector.create_sparse_index(conn, "Label", "tokens", "weights")`.
Sparse results rank by a **`score`** (higher = better) on `record.__metadata__[:vector_score]` (no
`distance`). Sparse passthrough opts are `group_by`/`group_size` only (`ef_search`/`max_distance`
do not apply).
> **⚠️ Sparse retro-index caveat (silent).** An ArcadeDB sparse index does NOT cover rows that
> existed BEFORE it was created — only rows inserted/updated afterwards are searchable — and the
> coverage signal fires at index-CREATE time, not at query time. **Create the sparse index BEFORE
> loading data**, or re-touch pre-existing rows. A query over uncovered rows silently returns fewer
> results, with no error.
- **Hybrid fusion** — combine ≥2 arms (dense / sparse / full-text) into one fused ranked list via
`kind: :hybrid`. Arms name the indexes/properties (developer config); the caller passes the query
values. The full-text arm's property needs a host-created full-text index
(`Arcadic.FullText.create_index`); its declaration surface is a later slice, but the fuse ARM ships now.
```elixir
read :hybrid_search do
argument :query_vector, {:array, :float}, allow_nil?: false
argument :query_tokens, {:array, :integer}, allow_nil?: false
argument :query_weights, {:array, :float}, allow_nil?: false
argument :text_query, :string, allow_nil?: false
argument :k, :integer, allow_nil?: false
prepare {AshArcadic.Preparations.VectorSearch,
kind: :hybrid,
arms: [{:dense, :embedding}, {:sparse, :sparse_embedding}, {:fulltext, :body}],
fusion: :rrf}
end
```
`fusion` is `:rrf` (default) | `:dbsf` | `:linear`; `weights`/`group_by`/`group_size` pass through;
a single `k` bounds every arm and the fused output. Fused rows rank by `score`
(`record.__metadata__[:vector_score]`).
- **Tenant-scoped, fail-closed, by default (ALL kinds).** `:context` runs the kNN in the tenant's physical DB;
`:attribute` two-phase-scopes it — ash_arcadic SELF-INJECTS the tenant predicate (never trusting
Ash's own filter, so a `:bypass` action cannot leak), pre-queries the scoped `@rid`s, and passes them
as the native candidate set. The SAME candidate set scopes sparse and **every hybrid arm, including
the full-text arm** (mutation-proven live). A no-tenant search fails closed (`tenant required`).
Caller/row-policy filters compose (a denied/filtered row never enters the candidate set).
- **Cross-tenant search is a deliberate TWO-part opt-in.** BOTH the Ash action must permit the
no-tenant read (`multitenancy :allow_global` or `:bypass`, or a `global?` resource) AND the
preparation must set `allow_global?: true`. One without the other either rejects upstream
(`TenantRequired`) or stays tenant-scoped. `allow_global?` is `:attribute`-only (`:context` has no
cross-DB "global" target). A non-multitenant resource searches globally (no tenancy to enforce).
- **Cardinality ceiling (`:attribute`).** The candidate set materializes the tenant's matching `@rid`s;
a set larger than `max_vector_candidates` (default 10 000, `config :ash_arcadic,
:max_vector_candidates`) fails closed — **never truncates** (truncation would silently drop the true
nearest neighbour). For very large tenants prefer `:context` (physical isolation) for vector search.
- **Params-only + value-free.** The query vector/tokens/weights/text, `k`, and the tenant value all
bind as `$param`; no query value ever reaches an error or telemetry (the read span carries only
value-free `vector_search?` + `vector_kind` tags). A malformed stash (crafted via `set_context`)
fails closed value-free — never a leak.
## Keyset pagination, `:async_engine` & read-path redaction (Slice 11)
- **Keyset pagination + `Ash.stream!`.** `can?(:keyset)` is advertised, so `Ash.read(query, page:
[after: cursor, limit: n])` / `page: [before: cursor, …]` and `Ash.stream!` use efficient cursor
pagination instead of offset re-scans. Ash builds the compound cursor filter itself (`(sort > $c)
OR (sort = $c AND pk > $i)`) — a normal filter AshArcadic already translates — and computes each
record's cursor from its sort-field values; the data layer just advertises the capability and
implements `data_layer_keyset_by_default?/0 → false` (the callback that routes to Ash's fallback
cursor path). `page: [count: true]` returns the tenant-scoped total.
- **Supported keyset-sort set.** Any **stored, comparable** sort attribute: `:integer`, `:float`,
`:boolean`, `:string`, and the datetime/time family (`:utc_datetime`, `:naive_datetime`, `:time`,
incl. `precision: :microsecond`). A duplicate-value sort resolves deterministically via the primary
key tiebreaker. **Fail-closed (never silently mis-page):** a `:binary`(sensitive)/`:decimal` sort,
and a COMPOSITE-typed sort (`:map`, `:struct`, `:union`, `{:array, _}` — no total order), are
rejected `UnsortableField` at the sort gate; a non-stored / calc / aggregate sort is rejected
value-free `UnsupportedFilter` on the cursor page (the filter guard). Keyset over a **combination**
query is supported (the cursor lands in the outer filter).
- **Keyset limitations inherited from Ash core** (not data-layer-fixable): (a) if you provide an
`after:`/`before:` cursor whose shape doesn't match the query's sort, Ash's `InvalidKeyset` error
interpolates the (decodable) cursor — it echoes YOUR OWN prior-page sort values, not another
tenant's, but avoid logging that error verbatim; (b) a keyset sort over a field a non-admin actor
cannot read (field policy) breaks on page 2 — Ash computes the cursor from the redacted
`%Ash.ForbiddenField{}`. Sort keyset pages by a field the actor can read.
- **Perf is bounded MEMORY, not bounded time.** Keyset's win here is streaming without an unbounded
in-memory read; it is NOT necessarily faster than offset per page — AshArcadic has no sort-index
DSL, so ArcadeDB full-scans + sorts each page over an unindexed sort field. Add a host-side index on
the sort field if you need deep-pagination speed.
- **`:async_engine` — concurrent reads/loads.** `can?(:async_engine)` is advertised (probe-verified
pool-safe): Ash runs INDEPENDENT relationship/aggregate loads concurrently on a read (each its own
pooled connection; a transactional action still runs sync). This is always safe and deterministic —
the marquee async value.
- **Concurrent bulk writes: use `transaction: false` — they CONVERGE.** Advertising `:async_engine`
also lets `Ash.bulk_*` run batches concurrently when you pass `max_concurrency > 1`. On ArcadeDB,
concurrent writes to one vertex type contend on that type's **buckets** (optimistic-lock
`ConcurrentModificationException`). Every AshArcadic AUTOCOMMIT write statement retries the
conflict at TWO levels — ArcadeDB's server-side statement retry (arcadic's `retries:` body param)
plus a client-side jittered-backoff retry — both idempotency-safe by construction (an autocommit
statement is all-or-nothing; nothing was applied on the failed attempt) and Ash hooks are NEVER
re-fired (the retry lives below the data layer, around one HTTP command). So
`Ash.bulk_create(rows, R, :create, transaction: false, max_concurrency: 8)` converges fully even
on a default-bucket type (probe-verified: deterministic 80/80 across 10 runs; each batch is ONE
`UNWIND` statement, so `transaction: false` costs no atomicity vs a single-statement session).
**The default `transaction: :batch` opens a session per batch**, whose conflicts surface at COMMIT
where no statement retry is safe (re-running would re-fire hooks) — under concurrency it can
return `:partial_success`: there, pre-create hot types with buckets ≥ concurrency
(`CREATE VERTEX TYPE X BUCKETS 32`, host-side; ArcadeDB caps ~32) and check
`result.status`/`.error_count`. Retry knob: `config :ash_arcadic, :write_conflict_retries, N`
(client attempts, default 5; 1 disables the client layer).
- **Read-path value-free redaction.** A non-encodable value in a read filter literal (a raw non-UTF8
binary nested in a `:map`/`:list`) fails closed value-free — a `%QueryFailed{}` naming the failure
CLASS, never the bytes — at every read wire site (flat, aggregate/count, combination, traversal,
vector-candidate). Encode such values app-side (e.g. `Base.encode64`) or use a `:binary` attribute.
## Query-scoped bulk writes + atomics (Slice 9, Plan 1)
- **Query-scoped bulk update/destroy push down to ONE statement.** `Ash.bulk_update`/`Ash.bulk_destroy`
over a query (`strategy: :atomic`) compile to a single parameterized Cypher `MATCH (n:Label) WHERE …
SET …`/`DETACH DELETE n` — the tenant predicate, the caller filter, and the changeset filter are all
ANDed into the WHERE (Ash pre-composes them into the data-layer query). Without this the operations
still work via Ash's per-row `:stream` fallback; this is the efficient one-statement path
(`can?(:update_query)` / `can?(:destroy_query)` / `can?(:expr_error)`).
- **Empty match is a NO-OP, not `StaleRecord`.** A query-scoped bulk update/destroy matching zero rows
returns success with `[]` records — bulk semantics differ from the single-row pk-scoped `update`/`destroy`
(which raise `StaleRecord` on a no-match).
- **Atomic expression updates push into Cypher `SET`.** `change atomic_update(:field, expr(field + 1))`
(and cross-property refs, `if`/`round`, etc.) render as `n.field = <cypher>` via
`AshArcadic.Query.Expression` — the RHS is hydrated then translated, every literal bound to a `$param`.
A `sensitive`, non-stored, `:binary`, `:decimal`, relationship-path, or aggregate atomic RHS fails closed
value-free (`%UnsupportedFilter{}`); an empty SET fails closed; the multitenancy discriminator is never
settable (atomic OR static) — a write to it is rejected value-free (it would tenant-hop the row). The
atomic **target** (LHS) is likewise guarded: a `sensitive` (app-side-encrypted) or non-stored target
field is rejected value-free — an atomic binds its RHS raw (no serialization), so it must never write
plaintext into an encrypted-binary column.
- **Atomic create/upsert are honest.** `change atomic_set(:field, expr(…))` on a create and an atomic
change on an upsert action are applied (`can?({:atomic, :create|:upsert|:update})`): `create_atomics`
fold into the `CREATE … SET …` — on a single create, a **bulk create**, and the **upsert `ON CREATE
SET`** (the insert branch) — and `atomics` fold into the upsert `ON MATCH SET …`. (Advertising these
without folding on ANY of those surfaces would silently DROP the atomic change — closed.)
- **Bulk destroy return-records captures pre-delete properties.** `return_records?: true` on a bulk
destroy returns the deleted rows WITH their attributes (`… WITH n, properties(n) AS p DETACH DELETE n
RETURN p`) — a post-delete read would yield no attributes.
- **`limit`/`offset`/`combination_of` on a query-scoped bulk write fail CLOSED.** A single `MATCH … SET`/
`DELETE` cannot honor a per-row limit/offset (no ordering semantics) or a combination — a paged/combined
bulk update/destroy returns a value-free error (use `strategy: :stream`), never a silent unscoped mutation.
A conditional after-batch hook (`change …, where: […]`) on the action likewise fails closed (unsupported
on the atomic path; use `:stream`).
- **Multitenancy is fail-closed on every bulk-write path.** `:context` — a blank tenant resolves no
database, so no statement runs; `:attribute` — the discriminator predicate scopes the WHERE. A fabricated
cross-tenant attacker cannot bulk-update/destroy another tenant's rows (mutation-proven).
- **Every value is a bound `$param`; errors are value-free.** Write-path params (static changes AND atomic
RHS literals) are JSON-encode-gated before the wire — a poisoned value fails closed value-free, never a
byte-leaking crash. (Read-path filter params are a separate, pre-existing gap — tracked outside this slice.)
- **Heterogeneous per-record bulk update (`update_many`, Slice 9 Plan 2).** `Ash.update_many/4` (a list of
`{record, input}` tuples with `strategy: :atomic`) pushes a heterogeneous bulk update — each record its
own changes — into ONE `UNWIND $rows AS r MATCH (n:Label {pk: r.pk}) [WHERE …] SET n += r.set[, <shared
atomics>]` statement keyed by primary key. A record absent from the graph is simply absent from the
result (never an error). Tenant scoping rides `opts.tenant`, never row data: `:context` targets the
tenant database and fails closed on a blank tenant; `:attribute` injects the discriminator predicate. The
group's shared `changeset.filter` (optimistic lock / atomic validation / policy) is AND-composed onto the
WHERE, fail-closed on an untranslatable filter — symmetric with single-row `update`/`destroy`.
- **Multi-row bulk upsert (Slice 9 Plan 2).** A bulk `upsert? true` action (`upsert_fields` required by
Ash) compiles to ONE `UNWIND $rows AS r MERGE (n:Label {<identity>: r.<identity>, …}) ON CREATE SET
n += r.all[, <create atomics>] ON MATCH SET n += r.set[, <match atomics>]` statement — existing rows
update, new rows create, idempotent, no duplicates. For an **`:attribute`** resource the tenant
discriminator is added to the MERGE identity (D4), so a same-PK upsert from another tenant matches
nothing and creates its own row (never hijacks). Atomic changes fold on BOTH branches (`create_atomics`
→ ON CREATE, `atomics` → ON MATCH); the discriminator is never in the ON MATCH set (D3). Every wire value
is encode-gated value-free.
- **Concurrency caveat (inherent MERGE limit).** "No duplicates / idempotent" holds for SEQUENTIAL
re-runs. ArcadeDB enforces no identity uniqueness, so two CONCURRENT bulk upserts of the SAME NEW
identity can each MATCH nothing and both CREATE — duplicate rows. This is the same limitation as the
single-row upsert; add a unique index or serialize writers if you need a hard guarantee.
- **`upsert_condition` is honored (single-row AND bulk).** The condition gates the ON-MATCH update
against the EXISTING row's values: condition true → update applies; condition false → the update is
SKIPPED — single-row default raises `StaleRecord`, `return_skipped_upsert?: true` returns the
existing row flagged `__metadata__.upsert_skipped`; in bulk (without `return_skipped_upsert?`) a
skipped row is OMITTED from the returned records (Ash's own bulk semantics), never an error. No
matched row → plain CREATE (the condition gates ON MATCH only). Mechanics: a conditional upsert runs
the Ash-reference three-step flow (conditional UPDATE through the tenant-scoped identity → existence
probe → CREATE) instead of one MERGE — atomic under the action's transaction (Ash creates default
`transaction?: true`); a conditional BULK upsert routes per-row through that flow (one statement per
row, not one UNWIND). Tenancy holds: another tenant's same-PK row is never evaluated or mutated
(mutation-proven). The condition's literals are encode-gated value-free like every write param.
## Query & filter push-down (Plan 2)
- **Supported filter operators:** `==` / `!=` / `>` / `<` / `>=` / `<=` / `in` /
`is_nil`, boolean `and`/`or`/`not`, and the string-match functions `contains`,
`string_starts_with`, `string_ends_with` (→ ArcadeDB `CONTAINS` / `STARTS WITH` /
`ENDS WITH`). Anything else (`like`/`ilike`, attribute-to-attribute comparisons,
aggregates/exists) returns a value-free `UnsupportedFilter` — filters fail closed,
never silently drop scoping.
- **String-match is case-SENSITIVE.** ArcadeDB `CONTAINS`/`STARTS WITH`/`ENDS WITH`
do not honor a `:ci_string` attribute's case-insensitive semantics.
- **`:decimal` is money-safe but range/sort-restricted (D27).** Decimals are stored
as their exact string form, so equality / `in` / `is_nil` work, but `gt/lt/gte/lte`
are **rejected** (`UnsupportedFilter`) and `:decimal` is **unsortable** — ArcadeDB
compares the string form lexicographically, which would be silently wrong for a
numeric range/order. **Model money as integer minor units** when you need range
filtering or sorting. (`:binary` attributes are likewise unrangeable/unsortable —
base64 is not byte-order-preserving.)
- **Datetime/time comparisons work (`:utc_datetime`, `:naive_datetime`, `:time`, incl.
`precision: :microsecond`).** ArcadeDB auto-coerces stored ISO8601 datetime/time strings to its
native temporal types, so AshArcadic wraps the bound comparison param in the matching Cypher
constructor (`datetime()` / `localtime()`) — equality, range (`gt/lt/gte/lte`) and `in` compare
temporal-to-temporal, not string-to-coerced-value. `:date` needs no wrapper (ArcadeDB keeps
date-only strings as strings). A **compound** temporal comparison (a temporal attr against a
value-EXPRESSION RHS — an `if/3`, arithmetic, fragment) is NOT wrapped on the expression path, so it
fails closed value-free (`UnsupportedFilter` naming the field) rather than silently mis-filtering —
use a plain literal comparison, which IS wrapped.
- **Filtering a `sensitive` field is unsupported.** A value comparison (`==`/`!=`/`>`/`<`/`in`/
`contains`/`string_starts_with`/`string_ends_with`) on a `sensitive` (app-side-encrypted
binary) field fails closed value-free (`%UnsupportedFilter{}`); `is_nil`/`not is_nil`
(presence) are allowed.
- **Searchable-encryption escape hatch.** A field needing deterministic/searchable-encryption
equality is modeled as a PLAIN `:binary` attribute (NOT `sensitive`), where equality on the
caller-encrypted value works; `sensitive` IS the "do not filter on this field" contract.
- **Presence-oracle residual.** `is_nil`/`not is_nil` on a `sensitive` field is allowed and
leaves a presence oracle (the has-value cohort is enumerable); treat presence-as-classified
with a host field policy if required.
- **Filtering a non-stored (`skip`-ped/computed) field is unsupported.** Value comparisons AND
`is_nil`/`not is_nil` on a non-stored ArcadeDB property fail closed value-free (mirrors the sort
rule) — the property is never stored, so `is_nil` would match every row. (`is_nil`/`not is_nil` on
a STORED `sensitive` field stays allowed — the presence oracle above.)
- **A string function over a relationship path is unsupported (upstream Ash bug).** `filter(res,
contains(rel.field, "x"))` raises a `KeyError` inside Ash-core `scope_refs` (Ash 3.29.3),
before AshArcadic sees it; use a flat filter or load-then-filter pending the upstream fix.
## Distinct (Slice 8, Plan 1)
- **`distinct`/`distinct_sort` push down to native Cypher.** `Ash.Query.distinct(res, [:field, ...])`
compiles to a DISTINCT-ON-subset render (`WITH n.<f> AS __d0, ..., collect(n)[0] AS n RETURN n`) —
one whole vertex per distinct group, over stored, non-`sensitive` fields. Outer `sort`/`limit`/
`offset` apply **after** the dedup. `limit` bounds the returned rows, not the DB-side dedup
working set (the collect-group materializes every group's full vertex list before `[0]`) —
filter narrowly on large labels.
- **Representative-row selection is via `distinct_sort`, else the query's `sort`.**
`Ash.Query.distinct_sort(res, [...])` orders each group before `collect(...)[0]` picks the
representative; with no `distinct_sort`, the query's `sort` selects it (Ash's documented
fallback — "if none is set, any sort applied to the query will be used"). With neither, the
representative is engine-arbitrary and the result rows carry no defined order (Ash promises
none absent a sort; sibling data layers like ETS happen to return distinct-key order — rely
on neither).
- **Aggregates over a distinct query fold the deduped representatives.** `Ash.count`, a
`page: [count: true]` read, and value aggregates (`sum`/`min`/…) over a query carrying
`distinct` dedup **before** folding — never the raw rows.
- **Dedup is per-tenant** under both multitenancy strategies (`:attribute` scoped by the
discriminator in the shared database; `:context` physically isolated per-tenant database).
- **Fails closed value-free (`QueryFailed`, naming only the field)** on: a non-stored
(`skip`-ped/computed) distinct field; a `sensitive` distinct field (random-IV ciphertext
never dedups equal plaintext); a calculation or relationship-path distinct entry; and any
sort direction outside Ash's six qualifiers
(`:asc`/`:desc`/`:asc_nils_first`/`:asc_nils_last`/`:desc_nils_first`/`:desc_nils_last`) —
`distinct_sort` reaches the data layer with no upstream validation, so the data layer rejects
it itself. A `:binary`/`:decimal` field in the distinct list is not rejected by this data
layer's guard (dedup is equality), but Ash core rejects it upstream (`UnsortableField`)
before it reaches the data layer.
- **`distinct_sort` additionally rejects `:binary`/`:decimal` storage** (base64/lexicographic order
≠ value order, so the "first" row after ordering would be the wrong representative) — the same
`can?({:sort, storage})` decision the record sort path already makes.
## Combinations (Slice 8, Plan 2)
- **`Ash.Query.combination_of(res, [Combination.base(...), Combination.union(...), ...])` is
first-class.** All five types are advertised: `:base` (the required first branch),
`:union`, `:union_all`, `:intersect`, `:except`. Combinations return **whole vertices**
(no field-projection `select`); the set-op keys on the resource's **primary key**.
- **Two execution strategies, chosen automatically by the branch types** (surfaced by the
`combination_strategy` telemetry tag):
- **Native** (`:native`) when every branch is union-family (`:base`/`:union`/`:union_all`) →
one `CALL { <branch> UNION[ ALL] <branch> } WITH n [WHERE <outer filter>] [distinct] RETURN n
[ORDER BY/SKIP/LIMIT]` statement pushed to ArcadeDB (each branch's `$params` re-keyed into a
disjoint namespace).
- **In-memory** (`:in_memory`) when any branch is `:intersect`/`:except` (ArcadeDB has no
`INTERSECT`/`EXCEPT`) **OR any branch carries a per-branch `limit`/`offset`** (paging forces the
in-memory strategy so the tenant filter is applied to each branch **before** its limit) → each
branch runs as its own query with the outer filter pushed in, then the results are folded by
primary key in the app. `intersect`/`except`/paged combinations therefore fetch each branch's
**full filtered result set** into memory before combining — filter narrowly.
- **The in-memory strategy is NOT a consistent snapshot.** Its branches are separate,
non-transactional queries; a concurrent write between two branch reads can combine records from
different database states (e.g. a row updated to fail the filter between the base and subtrahend
reads of an `except`). The native path is a single atomic statement; the in-memory path matches the
Ash ETS reference's sequential-per-branch semantics. A strongly-consistent read is not available for
`intersect`/`except`/paged combinations this slice.
- **`:union` after `:union_all` deduplicates only the incoming branch against the accumulator** (the
fold retains the accumulator's `union_all` duplicates), matching the Ash ETS reference. Appending an
`intersect`/`except` to a union-family chain (which switches it to the in-memory strategy) therefore
does not change what an earlier `union` deduplicated.
- **Multitenancy is enforced per branch.** `:context` requires every branch to resolve to the
**same non-nil tenant database** — a blank tenant or branches spanning databases **fail closed
value-free**. `:attribute` scoping rides the outer `query.filters` (Ash injects the tenant
predicate on the outer combination query); it is applied by the native `CALL`-wrap `WHERE` and
**pushed into every branch** on the in-memory path, so a cross-tenant primary-key collision can
never enter the fold.
- **An outer `distinct` over a combination** renders the DISTINCT-ON collect-group on the union
output but keeps an **engine-arbitrary representative** per group — it does **not** honor
`distinct_sort` (the union output has no stable pre-collect order to select by).
- **Read-span telemetry** gains `combination?`, `combination_types` (the branch type atoms), and
`combination_strategy` (`:native` | `:in_memory` | `nil`).
- **Fails closed value-free (`QueryFailed`)** on combination shapes this slice does not support:
- a branch carrying **`calculations`**;
- a branch carrying an **expression-calculation `sort`** (the branch-param re-key does not cover a
`sort` fragment — forward-compatible fail-closed);
- when the query runs on the **in-memory** path (any `intersect`/`except`, or any per-branch paging):
an **expression-calculation outer `sort`** or a **lazy outer filter `:expression`** (both are
honored on the native path — the in-memory runtime sort/fold path cannot evaluate them);
- a **mid-chain `:base`** branch (only the first branch may be `:base`);
- **loading an aggregate or a calculation ON a combination read** (Ash runs `add_aggregates`/
`add_calculations` on the combined query; both are out of scope this slice).
- **Documented Ash-core limitation — aggregating a combination directly is silently wrong.** A
standalone `Ash.count`/`Ash.sum`/`Ash.aggregate` over a combination query drops the combination
in **Ash core** (the aggregate action rebuilds the query without `combination_of`) and returns
the **un-combined base** result. This is not fixable in the data layer (the combination never
arrives). To aggregate a combination, **read it and fold app-side.**
## Calculations (Slice 7)
- **Expression calculations are first-class — load, filter-on, and sort-on.** A
`calculate :full_name, :string, expr(first <> " " <> last)` loads, `filter(res,
full_name == "…")` and `sort(res, full_name: :asc)` push down, and raw compound
attribute expressions in a filter (`filter(res, a + b > 5)`) are expanded and pushed
down. Module calculations and standalone `Ash.calculate/2` are unchanged.
- **Two compute paths, one supported set.** LOADED calcs compute in **Elixir** (Ash's
evaluator over the flat `RETURN n`, so sensitive fields stay app-decrypted upstream);
filter-on-calc, sort-on-calc, and raw filter-expansion translate to **Cypher** via the
`AshArcadic.Query.Expression` value translator (WHERE / ORDER BY only). The supported
expression set is identical across all three paths.
- **Supported operators/functions:** arithmetic `+` `-` `*` `/`, concat `<>`, comparison
(`==` `!=` `>` `<` `>=` `<=`), boolean `and`/`or`/`not`, `if`/`cond` (→ Cypher `CASE`),
`is_nil`, `string_downcase` / `string_length` / `length` / `string_trim` / `round`
(single-argument `round/1` only — `round(x, precision)` fails closed), and `contains` /
`string_starts_with` / `string_ends_with`. A comparison may carry a compound value
expression on **either** side (`a + b > 5`, `a > b + 1`, `first <> last == "…"`). Anything
else (date/time functions like `ago`/`now`, `fragment`, `type` coercions, relationship-path
calcs) fails closed value-free (`%UnsupportedFilter{}` naming the operator/field).
- **Division is float, matching Ash.** `a / b` emits `toFloat(a) / b` so integer operands
divide like Ash (`7 / 2 == 3.5`), NOT ArcadeDB's integer truncation (`7 / 2 → 3`) — the
filtered set matches the loaded value.
- **A `sensitive` or non-stored field in a calc expression fails closed value-free on ALL
paths** (load, filter, sort). The data layer only ever holds the STORED value, and a
`sensitive` field is app-side-encrypted ciphertext (AshCloak decrypts above the data
layer) — evaluating a calc over it is both wrong and a redaction-leak surface. For a
derived value over a sensitive field, use a **module calculation** (computed above the
data layer, post-decrypt).
- **Relationship-path calcs fail closed on ALL paths.** A calc referencing a related node
(`expr(author.name)`) is rejected value-free on load, filter, AND sort — the load path
never routes it through Ash's `authorize?: false` relationship-load fallback, so it cannot
read a related resource around its row/field policies. Relationship calculations are a
future (traversal-calc) concern.
- **Field-policy interaction.** A calc referencing a field-policy-protected (non-`sensitive`)
field in a filter/sort inherits AshArcadic's flat-field-filter behavior — the data layer
has no actor at translate time, the same documented class as the `exists`-oracle (an
upstream Ash concern, not data-layer-fixable).
- **Sort nil-placement is faithful.** All four Ash qualifiers are honored: `:asc`/`:desc`
use ArcadeDB's native placement (ASC → nulls last, DESC → nulls first, matching Ash's
default convention); the explicit opposites `:asc_nils_first` / `:desc_nils_last` are
honored with a leading `(<col> IS NULL)` sort key.
- **Load/filter parity boundary.** Pushed filter/sort computation runs in ArcadeDB and matches
the Elixir-loaded value on the common paths, but three edges diverge because Cypher cannot
reproduce Elixir's exact semantics: (1) a calc whose **declared type coerces** its
expression in a value-changing way (a non-natural type, e.g. `:string` over an integer
expression) — the load casts, the pushed filter/sort does not (a `type`-coercion non-goal;
use the natural declared type or a module calc); (2) `round/1` at exact **negative
half-integers** — Ash rounds half away from zero (`-2.5 → -3`), ArcadeDB half toward `+∞`
(`-2.5 → -2`); (3) **division by zero** — loading returns a value-free error (the calc eval is
rescued), while the pushed filter yields ArcadeDB `Infinity` (row included). For guaranteed
parity on these edges, compute via a module calculation.
## Aggregates (Slice 3, Plan 1)
- **Supported kinds:** `Ash.count / sum / avg / min / max / first / list / exists?` and
`Ash.aggregate` (including `uniq?`), plus offset-pagination `count: true`. Each aggregate
runs as **one parameterized Cypher statement**, **tenant-scoped fail-closed** — the same
posture as reads: a `:context` blank tenant errors (never a base-database read), and an
`:attribute` resource rides Ash-core's injected discriminator filter. A **per-aggregate
filter** is honored (ANDed onto the tenant scope); an unpushable per-aggregate filter fails
closed (`UnsupportedFilter`), never a silently unscoped aggregate.
- **Empty (and all-null-field) sets return Ash's per-kind default, not ArcadeDB's.** `sum` /
`avg` / `min` / `max` / `first` over a set with no non-null field values → `nil` — a
`count(n.<field>)` non-null-count companion disambiguates it from ArcadeDB's `sum → 0`
(matching Ash/SQL null-skipping); `count → 0`; `list → []`. A caller-supplied `default` is
honored.
- **Storage-class guard (fail-closed value-free).** `sum` / `avg` require **numeric** storage
(`:integer` / `:float`) — rejected over `:decimal` (exact-string; ArcadeDB `sum`/`avg` would
concatenate/error) and every non-numeric type. `min` / `max` / `first` require
**order-preserving** storage — rejected over `:binary` and `:decimal` (same D27 reason
sort is restricted). `list` rejects **`:binary`** (an encrypted-binary / `sensitive`
attribute would otherwise return ciphertext into the result). `count` / `exists?` are always
allowed. A rejected aggregate names only the field + kind — never a value.
- **Unsupported (this flat/query path):** flat inline field aggregates (`add_aggregates` over a
non-relationship) and lateral joins are **not** supported (ArcadeDB has no window functions).
**Custom** aggregate kinds are unsupported. `include_nil?: true` on `list` / `first` is
**unsupported on this flat path** (ArcadeDB `collect` drops nulls) — it fails closed value-free
(the **traversal** aggregate path below *does* honor it). **Relationship aggregates over a manual
`Traverse` relationship ARE supported** as of Slice 4 — see *Traversal aggregates* below; but a
**standalone** `Ash.aggregate` over a relationship path is rejected value-free (load it inline).
## Traversal aggregates (Slice 4)
- **Declare** an aggregate over a manual `Traverse` relationship in an `aggregates do … end`
block — e.g. `aggregates do count :descendant_count, :descendants end` — for any kind
(`count`/`sum`/`avg`/`min`/`max`/`first`/`list`/`exists`). It computes over the node's
**reachable subtree** (what the `Traverse` relationship reaches).
- **Computed POST-authorization, in Elixir** — never a DB-side Cypher aggregate. The subtree is
loaded through one batched authorized `Ash.load` (Traverse's `UNWIND $ids`, not N+1) threading the
**real `authorize?`/`actor`/`tenant`**, then folded in `AshArcadic.TraversalAggregate` over the
already-authorized, **node-deduped**, tenant-scoped, filtered, sorted destinations. Consequences a
DB-side aggregate would get wrong: a **policy-denied intermediate drops its entire subtree** (a
destination reachable only through a denied hop is not counted), a **cross-tenant node is not
counted**, and multi-path nodes are deduped (no double-count for `sum`/`avg`).
- **`include_nil?` is honored** for traversal `list`/`first` (Elixir null control) — the asymmetry
vs. the flat aggregate path above (which fails it closed because Cypher `collect` drops nulls).
Empty / all-null-field sets return the aggregate's Ash default; the same value-free storage-class
guard applies (`sum`/`avg` numeric; `min`/`max`/`first` order-preserving; `list` rejects `:binary`).
- **Load it inline** (`Ash.read(load: [:descendant_count])` / `Ash.load(record, :descendant_count)`).
A **standalone** `Ash.aggregate` over a relationship path is **rejected value-free** — its cross-row
collapse semantics are unresolved; the per-node subtree rollup is delivered by the inline load path.
- **Not supported:** multi-segment relationship paths fail closed value-free (compose two authorized
reads instead).
## Standard (attribute-FK) relationships (Slice 5)
- **A standard relationship is a property FK, not a graph edge.** `belongs_to` / `has_many` /
`has_one` / `many_to_many` store the FK as a vertex property; Ash's core batched-`IN` loader
(over AshArcadic's `run_query`) does the loading/aggregating — there is no new callback and no
edge write. Use a **graph edge** (a manual `Traverse` relationship + edge-write) only when you
need graph traversal semantics; use a **standard relationship** for ordinary FK associations.
- **A join/FK attribute must NOT be `sensitive`.** A `sensitive` attribute is app-side-encrypted
binary and cannot be `IN`-joined; declaring one as a relationship join key fails the build
(`ValidateRelationshipFk`, value-free). _Coverage boundary:_ the check is per-resource and local —
it catches a sensitive `belongs_to` `source_attribute` and a sensitive join-resource FK directly.
A `has_many`/`has_one` sensitive `destination_attribute` is caught only when the destination
declares the idiomatic inverse `belongs_to`; a sensitive `destination_attribute` with no inverse
is not caught at compile (a per-resource verifier cannot read a sibling's `sensitive` list) — its
effect is a silently-empty load, not a leak. Don't mark a relationship FK `sensitive`.
- **Filtering across a relationship is fail-closed to authorizer-bearing destinations.** A
source-on-related filter (`filter(Post, author.name == x)`) routes through Ash's separate-read
IN-rewrite, which reads the DESTINATION **without** per-hop authorization. To prevent an
unauthorized row-policy bypass / field-policy oracle, AshArcadic **rejects** (`"not filterable"`,
for every actor including admin) filtering across a relationship whose destination resource
carries any authorizer. Filter against a destination with no authorizer, or filter/load the
destination directly. **Loading and aggregates are unaffected** — they apply authorization
correctly. (Tenant isolation always holds on every delegated read.) _Known limitation (Ash-core, not
data-layer-fixable):_ the `exists(rel, …)` path is NOT gated by this guard — Ash decomposes a related
`exists` into flat reads before the data layer sees it, so there is no capability/translator hook, and
the one data-layer lever (rejecting any `internal?` read over an authorizer-bearing resource) was tried
and **reverted** because it also rejects legitimate relationship-referencing read policies
(`relates_to_actor_via` / `accessing_from` / `authorize_if expr(exists(rel, …))`). Do NOT rely on
`exists` over a relationship to a policy-protected field; the proper fix is an upstream Ash-core hook.
- **Filtering across a manual `Traverse` relationship is unsupported** (fail-closed, `"not
filterable"`) — its per-hop authz cannot be preserved by the IN-rewrite.
- **Filtering a source on a `many_to_many`-related field is unsupported.** `filter(Tag, posts.title
== x)` is rejected — by Ash-core (`"cannot access multiple resources for a data layer that can't be
joined…"`) when the endpoint has no authorizer, or earlier by the fail-closed rule above (`"not
filterable"`) when it does — because a m2m filter crosses the join resource and AshArcadic advertises
no join. Load the m2m and filter in memory, or use a standalone read. **m2m loading and aggregates
work.**
- **Filter-on-aggregate is unsupported** (`filter(res, some_agg > n)`) — it fails closed value-free
(`%UnsupportedFilter{}`); an aggregate is a computed fold value, not a stored property.
- **Index FK properties for large relationships (performance).** A relationship load/filter resolves
through `WHERE dest.<fk> IN [<source_pks>]`; without an ArcadeDB index on the FK property this is a
full-label scan. For large destination sets, add an index on the FK property in your host-app
`arcadic` migration (as you would the primary key).
## Multitenancy (Plan 2)
- **`:context` = database-per-tenant** (strongest isolation): `set_tenant/3`
re-targets a physically distinct ArcadeDB database. A nil/blank tenant fails
closed (no query runs) — never a silent base-database read/write. The per-resource
`database` DSL option is **ignored for `:context`** (the tenant resolves the
database; a static value can never pre-seed and defeat the fail-closed read). No
cross-tenant traversal. **`:context` database names are operator-visible** on the
server; use a `tenant_database` MFA to hash a classified tenant space if the
tenant identity is sensitive.
- **`:attribute` = discriminator on a shared graph:** Ash core injects the
`<discriminator> == <tenant>` filter (read) and force-sets it (write); AshArcadic
compiles the injected filter to a scoped `WHERE`. A cross-tenant update/destroy
matches zero rows and fails closed as `StaleRecord` (the scoped query runs and
matches nothing — never a silent unscoped mutation). One graph, cross-tenant
traversal possible.
- **Upsert via native `MERGE`:** an action with `upsert? true` maps to
`MERGE (n:Label {<identity>}) ON CREATE SET … ON MATCH SET …` — idempotent on the
primary key (or a composite identity). For an **`:attribute`-multitenant**
resource the tenant discriminator is **added to the MERGE identity**, so a
same-PK upsert from another tenant matches nothing and creates its own row (MERGE
matches the whole node pattern and cannot compose a `WHERE`, so the discriminator
must ride the identity — otherwise it would hijack the other tenant's row).
**Multi-row bulk upsert IS supported** (Slice 9 Plan 2) — a bulk `upsert? true`
action (`upsert_fields` required) compiles to ONE `UNWIND … MERGE … ON CREATE …
ON MATCH …` statement; existing rows update, new rows create, idempotent. The
`:attribute` discriminator rides the MERGE identity (same-PK cross-tenant upsert
creates its own row, never hijacks); atomic changes fold on both branches.
## Transactions (Plan 3)
- **Transactions are single-database.** A transaction opens one ArcadeDB session,
bound to one database. A **cross-database write inside a transaction fails closed**
(`:cross_database_transaction`) — an atomic write spanning two tenants' databases
(`:context` multitenancy) is impossible by construction, never a silent split-brain
write on a fresh connection. A cross-database *read* inside a transaction is allowed
and runs on its own connection (a read is not an atomicity hazard). The guard blocks
the **offending write** (returns the `:cross_database_transaction` error); rolling back
the transaction's **prior** in-session writes then depends on that error being
propagated. Ash does this for you — a data-layer error aborts the action and triggers
rollback — so a normal Ash action stays atomic. If you drive `transaction/4` directly
and *swallow* the error, the prior writes commit; propagate it (or call `rollback/2`).
- **Read-own-writes on the same database.** The session opens lazily on the **first
write**; a read issued after that write (on the transaction's database) reuses the
session and therefore **sees the transaction's own uncommitted writes**. A read
*before* any write runs on a plain connection (no session yet).
- **Owner-process only.** The transaction session lives in the calling process; Ash
disables async inside a transaction, so every action runs in that process. Do not
hand a transaction's work to a spawned task or a separate process — it will not see
the session.
- **The duplicate-PK residual is a data-model problem, not a transaction one.** Inside
a transaction a duplicate-primary-key update (two rows sharing a PK) matches >1 row,
fails as `UpdateFailed`, and the mutation rolls back atomically (nothing is left
half-written). To prevent the duplicate rows in the first place, add a **unique
index on the primary key** in your host-app `arcadic` migration — that removes the
residual entirely rather than relying on the transaction to clean it up.
## Traversal (Plan 4; upgraded in Slice 2, Plan 2 — spec §7)
- **Bounded graph reach as a manual relationship.** Declare a `has_many` whose
`manual` is `AshArcadic.ManualRelationships.Traverse` to traverse edges:
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}
end
`edge_label` is required and identifier-validated; `direction` is
`:outgoing | :incoming | :both`; `max_depth` is a required integer ≥ 1 (unbounded
`*` is forbidden); `min_depth` defaults to 1; `scope_edges` defaults to `true`
(see edge scoping below). The **destination resource must have a single-attribute
primary key** (composite → fail-closed value-free). Loading the relationship returns
the reachable **and authorized** destination records, deduped per source,
cardinality-aware.
- **Edge writes landed in Slice 2** (see "Edge writes" below). Traversal also reads
edges written out-of-band (host-app ingestion / raw `arcadic` Cypher).
- **Traversal is fail-closed multitenant.** A blank tenant runs no query. `:context`
traversal is physically scoped to the tenant's database (no cross-tenant reach).
`:attribute` traversal scopes **every node on the path** via the native predicate
`ALL(x IN nodes(p) WHERE x.<attr> = $tenant)` — an in-tenant node reachable only
through an out-of-tenant intermediate is **excluded**, not just the endpoints.
Traversing between two `:attribute` resources with **different** discriminators
fails closed (`:mixed_attribute`) — one tenant value cannot honor two dimensions.
- **Edge-property scoping is DEFAULT-ON for `:attribute`.** In addition to node
scoping, the path predicate also scopes **every edge** via
`ALL(r IN relationships(p) WHERE r.<attr> = $tenant)`. Library-written edges carry
the `<attr>` stamp (see "Edge writes"), so this is fail-closed: an out-of-band edge
**lacking** the stamp is *excluded* (never silently traversed into a cross-tenant
reachability leak). Opt out with **`scope_edges: false`** for graphs whose edges are
written out-of-band and rely on node-structure scoping only. `:context` traversal
needs no edge scoping (physical DB isolation).
- **Traversal delegates filter / sort / row-policy / field-policy to standard authorized
reads (Option B, spec §7.2 — resolves the Plan-4 CV1 carry).** The traversal is a
three-step primitive: (1) a tenant-scoped reachability query returns each source's
reachable paths as **node-PK lists** (scoping the whole path — nodes + edges); (2) two
authorized `Ash.read`s — **Read A** authorizes every path node by **row policy**, then
**Read B** reads the surviving destinations through the caller's `context.query` (its
**filter + sort**), applying row policy, **field policy** (redaction), and the tenant
filter / database; (3) regroup per source. The tenant boundary is enforced **twice over**
(the path predicate + the reads' `:attribute` filter / `:context` database), both
fail-closed. **Ash rejects *dynamic* `limit`/`offset` on manual relationships** (it raises
`Ash.Error.Load.InvalidQuery`), so a traversal relationship cannot be loaded with a caller
limit; for a bounded per-source result use the static **`per_source_limit`** /
**`per_source_offset`** opts (below), or a downstream read/pagination over the loaded set.
- **Authorization is PER-HOP (row policy on every node of the path).** Read A authorizes
**every node on each path** (destinations *and* intermediates) by row policy; a destination
is returned only if it has a path whose **every** node is authorized. So a destination
reachable **only** through a row-policy-denied intermediate is **dropped** (the intermediate
is never returned); a destination with any fully-authorized path survives. The caller's
destination **filter** (Read B) selects/shapes which destinations to return — it does **not**
block traversal *through* a filtered-out but authorized intermediate. This covers
**self-referential** traversal (the shipped norm). A path through an intermediate of a
**different resource** carrying a **different** policy is a Slice-3 concern and **fails
closed** here (such a destination is dropped). Field-policy redaction still applies to the
returned destinations.
- **Per-source limits are STATIC manual opts (Slice 3, Plan 2).** `per_source_limit`
(a positive integer, default `nil` = unbounded) and `per_source_offset` (a non-negative
integer, default `0`) on the manual `Traverse` opts cap each source's reachable destinations
at a per-source top-N, sliced `offset..+limit` **by the relationship's own `sort`**. The slice
is **output-shaping applied AFTER per-hop authorization and the caller sort** (in `regroup`,
over the already-authorized Read-B destinations) — **not a query-cost bound**: Read B still
reads the full authorized union first, so the top-N is by rank among the *authorized*
destinations and a policy-denied destination **never consumes a slot**. `per_source_limit` and
`per_source_offset` are **meaningless on a `:one` relationship and rejected value-free**. These are static because Ash
rejects *dynamic* limit/offset on manual relationships (above); declare them on the resource's
manual opts, e.g. `manual {AshArcadic.ManualRelationships.Traverse, edge_label: :KNOWS,
max_depth: 3, per_source_limit: 10}` with the relationship's `sort` setting the ranking.
## Edge writes (Slice 2, Plan 1)
- **Declare an edge** in the `arcade do … end` block, then wire a change on a
create/update action:
arcade do
client MyApp.Client
label :Person
edge :friends do
label :KNOWS
direction :outgoing # :outgoing (default) | :incoming | :both
destination MyApp.Person # must have a single-attribute primary key
properties [:since] # optional edge-property keys
# multiple? false # default → idempotent MERGE; true → parallel CREATE
end
end
actions do
update :befriend do
require_atomic? false # the change is a non-atomic after_action
argument :to, {:array, :string}
argument :since, :string
change {AshArcadic.Changes.CreateEdge, edge: :friends, to: :to}
end
update :unfriend do
require_atomic? false
argument :to, {:array, :string}
change {AshArcadic.Changes.DestroyEdge, edge: :friends, to: :to}
end
end
- **`to:` names an argument** holding the destination PK (or a list → N edges;
nil/empty → no edge, the action still succeeds). Edge **property values come from
same-named DECLARED action arguments**, serialized by the argument's declared type.
- **Writes run in the action's transaction** (an `after_action` hook). A failed or
0-row edge write returns `{:error, _}` so Ash rolls the vertex back; a mid-list
failure rolls **all** edges back (not a partial write). DB errors are redacted.
- **`multiple?` selects the primitive.** `false` (default) → `MERGE` — idempotent, one
edge per endpoint-pair + label (a repeat `befriend` updates the edge's properties, no
duplicate). `true` → `CREATE` — parallel edges (each write is a new edge).
- **Single-attribute destination PK required.** Edge destinations must have a
single-attribute primary key (the endpoint match binds `b.<pk> = $dst`).
- **Fail-closed multitenant.** For an `:attribute` resource, both endpoints are scoped
by the tenant discriminator in the `WHERE` *before* the MERGE/CREATE/DELETE
rel-pattern (never inlined into a node pattern), and the discriminator is stamped
onto the edge. A same-PK destination in another tenant is **not** bound — a
cross-tenant edge write 0-rows (`InvalidRelationship`); a cross-tenant edge delete
0-rows (`StaleRecord`). Deleting an absent edge also fails closed as `StaleRecord`.
- **Sensitive edge properties (R4).** An `edge` `properties` key naming a `sensitive`
attribute requires every same-named action argument to be **binary-storage-typed**
(`:binary`), else the classified datum would reach edges as plaintext. Enforced at
compile time (`ValidateSensitive` R4) and at runtime (`CreateEdge` fails closed,
value-free, on an undeclared/plaintext argument). Edge property values are
**full-param encode-gated** (Rule 4) before the DB touch — a raw non-UTF8 binary
nested in a `:map`/`:list` property fails closed value-free, naming only the key.
See `docs/CHARTER.md` for architecture and the open multitenancy decision; `AGENTS.md`
for the full working rules.