Per-project feature flags — Level 2 of the hub's granularity model
(Level 1 is extension enablement in PhoenixKitProjects.Extensions).
Every flag belongs to an extension: the built-in Tasks extension declares
the pre-hub feature set (assignees, estimates, statuses, …) and providers
declare their own via feature_flags on their descriptor — one channel,
no special-cased built-ins.
Resolution (frozen semantics — panel amendment #6)
on?(project, key) is true iff ALL of:
- the flag exists in the catalog (unknown keys are fail-closed false);
- the flag's OWNING extension is effectively enabled for the project
(
Extensions.enabled?/2); - every flag in
requiresresolves on (recursive, cycle-guarded) — the GitLab-style dependency rule:view_timelinerequiresscheduling,schedulingrequiresestimates; - the project's explicit value —
settings["features"][key]— when present; otherwise the flag's declareddefault.
Absence semantics are frozen: a missing settings key ALWAYS means "inherit the flag's catalog default", never a latent choice. Presets and the Modules panel write explicit booleans; pre-hub projects have no keys and every pre-hub flag defaults true — behavior-preserving with no backfill migration.
Presets
Named flag bundles applied at creation (or any time before a team relies
on the current shape): "simple" (a todo list — everything off but the
list), "standard" (the pre-hub defaults), "full" (everything on).
apply_preset/3 writes the bundle as EXPLICIT values. The site default
for new projects is the projects_default_preset setting ("standard").
Summary
Functions
Applies a preset's flag bundle to a project (explicit writes via
set_flags/3). Unknown preset keys are a no-op {:ok, project} — a
stale site-default setting must not break project creation.
The full flag catalog: %{flag_key => %{key, label, default, requires, ext_key}} — every extension's declared flags, stamped with their owner.
First declaration wins on key collisions (logged).
Flags grouped per extension, for the Modules panel: [{%Extension{}, [flag]}].
The promotable creation-page blocks (key + label), for the settings UI.
The block keys the site promoted to top level (default: none). A plain list, not a MapSet — dialyzer's opaqueness false positive on literal MapSet construction (the resolve/3 precedent in this module).
Gate map for surfaces with NO project yet (the :new forms): the catalog defaults — i.e. what a fresh project would resolve. All pre-hub flags default true, so this is all-true today and stays correct if a default ever changes.
The site-default preset key for new projects (projects_default_preset).
The resolved gate map LiveViews assign as @fx: the tasks extension
plus every built-in task flag, resolved for one project in one call.
Rebuild it on :project_features_changed / :project_modules_changed.
Look up a preset by key.
Whether a flag is on for a project. See the moduledoc for the frozen
resolution order. Accepts a %Project{} (preferred — no extra reads) or
a project uuid (loads the settings map).
Named flag bundles. flags are written as EXPLICIT values by
apply_preset/3; keys absent from a preset keep resolving by default.
Persist the promoted block set (unknown keys dropped).
Writes explicit flag values (%{"flag_key" => boolean}) into the
project's settings["features"], whitelisted against the catalog —
unknown keys and non-boolean values are dropped, known keys merge over
the stored map. Logs one projects.feature_toggled activity row with the
changed keys; broadcasts :project_features_changed.
Functions
@spec apply_preset(PhoenixKitProjects.Schemas.Project.t(), String.t(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, term()}
Applies a preset's flag bundle to a project (explicit writes via
set_flags/3). Unknown preset keys are a no-op {:ok, project} — a
stale site-default setting must not break project creation.
The full flag catalog: %{flag_key => %{key, label, default, requires, ext_key}} — every extension's declared flags, stamped with their owner.
First declaration wins on key collisions (logged).
@spec catalog_by_extension() :: [ {PhoenixKitProjects.Extensions.Extension.t(), [map()]} ]
Flags grouped per extension, for the Modules panel: [{%Extension{}, [flag]}].
@spec creation_blocks() :: [map()]
The promotable creation-page blocks (key + label), for the settings UI.
@spec creation_top_blocks() :: [String.t()]
The block keys the site promoted to top level (default: none). A plain list, not a MapSet — dialyzer's opaqueness false positive on literal MapSet construction (the resolve/3 precedent in this module).
Gate map for surfaces with NO project yet (the :new forms): the catalog defaults — i.e. what a fresh project would resolve. All pre-hub flags default true, so this is all-true today and stays correct if a default ever changes.
@spec default_preset_key() :: String.t()
The site-default preset key for new projects (projects_default_preset).
@spec gates(PhoenixKitProjects.Schemas.Project.t() | map() | binary()) :: %{ required(atom()) => boolean() }
The resolved gate map LiveViews assign as @fx: the tasks extension
plus every built-in task flag, resolved for one project in one call.
Rebuild it on :project_features_changed / :project_modules_changed.
Look up a preset by key.
@spec on?(PhoenixKitProjects.Schemas.Project.t() | map() | binary(), String.t()) :: boolean()
Whether a flag is on for a project. See the moduledoc for the frozen
resolution order. Accepts a %Project{} (preferred — no extra reads) or
a project uuid (loads the settings map).
@spec presets() :: [map()]
Named flag bundles. flags are written as EXPLICIT values by
apply_preset/3; keys absent from a preset keep resolving by default.
Persist the promoted block set (unknown keys dropped).
@spec set_flags(PhoenixKitProjects.Schemas.Project.t(), map(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, term()}
Writes explicit flag values (%{"flag_key" => boolean}) into the
project's settings["features"], whitelisted against the catalog —
unknown keys and non-boolean values are dropped, known keys merge over
the stored map. Logs one projects.feature_toggled activity row with the
changed keys; broadcasts :project_features_changed.