# PRD 0065: Multi-parent `extends:` for bottles - **Status:** Active - **Author:** didericis - **Created:** 2026-06-25 - **Issue:** #268 - **Extends:** PRD 0025 (`0025-bottle-extends.md`) ## Summary Allow a bottle's `extends:` field to accept either a single bottle name (existing behavior) or a list of bottle names (new). Multiple parents are resolved independently and folded left-to-right into a single effective parent before the child is merged on top. This lets orthogonal concerns (base env, networking/egress, agent provider) live in separate bottles and be composed without forcing them into a linear chain. ## Problem PRD 0025 shipped single-parent `extends:` and listed "No multi-parent inheritance" as a non-goal. In practice, users want to compose multiple orthogonal bottles — a base environment, a networking profile, and an agent-provider override — without creating a three-level linear chain that couples unrelated parents to each other. The linear chain workaround has two problems: 1. **Ordering constraint.** `networking extends base` works, but then `agent extends networking` can't also pick up `base` without going through `networking`, coupling two unrelated concerns. 2. **Quadratic duplication.** N orthogonal bottles require O(N²) chain variants (one chain per permutation of applied concerns). Multi-parent `extends:` removes both constraints: each orthogonal concern stays in its own bottle, and the child bottle is the only place that names the combination. ## Goals / Success Criteria - `extends:` accepts a list of strings in addition to a plain string. - Backward compat: existing single-string `extends:` is unchanged. - Parents are resolved left-to-right; later entries win on conflict. - Child wins over all parents (unchanged from PRD 0025). - Cycle detection covers multi-parent graphs, not just linear chains. - Diamond inheritance: a shared ancestor is resolved once (via the existing cache). - Invalid list entries (non-string, undefined bottle, self-reference) die at parse with clear messages. - `manifest_loader.py`'s `load_bottle_chain_from_dir` enqueues all parents from a list `extends:` so the resolver sees every bottle in the graph. ## Non-goals - No change to the agent-vs-bottle trust boundary (PRD 0025 "Alternatives considered" option 2 stays rejected). - No MRO / C3 linearization. Left-to-right fold is sufficient for the expected use cases. - No preflight display of per-field provenance across multiple parents (same open question as PRD 0025; remains a follow-up). ## Design ### Schema `extends:` now accepts either form: ```yaml # single parent (unchanged) extends: base # multiple parents (new) extends: [base, networking] ``` Both forms are normalized to a list internally. A list with one element behaves identically to the string form. ### Merge rules for multi-parent fold Parents are folded pairwise left-to-right before the child merge. For each step in the fold, the "earlier" bottle is the running accumulator and the "later" bottle is the next parent. Rules per field: | Field | Fold rule | |--------------------|--------------------------------------------------------------| | `env` | dict merge; later wins on key collision | | `git-gate.user` | per-field overlay; later's non-empty fields win | | `git-gate.repos` | union by name; for same-name entries, later wins per-field | | `egress.routes` | concatenate (earlier first, later appended) | | `egress.log` | later wins (last-wins) | | `agent_provider` | later wins (last-wins) | | `supervise` | later wins (last-wins) | After the fold, the combined parent is merged against the child using the existing PRD 0025 rules (child always wins). The child's `egress.routes` appends to the combined parent's concatenated routes; `validate_egress_routes` runs once on the final merged set and catches duplicate hosts. ### Algorithm ``` extends: [p1, p2, p3] fold: combined = resolve(p1) combined = fold_two(combined, resolve(p2)) combined = fold_two(combined, resolve(p3)) merge: result = _merge_bottles(combined, child_raw, name) ``` `fold_two(earlier, later)` applies the rules in the table above. Cycle detection (the `seen` tuple) is passed to each parent resolution call unchanged — if any parent's chain circles back to the current bottle, it is caught. The `cache` dict ensures a shared ancestor is only resolved once across all parents. ### Error cases | Condition | Error message shape | |----------------------------------------|------------------------------------------------------------------| | `extends` is not a string or list | `extends must be a string or list of strings (was )` | | A list entry is not a string | `extends[] must be a string (was )` | | A list entry names an undefined bottle | `extends '' which is not defined. Available bottles: ...` | | A list entry is the bottle itself | `extends itself; remove the self-reference` | | Cycle through any parent edge | `is in an extends cycle: ` | ## Implementation ### `bot_bottle/manifest_extends.py` - `_resolve_one_bottle`: accept `str | list[str]` for `extends`; normalize to list; validate each entry; for a single-entry list fall through to the existing single-parent path; for multiple entries call `_fold_parents` then `_merge_bottles`. - `_fold_parents(parent_names, raws, cache, repos_cache, seen)`: resolve each parent and fold pairwise left-to-right; return `(effective_bottle, effective_repos_raw)`. - `_fold_two_bottles(earlier, earlier_repos_raw, later, later_repos_raw)`: apply the fold rules above; return `(folded_bottle, folded_repos_raw)`. ### `bot_bottle/manifest_loader.py` - `load_bottle_chain_from_dir`: when `extends` is a list, enqueue all parent names for loading (previously only `isinstance(parent, str)` was handled). ### `tests/unit/test_manifest_extends.py` - `TestExtendsErrors.test_non_string_extends_dies`: update to use an integer `extends` value (a list is now valid). - New class `TestExtendsMultiParent` covering all cases listed in the issue. ## Testing strategy Unit tests via `ManifestIndex.from_json_obj` (same resolver surface used by all paths). No integration test changes needed — downstream code consumes the already- merged bottle and is unchanged. Test cases: - Two-parent list: env union, egress routes concat, git repos union - Last-parent-wins on scalar (supervise, agent_provider) - Child wins over all parents on conflict - Diamond: two parents share an ancestor; ancestor resolved once - Single-element list: identical to string form - Non-string extends value → ManifestError - Non-string list entry → ManifestError - Undefined bottle in list → ManifestError - Self-reference in list → ManifestError - Cycle through multi-parent edge → ManifestError