Skip to content

extending stacks

Mike Crowe edited this page Jul 12, 2026 · 3 revisions

Extending stacks (extends:) — proposed

Status: proposed. extends: is not implemented yet. This guide is the spec: it fixes the merge semantics, the hard limits, and the security rules so the implementation (and any stack authored against it) has one source of truth. Nothing below works today.

A stack (catalog/stacks/<name>/stack.yaml) composes recipes, services, and a handful of policy fields (permissions, instructions, state, credential forwarding). Once a few stacks share the same base — the same house recipes, the same permission mode, the same identity text — copy-paste starts to rot: you fix a base recipe list in one stack and forget the other four.

extends: names a base stack; the child is the base merged with its own manifest.

# catalog/stacks/team-base/stack.yaml
name: team-base
recipes: [repowise, gsd-core]
services: [ping]
permissions: auto
instructions: |
  You are a team agent working in a shared repo.

# catalog/stacks/team-web/stack.yaml
name: team-web
extends: team-base
recipes: [playwright]        # → repowise, gsd-core, playwright
services: []                 # → ping   (base's services still apply)
permissions: yolo            # → yolo   (child wins)

team-web resolves to recipes: [repowise, gsd-core, playwright], services: [ping], permissions: yolo, and the base's instructions.

Resolution

  • extends: is a single stack name, not a list. No multiple inheritance, no diamond merge.
  • The base is resolved by name across the catalog roots, exactly like a stack on the command line: user overlay (~/.config/harnessed/catalog) first, then the repo catalog. This is the point of the feature — a stack in your overlay extends a base the repo ships.
  • Chains are allowed (a extends b extends c), merged base-first. Cycles are a hard error, named in the message.
  • A repo-catalog stack may not extend a user-overlay stack. Shared content cannot depend on private content that only exists on one machine.
  • name: is never inherited. It must still equal the stack's directory name.

The hard limit: a stack with a recipe variety is not extendable

Recipe varieties (beads/stealth, beads/team — one family dir, path refs) are the same tool wired differently, and harnessed already treats two varieties of one family as mutually exclusive within a stack. Inheritance would force a third rule on top of that: does a child's beads/team replace the base's beads/stealth, or collide with it? Both answers are surprising, and both turn the recipes list into something you have to mentally execute rather than read.

So: if a stack's recipes: names any variety (<family>/<variety>), that stack cannot be used as an extends: target.

# catalog/stacks/team-base/stack.yaml
name: team-base
recipes: [repowise, beads/stealth]   # ← declares a variety

# catalog/stacks/team-web/stack.yaml
name: team-web
extends: team-base                   # ERROR: stack 'team-base' declares a variety recipe
recipes: [playwright]                #        (beads/stealth) and cannot be extended

The error is raised at load, against the base, and points at the fix: pull the varietied recipe out of the base and let each leaf stack name the variety it wants, or fork the base outright.

A child may name a variety freely — it just makes that child unextendable in turn. The restriction only ever applies to being the target of an extends:.

Merge rules, field by field

Field Rule
name Never inherited. Must match the stack's directory name.
recipes Base's list first (in base order), then the child's. Duplicates collapse to their first occurrence.
services Same as recipes.
permissions Child wins when set; otherwise the base's.
instructions Child replaces the base's text when set (no concatenation — two identity blocks read as a contradiction).
hatago Child wins when set (whole block, not per-key).
state Shallow per-key merge; a key the child sets wins.
forward_git_credentials, forward_aws_sso Inherited; the child may set either back to false. See the security note below.
ssh_keys Never inherited. See below.

There is no way to remove a recipe or service the base declares. If you need the base minus something, the base is wrong — split it.

Security: credentials do not ride down the chain silently

Two of harnessed's rules exist because a stack manifest can come from a shared catalog that you did not write:

  • ssh_keys is not inherited, ever. Mounting a private key is the key owner's decision, not a base-stack author's. Today the launcher already drops ssh_keys from any stack outside your user overlay; inheritance would launder that check — your overlay stack extends a repo base, and the base's ssh_keys would arrive looking like they came from you. Declare ssh_keys in the stack you actually launch, in your own overlay.
  • forward_git_credentials / forward_aws_sso are inherited, but harnessed tells you when the value came from a stack outside your overlay — these forward secret-bearing material (the gh oauth token, STS creds), so "a base you didn't write turned this on" must be visible, not inferred. Set either to false in the child to opt out.

Everything else in the table is configuration, not credentials, and inherits without ceremony.

Rebuilds: editing a base rebuilds its children

A built profile is a pure function of its catalog inputs, and harnessed hashes those inputs into a staleness stamp. Under extends:, the inputs of a child stack are every stack.yaml in its chain plus every recipe dir the merged list references. Edit team-base, and team-web reports stale on the next harnessed list / launch and rebuilds — the same as if you had edited team-web's own manifest.

See also

Clone this wiki locally