# arcadic usage rules

_A framework-agnostic Elixir client for ArcadeDB over the HTTP Cypher command API._

## What arcadic is (and is not)

- **Is:** a thin transport. Sends Cypher/SQL to ArcadeDB's HTTP command API,
  manages connections and session transactions, normalizes responses.
- **Is not:** Ash-aware, tenant-aware, or classification-aware. Never put
  multitenancy or sensitive-data logic here — that is `ash_arcadic`'s job.

## Public surface

- **`Arcadic`** — `connect/3`, `with_database/2`; `query/4` + `query!/4`
  (idempotent read endpoint), `command/4` + `command!/4` (write endpoint —
  accepts an `:auto_commit` boolean opt, forwarded as-is to ArcadeDB's
  `autoCommit` body param, not arcadic-interpreted; `auto_commit: false`
  outside `transaction/3` means ArcadeDB itself does not auto-commit the
  write),
  `command_async/4` (fire-and-forget, returns `:ok` on 202); `explain/4` + `explain!/4`
  (execution plan, **does NOT run** the statement) and `profile/4` + `profile!/4`
  (**EXECUTES** the statement — a write mutates — plan annotated with runtime
  metrics), both returning `{:ok, %{plan: String.t(), plan_tree: map(), rows:
  [map()]}}` (`plan` the portable human string, `plan_tree` the raw
  transport-defined structure, `rows` empty for `explain/4`); `transaction/3` and
  `rollback/2` for session transactions (`transaction/3` accepts an opt-in
  `:retry`, see Reliability below); `query_bookmarked/4` / `command_bookmarked/4`
  (+ `!`) for read-your-writes bookmark threading. Non-bang calls return
  `{:ok, rows}` or `{:error, %Arcadic.Error{} | %Arcadic.TransportError{}}`.
  Default language is `"cypher"`; opt into `sql`/`gremlin`/`graphql`/`mongo`/
  `sqlscript` per call. `query/4`/`command/4`/`query_stream/4` on a statement
  already carrying an `EXPLAIN`/`PROFILE` prefix return
  `{:error, %Arcadic.Error{reason: :use_explain}}` — call `explain/4`/`profile/4`
  instead.
- **`Arcadic.Conn`** — a pure-data connection handle (no process). Its `Inspect`
  redacts auth and session id. `with_database/2` derives a same-pool handle on
  another database (clears the session); `with_bearer/2` derives a
  Bearer-authenticated handle from a Basic one (typically fed
  `Arcadic.Security.login/1`'s token) — **HTTP-only**, raises `ArgumentError`
  on a Bolt conn (Bolt authenticates from `transport_options`, never
  `conn.auth`). `with_consistency/2` derives a handle at a given read-consistency
  level, and `connect(hosts: [...])` adds multi-host failover targets, both
  **HTTP-only**; see Reliability below.
- **`Arcadic.Server`** — server/database admin, HTTP-only, not delegated from
  the `Arcadic` facade: `create_database/2` (+ `!`), `drop_database/2` (+ `!`),
  `database_exists?/2`, `list_databases/1`, `ready?/1`, `open_database/2`,
  `close_database/2`, `align_database/2` (**cluster-only** — a single-server
  node returns `{:error, %Arcadic.Error{reason: :server_error}}`),
  `check_database/2` (`fix: true` → `CHECK DATABASE FIX`, returns the
  integrity map), `info/2` (`mode: :basic | :default | :cluster`),
  `metrics/1`, `health?/1`, `events/1`, `set_server_setting/3` /
  `set_database_setting/3` (key + value both validated value-free — see
  Errors below), and `profiler/2` (`action` ∈ `:results | :start | :stop |
  :reset`). `shutdown/1` halts the server; a **successful** shutdown typically
  surfaces as `{:error, %Arcadic.TransportError{reason: :closed}}` (the server
  stops responding mid-request) rather than `:ok` — treat that as success, not
  a retryable fault.
- **`Arcadic.Security`** — session/identity admin, HTTP-only: `login/1` mints
  a session token (`POST /api/v1/login`) — feed it to `Arcadic.Conn.with_bearer/2`
  for subsequent Bearer-authenticated calls; `logout/1` revokes the current
  session; `sessions/1`, `users/1`, `groups/1`, `api_tokens/1` list the
  corresponding admin resources; `create_user/2` takes `%{name:, password:,
  databases: %{db => [roles]}}` (`databases` optional) — the password is
  JSON-encoded into the server command and **never echoed** in an error, log,
  or telemetry line (an unencodable spec is rejected value-free as
  `{:error, :invalid_user_spec}`); `drop_user/2` removes a user by name.
- **`Arcadic.Backup`** — backup/restore, HTTP-only: `backup/2` (`BACKUP
  DATABASE` on `conn.database`, optional `:to` target URL), `list/1` (backups
  for `conn.database`), `restore/3` (`restore database <name> <url>`). A
  `:to` target and a `restore/3` URL are both `Arcadic.Identifier.validate_url/1`-
  validated before interpolation (neither command can bind a URL param) — a
  bad one returns `{:error, :invalid_url}`. **SSRF note:** whether the server
  blocks a private/loopback restore source is server-config-dependent —
  `restore/3`'s URL is trusted operator input, never pass it caller-supplied
  values.
- **Migrations** — `Arcadic.Migration` (behaviour: `version/0`, `up/1`, `down/1`),
  `Arcadic.MigrationRegistry` (`use` + `migrations [...]`), `Arcadic.Migrator`
  (`migrate/2`, `status/2`, `rollback/3`, `reset/2`, `pending_migrations/2`),
  tracking applied versions in `_arcadic_migrations`.
- **`Arcadic.Vector`** — dense + sparse vector search over ArcadeDB `LSM_VECTOR` /
  `LSM_SPARSE_VECTOR`: `create_dense_index/5`, `drop_dense_index/3`, `neighbors/6`,
  `fuse/3`, `index_ref/2`, plus `create_sparse_index/5`, `sparse_index_ref/3`,
  `drop_sparse_index/4`, `sparse_neighbors/8` (all + `!`). Tenant-blind; query vector / tokens / weights / `k` /
  `ef_search` / `max_distance` bind as params, index refs are identifier-validated, and
  metadata / query / fusion option inputs are allowlisted and validated value-free.
  Shared opts on `neighbors` / `sparse_neighbors` / `fuse`: `filter` (non-empty
  `#bucket:pos` RID candidate set), `group_by` (`Identifier`-shape-guarded), `group_size`
  — all param-bound. `distance` scale is similarity-dependent; `fuse/3` and
  `sparse_neighbors/8` rank by `score` (sparse rows carry no `distance`). Create sparse
  indexes **before** loading rows — they do not retro-index existing data (a
  `[:arcadic, :vector, :sparse_index_preexisting]` telemetry event fires if you do).
- **`Arcadic.Schema`** — read-only schema introspection: `types/1`, `properties/2`,
  `indexes/2` (with a `:type` filter), `buckets/1`, `database/1` (the engine config,
  `schema:database`), `stats/1` (`schema:stats` per-database operation counters, a single
  map), `dictionary/1` (`schema:dictionary`, a single map), and `materialized_views/1`
  (`schema:materializedviews`, a list) (all + `!`). SQL-only `SELECT FROM
  schema:*`; a caller type name binds as a SQL `:name` param (never `$name` — see
  Parameter binding below) and is `Identifier`-shape-guarded;
  ArcadeDB's `@props` serializer noise is deep-stripped at every depth. `indexes/2` returns
  both logical and physical per-bucket rows (filter on `fileId` absence for logical-only).
- **`Arcadic.Import`** — `database/3` (+ `!`): `IMPORT DATABASE` bulk load. The source URL is
  interpolated (ArcadeDB rejects a bound `:url`) behind a positive character + scheme
  (`http`/`https`/`file`) allowlist that closes the SQL-literal injection surface, value-free on
  rejection; `with:` takes number, boolean, and charset-allowlisted string settings (injection-inert),
  emitted as ArcadeDB's no-parens `WITH k = v` grammar. A private/loopback host trips ArcadeDB's SSRF
  guard (`:unauthorized` / `java.lang.SecurityException`, distinct from an auth failure via
  `error.exception`); `file://` is server-local.
- **`Arcadic.Export`** — `database/3` (+ `!`): `EXPORT DATABASE file://<name>` server-side, symmetric
  to `Arcadic.Import`. The bare export name is guarded by a positive allowlist (no path / traversal /
  quote, value-free); `with:` settings reuse the import grammar (e.g. `format: "jsonl"`,
  `overwrite: true`).
- **Streaming** — `Arcadic.query_stream(conn, sql, params, language: "sql", chunk_size: 500)`
  lazily streams a large read as raw row maps over the default HTTP transport. A streamable
  statement must NOT carry its own `ORDER BY`/`SKIP`/`LIMIT`, or a comment (`--`/`/*` for SQL,
  `//` for Cypher, which would neutralize arcadic's appended suffix) — each rejected value-free
  (`reason: :not_supported`), as is a param named `__arcadic_skip`/`__arcadic_limit` (reserved).
  `chunk_size` must be a positive integer. A WHERE-less **SQL** statement pages by an O(n)
  arcadic-owned `@rid` keyset cursor (`WHERE @rid > <cursor> ORDER BY @rid LIMIT`); a statement
  with its own `WHERE` falls back to `ORDER BY @rid SKIP/LIMIT` offset (O(n²) — arcadic cannot
  inject a keyset predicate without parsing). **Cypher** streams via a caller-supplied
  `order_key: "id(v)"` (restricted to `id(<identifier>)`, the only total, unique order), offset-paged
  with Cypher `$name` placeholders:
  `Arcadic.query_stream(conn, "MATCH (v:Person) RETURN v", %{}, language: "cypher", order_key: "id(v)")`.
  Either way, paging is a stable order, not a snapshot: each page is an independent stateless
  request, so a concurrent delete can skip a row — use a Bolt in-tx cursor for snapshot
  consistency. HTTP streaming refuses inside a transaction (`session_id` set) — in-tx streaming is
  Bolt-only, over the transaction's own connection (so it sees the transaction's own uncommitted
  writes), guarded so a `command`/`query` on that same conn cannot interleave an open cursor on the
  shared socket. **Consume an in-tx stream INSIDE the `transaction/3` body** — it is bound to the
  transaction's connection and cannot be enumerated after the transaction returns. ArcadeDB aborts
  a server-side scan cursor idle for ~10 minutes (`parallelScanAbandonedTimeout`) — a Bolt
  `query_stream/4` consumer that pauses between `PULL`s longer than that can have its cursor
  abandoned mid-stream, so keep pulling.
- **Bolt TLS** — `Arcadic.Transport.Bolt.setup(scheme: "bolt+s", ssl_opts: [...])` runs Bolt over
  TLS. `bolt+s` is **secure by default**: it verifies the server certificate against the OS trust
  store (`verify_peer`) unless the caller passes `ssl_opts: [verify: :verify_none]` — an explicit
  opt-in that accepts any certificate (documents the MITM exposure; only use it against a trusted
  network path, e.g. local dev). Omitting `:scheme` stays on the plaintext `bolt` scheme.
  **Operator note (upstream, fixed 2026-07-08 → ships in 26.7.2):** on ArcadeDB builds predating
  the fix, the Bolt-TLS listener ran every TLS handshake on its single shared accept thread — one
  early-closed connection pinned it in a tight loop (~100% CPU), and a stalled or untrusted-cert
  handshake blocked every other client (no ServerHello) until restart — an ArcadeDB **server**
  defect, not arcadic's (client-side TLS is unaffected). Fixed upstream (per-connection handshake
  threads + read timeout). If your server build predates the fix, treat the hazard as present —
  it is condition-dependent (early-close/stall trigger it; a clean `unknown_ca` alert exchange
  does not), so a clean probe proves nothing — and upgrade. Tracked at
  [ArcadeData/arcadedb#5106](https://github.com/ArcadeData/arcadedb/issues/5106).
- **`Arcadic.Transport`** — the transport behaviour seam; `Arcadic.Transport.HTTP`
  (Req/Finch) is the default, `Arcadic.Transport.Bolt` is the optional Bolt one.
- **`Arcadic.Error` / `Arcadic.TransportError`** — the typed error taxonomy.
- **`Arcadic.Telemetry`** — value-free `:telemetry.span/3` spans.
- **`Arcadic.Identifier`** — allowlist identifier validation.
- **`Arcadic.Param`** — `int8/1` / `bytes/1` typed param-value wrappers
  (`%{"$int8" => [...]}` / `%{"$bytes" => base64}`), decoded server-side to a
  Java `byte[]` before the query runs. HTTP-only, requires ArcadeDB ≥ 26.5.1.
- **`Arcadic.FullText`** — `FULL_TEXT` (Lucene) index DDL (`create_index/4` +
  `drop_index/3`) and `SEARCH_INDEX`/`SEARCH_FIELDS` query builders
  (`search/5`, `search_fields/5`), parallel to `Arcadic.Vector`. HTTP-only SQL;
  a `FULL_TEXT` index retro-indexes rows that already exist. `:with_score` (BM25 `$score`)
  applies to `search/5` (`SEARCH_INDEX`) only — `SEARCH_FIELDS` has no relevance score, so
  `search_fields/5` with `:with_score` projects a constant `0.0`.
- **`Arcadic.Bulk`** — `ingest/3` (+ `!`): bulk-creates vertices and edges over
  ArcadeDB's `POST /api/v1/batch/<db>` NDJSON endpoint, the heavy-ingest
  sibling of `Arcadic.Import.database`. Create-only, atomic by default,
  HTTP-only.
- **`Arcadic.Vector.fuse/3`** now accepts heterogeneous neighbor specs — a bare
  `{type, property, query_vector, k}` dense arm, a `{:sparse, type,
  tokens_property, weights_property, tokens, weights, k}` arm, and/or a
  `{:fulltext, type, property, query, k}` arm — fused in one hybrid-ranked
  result set (see `Arcadic.Vector` above and Bulk loading below).
- **`Arcadic.Geo`** — `GEOSPATIAL` index DDL: `create_index/4` (`type`, `property`,
  idempotent `IF NOT EXISTS` unless `if_not_exists: false`) and `drop_index/3`
  (`IF EXISTS`), both + `!`. The index sits on a **string property holding WKT**
  (ArcadeDB has no native `POINT` schema type) — see Geospatial indexing & functions
  below for the querying side.
- **`Arcadic.Function`** — `DEFINE FUNCTION` / `DELETE FUNCTION` DDL: `define/4`
  (`name` a dotted `library.fn`, validated per segment; `body`; a single trailing
  `opts` keyword list — `:params` an atom/string list, `:language` `:js` default |
  `:sql` | `:cypher`) and `delete/2`, both + `!`. There is no call wrapper (a query template, a charter
  non-goal) — invoke a defined function inside an ordinary `query/4`/`command/4`
  via the backtick idiom: `` SELECT `lib.fn`(:a, :b) `` (SQL) — the name is
  interpolated behind the per-segment allowlist, arguments ride `params`. **Body is
  single-line and single-quoted — a substrate limit**, not an arcadic narrowing:
  ArcadeDB's `"..."` body literal has no escape (a literal `"`, a backslash, or a
  newline all parse-error server-side), so a body needing any of those is rejected
  value-free before any wire call.
- **`Arcadic.Trigger`** — `CREATE TRIGGER` / `DROP TRIGGER` DDL: `create/4`
  (`name`, `type`, `opts` all required: `:timing` `:before`/`:after`, `:event`
  `:create`/`:delete`/`:update`/`:read`, `:execute` a `{lang, code}` tuple with
  `lang` `:sql`/`:javascript`/`:java`) and `drop/2` (**no `IF EXISTS`** — dropping a
  missing trigger is a server error), both + `!`. Shares `Arcadic.Function`'s
  **single-line/single-quoted body** substrate limit — same reject-not-escape guard.
- **`Arcadic.MaterializedView`** — `CREATE MATERIALIZED VIEW` / `DROP MATERIALIZED
  VIEW` DDL: `create/3` (`name`, a raw `select_sql` string emitted verbatim — unlike
  `Function`/`Trigger`, this is trailing SQL, not a quoted DDL literal, so an
  internal single-quoted string in a `WHERE` clause is legitimate and passes
  through) and `drop/2` (**no `IF EXISTS`**), both + `!`.
- **`Arcadic.Changes`** — a caller-supervised `GenServer` client for ArcadeDB's
  live `/ws` change-events feed: `start_link/1` (`:conn`, `:name`, `:max_buffer`,
  default 1000) and `subscribe/3` / `unsubscribe/2` (`:type` filter, `:change_types`
  subset of `[:create, :update, :delete]`, `:subscriber` pid — one subscriber per
  process, a conflicting second subscriber gets `{:error, :subscriber_conflict}`).
  Delivers `{:arcadic_change, %Arcadic.Changes.Event{}}` to the subscriber pid. See
  Change events below for the reliability contract.
- **`Arcadic.TimeSeries`** — ArcadeDB time-series client: `TIMESERIES` DDL
  (`create_type/4`, `drop_type/2`, `add_downsampling/3`, `drop_downsampling/2`),
  continuous aggregates (`create_aggregate/3`, `refresh_aggregate/2`,
  `drop_aggregate/2`), Influx line-protocol writes (`write/3`, `write_lines/3`),
  reads (`query/3`, `latest/3`), and the PromQL family (`prom_query/3`,
  `prom_query_range/6`, `prom_labels/2`, `prom_label_values/3`, `prom_series/3`)
  — all + `!`. Requires ArcadeDB ≥ 26.7.2 (an older server 404s every
  `/api/v1/ts` route). DDL and continuous-aggregate statements ride `Arcadic.command/4`
  SQL-only (like `Arcadic.Schema`); the write/query/PromQL wire family rides 4
  optional transport callbacks, HTTP-only. See Time-series below for the full
  operational contract.

## Bulk loading

- For a **large initial load**, prefer ArcadeDB's server-side import over an `INSERT`/`CREATE EDGE`
  loop: `Arcadic.Import.database(conn, "https://host/export.jsonl.tgz")` imports CSV / JSON /
  GraphML / Neo4j / OrientDB / ArcadeDB exports. The source URL is validated (positive character +
  scheme allowlist, value-free) rather than hand-interpolated — do NOT hand-build an
  `IMPORT DATABASE '<url>'` string, which reopens the injection surface. The URL must be reachable
  by the SERVER; ArcadeDB blocks private/loopback hosts by default, so use a public URL or a
  server-local `file://`. Optional `with:` number/boolean/string settings tune the load (e.g.
  `with: [commitEvery: 10_000]`).
- For an **index-deferred incremental** load, order it yourself: create the type, bulk-load the
  rows (a `command/4` loop or one `transaction/3`), then create the index — a `LSM_TREE`/dense
  `LSM_VECTOR` index retro-indexes existing rows, but a `LSM_SPARSE_VECTOR` index must be created
  BEFORE the load (see `Arcadic.Vector`). arcadic ships no generic index-deferral helper because
  the correct ordering is index-type-specific.
- For batched **incremental** writes, wrap them in `transaction/3` (one commit for many
  statements) instead of auto-committing each `command/4`.
- **Choosing a bulk-write path.** Three options, in order of what they optimize for:
  - **`Arcadic.Bulk.ingest/3`** (`POST /api/v1/batch`) — records held client-side, one
    atomic NDJSON POST. Vertices carry a structural `"@id"` temp key that edges
    reference via `"@from"`/`"@to"`; the response's `id_mapping` maps each temp `"@id"`
    to its assigned real RID. **Create-only** (no dedup) — a retry after a lost
    response duplicates every record. Best for a graph you're building in one shot from
    in-memory data.
  - **`Arcadic.Import.database/3`** — server-side fetch of a CSV/JSON/GraphML/
    Neo4j/OrientDB/ArcadeDB export. Best for large or already-serialized loads (the
    server streams it, not the client).
  - **The idempotent `UNWIND $rows` idiom** — for a bulk **upsert** (as opposed to
    create-only), unwind a list-of-maps param through `MERGE`:
    ```elixir
    Arcadic.command(conn, "UNWIND $rows AS r MERGE (n:T {id: r.id}) SET n += r.props", %{"rows" => rows})
    ```
    Safe to replay — `MERGE` matches existing rows instead of duplicating them, unlike
    `Arcadic.Bulk.ingest/3`.

## Reliability: retry, consistency & multi-host

- **Managed retry.** `transaction/3` accepts `retry: true` (defaults:
  `max_attempts: 3, base_backoff_ms: 50, max_backoff_ms: 1000`) or a keyword
  overriding any of those. Off by default (unchanged behavior). On a transient
  server fault (`:concurrent_modification`, `:not_leader`, and a pre-commit
  `:timeout`) it retries with jittered exponential backoff. **The retried
  function MUST be idempotent** - it can run more than once before it succeeds
  or the attempts are exhausted:
  ```elixir
  {:ok, _} =
    Arcadic.transaction(
      conn,
      fn tx -> Arcadic.command!(tx, "MERGE (u:User {id: $id}) SET u.seen = $ts", %{"id" => "u1", "ts" => ts}) end,
      retry: true
    )
  ```
  A `MERGE`-based upsert body is retry-safe; a body with a side effect outside the
  transaction (e.g. a non-idempotent external call) is not.
- **Read consistency & bookmarks.** `Arcadic.connect(..., consistency: level)` or
  `Arcadic.Conn.with_consistency(conn, level)` sets the read-consistency level for
  subsequent reads: `:eventual` (default, sends no extra header),
  `:read_your_writes`, or `:linearizable`. HTTP-only; a non-default level on a
  Bolt conn raises `ArgumentError`. Pair `:read_your_writes` with the bookmarked
  calls (`query_bookmarked/4` / `command_bookmarked/4`, same opts as
  `query/4`/`command/4`) to guarantee a read observes your own prior write:
  ```elixir
  rw = Arcadic.Conn.with_consistency(conn, :read_your_writes)
  {:ok, _rows, conn2} = Arcadic.command_bookmarked(rw, "CREATE (u:User {id: $id})", %{"id" => "u1"})
  {:ok, rows} = Arcadic.query(conn2, "MATCH (u:User {id: $id}) RETURN u", %{"id" => "u1"})
  ```
  `conn2` carries the monotonically-advancing bookmark - thread it forward, don't
  discard it. The `:read_your_writes` level is REQUIRED for the guarantee: on a
  plain `:eventual` conn the bookmark is still captured into `conn2` but never
  SENT (the `X-ArcadeDB-Read-After` header rides only a `:read_your_writes` conn),
  so a lagging replica can still serve a stale read - bookmarking is inert without
  the level. On a single-server deployment `:read_your_writes` is a harmless
  no-op (there is no replica lag to guard against).
- **Multi-host availability failover.** `connect(hosts: [url2, url3, ...])` adds
  failover targets. Reads fail over to the next host on any connection error;
  writes fail over only on a pre-send connect error (never on an ambiguous
  post-send close, so a write is never blindly resent to a second host after it
  may already have landed on the first). A session (`transaction/3`) pins to
  whichever host answers first. This is **availability failover, not load
  balancing**; front a cluster with a load-balancer VIP if you want request
  distribution across hosts. Bookmarked calls (`query_bookmarked/4` /
  `command_bookmarked/4`) target the primary host and do **not** participate in
  failover (the bookmark is host-relative); point `base_url` at a load balancer,
  or use non-bookmarked `query/4`/`command/4` when you need failover.

## Server-side programmability: functions, triggers & materialized views

`Arcadic.Function` (`DEFINE FUNCTION`/`DELETE FUNCTION`) and `Arcadic.Trigger`
(`CREATE TRIGGER`/`DROP TRIGGER`) both embed a caller body as a `"..."` DDL
string literal. **That literal has no escape** — ArcadeDB parse-errors on a
literal double-quote, a backslash, or a newline inside it — so the body must be
a **single line, single-quoted** (i.e. use `'...'` for any string literal
inside the body, never `"..."`). This is a substrate limit, not something
arcadic could lift: ArcadeDB's own end-to-end tests use only single-line,
single-quoted-JS bodies. A body that needs a double quote, a backslash, or a
newline is rejected value-free as `{:error, :unencodable_body}` before any wire
call — restructure it (e.g. drop the newline, single-quote your JS strings)
rather than trying to escape it.

```elixir
:ok = Arcadic.Function.define(conn, "math.sum", "return a + b;", params: [:a, :b])

:ok =
  Arcadic.Trigger.create(conn, "logCreate", "User",
    timing: :after,
    event: :create,
    execute: {:javascript, "print('user created: ' + record.name);"}
  )
```

`Arcadic.MaterializedView.create/3` is different: its `select_sql` is raw
trailing SQL, not a quoted DDL literal, so a `'single-quoted string'` inside a
`WHERE` clause is ordinary SQL and passes through unmodified — injection safety
instead rests on ArcadeDB's single-statement backstop (a `;`-separated second
statement is a parse error).

```elixir
:ok = Arcadic.MaterializedView.create(conn, "activeUsers", "SELECT FROM User WHERE active = true")
```

`Arcadic.Trigger.drop/2` and `Arcadic.MaterializedView.drop/2` take **no `IF
EXISTS`** (probe-confirmed) — dropping a name that doesn't exist is a server
error, unlike `Arcadic.Function.delete/2` (idempotent server-side) or
`Arcadic.Geo`'s index drop.

## Change events (`Arcadic.Changes`)

`Arcadic.Changes` is arcadic's one **caller-supervised** process — start it
under your own supervision tree (`start_link/1`, opts `:conn`/`:name`/
`:max_buffer`), then `subscribe/3` a database. It presents `conn.auth` on the
`/ws` handshake and pushes each change to a single subscriber pid as
`{:arcadic_change, %Arcadic.Changes.Event{}}`.

**Reliability contract: best-effort at-most-once, stated plainly.** ArcadeDB's
`/ws` feed has no replay and no checkpoint. On every reconnect (dropped socket,
re-established) the process delivers a `change_type: :reconnected` marker
*before* re-subscribing — any events that occurred during the gap are gone.
On buffer overflow (`:max_buffer`, default 1000 — a slow subscriber) it drops
the **oldest** buffered events and delivers one `change_type: :overflow`
marker (`database: nil`, since the drop can span the whole subscription).
**Receiving either marker is not optional to handle** — it obligates the
subscriber to reconcile the affected database against current state, because
the feed is a change *hint*, not a durable log. A terminal `401`/`403` on the
(re)handshake (auth expiry, credential rotation, or a forbidden principal) is
delivered as a distinct `{:arcadic_change_error, :unauthorized}` message and then
**stops the process** — it is terminal, not reconnected (the caller must
re-establish with fresh credentials). A server-side rejection of a
subscribe/unsubscribe (an error frame) arrives as a non-terminal
`{:arcadic_change_error, :subscribe_rejected}` (the socket stays open; the
server's error text is never forwarded). A subscribe with a different
`:subscriber` than the one already bound is rejected
`{:error, :subscriber_conflict}`; the bound subscriber's exit stops the process.
`start_link/1` also rejects a malformed `:conn` value-free
(`:invalid_auth` / `:invalid_url_scheme` / `:invalid_max_buffer`) — notably an
unrecognized URL scheme is refused rather than silently downgraded to plaintext.
The buffer bounds arcadic's own memory (a slow subscriber never wedges the
server); it does not bound the subscriber's mailbox — a persistently slow
consumer must reconcile on the markers and shed load itself.

```elixir
{:ok, pid} = Arcadic.Changes.start_link(conn: conn)
:ok = Arcadic.Changes.subscribe(pid, "mydb", change_types: [:create, :update])

receive do
  {:arcadic_change, %Arcadic.Changes.Event{change_type: :reconnected}} ->
    # reconcile — events during the gap are lost, not replayed
    :ok

  {:arcadic_change, %Arcadic.Changes.Event{change_type: :overflow}} ->
    # reconcile — this subscriber fell behind and the oldest events were dropped
    :ok

  {:arcadic_change, %Arcadic.Changes.Event{change_type: :create, record: record}} ->
    handle_create(record)

  {:arcadic_change, %Arcadic.Changes.Event{change_type: :update, record: record}} ->
    handle_update(record)

  {:arcadic_change_error, :unauthorized} ->
    # terminal — the process has stopped; re-establish with fresh credentials
    :stopped
end
```

The WebSocket client rides the **optional** `mint_web_socket` dependency —
`start_link/1` returns `{:error, :mint_web_socket_not_available}` at runtime
if it isn't in your deps (the module itself always compiles).

## Geospatial indexing & functions

`Arcadic.Geo.create_index/4` creates a `GEOSPATIAL` index over a **string
property holding WKT** (`"POINT (x y)"`, `"LINESTRING (...)"`, etc.) — this
ArcadeDB build has no native `POINT` schema type, so geospatial data is stored
as WKT text and indexed as such. Querying rides ordinary `query/4`/`command/4`;
`Arcadic.Geo` has no query builder (a query template is a charter non-goal).

Function names verified against the ArcadeDB engine source
(`engine/.../function/{geo,sql/geo}/`) and, for the constructor/distance set,
live-confirmed callable:

- **Live-confirmed callable** (bare names): `point`, `linestring`, `polygon`,
  `rectangle`, `circle`, `distance`. In Cypher (arcadic's default language),
  `point(x, y)` and `point({longitude:, latitude:})`/`point({x:, y:})` build a
  point map; `distance(p1, p2)` returns great-circle metres for WGS-84 points
  (Haversine) or Euclidean distance for Cartesian ones. `linestring`/`polygon`/
  `circle`/`rectangle` build WKT strings.
- **Source-registered, not independently live-tested this slice** — the
  `geo.*`-namespaced predicate family: `geo.contains`, `geo.crosses`,
  `geo.disjoint`, `geo.dWithin`, `geo.equals`, `geo.intersects`, `geo.overlaps`,
  `geo.touches`, `geo.within`. These have no bare alias (unlike `point`/
  `linestring`/`polygon`/`circle`/`rectangle`/`distance`, which are registered
  under both a `geo.`-namespaced primary name and a bare backward-compatibility
  alias) — verify live before depending on one in production.

```elixir
:ok = Arcadic.Geo.create_index(conn, "Place", "wkt")

Arcadic.command!(conn, "CREATE (p:Place {wkt: $wkt})", %{"wkt" => "POINT (-122.4 37.8)"})

# distance(p1, p2) rides ordinary query/4 — no arcadic query builder
{:ok, [%{"d" => meters}]} =
  Arcadic.query(conn, "RETURN distance(point(-122.4, 37.8), point(-122.0, 37.3)) AS d")
```

## Time-series

The write/read/PromQL wire family rides four optional transport callbacks
(`ts_write/3`, `ts_query/3`, `ts_latest/3`, `ts_prom_get/4`) implemented by
the HTTP transport only — Bolt returns
`{:error, %Arcadic.Error{reason: :not_supported}}` for all four — and a
pre-26.7.2 server's missing `/api/v1/ts` routes land as a plain
`%Arcadic.Error{http_status: 404}` (the function surface and version floor
are on the `Arcadic.TimeSeries` bullet in Public surface above).

**Two distinct precision grammars — do not conflate them.** `create_type/4`'s
`:precision` opt takes DDL tokens (`:second | :millisecond | :microsecond |
:nanosecond`, omit → server default nanosecond); `write/3`/`write_lines/3`'s
`:precision` opt takes wire tokens (`:ns` default | `:us | :ms | :s`) declaring
the unit of the timestamps in the body — each rejects the other's tokens.

**`query/3`'s `:from`/`:to` are always epoch-milliseconds** (integer or
`DateTime`, converted), regardless of the type's declared DDL `:precision` —
the raw wire 400s an ISO-8601 string (through `query/3` a string raises a
client-side `ArgumentError` before any request), and an epoch value in the
wrong unit (e.g. nanoseconds) silently returns an empty result, no error.
`prom_query/3`'s
`:time` and `prom_query_range/6`'s `from`/`to`/`step` are epoch-**seconds**
(PromQL convention) — do not reuse `query/3`'s millisecond values there. A
PromQL instant query's eval-time floor also matters: Prometheus excludes a
sample written at or after the eval instant, so a sample written at epoch-ms
`t0` needs `time: div(t0, 1000) + 1`, not `div(t0, 1000)`, to be visible.

**`latest/3` takes at most one tag.** The server applies only the **first**
`tag=k:v` query parameter and silently ignores the rest (order-dependent —
probed both orders return different rows), so a multi-entry tags map would be
a nondeterministic filter; `latest/3`'s `:tag` opt is a single `{key, value}`
pair, rejected value-free if given more than one. The tag *value* may contain
colons — the server splits the wire `key:value` on the **first** colon and
matches the remainder exactly (probed 2026-07-11); an empty value is still
rejected value-free (it would match nothing deterministically-uselessly).

**Operational contract — write path (every clause live-probed on 26.7.2):**
- **Append-only, non-idempotent.** No dedup, no upsert, no server-assigned id:
  the identical point written twice is TWO rows. A lost response followed by a
  naive retry **duplicates every point in the body** (the same
  non-confirmability class as `Arcadic.Bulk.ingest/3`). Verify with a windowed
  `query/3` count before retrying an unconfirmed write.
- **Mixed-body partial swallow.** When at least one line's type exists, lines
  naming an UNKNOWN type are **silently dropped** (HTTP 204, no error). The
  loud 400 `Unknown timeseries type(s)` fires only when every line's type is
  unknown. `write/3` guarantees syntactic validity by construction but cannot
  know server type existence (tenant-blind, no schema cache) — a typo'd
  `type:` in a mixed batch is silent; verify with `query/3` or
  `Arcadic.Schema.types/1`.
- **Unknown FIELD zero-fill.** A line whose field name is not on the type
  inserts a zero-filled row (204, no error).
- **int64 bound.** An integer field value or timestamp outside signed int64
  (±9223372036854775807/8) is a 204 + silent line drop server-side (probed
  both signs). As of the S13 closeout, `write/3` raises a value-free
  `ArgumentError` client-side instead — including on a `DateTime` whose
  converted timestamp overflows (e.g. year 2263+ at `:ns`); previously the
  out-of-range line was silently dropped server-side.
- **Unknown tag KEY fails open** on `query/3`/`latest/3` — the filter is
  ignored server-side rather than rejected.
- `write_lines/3` (raw passthrough) additionally inherits the malformed-line
  silent-skip: a syntactically bad line is dropped, not rejected.

**Known upstream defect (26.7.2) — `fields` projection.** `query/3`'s
`:fields` projection returns a `columns` list carrying the right names but row
*values* misaligned under a wrong-width header (pending an upstream fix).
Until fixed, treat a `:fields`-projected
query's row values as unverified — omit `:fields` (the
default, full-width columns) when the values matter.

## Non-negotiable rules

- **Parameters only.** Every dynamic value goes into the request `params` map and
  is referenced by a placeholder in the statement — **`$name` for Cypher, `:name`
  for SQL** (see Parameter binding below; never interpolate a value into a
  Cypher/SQL string — that is a query-injection defect). This holds for
  `query/4`, `command/4`, `command_async/4`, `query_stream/4`, `explain/4`,
  `profile/4`, `query_bookmarked/4`, `command_bookmarked/4`, and inside
  `transaction/3`.
- **Redact at the boundary.** Errors and logs carry structure only.
  `Arcadic.Error` exposes a typed `reason`, `http_status`, and `exception` class;
  its `detail` field is quarantined (absent from `message/1` and `inspect/1`).
  `Arcadic.TransportError` carries only the value-free reason atom. Never surface
  raw parameter values or response rows.
- **Validate identifiers.** Database names and other identifiers reaching a URL
  path or statement go through `Arcadic.Identifier.validate/1` first (a failure
  carries the invalid-shape fact only, never the offending string). Values are
  never identifiers — they ride `params`.

## Parameter binding

**SQL binds `:name`; Cypher binds `$name`.** A `$name` placeholder in a
`language: "sql"` statement binds to **null** (ArcadeDB does not error — a silent
mis-bind); a `:name` placeholder in Cypher (or any default-language call) is a
**parse error**.

```elixir
# SQL
Arcadic.query(conn, "SELECT FROM User WHERE name = :name", %{"name" => n}, language: "sql")
# Cypher (default language)
Arcadic.query(conn, "MATCH (u:User {name: $name}) RETURN u", %{"name" => n})
```

**Typed param-value wrappers (`Arcadic.Param`).** A param *value* that is a single-key
`%{"$int8" => list}` or `%{"$bytes" => base64}` map is decoded server-side to a `byte[]`
before the query runs — `Arcadic.Param.int8/1` / `bytes/1` build these. The statement
still references the parameter by the normal placeholder (`:name`/`$name`). **HTTP-only**
(inert over Bolt) and requires ArcadeDB ≥ 26.5.1. **Ambient single-key-collision caveat:**
ArcadeDB decodes *any* single-key `{"$int8" => …}` / `{"$bytes" => …}` value it finds in
`params`, whether or not it came from `Arcadic.Param` — a legitimate caller value that
happens to be exactly a single-key map with one of those keys is reinterpreted as a
`byte[]`; add a second key to a map you want left untouched.

## Options reference

Which options each function accepts (an unknown key is rejected value-free):

| opt | `query/4` | `command/4` / `command_async/4` | `query_stream/4` | `explain/4` / `profile/4` |
|---|---|---|---|---|
| `:language` | yes | yes | yes | yes |
| `:limit` | yes | yes | no | no |
| `:serializer` | yes | yes | no | no |
| `:retries` | no | yes | no | no |
| `:auto_commit` | no | yes | no | no |
| `:timeout` | yes | yes | yes | yes |
| `:chunk_size` | no | no | yes | no |
| `:order_key` | no | no | yes (Cypher only) | no |

## Errors

`Arcadic.Error.reason`: `:not_idempotent` (write via `query/4`), `:parse_error`,
`:unauthorized` (auth failure, or a blocked private/loopback import URL),
`:database_not_found`, `:transaction_error` (server fault, or client-side session
misuse), `:concurrent_modification`, `:duplicate_key`, `:timeout` (server-side
statement timeout — distinct from the client-side `TransportError` below),
`:not_leader` (the target node is not the cluster leader and could not forward
the write; a managed-retry `transaction/3` and multi-host failover both treat
it as retriable, since nothing was applied), `:invalid_begin_body` (bad
`:isolation` on `transaction/3`), `:server_error`
(generic fallback), `:use_explain` (call `explain/4`/`profile/4` instead), and
`:not_supported` (the transport lacks the called capability, e.g. `explain/4`
without a transport impl, HTTP streaming in a transaction, Bolt database admin —
or the statement/opts fail a streaming-eligibility check).

`Arcadic.TransportError.reason` is a connection-level failure with **no HTTP
response** — the underlying transport's own atom, not a fixed enum: for HTTP,
whatever Mint/Finch reports (e.g. `:timeout`, `:closed`, `:econnrefused`); for
Bolt, `:timeout` (a RUN/PULL receive timeout), `:bolt_protocol_error`,
`:transaction_error`, `:cursor_open`/`:cursor_already_open` (the stream
interleaving guard), a `boltx` error code, or `:unknown`.

A separate, non-`Arcadic.Error` convention: value-free bare-atom validation
failures, never echoing the offending value. `{:error, :invalid_identifier}`
(`Arcadic.Identifier.validate/1` — e.g. a bad type name to
`Arcadic.Schema.properties/2`, or a bad database/user name on the admin
surface); `{:error, :invalid_setting_key}` / `{:error, :invalid_setting_value}`
(`Arcadic.Server.set_server_setting/3` / `set_database_setting/3`);
`{:error, :invalid_url}` (`Arcadic.Backup.backup/2`'s `:to` target and
`restore/3`'s source URL); `{:error, :invalid_user_spec}`
(`Arcadic.Security.create_user/2` — an unencodable user spec, e.g. a non-UTF-8
password); and, from `Arcadic.Bulk.ingest/3`, `{:error, :invalid_record}` (a
record that fails to encode), `{:error, :not_supported}` (the transport has no
batch endpoint, e.g. Bolt), and `{:error, :unexpected_response}` (a non-map 2xx
body — off-contract). `Arcadic.Function.define/4` / `Arcadic.Trigger.create/4`
return `{:error, :unencodable_body}` for a body ArcadeDB's `"..."` DDL literal
cannot hold (a literal `"`, a backslash, or a newline). `Arcadic.Changes`
returns `{:error, :mint_web_socket_not_available}` from `start_link/1` (the
optional `mint_web_socket` dependency is absent) and `{:error,
:subscriber_conflict}` from `subscribe/3` (a second subscriber pid on an
already-bound process).

## Telemetry

Value-free `:telemetry.span/3` spans; metadata is validated against the fixed
allowlist in `Arcadic.Telemetry.allowed_meta_keys/0`: `:language`, `:mode`,
`:http_status`, `:reason`, `:row_count`, `:in_transaction?`, `:isolation`,
`:async?`, `:operation`. No statement, params, values, or database name ever
rides telemetry.

- `[:arcadic, :query, :start | :stop | :exception]` — `query/4`.
- `[:arcadic, :command, :start | :stop | :exception]` — `command/4` and
  `command_async/4` (the latter's metadata carries `:async? true`).
- `[:arcadic, :explain, :start | :stop | :exception]` — `explain/4` (`:mode`
  `:read`) and `profile/4` (`:mode` `:write`, carries `:in_transaction?`, since
  PROFILE executes).
- `[:arcadic, :query_stream, :start]` / `[:arcadic, :query_stream, :stop]` — every
  HTTP and Bolt stream path (manual `:telemetry.execute/3` events, not a span — no
  `:exception` variant); `:stop` carries `reason: :ok | :halted` plus a
  `:row_count` measurement.
- `[:arcadic, :transaction, :start | :stop | :exception]` — `transaction/3`
  (metadata carries `:isolation`).
- `[:arcadic, :transaction, :retry]` - one per managed-retry attempt on
  `transaction/3` (`retry:` opt); `:attempt` measurement, `:reason` metadata
  (the retriable error reason that triggered the attempt).
- `[:arcadic, :vector, :sparse_index_preexisting]` — see `Arcadic.Vector` above.
- `[:arcadic, :admin, :start | :stop | :exception]` — every `Arcadic.Server` /
  `Arcadic.Security` / `Arcadic.Backup` call (metadata carries `:operation`,
  the atom naming the call, e.g. `:login`, `:set_database_setting`,
  `:restore`, plus `:reason` on `:stop`).
- `[:arcadic, :bulk, :start | :stop | :exception]` — `Arcadic.Bulk.ingest/3`
  (`:stop` carries `:row_count`, the sum of vertices + edges created).

`:start` measurements are `:telemetry.span/3`'s standard `:system_time`/
`:monotonic_time`; `:stop`/`:exception` carry `:duration`/`:monotonic_time`.

## Bolt transport (optional)

The `Arcadic.Transport.Bolt` adapter (optional `boltx` dependency) runs the query
hot path over Bolt. Build it with `Arcadic.Transport.Bolt.setup/1`, which pins Bolt
to **v4** (`versions: [4.4, 4.3, 4.2, 4.1]` — ArcadeDB speaks v4; boltx defaults to
v5), uses the non-TLS **`bolt` scheme** (ArcadeDB Bolt is TLS-disabled by default),
and takes `username`/`password`. `setup/1` starts the pool AND returns the
`transport_options` for `Arcadic.connect/3` in one call — `[bolt: pool, bolt_opts:
resolved]` — carrying both the pool (`:bolt`, for `execute`/`transaction`/`ready?`)
and the resolved per-stream connect opts (`:bolt_opts`, for `query_stream/4`); pass
its return value straight through as `transport_options`. Do NOT hand-build
`transport_options: [bolt: pool]` alone (`start_link/1`'s bare return) — it omits
`:bolt_opts` and makes `query_stream/4` return `{:error, %Arcadic.Error{reason:
:not_supported}}`.
**Admin (`Arcadic.Server`, `Arcadic.Security`, `Arcadic.Backup`) is HTTP-only** —
use an HTTP conn for admin even when queries run over Bolt (`with_bearer/2` also
raises on a Bolt conn). **Vector search is HTTP-only too** —
`Arcadic.Vector` (`LSM_VECTOR` / `LSM_SPARSE_VECTOR`) runs SQL, and Bolt is
Cypher-only (a `SELECT` over Bolt is a syntax error; the Bolt `RUN` carries no
SQL-language selector), so keep vector queries on the HTTP transport.

**`BOLT_*` env vars are rejected.** arcadic **raises** if `BOLT_USER`, `BOLT_PWD`,
`BOLT_HOST`, or `BOLT_TCP_PORT` is set in the environment — at pool setup
(`start_link/1`/`setup/1`) **and** on every connect/reconnect. boltx reads those with
precedence over arcadic's explicit config and re-reads them at connect time, so a var
set after startup would otherwise silently override the connection or its credentials;
the connect-time reject closes that window. Unset the var and pass
`:scheme`/`:hostname`/`:port`/`:username`/`:password` explicitly.

See `AGENTS.md` for the full working rules and the verified ArcadeDB HTTP contract.
