All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[0.9.0] - 2026-08-25

Added

  • Targeting rules: force a flag's value for a specific key. Pass a context of attributes and let ordered rules override the value when it matches — onboard a beta customer, unblock one account, raise a limit for one tenant, without a deploy.

    # once per request, in a plug or on_mount hook
    PhoenixFlags.put_context(user_id: user.id, company_id: user.company_id)
    
    # unchanged call sites are now targeted
    MyApp.SystemConfig.get("enable_benefits", false)     #=> true for company 123
    MyApp.SystemConfig.variant("checkout_flow", user.id) #=> pinned arm for user 7
    
    # rules, from the dashboard or in code
    MyApp.SystemConfig.put_target("enable_benefits",
      conditions: [[attribute: :company_id, operator: :in, values: [123, 456]]],
      value: "true"
    )

    Requires migration V4PhoenixFlags.Migration.up(version: 4), or mix igniter.upgrade phoenix_flags to generate it. Existing flags are unaffected until a rule is added.

    This is the attribute-targeting half of the AWS AppConfig model deferred when :variant shipped in 0.7.0. Rules are structured rows rather than a string expression DSL, so they can be validated properly and edited in a UI. Conditions live in their own table rather than a jsonb column: jason is declared optional and unused in lib/, and a JSON column would make a JSON library effectively required.

    Details:

    • Applies to any flag type. A rule forces a value, validated against that flag's type through the same changeset a dashboard save uses. A :variant rule names a declared arm rather than a weights string.
    • Precedence: a test stub, then a matching rule, then the stored value or the weighted split. A rule therefore overrides an A/B split, which is the point of pinning a customer to one arm.
    • Operators :in, :not_in, :eq, :starts_with. Conditions within a rule are ANDed; rules are checked in order and the first match wins.
    • Everything compares as strings, so %{company_id: 123} matches "123" and :company_id matches "company_id" — consistent with every flag value being stored as a string.
    • A missing attribute never matches, :not_in included: "everyone except these" must not sweep in callers we know nothing about.
    • :secret flags cannot be targeted — the rule value would sit in the targets table as plaintext, defeating the encryptor.
    • Cost when unused is one :persistent_term read plus a map lookup, and a flag with no rules never reads the context. That ordering came from measurement: checking the context first measured slower than the read it was avoiding. See docs/benchmarks.md.
    • The context is per-process and not inherited by Task.async/1 and friends; documented, with context: PhoenixFlags.context() as the answer.
    • Cluster replication is free: a rule write triggers a full cache reload and notifies peers, which reload the same way they already do.
  • PhoenixFlags.Contextput/1, merge/1, get/0, clear/0, with PhoenixFlags.put_context/1 and friends as shorthands.

  • PhoenixFlags.Target and PhoenixFlags.Target.Condition, with resolve/2 and matches?/2 as pure, fuzz-tested functions.

  • Generated targets/1, put_target/2 and delete_target/1 on every config module; get/2 becomes get/3 with a :context option, and variant/3 accepts :context too.

  • The dashboard's edit dialog lists a flag's rules and can add or delete them. The forced-value input is constrained to what the flag's type allows — a dropdown of declared variants for :variant, true/false for :boolean.

Fixed

  • The :variant weight inputs did not line up, and the digits sat under the spinner. Each row was its own flex container, so the columns only aligned when the labels happened to be the same width — a longer label squeezed its input narrower, since .pf-input sets width: 100% and a flex item may shrink. The editor is now a single three-column grid, so every input shares one column by construction, and the inputs are wider with more right padding so the number clears the native spinner.

  • Elixir 1.16 and 1.18 could not compile PhoenixFlags.Target. The nested Condition module sat at the bottom of the file, below the first %Condition{} pattern match; those versions expand structs eagerly and failed with "Condition.__struct__/1 is undefined", while 1.20 tolerates it. Condition now lives in its own file. Caught by the CI version matrix, which is exactly what it is there for.

  • Save and Cancel sat in the middle of the edit dialog. Adding the targeting section below the value form left the footer stranded between the two, because HTML forbids nesting forms so the footer had to live inside the value one. The footer now sits outside both forms at the bottom of the dialog, with the submit button associated by HTML5's form attribute. The dialog is also a flex column now: the header and footer are pinned and only the body scrolls, so Save stays visible on a tall dialog.

  • A single bad :variant weights string produced two identical changeset errors, which the dashboard rendered twice. Type.validate_value/2 and the declared-name check both parsed the value and both reported the same failure; the name check now skips when the value has already been rejected.

Changed

  • Benchmarks use their own phoenix_flags_bench database, created and migrated on first run. bench/bench_helper.exs pointed at the test database and runs outside the Ecto sandbox, so benchmark writes committed and broke the next mix test. The README documented that as a trap; it is now simply gone.

[0.8.0] - 2026-08-25

Changed

  • The dashboard editor is now a modal dialog instead of an inline form that replaced the row. Opens from the row's Edit button; closes on Save, Cancel, the ×, Escape, or a backdrop click. Keyboard focus moves into the dialog on open, and a validation error keeps it open with the message in place.

    The dialog is rendered once for whichever flag is being edited, derived from the current entries rather than held in its own assign, so a cluster update cannot leave it showing a stale value. :secret flags now go through the same dialog as everything else rather than a bespoke inline form.

    Dead CSS for the old inline editor (.pf-row-editing, .pf-edit-actions, .pf-input-wrap) has been removed.

Fixed

  • Editing priv/static/app.css had no effect. The stylesheet is read into a module attribute at compile time, but nothing declared it as an @external_resource — so Elixir did not know the module depended on it and never recompiled. The old CSS stayed baked into the beam and kept being served. Found while adding the modal styles, which silently did not appear.

  • The edit dialog focused the close button on open. JS.focus_first/0 walks DOM order and reached the header's × before any input, which put keyboard focus in the wrong place and rendered the × with a focus ring. Focus is now scoped to the dialog body.

  • Modal styling: the weight rows had a 120px label gutter that left a dead gap for short labels, and a 92px right-aligned input put the value underneath the native spinner. Labels and inputs now sit at opposite ends of the row with the value padded clear of the spinner, and the footer has enough contrast to read as a footer in dark mode. The close button's focus ring is :focus-visible only, so it no longer shows for pointer users.

  • docs/benchmarks.md was linked from the README but missing from :extras, so the link resolved on GitHub but not on hexdocs, and mix docs warned.

  • The mix run dev.exs dashboard rendered but was completely inert. Clicking Edit or a toggle did nothing, with window.LiveView is undefined in the console. The inline dev bundle wraps phoenix.min.js and phoenix_live_view.min.js in an arrow function, and those are esbuild IIFEs of the form var LiveView = (() => {...})() — so inside that wrapper they are ordinary function-scoped locals, never globals. Reading them as window.LiveView / window.Phoenix therefore yielded undefined and no LiveSocket was ever constructed. Now references the local bindings, verified by evaluating the served bundle under Node with a DOM shim: it constructs a LiveSocket, where the previous bundle threw the exact reported error.

    Dev tooling only — the packaged library is unaffected, and the dashboard's server-side behaviour was always covered by the LiveView tests. But it means manual browser checks of the dashboard were never actually exercising interactivity.

  • dev.exs now asserts at load time that each bundled asset still begins with the expected var Phoenix= / var LiveView= binding, so an upstream rename or format change fails loudly instead of silently producing an inert page again.

  • .formatter.exs now includes dev.exs and bench/. They sat outside the input globs, so mix format --check-formatted in CI never checked them.

Added

  • The README's Development section now covers running the dashboard locally, running the suite, reproducing CI exactly, driving the API from a script (and why iex -S mix run dev.exs never reaches a prompt), trying the library in your own app via a path dependency, and the test-database pollution trap in bench/bench_helper.exs.
  • usage-rules.md gains an Admin dashboard section, leading with the fact that the dashboard ships no authentication and must be guarded at both the pipeline and :on_mount layers.

[0.7.1] - 2026-08-25

Bug fixes from a review of the 0.7.0 A/B feature, plus two crash paths that predate it. Every fix below is covered by a test that was verified to fail without it.

Fixed

  • Hash parts could be re-partitioned, aliasing two experiments together. The seed, flag key and identity were joined with ":", so variant("exp", "org:123") and variant("exp:org", "123") hashed identically — a 100% collision rate, not a rare one. Composite identities like "org:123" are common enough for this to bite. Parts are now length-prefixed. Distribution is unchanged (50.02/49.98 over 20k identities); collisions dropped from 5000/5000 to chance.

    This changes every assignment relative to 0.7.0. Since 0.7.0 was tagged but never published to Hex, nobody should be affected in practice — but if you installed it from git, expect your population to reshuffle once.

  • A changed variant set kept assigning removed variants. Seeding deliberately leaves the stored weights alone so a runtime rollout survives a deploy. But when the declared set of variants changed, the stored split still named variants the code no longer had, and variant/3 went on assigning them — crashing callers that pattern match on the declared names. Seeding now resets the split (with a warning) when the set differs, mirroring the existing reset on a type change. Comparison is by set, so a rollout survives both a reordering and any weight change.

  • variant/3 raised on an unusable identity. A nil identity is a data condition — an anonymous visitor, a record without an id — not necessarily a coding mistake, and it took down the caller's process. It now warns and returns :default, consistent with how get/3 handles a failed read. PhoenixFlags.Variant.assign/4 stays strict for direct callers.

  • A forged dashboard weight crashed the LiveView. A non-string value in entry[variants][...] reached string interpolation and raised Protocol.UndefinedError. It is now coerced to 0, which surfaces as an ordinary "must total 100" field error.

  • A forged pf-save for an unknown key crashed the dashboard. update_entry/4 returns {:error, :not_found} for a key that is not in the table; that bound as changeset and to_form/2 raised on it. The dashboard also gained a catch-all handle_event/3, so an unknown event name or a missing field no longer takes the view down for want of a matching clause.

  • Log statements inside rescue handlers could themselves raisepredates 0.7.0. Several interpolated the flag key directly, and String.Chars is undefined for a map, tuple or pid. Because these sat inside rescue blocks, the failure escaped to the caller: get("some_key") with a map key crashed with Protocol.UndefinedError instead of returning the default. All such log statements now use inspect/1, which is also unambiguous for keys containing spaces. Log message format changed accordingly.

  • A non-string key reached the databasepredates 0.7.0. get/3, update_entry/4 and variant/3 now screen the key before querying, rather than relying on a rescue to mop up an Ecto.Query.CastError.

Changed

  • A stored split must name every declared variant. Previously a subset was accepted, so "a=100" could be saved for a two-variant flag — and then be reset by the next restart, because seeding reconciles the stored set against the declaration. Writes are now durable or rejected; use a weight of 0 to switch a variant off.

  • Declaration options that would be ignored are now rejected, the same reasoning as PhoenixFlags.Config.new!/1 refusing unknown keys: :default on a :variant flag (its value comes from :variants), and :ttl or :seed on a non-variant flag.

Added

  • test/phoenix_flags/variant_robustness_test.exs — regression tests for each fix above, plus a fuzz suite that throws ~40 malformed inputs (separator abuse, unicode, huge and negative weights, wrong types) at Variant.parse/2, parse_weights/1, Entry.cast_value/2 and Entry.changeset/3, asserting they only ever return and never raise. The log-statement bug above was found this way.

[0.7.0] - 2026-08-25

Added

  • A/B testing via a new :variant flag type. A :variant flag resolves to a different value per caller, chosen by a consistent hash of an identity you supply, so a given user sees a stable experience and results stay analysable.

    flag "checkout_flow",
      type: :variant,
      category: "experiments",
      label: "Checkout flow experiment",
      ttl: nil,                    # nil (default) = assignment never expires
      variants: [{"Control", "control", 90}, {"New flow", "new_flow", 10}]
    MyApp.SystemConfig.variant("checkout_flow", user.id)   #=> "control"

    Weights are whole numbers totalling 100, stored as the flag's value ("control=90,new_flow=10"), so a rollout can go 5% → 15% → 40% → 100% from the dashboard with no deploy. No migration is requiredsystem_flags.type is an unconstrained string column and value is already text.

    Modelled on AWS AppConfig traffic splitting, with one deliberate difference. AppConfig evaluates each variant as an independent (split pct::N by::$id) rule, first match wins over a shared hash, which means two variants at pct::20 produce A: 20%, B: 0% — a footgun their own docs document. A single ordered weight table makes buckets disjoint by construction and lets the total be validated.

    Details:

    • Sticky rollouts. Buckets are cumulative in declaration order, so growing a variant at the expense of the next one moves only the boundary between them. Verified over 20k identities: going 90/1080/20 moves nobody out of new_flow. Reordering :variants, changing :seed, or a :ttl rollover all reshuffle the population, and the docs say so.
    • ttl: (milliseconds, nil by default and meaning permanent) folds a time window into the hash so each caller is re-rolled once per window. Windows are offset per identity, so the population does not all flip at once. Stateless — no rows stored, no database call.
    • Independence. The flag key is part of the hash input, so concurrent experiments do not correlate. seed: re-randomises everyone, for restarting an experiment on the same flag.
    • SHA-256, not :erlang.phash2/2. phash2 is not guaranteed stable across OTP major versions, and an OTP upgrade must not silently reshuffle a live experiment. Costs ~0.5 us per assignment; see docs/benchmarks.md.
    • get/2 raises for a :variant flag, naming variant/3, rather than leaking a %PhoenixFlags.Variant{} into application code. Benchmarked at no measurable cost to ordinary reads.
    • A missing identity raises. nil (or anything not a non-empty string or integer) would put every caller in the same bucket, which is an invisible bug, so it fails loudly.
    • Dashboard editor with one input per variant and a live running total that must reach 100.
    • Opt-in exposure events. variant/3 emits nothing by default; pass telemetry: true for [:phoenix_flags, :variant, :assigned]. :telemetry is now a declared dependency — it was previously only transitive.
  • PhoenixFlags.Variantparse/2, parse_weights/1, serialize/1, assign/4.

  • Generated variant/3 and variants/1 on every config module.

Fixed

  • usage-rules.md documented a function that does not exist. It described Test.put_override/2; the generated helper is Test.stub/2. Since usage-rules.md ships in the package and is consumed by coding agents, this was actively misleading.
  • Dashboard edit forms now carry an id, which LiveView needs for form recovery after a reconnect. Previously it warned about this in tests.

Changed

  • Unexpected stored boolean values now warn instead of failing silently. cast_value/2 was value == "true", so a value that was neither "true" nor "false" — only reachable via a hand-edited row or a data migration, since changeset/3 rejects anything else — read as false with no signal. It still returns false (for a boolean flag, failing closed beats returning nil and changing what get(key) == false means), but now logs a warning like the integer and decimal casts already did.

Internal

No behaviour change; all of these are covered by the existing suite.

  • Both write paths now build their changeset through one value_changeset/3. The encrypt-then-validate sequence was duplicated across the cached and uncached paths, which is why :select membership went unchecked on both at once.
  • Removed a dead rescue ArgumentError in read_persistent_term/2; :persistent_term.get/2 returns the default for a missing key rather than raising. Documented why the get/1 call sites do still need theirs.
  • Documented the deliberate asymmetry in update_in_caller/4: unlike the cached path it has no rescue, because with no GenServer to keep alive a database error should reach the caller rather than be flattened into an error changeset.
  • PhoenixFlags.Config derives both defstruct and its accepted-option list from a single @fields attribute; the two were maintained side by side and could drift.
  • Named the 999_999 sort sentinel in all_grouped/1.
  • Added PhoenixFlags.EntryTest covering cast_value/2 and changeset/3, which had no direct unit tests.

[0.6.2] - 2026-08-25

Fixed

  • :select values are validated against their declared options on write. PhoenixFlags.Type.validate_value/2 fell through to its catch-all :ok clause for select, so update_entry/3 accepted, stored and cached any string at all:

    update_entry("feature_tier", %{"value" => "not-an-option"})
    #=> {:ok, %Entry{value: "not-an-option"}}   # before
    #=> {:error, #Ecto.Changeset<errors: [value: {"must be one of: basic, pro", []}]>}

    The constraint was already enforced on the declared default at compile time (PhoenixFlags.Flag), so this closes an inconsistency rather than adding a new rule. It matters because the dashboard's rendered <select> is not a validation boundary — LiveView event params are client-controlled — and an out-of-range value reached get/2, crashing consumers that pattern match on the known options.

    PhoenixFlags.Entry.changeset/2 gained an optional third argument carrying :select_options; the membership check only runs when they are supplied, so callers that build a bare form (or a :name module exporting only flags/0) are unaffected.

Upgrading

No migration or code change required. If you were relying on storing arbitrary values in a :select flag, those writes now return {:error, changeset} — either add the value to the flag's :options or change the flag to :string. Values already in the database are left alone; only new writes are checked.

[0.6.1] - 2026-08-25

Changed

  • Dependencies updated. No library code changes; mix.exs constraints are unchanged (every bump is within the existing requirements).

    PackageFromTo
    phoenix1.8.91.8.12
    phoenix_live_view1.2.81.2.10
    postgrex0.22.30.22.4
    ecto3.14.13.14.2
    req (dev, via igniter)0.7.10.7.3
    spitfire (dev, via igniter)0.3.130.4.0

Security

  • Picks up postgrex 0.22.4, which escapes comments in Postgrex.stream/4 (CVE-2026-66838). PhoenixFlags does not call Postgrex.stream/4 itself, so this is a transitive hardening for host applications that do.
  • Picks up phoenix_live_view 1.2.9, which fixes an open redirect in redirect/2 via ASCII tab, LF and CR (CVE-2026-64941). The embedded dashboard performs no redirects or navigation of its own, so it was not exposed, but host applications on 1.2.8 should upgrade.
  • mix hex.audit reports no retired or advisory packages.

[0.6.0] - 2026-07-30

Upgrading

Generate the V3 migration with mix igniter.upgrade phoenix_flags, or manually:

defmodule MyApp.Repo.Migrations.UpgradeSystemFlagsV3 do
  use Ecto.Migration

  def up, do: PhoenixFlags.Migration.up(version: 3)
  def down, do: PhoenixFlags.Migration.down(version: 3)
end

The upgrade is seamless:

  • Existing databases (V1 or V2): the migration widens the value columns and moves the schema version from the system_flags table comment into the new system_flags_meta table. The version is read from the comment during the transition, so no manual steps are needed.
  • Fresh databases (new dev machines, CI): your existing migration folder keeps working — the original install migration now builds V3 directly, and older pinned migrations (up(version: 2)) become no-ops.
  • No downtime: varchar(255)text is binary-coercible in PostgreSQL, so the column change is a catalog-only update with no table rewrite, even on large audit tables.
  • Rollback: mix ecto.rollback of the V3 migration restores the previous scheme exactly (columns narrowed back — this fails if any stored value now exceeds 255 characters — and the version written back to the table comment).
  • Caveat: if you ever need to downgrade the package below 0.6.0, roll back the V3 migration first (while 0.6.0 is still installed). Older releases only know the comment-based version store, which V3 clears.

Fixed

  • Full rollback works again. PhoenixFlags.Migration.down(version: 1) executed invalid SQL (COMMENT ON TABLE IF EXISTS does not exist in PostgreSQL) after dropping the table, so mix ecto.rollback always failed. The redundant comment reset was removed.
  • :secret defaults are encrypted at seed time. Previously a :secret flag with a non-empty default was written to the database in plaintext, and cached reads then failed to decrypt it. Seeding (and the value reset on type changes) now runs the default through the configured encryptor.
  • Uncached reads decrypt secrets. With cache_enabled: false, get/2 returned the stored ciphertext for :secret flags instead of the plaintext the cached path returns. Both paths now share the same decrypt-and-cast pipeline.
  • Server survives database errors during writes. A Postgrex.Error raised inside update_entry/3 (e.g. connection loss) crashed the GenServer; it is now rescued and returned as an error changeset.
  • Restarts no longer serve call-site defaults. terminate/2 erased the persistent_term config key that get/3 needs, so every flag read as its default during a Server restart. The cache, config, and order keys are now all left intact and overwritten by init/1.
  • Decrypt failures signalled by return value are handled. An encryptor returning a non-binary (e.g. :error from :crypto.crypto_one_time_aead/7) was cached as if it were the plaintext; it now logs a warning and yields nil.
  • README example encryptor could not decrypt its own output (12-byte IV written, 16-byte IV read). The install snippet also referenced version 0.1.0 and omitted the organization option.
  • Dashboard toggle only accepts boolean flags. A forged pf-toggle event could overwrite any flag (including strings/selects) with "true"/"false".
  • Mounting flags_dashboard twice works. The router macro defined a shared pipeline and plug function per mount, so a second dashboard silently reused the first mount's :app_js and emitted duplicate-clause warnings.
  • Deterministic audit ordering. Audit queries now tiebreak on idinserted_at has second precision, so same-second changes had nondeterministic order.

Added

  • Migration V03 widens system_flags.value and the audit old_value/new_value columns from varchar(255) to text (encrypted secrets routinely exceed 255 characters and previously crashed the Server on write), and moves the schema version out of the system_flags table comment into a queryable system_flags_meta table. Older databases are still detected via the comment fallback, and rolling back V03 restores the comment. mix igniter.upgrade phoenix_flags generates the migration.
  • Periodic cache refresh. Each instance reloads its cache from the database on a jittered refresh_interval (default 60s, false to disable), bounding staleness for nodes that miss a :reload notification during partitions or restarts.
  • One-config-module-per-repo guard. Two config modules with different flag declarations sharing one repo would delete each other's rows at seed time; the Server now detects this at boot and raises with a clear message.

Changed

  • PhoenixFlags.Config.new!/1 raises on unknown options instead of silently ignoring them (a typo like audit_enabled: left auditing off).
  • audit_log/0 and audit_log/1 raise a clear PhoenixFlags.Error when audit: true is not set, instead of failing with a database error about the missing table.
  • :jason is now an optional dependency — nothing in lib/ uses it, so consumers are no longer forced to install it.
  • usage-rules.md is now included in the published package.

[0.5.0] - 2026-04-23

Added

  • Audit log. Opt-in per-flag change history via audit: true on use PhoenixFlags. Writes to a new system_flags_audit table. actor_fn resolves the actor from the LiveView socket / Plug conn. audit_log/0 and audit_log/1 query the history.
  • Migration V02 adds the system_flags_audit table. PhoenixFlags.Migration.up(version: 2) runs the whole chain for new installs; existing installs add a second migration calling the same helper (the mix igniter.upgrade phoenix_flags task generates this automatically).
  • :secret flag type for credentials and other sensitive values. Secrets are encrypted at rest via a host-supplied encryptor module (encrypt/1 + decrypt/1), displayed in the dashboard as "Set" / "Not set" with a password-style edit input, and redacted as "[redacted]" in the audit log. PhoenixFlags ships no cryptography itself — you pick the cipher and manage the key.
  • :encryptor option on use PhoenixFlags. Required whenever any :secret flag is declared — omitting it raises a PhoenixFlags.Error at compile time with the list of offending keys. A boot-time check also verifies the encryptor module exports encrypt/1 and decrypt/1.
  • mix igniter.upgrade phoenix_flags now works — new Mix.Tasks.PhoenixFlags.Upgrade module. Generates the v2 migration for users upgrading from 0.4.x.

Changed

  • update_entry/3 accepts an :actor option that's recorded in the audit log.
  • Entry.changeset/2 accepts "" as a valid value for :secret flags (so operators can clear a secret); non-secret flags continue to reject empty strings as before.
  • Renamed PhoenixFlags.Testing.put_override/3stub/3 and get_override/2get_stub/2. The generated MyApp.SystemConfig.Test.stub/2 helper exposes the new name. This is a breaking API change for tests written against 0.4.x — rename .Test.put_override("key", value) to .Test.stub("key", value).

[0.4.2] - 2026-03-20

Changed

  • update_entry/3 now patches the persistent_term cache in-memory instead of doing a full repo.all(Entry) reload — reduces DB calls per write from 3 to 2
  • Flag declaration order index is now pre-computed once at startup and stored in persistent_term, instead of being rebuilt on every all_grouped/0 call

Added

  • Benchee benchmarks (bench/phoenix_flags_bench.exs) covering all public functions
  • Benchmark documentation (docs/benchmarks.md) with I/O profiles and results

[0.4.1] - 2026-03-20

Fixed

  • SQL injection vector in migration prefix — now validated against ^[a-z_][a-z0-9_]*$
  • Unsafe String.to_integer/1 in migrated_version/1 — replaced with Integer.parse/1 with fallback
  • Cast failures (cast_value/2) now log at :warning instead of :debug for better observability
  • Cache reload failures now log at :error instead of :warning
  • insert_all seed log now reports the actual inserted count, not the declared count
  • terminate/2 now logs cleanup failures at :debug instead of silently swallowing them
  • to_seed_map/1 catch-all clause tightened to require :key, :type, and :value keys
  • Moved require Logger to top of Entry module

Added

  • Tests for malformed DB values (corrupted integer, decimal, percentage, partial integer)
  • docs/TODO.md with planned improvements

[0.4.0] - 2026-03-20

Added

  • Self-contained CSS and HTML layout — dashboard no longer depends on host app's stylesheets or layout system
  • Asset plug serves CSS with content-hashed URLs and immutable cache headers
  • Own root layout with <script> tag to load the host app's LiveView JS
  • :app_js option on flags_dashboard to customise the JS bundle path (defaults to /assets/js/app.js)
  • 14 LiveView tests covering renders, toggle, edit, save, validation errors, cancel, and select
  • select_options/1 callback on the generated module for :select type flag options
  • Declaration-order sorting — categories and entries always render in flag/2 declaration order

Changed

  • Dashboard is fully self-contained (like Oban Web / LiveDashboard) — no @source directive or :layout option needed
  • Removed :layout option from flags_dashboard macro
  • Removed flash messages from dashboard — updates are instant and don't need confirmation
  • Extracted entry_info component to reduce template duplication
  • Consolidated integer/decimal/percentage inputs into a single component clause
  • Extracted input_class and reload helpers to reduce repetition

Removed

  • :layout option from flags_dashboard — the package owns its own layout now
  • Flash message rendering from the live layout

[0.3.0] - 2026-03-19

Added

  • :options field on Flag struct for :select type — a list of {label, value} tuples
  • Compile-time validation that :select flags must provide :options
  • Compile-time validation that :select default value must be in the :options list

[0.2.0] - 2026-03-19

Added

  • PhoenixFlags.Error custom exception for all library-raised errors
  • PhoenixFlags.Type shared validation module, eliminating duplicated logic between Flag and Entry
  • update_entry/3 now accepts :timeout option for GenServer call timeout
  • Testing.insert_entry/4 infers type from value (boolean, integer, Decimal, string)
  • Helpful error message when numeric flag types omit :default (e.g. flag "x", type: :integer)
  • Catch-all handle_info clause to prevent GenServer crashes from unexpected messages
  • terminate/2 erases config key on shutdown, signalling "server down" to readers
  • Peer notification after flag seeding, so other nodes pick up changes from deploys
  • Dashboard :layout option to render inside the host app's layout
  • PhoenixFlags.UI.OnMount hook for mounting dashboard in custom live_session
  • Conditional compilation for UI modules (phoenix_live_view is truly optional)

Changed

  • get/3 never crashes — returns default when server is not running or restarting
  • all_grouped/0 returns [] when server is not running
  • Cache stores values and entries in a single atomic {values, entries} tuple (one persistent_term key instead of two)
  • Metadata updates during seeding are wrapped in Repo.transaction for atomicity
  • insert_all uses on_conflict: :nothing to handle concurrent node startups gracefully
  • Dashboard uses host app's layout and CSS framework instead of self-contained styles
  • load_cache crashes on first load in init (supervisor retries), rescues on subsequent reloads
  • Removed dead :table config option from Config struct

Fixed

  • SQL injection in Migration.migrated_version/1 — now uses parameterized query
  • Migration V01 now respects the :prefix option for table and index creation
  • Narrowed rescue ArgumentError in get/3 to only catch persistent_term lookup failures
  • cast_value logs at debug level for unparseable values instead of silently returning nil

[0.1.0] - 2026-03-19

Added

  • Initial release
  • use PhoenixFlags macro for defining configuration modules
  • flag/2 macro for compile-time validated flag declarations
  • PhoenixFlags.Flag struct with validation for key, type, and default values
  • PhoenixFlags.Entry Ecto schema with typed value casting and validation
  • PhoenixFlags.Server GenServer with :persistent_term caching
  • Declarative flag seeding on startup: insert new, update metadata, remove stale, reset value on type change
  • Cluster-aware cache replication via direct node messaging
  • Versioned migration system (PhoenixFlags.Migration)
  • V01 migration: system_flags table with key, value, type, category, label, description
  • PhoenixFlags.Testing module with process-scoped overrides and DB insert helpers
  • Auto-generated Test submodule in :test environment with put_override/2 and insert_entry/3
  • PhoenixFlags.Config struct for instance configuration
  • Support for boolean, integer, decimal, percentage, select, and string value types
  • Validation per type on update_entry/2
  • all_grouped/0 for admin UI data
  • Test sandbox compatibility via cache_enabled: false
  • Embedded admin dashboard (PhoenixFlags.Router.flags_dashboard/2)
  • Self-contained LiveView UI with own layout, components, and CSS (no app dependencies)
  • Dashboard features: boolean toggle switches, inline edit forms, validation errors, flash messages, dark mode