b8e2ce0b4d
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
167 lines
7.1 KiB
Markdown
167 lines
7.1 KiB
Markdown
# 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:
|
|
|
|
```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
|