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.
Summary
Functions
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.
Attaches a set to an item (appends; no-op when already attached).
How many items attach each of the given sets: %{set_uuid => count}.
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).
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.
Validates a set blueprint's catalogue contract. Returns
{:ok, %{kind: atom, default: slug | nil}} or
{:error, :contract_broken} — never a guessed fallback.
Provisions a new set: a managed blueprint from the locked template.
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).
The set's default value slug, or nil. See kind/1.
Deletes a set. Refused ({:error, :set_in_use}) while any item
attaches it — the same guard entities consults on its own delete path.
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.
Detaches a set from an item (no-op when not attached).
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 the set editor offers (a curated entities subset).
Fetches one set by blueprint uuid (nil when missing/not a set).
Fetches one value record, scoped to the set (nil when foreign/missing).
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.
The item's attachments in order.
Lists the catalogue's sets (managed blueprints), locale-resolved.
Lists a set's values in display order, locale-resolved.
Migrates the legacy group→attribute→value data into sets
Removes attachments whose set blueprint no longer exists (called by
AttributeSets.OrphanPruner off entities PubSub delete events).
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.
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.
Reorders a set's values to the given record-uuid order.
Single-item convenience over resolve_for_items/2.
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).
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.
True when any item attaches the set (drives the delete guard).
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.
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.
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.
Updates a value: :label rewrites the display text (the slug — the
stable key — never changes), :extras merges into the record data.
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).
Functions
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.
@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).
@spec attachment_counts([Ecto.UUID.t()]) :: %{ optional(Ecto.UUID.t()) => non_neg_integer() }
How many items attach each of the given sets: %{set_uuid => count}.
@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.
@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.
Validates a set blueprint's catalogue contract. Returns
{:ok, %{kind: atom, default: slug | nil}} or
{:error, :contract_broken} — never a guessed fallback.
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.
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).
The set's default value slug, or nil. See kind/1.
Deletes a set. Refused ({:error, :set_in_use}) while any item
attaches it — the same guard entities consults on its own delete path.
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.
@spec detach_set(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) :: :ok
Detaches a set from an item (no-op when not attached).
@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.
@spec extra_field_types() :: [String.t()]
Extra-field types the set editor offers (a curated entities subset).
@spec get_set( Ecto.UUID.t(), keyword() ) :: struct() | nil
Fetches one set by blueprint uuid (nil when missing/not a set).
@spec get_value( struct(), Ecto.UUID.t() ) :: struct() | nil
Fetches one value record, scoped to the set (nil when foreign/missing).
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.
@spec list_attachments(Ecto.UUID.t()) :: [ PhoenixKitCatalogue.Schemas.ItemAttributeSet.t() ]
The item's attachments in order.
Lists the catalogue's sets (managed blueprints), locale-resolved.
@spec list_values( struct() | Ecto.UUID.t(), keyword() ) :: [struct()]
Lists a set's values in display order, locale-resolved.
Migrates the legacy group→attribute→value data into sets:
- each
(group, attribute)pair → one set blueprint, slugcatalogue_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'sdefault_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}}.
@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).
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.
@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.
@spec reorder_values(struct(), [Ecto.UUID.t()], keyword()) :: :ok | {:error, term()}
Reorders a set's values to the given record-uuid order.
@spec resolve_for_item( Ecto.UUID.t(), keyword() ) :: map()
Single-item convenience over resolve_for_items/2.
@spec resolve_for_items( [Ecto.UUID.t()], keyword() ) :: %{optional(Ecto.UUID.t()) => 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.
@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.
@spec set_attached?(Ecto.UUID.t()) :: boolean()
True when any item attaches the set (drives the delete guard).
@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.
@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.
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.
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.
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.