25 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||
|---|---|---|---|---|---|---|
| draft | executors |
|
2026-08-17 |
|
Executor-Profile Environment Precedence System Design Part 4
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
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
WinningOriginsandLosingOriginsinside 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.Keyascending. Because AC-20 emits exactly one record per key,Keyis 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:WinningOriginsSHALL be sorted by origin string ascending;LosingOriginsSHALL be sorted byOriginascending.
Originalone 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 thatOrigincannot order, and AC-27's element-by-element comparison would rest on an undefined order between them.Tieris additionally functionally determined byOriginthroughTierForOrigin, so a tiebreak onTierwould carry no information even if entries could collide. -
AC-29 THE SYSTEM SHALL sort definitions by the NAMED columns
Key, thenOrigin, thenSecretID, thenWorkspaceID, thenLiteral, all ascending, before walking them. This is a total order over everyDefinitionfield that can affect the outcome, which the current three-column sort atenvironment.go:97is not. WHEN several definitions for one key share adefinitionIdentity, 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,Originand a non-emptySecretIDbut differ inWorkspaceID, THE SYSTEM SHALL select the one with the lexicographically smallerWorkspaceID, deterministically. This case merges rather than conflicts (both have identity"secret:" + SecretID), and the survivingWorkspaceIDdecides whetherresolveEnvironmentDefinitionreveals the secret globally or throughRevealForWorkspace, 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
[]OverrideRecordper 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
Keyis 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
*SecretErrornaming 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
*SecretErrornaming 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,
TierForOriginSHALL returnTierAuthoritativeand 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 examplerepositoryapp), 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
Valueand an emptySecretID, 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, thevalue.SecretID == "" && value.Value == ""skip,environment_resolution.go:257-259) — UNCHANGED; - the PRIMARY ORCHESTRATOR path (
Executor.taskEnvironmentSources,task_environment.go:66-70) appends everyProfileEnvVarunconditionally today and SHALL be changed to skip an entry whoseSecretIDandValueare 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:validateEnvVarValueis 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. - the LIFECYCLE agent-profile path drops the entry explicitly (
-
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.taskEnvironmentSourcesreceives aProfileEnvVarwhoseValueandSecretIDare both empty, THE SYSTEM SHALL NOT emit anenvironmentSourcefor it, and the key SHALL NOT appear inreq.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 andexecutorProfileEnvironmentDefinitions— 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 blankValuebut a non-emptySecretIDis secret-backed and SHALL still be emitted; an entry with a blankSecretIDbut a non-emptyValueis a literal and SHALL still be emitted. -
AC-38c WHEN an executor profile carries a key whose
ValueandSecretIDare 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*ConflictErrorand 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 profilewith a non-emptyValueor a non-emptySecretID, 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_DESCRIPTIONand anyKANDEV_*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 aKANDEV_FOOexecutor-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 anexecutor profiledefinition for a key also set bymanaged runtimereturns*ConflictErrorand 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 runtimeORCHESTRATOR assembly — resolveLaunchEnvironment→taskEnvironmentSources, writingreq.EnvironmentDefinitions(executor_execute.go:1100,executor_interaction.go:747)not seen by this launch agent profile(andAGENT_MODEL/AGENTCTL_AUTO_APPROVE_PERMISSIONSderived from it)LIFECYCLE — resolveAgentProfile(manager_launch.go:1140,:1197), appended inresolveStrictEnvironment(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.EnvironmentDefinitionswere 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
Validatereturns nil attask_environment.go:118andResolvesubsequently returns an error atenvironment_resolution.go:51. A test SHALL construct exactly that pair — a preflight set of Tier 1 definitions that validates cleanly, plus anagent profiledefinition 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, orreq.Env; it consumesreq.EnvironmentDefinitionsas 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
ValueandSecretID, 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.
buildAgentProfileMatcheris a separate card and is not changed here. - The legacy fill-missing branch of
buildEnvForExecution(manager_startup.go:314-339), taken when neitherEnvironmentFinalizednorEnvironmentResolutionRequiredis 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 profilebeatmanaged runtime, orrepositorybeatexecutor profile, is explicitly not part of this change and would contradict ADR-2026-08-03 and the testsTestResolveEnvironmentSources_RejectsEveryConflictingPair(task_environment_test.go:40) andTestBuildEnvForExecution_RejectsLateManagedValuesThatConflictWithRepositorySecrets(manager_launch_test.go:530). - Adding reserved-key validation to the executor-profile save path.
TASK_DESCRIPTIONandKANDEV_*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), andExecutor.taskEnvironmentSources(the emittedenvironmentSourcelist — 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), theenvironment override appliedlog record and its named fields (AC-23), and theenvironment_override_applied_totalexpvar map read back by its label key (AC-24, AC-24a). The expvar map is readable in-process viaexpvar.Get("environment_override_applied_total"), so the counter ACs need no HTTP round trip to assert.
- seams:
-
One Go signature changes; no data shape does.
Resolvegains a third return value ([]OverrideRecord) andValidateis unchanged (AC-19b).Definition,ConflictErrorandSecretErrorkeep 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-todayresolveEnvironmentSources, 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 bothValueandSecretID(AC-38, AC-38b). No signature changes there and no data shape changes; the function simply emits one fewerenvironmentSourcefor an input the restart-recovery path already discards. Nothing else in this feature touches the orchestrator.The
environmentpackage's complete new exported surface, all named rather than left to the builder:- three types:
Tier,OriginTier,OverrideRecord; - three
Tierconstants: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. - three types:
-
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.