Files
kandev/docs/specs/executors/system-design/executor-profile-env-precedence-01.md
T
2026-08-24 11:09:03 +00:00

391 lines
22 KiB
Markdown

---
status: draft
system: executors
requirements:
- REQ-EXECUTORS-EXECUTOR-PROFILE-ENV-PRECEDENCE-001
created: 2026-08-17
owners:
- tbd
---
# Executor-Profile Environment Precedence System Design Part 1
## Purpose and boundaries
This design preserves the technical source detail for `REQ-EXECUTORS-EXECUTOR-PROFILE-ENV-PRECEDENCE-001` during migration.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-EXECUTORS-EXECUTOR-PROFILE-ENV-PRECEDENCE-001` | [Migrated source detail](#migrated-source-detail) |
## Migrated source detail
## Why
Environment values split into two kinds that today share one home:
- **Machine-scoped**: where inference goes, where tools live. `ANTHROPIC_BASE_URL` is the
clearest case. It describes the host, not the model.
- **Agent-scoped**: `CLAUDE_CONFIG_DIR`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `MCP_TIMEOUT`.
Machine-scoped values live on the agent profile, so a profile named "Opus" means "Opus, and
always talk to localhost:3456". That is true on the local host and false on an SSH executor.
Today the only workaround is duplicating the agent profile per executor: N agents x M
executors.
A user who sets `ANTHROPIC_BASE_URL` on both an agent profile and an executor profile does not
get a choice. The task fails during environment resolution.
## Current behaviour (verified)
Verified 2026-08-17 by reading the implementation, not by inference.
Environment values reach an agent as `Definition` values that keep their source identity until a
single composition boundary
(`apps/backend/internal/agent/runtime/environment/environment.go`). `Definition` carries `Key`,
`Literal`, `SecretID`, `Origin`, `WorkspaceID`.
`selectDefinitions` (`environment.go:95`) sorts by `Key`, then `Origin`, then `SecretID`, then
walks the sorted list. For a repeated key it compares `definitionIdentity` (`environment.go:149`),
which is `"secret:" + SecretID` for a secret-backed definition and
`"literal:" + sha256(Literal)` otherwise. Identical identities are merged and every origin is
recorded. Different identities are collected into a conflict set and `Resolve` returns a
`*ConflictError` naming the key and every participating origin, sorted:
```text
environment key "ANTHROPIC_BASE_URL" has conflicting definitions from agent profile, executor profile
```
There is deliberately no precedence. The resolver refuses to choose.
### The two assembly sites
Definitions are assembled at exactly two places, and both funnel into the same resolver:
1. **Primary launch / resume / execute.**
`Executor.resolveLaunchEnvironment`
(`apps/backend/internal/orchestrator/executor/task_environment.go:90`) builds sources from
`req.Env` (origin `managed runtime`), the **executor** profile's `EnvVars` (origin
`executor profile`, confirmed via `executorConfig.ProfileEnvVars` populated from
`profile.EnvVars` at `executor_state.go:203`), and each attached repository's
`SecretBindings` (origin `repository <name>`). It then runs `runtimeenv.Validate` as a
shape-only preflight and sets `EnvironmentResolutionRequired = true`. Called from
`executor_interaction.go:747`, `executor_resume.go:964`, `executor_execute.go:1100`.
2. **Restart recovery / workspace-only.**
`Manager.prepareExecutionEnvironment`
(`apps/backend/internal/agent/runtime/lifecycle/manager_execution.go:740`) assembles
repository and executor-profile definitions directly.
Both reach `Manager.resolveStrictEnvironment`
(`apps/backend/internal/agent/runtime/lifecycle/environment_resolution.go:31`), which appends the
remaining origins and calls `runtimeenv.Resolve`. Both get there through
`Manager.buildEnvForExecution` (`manager_startup.go:295`): site 1 sets
`EnvironmentResolutionRequired = true` at `task_environment.go:121`, site 2 at
`manager_execution.go:772`, and `buildEnvForExecution` dispatches on that flag at `:303`.
`manager_interaction.go:1023` is unrelated: it resolves a task **environment ID**, not
environment variables.
#### The legacy fill-missing branch is out of scope
`buildEnvForExecution` has a THIRD branch (`manager_startup.go:314-339`), taken when neither
`EnvironmentFinalized` nor `EnvironmentResolutionRequired` is set. It never calls the resolver: it
copies `req.Env`, merges agent-profile values through `mergeAgentProfileEnv` /
`mergeAgentProfileEnvFromInfo`, then fills agent-runtime defaults with a
`if _, exists := env[k]; !exists` guard.
This spec does not change that branch, and no tier logic applies there. It is a named exclusion
rather than a claim of unreachability: both assembly sites above set the flag, and
`manager_launch.go:1254` sets it back to `false` only AFTER `buildEnvForExecution` has already
resolved (alongside `EnvironmentFinalized = true`, so a re-entry takes the first branch, not this
one) — but a `LaunchRequest` that reaches `buildEnvForExecution` having passed through neither
assembly site would land here, and this spec does not attempt to prove none does.
Nothing is lost by excluding it. Executor-profile definitions are attached ONLY by the two
assembly sites, so this branch never has an executor-profile value to weigh against an
agent-profile one — the conflict this feature resolves cannot arise in it. AC-49 is scoped to
strict-resolution launches for exactly this reason. Making the legacy branch tier-aware would mean
teaching it to load executor profiles, which is a behaviour change well outside this card.
### The complete origin set
Every origin string produced today, with its source:
| Origin | Constant / source | Backing |
| --- | --- | --- |
| `managed runtime` | `managedRuntimeOrigin`, `environment_resolution.go:17` | literal |
| `managed credentials` | `managedCredentialOrigin`, `environment_resolution.go:18` | literal |
| `managed agent defaults` | `managedAgentDefaultsOrigin`, `environment_resolution.go:19` | literal |
| `agent profile` | `agentProfileOrigin`, `environment_resolution.go:20` | literal or secret |
| `executor profile` | `executorProfileOrigin`, `environment_resolution.go:21` | literal or secret |
| `repository <name>`, or `repository` when the name is blank | built at `environment_resolution.go:225` and `task_environment.go:77` | secret only |
`managed runtime` covers `KANDEV_INSTANCE_ID`, `KANDEV_TASK_ID`, `KANDEV_SESSION_ID`,
`KANDEV_AGENT_PROFILE_ID`, `KANDEV_EXECUTION_PROFILE_ID`, `AGENT_MODEL`,
`AGENTCTL_AUTO_APPROVE_PERMISSIONS`, `GOCACHE`, and everything in `req.Env`.
The orchestrator site hardcodes the literal strings `"managed runtime"` and
`"executor profile"` (`task_environment.go:63`, `:67`) instead of importing the lifecycle
constants. That is a live drift hazard for any rule keyed on origin.
### The two profile kinds are NOT validated the same way — verified
This is load-bearing for AC-6, AC-38, AC-38a and AC-40, and three earlier drafts of this spec got
it wrong, so it is stated once here as fact and every AC below refers to it rather than restating
it.
ALL THREE save-time validators live ONLY in the **agent**-profile controller,
`apps/backend/internal/agent/settings/controller/profile_crud.go`:
| Rule | Symbol | Applies to |
| --- | --- | --- |
| value-or-secret required | `validateEnvVarValue` (`:913`, "must set either value or secret_id") | agent profiles ONLY |
| reserved key rejected | `TASK_DESCRIPTION` / `KANDEV_*` check (`:904`, constants `:867-868`) | agent profiles ONLY |
| duplicate key rejected | `env_vars[i].key duplicates env_vars[j].key` (`:908`) | agent profiles ONLY |
A `grep` for those symbols across the whole backend returns those lines and nothing else.
The **executor**-profile save path is a different code path entirely — HTTP
(`internal/task/handlers/executor_profile_handlers.go`), WS, and MCP
(`internal/mcp/handlers/config_executor_handlers.go`) all reach
`task/service.CreateExecutorProfile` / `UpdateExecutorProfile`
(`internal/task/service/service_resources.go:1587` / `:1625`), which validates exactly two things:
- global-secret references (`validateGlobalProfileEnvRefs`, `service_resources.go:1665`), and
- the Sprites token requirement.
It enforces NO value-or-secret rule, NO reserved-key rule, and NO duplicate-key rule. The web
editor does not compensate: `rowsToEnvVars`
(`apps/web/components/settings/profile-edit/env-vars-card.tsx`) filters empty KEYS only, so
`{key: "K", value: "", secretID: ""}` is savable from the UI as well as from HTTP, WS and
agent-driven MCP.
**Consequences this spec must own, and does:** an executor profile can carry a blank-valued entry
(AC-38, AC-38b), a `KANDEV_*` key (AC-40), and two entries with the same key and different values
(AC-6a). None of these is reachable from an agent profile.
### The primary path strips two shell keys before assembly
`resolveLaunchEnvironment` calls `applyPreferredShellEnvWithStatus`, and when a preferred shell
applies it drops `SHELL` and `AGENTCTL_SHELL_COMMAND` from the executor profile's env vars
before `taskEnvironmentSources` runs (`withoutPreferredShellProfileEnvVars` /
`isPreferredShellEnvKey`, `task_environment.go:141-154`; pinned by
`TestResolveLaunchEnvironment_PreferredShellWinsOverProfileShell`,
`task_environment_test.go:93`).
This spec does not change it, and it is recorded only so nobody debugging tier precedence with
key `SHELL` loses an hour watching an executor-profile value vanish before it ever reaches the
resolver. It is on the PRIMARY path only; the restart-recovery path applies no such filter.
### `managed agent defaults` is already lower than `agent profile`
`appendAgentRuntimeDefaults` (`environment_resolution.go:75`) skips any key for which
`hasEnvironmentDefinition` is already true. Agent-profile definitions are appended first
(`environment_resolution.go:40` before `:43`), so an agent-runtime default never competes with an
agent-profile value. This is pinned by
`TestAppendAgentRuntimeDefaultsFillsOnlyMissingKeys`
(`environment_resolution_defaults_test.go:17`), which asserts `"profile-wins"`.
### `mergeEnvFillMissing` is a different layer
`mergeEnvFillMissing` (`apps/backend/internal/agent/runtime/lifecycle/profile_env.go:57`) fills
agent-profile values into an already-built map at agent launch. The conflict check runs first and
a conflicting pair never reaches it. This spec does not change that function.
### The governing ADR already declares the layering
ADR-2026-08-03 (`docs/decisions/2026-08-03-scope-and-merge-repository-secrets.md`) models the
environment as a tree and states, twice, that the agent profile sits **below** the task
environment:
> Agent profile environment: existing fill-missing defaults applied after the task environment is
> resolved; Global secrets only.
> The existing agent-profile contract remains a lower-priority default: it fills keys absent from
> the resolved task environment.
The strict resolver flattened the agent profile into a peer origin of the task environment. That
contradicts the ADR. This spec restores the declared layering rather than inventing a new tier.
The same ADR fixes the invariants this spec must not break:
- "the same key bound to different secret IDs blocks launch";
- "an executor literal and repository secret using the same key block launch, even if their
current plaintext happens to match";
- "a repository key colliding with a managed runtime value blocks launch rather than replacing
it";
- "repository order and task-repository position never choose a winner";
- alternative 6, "Compare decrypted values and deduplicate equal plaintext", was **rejected**.
## Decision summary
1. Environment origins are ranked into three tiers. A higher tier wins a key outright.
2. Within the top tier, origins remain peers. Disagreement there still blocks launch, unchanged.
3. Precedence applies only when **every** definition for that key is literal-backed. If any
definition for the key is secret-backed, the conflict still blocks launch.
4. An override that actually takes effect is recorded and logged. Silence is not acceptable for a
value that changes where an agent sends inference.
5. Nothing migrates. This is a new capability, not a data change.
6. The primary assembly site gains ONE behaviour change beyond precedence: it drops a profile entry
whose value and secret are both empty, matching what the restart-recovery site already does.
Without it, tier precedence would let a blank executor-profile value beat a real agent-profile
value and launch the agent with the key set to the empty string. See AC-38.
## Precedence model
### Tiers
| Tier | Name | Members |
| --- | --- | --- |
| 1 | Authoritative | `managed runtime`, `managed credentials`, `executor profile`, any origin for which `IsRepositoryOrigin` reports true, **and any origin not recognised by this table** |
| 2 | Profile default | `agent profile` |
| 3 | Agent runtime default | `managed agent defaults` |
Tier 1 members are peers with each other. Tier 1 beats Tier 2 beats Tier 3.
Tier membership is decided by the origin string. Because the two assembly sites do not share
constants today, tier classification must be driven from one shared, exported table so the sites
cannot drift.
#### What counts as a repository origin — ONE normative rule
There is exactly one definition, and everything else in this spec refers to it rather than
restating it:
> An origin is a REPOSITORY ORIGIN **if and only if** its first whitespace-delimited field — the
> first element of `strings.Fields(origin)` — is exactly `repository`.
This is the NORMATIVE rule, and `IsRepositoryOrigin(origin string) bool` (AC-19c) is its single
implementation. Both the tier classifier and the metric-label normaliser (AC-25) call that one
function; neither re-derives the rule.
`OriginRepositoryPrefix` (the literal `"repository "`) is a CONSTRUCTION constant, not a matching
rule. The assembly sites use it to BUILD an origin (`OriginRepositoryPrefix + name`); nothing
classifies with `strings.HasPrefix` against it. The distinction is load-bearing because the two
are not equivalent: `task_environment.go:77` and `environment_resolution.go:225` emit the BARE
string `repository` when the repository name is blank, and a `HasPrefix(origin, "repository ")`
test rejects that string while the normative rule accepts it. Both readings happen to land on
Tier 1 and on the same label today, so this is a latent divergence rather than a live bug — but
two independent spellings of this rule is exactly the drift AC-19a exists to prevent, so the spec
keeps only one.
Worked consequences of the normative rule, so Build does not have to guess at the boundaries:
| Origin | Repository origin? | Why |
| --- | --- | --- |
| `repository` | yes | sole field is `repository` (the blank-name case, produced today) |
| `repository app` | yes | first field is `repository` |
| `repository app` (two spaces) | yes | `strings.Fields` collapses runs of whitespace |
| `repository\tapp` (tab) | yes | `strings.Fields` splits on any whitespace, not only spaces |
| `repositoryapp` | no | first field is `repositoryapp` — unrecognised, so Tier 1 by AC-35 |
| `` (empty) | no | no fields at all — unrecognised, so Tier 1 by AC-35 |
### Where the constants and the tier table live
Both live in `apps/backend/internal/agent/runtime/environment` — the `environment` package, which
both assembly sites already import as `runtimeenv`. That package is where tier classification
actually runs, and both sites already depend on it, so this introduces no new edge in the import
graph. Concretely it gains exported constants `OriginManagedRuntime`, `OriginManagedCredentials`,
`OriginManagedAgentDefaults`, `OriginAgentProfile`, `OriginExecutorProfile`, and
`OriginRepositoryPrefix` (the literal `"repository "`, a construction constant per the normative
rule above), plus the tier table and its classifier. The unexported lifecycle constants at
`environment_resolution.go:17-21` and the hardcoded orchestrator strings at
`task_environment.go:63` and `:67` are replaced by references to them.
**The classifier's exported surface is named here, not left to the builder** (AC-19c). The spec
declares every other symbol it introduces, and the tier table is the one artefact AC-19a and AC-35
both hang off, so it gets the same treatment:
```go
// TierForOrigin classifies one origin string into its tier. It is the ONLY
// place the tier table is read. An origin the table does not name classifies
// as TierAuthoritative (AC-35).
func TierForOrigin(origin string) Tier
// IsRepositoryOrigin reports whether origin is a repository origin, per the
// single normative rule: strings.Fields(origin)[0] == "repository".
// TierForOrigin and NormalizeOriginLabel both call it; neither restates it.
func IsRepositoryOrigin(origin string) bool
```
THE TABLE ITSELF IS UNEXPORTED — an unexported package-level `map[string]Tier` reached only
through `TierForOrigin`. This is deliberate rather than incidental: an exported map is mutable by
any importer, so exporting it would let a caller silently re-rank precedence at run time and
defeat the single-source-of-truth guarantee AC-19a is asking for. A function cannot be mutated,
and it is also where the "unrecognised origins are Tier 1" default and the `IsRepositoryOrigin`
call live, neither of which a bare map lookup can express.
This is a placement decision about ownership, not a workaround for an import restriction. The
orchestrator already imports `runtime/lifecycle` directly (`executor.go`,
`executor_environment_reuse.go`, `executor_execute.go`), so exporting the constants from
`lifecycle` would also have compiled. `environment` is chosen because the resolver owns the tier
decision, and a table read by the resolver should not live in one of the resolver's callers.
### Why `repository` is in Tier 1 and not above `executor profile`
Repository bindings are secret-only by ADR construction ("Bindings contain no literal values"),
so a repository definition is always secret-backed. Rule 3 therefore blocks any repository
disagreement before tier rank is ever consulted. Placing repository in Tier 1 keeps the ADR's
"never silently replaced" guarantee absolute, without needing a repository-specific carve-out.
### Why `managed credentials` is in Tier 1
A managed credential is the value the install has been configured to authenticate with, resolved
from the credential manager for a key the agent runtime declares in `RequiredEnv`. It describes
the machine's identity, not the agent's persona, which is the same test that puts
`managed runtime` and `executor profile` in Tier 1.
State the consequence plainly, because AC-3 changes behaviour: a `managed credentials` value and a
differing `agent profile` literal for the same key block the launch today, and after this change
the managed credential wins and the agent-profile literal is discarded with an override record.
For an API-key-shaped key that changes which account is billed. That is the intended direction —
a per-profile literal should not silently redirect billing away from the install's configured
credential — and it is observable, logged (AC-23) and counted (AC-24) rather than silent. The
common case is unaffected either way: `appendRequiredCredentialDefinitions` skips any key whose
credential lookup returns empty, and an identical pair merges without an override (AC-15).
### Why unrecognised origins are Tier 1
Fail closed. A typo'd or newly added origin must never become a silent loser. Classifying it into
the peer group preserves today's conflict behaviour for anything this table does not name.
## The secret veto
For one key, after collecting all its definitions:
- If two or more definitions have different `definitionIdentity` values, and **any** definition
for that key is secret-backed (`SecretID != ""`), the resolver returns `*ConflictError` naming
every participating origin — defined precisely below as every CONTRIBUTING origin of every
surviving identity, de-duplicated and sorted ascending. Tier rank is not consulted.
- The veto is symmetric. It applies whether the higher-tier definition, the lower-tier
definition, or both are secret-backed.
Rationale: silently discarding a token source, or silently choosing between two token sources, is
exactly the failure ADR-2026-08-03 rejected. Rotation and identity semantics matter more than
convenience, and the values are not comparable without decrypting them, which the ADR also
rejected.
### "Every participating origin" means every CONTRIBUTING origin, on the veto path too
`ConflictError.Origins` SHALL hold **every contributing origin of every surviving identity**,
de-duplicated and sorted ascending — NOT one origin per surviving identity, and NOT the surviving
`Definition.Origin` field alone.
This is the same rule procedure step 6 states for the precedence path, and it is spelled out here
because the veto path needs it MORE, not less. Merging (step 3) runs before the veto (step 5), so
a merged identity reaching the veto already carries several origins: a `repository app` binding
and an `executor profile` entry that reference one `SecretID` share the identity `"secret:<id>"`
and collapse into a single surviving definition whose `Origin` field can only name one of them.
An implementation that enumerates `Definition.Origin` per surviving identity therefore drops
`executor profile` (or `repository app`, depending on sort order) from the error the user reads,
while every precedence test still passes.
The veto path is also the more production-reachable of the two: ADR-2026-08-03 makes every
repository binding secret-only, so real-world secret disagreements arrive here rather than at
step 6. This preserves today's behaviour rather than changing it — `selectDefinitions` already
folds the accumulated origin set of the prior identity into the conflict set before adding the
differing definition's origin, and already returns that set de-duplicated and sorted. AC-12a
observes it, and the existing
`TestResolveEnvironmentSources_ReportsEveryConflictingOrigin` asserts against the same field.