# PRD 0025: Bottle composition via `extends:` - **Status:** Active - **Author:** didericis - **Created:** 2026-05-27 - **Issue:** #88 ## Summary Let a bottle inherit from another bottle by name. A bottle frontmatter gains an optional `extends: ` field; at manifest-load time the parent's resolved config is the base and the child's declared fields overlay it. Bottles remain home-only — the trust boundary the README documents stays intact. Solves the "I don't want to duplicate a 50-line bottle just to add one egress route or env var" pain that motivated issue #88's `bottle_config` proposal, without weakening the agent-vs-bottle trust separation that proposal would have eroded. ## Problem Today the only way to vary a bottle's config is to write a whole new `bottles/.md`. If `staging` is the same as `dev` but with one extra egress route, the operator copy-pastes the entire `dev` file. Drift between copies follows: an egress addition to `dev` is silently absent from `staging` until someone notices. Issue #88 proposed inlining a `bottle_config:` block in agent files that would merge with (and override) the referenced bottle. That design lets a `$CWD/.bot-bottle/agents/.md` file from a cloned repo redeclare egress routes, env mappings, and git remotes — breaking the existing security model where bottles are `$HOME`-only specifically so cloned repos can't influence them (see README, "Manifest" section). `extends:` solves the same composition pain *within the existing trust boundary*: only `$HOME` bottles can declare it, only `$HOME` bottles can be its target. Cloned repos still cannot author bottle-equivalent config. ## Alternatives considered The question raised in issue #88 was *where composition should live*. Three points in that design space, recorded here so the decision stands on its own without the issue thread: 1. **Duplicate bottles (status quo).** Copy `dev.md` to `staging.md` and edit. Zero new mechanism, but every shared field drifts: a route added to `dev` is silently missing from `staging`. This is the pain that prompted #88. 2. **Agent-side `bottle_config:` override (the original #88 proposal).** Let an agent file carry an inline block that merges over its referenced bottle. Ergonomically attractive — one file, no second bottle — but it **breaks the trust boundary**: agent files can come from `$CWD/.bot-bottle/agents/` in a cloned repo, so a clone could redeclare egress routes, env mappings, and git remotes — i.e. grant itself bottle-equivalent authority over credentials and network egress. The home-only-bottle invariant exists precisely to stop this. 3. **Bottle-side `extends:` (chosen).** Move composition to the bottle layer, where it inherits the home-only property for free: only `$HOME` bottles can declare `extends:`, and only `$HOME` bottles can be its target. Identical duplication relief to option 2, none of its trust erosion. The cost is that an override requires a (home-owned) child bottle rather than an inline agent block — which is the *point*: the override authority stays in `$HOME`. `extends:` wins because it solves the duplication pain entirely on the trusted side of the agent-vs-bottle boundary. (PRD 0027 later lifts a deliberately narrow, non-credential field — `git.user` — to the agent layer, on the separate reasoning that commit identity is not a capability; egress, credentials, and remotes stay bottle-only.) ## Goals / Success Criteria - Add `extends: ` to the bottle frontmatter schema. - At manifest load, resolve `extends:` chains into a fully-merged effective config before the rest of the pipeline sees the `Bottle` object. Downstream code (provisioners, compose renderer, etc.) is unchanged. - Defined, simple merge semantics (see "Merge rules" below). - Cycle detection: `A extends B extends A` dies at parse with a clear pointer. - Missing parent dies at parse with a clear pointer + the list of available bottle names. - Existing bottles continue to parse identically — `extends:` is opt-in. - Backend-agnostic: docker + smolmachines behave the same because they both consume `Bottle` after the merge. ## Non-goals - **No agent-side `bottle_config:`.** Option 2 under "Alternatives considered" — weighed and rejected on trust grounds. Don't reintroduce it. - **No additive list merges** (e.g., `routes: append` keyword). The `extends:` design uses full-replace for list-valued fields (see "Merge rules"); if a use case shows up that genuinely needs `parent_routes + child_routes`, design that separately rather than baking it in now. - **No multi-parent inheritance.** A bottle has at most one parent. Diamond resolution is out of scope and rarely worth the complexity for a manifest of this size. - **No agent-level extends.** Agents stay simple (bottle ref + skills + prompt). Inheritance lives only on the bottle side. ## Design ### Schema A new optional top-level frontmatter key on bottle files: ```yaml --- extends: dev egress: routes: - host: staging.example.com auth: scheme: Bearer token_ref: STAGING_TOKEN --- ``` `extends:` is a string — the name of another bottle (without the `.md`). Required to be one of the bottles loaded from `$HOME/.bot-bottle/bottles/`. Self-reference (`extends: self` in `self.md`) and longer cycles die at parse. ### Merge rules Resolution walks `extends:` chains bottom-up: parent's already-resolved config is the base, child's declared fields overlay it. For each field on `Bottle`: | Field | Type | Merge | |--------------|-----------------------|---------------------------------------------| | `env` | `Mapping[str, str]` | dict merge, child wins on key collision | | `git.user` | `GitUser` | child overlay: child's non-empty fields win | | `git.remotes`| `tuple[GitEntry,…]` | dict merge by host, child wins | | `egress` | `EgressConfig` | full replace if child declares `egress:` | | `supervise` | `bool` | full replace if child declares `supervise:` | Why full-replace for `egress.routes[]`: - **Ordering matters.** Egress route ordering is part of the match semantics (first matching host wins). Merging two ordered lists by name introduces "where does the child's route go?" ambiguity. - **Simpler precedence.** "Child declares X → X wins, full stop" is one sentence; partial merges need a table per list. The `env` dict is the one exception because dict-merge has no ordering concern and dict-keyed overrides are the obvious user expectation. (Same model as shell `export` precedence.) `git.remotes` is also keyed, so it follows dict-style inheritance: children can override one host without restating every remote. The remote entry is replaced as a whole on host collision because `Upstream`, `IdentityFile`, and `KnownHostKey` are tightly coupled. The `git.user` dataclass-overlay (each non-empty field wins individually) is so a parent can declare `git.user.name` and a child can add just `git.user.email`. The default `GitUser()` fields are empty strings, which are treated as "not set" for overlay purposes — same `is_empty()` predicate the provisioner uses. ### Resolution algorithm ``` bottles_raw: dict[name, raw_frontmatter_dict] # before parsing def resolve(name, seen=()) -> Bottle: if name in seen: die(f"bottle '{name}' extends-cycle: {' -> '.join(seen + (name,))}") raw = bottles_raw[name] parent_name = raw.get("extends") if parent_name is None: return Bottle.from_dict(name, raw) # leaf if parent_name not in bottles_raw: die(f"bottle '{name}' extends '{parent_name}' which is not defined; " f"available: {sorted(bottles_raw)}") parent = resolve(parent_name, seen + (name,)) return _merge(parent, raw, name) ``` Resolution is cached per-name within a single `Manifest.from_*` call so a diamond-like reference graph (multiple children extending the same parent) doesn't reparse the parent N times. Cycles are caught by the `seen` set; the error message includes the full chain so operators can find the offending file. ### Trust boundary preservation Bottles continue to be loaded from `$HOME/.bot-bottle/bottles/` only (`Manifest.from_md_dirs` is unchanged). The `extends:` field references another file in that same directory. No cwd-readable file gains the ability to declare or modify bottle config — the attack surface from option 2 ("Alternatives considered") stays closed. If a future change ever introduces cwd-loaded bottles, the `extends:` resolver should be gated to forbid a `$CWD` bottle from extending a `$HOME` bottle (lest cwd-loaded config inherit home-resident credentials via the merge step). Today this is moot because there's only one bottle source. ## Implementation chunks 1. **PRD (this commit).** Sets the design. 2. **Resolver + schema + tests.** - Add `"extends"` to `_BOTTLE_KEYS`. - Add `extends: str = ""` to a new pre-merge raw shape (or keep raw dicts and resolve as a separate pass before `Bottle.from_dict`). - Implement `_resolve_bottle_with_extends` recursive walk with cycle detection. - Wire into `_load_bottles_from_dir` so the public `Manifest.bottles` dict already contains resolved Bottle instances (downstream code unchanged). - Implement `_merge(parent: Bottle, child_raw: dict, name: str) -> Bottle` with the rules table above. - Unit tests: simple two-bottle extends, env merge with collision, host-keyed git remote merge, egress list-replace, git.user overlay, supervise override, missing parent dies, cycle dies, deeper chains (A extends B extends C). 3. **Docs.** Add an `extends:` example to the README's manifest section. Note that the field is optional + how merge precedes. ## Testing strategy - **Unit (must):** all the merge semantics, the parse-time errors (cycle, missing parent), and a multi-step inheritance chain locking the resolver's recursion. - **No integration changes needed:** downstream code consumes the already-merged `Bottle`. Existing integration tests cover the docker / smolmachines provisioning paths and would catch any regression in how `Bottle.git`, `Bottle.egress`, etc., are consumed. ## Open questions - **Should the parent appear in the preflight summary?** Right now the y/N preflight prints the resolved bottle config; with `extends:` the operator doesn't see *which* fields came from which level. A short `extends:` annotation in the preflight output ("inherits from `dev`") would let the operator spot surprises. Cheap follow-up; out of scope for this PRD. - **Should `cli.py info ` show the resolution chain?** Same shape as the preflight question — useful diagnostic surface, doesn't change the runtime. Out of scope.