defmodule OpenAgents.RuntimeConfig do
@moduledoc """
Typed, fail-closed boundary for behavior-changing runtime configuration.
The boundary validates application configuration before migrations, workers,
or the endpoint start. Its readiness report contains only environment,
feature booleans, and group status; secret and infrastructure values never
enter the report or validation errors.
"""
alias OpenAgents.Forge.HotLoader
alias OpenAgents.Forge.RollingProvider.Gcp
alias OpenAgents.Tools.Snapshot
@persistent_key {__MODULE__, :current}
@target_repository "openagents.com"
@target_owner "OpenAgentsInc"
@environments [:development, :test, :staging, :production]
@group_names ~w(endpoint database github providers features forge cluster)a
@enforce_keys [:environment, :staging_gate, :features, :groups]
defstruct @enforce_keys ++ [:hot_load_allowlist, :hot_load_examples]
@type t :: %__MODULE__{
environment: :development | :test | :staging | :production,
staging_gate: 0..16,
features: %{required(atom()) => boolean()},
groups: %{required(atom()) => :ready},
hot_load_allowlist: [String.t()],
hot_load_examples: %{required(String.t()) => boolean()}
}
@spec install!() :: t()
def install! do
config = load!() |> verify_compiled_settings!()
:persistent_term.put(@persistent_key, config)
config
end
@spec current!() :: t()
def current! do
case :persistent_term.get(@persistent_key, :not_installed) do
%__MODULE__{} = config -> config
:not_installed -> raise "runtime configuration is not installed"
end
end
@spec load!(keyword() | map()) :: t()
def load!(settings \\ Application.get_all_env(:openagents)) do
case validate(settings) do
{:ok, config} ->
config
{:error, %{setting: setting, reason: reason}} ->
raise ArgumentError, "runtime configuration invalid: #{setting} #{reason}"
end
end
@spec validate(keyword() | map()) :: {:ok, t()} | {:error, map()}
def validate(settings) when is_list(settings) or is_map(settings) do
settings = Map.new(settings)
with {:ok, environment} <- required_enum(settings, :runtime_environment, @environments),
{:ok, staging_gate} <- required_integer(settings, :staging_gate, 0..16),
:ok <- validate_production_lock(settings, environment),
:ok <- validate_endpoint(settings, environment),
:ok <- validate_database(settings, environment),
:ok <- validate_github(settings, environment),
:ok <- validate_vault_separation(settings, environment),
{:ok, features} <- validate_features(settings, environment, staging_gate),
:ok <- validate_staging_cleanup(settings, environment, staging_gate),
:ok <- validate_providers(settings, features),
:ok <- validate_release_identity(settings, environment, staging_gate),
{:ok, allowlist, examples} <- validate_forge(settings, environment, features),
:ok <- validate_cluster(settings, environment, features) do
{:ok,
%__MODULE__{
environment: environment,
staging_gate: staging_gate,
features: features,
groups: Map.new(@group_names, &{&1, :ready}),
hot_load_allowlist: allowlist,
hot_load_examples: examples
}}
end
end
def validate(_settings), do: error(:application_environment, "must be a map or keyword list")
@spec feature_enabled?(atom()) :: boolean()
def feature_enabled?(feature), do: feature_enabled?(current!(), feature)
@spec feature_enabled?(t(), atom()) :: boolean()
def feature_enabled?(%__MODULE__{features: features}, feature),
do: Map.fetch!(features, feature)
@doc """
Whether surfaces built for people working on the application are advertised.
The component library documents the parts a page is assembled from rather
than anything a visitor came for, so production does not link to it. This
hides the link, not the route: `/components` still resolves, because a
surface that is reachable but unadvertised is honest, while a route that
disappears between environments is a difference that only shows up in
production.
"""
@spec internal_surfaces_visible?() :: boolean()
def internal_surfaces_visible?, do: internal_surfaces_visible?(current!())
@spec internal_surfaces_visible?(t()) :: boolean()
def internal_surfaces_visible?(%__MODULE__{environment: environment}),
do: environment != :production
@spec fetch_secret(:openai_api_key | :openrouter_api_key) ::
{:ok, String.t()} | {:error, :not_configured}
def fetch_secret(secret)
when secret in [
:openai_api_key,
:openrouter_api_key,
:gemini_api_key,
:vercel_gateway_api_key
] do
case Application.fetch_env(:openagents, secret) do
{:ok, value} when is_binary(value) and byte_size(value) > 0 -> {:ok, value}
_missing -> {:error, :not_configured}
end
end
@spec readiness_report(t()) :: map()
def readiness_report(config \\ current!()) do
%{
"schema" => "openagents.runtime_configuration.v1",
"status" => "ready",
"environment" => Atom.to_string(config.environment),
"staging_gate" => config.staging_gate,
"groups" =>
Map.new(config.groups, fn {name, status} -> {to_string(name), to_string(status)} end),
"features" =>
Map.new(config.features, fn {name, enabled?} -> {to_string(name), enabled?} end)
}
end
@spec print_readiness!() :: :ok
def print_readiness! do
load!()
|> verify_compiled_settings!()
|> readiness_report()
|> Jason.encode!()
|> IO.puts()
end
@spec verify_startup!(t(), Snapshot.t()) :: :ok
def verify_startup!(%__MODULE__{} = config, %Snapshot{} = snapshot) do
if feature_enabled?(config, :tools) and map_size(snapshot.tools) == 0 do
raise ArgumentError, "runtime self-test failed: tools catalog is empty"
end
Enum.each(config.hot_load_examples, fn {module_name, expected?} ->
if HotLoader.allowlisted?(module_name, config.hot_load_allowlist) != expected? do
raise ArgumentError,
"runtime self-test failed: forge_hot_load_examples classification mismatch"
end
end)
:ok
end
defp validate_production_lock(settings, :production) do
case Map.fetch(settings, :production_deploy_enabled) do
{:ok, true} -> :ok
_locked -> error(:production_deploy_enabled, "must be explicitly true for production")
end
end
defp validate_production_lock(_settings, _environment), do: :ok
defp validate_staging_cleanup(settings, environment, staging_gate) do
case Map.get(settings, :staging_cleanup_enabled) do
false ->
:ok
true when environment == :staging and staging_gate >= 12 ->
:ok
true ->
error(:staging_cleanup_enabled, "is admitted only in staging at Gate 12 or later")
_invalid ->
error(:staging_cleanup_enabled, "must be a boolean")
end
end
defp validate_release_identity(settings, environment, staging_gate) do
required? = environment == :production or (environment == :staging and staging_gate >= 12)
revision = Map.get(settings, :build_revision)
image_digest = Map.get(settings, :image_digest)
cond do
not required? ->
:ok
not exact_sha?(revision) ->
error(:build_revision, "must identify the exact packaged Git commit")
not image_digest?(image_digest) ->
error(:image_digest, "must identify the immutable packaged image")
true ->
:ok
end
end
defp verify_compiled_settings!(%__MODULE__{environment: environment} = config)
when environment in [:staging, :production] do
if keyword_value(OpenAgentsWeb.Endpoint.session_options(), :secure) == true do
config
else
raise ArgumentError, "runtime configuration invalid: secure_cookies must be compiled true"
end
end
defp verify_compiled_settings!(%__MODULE__{} = config), do: config
defp validate_endpoint(settings, environment) do
endpoint = Map.get(settings, OpenAgentsWeb.Endpoint, [])
url = keyword_value(endpoint, :url, [])
host = keyword_value(url, :host)
check_origin = keyword_value(endpoint, :check_origin)
secure_cookies = Map.get(settings, :secure_cookies)
aliases = Map.get(settings, :https_aliases)
cond do
not hostname?(host) ->
error(:endpoint_host, "must be a valid hostname")
not (is_list(aliases) and Enum.all?(aliases, &hostname?/1)) ->
error(:https_aliases, "must be a list of hostnames")
environment in [:staging, :production] and secure_cookies != true ->
error(:secure_cookies, "must be true")
environment == :staging and host != "staging.openagents.com" ->
error(:endpoint_host, "must be the staging hostname")
environment in [:staging, :production] and
not valid_origins?(check_origin, host, aliases) ->
error(:allowed_origins, "must contain every exact HTTPS public origin")
true ->
:ok
end
end
defp validate_database(settings, environment) do
repo = Map.get(settings, OpenAgents.Repo, [])
migrate? = Map.get(settings, :migrate_on_boot)
connection? =
case keyword_value(repo, :url) do
value when is_binary(value) -> database_url?(value)
nil -> socket_database?(repo)
_invalid -> false
end
cond do
not connection? ->
error(:database_connection, "must be a PostgreSQL URL or complete socket configuration")
environment in [:staging, :production] and migrate? != true ->
error(:migrate_on_boot, "must be true")
true ->
:ok
end
end
defp validate_github(settings, environment) do
oauth = Map.get(settings, :github_oauth, [])
client_id = keyword_value(oauth, :client_id)
client_secret = keyword_value(oauth, :client_secret)
redirect_uri = keyword_value(oauth, :redirect_uri)
scopes = Map.get(settings, :github_oauth_scopes)
token_key = Map.get(settings, :github_token_encryption_key)
token_key_id = Map.get(settings, :github_token_encryption_key_id)
decryption_keys = Map.get(settings, :github_token_decryption_keys)
machine_token_key = Map.get(settings, :machine_token_encryption_key)
with :ok <- ensure(present?(client_id), :github_oauth_client_id, "is required"),
:ok <- ensure(present?(client_secret), :github_oauth_client_secret, "is required"),
:ok <- validate_redirect(redirect_uri, environment),
:ok <-
ensure(
scopes == ["user:email"],
:github_oauth_scopes,
"must contain only user email access"
),
:ok <-
ensure(
encryption_key?(token_key),
:github_token_encryption_key,
"must be a base64-encoded 32-byte key"
),
:ok <-
ensure(
token_key_id_for_environment?(token_key_id, environment),
:github_token_encryption_key_id,
"must be a bounded key identifier prefixed for the runtime environment"
),
:ok <-
ensure(
decryption_keyring?(decryption_keys, token_key_id, environment),
:github_token_decryption_keys,
"must contain only bounded identifiers and base64-encoded 32-byte keys"
),
# The machine pairing vault's own key, validated beside the keyring
# it historically borrowed from. `config/runtime.exs` bridges an
# unset `MACHINE_TOKEN_ENCRYPTION_KEY` to the GitHub key, so an
# absent value here means both are missing (VAULT-001, #192).
# Presence alone cannot tell a provisioned key from a bridged one;
# `validate_vault_separation/2` is what does (#253).
:ok <-
ensure(
encryption_key?(machine_token_key),
:machine_token_encryption_key,
"must be a base64-encoded 32-byte key"
) do
:ok
end
end
# The vaults VAULT-001 keeps apart, in the order a collision is reported.
# The GitHub vault comes first because it holds the key the others
# historically borrowed, so a duplicate is named against the borrower.
@vault_key_settings [
:github_token_encryption_key,
:machine_token_encryption_key,
:voice_recording_encryption_key,
:content_encryption_key
]
# Each vault seals under its own key (VAULT-001), and until this check
# nothing enforced the "own" half. `config/runtime.exs` bridges an unset
# `MACHINE_TOKEN_ENCRYPTION_KEY` to the GitHub vault's active key so the
# deploy that introduced the variable could boot before an operator
# provisioned the secret. A bridged boot presents a perfectly valid
# 32-byte key that belongs to another vault, so the presence check above
# passes and a GitHub rotation quietly moves the pairing vault with it —
# correct on the day it shipped, wrong a week later, and indistinguishable
# from correct without reading two values by hand (#192, #253).
#
# Distinctness is the predicate rather than "did the bridge fire", because
# it also catches an operator who provisions the dedicated secret with the
# GitHub vault's value, which is the same defect by a different route.
#
# This lives here rather than in `config/runtime.exs` on purpose. The
# bridge is deliberate and still assembles a complete configuration, so
# configuration evaluation keeps working everywhere; what changes is that
# a staging or production node refuses to *serve* on a borrowed key, at
# the declared fail-closed boundary, with the setting named. Development
# and test are exempt: both already configure distinct keys, and a shared
# key there strands nothing a person would retry. That is the same posture
# the content vault's key already takes in `validate_features/3`.
#
# Only configured keys are compared. An absent key is another check's
# error, and a vault whose feature is off has none.
defp validate_vault_separation(settings, environment)
when environment in [:staging, :production] do
@vault_key_settings
|> Enum.map(&{&1, Map.get(settings, &1)})
|> Enum.filter(fn {_setting, key} -> encryption_key?(key) end)
|> Enum.reduce_while(%{}, fn {setting, key}, seen ->
case Map.fetch(seen, key) do
{:ok, owner} -> {:halt, {setting, owner}}
:error -> {:cont, Map.put(seen, key, setting)}
end
end)
|> case do
{setting, owner} ->
error(setting, "must differ from #{owner}: no vault seals under another vault's key")
seen when is_map(seen) ->
:ok
end
end
defp validate_vault_separation(_settings, _environment), do: :ok
defp token_key_id?(key_id) when is_binary(key_id),
do: String.match?(key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/)
defp token_key_id?(_key_id), do: false
defp token_key_id_for_environment?(key_id, environment) do
token_key_id?(key_id) and String.starts_with?(key_id, Atom.to_string(environment) <> "-")
end
defp decryption_keyring?(keys, active_key_id, environment) when is_map(keys) do
map_size(keys) <= 16 and not Map.has_key?(keys, active_key_id) and
Enum.all?(keys, fn {key_id, key} ->
token_key_id_for_environment?(key_id, environment) and encryption_key?(key)
end)
end
defp decryption_keyring?(_keys, _active_key_id, _environment), do: false
defp exact_sha?(value) when is_binary(value),
do: Regex.match?(~r/\A[0-9a-f]{40}\z/, value)
defp exact_sha?(_value), do: false
defp image_digest?(value) when is_binary(value),
do: Regex.match?(~r/\Asha256:[0-9a-f]{64}\z/, value)
defp image_digest?(_value), do: false
defp validate_features(settings, environment, staging_gate) do
with {:ok, tools?} <- required_boolean(settings, :tools_enabled),
{:ok, voice?} <- nested_boolean(settings, :voice, :enabled),
{:ok, recording?} <- nested_boolean(settings, :voice_recording, :enabled),
{:ok, work?} <- nested_boolean(settings, :work, :enabled),
{:ok, semantic?} <- nested_boolean(settings, :semantic_index, :enabled),
{:ok, experience?} <- nested_boolean(settings, :experience_memory, :enabled),
{:ok, graph?} <- nested_boolean(settings, :graph_memory, :enabled),
{:ok, portability?} <- nested_boolean(settings, :memory_portability, :enabled),
{:ok, shadow?} <- nested_boolean(settings, :shadow_programs, :enabled),
{:ok, tool_embeddings?} <-
nested_boolean(settings, :tool_discovery, :embeddings_enabled),
{:ok, forge?} <- required_boolean(settings, :forge_enabled),
{:ok, forge_deploy?} <- required_boolean(settings, :forge_deploy_lane_enabled),
{:ok, boot_convergence?} <- required_boolean(settings, :forge_boot_converge_enabled),
{:ok, turn_recovery?} <- required_boolean(settings, :turn_recovery_enabled),
{:ok, voice_retention?} <- required_boolean(settings, :voice_retention_enabled),
{:ok, deployments?} <- required_boolean(settings, :deployment_control_plane_enabled),
{:ok, voice_recovery?} <-
required_boolean(settings, :voice_recovery_worker_enabled),
{:ok, work_workers?} <- required_boolean(settings, :work_workers_enabled),
{:ok, computers?} <- required_boolean(settings, :computer_controller_enabled),
{:ok, conversation_reset?} <-
required_boolean(settings, :conversation_reset_enabled),
{:ok, incident_fixer?} <- required_boolean(settings, :incident_fixer_enabled),
{:ok, scv_deploy?} <- nested_boolean(settings, :scv_deploy, :enabled),
{:ok, ra?} <- required_boolean(settings, :ra_enabled) do
features = %{
tools: tools?,
voice: voice?,
voice_recording: recording?,
voice_retention: voice_retention?,
work: work?,
computers: computers?,
semantic_memory: semantic?,
experience_memory: experience?,
graph_memory: graph?,
memory_portability: portability?,
shadow_programs: shadow?,
tool_embeddings: tool_embeddings?,
forge: forge?,
forge_deploy: forge_deploy?,
boot_convergence: boot_convergence?,
turn_recovery: turn_recovery?,
deployment_control_plane: deployments?,
voice_recovery: voice_recovery?,
work_workers: work_workers?,
conversation_reset: conversation_reset?,
incident_fixer: incident_fixer?,
scv_deploy: scv_deploy?,
ra: ra?
}
case validate_feature_combinations(features, settings, environment, staging_gate) do
:ok -> {:ok, features}
{:error, _details} = error -> error
end
end
end
defp validate_feature_combinations(features, settings, environment, staging_gate) do
early_staging_features =
Enum.filter(
[
:voice,
:voice_recording,
:work,
:computers,
:semantic_memory,
:experience_memory,
:graph_memory,
:memory_portability,
:shadow_programs,
:tool_embeddings,
:conversation_reset,
:incident_fixer,
:scv_deploy
],
&features[&1]
)
cond do
not valid_feature_domains?(settings) ->
error(:feature_configuration, "contains invalid limits or provider settings")
environment in [:staging, :production] and features.voice_recording and
not features.voice ->
error(:voice_recording, "cannot be enabled while voice is disabled")
features.voice_recording and
not encryption_key?(Map.get(settings, :voice_recording_encryption_key)) ->
error(:voice_recording_encryption_key, "is required when recording is enabled")
# The content vault's own key (VAULT-001, issue #193). Without it a voice
# transcript, a compaction summary, a preference observation, and a
# project note all refuse to be written rather than being written
# readable, so a node missing this key is a node that cannot serve —
# which is worth finding at boot rather than at the first write.
environment in [:staging, :production] and
not encryption_key?(Map.get(settings, :content_encryption_key)) ->
error(:content_encryption_key, "must be a base64-encoded 32-byte key")
features.voice_retention and not features.voice_recording ->
error(:voice_retention_enabled, "requires voice recording")
features.voice_recovery and not features.voice ->
error(:voice_recovery_worker_enabled, "requires voice")
features.work_workers != features.work ->
error(:work_workers_enabled, "must match the work feature")
environment in [:staging, :production] and features.computers and
not clean_service_url?(Map.get(settings, :inference_proxy_url)) ->
error(:inference_proxy_url, "must be an HTTPS URL without credentials")
environment in [:staging, :production] and
(features.work or features.computers) and
not durable_path?(Map.get(settings, :coding_jobs_dir)) ->
error(:coding_jobs_dir, "must be durable when work or computers are enabled")
features.incident_fixer and not features.computers ->
error(:incident_fixer_enabled, "requires computers")
# An SCV deployment IS a work job (SCV-001): without the work lane there
# is no durable row, no recovery sweep, and no report back into the
# conversation, so the run would spend our capacity unaccountably.
features.scv_deploy and not features.work ->
error(:scv_deploy, "requires the work lane")
features.scv_deploy and not features.tools ->
error(:scv_deploy, "requires tools")
features.scv_deploy and not valid_scv_deploy?(Map.get(settings, :scv_deploy)) ->
error(:scv_deploy, "requires an admitted model, bounds, and output root")
# An SCV clones the repository before it runs, and that clone is the
# largest thing the lane writes. `System.tmp_dir!()` inside a container
# is the writable image layer on the boot disk, which the node already
# shares with Docker and the import workspace; a repository landing
# there is how a node runs out of room while its durable volume idles.
environment in [:staging, :production] and
(features.scv_deploy or Map.get(settings, :scv_codex, [])[:enabled] == true) and
not durable_path?(keyword_value(Map.get(settings, :scv_codex), :temporary_root)) ->
error(:scv_temporary_root, "must be durable when an SCV lane is enabled")
features.forge_deploy and not features.forge ->
error(:forge_deploy_lane_enabled, "requires the forge")
features.boot_convergence and not features.forge_deploy ->
error(:forge_boot_converge_enabled, "requires the forge deployment lane")
environment == :staging and staging_gate < 14 and early_staging_features != [] ->
error(
:staging_gate,
"does not admit advanced product features"
)
environment == :staging and staging_gate < 13 and
(features.forge_deploy or features.boot_convergence) ->
error(:staging_gate, "does not admit forge deployment or boot convergence")
environment == :staging and staging_gate < 12 and features.ra ->
error(:staging_gate, "does not admit Ra")
true ->
:ok
end
end
defp validate_providers(settings, features) do
provider = Map.get(settings, :provider)
model = Map.get(settings, :openai_model)
openai_key = Map.get(settings, :openai_api_key)
voice = Map.get(settings, :voice, [])
shadow = Map.get(settings, :shadow_programs, [])
semantic = Map.get(settings, :semantic_index, [])
needs_openai? =
provider == OpenAgents.Providers.OpenAI or features.voice or features.shadow_programs or
features.semantic_memory or features.tool_embeddings
cond do
not is_atom(provider) ->
error(:provider, "must be a module")
not (is_binary(model) and byte_size(model) in 1..128) ->
error(:openai_model, "must be a bounded model identifier")
needs_openai? and not present?(openai_key) ->
error(:openai_api_key, "is required by an enabled OpenAI provider")
not valid_voice_config?(voice) ->
error(:voice, "has invalid admission settings")
not valid_shadow_config?(shadow) ->
error(:shadow_programs, "has invalid settings")
not valid_semantic_config?(semantic) ->
error(:semantic_index, "has invalid settings")
true ->
:ok
end
end
defp validate_forge(settings, environment, features) do
repos = Map.get(settings, :forge_repos)
owners = Map.get(settings, :forge_repo_owners)
visibility = Map.get(settings, :forge_public_visibility)
paths = Map.get(settings, :forge_public_paths)
internal_url = Map.get(settings, :forge_internal_git_url)
allowlist = Map.get(settings, :forge_hot_load_allowlist)
examples = Map.get(settings, :forge_hot_load_examples)
expected_fleet_size = Map.get(settings, :forge_expected_fleet_size)
artifact_store = Map.get(settings, :forge_artifact_store)
build_executor = Map.get(settings, :forge_build_executor)
deploy_timeout_ms = Map.get(settings, :forge_deploy_timeout_ms)
deploy_token_ttl_ms = Map.get(settings, :forge_deploy_token_ttl_ms)
boot_retry_min_ms = Map.get(settings, :forge_boot_retry_min_ms)
boot_retry_max_ms = Map.get(settings, :forge_boot_retry_max_ms)
operator_token = Map.get(settings, :forge_operator_token)
mirror_urls = Map.get(settings, :forge_mirror_urls)
rolling_provider = Map.get(settings, :forge_rolling_provider)
rolling_provider_config = Map.get(settings, Gcp, [])
durable_required? = environment in [:staging, :production] and features.forge
with :ok <-
ensure(valid_repositories?(repos), :forge_repos, "must list valid repository names"),
:ok <-
ensure(
target_repository?(repos, owners, visibility, paths),
:forge_target_repository,
"must be OpenAgentsInc/openagents.com"
),
:ok <-
ensure(
clean_internal_url?(internal_url),
:forge_internal_git_url,
"must be an HTTP URL without credentials"
),
:ok <-
ensure(
clean_mirror_urls?(mirror_urls),
:forge_mirror_urls,
"must contain only credential-free git remote URLs or paths"
),
:ok <-
ensure(
valid_allowlist?(allowlist),
:forge_hot_load_allowlist,
"must contain module names or prefixes"
),
:ok <-
ensure(
valid_examples?(examples, allowlist),
:forge_hot_load_examples,
"must classify every example as configured"
),
:ok <-
ensure(
is_integer(expected_fleet_size) and expected_fleet_size in 1..100,
:forge_expected_fleet_size,
"must be between 1 and 100"
),
:ok <-
ensure(
artifact_store in [:local, :gcs],
:forge_artifact_store,
"must be a supported store"
),
:ok <-
ensure(
is_atom(build_executor),
:forge_build_executor,
"must be a module"
),
:ok <-
ensure(
is_integer(deploy_timeout_ms) and deploy_timeout_ms in 1_000..120_000,
:forge_deploy_timeout_ms,
"must be between 1 and 120 seconds"
),
:ok <-
ensure(
is_integer(deploy_token_ttl_ms) and deploy_token_ttl_ms in 30_000..1_800_000 and
deploy_token_ttl_ms >= deploy_timeout_ms * 8,
:forge_deploy_token_ttl_ms,
"must cover eight deployment phase timeouts"
),
:ok <-
ensure(
is_integer(boot_retry_min_ms) and is_integer(boot_retry_max_ms) and
boot_retry_min_ms in 100..60_000 and boot_retry_max_ms in 1_000..300_000 and
boot_retry_min_ms <= boot_retry_max_ms,
:forge_boot_retry_bounds,
"must define an ordered bounded backoff"
),
:ok <-
ensure(
not features.forge_deploy or expected_fleet_size >= 2,
:forge_expected_fleet_size,
"must include a canary and peer for deployment"
),
:ok <-
validate_rolling_provider(
rolling_provider,
rolling_provider_config,
features,
environment
),
:ok <- validate_forge_secrets(operator_token, durable_required? or features.forge_deploy),
:ok <- validate_forge_paths(settings, durable_required? or features.forge_deploy),
:ok <- validate_wal(settings, durable_required? or features.forge_deploy) do
{:ok, allowlist, examples}
end
end
defp validate_rolling_provider(_provider, _config, %{forge_deploy: false}, _environment),
do: :ok
defp validate_rolling_provider(nil, _config, %{forge_deploy: true}, :production), do: :ok
defp validate_rolling_provider(Gcp, config, %{forge_deploy: true}, _environment) do
case Gcp.validate_config(config) do
:ok -> :ok
{:error, _reason} -> error(:forge_rolling_provider, "must use an isolated staging project")
end
end
defp validate_rolling_provider(_provider, _config, %{forge_deploy: true}, _environment),
do: error(:forge_rolling_provider, "must be the admitted infrastructure provider")
defp validate_forge_secrets(operator_token, true) do
if present?(operator_token),
do: :ok,
else: error(:forge_operator_token, "is required while the forge is enabled")
end
defp validate_forge_secrets(_operator_token, false), do: :ok
defp validate_forge_paths(settings, true) do
keys = [:forge_data_dir, :forge_build_dir, :forge_build_queue_dir, :forge_artifact_dir]
if Enum.all?(keys, &durable_path?(Map.get(settings, &1))) do
:ok
else
error(:forge_storage_paths, "must be absolute and outside temporary storage")
end
end
defp validate_forge_paths(_settings, false), do: :ok
defp validate_wal(settings, true) do
case Map.get(settings, :forge_wal_adapter) do
OpenAgents.Forge.WAL.Local ->
if durable_path?(Map.get(settings, :forge_wal_dir)),
do: :ok,
else: error(:forge_wal_dir, "must be durable for the local WAL")
OpenAgents.Forge.WAL.Gcs ->
if present?(Map.get(settings, :forge_wal_bucket)) and
is_atom(Map.get(settings, :forge_gcs_token_provider)),
do: :ok,
else: error(:forge_wal, "requires a bucket and token provider")
_invalid ->
error(:forge_wal_adapter, "must be a supported adapter")
end
end
defp validate_wal(_settings, false), do: :ok
defp validate_cluster(settings, environment, features) do
horde? = Map.get(settings, :horde_enabled)
expected = Map.get(settings, :ra_expected_size)
data_dir = Map.get(settings, :ra_data_dir)
dns_query = Map.get(settings, :dns_cluster_query)
distribution = Map.get(settings, :distribution, [])
distributed_feature? = features.ra or features.forge_deploy
cond do
not is_boolean(horde?) ->
error(:horde_enabled, "must be boolean")
not (is_integer(expected) and expected in 1..100) ->
error(:ra_expected_size, "must be between 1 and 100")
features.ra and expected < 3 ->
error(:ra_expected_size, "must admit a quorum")
features.ra and environment in [:staging, :production] and not durable_path?(data_dir) ->
error(:ra_data_dir, "must be durable")
features.ra and not present?(dns_query) ->
error(:dns_cluster_query, "is required when Ra is enabled")
distributed_feature? and not horde? ->
error(:horde_enabled, "is required for distributed features")
features.forge_deploy and not present?(dns_query) ->
error(:dns_cluster_query, "is required for fleet deployment")
distributed_feature? and not valid_distribution?(distribution) ->
error(:distribution, "must configure node name, cookie, and bounded ports")
true ->
:ok
end
end
defp required_enum(settings, key, allowed) do
case Map.fetch(settings, key) do
{:ok, value} ->
if value in allowed,
do: {:ok, value},
else: error(key, "must be one of the admitted values")
_invalid ->
error(key, "must be one of the admitted values")
end
end
defp required_integer(settings, key, range) do
case Map.fetch(settings, key) do
{:ok, value} when is_integer(value) ->
if value in range,
do: {:ok, value},
else: error(key, "must be within the admitted range")
_invalid ->
error(key, "must be within the admitted range")
end
end
defp required_boolean(settings, key) do
case Map.fetch(settings, key) do
{:ok, value} when is_boolean(value) -> {:ok, value}
_invalid -> error(key, "must be boolean")
end
end
defp nested_boolean(settings, key, nested_key) do
case Map.fetch(settings, key) do
{:ok, values} when is_list(values) ->
case Keyword.fetch(values, nested_key) do
{:ok, value} when is_boolean(value) -> {:ok, value}
_invalid -> error(key, "must contain a boolean #{nested_key}")
end
_invalid ->
error(key, "must be a keyword list")
end
end
defp validate_redirect(redirect_uri, environment) do
case URI.new(redirect_uri) do
{:ok,
%URI{
scheme: scheme,
host: host,
path: "/auth/github/callback",
query: nil,
fragment: nil,
userinfo: nil
}}
when is_binary(host) and
(scheme == "https" or
(environment in [:development, :test] and scheme == "http" and
host in ["127.0.0.1", "localhost"])) ->
:ok
_invalid ->
error(:github_oauth_redirect_uri, "must be an exact admitted callback")
end
end
defp valid_origins?(origins, host, aliases) when is_list(origins) and is_list(aliases) do
required = Enum.map([host | aliases], &"https://#{&1}")
Enum.all?(origins, &https_origin?/1) and Enum.all?(required, &(&1 in origins))
end
defp valid_origins?(_origins, _host, _aliases), do: false
defp https_origin?(origin) do
case URI.new(origin) do
{:ok,
%URI{scheme: "https", host: host, path: path, query: nil, fragment: nil, userinfo: nil}}
when is_binary(host) and host != "" and path in [nil, "", "/"] ->
true
_invalid ->
false
end
end
defp database_url?(url) do
case URI.new(url) do
{:ok, %URI{scheme: scheme, host: host}} when scheme in ["ecto", "postgres", "postgresql"] ->
present?(host)
_invalid ->
false
end
end
defp socket_database?(settings) do
Enum.all?([:username, :database, :socket_dir], &present?(keyword_value(settings, &1))) and
is_integer(keyword_value(settings, :pool_size))
end
defp valid_voice_config?(settings) when is_list(settings) do
architecture = keyword_value(settings, :architecture)
provider = keyword_value(settings, :provider)
model = keyword_value(settings, :model)
voice = keyword_value(settings, :voice)
effort = keyword_value(settings, :reasoning_effort)
duration = keyword_value(settings, :maximum_session_seconds)
architecture == :openai_realtime and provider == "openai" and model == "gpt-realtime-2.1" and
voice in ["marin", "cedar"] and effort in ["low", "medium", "high"] and
is_integer(duration) and duration in 1..3_300
end
defp valid_voice_config?(_settings), do: false
defp valid_shadow_config?(settings) when is_list(settings) do
is_boolean(keyword_value(settings, :enabled)) and is_atom(keyword_value(settings, :provider)) and
is_integer(keyword_value(settings, :timeout_ms)) and
keyword_value(settings, :timeout_ms) in 100..60_000
end
defp valid_shadow_config?(_settings), do: false
defp valid_semantic_config?(settings) when is_list(settings) do
is_boolean(keyword_value(settings, :enabled)) and is_atom(keyword_value(settings, :provider)) and
present?(keyword_value(settings, :model_id)) and
present?(keyword_value(settings, :model_version)) and
is_integer(keyword_value(settings, :dimensions)) and
keyword_value(settings, :dimensions) in 1..4_096 and
is_integer(keyword_value(settings, :batch_size)) and
keyword_value(settings, :batch_size) in 1..1_000 and
is_integer(keyword_value(settings, :poll_interval_ms)) and
keyword_value(settings, :poll_interval_ms) in 100..60_000 and
is_integer(keyword_value(settings, :provider_timeout_ms)) and
keyword_value(settings, :provider_timeout_ms) in 100..120_000 and
is_integer(keyword_value(settings, :lease_ms)) and
keyword_value(settings, :lease_ms) in keyword_value(settings, :provider_timeout_ms)..300_000
end
defp valid_semantic_config?(_settings), do: false
defp valid_feature_domains?(settings) do
recording = Map.get(settings, :voice_recording, [])
experience = Map.get(settings, :experience_memory, [])
graph = Map.get(settings, :graph_memory, [])
discovery = Map.get(settings, :tool_discovery, [])
maximum_chunk_bytes = keyword_value(recording, :maximum_chunk_bytes)
maximum_bytes = keyword_value(recording, :maximum_bytes)
keyword_integer_in?(recording, :timeslice_ms, 250..60_000) and
keyword_integer_in?(recording, :maximum_chunk_bytes, 1..10_485_760) and
keyword_integer_in?(recording, :maximum_chunks, 1..4_096) and
is_integer(maximum_bytes) and is_integer(maximum_chunk_bytes) and
maximum_bytes in maximum_chunk_bytes..10_737_418_240 and
keyword_integer_in?(recording, :late_chunk_grace_seconds, 1..3_600) and
keyword_integer_in?(recording, :retention_days, 1..365) and
keyword_integer_in?(experience, :maximum_records, 1..50) and
keyword_integer_in?(experience, :maximum_patterns, 1..20) and
keyword_integer_in?(experience, :maximum_bytes, 256..65_536) and
keyword_integer_in?(graph, :maximum_nodes, 1..10_000) and
keyword_integer_in?(graph, :maximum_depth, 1..20) and
keyword_integer_in?(graph, :maximum_export_artifacts, 1..100_000) and
is_boolean(keyword_value(discovery, :embeddings_enabled)) and
is_atom(keyword_value(discovery, :provider)) and
present?(keyword_value(discovery, :model_id)) and
present?(keyword_value(discovery, :model_version)) and
keyword_integer_in?(discovery, :dimensions, 1..4_096) and
keyword_integer_in?(discovery, :top_k, 1..64) and
integer_setting_in?(settings, :conversation_page_size, 1..200) and
integer_setting_in?(settings, :maximum_message_bytes, 1..1_048_576) and
integer_setting_in?(settings, :turn_rate_limit, 1..10_000) and
integer_setting_in?(settings, :inference_grant_max_total_tokens, 1..100_000_000) and
integer_setting_in?(settings, :inference_grant_max_calls, 1..10_000) and
integer_setting_in?(settings, :inference_grant_max_cost_microusd, 1..1_000_000_000) and
integer_setting_in?(settings, :inference_grant_ttl_seconds, 1..86_400) and
integer_setting_in?(settings, :machine_token_ttl_seconds, 300..2_592_000)
end
defp keyword_integer_in?(settings, key, range) do
value = keyword_value(settings, key)
is_integer(value) and value in range
end
defp integer_setting_in?(settings, key, range) do
value = Map.get(settings, key)
is_integer(value) and value in range
end
defp valid_repositories?(repos) when is_list(repos) and repos != [] do
Enum.all?(repos, &Regex.match?(~r/\A[a-z0-9][a-z0-9._-]{0,63}\z/, &1)) and
length(repos) == length(Enum.uniq(repos))
end
defp valid_repositories?(_repos), do: false
defp target_repository?(repos, owners, visibility, paths)
when is_list(repos) and is_map(owners) and is_map(visibility) and is_map(paths) do
@target_repository in repos and owners[@target_repository] == @target_owner and
Map.has_key?(visibility, @target_repository) and Map.has_key?(paths, @target_repository)
end
defp target_repository?(_repos, _owners, _visibility, _paths), do: false
defp clean_internal_url?(url) do
case URI.new(url) do
{:ok, %URI{scheme: scheme, host: host, userinfo: nil}}
when scheme in ["http", "https"] and is_binary(host) ->
true
_invalid ->
false
end
end
defp clean_mirror_urls?(urls) when is_map(urls) do
Enum.all?(urls, fn {repo, url} ->
is_binary(repo) and clean_mirror_url?(url)
end)
end
defp clean_mirror_urls?(_urls), do: false
defp clean_mirror_url?(url) when is_binary(url) and byte_size(url) in 1..2_048 do
cond do
String.contains?(url, ["\n", "\r", "\0"]) ->
false
Path.type(url) == :absolute ->
true
true ->
case URI.new(url) do
{:ok, %URI{scheme: scheme, host: host, userinfo: userinfo}}
when scheme in ["http", "https", "git", "ssh"] and is_binary(host) ->
is_nil(userinfo) or (scheme == "ssh" and clean_ssh_username?(userinfo))
_invalid ->
false
end
end
end
defp clean_mirror_url?(_url), do: false
defp clean_ssh_username?(userinfo) do
is_binary(userinfo) and userinfo != "" and
not String.contains?(userinfo, [":", "@", "/", "\\"])
end
defp clean_service_url?(url) do
case URI.new(url) do
{:ok, %URI{scheme: "https", host: host, userinfo: nil}} when is_binary(host) -> true
_invalid -> false
end
end
defp valid_allowlist?(allowlist) when is_list(allowlist) and allowlist != [] do
Enum.all?(allowlist, fn entry ->
is_binary(entry) and Regex.match?(~r/\A[A-Z][A-Za-z0-9_.]*\z/, entry) and
not String.contains?(entry, ["/", "\\"])
end)
end
defp valid_allowlist?(_allowlist), do: false
defp valid_examples?(examples, allowlist) when is_map(examples) do
map_size(examples) >= 2 and
Enum.all?(examples, fn {module_name, expected?} ->
is_binary(module_name) and is_boolean(expected?) and
HotLoader.allowlisted?(module_name, allowlist) == expected?
end)
end
defp valid_examples?(_examples, _allowlist), do: false
defp valid_distribution?(settings) when is_list(settings) do
min = keyword_value(settings, :port_min)
max = keyword_value(settings, :port_max)
keyword_value(settings, :enabled) == true and
keyword_value(settings, :node_configured) == true and
keyword_value(settings, :cookie_configured) == true and is_integer(min) and
is_integer(max) and min in 1_024..65_535 and max in min..65_535 and max - min <= 100
end
defp valid_distribution?(_settings), do: false
defp hostname?(value) when is_binary(value) do
byte_size(value) in 1..253 and
Regex.match?(
~r/\A(?=.{1,253}\z)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\z/,
value
)
end
defp hostname?(_value), do: false
defp encryption_key?(value) when is_binary(value) do
match?({:ok, key} when byte_size(key) == 32, Base.decode64(value))
end
defp encryption_key?(_value), do: false
# The SCV deploy lane spends our own capacity, so its ceiling is configuration
# rather than a runtime argument: a bounded model slug, a bounded concurrency
# limit, a wall clock, an output cap, and a durable place to keep artifacts.
defp valid_scv_deploy?(settings) when is_list(settings) do
model = keyword_value(settings, :model)
is_binary(model) and byte_size(model) in 3..128 and
Regex.match?(~r{\A[a-zA-Z0-9_.:-]+/[a-zA-Z0-9_.:-]+\z}, model) and
keyword_integer_in?(settings, :concurrency_limit, 1..8) and
keyword_integer_in?(settings, :wall_clock_ms, 60_000..3_600_000) and
keyword_integer_in?(settings, :maximum_output_bytes, 65_536..67_108_864) and
durable_path?(keyword_value(settings, :output_root))
end
defp valid_scv_deploy?(_settings), do: false
defp durable_path?(path) when is_binary(path) do
Path.type(path) == :absolute and path != "/" and
not (path == "/tmp" or String.starts_with?(path, "/tmp/"))
end
defp durable_path?(_path), do: false
defp present?(value), do: is_binary(value) and String.trim(value) != ""
defp keyword_value(values, key, default \\ nil)
defp keyword_value(values, key, default) when is_list(values),
do: Keyword.get(values, key, default)
defp keyword_value(_values, _key, default), do: default
defp ensure(true, _setting, _reason), do: :ok
defp ensure(false, setting, reason), do: error(setting, reason)
defp error(setting, reason), do: {:error, %{setting: setting, reason: reason}}
end