9fe867f9fe
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
1276 lines
86 KiB
Markdown
1276 lines
86 KiB
Markdown
---
|
|
status: building
|
|
created: 2026-08-17
|
|
owner: tbd
|
|
---
|
|
|
|
# Executor-Profile Environment Precedence
|
|
|
|
## 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.
|
|
|
|
## Resolution procedure
|
|
|
|
The order of these steps is part of the contract. A different order produces different answers for
|
|
mixed secret and multi-origin cases.
|
|
|
|
For each environment key, in a deterministic TOTAL sort order over named columns.
|
|
|
|
The sort that exists today at `environment.go:97` compares `Key`, then `Origin`, then `SecretID`.
|
|
That is not a total order, and the gap is load-bearing rather than theoretical: two definitions
|
|
agreeing on all three but differing in `WorkspaceID` share one `definitionIdentity` (identity is
|
|
`"secret:" + SecretID` for a secret), so they MERGE, and which one survives is decided by input
|
|
order — while `WorkspaceID` is exactly what `resolveEnvironmentDefinition` branches on to choose
|
|
between a global reveal and `RevealForWorkspace`. Input order could therefore change the revealed
|
|
value, which AC-30 forbids.
|
|
|
|
The sort therefore gains two further NAMED columns and becomes, all ascending:
|
|
|
|
Key, then Origin, then SecretID, then WorkspaceID, then Literal
|
|
|
|
Adding columns can only break ties that were previously left to input order, so no definition set
|
|
whose outcome is already deterministic changes (AC-16, AC-29a).
|
|
|
|
For each key, in that order:
|
|
|
|
1. **Skip blanks.** Discard any definition whose `Key` is empty or whitespace-only.
|
|
2. **Group** the remaining definitions by `Key`.
|
|
3. **Merge identical identities.** Definitions sharing one `definitionIdentity` collapse to a
|
|
single surviving definition, recording every contributing origin, exactly as today. Merging
|
|
happens **before** any tier or secret evaluation. The merged group RETAINS THE FULL SET of
|
|
contributing origins; step 6 classifies the group by that set, not by the single surviving
|
|
`Definition.Origin` field.
|
|
4. **If one identity survives**, that is the value. No conflict, no override record.
|
|
5. **If more than one identity survives, apply the secret veto first.** If any surviving
|
|
definition for that key has a non-empty `SecretID`, return `*ConflictError` whose `Origins`
|
|
holds **every CONTRIBUTING origin of every surviving identity** (per "The secret veto"),
|
|
de-duplicated and sorted ascending. Tier rank is not consulted.
|
|
6. **Otherwise compare tiers.** Classify each surviving IDENTITY — not each surviving
|
|
`Definition` — into a tier by `TierForOrigin`. **An identity's tier is the STRONGEST
|
|
(numerically smallest) tier among ALL of its contributing origins.**
|
|
- If the highest occupied tier holds more than one surviving identity, return
|
|
`*ConflictError` whose `Origins` holds every CONTRIBUTING origin of every surviving identity,
|
|
including origins from lower tiers, de-duplicated and sorted ascending.
|
|
- Otherwise the single identity in the highest occupied tier wins, every identity in a lower
|
|
tier is discarded, and one override record is emitted. Both origin lists in that record are
|
|
DE-DUPLICATED SETS (see "The override record"): `WinningOrigins` holds each distinct
|
|
contributing origin of the winning identity once, and `LosingOrigins` holds each distinct
|
|
origin appearing anywhere among the discarded identities once — one entry per ORIGIN, never
|
|
one per discarded identity.
|
|
7. **Reveal** secrets only for surviving winners, after the whole key set is conflict-free,
|
|
preserving today's ordering in `Resolve`.
|
|
|
|
Step 5 preceding step 6 is deliberate: a secret and a literal that disagree must block even when
|
|
they sit in different tiers.
|
|
|
|
Steps 5 and 6 use ONE enumeration rule between them — every contributing origin of every surviving
|
|
identity, de-duplicated and sorted. Stating it once per step rather than only on the precedence
|
|
path is deliberate: merging happens at step 3, before either, so BOTH paths can face a surviving
|
|
identity whose single `Definition.Origin` field under-reports the origins that produced it.
|
|
|
|
Step 6's "including origins from lower tiers" is deliberate: when the top tier is itself
|
|
ambiguous, the error should name every origin the user has to look at, matching today's behaviour
|
|
of reporting the complete participating set.
|
|
|
|
**Step 6 classifies the IDENTITY by its strongest contributing origin, and that word "strongest"
|
|
is load-bearing.** Classifying by the surviving `Definition.Origin` instead would silently
|
|
discard a Tier 1 value in the most common real configuration this feature exists to serve.
|
|
Worked case: key `AGENT_MODEL` carries `executor profile` literal X, `agent profile` literal X
|
|
(the SAME literal — precisely the duplicate-the-value workaround the Why section says users
|
|
employ today), and `managed runtime` literal Y from `profileInfo.Model`
|
|
(`environment_resolution.go:65`). Step 3 merges the X pair into one identity. Under the
|
|
five-column sort the surviving `Definition` is the `agent profile` one, because
|
|
`"agent profile" < "executor profile"` — so classifying by that single field would put identity X
|
|
in Tier 2, leave Y as the sole Tier 1 identity, and hand the key to Y while silently dropping an
|
|
`executor profile` literal that disagrees with it. AC-5 requires that pair to conflict. Under the
|
|
strongest-contributing-origin rule, identity X is Tier 1 (via `executor profile`), the top tier
|
|
holds two identities, and the key conflicts — which is what AC-5 says and what AC-5a pins.
|
|
|
|
## The override record
|
|
|
|
The record is a declared Go type, not a shape left to the builder. It lives in the `environment`
|
|
package beside the tier table, and it carries identity and rank only — never a value.
|
|
|
|
```go
|
|
// Tier ranks an origin. LOWER IS STRONGER: Tier 1 beats Tier 2 beats Tier 3.
|
|
type Tier int
|
|
|
|
const (
|
|
TierAuthoritative Tier = 1
|
|
TierProfileDefault Tier = 2
|
|
TierAgentRuntimeDefault Tier = 3
|
|
)
|
|
|
|
// OriginTier pairs an origin string with the tier it classified into.
|
|
type OriginTier struct {
|
|
Origin string
|
|
Tier Tier
|
|
}
|
|
|
|
// OverrideRecord describes one environment key whose value was chosen by tier
|
|
// precedence instead of blocking the launch. It contains no literal value, no
|
|
// SecretID, and no decrypted secret.
|
|
type OverrideRecord struct {
|
|
Key string // the environment key that was overridden
|
|
WinningOrigins []string // SET: >= 1 entry, each distinct origin ONCE,
|
|
// sorted ascending; MAY span tiers
|
|
WinningTier Tier // the STRONGEST tier among WinningOrigins — the tier the
|
|
// winning identity was classified into by procedure step 6
|
|
LosingOrigins []OriginTier // SET: >= 1 entry, each distinct origin ONCE,
|
|
// sorted by Origin ascending
|
|
}
|
|
```
|
|
|
|
`WinningOrigins` is a SLICE, not a string. A merged winner can legitimately have more than one
|
|
contributing origin — that is exactly the case AC-8a describes, where two Tier 1 definitions share
|
|
one `definitionIdentity`, merge, and then beat a Tier 2 literal.
|
|
|
|
### BOTH ORIGIN LISTS ARE SETS, NOT BAGS
|
|
|
|
This is a contract, not an implementation detail, because it decides observable test assertions
|
|
and observable counter values:
|
|
|
|
> **Every origin string appears AT MOST ONCE in `WinningOrigins`, and at most once across all of
|
|
> `LosingOrigins`.** Both lists are de-duplicated by origin string, then sorted. Neither list ever
|
|
> contains two entries carrying the same `Origin`.
|
|
|
|
Concretely, per list:
|
|
|
|
- **`WinningOrigins`** holds each DISTINCT origin that contributed to the winning identity. Two
|
|
definitions that share both an origin and a `definitionIdentity` contribute ONE entry, not two.
|
|
- **`LosingOrigins`** holds each DISTINCT origin appearing anywhere among the discarded
|
|
identities, paired with that origin's own tier. One entry per ORIGIN — **never one per discarded
|
|
identity.** So when a single losing origin contributes SEVERAL differing identities, which is
|
|
exactly what AC-6's stronger-tier carve-out produces, that origin still yields ONE
|
|
`LosingOrigins` entry and therefore ONE counter increment (AC-8d).
|
|
|
|
Because `Tier` is functionally determined by `Origin` through `TierForOrigin`, de-duplicating by
|
|
origin can never discard distinct tier information: two entries sharing an `Origin` are always
|
|
identical `OriginTier` values.
|
|
|
|
**The two lists are de-duplicated INDEPENDENTLY, and one origin MAY appear in both.** The rule
|
|
above bounds each list on its own; it does not make them disjoint, and THE SYSTEM SHALL NOT
|
|
subtract `WinningOrigins` from `LosingOrigins`. Worked case: `executor profile` = X,
|
|
`agent profile` = X, `agent profile` = Y. Identity X is contributed by both origins and classifies
|
|
Tier 1; identity Y is contributed by `agent profile` alone and classifies Tier 2; X wins. Then
|
|
`WinningOrigins` is `[agent profile, executor profile]` and `LosingOrigins` is
|
|
`[{agent profile, TierProfileDefault}]` — the same origin on both sides, because it genuinely both
|
|
contributed to the winner and had a distinct value discarded. The metric label pair is
|
|
`winning_origin=agent profile+executor profile;losing_origin=agent profile`, which is correct
|
|
rather than a defect to normalise away.
|
|
This is resolver-level only, for the same reason AC-6's ambiguous case is: reaching it needs one
|
|
origin contributing two differing identities, and the only origin that can do that is
|
|
`executor profile` (AC-6a), which is Tier 1 and therefore never a loser. It is pinned because the
|
|
rule is otherwise silent on the interaction and a builder could reasonably "clean up" the
|
|
duplicate, changing an observable counter value with no AC to catch it.
|
|
|
|
Three reasons this is the SET reading and not the bag reading, in order of weight:
|
|
|
|
1. **It preserves today's behaviour.** `selectDefinitions` already accumulates contributing
|
|
origins in a `map[string]struct{}` (`origins[key][origin] = struct{}{}`) and already emits
|
|
`ConflictError.Origins` de-duplicated and sorted. The bag reading would be a silent behaviour
|
|
CHANGE to merge-origin reporting on a path this feature was not asked to touch.
|
|
2. **It is what the counter means.** AC-24 counts "per-ORIGIN, not per input definition". Under the
|
|
bag reading, one key overriding one origin could increment the same
|
|
`winning_origin=…;losing_origin=…` label pair twice, which makes the metric a count of internal
|
|
identity groupings rather than of overrides.
|
|
3. **It makes AC-28's ordering total.** With origins unique, `Origin` alone totally orders
|
|
`LosingOrigins`; under the bag reading it does not, and AC-27's deep-equality requirement would
|
|
rest on an undefined order between indistinguishable entries.
|
|
|
|
**The winning origins may span tiers, and `WinningTier` is the strongest of them.** A merge groups
|
|
definitions by `definitionIdentity`, which is the hash of the literal (or the secret ID) and says
|
|
nothing about origin, so one identity can be contributed by an `executor profile` (Tier 1) and an
|
|
`agent profile` (Tier 2) that happen to carry the same value. `WinningTier` therefore records the
|
|
tier the identity was CLASSIFIED into by procedure step 6 — the numerically smallest tier among
|
|
`WinningOrigins` — not a property shared by every member. Consumers that need the per-origin
|
|
detail read `WinningOrigins`; `WinningTier` answers only "which tier won this key".
|
|
|
|
Note the remaining asymmetry with `LosingOrigins`, which pairs each origin with its OWN tier via
|
|
`OriginTier`. That is deliberate and is not affected by the set rule above: losers are enumerated
|
|
per origin because AC-24 fans the counter out per losing origin, whereas the winner is a single
|
|
classified identity with a single rank.
|
|
|
|
### Signatures
|
|
|
|
```go
|
|
// CHANGED: gains a third return value.
|
|
func Resolve(ctx context.Context, definitions []Definition, reveal RevealFunc) (map[string]string, []OverrideRecord, error)
|
|
|
|
// UNCHANGED.
|
|
func Validate(definitions []Definition) error
|
|
```
|
|
|
|
`Resolve` gains a return value. That is a deliberate, named change to the function signature; the
|
|
exported FIELDS of `Definition`, `ConflictError` and `SecretError` are untouched. There are only
|
|
two call sites to update: `Manager.resolveStrictEnvironment`
|
|
(`environment_resolution.go:51`, the sole production resolve site) and `resolveEnvironmentSources`
|
|
(`task_environment.go:40`, which today is reached only from tests).
|
|
|
|
**What each call site does with the new value.** `Manager.resolveStrictEnvironment` consumes the
|
|
records: it emits the AC-23 log and the AC-24 counter increments. `resolveEnvironmentSources`
|
|
DISCARDS them (`resolved, _, err := ...`) and SHALL NOT log or count. That is not laziness about a
|
|
test helper — it is AC-46's rule applied consistently: emission is tied to an actual launch
|
|
resolution, and this function is reached from no production path (5 test callers, 0 production).
|
|
A test helper that incremented the shared expvar map would make the counter a count of test runs.
|
|
If a future change gives it a production caller, that caller must take over the emission and this
|
|
clause must be revisited.
|
|
|
|
**On any error, `Resolve` returns NO override records.** When the third return value is non-nil the
|
|
second SHALL be `nil` and the first SHALL be `nil`, matching today's `return nil, err` on every
|
|
failure path. This is all-or-nothing, not partial (AC-20a).
|
|
|
|
The case is reachable and is not hypothetical. Records are computed for every key before any
|
|
secret is revealed, because step 7 reveals only after the whole key set is conflict-free. So a set
|
|
where key `A` is resolved by tier precedence while key `B` conflicts, or where `A` overrides and
|
|
any secret then fails to reveal, has already produced a record for `A` at the moment the error is
|
|
returned. Handing those records back would let `Manager.resolveStrictEnvironment` log
|
|
`environment override applied` and increment `environment_override_applied_total` for a launch
|
|
that failed and applied nothing — corrupting the counter AC-24 defines and contradicting AC-46's
|
|
"tied to an actual resolution". A launch that does not start has not overridden anything.
|
|
|
|
`Validate` keeps its `error`-only signature and emits no records, no log, and no counter
|
|
increment. This is safe rather than merely convenient: the preflight set is assembled by
|
|
`Executor.taskEnvironmentSources` from `managed runtime`, `executor profile` and
|
|
`repository <name>` definitions only — every one of them Tier 1 — because agent-profile
|
|
definitions are not appended until `resolveStrictEnvironment` runs at the lifecycle site. A
|
|
Tier-1-only set can never occupy two tiers, so it can never produce an override, so there is
|
|
nothing for `Validate` to return. If a future change adds a Tier 2 or Tier 3 definition to the
|
|
preflight set, this AC must be revisited (AC-19b).
|
|
|
|
## Acceptance criteria
|
|
|
|
Observable behaviour. Each criterion is pass/fail against the resolver's return value, the emitted
|
|
override records, or the resulting environment map.
|
|
|
|
### Precedence
|
|
|
|
- **AC-1** WHEN a key has one literal definition from origin `executor profile` and one literal
|
|
definition from origin `agent profile` with a different value, THE SYSTEM SHALL resolve that key
|
|
to the `executor profile` value and SHALL NOT return an error.
|
|
- **AC-2** WHEN a key has one literal definition from origin `managed runtime` and one literal
|
|
definition from origin `agent profile` with a different value, THE SYSTEM SHALL resolve that key
|
|
to the `managed runtime` value and SHALL NOT return an error.
|
|
- **AC-3** WHEN a key has one literal definition from origin `managed credentials` and one literal
|
|
definition from origin `agent profile` with a different value, THE SYSTEM SHALL resolve that key
|
|
to the `managed credentials` value and SHALL NOT return an error.
|
|
- **AC-4** WHEN the resolver receives one literal definition from origin `agent profile` and one
|
|
literal definition from origin `managed agent defaults` for the same key with different values,
|
|
THE SYSTEM SHALL resolve that key to the `agent profile` value and SHALL NOT return an error.
|
|
This is a resolver-level criterion. In the assembled launch path the pair cannot occur, because
|
|
`appendAgentRuntimeDefaults` drops the agent-runtime default before resolution (AC-17). Tier 3
|
|
exists so the resolver's table is total and the assembly-time skip is a redundant optimisation
|
|
rather than the only thing preventing a conflict.
|
|
- **AC-4a** WHEN a definition is dropped at assembly time by `appendAgentRuntimeDefaults`, THE
|
|
SYSTEM SHALL NOT emit an override record for that key. Assembly-time filtering is not an
|
|
override; only a resolver tier decision is.
|
|
- **AC-5** WHEN a key has literal definitions from two different Tier 1 origins with different
|
|
values, THE SYSTEM SHALL return a `*ConflictError` whose `Origins` lists every participating
|
|
Tier 1 origin sorted ascending, and SHALL NOT resolve the key.
|
|
- **AC-5a** WHEN a key has an `executor profile` literal and an `agent profile` literal carrying
|
|
THE SAME value, plus a `managed runtime` literal carrying a DIFFERENT value, THE SYSTEM SHALL
|
|
return a `*ConflictError` naming all three origins sorted ascending, and SHALL NOT resolve the
|
|
key. The merged `executor profile`/`agent profile` identity classifies as Tier 1 via its
|
|
strongest contributing origin (procedure step 6), so the top tier holds two identities and AC-5
|
|
applies. THE SYSTEM SHALL NOT classify that merged identity by the surviving `Definition.Origin`
|
|
field alone, which would rank it Tier 2 and silently discard the `executor profile` value.
|
|
This case is production-reachable and is the reason step 6 says "strongest": `AGENT_MODEL` is
|
|
emitted as `managed runtime` from `profileInfo.Model` (`environment_resolution.go:65`) and is
|
|
not a reserved key (`profile_crud.go:867-868` reserve only `TASK_DESCRIPTION` and `KANDEV_*`),
|
|
so a user who duplicated it onto both profiles — the workaround this feature replaces — hits it.
|
|
- **AC-5b** WHEN a key has an `executor profile` literal and an `agent profile` literal carrying
|
|
THE SAME value, plus a differing literal from a Tier 3 origin, THE SYSTEM SHALL resolve the key
|
|
to the merged value and emit ONE `OverrideRecord` whose `WinningOrigins` holds BOTH
|
|
`agent profile` and `executor profile` sorted ascending and whose `WinningTier` is
|
|
`TierAuthoritative`. This pins that `WinningOrigins` may span tiers while `WinningTier` records
|
|
only the strongest. This is a resolver-level criterion; in the assembled launch path
|
|
`appendAgentRuntimeDefaults` drops the Tier 3 definition first (AC-17).
|
|
- **AC-6** WHEN a key has two definitions carrying the same `Origin` string and different
|
|
`definitionIdentity` values, AND no definition for that key sits in a STRONGER tier than that
|
|
origin, THE SYSTEM SHALL return a `*ConflictError`. Where a stronger tier does hold exactly one
|
|
surviving identity, procedure step 6 governs instead: the stronger tier wins and the ambiguous
|
|
weaker-tier origins are discarded into `LosingOrigins`, because a set of definitions that is
|
|
being thrown away wholesale need not be self-consistent first. That discarded origin appears in
|
|
`LosingOrigins` EXACTLY ONCE even though it contributed two identities, because both origin
|
|
lists are de-duplicated sets ("The override record"); AC-8d observes this case directly.
|
|
Same-origin ambiguity WITHIN the winning tier still conflicts, which is the case that matters:
|
|
two differing `executor profile` definitions, or two differing `managed runtime` definitions,
|
|
block the launch exactly as today (AC-5).
|
|
REACHABILITY IS PER-ORIGIN, AND ONE ORIGIN REACHES THIS IN PRODUCTION. An earlier draft claimed
|
|
the assembled launch path "cannot produce two same-origin definitions that disagree, because
|
|
duplicate keys within one profile are rejected at save time (`profile_crud.go:908`)". That is
|
|
true for `agent profile` and false for `executor profile`: the duplicate-key check is
|
|
agent-profile-only (see "The two profile kinds are NOT validated the same way"), so one executor
|
|
profile may legitimately hold two entries with the same key and different values. Per origin:
|
|
- `agent profile` — NOT reachable; duplicate keys rejected at save time (`profile_crud.go:908`).
|
|
- `managed agent defaults` — NOT reachable; built from a map.
|
|
- `managed runtime` — reachable in principle, since `req.Env` and `appendStandardDefinitions`
|
|
both emit this origin, though a collision there is a managed-value bug rather than user input.
|
|
- `executor profile` — REACHABLE from ordinary user input, with no validation preventing it.
|
|
The behaviour is nonetheless unchanged from today: every one of those origins is Tier 1, so with
|
|
no stronger tier present the key returns `*ConflictError` exactly as it does now. AC-6a requires
|
|
the test for the reachable case.
|
|
- **AC-6a** WHEN one executor profile holds TWO entries with the same key and different non-empty
|
|
values, and no definition for that key sits in a stronger tier, THE SYSTEM SHALL return a
|
|
`*ConflictError` naming `executor profile`, and SHALL NOT resolve the key by picking either
|
|
value.
|
|
This is the ASSEMBLED-PATH case AC-6 says is reachable, and it SHALL be tested through
|
|
`Executor.taskEnvironmentSources` rather than only against the resolver, because the claim being
|
|
pinned is that a real executor profile can produce the pair. `executor profile` is Tier 1, so
|
|
this is AC-5's same-tier behaviour and is unchanged from today; the AC exists so the reachable
|
|
case has a test rather than resting on a false save-time guarantee.
|
|
- **AC-7** WHEN a key is defined only by origin `agent profile` and no executor profile is
|
|
selected for the session, THE SYSTEM SHALL resolve that key to the `agent profile` value.
|
|
- **AC-8** WHEN a key is defined only by origin `agent profile` and the selected executor profile
|
|
declares no environment variables, THE SYSTEM SHALL resolve that key to the `agent profile`
|
|
value.
|
|
- **AC-8a** WHEN a key has two Tier 1 definitions that share one `definitionIdentity` and one
|
|
differing literal Tier 2 definition, THE SYSTEM SHALL merge the Tier 1 pair first, then apply
|
|
tier precedence, resolving the key to the merged Tier 1 value and emitting a single
|
|
`OverrideRecord` whose `WinningOrigins` holds BOTH contributing Tier 1 origins sorted ascending,
|
|
whose `WinningTier` is `TierAuthoritative`, and whose `LosingOrigins` holds the one Tier 2
|
|
origin. Merging precedes tier comparison (procedure step 3 before step 6). This is the case that
|
|
makes `WinningOrigins` a slice rather than a string.
|
|
- **AC-8b** WHEN a key has two differing literal Tier 1 definitions and one differing literal
|
|
Tier 2 definition, THE SYSTEM SHALL return a `*ConflictError` whose `Origins` names all three
|
|
origins, sorted ascending. An ambiguous top tier is not resolved by falling through to a lower
|
|
tier.
|
|
- **AC-8c** WHEN a key has one literal Tier 1 definition and differing literal definitions in both
|
|
Tier 2 and Tier 3, THE SYSTEM SHALL resolve the key to the Tier 1 value and emit ONE
|
|
`OverrideRecord` whose `LosingOrigins` holds both the Tier 2 and the Tier 3 origin, each paired
|
|
with its own tier, sorted by `Origin` ascending. Per AC-24 this one record produces TWO counter
|
|
increments, one per losing origin.
|
|
- **AC-8d** WHEN a key has ONE literal Tier 1 definition and TWO literal definitions that carry
|
|
THE SAME lower-tier `Origin` string as each other but DIFFERENT `definitionIdentity` values,
|
|
THE SYSTEM SHALL resolve the key to the Tier 1 value and emit ONE `OverrideRecord` whose
|
|
`LosingOrigins` has `len(LosingOrigins) == 1` — a single entry for that origin paired with its
|
|
own tier — and which produces EXACTLY ONE counter increment.
|
|
This is the losing-side de-duplication pin, and it is the case AC-6's stronger-tier carve-out
|
|
creates: one origin contributing two discarded identities is ONE loser, not two. A test asserting
|
|
`len(LosingOrigins) == 2` or two increments is asserting the rejected bag reading.
|
|
This one IS genuinely resolver-level, and unlike AC-6 the reason survives the per-origin check in
|
|
AC-6a. The ambiguous origin here is a LOSING origin, so it necessarily sits in Tier 2 or Tier 3 —
|
|
a Tier 1 origin can never lose, and an ambiguity inside Tier 1 conflicts instead (AC-5, AC-6a).
|
|
The only Tier 2 origin is `agent profile`, whose duplicate keys ARE rejected at save time
|
|
(`profile_crud.go:908`), and the only Tier 3 origin is `managed agent defaults`, which is built
|
|
from a map. `executor profile`, the one origin that can produce disagreeing duplicates, is Tier 1
|
|
and therefore cannot appear in `LosingOrigins` at all.
|
|
- **AC-8e** WHEN a key has ONE Tier 1 value contributed by TWO definitions that share BOTH an
|
|
`Origin` string and a `definitionIdentity`, plus a differing literal definition in a lower tier,
|
|
THE SYSTEM SHALL emit ONE `OverrideRecord` whose `WinningOrigins` has
|
|
`len(WinningOrigins) == 1` — that origin listed once, not twice.
|
|
This is the winning-side de-duplication pin. It matters beyond symmetry: today's
|
|
`selectDefinitions` already accumulates contributing origins into a `map[string]struct{}`, so the
|
|
bag reading would be a silent behaviour change to existing merge-origin reporting. Contrast with
|
|
AC-8a, where two DISTINCT origins contribute one identity and both are listed.
|
|
|
|
### Secret veto
|
|
|
|
- **AC-9** IF a key has definitions with different `definitionIdentity` values and at least one of
|
|
them has a non-empty `SecretID`, THEN THE SYSTEM SHALL return a `*ConflictError` and SHALL NOT
|
|
apply tier precedence, even when the definitions sit in different tiers.
|
|
- **AC-10** WHEN a key has a secret-backed `executor profile` definition and a literal
|
|
`agent profile` definition, THE SYSTEM SHALL return a `*ConflictError`.
|
|
- **AC-11** WHEN a key has a literal `executor profile` definition and a secret-backed
|
|
`agent profile` definition, THE SYSTEM SHALL return a `*ConflictError`.
|
|
- **AC-12** WHEN a key has a secret-backed `repository <name>` definition and any differing
|
|
definition from any other origin, THE SYSTEM SHALL return a `*ConflictError`, preserving
|
|
ADR-2026-08-03.
|
|
- **AC-12a** WHEN a key has a secret-backed `repository app` definition and a secret-backed
|
|
`executor profile` definition that carry THE SAME `SecretID`, plus a differing literal
|
|
`agent profile` definition, THE SYSTEM SHALL return a `*ConflictError` whose `Origins` names ALL
|
|
THREE origins — `agent profile`, `executor profile`, `repository app` — de-duplicated and sorted
|
|
ascending, and SHALL NOT resolve the key.
|
|
The two secret-backed definitions share the identity `"secret:<id>"` and MERGE at procedure
|
|
step 3, so a single surviving `Definition` carries one `Origin` field between them. THE SYSTEM
|
|
SHALL NOT enumerate `Definition.Origin` per surviving identity, which would report only two
|
|
origins and hide from the user one of the two places the secret is bound.
|
|
This is the veto-path counterpart of AC-5a, and it is the MORE production-reachable of the two:
|
|
ADR-2026-08-03 makes every repository binding secret-only, so real secret disagreements arrive
|
|
at step 5 rather than step 6. It preserves today's behaviour — `selectDefinitions` already folds
|
|
the merged identity's accumulated origin set into the conflict set — and
|
|
`TestResolveEnvironmentSources_ReportsEveryConflictingOrigin` already asserts against this field.
|
|
- **AC-13** WHEN a `*ConflictError` is returned, its message and `Origins` SHALL contain no secret
|
|
value, no `SecretID`, and no literal value.
|
|
|
|
### No regression for currently-succeeding tasks
|
|
|
|
- **AC-14** WHEN a key has exactly one definition, THE SYSTEM SHALL resolve it to that
|
|
definition's value, unchanged from today.
|
|
- **AC-15** WHEN a key has multiple definitions that all share one `definitionIdentity`, THE
|
|
SYSTEM SHALL merge them, resolve the key to that shared value, record every origin as it does
|
|
today, and SHALL NOT emit an override record. Nothing was overridden.
|
|
- **AC-16** WHEN a definition set produced no error before this change AND its result was
|
|
DETERMINISTIC under the current three-column sort — that is, its outcome did not depend on input
|
|
slice order — THE SYSTEM SHALL produce the same environment map after this change.
|
|
The determinism qualifier is not a hedge; it is required, and AC-29a states the same bound for
|
|
the sort. Two changes land together, and only one of them is precedence:
|
|
- PRECEDENCE re-routes only sets that previously returned `*ConflictError`, so it cannot alter
|
|
any set that already succeeded.
|
|
- THE FIVE-COLUMN SORT (AC-29) additionally settles ties the three-column sort left to input
|
|
order. One such tie already succeeds today: a same-`Key`/`Origin`/`SecretID` pair differing
|
|
only in `WorkspaceID` merges (identity is `"secret:" + SecretID`), and whichever definition
|
|
happens to come first supplies the `WorkspaceID` that `resolveEnvironmentDefinition` branches
|
|
on to choose `RevealForWorkspace` over a global reveal (`environment_resolution.go:188-195`).
|
|
Its revealed VALUE is therefore input-order-dependent today, and AC-29b now pins it. For such
|
|
a set the post-change map may differ from one of its pre-change orderings, deliberately.
|
|
A differential test against the current `selectDefinitions` as oracle MUST therefore restrict
|
|
its generator to conflict-free sets that are order-INDEPENDENT today, or assert per-ordering
|
|
agreement only for sets with no `WorkspaceID` tie. Generating the tie class and demanding
|
|
equality would be testing a promise this AC does not make.
|
|
- **AC-17** WHEN `appendAgentRuntimeDefaults` runs, it SHALL continue to skip any key already
|
|
defined by an earlier definition, preserving
|
|
`TestAppendAgentRuntimeDefaultsFillsOnlyMissingKeys`.
|
|
|
|
### Preflight consistency
|
|
|
|
- **AC-18** THE shape-only preflight at `task_environment.go:118` SHALL apply the same tier and
|
|
secret-veto rules as the final resolve, so it never rejects a definition set that the final
|
|
resolve would accept.
|
|
- **AC-19** WHILE the preflight definition set contains no `agent profile` definitions, its
|
|
accept/reject verdict SHALL be identical to today's for every input.
|
|
- **AC-19a** BOTH assembly sites SHALL derive their origin strings from one shared set of exported
|
|
constants declared in the `environment` package, and the tier table SHALL be defined once, in
|
|
that same package. The orchestrator site currently hardcodes `"managed runtime"` and
|
|
`"executor profile"` (`task_environment.go:63`, `:67`) while the lifecycle site uses unexported
|
|
constants (`environment_resolution.go:17-21`); a rule keyed on origin cannot depend on two
|
|
independent spellings staying in sync.
|
|
- **AC-19c** THE `environment` package SHALL export exactly two classification functions,
|
|
`TierForOrigin(origin string) Tier` and `IsRepositoryOrigin(origin string) bool`, and SHALL NOT
|
|
export the tier table itself. `TierForOrigin` SHALL be the only reader of that table.
|
|
`IsRepositoryOrigin` SHALL be the only implementation of the normative repository-origin rule
|
|
("What counts as a repository origin"), and BOTH `TierForOrigin` and `NormalizeOriginLabel`
|
|
(AC-25) SHALL call it rather than re-deriving it — one spelling, per AC-19a.
|
|
A test SHALL assert `IsRepositoryOrigin` against the worked boundary table in that section:
|
|
true for `repository`, `repository app`, `repository app` (two spaces) and `repository\tapp`;
|
|
false for `repositoryapp` and the empty string. A test SHALL also assert that
|
|
`TierForOrigin` returns `TierAuthoritative` for an origin the table does not name (AC-35).
|
|
Keeping the table unexported is observable at compile time and is the point: an exported map
|
|
could be mutated by any importer, which would defeat the single-source-of-truth guarantee AC-19a
|
|
asks for.
|
|
- **AC-19b** `Validate` SHALL keep the signature `func Validate([]Definition) error` and SHALL
|
|
emit no override record, no override log, and no counter increment. A test SHALL assert that a
|
|
definition set which WOULD produce an override when passed to `Resolve` produces no observable
|
|
override side effect when passed to `Validate`.
|
|
|
|
### Observability
|
|
|
|
- **AC-20** WHEN tier precedence resolves a key that would previously have produced a
|
|
`*ConflictError`, THE SYSTEM SHALL produce exactly one `OverrideRecord` for that key, populated
|
|
as declared in "The override record": `Key`, `WinningOrigins` (every DISTINCT origin that
|
|
contributed to the merged winning identity, each listed ONCE, sorted ascending, at least one),
|
|
`WinningTier`, and `LosingOrigins` (every DISTINCT origin appearing among the discarded
|
|
identities, each listed ONCE with its tier, sorted by `Origin` ascending, at least one).
|
|
BOTH lists are DE-DUPLICATED SETS: no origin string appears twice in either. One entry per
|
|
ORIGIN, never one per contributing or discarded identity. AC-8d and AC-8e observe the two
|
|
de-duplication cases directly.
|
|
- **AC-20a** WHEN `Resolve` returns a non-nil error, it SHALL return a nil map and a nil
|
|
`[]OverrideRecord`, even if tier precedence had already resolved other keys before the failing
|
|
key was reached, AND the caller SHALL emit no override log record and SHALL NOT increment the
|
|
counter for that resolve. A test SHALL cover both error paths: (a) one key resolved by tier
|
|
precedence plus a second key that conflicts, and (b) one key resolved by tier precedence plus a
|
|
secret that fails to reveal, which is the more reachable case because step 7 reveals only after
|
|
every key is conflict-free. Nothing was applied, so nothing is reported.
|
|
- **AC-21** An `OverrideRecord` SHALL NOT contain any literal value, any `SecretID`, or any
|
|
decrypted secret. The type has no field capable of carrying one.
|
|
- **AC-22** THE resolver SHALL return override records to its caller rather than logging them
|
|
itself. The `environment` package holds no logger and must stay a pure function of its inputs.
|
|
Records reach the caller as `Resolve`'s new third return value.
|
|
- **AC-23** WHEN a launch applies one or more overrides, `Manager.resolveStrictEnvironment` SHALL
|
|
emit one structured log record at Info level per `OverrideRecord`, with the message
|
|
`environment override applied` and these fields:
|
|
|
|
| Field | Type | Source |
|
|
| --- | --- | --- |
|
|
| `env_key` | string | `OverrideRecord.Key` |
|
|
| `winning_origins` | []string | `WinningOrigins`, in record order |
|
|
| `winning_tier` | int | `WinningTier` |
|
|
| `losing_origins` | []string | `LosingOrigins[].Origin`, in record order |
|
|
| `losing_tiers` | []int | `LosingOrigins[].Tier`, index-aligned with `losing_origins` |
|
|
| `task_id` | string | `LaunchRequest.TaskID` |
|
|
| `session_id` | string | `LaunchRequest.SessionID` |
|
|
|
|
Origins are logged UNNORMALISED, so a reader sees which repository was involved. Normalisation
|
|
(AC-25) applies to the metric label only, where cardinality matters.
|
|
- **AC-24** THE SYSTEM SHALL increment an expvar counter map named
|
|
`environment_override_applied_total` once per entry in `LosingOrigins`, so the number of
|
|
increments for one record equals `len(LosingOrigins)` exactly. Counting is POST-MERGE and
|
|
strictly per-ORIGIN, never per input definition and never per discarded identity: ALL discarded
|
|
definitions sharing one origin collapse into a single `LosingOrigins` entry and are counted
|
|
ONCE, **whether or not they share a `definitionIdentity`**. Two differing `agent profile`
|
|
literals discarded together are ONE increment, not two (AC-8d).
|
|
The earlier wording conditioned that collapse on sharing "one origin AND one
|
|
`definitionIdentity`", which read as one entry per identity and contradicted this AC's own
|
|
per-ORIGIN rule; the condition is the ORIGIN alone, because `LosingOrigins` is a de-duplicated
|
|
set (AC-20, "The override record"). One key overriding one origin increments its label pair
|
|
exactly once, so the counter measures overrides rather than internal identity groupings.
|
|
The record and the log record remain one per key (AC-20, AC-23); only the counter fans out per
|
|
losing origin, because one label pair cannot represent several losers.
|
|
- **AC-24a** THE counter SHALL live in a new neutral package
|
|
`apps/backend/internal/agent/runtime/envmetrics`, following the existing convention in
|
|
`apps/backend/internal/workflow/signalmetrics`: an `expvar.NewMap` at package scope, incremented
|
|
through an exported `RecordOverrideApplied(winningOrigin, losingOrigin string)` helper, with the
|
|
label key built as `k1=v1;k2=v2` so a downstream Prometheus translation splits on the same
|
|
delimiters. The label key names SHALL be exactly `winning_origin` and `losing_origin`, giving
|
|
map keys of the form `winning_origin=executor profile;losing_origin=agent profile`.
|
|
`RecordOverrideApplied` SHALL receive values that are ALREADY NORMALISED per AC-25 and SHALL
|
|
perform no normalisation of its own. `envmetrics` stays a neutral recorder with no dependency on
|
|
`environment`, exactly as `signalmetrics` is neutral today.
|
|
- **AC-24b** THE counter SHALL be incremented by the lifecycle caller, in the same place as the
|
|
AC-23 log, and SHALL NOT be incremented inside `selectDefinitions`, `Resolve`, or `Validate`.
|
|
The `environment` package must not import `envmetrics`; keeping the resolver pure is what makes
|
|
AC-27's repeatability testable.
|
|
- **AC-25** THE normalised origin used as a metric label SHALL be derived as follows, and this
|
|
normalisation applies to BOTH label values:
|
|
- an origin for which `IsRepositoryOrigin` reports true collapses to the single value
|
|
`repository`, so repository names cannot make metric cardinality unbounded;
|
|
- every other origin, INCLUDING an origin the tier table does not recognise, is used verbatim,
|
|
with no case folding and no whitespace substitution. Origins are produced by code constants,
|
|
so the value set is bounded without further processing.
|
|
- WHEN `WinningOrigins` holds more than one origin, the `winning_origin` label value SHALL be
|
|
each origin normalised, de-duplicated, sorted ascending, and joined with `+` (for example
|
|
`executor profile+managed runtime`).
|
|
The join's de-duplication is a POST-NORMALISATION step and is separate from the set rule on
|
|
`WinningOrigins` itself: AC-20 already guarantees no origin appears twice in the field, but two
|
|
DISTINCT repository origins (`repository app`, `repository web`) both normalise to
|
|
`repository`, so the collapse can still produce a duplicate that the join must remove. That
|
|
specific pair is resolver-level only, exactly as AC-4 and AC-6 are: repository definitions are
|
|
secret-only by ADR construction, and the veto (AC-9) bars any secret-backed definition from
|
|
reaching `WinningOrigins` beside a differing definition. The join is de-duplicated anyway so
|
|
the label rule is total and cannot depend on that reachability argument staying true.
|
|
|
|
THE normalisation SHALL be owned by the `environment` package and exposed as two exported
|
|
functions beside the tier table: `NormalizeOriginLabel(origin string) string` for a single
|
|
origin, and `JoinOriginLabels(origins []string) string` for the collapse-dedupe-sort-join over
|
|
`WinningOrigins`. `NormalizeOriginLabel` SHALL make its repository decision by calling
|
|
`IsRepositoryOrigin` (AC-19c), never by re-deriving the rule. The lifecycle caller calls these
|
|
two and passes the results to `RecordOverrideApplied`; because AC-24 fans the counter out per
|
|
losing origin, the `losing_origin` value is always a single origin and `NormalizeOriginLabel`
|
|
alone produces it. `environment` owns this because the repository rule is the same knowledge the
|
|
tier table already consults (AC-19a): two independent spellings of "what counts as a repository
|
|
origin" is precisely the drift AC-19a exists to prevent, which is why exactly one predicate
|
|
exists and both callers share it.
|
|
- **AC-26** WHEN no override is applied during a launch, THE SYSTEM SHALL emit no override log
|
|
record and SHALL NOT increment the counter.
|
|
THE RETURN CONVENTION IS PINNED: when no override is applied, `Resolve` SHALL return `nil` for
|
|
`[]OverrideRecord`, never a non-nil empty slice. `nil` and `[]OverrideRecord{}` are not
|
|
`reflect.DeepEqual`, so leaving the choice open would make the AC-27 and AC-30 determinism
|
|
tests fail or pass on an irrelevant detail. Callers SHALL nonetheless treat an empty slice and
|
|
`nil` identically — `len(records) == 0` is the correct caller-side test, so a future change to
|
|
the convention cannot silently start emitting logs.
|
|
|
|
### Determinism and ordering
|
|
|
|
- **AC-27** WHEN the same definition set is resolved twice, THE SYSTEM SHALL return an identical
|
|
environment map and a deeply identical override-record sequence. "Deeply identical" includes the
|
|
contents and order of `WinningOrigins` and `LosingOrigins` inside every record, not just the
|
|
sequence of records — a test comparing records SHALL compare their nested slices element by
|
|
element.
|
|
- **AC-28** THE SYSTEM SHALL order override records by `OverrideRecord.Key` ascending. Because
|
|
AC-20 emits exactly one record per key, `Key` is unique across the sequence and is therefore a
|
|
total order on its own; no secondary record-level sort key exists or is needed. Ordering WITHIN a
|
|
record is specified separately and is what AC-27 depends on:
|
|
- `WinningOrigins` SHALL be sorted by origin string ascending;
|
|
- `LosingOrigins` SHALL be sorted by `Origin` ascending.
|
|
|
|
`Origin` alone TOTALLY orders both lists, and it does so only because AC-20 makes them
|
|
de-duplicated sets: with each origin appearing at most once, no two entries can compare equal,
|
|
so no tiebreak is needed or possible. This sentence depends on the set rule and would be FALSE
|
|
without it — under a bag reading, two discarded identities sharing an origin would produce two
|
|
entries that `Origin` cannot order, and AC-27's element-by-element comparison would rest on an
|
|
undefined order between them. `Tier` is additionally functionally determined by `Origin` through
|
|
`TierForOrigin`, so a tiebreak on `Tier` would carry no information even if entries could
|
|
collide.
|
|
- **AC-29** THE SYSTEM SHALL sort definitions by the NAMED columns `Key`, then `Origin`, then
|
|
`SecretID`, then `WorkspaceID`, then `Literal`, all ascending, before walking them. This is a
|
|
total order over every `Definition` field that can affect the outcome, which the current
|
|
three-column sort at `environment.go:97` is not. WHEN several definitions for one key share a
|
|
`definitionIdentity`, THE SYSTEM SHALL select the first under that order, and input slice order
|
|
SHALL NOT affect which one survives.
|
|
- **AC-29a** WHEN a definition set produced a deterministic result under the current three-column
|
|
sort, THE SYSTEM SHALL produce that same result under the five-column sort. Adding sort columns
|
|
can only break ties previously left to input order; it cannot reorder definitions that already
|
|
differed on an earlier column.
|
|
- **AC-29b** WHEN two definitions share `Key`, `Origin` and a non-empty `SecretID` but differ in
|
|
`WorkspaceID`, THE SYSTEM SHALL select the one with the lexicographically smaller `WorkspaceID`,
|
|
deterministically. This case merges rather than conflicts (both have identity
|
|
`"secret:" + SecretID`), and the surviving `WorkspaceID` decides whether
|
|
`resolveEnvironmentDefinition` reveals the secret globally or through `RevealForWorkspace`, so
|
|
leaving it to input order would make the revealed VALUE order-dependent.
|
|
- **AC-30** WHEN definitions are supplied in a different input order, THE SYSTEM SHALL produce the
|
|
same result: the same winner, the same revealed value, the same error, and the same override
|
|
records including their nested slices.
|
|
|
|
### Nil, empty, and error behaviour
|
|
|
|
- **AC-31** WHEN the definition list is empty or nil, THE SYSTEM SHALL return an empty (non-nil)
|
|
map, a nil error, and a nil `[]OverrideRecord` per AC-26's pinned convention. The empty map
|
|
matches today's behaviour, which allocates before the loop and returns it.
|
|
- **AC-32** WHEN a definition's `Key` is empty or whitespace-only, THE SYSTEM SHALL skip it,
|
|
unchanged from today (`environment.go:111`).
|
|
- **AC-33** WHEN a secret-backed definition is selected and the reveal callback is nil, THE SYSTEM
|
|
SHALL return a `*SecretError` naming the key and origin, unchanged from today.
|
|
- **AC-34** WHEN a secret-backed definition is selected and the reveal callback returns an error,
|
|
THE SYSTEM SHALL return a `*SecretError` naming the key and origin, unchanged from today.
|
|
- **AC-35** WHEN a definition carries an origin string that this spec's tier table does not name,
|
|
`TierForOrigin` SHALL return `TierAuthoritative` and THE SYSTEM SHALL apply peer-conflict
|
|
behaviour to it. This covers the empty origin string and any string that is not a repository
|
|
origin under the normative rule (for example `repositoryapp`), both of which fall through to
|
|
the unrecognised default rather than into the repository row.
|
|
- **AC-36** IF more than one key conflicts, THEN THE SYSTEM SHALL report the lexicographically
|
|
first conflicting key, unchanged from today (`environment.go:138`).
|
|
- **AC-37** WHEN precedence selects a winner, THE SYSTEM SHALL reveal secrets only for the
|
|
surviving definition. A discarded definition SHALL NOT be revealed. Because AC-9 blocks every
|
|
mixed-secret case, a discarded definition is always literal in practice; this criterion pins
|
|
that no reveal is attempted for a loser.
|
|
|
|
### Boundary values and defaults
|
|
|
|
- **AC-38** WHERE a profile entry has an empty `Value` and an empty `SecretID`, no definition for
|
|
that key SHALL reach the resolver from that profile, and its absence SHALL NOT count as an
|
|
override. This SHALL hold on ALL THREE assembly paths. Two already drop the entry and are
|
|
unchanged; the third does not drop it today and SHALL be changed to:
|
|
- the LIFECYCLE agent-profile path drops the entry explicitly (`appendProfileDefinitions`,
|
|
`environment_resolution.go:134`) — UNCHANGED;
|
|
- the LIFECYCLE executor-profile path drops it explicitly
|
|
(`executorProfileEnvironmentDefinitions`, the `value.SecretID == "" && value.Value == ""`
|
|
skip, `environment_resolution.go:257-259`) — UNCHANGED;
|
|
- the PRIMARY ORCHESTRATOR path (`Executor.taskEnvironmentSources`,
|
|
`task_environment.go:66-70`) appends every `ProfileEnvVar` unconditionally today and **SHALL
|
|
be changed to skip an entry whose `SecretID` and `Value` are both empty**, using the same
|
|
condition as the lifecycle executor-profile path.
|
|
|
|
**This reverses an instruction earlier drafts of this spec gave, and the reason is recorded so
|
|
it is not reverted again.** Those drafts asserted the orchestrator path "cannot receive such an
|
|
entry, because `validateEnvVarValue` (`profile_crud.go`) rejects it at profile save time", and
|
|
told the builder not to test for a drop there. That justification is FALSE: `validateEnvVarValue`
|
|
is agent-profile-only, the vars on this path are EXECUTOR-profile vars, and the executor-profile
|
|
save path enforces no value-or-secret rule at all (see "The two profile kinds are NOT validated
|
|
the same way"). A blank executor-profile value is therefore savable from the UI, HTTP, WS and
|
|
agent-driven MCP, and under tier precedence it would classify Tier 1, beat a real Tier 2
|
|
agent-profile value, and launch the agent with the key set to the empty string.
|
|
DO write a test asserting the drop at the orchestrator assembly step (AC-38b). The save-time
|
|
rejection test remains valid, but ONLY as a statement about agent profiles.
|
|
- **AC-38a** An override to the EMPTY STRING is therefore not expressible from a profile, on any
|
|
path. Lifting that limitation is out of scope.
|
|
The guarantee now rests on the ASSEMBLY-TIME SKIP being present on all three paths (AC-38), not
|
|
on save-time validation. That distinction is the whole of F25: save-time validation covers only
|
|
agent profiles, so an invariant resting on it was false for executor profiles.
|
|
- **AC-38b** WHEN `Executor.taskEnvironmentSources` receives a `ProfileEnvVar` whose `Value` and
|
|
`SecretID` are both empty, THE SYSTEM SHALL NOT emit an `environmentSource` for it, and the key
|
|
SHALL NOT appear in `req.EnvironmentDefinitions`.
|
|
A test SHALL assert this directly at the orchestrator assembly step. A test SHALL also assert
|
|
that the same profile produces the same definition set through BOTH assembly paths — the
|
|
orchestrator path and `executorProfileEnvironmentDefinitions` — for an input containing one
|
|
populated entry and one blank entry, because eliminating the divergence between those two paths
|
|
is the point of the change and a one-sided fix would satisfy the first test alone.
|
|
THE SKIP SHALL match the lifecycle condition exactly: `SecretID == "" && Value == ""`. An entry
|
|
with a blank `Value` but a non-empty `SecretID` is secret-backed and SHALL still be emitted; an
|
|
entry with a blank `SecretID` but a non-empty `Value` is a literal and SHALL still be emitted.
|
|
- **AC-38c** WHEN an executor profile carries a key whose `Value` and `SecretID` are both empty and
|
|
an agent profile defines the same key with a non-empty literal, THE SYSTEM SHALL resolve that key
|
|
to the AGENT-PROFILE value and SHALL NOT emit an override record, because after AC-38b only one
|
|
definition for that key reaches the resolver.
|
|
This is a deliberate BEHAVIOUR CHANGE and is the user-visible consequence of the AC-38 decision.
|
|
Today the same pair returns a `*ConflictError` and blocks the launch. It is recorded as an AC
|
|
rather than left implicit because a reader comparing before-and-after will otherwise read it as
|
|
a regression in AC-49's "no behaviour change for currently-succeeding tasks" — that AC is about
|
|
tasks that SUCCEED today, and this pair fails today.
|
|
Related: WHEN an executor profile carries such an entry and NO other origin defines that key, the
|
|
key SHALL be absent from the resolved environment. Today it resolves to the empty string on the
|
|
primary path and is absent on the recovery path; after this change both paths agree that it is
|
|
absent.
|
|
- **AC-39** WHEN a key is defined only by origin `executor profile` with a non-empty `Value` or a
|
|
non-empty `SecretID`, THE SYSTEM SHALL resolve it to that value and SHALL NOT emit an override
|
|
record, because nothing was overridden. The qualifier is required by AC-38b: an entry blank on
|
|
both fields no longer reaches the resolver at all, so there is no value to resolve it to.
|
|
- **AC-40** THE **AGENT**-PROFILE save path SHALL continue to reject `TASK_DESCRIPTION` and any
|
|
`KANDEV_*` key (`profile_crud.go:904`), unchanged by this spec. Precedence SHALL NOT create a
|
|
new path to define a reserved key from a profile.
|
|
THE SCOPE OF THAT FIRST CLAUSE IS THE AGENT PROFILE, AND SAYING SO IS THE POINT. The
|
|
executor-profile save path rejects no reserved key (see "The two profile kinds are NOT validated
|
|
the same way"), so a `KANDEV_FOO` executor-profile entry is savable today and reaches the agent
|
|
today whenever no managed-runtime definition claims that key. An earlier draft stated this AC
|
|
without the scope, which would send a builder to write a failing test against executor-profile
|
|
save and then invent either new validation or a silently narrowed test.
|
|
THE SECOND CLAUSE HOLDS AND IS OBSERVABLE, which is why this AC survives rather than being
|
|
deleted: precedence creates no NEW path. A reserved key that a managed-runtime definition also
|
|
sets puts two Tier 1 definitions against each other, so it conflicts exactly as it does today
|
|
(AC-5); a reserved-prefixed key that nothing else sets resolved before this change and resolves
|
|
after it. A test SHALL assert that an `executor profile` definition for a key also set by
|
|
`managed runtime` returns `*ConflictError` and is NOT resolved by tier precedence.
|
|
Closing the executor-profile validation gap itself is OUT OF SCOPE — see "Out of scope".
|
|
|
|
## Concurrency
|
|
|
|
Resolution is a pure function of its definition set. There is no shared row, no lock, and no
|
|
read-modify-write.
|
|
|
|
- **AC-41** WHEN two launches for different sessions resolve concurrently, each SHALL produce a
|
|
result determined solely by its own definition set.
|
|
- **AC-42** WHEN a profile is edited after a launch has captured that profile's definitions, the
|
|
in-flight launch SHALL keep what it captured and SHALL NOT re-read that profile. This inherits
|
|
ADR-2026-08-03: "An already-running process or open terminal does not change when a binding or
|
|
secret value changes." A later launch, resume, or Reset Environment re-resolves.
|
|
|
|
**ONE LAUNCH HAS TWO CAPTURE MOMENTS, NOT ONE, AND THEY ARE NAMED HERE** because a test written
|
|
against a single "the snapshot" fails against the actual code:
|
|
|
|
| Definitions | Captured at | Effect of an edit after that moment |
|
|
| --- | --- | --- |
|
|
| `executor profile`, `repository <name>`, `managed runtime` | ORCHESTRATOR assembly — `resolveLaunchEnvironment` → `taskEnvironmentSources`, writing `req.EnvironmentDefinitions` (`executor_execute.go:1100`, `executor_interaction.go:747`) | not seen by this launch |
|
|
| `agent profile` (and `AGENT_MODEL` / `AGENTCTL_AUTO_APPROVE_PERMISSIONS` derived from it) | LIFECYCLE — `resolveAgentProfile` (`manager_launch.go:1140`, `:1197`), appended in `resolveStrictEnvironment` (`environment_resolution.go:40`) | SEEN by this launch, because the read has not happened yet |
|
|
|
|
So an EXECUTOR-profile edit between the two moments is not picked up, and an AGENT-profile edit
|
|
between the two moments IS. THE SYSTEM SHALL preserve exactly that. This is today's behaviour and
|
|
this spec does not change it; it is written down because the asymmetry is invisible from either
|
|
call site alone and a reasonable reader assumes one snapshot covers both.
|
|
A test SHALL assert the executor-profile half: a launch whose `req.EnvironmentDefinitions` were
|
|
assembled from executor profile state E resolves against E even when the stored profile has since
|
|
changed to E'.
|
|
- **AC-43** WHEN the orchestrator preflight ACCEPTS a definition set, that verdict SHALL NOT be
|
|
treated as a guarantee that the launch resolves: the lifecycle resolve runs over a STRICTLY
|
|
LARGER definition set and its verdict is the one that decides the launch.
|
|
Observable outcome, and the whole content of "authoritative": THE SYSTEM SHALL fail the launch
|
|
when `Validate` returns nil at `task_environment.go:118` and `Resolve` subsequently returns an
|
|
error at `environment_resolution.go:51`. A test SHALL construct exactly that pair — a preflight
|
|
set of Tier 1 definitions that validates cleanly, plus an `agent profile` definition appended at
|
|
the lifecycle site that makes the key conflict — and assert the launch fails with the resolver's
|
|
error, not the preflight's success.
|
|
**THE CONVERSE SHALL NOT HOLD, and this is the clause that closes the ambiguity:** "authoritative"
|
|
describes WHICH VERDICT BINDS, not a re-read of the data. The lifecycle resolve SHALL NOT re-read
|
|
the executor profile, the repositories, or `req.Env`; it consumes `req.EnvironmentDefinitions` as
|
|
captured (AC-42) and appends to them. An implementation that makes the lifecycle site re-load the
|
|
executor profile satisfies a plain reading of the word "authoritative" and violates AC-42, and
|
|
because no precedence criterion observes the difference, every other test in this spec would
|
|
still pass. AC-19 remains true alongside this: the preflight never REJECTS a set the final
|
|
resolve would accept, so the two verdicts can only diverge in the accept-then-fail direction.
|
|
|
|
## Idempotency and retry
|
|
|
|
- **AC-44** WHEN a launch fails after environment resolution and is retried with the same profile
|
|
and repository state, resolution SHALL produce the same map and the same override records.
|
|
- **AC-45** WHEN a session is resumed, THE SYSTEM SHALL re-resolve from current definitions and
|
|
SHALL apply the same rules as an initial launch. Resume SHALL NOT inherit a stale precedence
|
|
decision.
|
|
- **AC-46** Emitting an override log record and incrementing the counter SHALL be tied to an
|
|
actual resolution. A retried launch that resolves again SHALL emit again; these are per-resolve
|
|
events, not deduplicated across retries.
|
|
|
|
## Migration
|
|
|
|
- **AC-47** THE SYSTEM SHALL NOT move, copy, or rewrite any environment variable between an agent
|
|
profile and an executor profile.
|
|
- **AC-48** THE SYSTEM SHALL NOT require a database migration for this feature.
|
|
- **AC-49** WHEN an existing install already has the same key on both an agent profile and an
|
|
executor profile with DIFFERING LITERAL-BACKED values, that install SHALL begin succeeding with
|
|
the executor-profile value on the next strict-resolution launch, with an override record emitted
|
|
per AC-20. The qualifier is load-bearing in four directions, and a migration test written
|
|
without it contradicts other ACs:
|
|
- if EITHER side is secret-backed, the veto still blocks the launch (AC-9, AC-10, AC-11). Such
|
|
an install does NOT begin succeeding, by design;
|
|
- if the two values are IDENTICAL, the install already succeeds today — they merge, and no
|
|
override record is emitted (AC-15). Nothing changes for it;
|
|
- if the EXECUTOR-profile side is blank on both `Value` and `SecretID`, no executor-profile
|
|
definition reaches the resolver at all (AC-38b), so the install begins succeeding with the
|
|
AGENT-profile value and NO override record (AC-38c). The empty string is a differing
|
|
literal-backed value on a plain reading, so without this bullet AC-49 would demand the
|
|
executor's empty value win and contradict AC-38a outright;
|
|
- "strict-resolution launch" excludes the legacy fill-missing path scoped out under
|
|
"The two assembly sites", which never receives executor-profile definitions at all.
|
|
|
|
Rationale for no automatic move: there is no safe general rule for classifying a key as
|
|
machine-scoped. `ANTHROPIC_BASE_URL` is obvious to a human and not to a matcher. Moving a value
|
|
off an agent profile would also change what every other task using that profile sees. And an
|
|
executor profile is frequently absent: in the observed install, 9 worktree sessions and 4 sessions
|
|
carried no `executor_profile_id`, and `Local Docker` had no executor profiles at all. The agent
|
|
profile must remain the fallback (AC-7, AC-8), so relocating values there would break more than it
|
|
fixes.
|
|
|
|
## Out of scope
|
|
|
|
Named exclusions. Each is a contract, not an omission.
|
|
|
|
- **The agent-profile matcher collision.** `buildAgentProfileMatcher` is a separate card and is
|
|
not changed here.
|
|
- **The legacy fill-missing branch of `buildEnvForExecution`** (`manager_startup.go:314-339`),
|
|
taken when neither `EnvironmentFinalized` nor `EnvironmentResolutionRequired` is set. It never
|
|
reaches the resolver and never receives executor-profile definitions, so the conflict this
|
|
feature resolves cannot arise in it. Full reasoning under "The two assembly sites".
|
|
- **A per-task or per-session environment override tier.** ADR-2026-08-03 rejected task-stored
|
|
bindings for v1; this spec does not reopen that.
|
|
- **Any change to `mergeEnvFillMissing`** (`profile_env.go:57`) or to the process-environment
|
|
merge it performs at agent launch.
|
|
- **Any change to which secrets a profile may reference.** Agent and executor profiles remain
|
|
Global-secrets-only per ADR-2026-08-03.
|
|
- **Making an override expressible as the empty string.** AC-38 states the limitation; lifting it
|
|
would require changing the assembly-time drop, which is out of scope.
|
|
- **A UI affordance for choosing a winner per key.** Precedence is by origin, not by user
|
|
selection.
|
|
- **Surfacing override records in the web UI.** AC-20 through AC-26 require the record, the log,
|
|
and the counter. Rendering them in a settings or task panel is deliberately deferred; no
|
|
frontend change is required by this spec.
|
|
- **Reordering the Tier 1 peer group.** Making `executor profile` beat `managed runtime`, or
|
|
`repository` beat `executor profile`, is explicitly not part of this change and would contradict
|
|
ADR-2026-08-03 and the tests `TestResolveEnvironmentSources_RejectsEveryConflictingPair`
|
|
(`task_environment_test.go:40`) and
|
|
`TestBuildEnvForExecution_RejectsLateManagedValuesThatConflictWithRepositorySecrets`
|
|
(`manager_launch_test.go:530`).
|
|
- **Adding reserved-key validation to the executor-profile save path.** `TASK_DESCRIPTION` and
|
|
`KANDEV_*` are rejected only on the agent-profile path (AC-40), so an executor profile can hold
|
|
such a key today. This spec does not close that: precedence creates no new path to it, closing it
|
|
would change behaviour on three save surfaces (HTTP, WS, MCP) that this card never asked to
|
|
touch, and it could reject profiles already persisted in existing installs on their next update.
|
|
A separate card owns it.
|
|
- **Adding duplicate-key validation to the executor-profile save path.** Same reasoning. AC-6a
|
|
pins that the resulting pair conflicts rather than silently picking a winner, which is the part
|
|
this feature is responsible for.
|
|
- **Adding value-or-secret validation to the executor-profile save path.** The blank-value problem
|
|
is solved at ASSEMBLY (AC-38b), not at save time, so an existing profile carrying a blank entry
|
|
keeps saving and loading exactly as it does now — the entry is simply dropped before resolution,
|
|
on both paths instead of one. Validating it at save time was considered and deliberately not
|
|
chosen, for the same back-compatibility reason as the two entries above.
|
|
- **Changing `definitionIdentity`.** Comparing decrypted plaintext remains rejected.
|
|
|
|
## Verification surfaces
|
|
|
|
Recorded as the E2E decision input.
|
|
|
|
- **Backend only.** Every acceptance criterion above is observable from Go tests against four
|
|
seams and three artefacts:
|
|
- seams: `runtimeenv.Resolve` (map, `[]OverrideRecord`, error), `runtimeenv.Validate` (error),
|
|
`Manager.resolveStrictEnvironment` (log + counter side effects),
|
|
`Executor.resolveLaunchEnvironment` (preflight verdict), and
|
|
`Executor.taskEnvironmentSources` (the emitted `environmentSource` list — this is where the
|
|
AC-38b blank-entry skip and the AC-6a duplicate-key case are observed);
|
|
- artefacts: the returned `[]OverrideRecord` (AC-20, AC-27, AC-28), the
|
|
`environment override applied` log record and its named fields (AC-23), and the
|
|
`environment_override_applied_total` expvar map read back by its label key (AC-24, AC-24a).
|
|
The expvar map is readable in-process via `expvar.Get("environment_override_applied_total")`,
|
|
so the counter ACs need no HTTP round trip to assert.
|
|
- **One Go signature changes; no data shape does.** `Resolve` gains a third return value
|
|
(`[]OverrideRecord`) and `Validate` is unchanged (AC-19b). `Definition`, `ConflictError` and
|
|
`SecretError` keep their exported fields, so no caller has to reinterpret existing data. The
|
|
signature change touches two call sites: `Manager.resolveStrictEnvironment`, which consumes the
|
|
records, and the test-only-today `resolveEnvironmentSources`, which discards them.
|
|
|
|
**One production behaviour changes outside the resolver**, and it is the only such change in this
|
|
spec: `Executor.taskEnvironmentSources` (`task_environment.go:66-70`) starts skipping profile
|
|
entries blank on both `Value` and `SecretID` (AC-38, AC-38b). No signature changes there and no
|
|
data shape changes; the function simply emits one fewer `environmentSource` for an input the
|
|
restart-recovery path already discards. Nothing else in this feature touches the orchestrator.
|
|
|
|
The `environment` package's complete new exported surface, all named rather than left to the
|
|
builder:
|
|
- three types: `Tier`, `OriginTier`, `OverrideRecord`;
|
|
- three `Tier` constants: `TierAuthoritative`, `TierProfileDefault`, `TierAgentRuntimeDefault`;
|
|
- six origin constants: `OriginManagedRuntime`, `OriginManagedCredentials`,
|
|
`OriginManagedAgentDefaults`, `OriginAgentProfile`, `OriginExecutorProfile`,
|
|
`OriginRepositoryPrefix`;
|
|
- four functions: `TierForOrigin`, `IsRepositoryOrigin` (AC-19c), `NormalizeOriginLabel`,
|
|
`JoinOriginLabels` (AC-25).
|
|
|
|
The tier table itself stays UNEXPORTED behind `TierForOrigin` (AC-19c). One new package,
|
|
`internal/agent/runtime/envmetrics`, is created with one exported function,
|
|
`RecordOverrideApplied` (AC-24a).
|
|
This is not a REST/WS/DTO change: nothing crosses the HTTP or WebSocket boundary.
|
|
- **No frontend change.** The agent-profile editor
|
|
(`apps/web/components/settings/profile-edit/`) and the executor-profile editor
|
|
(`apps/web/app/settings/executors/[profileId]/page.tsx`) already accept the env vars this spec
|
|
reconciles. No new copy, so no i18n work.
|
|
- **No database migration.**
|
|
- **E2E recommendation: none required.** The behaviour is a pure-function decision several layers
|
|
below any user-visible surface, and no user-visible surface changes. Existing backend tests plus
|
|
new unit tests at the resolver and both assembly sites give better coverage per unit of runtime
|
|
than a browser test could.
|
|
|
|
Note for Build: `apps/backend/internal/agent/runtime/environment/` currently has **no test file**.
|
|
The resolver is covered only indirectly through the orchestrator and lifecycle packages. This
|
|
feature changes that resolver's core decision, so package-level tests belong there.
|
|
|
|
## References
|
|
|
|
- `apps/backend/internal/agent/runtime/environment/environment.go`
|
|
- `apps/backend/internal/agent/runtime/lifecycle/environment_resolution.go`
|
|
- `apps/backend/internal/agent/runtime/lifecycle/profile_env.go`
|
|
- `apps/backend/internal/orchestrator/executor/task_environment.go`
|
|
- `apps/backend/internal/orchestrator/executor/executor_state.go`
|
|
- `apps/backend/internal/agent/settings/controller/profile_crud.go` — the AGENT-profile save path,
|
|
and the only home of all three save-time validators (`validateEnvVarValue:913`, reserved-key
|
|
`:904`, duplicate-key `:908`). AC-38 no longer relies on any of them; AC-40 cites the reserved-key
|
|
rule with its scope stated, and AC-6/AC-8d cite the duplicate-key rule the same way
|
|
- `apps/backend/internal/task/service/service_resources.go` — `CreateExecutorProfile` (`:1587`),
|
|
`UpdateExecutorProfile` (`:1625`) and `validateGlobalProfileEnvRefs` (`:1665`): the
|
|
EXECUTOR-profile save path, which enforces none of the three rules above
|
|
- `apps/web/components/settings/profile-edit/env-vars-card.tsx` — `rowsToEnvVars`, which filters
|
|
empty keys only, so a blank-valued entry is savable from the UI as well
|
|
- `apps/backend/internal/workflow/signalmetrics/metrics_vars.go` — the expvar counter convention
|
|
AC-24a follows (`expvar.NewMap` + `k1=v1;k2=v2` label keys)
|
|
- `docs/decisions/2026-08-03-scope-and-merge-repository-secrets.md`
|
|
- `docs/specs/workspaces/repository-secrets.md`
|