A named deployment target within a project (003 T007).
defmodule MyApp.Environment do
use AshSandbox.EnvironmentTemplate,
data_layer: AshPostgres.DataLayer,
domain: MyApp.Sandboxes,
repo: MyApp.Repo,
table: "environments",
project_resource: MyApp.Project,
registry_resource: MyApp.SandboxRegistry
endpreview, staging, production — backed by exactly one sandbox.
target_stack selects the mechanism
It lives here rather than on the sandbox because the mechanism is a property of what is being run, not of the running instance, and it must be known before a sandbox exists in order to create one (003 research R1).
Resolution is the host's (012-FR-001): ex_sandbox cannot assume the host
is an OTP application named :axonn, so it supplies no configuration key.
An unknown stack is an error naming the stack — never a fallback to a
default, which would run tenant code under an isolation model nobody chose.
template_name is recorded even when the template is absent
So provisioning fails naming the missing template rather than starting an
empty environment (003-FR-008, spec edge case). Validating its existence at
creation time would be worse, not better: templates are built out of band and
may be registered after an environment references one.
registry_resource is required, and it is what makes the allowlist writable
update accepts :network_allowlist and refuses while the environment has
a live sandbox (029 T018 ruling) — see
AshSandbox.Internal.RefuseAllowlistChangeWhileLive for why refusing is the
honest shape. Deciding liveness means reading the host's registry, and the
host names its own binding here rather than this library naming a module it
cannot know (012-FR-009).
⚠️ Required rather than optional, on purpose. An optional reference has
two shapes, and both are worse. Without it the guard is skipped — a control
that reports success and changes nothing, the exact defect this exists to
prevent — or the field silently stays unwritable and an operator meets "no
such input" with nothing telling them why. A missing option is instead a
compile error naming what to pass. (012-FR-015 makes this a breaking change
to a public interface; that requirement is deferred until a version is
published, and there is no published version.)
:unique_name_per_project compiles on any data layer
Its pre_check_with is derived from the host's data layer, the same way
AshSandbox.RegistryTemplate's :unique_environment identity derives it —
see AshSandbox.Internal.DataLayerSection.pre_check_with/2 for both branches
and why neither is a safe default. Without this, Ash.DataLayer.Verifiers.RequirePreCheckWith
refused to compile the resource on any data layer that cannot enforce a
unique identity itself (ETS, Mnesia), which made this template AshPostgres-only
in practice and contradicted 012-FR-009. ash_sandbox's own ETS HostApp
fixtures now bind a Project and Environment, not only a registry.
purpose is here; the rule that reads it is not
An environment having a purpose — development, staging, production — is a
general fact about environments, so it is an attribute of this template. What
a purpose implies about availability is not general: it depends on the
host's commercial arrangement with the owner, and a library that knows what
:free and :paid mean has stopped being a library
(derive-availability-from-plan-and-purpose design D2).
⚠️ purpose is never derived from name. The name is free text; a rule
that read it would classify production-old and staging-2 wrongly,
silently, and only for the hosts that happen to have named things that way.
availability_derivation: is how a host takes the choice away
Pass a change module and three things happen: :availability_mode leaves the
accept lists of create and update, the module runs on both, and an
update :rederive_availability appears that runs it and nothing else.
Omit it — as this library's own ETS HostApp fixtures do — and the template
behaves exactly as it did: availability_mode stays caller input. The
library keeps the field writable at the DSL level; it is the binding that
removes the choice.
⚠️ The module must write availability_mode and idle_timeout_seconds
together, because the validations below reject a mode/timeout pair that
disagrees. It must also write them while the changeset is being built rather
than in a before_action hook, since those validations run after the
action's changes and before any hook.