ADR-0018: Org hierarchy and delegated policy
ADR-0018: Org hierarchy and delegated policy
Section titled “ADR-0018: Org hierarchy and delegated policy”Amended 2026-10-21. A unit’s own owners approve its file, so additions that run code or reroute traffic on members’ machines are loosenings too:
hooks.items(unlesshalos.yamlsetsdelegation.allowHooks), newenvkeys (unless listed indelegation.envKeys; provider, base-URL, proxy and runtime-injection keys never), and MCP servers that are not an exactselfService.catalogentry; overlays carry no extensions. A top-level unit may selectmembers.groupsonly, andhalo units codeownersgives a top-level unit’s file to the root owners alone (--owneris then required). See Org hierarchy.Amended again 2026-10-21 (second security review). The never-delegable env keys now also cover, case-insensitively:
PATH,BASH_ENV,ENV,CLAUDE_CODE_*(all, not justCLAUDE_CODE_USE_*),AWS_*(includingAWS_ENDPOINT_URL*),GOOGLE_*,CLOUD_ML_REGION,OTEL_EXPORTER_OTLP_*,GIT_SSH_COMMAND,GIT_CONFIG_*,GIT_EXEC_PATH,npm_config_*,JAVA_TOOL_OPTIONS,_JAVA_OPTIONS,PERL5OPT,RUBYOPT,SSL_CERT_FILE,SSL_CERT_DIR,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLEandNODE_EXTRA_CA_CERTS(test:TestUnitForbiddenEnv). Loosening was enforced only byhalo validate; halo-server and the gateways now refuse to activate a policy whoseunitsordelegationhave validation errors and keep the last good one (tests:TestPolicyLoadRefusesSecurityErrors,TestSnapshotRefusesSecurityErrors).
Amended 2026-10-21 (third security review). The never-delegable env keys now also cover every
OTEL_*, shell hooks (BASH_FUNC_*,PROMPT_COMMAND,SHELLOPTS,BASHOPTS,PS4,ZDOTDIR,HOME,XDG_CONFIG_HOME), pagers and editors, git wrappers (GIT_SSH,GIT_ASKPASS,SSH_ASKPASS*,GIT_PROXY_COMMAND,GIT_TEMPLATE_DIR,GIT_SSL_*), compiler and runtime hooks (GOFLAGS,RUSTC,RUSTC_*,CARGO_*,PERL5LIB,RUBYLIB,JDK_JAVA_OPTIONS,DOTNET_STARTUP_HOOKS,ELECTRON_RUN_AS_NODE) and harness config dirs (CLAUDE_CONFIG_DIR,CODEX_HOME,GEMINI_*). Adapters write policy telemetry and gateway keys after the profile env (TestUnitForbiddenEnv,TestTelemetryEnvWinsOverProfileEnv).
Amended 2026-10-22 (final security review). Units may never set any
GIT_*env key except commit identity (GIT_AUTHOR_NAME,GIT_AUTHOR_EMAIL,GIT_COMMITTER_NAME,GIT_COMMITTER_EMAIL), soGIT_EXTERNAL_DIFF,GIT_SEQUENCE_EDITORand later git additions are refused, nor compilers and build tools (CC,CXX,CGO_*,MAKEFLAGS,MAKEFILES,MAVEN_OPTS,GRADLE_OPTS,JAVA_OPTS,CLASSPATH,GOENV,GOTOOLCHAIN), package and module sources (GOPROXY,GONOPROXY,GOSUMDB,GONOSUMDB,GONOSUMCHECK,GOINSECURE,GOPRIVATE,PIP_*,UV_*,YARN_*,BUNDLE_*,GEM_*), runtime hooks (GCONV_PATH,LUA_INIT*,PHPRC,PHP_INI_SCAN_DIR),SUDO_ASKPASS,BROWSERorDOCKER_HOST, in any case (test:TestUnitForbiddenEnv).
Context and problem statement
Section titled “Context and problem statement”The policy model is one flat org: rings point at profiles, and profiles share settings through a single extends chain. A large org with many business units ends up with one of two problems. Either every BU-specific change goes through the central platform team, or BUs get write access to the shared profiles and can weaken the security baseline by accident. Neither scales to hundreds of teams. How do business units and teams own their own settings without being able to loosen what the org enforces?
Decision drivers
Section titled “Decision drivers”- Keep
halos.dev/v1frozen: the change has to be additive (ADR-0012). - The central security baseline (permissions, sandbox, MCP denylist, telemetry, egress) must not be weakened by a delegated owner, and CI has to be able to prove it.
- Ring and experiment assignment stay in
internal/assign(invariant 3). A hierarchy must not create a second assignment mechanism that drifts from it. - Delegation has to map onto how policy repos are already governed: pull requests with code-owner review.
- Validate and release build must stay fast at 150+ units.
Considered options
Section titled “Considered options”- A
Unitkind that overlays the ring’s profile, with enforced fields checked at validate time and unit files shipped inside the ring release. - Per-unit rings. Units become rings with group membership. This reuses existing machinery, but the ring then carries two meanings (rollout stage and org placement), and the number of rings becomes BUs × stages.
- Multiple
extends(profile mixins) plus CODEOWNERS on profile files. This needs no new kind, but nothing stops a mixin from loosening a field, and membership stays tied to rings. - Separate channels per unit, like client-axis experiment variants (ADR-0010). The release count would be (variants + 1) × (units + 1) per ring, and every release would need its own signed pointer.
Decision outcome
Section titled “Decision outcome”Chosen: option 1.
- Kind.
Unit(apiVersion: halos.dev/v1) hasparent,owners(CODEOWNERS owners),members(groups,users) andoverlay. The overlay is the profile subset a unit may change:models,permissions,mcp,hooks,telemetry,egress,instructions,env. Harness pins and overrides stay central. - Placement. A user’s unit is the deepest one whose selector, and every ancestor’s, matches the user’s id or IdP groups. Equal depth goes to the lexically smaller name. Rings and variants are unchanged and still resolve through
internal/assign. - Merge order. The profile
extendschain (root first), then ancestor overlays (root first), then the unit’s own. Deny lists and hooks union, MCP servers are added, other lists replace,envandtelemetry.attributesmerge per key, and instructions are appended. - Enforced fields.
policy.Looseningsdefines the security baseline: permission mode and sandbox (ranked), the bypass, sandbox-required, managed-only and telemetry switches, deny/ask/denied lists (no drops), allow and egress lists (no additions), telemetry destination and prompt logging (unchanged), inherited env keys and telemetry attributes (unchanged), andmodels.allowedwhilemodels.enforceis set (a subset).halo validatechecks every unit against its parent’s effective config, on every shipped profile (ring profiles and running client-axis variants). Each loosening is an error atunits[<name>].overlay.<field>. The effective config must also pass the profile checks and built-in guardrails the base passes. - Delegation. A unit lives in its own directory. Unit directories may hold only Unit documents, and a unit nested in another unit’s directory must be its descendant.
halo units codeownersrenders CODEOWNERS from the units. Branch protection with code-owner review and a requiredhalo validateturns that into delegated, baseline-safe merges. - Delivery. Each release (ring and variant) lists every unit in its signed manifest: the selector, plus the files its effective config renders differently from the release, as whole files. Toggle deltas cannot express a narrowed list or a new non-JSON file, so units don’t use them.
halodpicks the unit withpolicy.MatchUnit, using the subject and groups from the ring endpoint, writes the unit’s files in place of the release’s own, then merges toggle fragments. - Explain.
halo explain --user U --groups Gshows the ring, variant, unit chain and every effective field, with the layer (profile <name>orunit <name>) that set it.
Consequences
Section titled “Consequences”- Good: units own their overlays without central review, and CI rejects any loosening with the exact field path.
- Good: no new assignment path, no new channels or pointers. A rollback re-points the ring, and every unit moves with it.
- Good: additive to v1. A policy without units loads, validates and builds byte-for-byte the same releases (
Manifest.UnitsandOrg.Unitsare omitted when empty). - Bad: release size grows with the number of units whose effective config differs. Blobs are content-addressed, so identical files are stored once. Measured at 50 units × 3 levels, validate takes about 11 ms (
BenchmarkValidateLargeOrg) and a release build about 5 ms with a fake adapter (BenchmarkBuildLargeOrg). - Bad: a unit that adds an MCP server needs its host in the baseline’s egress allowlist, because adding an egress domain is a loosening. The central team has to allow the host first.
- Neutral: a device without groups (ring pinned in
halod.yaml, or no halo-server) gets no unit. The gateway does not enforce unit model allowlists; it enforces aliases as before, and the unit’s narrowermodels.allowedis enforced in the rendered client config. - Skew:
haloandhalo-servermust be new enough to decodekind: Unitbefore a repo uses it, because decoding is strict. An olderhalodignoresunitsin the manifest and applies the release’s own files, which is the baseline, never something looser.
More information
Section titled “More information”internal/policy/unit.go(Unit,MatchUnit,EffectiveProfile,Loosenings,ExplainEffective,CodeOwners),internal/release/units.go,cmd/halod/toggles.go,cmd/halo/cmd_units.go.- Org hierarchy, Compatibility.