All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.2.0 - 2026-09-02

Ambient shipped four built-in values. Two of them – Ambient.Env and Ambient.Random – turned out not to earn their surface, and the flagship one couldn't express the way real config is written. This release cuts the first two, fixes the third, and repositions the library around what it is actually for: a per-process Application.get_env layer, plus Ambient.Value for ambient values of your own.

Removed

  • BREAKING: Ambient.Env and Ambient.Credo.NoDirectEnv. An override can only affect a read that happens at runtime, and idiomatic Elixir reads env vars in config/runtime.exs – before any override can exist – then puts them into app config. Ambient.Env's own moduledoc said so and the README pointed readers at Ambient.Config instead. Use Ambient.Config; for a read that genuinely happens at runtime, use Ambient.Value wraps System.get_env/2 in about ten lines.
  • BREAKING: Ambient.Random and Ambient.Credo.NoDirectRandom. It cost a fork-vs-shared-stream semantics table, a crypto contract, and a "seedable in tests, :crypto.strong_rand_bytes/1 in production" pitch guarding a function with no production callers – the real credential sites in the app this was built for all call :crypto.strong_rand_bytes/1 directly, which is the right answer. The machinery it leaned on – get_and_update/3 and shared-mode atomicity – stays and serves any read-modify-write value, but the README now argues a seeded RNG is usually the wrong thing to build on it: fixture generation is single-process and wants :rand.seed/2, and production randomness is jitter, where a test should pin the delay rather than replay a stream.

Added

  • Ambient.mode/1, which reports :private or {:shared, pid} for a value module or facade, like set_shared/2 and set_private/1 take. Ambient.ProcessOverride.mode/1 is the same query against a raw table atom – passing it a module used to return a silent :private.

  • Nested config keys. config :my_app, :oauth, client_id: "…" reads back as a keyword list, so the call site is Application.get_env(:my_app, :oauth)[:client_id] – which the flat accessor could only override wholesale. That is why nested reads never migrated in the app this was built for. get/2, put/2, revert/1 and overridden?/1 now take a path:

    MyApp.Config.get([:oauth, :client_id], "default")
    MyApp.Config.put([:oauth, :client_id], "test-client")

    Paths step through keyword lists and maps to any depth, and resolve longest-prefix-first, so an existing wholesale put(:oauth, …) stays visible to a leaf read. A one-element path is the same key as the bare atom. The disabled build resolves paths straight out of app env with no ETS lookup.

  • fetch/1 and fetch!/1 on a generated config accessor, the Application.fetch_env/2 shape – so "absent" stays distinguishable from "set to nil", and code doing fetch_env doesn't have to stay on the global reader. Both take paths and both compile out of a disabled build.

Fixed

  • A raising get_and_update/3 callback took down the table's Server. The callback runs inside the Server in shared mode, so an exception in it killed the table owner: the caller saw an :exit instead of its own error, and the restart handed back an empty ETS table, silently voiding every override in flight for every process. The exception is now caught, shipped back, and re-raised in the caller, so it behaves exactly like the private-mode path.
  • An allow cycle discarded a valid $callers answer. Hitting an already-visited pid returned nil from the whole resolution rather than falling through to the caller chain, so a cycle anywhere in the grants could hide an override an ancestor really owned.
  • A generated config module's raw writers ignored path keys. put_override/2 and delete_override/1 come from Ambient.Value and stored under the term as given, while put/2, revert/1 and overridden?/1 normalize – so put_override([:port], v) wrote a row the same module's overridden?([:port]) reported as absent and revert([:port]) could not clear. Both now normalize, so [:port] and :port are interchangeable across the whole generated surface, as the key typedoc says.
  • A slow get_and_update/3 callback could consume a value nobody received. In shared mode the callback runs inside the Server, and the GenServer.call took the default 5s timeout – so a caller could give up while the Server still applied the write. It now waits :infinity; this is test-only infrastructure with no liveness requirement.
  • Ambient.Credo.NoDirectConfig missed Application.get_env(@otp_app, :key). It matched the app argument as a literal atom, so one of the commonest spellings of the banned call was invisible to it. Module attributes are now flagged. This matters because "Credo pins it" is the whole answer to Ambient asking you to change every read site.

Changed

  • "Build your own" no longer promotes an anti-pattern. The worked example was a MyApp.Tenant whose fallback was a stub module – which in a production build is all that remains, so the example described a value that is a constant in production and only real in tests. It is now a feature flag whose fallback is the real flag-service lookup, alongside the rule that separates the two: the fallback must be the real production implementation. The docs also now say not to make the acting user or current tenant ambient – they decide what a request may see, so a leaked override is a data-exposure bug, not a wrong timestamp.
  • The package description and README are rebuilt around Ambient.Config and use Ambient.Value, with Ambient.Clock as the worked example. The comparison section is a third of its former length, and a new "What it costs to adopt" section states plainly what the migration does not reach.
  • The Credo checks' alias/apply blind spot is now documented rather than implied away.

0.1.1 - 2026-07-29

Fixed

  • Every Ambient.Random read crashed when Ambient.start_servers/1 hadn't been called, in any build with overrides enabled. uniform/1, bytes/1, shuffle/1 and friends route through Ambient.ProcessOverride.get_and_update/3, which reached shared_owner/1 – a bare :ets.lookup – before checking the table existed, so ETS raised ArgumentError ("the table identifier does not refer to an existing ETS table") where a read should simply miss and fall through to :rand.

    It bit hardest in :dev, which the recommended enable_overrides: config_env() != :prod leaves enabled while nothing starts the servers: iex -S mix plus any Ambient.Random call crashed. Ambient.Clock and Ambient.Config were unaffected – they read through fetch/2, which has always guarded.

    No table now means no override, so get_and_update/3 returns :error and the caller falls through. Writers are unchanged and still raise Ambient.Error with :server_not_started, which is the actionable message for the case that really is a mistake.

0.1.0 - 2026-07-29

First release.

Added

  • Ambient.ProcessOverride – ETS-backed process-local override store with $callers inheritance and an Ecto-Sandbox-style allow/3.
  • Ambient.Clock – overridable wall clock (set/1, advance/1, reset/0).
  • Ambient.Random – seedable, replayable RNG (seed/1, uniform, shuffle, …).
  • Ambient.Configuse-able app-config accessor with a per-process override layer.
  • Ambient.start_servers/1 – one-call test setup (runs the servers under Ambient.Supervisor so a Server crash is restarted + logged, not silent).
  • Ambient.Facadeuse Ambient.Facade, for: Ambient.Clock to re-export a value module under your own module name, with compile-time-derived delegates.
  • Optional Credo checks Ambient.Credo.NoDirectClock, NoDirectRandom and NoDirectConfig.
  • config :ambient, enable_overrides: config_env() != :prod – a compile-time switch, off by default, that decides whether the override machinery is built at all. With it off, Ambient.start_servers/1, ProcessOverride.Server.{start_link/1, init/1}, put/3 and allow/3 all refuse, so no Ambient API can produce an override. Ambient.ProcessOverride.enabled?/0 reports the build; compiling with the flag hard-coded on warns when Ambient can tell it's a prod build.
  • Ambient.Random.bytes/1 now falls through to :crypto.strong_rand_bytes/1 when no seed is in scope, making it credential-safe in production: the seeded clause isn't compiled into a build that didn't opt in, so no ambient seed can downgrade it. It stays deterministic (and non-cryptographic) under seed/1. The rest of Ambient.Random remains :rand-backed and must never be used for credentials.
  • Shared mode. Ambient.set_shared/2 / Ambient.set_private/1 (and Ambient.ProcessOverride.set_shared/2 / set_private/1 / mode/1) make one process's overrides the ones every process reads, for async: false tests that can't reach a process with allow/3. Only the shared owner may write; allow/3 is refused while shared; the owner is monitored, so its exit returns the table to private.
  • Ambient.Error – every Ambient misuse now raises this instead of a bare ArgumentError/RuntimeError, carrying a machine-readable :reason and the :table involved. Bad argument values still raise ArgumentError.
  • Ambient.Env – overridable OS environment variables, so tests stop reaching for the VM-global System.put_env/2. get/2, fetch/1, fetch!/1, put/2, put_all/1, unset/1 (override as absent), revert/1 (drop the override), reset/0.
  • Ambient.Value – the supported extension point. use Ambient.Value, table: :t generates the writers (put_override/2, delete_override/1, delete_all/0, overridden?/1, allow/2, set_shared/1, set_private/0, __ambient_table__/0, all overridable) and imports the get_or/2 macro. The built-ins are built on it.
  • Ambient.Credo.NoDirectEnv – flags System.get_env/* and System.put_env/*.
  • Ambient.ProcessOverride.delete_all/1 – drop every override the calling process owns in a table.
  • Ambient.ProcessOverride.get_and_update/3 – atomic read-modify-write for values whose reads also write, like Ambient.Random. A plain put/3 would raise for every non-owner once a table went shared, and a fetch/2 plus put/3 would lose updates: every process shares one row in shared mode, so concurrent draws read the same state and overwrite each other (99 duplicates in 200 draws, measured). Shared mode runs the whole operation inside the Server; private mode stays client-side, where a process can't race itself.

Fixed

  • Ambient.Random was unusable under shared mode. Every draw writes its advanced state back, and shared mode forbids non-owner writes, so any process that wasn't the shared owner raised {:not_shared_owner, pid} – i.e. exactly the processes shared mode exists to reach. Writes now route through get_and_update/3, giving one globally advancing stream.
  • allow/3 and set_shared/2 monitored by cast, then inserted from the client, so a pid dying in the gap left a row no :DOWN would ever clean. Measured over 40k attempts: 202 orphaned allow rows (which pid reuse then hands to an unrelated process – a leak in the library whose promise is no leaks) and 146 tables stuck shared to a dead pid, where every write raises until someone calls set_private/1. Both now monitor and insert inside the Server, on the same side of its mailbox as the :DOWN. Reproduced at 0 after.
  • Ambient.Supervisor used the default 3-restarts-in-5-seconds and stayed linked to whichever process called start_servers/1 first. A suite that restarts a Server (or --repeat-until-failure) exhausted it, and the supervisor's exit took every override table and the test run with it.
  • A non-owner could silently steal or cancel shared mode. set_shared/2 now raises {:not_shared_owner, pid} when the table is already shared by someone else. set_private/1 stays open deliberately – on_exit/1 runs in a different process from the test.
  • All four Credo checks missed piped calls when the banned entry pinned an exact arity: a pipe leaves the receiver out of the call node, so list |> Enum.shuffle() – the form almost everyone writes – slipped past NoDirectRandom entirely.
  • Ambient.Value's defoverridable list omitted __ambient_table__/0, so redefining it only produced a "clause cannot match" warning while the generated one silently won.
  • Ambient.Facade now passes __ambient_table__/0 through, so a facade can be given to Ambient.start_servers/1 and set_shared/2 in place of the value module it wraps. It was rejected as :not_a_value_module.
  • Ambient.Random.normal/2's second argument was documented as the standard deviation; like :rand.normal_s/3, it is the variance.

Changed

  • use Ambient.Config now generates the domain verbs the other values have: put/2, revert/1 and reset/0, alongside get/2. It was the only value module whose documented API was the raw Ambient.Value layer.
  • Ambient.start_servers/1, set_shared/2 and set_private/1 accept a single value module as well as a list, so they no longer collide by argument shape with the same-named Ambient.ProcessOverride functions that take one raw table. A non-atom, non-list argument now raises Ambient.Error with :not_a_value_module instead of FunctionClauseError.
  • Production wrappers are now free. get_or/2 expands at compile time, so in a build without overrides each wrapper compiles to exactly the function it wraps: Ambient.Clock.utc_now/0 to DateTime.utc_now/0, a generated MyApp.Config.get/2 to Application.get_env/3, Ambient.Env.get/2 to System.get_env/2. Ambient.Clock and Ambient.Config previously paid one :ets.whereis/1 per call.
  • Ambient.Random's unseeded path no longer reseeds per call. It built a fresh :rand.seed_s(:exsss) on every call, ~12x the cost of the plain :rand function; it now delegates to :rand.uniform/1 and friends, which seed the process dictionary once. Seeded behaviour is unchanged.
  • Ambient.Clock.utc_now/0 no longer re-checks that the stored override is a DateTimeset/1 is the only writer and is typed.

Upgrading from the git dependency

Only relevant if you tracked main before this release.

Add the switch to config/config.exs – without it Ambient.start_servers/1 raises and your suite won't boot:

config :ambient, enable_overrides: config_env() != :prod

Derive it from config_env/0 rather than hard-coding true; that's what keeps the machinery – and the only way to downgrade Random.bytes/1 – out of your release. Prefer != :prod over == :test: Dialyzer runs in :dev, and in a disabled build the writers raise, so gating on == :test makes it report every generated writer in your own modules as having no local return.

Also:

  • If you rescue Ambient's exceptions, switch from ArgumentError to Ambient.Error and match on :reason.
  • Ambient.start_servers/1 now raises :not_a_value_module for a module-looking atom that doesn't export __ambient_table__/0, where it previously accepted it as a raw table name. Facades are fine – they now pass it through.
  • Unseeded Ambient.Random.bytes/1 changed source, from a :rand stream to :crypto.strong_rand_bytes/1. Output shape is identical; it is simply no longer predictable from a :rand seed.
  • Unseeded Ambient.Random now draws from the process dictionary's :rand state rather than a fresh one per call, so a caller who seeded :rand directly will see those draws follow that seed.