Support for MANAGED entity blueprints — blueprints owned by another module (e.g. the catalogue's attribute sets), created and administered exclusively through that module's own UI on top of the entities API.
A managed blueprint carries in its settings:
"managed_by" => "catalogue" # owning module key
"locked_keys" => ["kind", ...] # settings["<owner>"] keys the
# generic write path must not touchThe two guarantees
- Hidden from the generic admin —
managed?/1lets listings exclude these blueprints (PhoenixKitEntities.list_entities/1acceptsinclude_managed: false); the owning module renders its own management UI. - Write-path protection —
validate_mutation/2andvalidate_delete/1are called by the entities write path (not just the UI): mutations from anywhere but the owning module cannot rename the blueprint's slug, change its status, or touch locked settings keys; deletion requires the owner's confirmation callback to approve (e.g. the catalogue refuses while item attachments exist). UI guards without a write interceptor are theater. One write path is deliberately outside the interception:reorder_entities/2bulk-updatespositionviaupdate_all, andpositionis not part of any owner contract.
Owners bypass the guard by passing on_behalf_of: "<owner>" in opts —
the guard is against accidental generic-admin edits, not a security
boundary (all callers are admin code). It also covers only the
blueprint rows themselves — data records under a managed blueprint go
through the ordinary EntityData write path.
Delete approval
Owners register a delete-approval callback at runtime:
PhoenixKitEntities.Managed.register_delete_guard(
"catalogue",
&MyApp.Catalogue.deletion_guard/1
)where deletion_guard/1 returns :ok or {:error, reason} (e.g.
{:error, :set_in_use} while item attachments exist). The callback
MUST be an external capture (&Mod.fun/1), never an anonymous fun: a
local capture in :persistent_term goes stale when the registering
module is purged (code reload / hot upgrade), raises on call, and
every delete then fails closed with {:error, :delete_guard_error}.
Registration is process-independent (persistent_term), set up in the owning module's application start. A managed blueprint with no registered guard refuses deletion outright — fail closed.
Summary
Functions
True when the entity is managed by another module.
The owning module key, or nil.
Registers (replaces) the owner's delete-approval callback.
Validates CREATING an entity with attrs: a blueprint claiming a
managed_by owner can only be provisioned by that owner
(on_behalf_of matching). Without this, any generic caller could
create a blueprint that masquerades as module-owned — hidden from
the generic admin yet picked up by the owning module's listings
(panel finding, 2026-08-18 review).
Validates deleting entity. Owner-originated calls consult nothing;
generic calls are refused; the owner's registered guard arbitrates
owner-side deletes.
Validates an update to entity with attrs. Returns :ok or
{:error, reason}. Owner-originated calls (on_behalf_of matching
the owner) pass unconditionally.
Functions
True when the entity is managed by another module.
The owning module key, or nil.
Registers (replaces) the owner's delete-approval callback.
Validates CREATING an entity with attrs: a blueprint claiming a
managed_by owner can only be provisioned by that owner
(on_behalf_of matching). Without this, any generic caller could
create a blueprint that masquerades as module-owned — hidden from
the generic admin yet picked up by the owning module's listings
(panel finding, 2026-08-18 review).
Validates deleting entity. Owner-originated calls consult nothing;
generic calls are refused; the owner's registered guard arbitrates
owner-side deletes.
@spec validate_mutation(struct(), map(), keyword()) :: :ok | {:error, :managed_blueprint | :locked_key}
Validates an update to entity with attrs. Returns :ok or
{:error, reason}. Owner-originated calls (on_behalf_of matching
the owner) pass unconditionally.