Files
bot-bottle/docs/prds/0025-bottle-extends.md
T
didericis 598c96679a
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 42s
docs(prd): inline #88 rationale into PRD 0025
Add an "Alternatives considered" section enumerating the design
options from issue #88 (duplicate bottles / agent-side bottle_config
/ bottle-side extends) and why extends won, so the PRD stands without
the forge thread. Repoint the two phrases that depended on the #88
comment thread at the new section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 21:51:21 -04:00

11 KiB

PRD 0025: Bottle composition via extends:

  • Status: Draft
  • 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: <bottle-name> 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/<name>.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/<name>.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: <bottle-name> 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:

---
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, KnownHostKey, and ExtraHosts 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 <agent> show the resolution chain? Same shape as the preflight question — useful diagnostic surface, doesn't change the runtime. Out of scope.