Allow extends: to accept a list of bottle names in addition to a plain string. Parents are resolved independently and folded left-to-right into a single combined parent before the child is merged on top, so orthogonal concerns (base env, networking, agent provider) can live in separate bottles without forcing a linear chain. Merge rules for the parent fold: env dict-merge with later winning on collision; git-gate.user per-field overlay; git-gate.repos union by name with later winning per-field on same name; egress.routes concatenated; all scalar fields (supervise, agent_provider, egress.log) use last-wins. The existing child-wins-over-all-parents rule is unchanged. Cycle detection, diamond deduplication, and missing/invalid parent errors all work across multi-parent graphs. Closes #268
7.1 KiB
PRD prd-new: Multi-parent extends: for bottles
- Status: Draft
- 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:
-
Ordering constraint.
networking extends baseworks, but thenagent extends networkingcan't also pick upbasewithout going throughnetworking, coupling two unrelated concerns. -
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'sload_bottle_chain_from_direnqueues all parents from a listextends: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:
# 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: acceptstr | list[str]forextends; normalize to list; validate each entry; for a single-entry list fall through to the existing single-parent path; for multiple entries call_fold_parentsthen_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: whenextendsis a list, enqueue all parent names for loading (previously onlyisinstance(parent, str)was handled).
tests/unit/test_manifest_extends.py
TestExtendsErrors.test_non_string_extends_dies: update to use an integerextendsvalue (a list is now valid).- New class
TestExtendsMultiParentcovering 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