Skip to content

[sdk/profile:merge] Shallow merge clobbers sibling orchestrationPolicy keys (silently drops a required policy) #957

Description

@rogelsm

Bug Description

babysitter profile:merge --user performs a shallow merge that replaces nested objects wholesale instead of deep-merging them. Merging an input whose preferences.orchestrationPolicy contains only one sub-key silently clobbers the sibling sub-keys already present under orchestrationPolicy. This destroys an existing required orchestration policy — breaking its enforcement for all future runs — with no error or warning.

Expected Behavior

profile:merge should recursively deep-merge plain objects at all depths (objects merge key-by-key; only scalars and arrays are replaced), consistent with how it already preserves untouched siblings at the top preferences level. After merging a new orchestrationPolicy.modelSelection, both modelSelection and the pre-existing tokenOptimizer should remain.

Actual Behavior

After merging an input containing only orchestrationPolicy.modelSelection, a read-back shows orchestrationPolicy has only ["modelSelection"] — the pre-existing tokenOptimizer sibling is gone. Notably, sibling keys one level up (preferences.toolPreferences, preferences.installedSkills) survive untouched. This proves the merge IS deep at the top preferences level but stops recursing one level down — it replaces the value of orchestrationPolicy rather than merging into it.

Steps to Reproduce

  1. Start with a user profile that already has two sibling sub-keys under preferences.orchestrationPolicy (e.g. tokenOptimizer with { required: true, ... }). Confirm:
    babysitter profile:read --user --json | jq '.preferences.orchestrationPolicy | keys'
  2. Create only-modelSelection.json:
    { "preferences": { "orchestrationPolicy": { "modelSelection": { "required": true } } } }
  3. Merge it: babysitter profile:merge --user --input only-modelSelection.json --json
  4. Read back: babysitter profile:read --user --json | jq '.preferences.orchestrationPolicy | keys'

Observed: ["modelSelection"] only — tokenOptimizer silently dropped.
Expected: ["modelSelection","tokenOptimizer"].

Real occurrence: run 01KTW3TCN5RKJCM8VJ6EQ4ZSM9 (process contrib/rogelsm/model-selection-policy-install) clobbered tokenOptimizer. Recovered by re-merging a combined file containing both sub-keys (profile version went 5 → 6), after which the parent's keys correctly returned ["modelSelection","tokenOptimizer"].

Environment

  • Babysitter SDK: 0.0.187 (global install, /usr/local/bin/babysitter)
  • AI Harness: Claude Code (claude-opus-4-8), plugin a5c-ai/babysitter @ 4.0.157
  • OS: macOS (Darwin 27.0.0) arm64
  • Node.js: v22.18.0
  • npm: 11.13.0
  • Shell: zsh
  • Git: 2.41.0

Additional Context

Impact: HIGH for data integrity, LOW visibility. A required orchestration policy being silently dropped is a correctness failure that surfaces much later (a future run simply stops composing the dropped policy). No exit code or stderr signals the loss.

Suggested fixes (any of):

  • (a) Make profile:merge use a true recursive deep-merge for nested plain objects, not a one-level shallow assign.
  • (b) If a shallow replace is intended for some keys, document it and emit a warning listing sibling keys that will be dropped.
  • (c) Provide a --deep flag (or make deep the default) for object-valued keys.

Workaround for authors today: before any profile:merge that touches an object with existing sibling sub-keys, profile:read first and build the merge input so the nested object contains every sibling key, then verify with a read-back of the parent's keys.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions