Use profiles and check isolation
Select a store
AXM_PROFILE is read at call time. Unset or empty means production.
| Selection | Store | profile_root() |
|---|---|---|
| production | ~/.axm/config.toml |
None |
| docs-demo | ~/.axm/profiles/docs-demo/config.toml |
~/.axm/profiles/docs-demo |
Names follow ^[a-z][a-z0-9-]{0,31}$: ci-2 is valid;
Dev, 1dev and dev_x raise ConfigError.
The generic resolver, store, model loading and namespace enumeration use the selected file and its profile-local legacy files. A missing named-profile file does not fall back to production. Environment overrides are shared between profiles unless the launching process scopes them.
axm_home() creates/tightens ~/.axm to mode 0700 on POSIX, and only the
code that persists state calls it: a path helper or a store read resolves the
location through axm_home_path() and creates nothing. The home is therefore
materialised on first write, and the named profile directory with it, with
normal mkdir/umask permissions under that private parent.
axm-config path prints the base home, not the selected config file.
Propagate the selection
profile_env() returns only {"AXM_PROFILE": current_profile()}.
Merge that overlay into a child process's environment; it is not a complete
environment and does not carry other AXM overrides. Avoid changing process-global
os.environ concurrently to switch profiles: the selection is not a per-request
or thread-local context.
Read the isolation report
The report answers through the resolver instead of recomposing a convention:
its six entries are tickets_db, warden_socket, warden_log_path,
sessions_root, quality_dir and protocols_dir called with the requested
profile, so env > file > default and the containment guard apply exactly as
they do at runtime. A configured [paths] sessions_root is therefore the value
printed, not a convention-derived sibling. The calculation still creates
nothing: it resolves through the non-creating axm_home_path() and never calls
axm_home(). profile_root names the convention root
<axm home>/profiles/<name> for the requested profile, rooted at ~/.axm:
AXM_HOME selects only the compatibility config.toml that is read, never
this layout.
isolated and escapes are derived from those reported paths by
is_isolated(root, paths) -- every location not contained by profile_root is
named in the sorted escapes list. Run the diagnostic without a profile and
the default one reports isolated: false with all six locations listed as
escapes: profile_root_for("production") is None, the resolver places nothing
under profiles/production, and the report now says so instead of announcing a
profile tree nobody writes to. Production state is deliberately shared; that
answer is the truth, not a regression.
is_isolated(root, paths) is a lexical Path.is_relative_to check; it does
not resolve symlinks or .. segments. Supply normalized paths when using it
directly, and do not treat its result as a filesystem security audit.
Ask what another profile would use
Reading a location for a profile you are not running under no longer requires
exporting AXM_PROFILE in the current process:
from axm_config import profile_root_for, sessions_root, tickets_db
profile_root_for("scratch") # ~/.axm/profiles/scratch
profile_root_for("production") # None -- the default profile owns no root
sessions_root(profile="scratch") # ~/.axm/profiles/scratch/sessions
tickets_db(profile="scratch") # ~/.axm/profiles/scratch/tickets/tickets.db
tickets_db, warden_socket, warden_log_path, sessions_root, quality_dir
and protocols_dir all take that keyword, and so do the generic
get_path(key, default, *, profile=...) and service_port(service, *,
profile=...) -- see below for the
listening points. Three properties make the answer safe to print in a read-only
report:
- nothing is created, neither
~/.axmnor the profile directory; AXM_PROFILEis neither read for the decision nor written, so a concurrent resolution in the same process is unaffected;- configured overrides are honoured, and a configured value escaping the
requested root raises
ConfigErrornaming that profile rather than the active one.profile_isolationreports exactly these values, because it calls these same accessors.
profile="production" is the deliberate exception: the default profile owns no
root, so the caller default is returned unprefixed, exactly as a production
resolution does. Omit the keyword entirely and resolution is byte-for-byte the
active-profile behaviour described above.
Run two installations side by side
An application installed under its own profile needs a listening point, not only state directories. Ask the profile for one instead of demanding it from the operator:
from axm_config import service_port
port = service_port("mcp") # 9427 in production, profile-derived otherwise
Nothing has to be configured. Under AXM_PROFILE=alpha the number is derived
from the profile name, is the same on every restart, and differs from the one
the same service gets under AXM_PROFILE=beta. Production is untouched: it
still resolves the adopted default.
Pin a port explicitly when you must; the usual precedence applies.
An installation that already exports the historical AXM_MCP_PORT keeps working
as is: it is read after AXM_NETWORK_MCP_PORT and before any configured
[network] mcp_port, under every profile. There is nothing to migrate; prefer
the derived name for new deployments, and set only one of the two, since the
derived name wins when both are present.
service_port returns a number; it binds nothing and does not check that the
port is free. Two different profile names are unlikely to collide, but nothing
prevents it, so a deployment that cannot tolerate a clash should still configure
the value. See runtime settings for the
registered services.
Check for a clash before launching
Asking what another installation would bind no longer requires switching the current process over to its profile:
from axm_config import service_port
service_port("mcp", profile="alpha") # alpha's port, asked from production
service_port("orison_web", profile="beta") # beta's port, same process
AXM_PROFILE is neither read for the decision nor written, so one process can
enumerate every candidate in a single pass -- and compare the two profiles'
listening points, which have no number in common -- while a concurrent
resolution under the active profile stays unaffected. Omit the keyword and
resolution is byte-for-byte the active-profile behaviour above;
profile="production" returns the adopted number.
The named profile supplies only the derived fallback, so a configured value
still wins for it exactly as for the active one: with AXM_NETWORK_MCP_PORT
exported, every profile answers that number, and the comparison above tells you
nothing about a clash. Clear the port variables before comparing derived
allocations.
Start the dev services with the same profile
Embedded warden writes require the dev HTTP MCP to enforce session contracts:
AXM_PROFILE=dev /absolute/runtime/.venv/bin/axm-mcp serve --shared
AXM_PROFILE=dev /absolute/runtime/.venv/bin/axm-warden serve
Dedicated MCP mode reports write_contracts_enforced: false at /health and
the executor refuses it. Use --shared; selecting AXM_PROFILE=dev alone does
not enable enforcement. Start Orison's backend with AXM_PROFILE=dev too.
From Orison's frontend directory, an explicitly pinned development frontend
can be started as follows, with the URL of the dev backend actually launched:
ORISON_PORT binds Vite; ORISON_API selects its backend proxy. Without these
overrides, Vite resolves both through orison web --print-target --profile dev.
Put the intended runtime's absolute .venv/bin first on PATH for that lookup.
Check /api/profile through the frontend to verify it reaches the dev backend.
AXM_HOME is not a store override
axm_home, NamespaceStore, get, set_, load and profile storage
derive their home from Path.home() / ".axm" and ignore AXM_HOME.
Typed accessors have a different compatibility behavior: after the normal
environment and selected-profile file resolution finds nothing, they try
AXM_HOME/config.toml before the caller default. That fallback reads a
top-level namespace table, does not select profiles/<name>, and reports
invalid/unreadable TOML as ConfigError. It does not bypass the normal store
lookup. Therefore setting AXM_HOME alone cannot safely sandbox ordinary
configuration reads or writes.
Configured paths must be outside a git checkout and, with a named profile,
inside its resolved root. Registered state defaults relocate to that root;
arbitrary caller defaults remain unchanged. In particular,
tickets_db(default=...) can retain an external fallback, and the default
warden executable remains beside the interpreter. See
runtime settings for the exact mapping.