feat(bottle): composition via extends: (PRD 0025, issue #88) #89

Merged
didericis merged 5 commits from bottle-extends-prd-0025 into main 2026-05-28 01:26:46 -04:00
Collaborator

Closes #88.

Summary

Bottles can declare extends: <other-bottle> to inherit and override config. Picked from the #88 design discussion — option 3, the alternative to allowing agents to inline a bottle_config: block. Preserves the README's home-only-bottle trust boundary while addressing the duplication pain.

Schema

---
extends: dev

egress:
  routes:
    - host: staging.example.com
      auth:
        scheme: Bearer
        token_ref: STAGING_TOKEN
---

Merge rules

Field Merge
env dict merge, child wins on key collision
git full replace if child declares git:
git_user child overlay: child's non-empty fields win
egress full replace if child declares egress:
supervise full replace if child declares supervise:

List-valued fields full-replace because partial merge is ambiguous (ordering + name-collision semantics make it under-specified). env is dict-merge because dict-keyed override is the natural shape. git_user overlays per-field so a parent can declare just name and a child can add email. An explicit git: [] clears the parent's list; an omitted git: inherits it.

See PRD 0025 for the full design + rationale + alternatives considered.

Changes (3 commits)

  1. docs(prd): 0025 — design doc.
  2. feat(manifest)extends: schema + recursive resolver (_resolve_bottles_resolve_one_bottle_merge_bottles) + cycle / missing-parent / self-extends detection. Wired into both _load_bottles_from_dir (md loader) and Manifest.from_json_obj (JSON path) so downstream code (provisioners, compose renderer, etc.) consumes already-merged bottles. 24 unit tests in test_manifest_extends.py.
  3. docs(readme)extends: section in the manifest docs (rules table + example + trust-boundary note).

Tests

685 unit tests pass (24 new). Coverage:

  • Leaf bottle (no extends:) parses unchanged
  • Inheritance: full inheritance, scalar override, list replace, list inherit, explicit-empty-clears
  • env dict merge: disjoint, collision (child wins), child-omits
  • git_user overlay: parent-only, child-overrides-both, partial (name from parent + email from child, and vice versa)
  • 3-step chain (grandparent → parent → child) + intermediate override
  • Errors: missing parent, self-extends, 2-node cycle, 3-node cycle, non-string extends:

Trust boundary

Bottles continue to load from $HOME/.claude-bottle/bottles/ only. extends: references another file in that same directory; no cwd-readable file gains the ability to declare bottle config. The README's manifest section now documents the new field directly under the trust-boundary callout.

Closes #88. ## Summary Bottles can declare `extends: <other-bottle>` to inherit and override config. Picked from the [#88 design discussion](https://gitea.dideric.is/didericis/claude-bottle/issues/88) — option 3, the alternative to allowing agents to inline a `bottle_config:` block. Preserves the README's home-only-bottle trust boundary while addressing the duplication pain. ## Schema ```yaml --- extends: dev egress: routes: - host: staging.example.com auth: scheme: Bearer token_ref: STAGING_TOKEN --- ``` ## Merge rules | Field | Merge | |--------------|---------------------------------------------| | `env` | dict merge, child wins on key collision | | `git` | full replace if child declares `git:` | | `git_user` | child overlay: child's non-empty fields win | | `egress` | full replace if child declares `egress:` | | `supervise` | full replace if child declares `supervise:` | List-valued fields full-replace because partial merge is ambiguous (ordering + name-collision semantics make it under-specified). `env` is dict-merge because dict-keyed override is the natural shape. `git_user` overlays per-field so a parent can declare just `name` and a child can add `email`. An explicit `git: []` clears the parent's list; an omitted `git:` inherits it. See PRD 0025 for the full design + rationale + alternatives considered. ## Changes (3 commits) 1. **`docs(prd): 0025`** — design doc. 2. **`feat(manifest)`** — `extends:` schema + recursive resolver (`_resolve_bottles` → `_resolve_one_bottle` → `_merge_bottles`) + cycle / missing-parent / self-extends detection. Wired into both `_load_bottles_from_dir` (md loader) and `Manifest.from_json_obj` (JSON path) so downstream code (provisioners, compose renderer, etc.) consumes already-merged bottles. 24 unit tests in `test_manifest_extends.py`. 3. **`docs(readme)`** — `extends:` section in the manifest docs (rules table + example + trust-boundary note). ## Tests 685 unit tests pass (24 new). Coverage: - Leaf bottle (no `extends:`) parses unchanged - Inheritance: full inheritance, scalar override, list replace, list inherit, explicit-empty-clears - `env` dict merge: disjoint, collision (child wins), child-omits - `git_user` overlay: parent-only, child-overrides-both, partial (name from parent + email from child, and vice versa) - 3-step chain (grandparent → parent → child) + intermediate override - Errors: missing parent, self-extends, 2-node cycle, 3-node cycle, non-string `extends:` ## Trust boundary Bottles continue to load from `$HOME/.claude-bottle/bottles/` only. `extends:` references another file in that same directory; no cwd-readable file gains the ability to declare bottle config. The README's manifest section now documents the new field directly under the trust-boundary callout.
didericis-claude added 1 commit 2026-05-27 23:27:25 -04:00
docs(prd): 0025 — bottle composition via extends: (issue #88)
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 40s
4f7a506a9e
didericis added 1 commit 2026-05-27 23:30:44 -04:00
feat(manifest): bottle composition via \extends:\ resolver (PRD 0025, #88)
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 39s
a5c8b4e7b2
Add an optional `extends: <bottle-name>` field to bottle
frontmatter. Two-pass load:

  1. Collect raw frontmatter for every bottle file.
  2. Recursively resolve each name into a merged Bottle via
     `_resolve_one_bottle` + `_merge_bottles`.

Merge rules (per PRD 0025):

- env: dict merge, child wins on key collision
- git: full replace if child declares `git:`
- git_user: per-field overlay (child's non-empty fields win)
- egress: full replace if child declares `egress:`
- supervise: full replace if child declares `supervise:`

List-valued fields full-replace because partial merge is
ambiguous (ordering matters, name collisions ambiguous); env is
dict-merge because dict-keyed override is the natural shape.
git_user overlays per-field so a parent can declare just the
name and a child can add just the email.

Cycles / self-extends / missing-parent / non-string `extends:`
all die at parse with a pointer that includes the chain (cycles)
or the available names (missing parent). Resolution is cached
per-name so a diamond reference graph doesn't reparse the same
parent N times.

Both load paths threaded:
- `_load_bottles_from_dir` (md files) — collect raws, then
  resolve.
- `Manifest.from_json_obj` (JSON / test fixtures) — same.

Tests (24, in `test_manifest_extends.py`):
- Leaf without extends parses unchanged
- Child inherits parent unchanged when child only declares
  `extends:`
- env: disjoint union, collision (child wins), child-omits
- git: replace, omit, explicit-empty-clears-parent
- egress: same shape (replace, inherit)
- git_user: parent-only, child-overrides-both, partial fields
- 3-step chain (grandparent → parent → child)
- Errors: missing parent, self-extends, 2-node cycle, 3-node
  cycle, non-string extends

685 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
didericis added 1 commit 2026-05-27 23:31:05 -04:00
docs(readme): document bottle extends: composition (PRD 0025)
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 42s
85104742ca
didericis-claude requested review from didericis 2026-05-27 23:31:32 -04:00
didericis reviewed 2026-05-27 23:37:11 -04:00
README.md Outdated
@@ -251,0 +256,4 @@
child's declared fields overlay. Merge rules:
- `env:` — dict merge, child wins on key collision.
- `git:`, `egress:`, `supervise:` — full replace when the child
Owner

we should probably refactor how the git config is structured: there should be two subkeys, “user” and “remotes”.

  • “user” has the username and email fields, and is merged with the parent. child wins on duplicate keys
  • “remotes” is a dict keyed by host name. rest of the git config goes as a dict value

Weigh in on the design/wait for feedback before implementing

we should probably refactor how the git config is structured: there should be two subkeys, “user” and “remotes”. - “user” has the username and email fields, and is merged with the parent. child wins on duplicate keys - “remotes” is a dict keyed by host name. rest of the git config goes as a dict value Weigh in on the design/wait for feedback before implementing
Collaborator

I agree with this direction. The current split between git: as a full-replace list and git_user: as a separate per-field overlay is starting to encode two different merge models for one conceptual feature, and the list shape makes targeted remote overrides awkward.

My preferred shape would be:

git:
  user:
    name: Example Bot
    email: bot@example.com
  remotes:
    gitea.dideric.is:
      Name: claude-bottle
      Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
      IdentityFile: ~/.ssh/id_ed25519
      KnownHostKey: ...
      ExtraHosts: {}

Merge semantics:

  • git.user overlays per field, child wins on duplicate non-empty fields. This preserves the useful git_user behavior.
  • git.remotes merges by hostname key, child wins for a duplicate host.
  • For each remote value, I would lean toward replacing the whole remote entry on duplicate host rather than deep-merging individual remote fields. Remote entries contain tightly-coupled values (Upstream, IdentityFile, KnownHostKey, ExtraHosts), so partial deep merge could accidentally combine credentials/host keys from different intended remotes.
  • An explicit empty git.remotes: {} should probably clear inherited remotes, matching the existing git: [] escape hatch.

I would also keep a compatibility layer for the current git: list + git_user: for at least one transition window: parse both, normalize internally to the new shape, document the new schema, and warn when the old keys are used. If both old and new forms are present in one bottle, I think that should be a validation error rather than trying to reconcile them.

Pausing here as requested before implementation.

I agree with this direction. The current split between `git:` as a full-replace list and `git_user:` as a separate per-field overlay is starting to encode two different merge models for one conceptual feature, and the list shape makes targeted remote overrides awkward. My preferred shape would be: ```yaml git: user: name: Example Bot email: bot@example.com remotes: gitea.dideric.is: Name: claude-bottle Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git IdentityFile: ~/.ssh/id_ed25519 KnownHostKey: ... ExtraHosts: {} ``` Merge semantics: - `git.user` overlays per field, child wins on duplicate non-empty fields. This preserves the useful `git_user` behavior. - `git.remotes` merges by hostname key, child wins for a duplicate host. - For each remote value, I would lean toward replacing the whole remote entry on duplicate host rather than deep-merging individual remote fields. Remote entries contain tightly-coupled values (`Upstream`, `IdentityFile`, `KnownHostKey`, `ExtraHosts`), so partial deep merge could accidentally combine credentials/host keys from different intended remotes. - An explicit empty `git.remotes: {}` should probably clear inherited remotes, matching the existing `git: []` escape hatch. I would also keep a compatibility layer for the current `git:` list + `git_user:` for at least one transition window: parse both, normalize internally to the new shape, document the new schema, and warn when the old keys are used. If both old and new forms are present in one bottle, I think that should be a validation error rather than trying to reconcile them. Pausing here as requested before implementation.
Owner

I would also keep a compatibility layer for the current git: list + git_user: for at least one transition window: parse both, normalize internally to the new shape, document the new schema, and warn when the old keys are used. If both old and new forms are present in one bottle, I think that should be a validation error rather than trying to reconcile them.

Don't worry about compatibility/not yet released.

> I would also keep a compatibility layer for the current `git:` list + `git_user:` for at least one transition window: parse both, normalize internally to the new shape, document the new schema, and warn when the old keys are used. If both old and new forms are present in one bottle, I think that should be a validation error rather than trying to reconcile them. Don't worry about compatibility/not yet released.
Collaborator

Got it. I will drop the compatibility layer from the plan since this has not shipped yet.

Updated implementation direction:

  • Replace the old top-level git: list / git_user: shape with the new git.user and git.remotes shape directly.
  • Make old git_user: an unknown/invalid key under the normal validation path.
  • Treat git.remotes as a hostname-keyed dict and merge by host across extends, with child entries replacing the full remote entry for duplicate hosts.
  • Keep git.user as a per-field overlay where child fields win.
  • Update docs and tests to the new schema rather than carrying migration behavior.

Pausing again before implementation.

Got it. I will drop the compatibility layer from the plan since this has not shipped yet. Updated implementation direction: - Replace the old top-level `git:` list / `git_user:` shape with the new `git.user` and `git.remotes` shape directly. - Make old `git_user:` an unknown/invalid key under the normal validation path. - Treat `git.remotes` as a hostname-keyed dict and merge by host across `extends`, with child entries replacing the full remote entry for duplicate hosts. - Keep `git.user` as a per-field overlay where child fields win. - Update docs and tests to the new schema rather than carrying migration behavior. Pausing again before implementation.
Owner

Go for it

Go for it
didericis added 1 commit 2026-05-28 00:49:49 -04:00
refactor(manifest): key git config by host
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 42s
59ee32cc8d
Collaborator

Implemented and pushed in 59ee32c.

What changed:

  • Replaced the manifest-facing top-level git: list / git_user: shape with git.user and host-keyed git.remotes.
  • git.user keeps the per-field overlay semantics across extends.
  • git.remotes now merges by upstream host; child entries replace the full remote entry for duplicate hosts.
  • git.remotes: {} explicitly clears inherited remotes.
  • Old top-level git_user now fails validation with a pointer to git.user.
  • Updated README, PRD docs, fixtures, and tests to the new schema.

Verification:

  • python3 -m unittest tests.unit.test_smolmachines_provision tests.unit.test_manifest_extends tests.unit.test_manifest_git
  • python3 -m unittest discover tests/unit passed outside the sandbox; the sandboxed run only failed on local 127.0.0.1 bind permissions for supervise HTTP tests.

Pausing here for review feedback.

Implemented and pushed in `59ee32c`. What changed: - Replaced the manifest-facing top-level `git:` list / `git_user:` shape with `git.user` and host-keyed `git.remotes`. - `git.user` keeps the per-field overlay semantics across `extends`. - `git.remotes` now merges by upstream host; child entries replace the full remote entry for duplicate hosts. - `git.remotes: {}` explicitly clears inherited remotes. - Old top-level `git_user` now fails validation with a pointer to `git.user`. - Updated README, PRD docs, fixtures, and tests to the new schema. Verification: - `python3 -m unittest tests.unit.test_smolmachines_provision tests.unit.test_manifest_extends tests.unit.test_manifest_git` - `python3 -m unittest discover tests/unit` passed outside the sandbox; the sandboxed run only failed on local `127.0.0.1` bind permissions for supervise HTTP tests. Pausing here for review feedback.
didericis added 1 commit 2026-05-28 01:08:43 -04:00
refactor(manifest): drop stale migration errors
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 54s
test / unit (push) Successful in 29s
test / integration (push) Successful in 40s
f029a3d7f5
didericis approved these changes 2026-05-28 01:26:29 -04:00
didericis merged commit f029a3d7f5 into main 2026-05-28 01:26:46 -04:00
didericis deleted branch bottle-extends-prd-0025 2026-05-28 01:26:47 -04:00
Sign in to join this conversation.