# 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 <type>)`       |
| A list entry is not a string           | `extends[<i>] must be a string (was <type>)`                     |
| A list entry names an undefined bottle | `extends '<name>' 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: <chain>`                                |

## 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
