# `PhoenixKitCatalogue.Catalogue.AttributeSets`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/v0.18.0/lib/phoenix_kit_catalogue/catalogue/attribute_sets.ex#L1)

Attribute SETS — the 2026-08-18 rework of the group/attribute system.

A set is one dimension from one vendor ("Ikea colors"), stored as a
MANAGED entities blueprint (created only through this module, hidden
from the generic entities admin); its data records are the values.
Items attach any number of sets through the catalogue-owned
`phoenix_kit_cat_item_attribute_sets` join (V177).

## The blueprint contract

    name:      "catalogue_set_<slug>"          (immutable identity)
    settings:  "managed_by"  => "catalogue"
               "locked_keys" => ["kind", "default_value_slug"]
               "catalogue"   => %{"kind" => "fixed" | "multi",
                                  "default_value_slug" => slug | nil}
    records:   slug = the value's stable key, title = display text,
               position = order, data = extras (per-set fields)

Everything else on the blueprint (display name, translations,
`fields_definition` extras like "price per liter") is freely editable.
`contract/1` validates the shape on every resolve; a broken contract
is surfaced (`{:error, :contract_broken}`), never guessed around.

## Enablement

Requires the entities module (`PhoenixKitEntities.enabled?/0`). Every
WRITE returns `{:error, :entities_disabled}` when it is off — same
loud-failure doctrine as the `:catalogue_pdf` queue guard. Reads
degrade quietly instead (`[]`, `nil`, `%{}`, `0`): UI callers render
empty rather than crash during a feature toggle.

Public surface re-exported from `PhoenixKitCatalogue.Catalogue`.

# `add_extra_field`

```elixir
@spec add_extra_field(struct(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}
```

Adds an extra field to the set's blueprint (`:label` required,
`:type` one of `extra_field_types/0`; `select` additionally needs
`:options`, a non-empty list). Every value can then carry data for
it. The key is derived from the label and is stable.

# `attach_set`

```elixir
@spec attach_set(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) ::
  {:ok, PhoenixKitCatalogue.Schemas.ItemAttributeSet.t()} | {:error, term()}
```

Attaches a set to an item (appends; no-op when already attached).

Runs under the per-set advisory lock shared with `delete_set/2` —
without it, an attach racing a delete could commit after the guard's
`set_attached?` check read false, leaving an instant orphan row
(panel finding, 2026-08-18 review).

# `attachment_counts`

```elixir
@spec attachment_counts([Ecto.UUID.t()]) :: %{
  optional(Ecto.UUID.t()) =&gt; non_neg_integer()
}
```

How many items attach each of the given sets: %{set_uuid => count}.

# `auto_migrate_legacy`

```elixir
@spec auto_migrate_legacy() :: :ok
```

Migrates any remaining legacy groups into sets, silently and safely —
there is no legacy UI once sets are live ("it should just migrate",
boss direction 2026-08-18), so this runs from the supervision-tree
startup task and again from the attributes page as a backstop (boot
can race the repo/settings, and entities can be enabled at runtime).

Never raises: any failure is logged and swallowed — a broken
migration must not take down boot or an admin page. Idempotent by
way of `migrate_groups_to_sets/1`.

# `child_spec`

```elixir
@spec child_spec(keyword()) :: Supervisor.child_spec()
```

Registers the catalogue's blueprint delete guard with entities.
Ships as a supervision child via `PhoenixKitCatalogue.children/0`, so
it runs once per boot; deleting a set with item attachments is
refused at the entities write path.

# `contract`

```elixir
@spec contract(struct()) :: {:ok, map()} | {:error, :contract_broken}
```

Validates a set blueprint's catalogue contract. Returns
`{:ok, %{kind: atom, default: slug | nil}}` or
`{:error, :contract_broken}` — never a guessed fallback.

# `create_set`

```elixir
@spec create_set(
  map(),
  keyword()
) :: {:ok, struct()} | {:error, term()}
```

Provisions a new set: a managed blueprint from the locked template.

`attrs`: `:name` (display, required), `:slug` (optional — derived
from the name when absent), `:kind` (`"fixed"`/`"multi"`, default
`"multi"`), `:description`.

# `create_value`

```elixir
@spec create_value(struct(), map(), keyword()) :: {:ok, struct()} | {:error, term()}
```

Adds a value to a set. `attrs`: `:label` (required), `:slug`
(derived from label when absent), `:extras` (map merged into the
record's data — cast against the blueprint's fields the same way
`update_value/4` casts them).

# `default_value_slug`

```elixir
@spec default_value_slug(struct()) :: String.t() | nil
```

The set's default value slug, or nil. See `kind/1`.

# `delete_set`

```elixir
@spec delete_set(struct(), keyword()) :: {:ok, struct()} | {:error, term()}
```

Deletes a set. Refused (`{:error, :set_in_use}`) while any item
attaches it — the same guard entities consults on its own delete path.

# `delete_value`

```elixir
@spec delete_value(struct(), struct(), keyword()) ::
  {:ok, struct()} | {:error, term()}
```

Deletes a value record. When the value is the set's default, the
default is cleared first so the contract never points at a ghost.

# `detach_set`

```elixir
@spec detach_set(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) :: :ok
```

Detaches a set from an item (no-op when not attached).

# `enabled?`

```elixir
@spec enabled?() :: boolean()
```

True when the sets feature is live: the entities module is enabled
AND its package carries the Managed API (entities > 0.4.0) — on an
older package the whole feature degrades to `:entities_disabled`
rather than crashing on missing functions. UI surfaces branch on
this to decide sets-vs-legacy rendering.

# `extra_field_types`

```elixir
@spec extra_field_types() :: [String.t()]
```

Extra-field types the set editor offers (a curated entities subset).

# `get_set`

```elixir
@spec get_set(
  Ecto.UUID.t(),
  keyword()
) :: struct() | nil
```

Fetches one set by blueprint uuid (nil when missing/not a set).

# `get_value`

```elixir
@spec get_value(
  struct(),
  Ecto.UUID.t()
) :: struct() | nil
```

Fetches one value record, scoped to the set (nil when foreign/missing).

# `kind`

```elixir
@spec kind(struct()) :: String.t()
```

The set's kind string (`"fixed"`/`"multi"`, tolerant default
`"multi"`). Public so UI layers read the contract through one
accessor instead of destructuring `settings["catalogue"]` — the
strict validating read stays `contract/1`.

# `list_attachments`

```elixir
@spec list_attachments(Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.ItemAttributeSet.t()
]
```

The item's attachments in order.

# `list_sets`

```elixir
@spec list_sets(keyword()) :: [struct()]
```

Lists the catalogue's sets (managed blueprints), locale-resolved.

# `list_values`

```elixir
@spec list_values(
  struct() | Ecto.UUID.t(),
  keyword()
) :: [struct()]
```

Lists a set's values in display order, locale-resolved.

# `migrate_groups_to_sets`

```elixir
@spec migrate_groups_to_sets(keyword()) :: {:ok, map()} | {:error, term()}
```

Migrates the legacy group→attribute→value data into sets:

  * each `(group, attribute)` pair → one set blueprint, slug
    `catalogue_set_<group>_<attr-key>` (display "<Group> — <Attr>");
  * attribute values → records, slug = the old value key (stable, so
    existing order-line picks keep resolving), old `is_default` → the
    set's `default_value_slug`;
  * every item's single group assignment explodes into one attachment
    per attribute of that group, in attribute order.

Idempotent: an existing blueprint with the target slug is reused (its
values/attachments are topped up, never duplicated), so re-running
after a partial failure is safe. Old tables are left untouched
(read-only by convention; dropped by a later core migration after
cutover). Returns `{:ok, %{sets: n, values: n, attachments: n}}`.

# `prune_orphan_attachments`

```elixir
@spec prune_orphan_attachments(Ecto.UUID.t()) :: non_neg_integer()
```

Removes attachments whose set blueprint no longer exists (called by
`AttributeSets.OrphanPruner` off entities PubSub delete events).

Guarded on enablement: with entities disabled, `get_set/1` returns
nil for EVERY uuid — without the guard a stray call during a feature
toggle would read that as "blueprint deleted" and destroy valid
attachments (panel finding, 2026-08-18 review).

# `remove_extra_field`

```elixir
@spec remove_extra_field(struct(), String.t(), keyword()) ::
  {:ok, struct()} | {:error, term()}
```

Removes an extra field from the blueprint. Existing per-value data
for the key is left in place (harmless, invisible) — same doctrine
as entities' own field removal.

# `reorder_attachments`

```elixir
@spec reorder_attachments(Ecto.UUID.t(), [Ecto.UUID.t()], keyword()) :: :ok
```

Reorders an item's attachments to the given set_uuid order. No-op
(no writes, no activity row) when the order already matches — this
runs on every item save.

# `reorder_values`

```elixir
@spec reorder_values(struct(), [Ecto.UUID.t()], keyword()) :: :ok | {:error, term()}
```

Reorders a set's values to the given record-uuid order.

# `resolve_for_item`

```elixir
@spec resolve_for_item(
  Ecto.UUID.t(),
  keyword()
) :: map()
```

Single-item convenience over `resolve_for_items/2`.

# `resolve_for_items`

```elixir
@spec resolve_for_items(
  [Ecto.UUID.t()],
  keyword()
) :: %{optional(Ecto.UUID.t()) =&gt; map()}
```

Resolves the attached sets for many items in one batched pass:
one attachment query + one value listing per DISTINCT set (values are
shared across items, so a 50-item page with 6 sets is 7 queries).

Returns `%{item_uuid => resolved}` where resolved is the v2 shape:

    %{schema_version: 2,
      sets: [%{uuid, key, name, kind, default,
               values:   [%{key, label, extras}],
               fields:   [%{key, label, type}],
               selected: [slug]}]}

`:fields` mirrors the blueprint's extra-field definitions (what each
value's `extras` keys mean); `:selected` is the per-ATTACHMENT value
selection, already intersected against current values (ghost slugs
degrade out). `:selected` exists ONLY on this batched read —
`resolve_set/2` resolves a bare set with no attachment context and
carries no `:selected` key.

Sets with a broken contract are skipped with a warning — a tampered
blueprint must not take item pages down, but it must not render
guessed data either.

# `resolve_set`

```elixir
@spec resolve_set(
  Ecto.UUID.t(),
  keyword()
) :: map() | nil
```

Resolves ONE set to the v2 per-set shape
(`%{uuid, key, name, kind, default, values, fields}`), or `nil` when
the set is missing or its contract is broken. No attachment context,
so no `:selected` key — that exists only on `resolve_for_items/2`'s
per-item sets. Powers the item form's attach-preview; the batched
item reads go through `resolve_for_items/2`.

# `set_attached?`

```elixir
@spec set_attached?(Ecto.UUID.t()) :: boolean()
```

True when any item attaches the set (drives the delete guard).

# `set_attachment_selection`

```elixir
@spec set_attachment_selection(Ecto.UUID.t(), Ecto.UUID.t(), [String.t()], keyword()) ::
  :ok | {:error, term()}
```

Stores the per-attachment value selection (`selected_value_slugs` in
the join row's reserved `data`) — the boss's two modes: ONE slug says
"this exact object is Red", several say "this object comes in these
options", empty clears the statement. Unknown slugs are dropped
against the set's current values; `{:error, :not_attached}` when the
item doesn't attach the set.

# `update_extra_field`

```elixir
@spec update_extra_field(struct(), String.t(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}
```

Updates an existing extra field: `:label` renames the display text
(the key — referenced by stored per-value data — never changes),
`:options` replaces a select field's option list (non-empty
required). The type is immutable after creation: stored values were
cast for it.

# `update_set`

```elixir
@spec update_set(struct(), map(), keyword()) :: {:ok, struct()} | {:error, term()}
```

Updates a set's unlocked surface: `:name` (display), `:description`,
`:kind`, `:default_value_slug`. Kind/default ride the owner bypass —
they are locked against GENERIC writes, not against this module.

# `update_value`

```elixir
@spec update_value(struct(), struct(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}
```

Updates a value: `:label` rewrites the display text (the slug — the
stable key — never changes), `:extras` merges into the record data.

Extras are cast per field type through the entities pipeline
(`FormBuilder.cast_field/2`): raw form strings coerce ("12.5" →
12.5, "" clears), invalid content returns `{:error, :invalid_value}`
and unknown keys `{:error, :unknown_field}` — never a silent junk
write.

# `valid_selection`

```elixir
@spec valid_selection(term(), map() | nil) :: [String.t()]
```

Filters stored selection slugs against a resolved set's CURRENT
values — THE single implementation of the ghost rule, shared with
every hydration path (the item form stages selections off raw
attachment rows).

The per-attachment selection is the boss's two modes (2026-08-19):
one slug = "this exact object is Red", several = "this object comes
in Red/Blue/Yellow", empty = no statement, the whole set applies.
The count IS the mode — nothing else is tracked. A value deleted
after being ticked must not ghost through reads: unknown slugs drop
out, and a fully-ghosted selection degrades to `[]` ("whole set
applies"), never to a vanished or mode-flipped set.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
