Skip to content

bug: child harness top-level fields silently overridden by inherited forge-level values #6798

Description

@ggallen

Summary

When a child harness extends a base harness (e.g., base: triage) and sets top-level fields, those values can be silently overridden by forge-level values inherited from the base during ResolveForge. The child's explicit intent is discarded with no warning.

mergeForgeConfig (forge.go:342-398) applies forge-level values to the top-level harness across every field type. It cannot distinguish between forge values the child explicitly set and values inherited from the base:

Scalars (child's value replaced entirely):

Line Field Pattern
343-344 PreScript if fc.PreScript != "" { h.PreScript = fc.PreScript }
346-347 PostScript if fc.PostScript != "" { h.PostScript = fc.PostScript }
349-350 Policy if fc.Policy != "" { h.Policy = fc.Policy }

Skills (replace-by-name via mergeSkills, compose.go:2110-2138):

Line Field Effect
353-355 Skills Inherited forge skills merged by filepath.Base(source) — matching names replace, new names appended. Inherited forge skills can replace the child's top-level skills with the same base filename.

Providers (concat at merge, name-based last-writer-wins at resolution via mergeProviderDefs in run.go:4486-4511):

Line Field Effect
357-361 Providers Inherited forge providers appended after child's top-level providers. Since mergeProviderDefs uses last-writer-wins by ProviderDef.Name, inherited forge providers silently override the child's top-level providers of the same name.

Maps (inherited forge keys override child's matching keys):

Line Field Effect
378-385 RunnerEnv Inherited forge env vars override child's top-level env vars with matching keys
392-397 Env Same — inherited forge env sub-maps override child's matching keys

Expected resolution order

The correct precedence, from lowest to highest priority:

  1. Base (all of it — top-level + forge) — provides the foundation/defaults
  2. Child top-level — overrides base defaults
  3. Child forge — platform-specific overrides, most specific wins

A harness with a base should behave identically to one without a base, except that fields the child leaves empty are filled in by the base. Adding base: triage should be purely additive — it should never cause a field the child explicitly set to behave differently.

Today, inherited base forge values are indistinguishable from child forge values by the time ResolveForge runs, so they participate in step 3 with the same authority — silently overriding or polluting the child's explicit top-level settings across all field types.

Reproduction

Given triage's harness defines scripts at both levels:

# base: triage harness
pre_script: scripts/pre-triage.sh
post_script: scripts/post-triage.sh
forge:
  github:
    pre_script: scripts/pre-triage.sh
    post_script: scripts/post-triage.sh
  gitlab:
    pre_script: scripts/pre-triage.sh
    post_script: scripts/post-triage.sh

A child harness that sets only top-level scripts:

base: ../triage/harness/harness.yaml
pre_script: scripts/my-pre.sh
post_script: scripts/my-post.sh

Expected: scripts/my-pre.sh and scripts/my-post.sh run.
Actual: scripts/pre-triage.sh and scripts/post-triage.sh run (the base's forge-level scripts silently win).

The same applies to policy, env, skills, and other fields — a child that sets top-level values would have them silently overridden or polluted by inherited forge-level values.

Root cause

The composition pipeline is: mergeBaseIntoChildResolveForgemergeForgeConfig.

  1. mergeBaseIntoChild (compose.go:575-580): correctly applies the child's top-level scalars over the base's — scalar override works as designed.
  2. mergeForgeBlocks (compose.go:2168-2185): if the child has no forge block for a platform, the entire base ForgeConfig is inherited wholesale (line 2179).
  3. mergeForgeConfig (forge.go:343-350): blindly applies forge-level scalars over top-level scalars if non-empty — it cannot distinguish between a forge value the child explicitly set and one inherited from the base.

Result: the child's explicit top-level values are overwritten by inherited forge values it never asked for.

Suggested fix

Principle: A child's explicit top-level value should never be silently overridden by an inherited forge-level value. This applies to all field types — scalars, slices, and maps.

In mergeBaseIntoChild, before mergeForgeBlocks, snapshot which forge platform keys the child already has. After merge, for any inherited platforms (keys the child did NOT define), clear fields that the child explicitly set at the top level:

  • Scalars (PreScript, PostScript, Policy): clear on inherited forge blocks if child set the corresponding top-level scalar
  • Skills: inherited forge skills can replace child's top-level skills by name — clear inherited forge skill entries that match names the child explicitly set at the top level
  • Providers: requires a different approach. Harness.Providers is []string (file paths/URLs) at composition time — the Name field only exists on ProviderDef after YAML files are fetched and parsed in the CLI run phase (mergeProviderDefs at run.go:4486-4511). Name-based dedup can't happen in mergeBaseIntoChild. Two options:
    1. Strip inherited forge provider paths from the list before they reach mergeProviderDefs
    2. Adjust append order in mergeForgeConfig so inherited forge providers go before child top-level providers, giving child entries last-writer-wins naturally
  • Maps (RunnerEnv, Env): remove inherited forge map keys that match keys the child explicitly set at the top level

This ensures mergeForgeConfig during ResolveForge only applies forge values the child (or its own forge block) explicitly set.

Edge cases

  • Child has a forge block for github but not gitlab — only clear inherited scalars on the platform the child didn't define
  • Child sets pre_script but not post_script — only clear the specific field the child explicitly set
  • Overlays (ResolveOverlays) also call mergeForgeConfig — same bug may exist there
  • Multi-level base chains — the bug compounds (grandparent forge values can shadow child's top-level)

Missing test coverage

No existing test covers this scenario. Closest tests:

  • TestLoadWithBase_ForgeBlockMerge (compose_test.go:560): child has forge blocks — tests forge-to-forge merge only
  • TestLoadWithBase_ForgeInheritPlatform (compose_test.go:605): child has no forge AND no top-level scripts — tests pure inheritance

Needed: tests where child sets top-level fields (scalars, skills, env vars), base has forge-level values for the same fields, child has no forge block — assert child's top-level values survive ResolveForge.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugcomponent/harnessAgent harness, config, and skills loadingpr-openAn open PR already addresses this issuepriority/highSignificant impact, address soontype/bugConfirmed defect in existing behavior

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions