Configuration Precedence¶
On-demand reference for how SynthOrg resolves configuration values. The
short rule in CLAUDE.md is the contract; this page is the full
exception registry, source matrix, and rationale.
The rule¶
Three sources, in order, first match wins:
1. Settings Database (per-installation runtime override, set via /settings)
2. Environment Variable (deployment preset; docker-compose, K8s, .env)
3. Code Default (SettingDefinition.default)
The chain is implemented in synthorg.settings.service.SettingsService
and is the only sanctioned way to resolve a runtime-mutable
setting. Direct env-var reads in application code are forbidden except
for the documented bootstrap exceptions below.
YAML is not a precedence tier. The company.yaml file is an
ingestion format for company templates (charter, departments, agents,
workflows). Its contents flow into domain tables on synthorg init;
they do not participate in the settings chain.
The three categories¶
Every setting belongs to exactly one of three categories. The category determines which subset of the chain applies.
Category 1: Standard mutable¶
Default category. The full chain applies: a /settings runtime
override (DB row) wins over an env var, which wins over the registered
code default.
Examples: observability.root_log_level, observability.log_level_console,
api.lifecycle_cleanup_enabled, engine.timeout_enforcement_enabled.
Category 2: Compose-set¶
Registered with compose_set=True. The DB lookup is bypassed on reads, and
SettingsService.set(), set_many(), and delete() raise
SettingReadOnlyError rather than store a value the running process will
never read. delete_namespace() does not raise: a compose-set key in the
target namespace is logged as a WARNING (reason="compose_set_swept") and
skipped, so it cannot hold the writable overrides the operator wants to clear
hostage.
For these entries the precedence chain collapses to env > default.
The DB step is bypassed on reads (get, get_namespace, get_all,
get_page, get_versioned) regardless of whether a stale row exists,
because the running process resolves its bootstrap value once and
holds onto it; a row left over from a pre-rename schema or an ops
mistake on a peer node would otherwise surface a value the runtime no
longer honours. The /settings UI therefore reflects the actual
running value sourced from the env var or registered default at
first read.
The DB-bypass branch lives in src/synthorg/settings/service.py
inside get() (the if not definition.compose_set: guard around the
_resolve_db call) and is mirrored in _resolve_with_db_lookup for
batch reads (the if definition.compose_set: db_hit = None
short-circuit before the DB row is consulted).
Examples: api.server_port, api.server_host, api.api_prefix,
communication.nats_url, workers.count, observability.log_directory,
api.cors_allowed_origins, api.trusted_proxies,
api.rate_limiter_enabled.
Category 3: Bootstrap secret (init-time exception)¶
Read once at process start before SettingsService exists. No
registry entry. Pure env. The value is captured into a typed domain
object at the boot site (e.g. JwtSecret, CursorConfig,
SettingsEncryptor, persistence config) and never re-read.
Why not register them as Cat-2 with sensitive=True? Two reasons:
- Persistence URLs and credentials. Rotating DB credentials at
runtime through the settings UI exposes them to every operator
holding
settings:read, even whensensitive=Truemasks the displayed value. Env-only plus a secret backend is the safer pattern. - Bootstrap secrets. JWT secret, master key, pagination cursor secret, settings encryption key: all read once before the settings service exists. A registry entry would be inert for these.
Examples: SYNTHORG_DATABASE_URL, SYNTHORG_DB_PATH,
SYNTHORG_POSTGRES_SSL_MODE, SYNTHORG_CONFIG_PATH,
SYNTHORG_JWT_SECRET, SYNTHORG_MASTER_KEY,
SYNTHORG_PAGINATION_CURSOR_SECRET, SYNTHORG_SETTINGS_KEY.
Discoverability¶
Every (namespace, key) emits one INFO settings.value.resolved event
on its first cold read per process. The payload carries source
(db / env / default) so an operator can audit at startup which
surface supplied each value. Subsequent resolutions stay at DEBUG.
Category 3 secrets do not emit a settings.value.resolved event;
they are read directly at the boot site and logged via the
domain-specific startup event (e.g. API_APP_STARTUP,
SETTINGS_ENCRYPTOR_BOOTSTRAP).
Source matrix¶
Category 1 examples (DB > env > default)¶
| Setting | Env override | Notes |
|---|---|---|
observability.root_log_level |
SYNTHORG_OBSERVABILITY_ROOT_LOG_LEVEL |
Standard mutable. |
observability.log_level_console |
SYNTHORG_LOG_LEVEL |
Mutable; overrides the console sink only. |
telemetry.enabled |
SYNTHORG_TELEMETRY_ENABLED |
Mutable; the collector reads the env var at boot for the fast-path, and a DB write applies at once through TelemetrySettingsSubscriber. |
engine.timeout_enforcement_enabled |
SYNTHORG_ENGINE_TIMEOUT_ENFORCEMENT_ENABLED |
Mutable kill-switch. |
providers.model_refresh_mode |
SYNTHORG_PROVIDERS_MODEL_REFRESH_MODE |
Config discriminator for the periodic model-refresh subsystem (off / manual_only / detect_only / reconcile_recommend); off is the safe default. The scheduler re-reads it every tick (fail-safe to off), so mode changes apply without a restart. |
providers.model_refresh_interval_seconds |
SYNTHORG_PROVIDERS_MODEL_REFRESH_INTERVAL_SECONDS |
Cadence between automatic reconcile cycles (60s..604800s). Re-read by the scheduler each tick (like the mode), so a change applies on the next cycle without a restart. |
providers.model_refresh_auto_apply_within_family |
SYNTHORG_PROVIDERS_MODEL_REFRESH_AUTO_APPLY_WITHIN_FAMILY |
Opt-in (default off) auto-apply of strictly in-family upgrades; re-read every cycle. |
chief_of_staff.propose_enabled |
SYNTHORG_CHIEF_OF_STAFF_PROPOSE_ENABLED |
On-by-default conversational capability; live-gated per request via ensure_feature_enabled (no restart). The siblings explain_chat_enabled / group_chat_enabled / routing_enabled behave the same. |
chief_of_staff.alerts_enabled |
SYNTHORG_CHIEF_OF_STAFF_ALERTS_ENABLED |
Off-by-default autonomous capability (also: learning_enabled / narrative_enabled / invite_enabled). No restart: alerts_enabled is started/stopped live by ChiefOfStaffAlertsSettingsSubscriber; the others are gated per cycle/turn. Each additionally requires the persona master switch self_improvement.chief_of_staff_enabled. |
chief_of_staff.direct_mcp_enabled |
SYNTHORG_CHIEF_OF_STAFF_DIRECT_MCP_ENABLED |
Off-by-default autonomous MCP acting; hot-reloadable, still fail-closed. The subsystem reconciler rebuilds the actor through the same fail-closed builder on toggle, so the actor materialises only when security governance + the MCP self-consumer are wired on the boot engine (else it stays inert and the endpoint 503s). The fail-closed property moved from a restart bind to a per-rebuild governance re-check, so it no longer needs a restart. |
chief_of_staff.chat_model |
SYNTHORG_CHIEF_OF_STAFF_CHAT_MODEL |
Per-feature model for conversational turns (also propose_model / routing_model / narrative_model); read live per LLM call, no restart. Auto-filled at setup-complete when left blank. |
knowledge.enabled |
SYNTHORG_KNOWLEDGE_ENABLED |
On-by-default knowledge substrate; ghost-wired at boot and live-gated per request at the knowledge tools (no restart). The knowledge.synthesis_model / knowledge.synthesis_provider / knowledge.synthesis_synthesizer / knowledge.synthesis_max_chunks keys rebuild + swap the synthesiser via a subscriber; knowledge.synthesis_enabled remains live-gated at the entrypoint. |
research.enabled |
SYNTHORG_RESEARCH_ENABLED |
On-by-default research pipeline; ghost-wired at boot and live-gated per request at the research tools (no restart). The model lives in research.model (auto-filled at setup-complete); model / provider / strategy / threshold keys rebuild + swap the service via a subscriber. |
self_improvement.enabled |
SYNTHORG_SELF_IMPROVEMENT_ENABLED |
Off-by-default self-modification master switch; read live per cycle by run_cycle (with engine.evolution_enabled), so toggling it applies with no restart. The strategy toggles (config_tuning_enabled / architecture_proposals_enabled / prompt_tuning_enabled), tool_creation_enabled (+ its allowlist), and the analysis / code-mod models are likewise live. |
self_improvement.code_modification_enabled |
SYNTHORG_SELF_IMPROVEMENT_CODE_MODIFICATION_ENABLED |
Off-by-default self-modifying code; read live per cycle through the feature overlay. It additionally requires GitHub credentials in the meta.self_improvement blob: without them the next config load refuses and forces the flag back off, which surfaces the failure to an operator rather than leaving a silently un-applied switch. |
providers.tool_call_feedback_enabled |
SYNTHORG_PROVIDERS_TOOL_CALL_FEEDBACK_ENABLED |
Master switch for the runtime tool-call failure feedback loop (default true). Re-read live per observation by the ToolCallFeedbackTracker, so toggling it on/off applies without a restart while the sink stays installed. |
providers.tool_call_failure_threshold |
SYNTHORG_PROVIDERS_TOOL_CALL_FAILURE_THRESHOLD |
Decayed-score threshold (1..20, default 3) at which a model is downgraded (tool_calls_verified=False). Re-read on each failure. |
providers.tool_call_failure_decay_half_life_seconds |
SYNTHORG_PROVIDERS_TOOL_CALL_FAILURE_DECAY_HALF_LIFE_SECONDS |
Half-life (60s..86400s, default 3600s) over which a failure's weight halves, so a transient blip decays away rather than permanently downgrading a capable model. Re-read on each failure. |
Category 2 examples (env > default; DB bypassed)¶
| Setting | Env override | Notes |
|---|---|---|
api.server_host |
SYNTHORG_API_SERVER_HOST |
Consumed pre-init via bootstrap_resolver at app construction; registry entry for /settings discoverability. |
api.server_port |
SYNTHORG_API_SERVER_PORT |
Same as above. |
api.api_prefix |
SYNTHORG_API_API_PREFIX |
Same. |
api.cors_allowed_origins |
SYNTHORG_API_CORS_ALLOWED_ORIGINS |
Same. JSON-encoded list. |
api.trusted_proxies |
SYNTHORG_API_TRUSTED_PROXIES |
Same. JSON-encoded list. |
communication.nats_url |
SYNTHORG_NATS_URL |
Read once by the bus driver at startup. |
workers.count |
SYNTHORG_WORKERS |
A launch argument of the separate worker process, passed by synthorg worker start. |
observability.log_directory |
SYNTHORG_LOG_DIR |
Path-traversal validated at the boot site. |
observability.tsa_endpoint_freetsa |
SYNTHORG_OBSERVABILITY_TSA_ENDPOINT_FREETSA |
Timestamp-authority trust anchor, resolved during configure_logging before the DB-backed resolver exists. |
Category 3 examples (env only; no registry entry)¶
| Concern | Env var | Boot site |
|---|---|---|
| SQLite path | SYNTHORG_DB_PATH |
api/boot_persistence.py, api/app_helpers.py, api/integrations_wiring.py |
| Postgres URL | SYNTHORG_DATABASE_URL |
api/boot_persistence.py, api/app_helpers.py |
| Postgres SSL mode | SYNTHORG_POSTGRES_SSL_MODE |
api/boot_persistence.py |
| Config-file path | SYNTHORG_CONFIG_PATH |
api/boot_persistence.py, backup/factory.py |
| JWT secret | SYNTHORG_JWT_SECRET |
api/auth/secret.py |
| Master key (OAuth) | SYNTHORG_MASTER_KEY |
integrations/oauth/pkce.py |
| Pagination cursor secret | SYNTHORG_PAGINATION_CURSOR_SECRET |
api/cursor_config.py |
| Settings encryption key | SYNTHORG_SETTINGS_KEY |
settings/encryption.py |
For the full inventory of SYNTHORG_* env vars, see
environment-variables.md.
Custom env var names (env_var_override)¶
The default env var name for a registered setting is auto-derived as
SYNTHORG_<NAMESPACE>_<KEY>. When an established operator-facing env
var name predates this rule (e.g. the Docker-compose template already
sets SYNTHORG_LOG_DIR), the registry definition can set
env_var_override="SYNTHORG_LOG_DIR" and the resolver will look up
that exact name instead. Settings using overrides:
| Registry key | Override env var |
|---|---|
observability/log_directory |
SYNTHORG_LOG_DIR |
observability/log_level_console |
SYNTHORG_LOG_LEVEL |
communication/nats_url |
SYNTHORG_NATS_URL |
workers/count |
SYNTHORG_WORKERS |
workers/executor_http_timeout_seconds |
SYNTHORG_WORKER_HTTP_TIMEOUT_SECONDS |
tools/sandbox_image |
SYNTHORG_SANDBOX_IMAGE |
tools/sidecar_image |
SYNTHORG_SIDECAR_IMAGE |
memory/fine_tune_image |
SYNTHORG_FINE_TUNE_IMAGE |
memory/fine_tune_data_volume |
SYNTHORG_FINE_TUNE_DATA_VOLUME |
integrations/tunnel_state_dir |
SYNTHORG_TUNNEL_STATE_DIR |
When env_var_override is set, the auto-derived name is not
consulted: only the override. This keeps the operator surface clean:
exactly one env var name per setting.
Adding a new setting¶
- Decide which category fits.
- Category 1 (mutable): register a normal
SettingDefinitionin the appropriatesrc/synthorg/settings/definitions/<namespace>.pymodule. The env-var override is auto-derived asSYNTHORG_<NAMESPACE>_<KEY>; supplyenv_var_override=if an operator-facing name predates the rule. - Category 2 (fixed by the deployment but operator-visible):
register with
compose_set=True, and add the env var in the same change to both backend compose sources (cli/internal/compose/compose.yml.tmplanddocker/compose.yml), or tocli/cmd/worker_start.gofor a setting only the worker reads: theSettingsServicerejects runtime mutation and bypasses the DB on reads, andcheck_setting_compose_backed.pyfails a claim the deployment does not actually back. - Category 3 (bootstrap secret): do not register. Read the env var directly at the boot site and document the env var on environment-variables.md. Capture into a typed domain object; never re-read.
- Consume the value via
ConfigResolver.get_*()(post-init) orsynthorg.settings.bootstrap_resolver.resolve_init_value(...)(pre-init). Directos.environ.getreads in application code outside startup are forbidden. For a Category-1 setting this step is also what satisfiescheck_setting_live_or_compose_set.py: a read that runs only while the runtime is assembled does not count, so see The complement is enforced too before wiring one into worker assembly.
Bootstrap resolver (pre-SettingsService Cat-2 reads)¶
Some Category-2 settings are consumed at app construction time, before
SettingsService has been wired. Examples: rate-limiter middleware
construction, log-sink bootstrap, log-directory selection. Reading
os.environ directly at these sites is drift: the registry already
owns the env var name and the default, and the chain (env > default)
should be applied uniformly.
synthorg.settings.bootstrap_resolver.resolve_init_value(...) is the
sanctioned pre-init resolver. It reads the SettingDefinition from
the registry to obtain the env var name (override or auto-derived)
and the typed default, then returns the env value (if set) or the
registered default. Optional parse callback validates and converts
the env string to the consumer's type, returning None to fall back
to the default.
from synthorg.settings.bootstrap_resolver import resolve_init_value
from synthorg.settings.enums import SettingNamespace
resolved = resolve_init_value(
SettingNamespace.API,
"rate_limiter_enabled",
parse=_parse_bool_token,
)
rate_limiter_enabled = resolved.value
Used by:
synthorg.api.app._build_rate_limiter_enabled(rate-limiter middleware boot)synthorg.api.app_builders._bootstrap_app_logging(log directory)synthorg.observability.setup._apply_console_level_override(console log level)
Pydantic mirror fields (apply_settings_mirrors)¶
Many Pydantic config classes (ApiConfig, ServerConfig,
BudgetConfig, etc.) carry fields that mirror registered settings.
With YAML eliminated from the precedence chain, the Pydantic-tier
default would otherwise drift from the env-tier override resolved by
SettingsService.
Settings-only registered keys (no Pydantic mirror)¶
Some registered settings are consumed exclusively through
SettingsService (or ConfigResolver) and have no corresponding
field on any Pydantic config class. They participate in the standard
precedence chain (DB > env > default) without needing a mirror
declaration. Examples in the company namespace:
company.name_locales: consumed insrc/synthorg/api/controllers/setup/company_helpers.pyviaSettingsService.get_entry.company.description: registered for/settingsUI discoverability; no current code consumer.
These keys are NOT fields on RootConfig; treating them as
settings-only avoids the dual-surface drift that the mirror pattern
exists to fix.
synthorg.settings.mirrors.apply_settings_mirrors is the sanctioned
fix. Each Pydantic class with mirror fields declares them via a
MirrorField tuple and attaches a model_validator(mode="before")
that populates unset fields from the registry. The Pydantic field
declarations remain (consumer API unchanged) but the value at
construction time IS the precedence-chain result.
from typing import Any, ClassVar
from pydantic import BaseModel, ConfigDict, Field, model_validator
from synthorg.settings.enums import SettingNamespace
from synthorg.settings.mirrors import (
MirrorField, apply_settings_mirrors, parse_bool,
)
class MyConfig(BaseModel):
model_config = ConfigDict(frozen=True, allow_inf_nan=False)
_MIRROR_FIELDS: ClassVar[tuple[MirrorField, ...]] = (
MirrorField(
field="enabled",
namespace=SettingNamespace.MYNS,
key="enabled",
parse=parse_bool,
),
)
enabled: bool = Field(default=True)
@model_validator(mode="before")
@classmethod
def _apply_mirrors(cls, data: Any) -> Any:
return apply_settings_mirrors(data, cls._MIRROR_FIELDS)
Available parsers¶
synthorg.settings.mirrors ships the parser callbacks below. A
MirrorField with parse=None applies identity parsing (the raw env
string reaches the field, and the Pydantic field type does any
coercion). A parser returning None signals invalid input; the
registered default is then applied.
| Parser | Signature | Use for |
|---|---|---|
parse_bool |
(str) -> bool \| None |
Boolean tokens (true/false/1/0/yes/no). |
parse_int |
(str) -> int \| None |
Integer settings. |
parse_float |
(str) -> float \| None |
Float settings. |
parse_str_tuple_json |
(str) -> tuple[str, ...] \| None |
JSON list-of-strings into a tuple. |
parse_json_int_pair_dict |
(str) -> dict[str, list[int]] \| None |
JSON {op: [int, int]} (e.g. PerOpRateLimitConfig.overrides). Top-level shape only; the owning config's mode="before" validator promotes inner lists to tuples and rejects negatives. |
parse_json_int_dict |
(str) -> dict[str, int] \| None |
JSON {op: int} (e.g. PerOpConcurrencyConfig.overrides). Top-level shape only; the owning validator rejects non-int / negative values. |
The two JSON-dict parsers deliberately validate only the top-level
JSON structure. Per-entry semantics (non-blank keys, tuple arity,
non-negativity) belong to the owning config's mode="before"
validator so operator-facing error context fires before Pydantic
coercion. See "Validator declaration order" in
conventions.md.
Sentinel-preserving mode: only_if_env_set=True¶
When the Pydantic field's None default carries semantic meaning the
registry default would clobber, set only_if_env_set=True on the
MirrorField. The mirror then fires ONLY when the operator has
explicitly set the env var; if the resolver falls back to the
registered default the Pydantic field keeps its declared default.
Used by:
AuthConfig.exclude_paths(None= auto-derive from API prefix)CoordinationSectionConfig.max_concurrency_per_wave(None= unlimited)CeremonyPolicyConfig.{strategy, velocity_calculator, auto_transition, transition_threshold}(None= inherit from level up)
Selecting between the three resolution helpers¶
| Use case | Helper |
|---|---|
Settings consumed at app construction, before SettingsService exists |
bootstrap_resolver.resolve_init_value |
Settings consumed via a Pydantic Config field whose value comes from RootConfig |
mirrors.apply_settings_mirrors |
| Runtime-mutable settings consumed per request | ConfigResolver.get_*() (post-init) |
| Hot-reloadable knobs needing one snapshot per process tick | Bridge-config snapshot pattern below |
Protocol constants are not settings¶
Wire-protocol numerics such as JSON-RPC error codes
(JSONRPC_PARSE_ERROR: int = -32700), framing thresholds, or
specification-mandated limits are NOT operator-tunable policy:
changing the value silently breaks interop with peers that read the
public spec. Express them as typed module-level constants and let
scripts/check_no_magic_numbers.py recognise the annotation as the
named-constant signal. Import Final directly from typing; the
gate matches only the bare names int, float, Final, Final[int],
and Final[float], so qualified forms such as typing.Final[int]
still flag. Examples:
from typing import Final
JSONRPC_PARSE_ERROR: int = -32700
A2A_TASK_NOT_FOUND: int = -32001
_MAX_FRAME_SIZE: Final[int] = 16384
Do not register these in settings/definitions/. The precedence
chain is for values that an operator may legitimately tune; protocol
constants are part of the algorithm.
A settings write that swaps a collaborator must swap a complete one¶
The precedence chain gets the value right; the swap has to get the
collaborator right. A boot-time model-refresh sweep found three stale models,
rewrote providers.configs, and swapped in a rebuilt ProviderRegistry that
_build_registry_and_router had constructed with no connection_catalog. The
value was correct and the swap was silent, and every dispatch after it went out
with no credential.
So the swap seam owns the invariant, not each caller:
AppState.swap_provider_registry rebinds the always-on credential catalog onto
the incoming registry before installing it, and the worker's
_select_active_provider reads the registry once and threads that instance
through the rest of the build so a mid-build swap cannot split the runtime
across two registries. The rule generalises: whatever a settings write
replaces, it replaces whole, and the seam that installs it is where that is
enforced. See security.md for the fail-closed half.
Bridge-config snapshot pattern (hot-reloadable AppState fields)¶
For controller / service knobs that should be hot-reloadable but cost
too much to resolve through ConfigResolver.get_*() on every request,
the canonical pattern is a frozen Pydantic snapshot on AppState
populated at startup and hot-swapped by a settings subscriber on
operator-driven changes. Reference implementation:
api.max_lifecycle_events_per_query consumed by
ActivityController.list_activities.
The pattern has four pieces:
- Frozen bridge model. A class in
synthorg/settings/bridge_configs.py(e.g.ApiBridgeConfig) withmodel_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid"), one field per setting it carries, defaults that match the registered defaults. The model is the single source of truth for the fallback value: no controller carries a duplicate constant. - Resolver builder.
ConfigResolver.get_<ns>_bridge_config()resolves every field at once via_resolve_bridge_fields(). BridgeConfigStateowner + accessors. Theapp_state.bridge_configowner default-constructs each bridge model so consumers always see a valid snapshot, even before_apply_bridge_confighas run.app_state.bridge_config.<name>returns the current snapshot;app_state.bridge_config.swap_<name>(config)does a wholesale replace under a per-bridgethreading.Lock;app_state.bridge_config.mutate_<name>({field: value, ...})applies a partial update under the same lock so two concurrent subscribers cannot lose each other's writes.- Settings subscriber. A
SettingsSubscriberimplementation insynthorg/settings/subscribers/<name>_bridge_subscriber.pywhose_WATCHEDset lists every hot-reloadable field. On change, the subscriber resolves the new value and callsmutate_*with the single-field update;mutate_*re-validates the merged dict viamodel_validate(...)against the field'sField(ge=..., le=...)bounds, so an out-of-range value raisesValidationErrorand the prior snapshot is retained. Module-load-time guard: every key in_WATCHEDis asserted to exist on the bridge model so a typo or rename surfaces at import, not on the next operator hot-reload.
Use this pattern when per-request resolver lookup would add overhead or
coupling. Where a single live object owns the value (e.g.
ws_auth_timeout_seconds), the simpler set_*() pattern in
_apply_bridge_config plus a subscriber is sufficient.
Bootstrap-wiring trace (ghost-wired settings gate)¶
A registered setting whose consuming machinery exists but is never instantiated at boot is ghost-wired: the value resolves cleanly through the precedence chain, but no code path that reads it ever runs in default config. The standing gate scripts/check_setting_to_startup_trace.py (pre-push and CI) detects two ghost-service patterns and matches settings to them via three matchers. The full mechanics, the suppression marker, and the baseline-file contract have their own reference: Bootstrap-Wiring Trace (Ghost-Wired Settings Gate).
Compose-set or live¶
A registered setting is either fixed when a process starts or changeable
while the system runs. There is no third category, and therefore no restart
control and no pending-restart state: a compose_set=True definition rejects a
write and is shown read-only in the dashboard, and everything else applies
immediately through one of the seams below.
This is a statement about the settings registry, not about every value the process reads. The Category 3 bootstrap secrets above are pure environment and are never registered: they are consumed before a settings backend exists, so they have no definition to carry the flag and no write path to reject.
compose_set is reserved for what a running process genuinely cannot change
about itself: the bind address and TLS material uvicorn already opened, the
sandbox image the CLI pulled and verified, the RFC 3161 trust anchors resolved
before the settings backend exists, the Litestar middleware exclusion lists
applied at mount time. scripts/check_setting_compose_backed.py (pre-push +
CI) requires the shipped tooling to actually pass each one's environment
variable, so the label cannot decay into "we did not wire this up": a backend
compose_set key missing from either cli/internal/compose/compose.yml.tmpl
or docker/compose.yml, or a worker-only one missing from
cli/cmd/worker_start.go, fails the gate.
Everything else is live, through whichever seam its consumer allows:
- a per-request / per-call
ConfigResolver.get_*()read (e.g.api.max_meeting_context_keys,api.readiness_probe_timeout_seconds,integrations.oauth_http_timeout_seconds, and thecharter.interview_*/charter.default_currencyknobs the interview service resolves once per turn through an injected config provider); - a
set_*()setter on a live object pushed by aSettingsSubscriber(e.g. theWsAuthLimitsknobs, theHttpBatchHandlerHTTP batch knobs,backup.path, thebackup.compression/on_shutdown/on_startupconfig flags,a2a.client_timeout_seconds,engine.timeout_enforcement_enabled,observability.audit_chain_signing_timeout_secondsre-applied onto the live audit sink, andintegrations.github_api_urlre-bound onto the GitHub health checker); - a bridge-config snapshot + subscriber (e.g. the
tools.docker_sidecar_*resource limits, read per container launch through the sidecar cache); - a rebuild-and-swap subscriber that re-resolves and replaces a live
strategy / pipeline (the
hr.eval_loop_llm_model/pattern_identifier_mode/fix_proposer_modestrategies swapped onto the eval-loop coordinator); - a
reload_runtime_servicestrigger for knobsbuild_runtime_servicesalready re-reads (the engine classifier / matcher knobs,external_api.enabled/provider_type,coordination.enable_coordination_middleware,budget.benchmark_provider/model_capability_overrides, and thesimulations.verification_review_enabled/verification_grader/verification_decomposerpipeline rebuild). These writes arrive in bursts, because saving a settings form writes one key per field, so the dispatcher batches them oversettings.dispatcher_coalesce_window_secondsand hands the subscriber one batch to serve with one rebuild. The window is the dispatcher's and applies to every subscriber, not a knob of this one. What a write promises is unchanged: it returns only after a rebuild that started after it, and a failed rebuild still raises to every write it carried. The window is the added latency on a single write, and 0 turns the wait off; - a
settings=declaration on aSubsystemSpec, which puts the key in the watched set so a write to it triggers a reconcile pass. That alone does not replace a running subsystem:rebuild_on_change=Trueis what makes the pass tear the subsystem down and activate it again.memory_backenddeclares both, which is whymemory.embedder_modeland its siblings reconnect the memory backend on the spot. See Subsystem Reconciliation.
The complement is enforced too¶
scripts/check_setting_live_or_compose_set.py (pre-push + CI) is the sibling
of the compose-backed gate and asks the opposite question of every writable
setting: can an operator's write reach anything that is running? A setting that
nothing reaches accepts the write, shows the new value on the settings page, and
changes no behaviour. The value is not lost: it is retained and picked up the
next time the runtime is rebuilt. But nothing about the write schedules that
rebuild, so it arrives only as a side effect of some unrelated watched key
firing, or of a restart. An operator therefore cannot tell from the dashboard
when, or whether, the change has taken effect, which is the third category the
rule abolishes wearing the first category's clothes.
The gate accepts as evidence any of the seams above, read straight from the source tree rather than by importing it:
- a
(namespace, key)pair in any settings subscriber's_WATCHEDset; - an
enabled_byentry on aSubsystemSpec, or asettings=entry on a spec that also declaresrebuild_on_change=True(see below); - a resolver read in any shape the tree uses: a positional
(ns, key)pair,namespace=/key=keywords (which is also what aMirrorFielddeclaration looks like), a_resolve_bridge_fieldsbundle, a namespace-wideget_namespace/get_page/get_allread, a dotted"ns.key"literal, a loop over a literal collection of keys, or a helper that takes the namespace or key as a parameter and is called with a literal; - the namespace and the key quoted in the same
web/src/file, outside test files and generated*.gen.tstypes. The dashboard persists no domain state and re-fetches throughGET /settings, so a key it reads applies on the next render. Both halves are required because eight settings share the keyenabled, and matching the key alone would let one unrelated token certify every one of them.
Two things are deliberately not evidence. A read inside any module
build_runtime_services reaches runs while the runtime is assembled, and a
read inside a function a SubsystemSpec names as its activate= /
deactivate= target runs during activation; both happen once per rebuild. The
construction path is derived by closing over the synthorg.workers imports of
the module defining build_runtime_services, rather than listed, so it does
not go stale as the assembly grows.
Declaring the key in that subsystem's settings= makes the read live again
only alongside rebuild_on_change=True. Without the flag the reconciler
short-circuits on an already-active subsystem, so the write is watched but
replaces nothing and the value waits for the next fresh wiring: watched is not
the same as applied. enabled_by needs no flag, because the reconciler
evaluates it on every pass regardless.
The activation analysis follows one import hop (the registry's _activate_*
wrapper to the wiring function it calls) and excludes reads lexically inside
that function. It matches the function by name rather than tracing calls, so a
read inside a helper that wiring function calls is not excluded and stays
live.
A blank-default setting is judged on cold-start evidence¶
A setting that ships with no value at all (default="", default=None, or no
default=) is in a position the seams above do not describe. For it the write
that matters is the first one: the one that turns the feature on. Two shapes
that satisfy the general rule prove nothing about that write, so the gate does
not credit them here.
- A read that supplies a fallback. It lives inside the component the setting decides whether to build, and falls back to the pair that component was built with. It lets an operator move a running instance to a different model; it cannot bring one into being, because it does not run until one exists.
- A namespace-wide bulk read. It names the namespace, not the key: it sweeps up every entry including the ones nothing consumes, and in this tree it feeds a boot config snapshot rather than a live consumer.
Between them those two masked chief_of_staff.turn_intent_model, which was
written, persisted, shown in the dashboard, and applied to nothing until a
restart, while the gate passed it on a live read inside the classifier the
setting itself decided whether to build. What still counts for a blank-default
setting is a declaration (subscriber pair, enabled_by, rebuild-backed
settings=, dashboard reference) or a read with nowhere to fall back to: the
resolve_bound_model_live + require_configured_model shape, where unset
fails loud with a 503 naming the setting, so the first write arms the very next
call. The violation is reported as kind gated-by-itself, and its message
names the seams that would fix it rather than only the verdict.
There is no per-line opt-out. A marker on this rule would read "this setting is
writable and reaches nothing, and that is fine", which is the category the rule
exists to abolish. The three sanctioned exits are: make it live, mark it
compose_set and pass it from the launchers (which the sibling gate then
checks), or delete it. Pre-existing violations are frozen in
scripts/setting_live_or_compose_set_baseline.txt, one
<namespace>.<key>:<kind> per line, where kind is unreachable (nothing
names it), construction-only (the only reads run while the runtime is
assembled), or gated-by-itself (blank by default, and its only reads sit
inside the component it gates); a listed setting whose kind changes is a new
violation, not a covered one.
Security toggle write guardrail¶
security.enabled, audit_enabled, post_tool_scanning_enabled, and
output_scan_policy_type are hot-reloadable, but weakening them is a
deliberate-action decision: turning a boolean off, or switching
output_scan_policy_type to log_only, requires confirm=True plus a
non-blank reason and actor at the write path
(settings/write_governance.py, enforced centrally in
SettingsService.set / set_many, surfaced via the dedicated
POST /settings/security/import endpoint). Enabling / tightening applies
immediately with no gate. The per-request interceptor reads the live config
through app_state.security_runtime_config, which the
SecurityBridgeSettingsSubscriber swaps on an authorised change.
The same guardrail covers four more namespaces: engine (the completion-oracle
keys, the agent middleware, the three loop-routing keys
loop_auto_select_enabled, default_loop_type and loop_complexity_overrides,
where naming the sandboxed loop is the weakening direction, and the three
human-ask toggles ask_policy_enabled, clarification_enabled and
scoping_enabled, whose off direction removes the only in-run path by which an
agent defers a material, hard-to-reverse choice to a human), tools (MCP
sandbox isolation, the credentialed-MCP grant, openhands_enabled, and each
destructive tool family's enable + targets), output_style (disable, shadow,
exemptions, pack swap), and providers (gateway_enabled,
failover_enabled, failover_routes).
Six of those toggles ship on, and the guarded direction differs by what turning one on actually does.
For the three default-on capabilities (providers.gateway_enabled,
tools.openhands_enabled, tools.credentialed_mcp_enabled) the weakening
direction is false -> true, because that is what reopens an egress or
credential surface. An unset key already resolves to the registered true, so
writing true over it restates the running posture and is unguarded; only an
explicit stored false returning to true needs confirm+reason+actor.
For the three human-ask toggles (ask_policy_enabled,
clarification_enabled, scoping_enabled) it is the mirror image: turning one
off is what removes the deferral path, so true -> false and unset ->
false need confirm+reason+actor (unset counts because it resolves to the
registered true), while false -> true restores the posture and is
unguarded.
Either way the guardrail is a live-write control, not an upgrade-time one: a deployment that never wrote an explicit row inherits the new default on its next boot with no prompt, so a default flip on any of these belongs in the release notes.
Seven tools keys this guardrail covers are also frozen construction-only in
scripts/setting_live_or_compose_set_baseline.txt: mcp_sandbox_enabled,
mcp_sandbox_network, mcp_sandbox_cpus, mcp_sandbox_memory_limit,
mcp_sandbox_pids_limit, forge_tools_enabled and chat_tools_enabled. The
combination is worth naming: an operator completes the confirm + reason + actor
prompt, the write is accepted and the dashboard shows the new value, and the
sandbox isolation posture stays as it was until the next runtime rebuild. For
these the gap is a security-posture one rather than a convenience one, so they
are the priority set for the follow-up that makes each key live, ahead of the
timeout and limit entries sitting beside them in the baseline.
The two declared-failover keys ship off, so their guarded direction is
the plain one and unset counts as off. providers.failover_enabled needs
confirm + reason + actor on the first stored true, because that is what
widens what may answer a bound request: the same model id through two
connections is two different calls, billed and rate-limited separately.
providers.failover_routes is guarded on addition rather than on the
value as a whole, keyed declared -> alternate, so declaring a first route,
adding a second, and repointing an existing pair at a different connection
each need the guardrail, while removing one, clearing them all, or reordering
the same set is a narrowing and is not guarded. Keying on both halves is what
makes the repoint case guarded: the declared half is unchanged and the count
is unchanged, but a connection that could not serve that pair now can, and the
enable toggle cannot ask again about a grant made months later.
integrations.webhook_receipt_retention_days is governed for a different reason:
it relaxes no boundary, but shortening the window has the next sweep destroy
delivery evidence irreversibly, so the shortening direction (including the
default never-sweep 0 becoming any finite window) needs the same
confirm + reason + actor. Lengthening it, or returning to 0, retains strictly
more and is unguarded.
Kill-Switch Idiom (MANDATORY)¶
Every long-running async loop in src/synthorg/ MUST be pause-able
at runtime via an <namespace>.<service>_enabled boolean setting,
without restarting the process. The canonical shape:
- Register the flag in
src/synthorg/settings/definitions/<ns>.pywithSettingType.BOOLEAN,default="true", and adescriptionthat names the gated service. The setting participates in the full DB > env > default precedence chain. - Add a fail-safe-to-enabled resolver helper next to the loop. The
"no resolver wired" fast-path returns
Truedirectly so a service constructed in a test or pre-startup context (whereapp_state.has_config_resolverisFalse/config_resolver is None) does not crash on aNone.get_boolaccess:
async def _resolve_<x>_enabled(...) -> bool:
if not app_state.has_config_resolver:
return True
try:
return await app_state.config_resolver.get_bool(<ns>, "<x>_enabled")
except asyncio.CancelledError:
raise
except (MemoryError, RecursionError):
raise
except Exception as exc:
logger.warning(<event>, error_type=type(exc).__name__,
error=safe_error_description(exc))
return True
- Gate the loop body per iteration (or per call for non-loop
surfaces like
NotificationDispatcher.dispatch):
while not self._stop_event.is_set():
if await self._resolve_enabled():
await self._do_work()
else:
logger.debug(<paused_event>, reason="paused_by_setting")
await asyncio.sleep(self._interval)
The fail-safe-to-enabled rule is non-negotiable: a settings-backend outage must not silently silence the surface. Operators silence by setting the value explicitly.
Reference implementations (symbol-only references; line numbers churn):
api.lifecycle_helpers._ticket_cleanup_loop,
api.lifecycle_helpers._audit_retention_loop,
api.webhook_cleanup._webhook_receipt_cleanup_loop,
providers.health_prober.ProviderHealthProber._run_loop,
notifications.dispatcher.NotificationDispatcher.dispatch,
communication.conflict_resolution.escalation.sweeper.EscalationExpirationSweeper._run.
Per-line opt-out:
# lint-allow: long-running-loop-kill-switch -- <reason> on the
while line itself, or on one of the two preceding source lines
(leading comment block / decorator). The justification is mandatory
and must be non-empty (mirrors the existing # lint-allow:
markers). Suppression is per-loop: a function with two unguarded
long-running loops needs two markers, otherwise a function-wide
opt-out could silently mask a new sibling loop added later.
Pre-existing not-yet-pause-able loops live in
scripts/long_running_loops_kill_switch_baseline.txt; the gate
fails when a NEW loop missing the kill-switch lands.
Enforced by scripts/check_long_running_loops_have_kill_switch.py
(pre-push + CI). Scope: the gate scans every long-running
while True: / while not <stop_event>.is_set(): inside an
async def under src/synthorg/, so the loop-bodied surfaces above
(_ticket_cleanup_loop, ProviderHealthProber._run_loop,
_webhook_receipt_cleanup_loop) are lint-enforced. Per-call
non-loop surfaces such as NotificationDispatcher.dispatch are
covered by project convention and reviewed by CodeRabbit / human
review, but they sit outside the AST gate's loop-shaped detection.
Sandbox image cache¶
The Pydantic field defaults in
src/synthorg/tools/sandbox/docker_config.py no longer read
SYNTHORG_SANDBOX_IMAGE / SYNTHORG_SIDECAR_IMAGE directly from
os.environ; the canonical resolution path is
tools.sandbox_image / tools.sidecar_image registered in
definitions/tools.py with env_var_override= matching the
historical env var names. _apply_bridge_config resolves both
once at startup and writes them into the process-singleton cache
in tools/sandbox/_image_resolution.py. Tests override the cache
via set_resolved_*_image(...); the autouse fixture
_isolate_sandbox_image_resolution in
tests/unit/tools/sandbox/conftest.py clears the cache around
every sandbox test.