ruvnet/ruflo · error
Cannot compose capabilities with different resources: "${cap
Error message
Cannot compose capabilities with different resources: "${cap1.resource}" vs "${cap2.resource}" What it means
The second precondition of compose(): after scopes match, `cap1.resource` must equal `cap2.resource`. The resource identifies the target object (a file, memory namespace, API route), and intersecting actions across two different resources is meaningless, so it throws with both resource names in the message. Actions are intersected only after this check passes.
Source
Thrown at v3/@claude-flow/guidance/src/capabilities.ts:456
/**
* Compose two capabilities via intersection.
*
* - Actions = intersection of both action sets
* - Constraints = union of both constraint sets
* - Expiry = the tighter (earlier) of the two
* - Delegatable = true only if both are delegatable
* - Scope and resource must match; throws if they differ
*
* @throws Error if scope or resource do not match
*/
compose(cap1: Capability, cap2: Capability): Capability {
if (cap1.scope !== cap2.scope) {
throw new Error(
`Cannot compose capabilities with different scopes: "${cap1.scope}" vs "${cap2.scope}"`
);
}
if (cap1.resource !== cap2.resource) {
throw new Error(
`Cannot compose capabilities with different resources: "${cap1.resource}" vs "${cap2.resource}"`
);
}
// Actions: intersection
const actionSet1 = new Set(cap1.actions);
const intersectedActions = cap2.actions.filter(a => actionSet1.has(a));
// Constraints: union
const combinedConstraints = [...cap1.constraints, ...cap2.constraints];
// Expiry: tightest
let expiresAt: number | null = null;
if (cap1.expiresAt !== null && cap2.expiresAt !== null) {
expiresAt = Math.min(cap1.expiresAt, cap2.expiresAt);
} else if (cap1.expiresAt !== null) {
expiresAt = cap1.expiresAt;
} else if (cap2.expiresAt !== null) {View on GitHub (pinned to fa13ee4ad6)
Solutions
- Compose only capabilities whose `resource` strings are byte-identical
- Normalize resource identifiers (canonical paths, consistent encoding) at grant time
- Re-grant one capability against the other's resource, then compose
- Filter pools by resource equality before calling compose()
Example fix
// before
const merged = authority.compose(capRepoA, capRepoB); // resources differ → throws
// after
if (capRepoA.resource !== capRepoB.resource) {
// re-grant capRepoB on capRepoA.resource, or mint a fresh capability
}
const merged = authority.compose(capRepoA, capOnSameResource); Defensive patterns
Strategy: validation
Validate before calling
if (cap1.resource !== cap2.resource) {
// different targets — skip compose or re-grant on a common resource
} Type guard
const sameResource = (a: Capability, b: Capability): boolean => a.resource === b.resource;
Prevention
- Canonicalize resource identifiers (paths, URIs) at grant time
- Re-grant capabilities after a resource is renamed or moved
- Compare exact resource strings before calling compose()
When it happens
Trigger: compose(cap for resource 'repo://a', cap for resource 'repo://b'); capabilities granted on different file paths or namespace URIs being merged; resource identifiers that differ only by trailing slash or encoding.
Common situations: Path-based resources where one grant used an absolute and one a relative path; URL-encoded vs raw resource strings; renaming a resource (moved file, renamed workspace) so old grants reference stale identifiers.
Related errors
- Cannot compose capabilities with different scopes: "${cap1.s
- Capability ${capability.id} is not delegatable
- Cannot delegate revoked capability ${capability.id}
- Cannot delegate expired capability ${capability.id}
- Issue ${input.issueId} is not claimed by ${input.claimantId}
AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18).
Data as JSON: /api/errors/e8781869bb51d795.
Report an issue: GitHub.