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.EnvandAmbient.Credo.NoDirectEnv. An override can only affect a read that happens at runtime, and idiomatic Elixir reads env vars inconfig/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 atAmbient.Configinstead. UseAmbient.Config; for a read that genuinely happens at runtime,use Ambient.ValuewrapsSystem.get_env/2in about ten lines. - BREAKING:
Ambient.RandomandAmbient.Credo.NoDirectRandom. It cost a fork-vs-shared-stream semantics table, a crypto contract, and a "seedable in tests,:crypto.strong_rand_bytes/1in 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/1directly, which is the right answer. The machinery it leaned on –get_and_update/3and 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:privateor{:shared, pid}for a value module or facade, likeset_shared/2andset_private/1take.Ambient.ProcessOverride.mode/1is 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 isApplication.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/1andoverridden?/1now 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/1andfetch!/1on a generated config accessor, theApplication.fetch_env/2shape – so "absent" stays distinguishable from "set tonil", and code doingfetch_envdoesn'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/3callback took down the table'sServer. The callback runs inside theServerin shared mode, so an exception in it killed the table owner: the caller saw an:exitinstead 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
allowcycle discarded a valid$callersanswer. Hitting an already-visited pid returnednilfrom 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/2anddelete_override/1come fromAmbient.Valueand stored under the term as given, whileput/2,revert/1andoverridden?/1normalize – soput_override([:port], v)wrote a row the same module'soverridden?([:port])reported as absent andrevert([:port])could not clear. Both now normalize, so[:port]and:portare interchangeable across the whole generated surface, as thekeytypedoc says. - A slow
get_and_update/3callback could consume a value nobody received. In shared mode the callback runs inside theServer, and theGenServer.calltook 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.NoDirectConfigmissedApplication.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.Tenantwhose 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.Configanduse Ambient.Value, withAmbient.Clockas 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/
applyblind spot is now documented rather than implied away.
0.1.1 - 2026-07-29
Fixed
Every
Ambient.Randomread crashed whenAmbient.start_servers/1hadn't been called, in any build with overrides enabled.uniform/1,bytes/1,shuffle/1and friends route throughAmbient.ProcessOverride.get_and_update/3, which reachedshared_owner/1– a bare:ets.lookup– before checking the table existed, so ETS raisedArgumentError("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 recommendedenable_overrides: config_env() != :prodleaves enabled while nothing starts the servers:iex -S mixplus anyAmbient.Randomcall crashed.Ambient.ClockandAmbient.Configwere unaffected – they read throughfetch/2, which has always guarded.No table now means no override, so
get_and_update/3returns:errorand the caller falls through. Writers are unchanged and still raiseAmbient.Errorwith: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$callersinheritance and an Ecto-Sandbox-styleallow/3.Ambient.Clock– overridable wall clock (set/1,advance/1,reset/0).Ambient.Random– seedable, replayable RNG (seed/1,uniform,shuffle, …).Ambient.Config–use-able app-config accessor with a per-process override layer.Ambient.start_servers/1– one-call test setup (runs the servers underAmbient.Supervisorso a Server crash is restarted + logged, not silent).Ambient.Facade–use Ambient.Facade, for: Ambient.Clockto re-export a value module under your own module name, with compile-time-derived delegates.- Optional Credo checks
Ambient.Credo.NoDirectClock,NoDirectRandomandNoDirectConfig. 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/3andallow/3all refuse, so no Ambient API can produce an override.Ambient.ProcessOverride.enabled?/0reports the build; compiling with the flag hard-coded on warns when Ambient can tell it's a prod build.Ambient.Random.bytes/1now falls through to:crypto.strong_rand_bytes/1when 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) underseed/1. The rest ofAmbient.Randomremains:rand-backed and must never be used for credentials.- Shared mode.
Ambient.set_shared/2/Ambient.set_private/1(andAmbient.ProcessOverride.set_shared/2/set_private/1/mode/1) make one process's overrides the ones every process reads, forasync: falsetests that can't reach a process withallow/3. Only the shared owner may write;allow/3is 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 bareArgumentError/RuntimeError, carrying a machine-readable:reasonand the:tableinvolved. Bad argument values still raiseArgumentError.Ambient.Env– overridable OS environment variables, so tests stop reaching for the VM-globalSystem.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: :tgenerates 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 theget_or/2macro. The built-ins are built on it.Ambient.Credo.NoDirectEnv– flagsSystem.get_env/*andSystem.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, likeAmbient.Random. A plainput/3would raise for every non-owner once a table went shared, and afetch/2plusput/3would 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 theServer; private mode stays client-side, where a process can't race itself.
Fixed
Ambient.Randomwas 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 throughget_and_update/3, giving one globally advancing stream.allow/3andset_shared/2monitored by cast, then inserted from the client, so a pid dying in the gap left a row no:DOWNwould ever clean. Measured over 40k attempts: 202 orphanedallowrows (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 callsset_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.Supervisorused the default 3-restarts-in-5-seconds and stayed linked to whichever process calledstart_servers/1first. 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/2now raises{:not_shared_owner, pid}when the table is already shared by someone else.set_private/1stays open deliberately –on_exit/1runs 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 pastNoDirectRandomentirely. Ambient.Value'sdefoverridablelist omitted__ambient_table__/0, so redefining it only produced a "clause cannot match" warning while the generated one silently won.Ambient.Facadenow passes__ambient_table__/0through, so a facade can be given toAmbient.start_servers/1andset_shared/2in 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.Confignow generates the domain verbs the other values have:put/2,revert/1andreset/0, alongsideget/2. It was the only value module whose documented API was the rawAmbient.Valuelayer.Ambient.start_servers/1,set_shared/2andset_private/1accept a single value module as well as a list, so they no longer collide by argument shape with the same-namedAmbient.ProcessOverridefunctions that take one raw table. A non-atom, non-list argument now raisesAmbient.Errorwith:not_a_value_moduleinstead ofFunctionClauseError.- Production wrappers are now free.
get_or/2expands at compile time, so in a build without overrides each wrapper compiles to exactly the function it wraps:Ambient.Clock.utc_now/0toDateTime.utc_now/0, a generatedMyApp.Config.get/2toApplication.get_env/3,Ambient.Env.get/2toSystem.get_env/2.Ambient.ClockandAmbient.Configpreviously paid one:ets.whereis/1per 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:randfunction; it now delegates to:rand.uniform/1and friends, which seed the process dictionary once. Seeded behaviour is unchanged.Ambient.Clock.utc_now/0no longer re-checks that the stored override is aDateTime–set/1is 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() != :prodDerive 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
ArgumentErrortoAmbient.Errorand match on:reason. Ambient.start_servers/1now raises:not_a_value_modulefor 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/1changed source, from a:randstream to:crypto.strong_rand_bytes/1. Output shape is identical; it is simply no longer predictable from a:randseed. - Unseeded
Ambient.Randomnow draws from the process dictionary's:randstate rather than a fresh one per call, so a caller who seeded:randdirectly will see those draws follow that seed.