@@ -11,7 +11,7 @@ Add a `merge: Callable[[Any, Any], Any] | None` field to `KeySpec`. The
1111respectively), so every ` SERVICE_KEYS ` entry built through them gets extends
1212support for free. ` extends.py ` 's ` _merge ` looks up ` SERVICE_KEYS[key].merge `
1313first, falling back to a shrunk, structural-only ` _MERGE_KEYS ` /` _CONCAT_KEYS ` for
14- the 8 keys that still have no ` KeySpec ` . A new ` _pairs_to_mapping ` in ` keys.py `
14+ the 9 keys that still have no ` KeySpec ` . A new ` _pairs_to_mapping ` in ` keys.py `
1515(the inverse of ` _key_value_pairs ` ) backs the map-merge callable and, as a side
1616effect, fixes list-form ` labels ` /` annotations ` merging — currently untested and
1717currently broken.
@@ -46,7 +46,7 @@ for `labels`/`annotations` (that's what `_key_value_pairs` exists for), but
4646class KeySpec :
4747 validate: Callable[[str , str , Any], None ]
4848 emit: Callable[[Any], list[Token]]
49- merge: Callable[[Any, Any], Any] | None = None
49+ merge: Callable[[str , str , Any, Any], Any] | None = None
5050```
5151
5252` _list() ` and ` _map() ` set it automatically — no opt-in argument, so a future
@@ -62,11 +62,14 @@ def _map(flag: str) -> KeySpec:
6262 return KeySpec(validate = _validate_map, emit = emit, merge = _merge_map)
6363```
6464
65- ` _concat_list(base, local) ` normalizes both sides via the existing
65+ ` _concat_list(name, key, base, local) ` normalizes both sides via the existing
6666list-or-str handling (moved from ` extends._as_list ` ) and concatenates.
67- ` _merge_map(base, local) ` normalizes both sides via the new ` _pairs_to_mapping `
68- (list-or-dict → dict, the inverse of ` _key_value_pairs ` , living beside it in
69- ` keys.py ` ) and dict-merges, local winning. ` ulimits ` 's bespoke ` KeySpec `
67+ ` _merge_map(name, key, base, local) ` normalizes both sides via the new
68+ ` _pairs_to_mapping ` (list-or-dict → dict, the inverse of ` _key_value_pairs ` ,
69+ living beside it in ` keys.py ` ) and dict-merges, local winning. ` merge ` 's
70+ ` (name, key, ...) ` prefix mirrors ` validate ` 's signature, so a merge callable
71+ can raise the same service/key-named ` UnsupportedComposeError ` as validation
72+ does. ` ulimits ` 's bespoke ` KeySpec `
7073(` keys.py:191 ` ) sets ` merge=_merge_map ` explicitly since it bypasses the ` _map() `
7174factory.
7275
@@ -78,7 +81,7 @@ def _merge(base: dict[str, Any], local: dict[str, Any], name: str) -> dict[str,
7881 for key, local_val in local.items():
7982 spec = SERVICE_KEYS .get(key)
8083 if key in base and spec is not None and spec.merge is not None :
81- merged[key] = spec.merge(base[key], local_val)
84+ merged[key] = spec.merge(name, key, base[key], local_val)
8285 elif key in base and key in _MERGE_KEYS : # structural keys only
8386 ...
8487 elif key in base and key in _CONCAT_KEYS : # structural keys only
@@ -88,7 +91,7 @@ def _merge(base: dict[str, Any], local: dict[str, Any], name: str) -> dict[str,
8891 return merged
8992```
9093
91- ` _MERGE_KEYS ` /` _CONCAT_KEYS ` shrink to the 8 keys with no ` KeySpec ` :
94+ ` _MERGE_KEYS ` /` _CONCAT_KEYS ` shrink to the 9 keys with no ` KeySpec ` :
9295` _MERGE_KEYS = {"environment", "extra_hosts", "healthcheck", "depends_on"} ` ,
9396` _CONCAT_KEYS = {"secrets", "configs", "volumes", "tmpfs", "env_file"} ` — their
9497` _as_mapping ` /` _as_list ` handling is unchanged. A one-line comment marks these
@@ -102,7 +105,7 @@ and shape-derived.
102105
103106## Non-goals
104107
105- - Unifying the 8 structural keys' merge policy — deferred; would conflate this
108+ - Unifying the 9 structural keys' merge policy — deferred; would conflate this
106109 change with candidate #3 of the 2026-07-13 architecture review (owning
107110 module per structural key), and ` decisions/2026-07-12-reject-structural-key-registry.md `
108111 already rejects a uniform structural registry on shape-heterogeneity grounds
0 commit comments