Files
bot-bottle/docs/prds/prd-new-multi-parent-extends.md
T
didericis-claude 302920e290 feat: support multiple parents in bottle extends:
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
2026-06-25 05:10:03 -04:00

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:

  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:

# 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