88. CEL-guarded overlays in the harness schema
Date: 2026-08-14
Status
Accepted
Living reference: The overlay resolution pipeline, CEL environment, and merge rules described here are maintained in Harness Field Reference. Update that document (not this ADR) when overlay behavior evolves.
Note (2026-08-27): The matching semantics have been revised from first-match-wins to merge-all-matching (later entries take precedence). See Harness Field Reference — Overlay resolution and #6686 for the current behavior.
Context
ADR 0045 added a forge: block to the harness schema, keyed by platform (github, gitlab), so a single harness file can carry platform-specific overrides for pre_script, post_script, skills, host_files, runner_env, and other fields. ResolveForge(platform) merges the selected platform's block into the harness's top-level fields and nils out the map.
This design is rigid in two ways:
Fixed conditioning axis. The only thing you can condition on is the forge platform — a value detected from the CI environment or passed via
--forge. There is no way to condition config on the event source system (e.g. JIRA vs GitHub), the event type, or any other property of the triggering event. This blocks #2264 and #5989, where a code agent needs JIRA-specific setup scripts when triggered by a JIRA issue but GitHub-specific scripts when triggered by a GitHub issue — and in both cases writes to the same forge.Closed key set. Adding a new conditioning value (e.g.
jira) requires modifyingvalidForgeKeysin Go code, even though JIRA is not a forge and doesn't belong inforge:. Any future conditioning axis (event type, repo language, deployment target) would need its own top-level block and its own parallel resolution/merge/compose code.
The harness already has a CEL expression engine for trigger: expressions (ADR 0061), evaluated against normevent.Event (internal/normevent) — a typed struct with fields like Source.System (SourceSystem), Entity.Kind (EntityKind), Transition.Kind (TransitionKind), and others. A general conditional-config mechanism can reuse this infrastructure.
Related work
- ADR 0045: Forge-portable harness schema — established the forge block, merge rules, and base composition.
- ADR 0061: Harness CEL dispatch — CEL trigger expressions and
NormalizedEventschema.
Decision
Add an overlays: list field to the harness schema. Each entry has a when: CEL expression (same environment as trigger:, evaluated against the event variable) and the same override fields as ForgeConfig. At resolution time, the first entry whose when evaluates to true is merged into the harness using the same merge semantics as mergeForgeConfig (ADR 0045). Remaining entries are not evaluated.
overlays:
- when: event.source.system == "github"
pre_script: scripts/pre-gh.sh
skills:
- skills/github-issue-triage
runner_env:
GH_TOKEN: ${GH_TOKEN}
- when: event.source.system == "jira"
pre_script: scripts/pre-jira.sh
skills:
- skills/jira-issue-read
runner_env:
JIRA_TOKEN: ${JIRA_TOKEN}First-match-wins keeps the mental model simple: exactly one overlay (or none) applies to any given event. When an agent needs config from multiple concerns (e.g. JIRA-specific scripts and GitHub-specific runner env), the harness author creates a combined entry for that scenario:
overlays:
- when: event.source.system == "jira" && runtime.forge == "github"
pre_script: scripts/pre-jira.sh
skills:
- skills/jira-issue-read
runner_env:
GH_TOKEN: ${GH_TOKEN}
JIRA_TOKEN: ${JIRA_TOKEN}
- when: event.source.system == "github"
pre_script: scripts/pre-gh.sh
skills:
- skills/github-issue-triage
runner_env:
GH_TOKEN: ${GH_TOKEN}More specific entries go first; broader fallbacks go last.
forge: and overlays: must not coexist in the same harness — Validate() rejects a harness that declares both. forge: continues to work unchanged as a deprecated feature. Lint() emits a deprecation warning when forge: is present, recommending migration to overlays:.
Resolution pipeline
Update (2026-08-21): The empty-event semantics described below have evolved since this ADR was written. See Harness Field Reference for the current behavior (nil event → empty map substitution).
LoadWithOpts and LoadWithBase gain Event normevent.Event and Config map[string]any fields in their options structs. The pipeline becomes:
Unmarshal → validateForge → validateOverlays →
ResolveForge(platform) → ResolveOverlays(event, config) → ValidateResolveOverlays(event, config) evaluates each entry's when against the CEL environment (see below). The first entry whose when returns true is merged; remaining entries are skipped. Like ResolveForge, it nils out the field after resolution (consumed).
CEL environment
The overlay when expressions are evaluated in the same CEL environment as trigger: (ADR 0061), extended with additional variables:
| Variable | Type | Source |
|---|---|---|
event | normevent.Event | The triggering event — same typed struct from internal/normevent with fields like source.system (SourceSystem), entity.kind (EntityKind), transition.kind (TransitionKind), etc. |
runtime.forge | string | The effective forge platform, resolved with precedence: (1) --forge CLI flag, (2) config.forge from config.yaml, (3) CI env vars (GITHUB_ACTIONS, GITLAB_CI). Today detectForgePlatform() only checks (1) and (3); this ADR adds (2) so runtime.forge always reflects the configured platform whether or not the agent runs in CI. |
config | map[string]any | The full per-repo config from config.yaml (perRepoConfig). Available for overlays that need to condition on repo-level settings beyond forge. |
This means an overlay can condition on the event origin, the runtime platform, repo-level settings, or any combination. Most overlay when expressions should reference runtime.forge and/or event fields — config is available but typically not needed in when expressions since runtime.forge already incorporates config.forge.
Base composition
mergeBaseIntoChild concatenates overlays lists (base entries first, child entries appended), the same way it handles plugins, providers, and api_servers. With first-match-wins, a child entry that matches shadows all base entries — child entries go last in the concatenated list, but more-specific child when expressions can be ordered before base fallbacks by the harness author.
The mutual exclusion between forge: and overlays: applies to the post-merge result — the harness as seen by Validate() after mergeBaseIntoChild runs. This means a base harness using forge: and a child using overlays: (or vice versa) would produce a merged harness containing both, which Validate() rejects. To migrate incrementally, the base harness must convert from forge: to overlays: before any child can adopt overlays:. This is a deliberate constraint: mixing the two mechanisms in a composed harness would create ambiguous resolution order between ResolveForge and ResolveOverlays.
Validation
Each overlays entry requires:
- A non-empty
whenfield that compiles as a CEL expression returning bool (same rules astrigger:). - Valid override fields, checked by the same validation logic as
validateForge(script paths are local, URL fields have integrity hashes, etc.).
Deprecation of forge:
forge: is not removed by this ADR. It continues to work, is validated and resolved exactly as before, and existing harnesses are unaffected. The deprecation is advisory:
Lint()warns whenforge:is present.- New harnesses should use
overlays:instead. - A future ADR may remove
forge:once all harnesses have migrated.
A forge: block like:
forge:
github:
pre_script: scripts/pre-gh.shmaps to an overlay that conditions on the forge platform:
overlays:
- when: runtime.forge == "github"
pre_script: scripts/pre-gh.shThe mapping is mechanical — each forge key becomes a when expression checking runtime.forge — but note the conditioning axis differs from event.source.system. runtime.forge reflects the effective forge platform (from --forge flag, config.forge, or CI env vars — see the CEL environment table above), while event.source.system identifies the event origin. These diverge for cross-system events: a JIRA issue triggering work on GitHub Actions has runtime.forge == "github" but event.source.system == "jira".
To support this, the overlay CEL environment exposes runtime.forge alongside the existing event variable, so overlays can faithfully replicate forge: conditioning when needed.
Consequences
- Harness authors can condition config on any event property, not just the forge platform — enabling JIRA-triggered agents, event-type-specific setup, and future conditioning axes without schema changes.
forge:is deprecated but remains functional, so existing harnesses need no immediate migration.- The CEL expression engine is already present (
trigger.go,github.com/google/cel-go);overlaysreuses it rather than introducing a new expression language or matching mechanism. - First-match-wins means exactly one overlay (or none) applies per event, making the resolved harness easy to predict. Cross-concern scenarios (e.g. JIRA-triggered agent on GitHub) require a dedicated combined entry rather than implicit layering.
- The harness composition guide (
docs/contributing/harness-composition.md) gainsvalidateOverlays,ResolveOverlays, and theoverlaysconcatenation inmergeBaseIntoChildas new counterpart functions to keep in sync.
