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

  1. Compose only capabilities whose `resource` strings are byte-identical
  2. Normalize resource identifiers (canonical paths, consistent encoding) at grant time
  3. Re-grant one capability against the other's resource, then compose
  4. 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

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


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/e8781869bb51d795. Report an issue: GitHub.