feat(schema): let a role narrow the skills it receives from a mounted provider (#297) #298

Merged
coilysiren merged 1 commit from aos/claude/gh297-binding-skill-selector into main 2026-08-17 17:54:06 +00:00
Member

Closes #297.

Shape

use-provider and use-repository now accept skill children, reusing parseSkillSelectorChildren so the child syntax is identical to the definition form.

role "creator" {
    use-repository lore {
        skill "lore-self-*"
        skill "lore-rule-*"
    }
}

How the semantics are enforced

SelectOrdinarySkills takes the definition selector and the binding selector and applies them as two passes, the binding running over what the definition already admitted. Every semantic the issue asked for falls out of that ordering rather than needing a new rule:

  1. Intersect, never replace - the binding only ever sees the definition's survivors.
  2. Empty intersection is an error - a pattern that selects nothing is by definition unmatched, which Select already rejects.
  3. Unmatched scoped to the intersection - the binding's catalogue is the intersection, so this is the existing check unchanged.
  4. Overlap within the binding's own patterns - one Select call per pass, so the existing overlap rule applies per selector.
  5. Omitted binding keeps today's behavior - nil returns before touching the source.

Glob-to-glob intersection is never computed symbolically. Ordering does the work.

Threading

The selector rides ProviderUse and RepositoryUse, into repositoryplan.Selection.BindingSkills (binding_skills, omitempty), through compose.RootSource into SelectOrdinarySkills. Residency clears it, because a role-union entry must not report one role's narrowing as a property of the repository.

One thing worth flagging

repositoryplan.Marshal hand-builds its YAML node tree and Load validates against a closed key set, so adding the struct tag was not enough on its own - the field serialized as nothing. The schema unit tests all passed while the plan silently dropped the selector. The cascade end-to-end test is what caught it, which is why that test is in here rather than just the unit ones.

Tests

All six the issue suggested, plus parse-level and use-provider coverage:

  • binding narrows, and a second role mounting the same definition still gets everything
  • widening past the definition fails, naming the pattern
  • empty binding selector fails with the existing message shape
  • binding pattern matching nothing after intersection fails, naming the pattern
  • overlapping binding patterns fail with the overlap message
  • omitted binding selector produces an identical selection and an identical trace reason
  • unknown child node under a binding is rejected
  • the selector survives into the generated repository-plan.yaml for the narrowing role, is absent for the unbound role, and is absent from residency

go build ./..., go vet ./..., go test ./..., gofmt -l, and pre-commit run --all-files all pass. The existing suite passing unchanged is the evidence for requirement 6 at bundle level.

Not changed

global and resident-only still reject children. They are repository policy rather than role bindings, and the issue is about what one role receives.

No docs/FEATURES.md entry. This extends the expressiveness of the existing .agents/roles.kdl line rather than adding a capability, so per the FEATURES rule the detail went to docs/kdl-contracts.md instead. Easy to overrule if you read it the other way.

Closes #297. ## Shape `use-provider` and `use-repository` now accept `skill` children, reusing `parseSkillSelectorChildren` so the child syntax is identical to the definition form. ```kdl role "creator" { use-repository lore { skill "lore-self-*" skill "lore-rule-*" } } ``` ## How the semantics are enforced `SelectOrdinarySkills` takes the definition selector and the binding selector and applies them as two passes, the binding running over what the definition already admitted. Every semantic the issue asked for falls out of that ordering rather than needing a new rule: 1. **Intersect, never replace** - the binding only ever sees the definition's survivors. 2. **Empty intersection is an error** - a pattern that selects nothing is by definition unmatched, which `Select` already rejects. 3. **Unmatched scoped to the intersection** - the binding's catalogue *is* the intersection, so this is the existing check unchanged. 4. **Overlap within the binding's own patterns** - one `Select` call per pass, so the existing overlap rule applies per selector. 5. **Omitted binding keeps today's behavior** - nil returns before touching the source. Glob-to-glob intersection is never computed symbolically. Ordering does the work. ## Threading The selector rides `ProviderUse` and `RepositoryUse`, into `repositoryplan.Selection.BindingSkills` (`binding_skills`, omitempty), through `compose.RootSource` into `SelectOrdinarySkills`. Residency clears it, because a role-union entry must not report one role's narrowing as a property of the repository. ## One thing worth flagging `repositoryplan.Marshal` hand-builds its YAML node tree and `Load` validates against a closed key set, so adding the struct tag was not enough on its own - the field serialized as nothing. The schema unit tests all passed while the plan silently dropped the selector. The cascade end-to-end test is what caught it, which is why that test is in here rather than just the unit ones. ## Tests All six the issue suggested, plus parse-level and `use-provider` coverage: * binding narrows, and a second role mounting the same definition still gets everything * widening past the definition fails, naming the pattern * empty binding selector fails with the existing message shape * binding pattern matching nothing after intersection fails, naming the pattern * overlapping binding patterns fail with the overlap message * omitted binding selector produces an identical selection and an identical trace reason * unknown child node under a binding is rejected * the selector survives into the generated `repository-plan.yaml` for the narrowing role, is absent for the unbound role, and is absent from residency `go build ./...`, `go vet ./...`, `go test ./...`, `gofmt -l`, and `pre-commit run --all-files` all pass. The existing suite passing unchanged is the evidence for requirement 6 at bundle level. ## Not changed `global` and `resident-only` still reject children. They are repository policy rather than role bindings, and the issue is about what one role receives. No `docs/FEATURES.md` entry. This extends the expressiveness of the existing `.agents/roles.kdl` line rather than adding a capability, so per the FEATURES rule the detail went to `docs/kdl-contracts.md` instead. Easy to overrule if you read it the other way.
feat(schema): let a role narrow the skills it receives from a mounted provider (#297)
All checks were successful
ci / test (pull_request) Successful in 43s
ab46ebec1c
A skill selector could only sit on a provider or repository definition, so
every role mounting a path received an identical set. Declaring the path twice
with different selectors was closed in both directions, leaving all-or-nothing
as the only choice. For a repository holding third-party identity and NDA-bound
material next to publishable material, nothing was the safe answer, and the
role that needed the publishable quarter worked from a weaker secondary source.

Accept skill children on the use-provider and use-repository bindings:

    role "creator" {
        use-repository lore {
            skill "lore-self-*"
        }
    }

The binding runs as a second selector pass over what the definition already
admitted, so intersection, non-widening, unmatched-pattern, and overlap
semantics all fall out of the existing skillselector rather than needing new
rules. A pattern reaching past the definition matches nothing in the narrowed
catalogue and fails with the existing message. An omitted binding is nil and
returns before touching the source, so current bundles are unchanged.

The selector travels on ProviderUse and RepositoryUse, into the repository plan
as binding_skills, and through RootSource into compose. Residency drops it,
because a role-union entry must not carry one role's narrowing.

Note that repositoryplan.Marshal hand-builds its YAML node tree and its loader
validates against a closed key set, so the struct tag alone was not enough. The
cascade test is what caught that, not the schema unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agent-compose!298
No description provided.