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.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 throughput_resolved/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.