# Guarded Struct Ash

> Ash integration

- Skill: `mishka-group/guarded-struct-ash` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mishka-group/guarded-struct-ash`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mishka-group/guarded-struct-ash/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: mishka-group (https://skillmd.com/u/mishka-group)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mishka-group/guarded-struct-ash

---


# Ash integration

Reference: `usage-rules/ash.md`.

## Resource template

```elixir
defmodule MyApp.User do
  use Ash.Resource,
    domain: MyApp.Domain,
    extensions: [GuardedStruct.AshResource]

  guardedstruct do
    auto_wire true   # injects GuardedStruct.AshResource.Change automatically
    field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
  end

  attributes do
    uuid_primary_key :id
    attribute :email, :string, allow_nil?: false, public?: true
  end

  actions do
    defaults [:read, :destroy, :create]
    update :update, accept: [:email]
  end
end
```

`auto_wire: false` (default) requires a manual `changes do change GuardedStruct.AshResource.Change end` block.

## Atomic mode — three behaviours

`Change.atomic/3` inspects every key in `changeset.attributes` and
`changeset.atomics`. For each owned field (in `__guarded_field_name_set__/0`):

| Input | Result |
|---|---|
| Plain literal in `attributes` or `atomics` | Sanitize via the pipeline, return `{:atomic, sanitized_map}`. UPDATE stays atomic. |
| `Ash.Expr` in `atomics` (e.g. `expr(counter + 1)`) | Return `{:not_atomic, reason}`. Ash falls back to imperative `change/3` if the action has `require_atomic? false`; otherwise Ash raises `Ash.Error.Framework.MustBeAtomic`. |

Non-owned keys are passed through untouched — `expr(now())` on a plain Ash
attribute (not in `guardedstruct`) stays atomic.

## Three options when an atomic expression is needed

* **Accept the fallback** — declare a companion action with `require_atomic? false`.
  `atomic/3` bails, Ash runs `change/3` imperatively, update still succeeds.
* **Pass a plain value** — compute in Elixir, pass via `attributes`. Stays
  atomic, pipeline validates.
* **Move the field outside `guardedstruct`** — `atomic_update(_, expr(... + 1))`
  works without going through our pipeline.

## Bulk

* `Ash.bulk_create/3` → uses `batch_change/3` (per-row pipeline).
* `Ash.bulk_update/3` with `strategy: :atomic` (default) → uses `atomic/3`.
* `Ash.bulk_update/3` with `strategy: :stream` → uses `change/3`.

All three produce identical sanitized results.

## Auto-map cascade

`__guarded_change__/1` returns **plain maps** at every depth for nested
sub_fields — never structs. This matches Ash's `:map` attribute type so output
drops directly into `changeset.attributes` without conversion. Set via a
process-local flag at the top of `validate/3`; concurrency-safe.

## Don'ts

* Don't add `require_atomic? false` to every update action — most updates are
  atomic-safe by default with this extension. Only add it on actions that need
  to accept `Ash.Expr` values for owned fields.
* Don't write `defstruct` or `builder/1` in the resource module — Ash provides
  both. The extension only adds `__guarded_change__/1` and metadata.
* Don't rely on `function_exported?(MyResource, :__guarded_field_name_set__, 0)`
  — it's always defined on resources using this extension.

## Companion modules

* `GuardedStruct.AshResource.Info` — Ash-flavoured introspection
  (`fields/1`, `field/2`, `validate/2`, etc.).
* `GuardedStruct.AshResource.Change` — the bridge change module.

