Files
cleveragents-core/docs/adr/ADR-037-tool-reachability-and-access-projection.md
freemo c2db74ba81
CI / lint (push) Successful in 15s
CI / build (push) Successful in 16s
CI / quality (push) Successful in 20s
CI / security (push) Successful in 35s
CI / typecheck (push) Successful in 42s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m38s
CI / integration_tests (push) Successful in 3m11s
CI / docker (push) Successful in 39s
CI / coverage (push) Successful in 4m58s
CI / benchmark-publish (push) Successful in 17m32s
Docs: Restyled ADR pages
2026-03-10 12:38:35 -04:00

18 KiB
Raw Permalink Blame History

adr_number, title, status_history, tier, authors, superseded_by, related_adrs, acceptance
adr_number title status_history tier authors superseded_by related_adrs acceptance
37 Tool Reachability and Access Projection
2026-02-21
Proposed
Jeffrey Phillips Freeman
2026-02-21
Accepted
Jeffrey Phillips Freeman
3
Jeffrey Phillips Freeman
null
number title relationship
8 Resource System Resource DAG provides the containment and equivalence structure for reachability
number title relationship
11 Tool System Tools declare resource slots; this ADR extends binding resolution with transitive reachability
number title relationship
36 Resource DAG Operational Semantics Defines purposes 13 (reachability, equivalence, routing) that this ADR formalizes
number title relationship
38 Cross-Mechanism Sandbox Coordination Routing decisions must account for cross-mechanism coherence and sandbox coordination
number title relationship
39 Container and Execution Environment Resource Types Container resources create alternative reachability paths through mount hierarchies
number title relationship
40 LSP Resource Types LSP resources are the primary use case for semantically-rich read routing
votes_for votes_against abstentions
voter comment
Jeffrey Phillips Freeman <Jeffrey.Freeman@CleverThis.com> Transitive reachability with access projection and read/write routing is the missing link between DAG structure and tool execution

Context

ADR-011 defines how tools declare resource slots and how the BindingResolutionService resolves slots to concrete resources. The specification (lines 2011320321) details the three binding modes (contextual, static, parameter) and shows resource discovery queries like "What tools can modify this resource?" However, the current model only answers direct binding questions — whether a tool's slot type matches a specific resource's type. It does not formalize:

  1. Transitive reachability: A tool bound to a git-checkout can transitively reach every file within that checkout, but this reach is implicit in the tool's implementation, not explicit in the DAG.
  2. Inverse reachability: Given a specific file deep in the DAG, what tools can reach it? This requires walking up the containment hierarchy, not just checking direct type matches.
  3. Cross-equivalence reachability: A file accessible through both a git checkout and a Docker container mount might be reachable through tools bound to either ancestor chain.
  4. Access projection: When a tool is bound to an ancestor resource and needs to access a descendant, how is the descendant identified within the ancestor's access space? (e.g., relative path for filesystem, document URI for LSP, SQL query for database).
  5. Read/write routing: When a virtual resource has multiple physical manifestations, which tool should the system prefer for reads (semantic richness) versus writes (sandbox tracking)?

Decision Drivers

  • Current binding resolution only answers direct type-match questions; a tool bound to a git-checkout implicitly reaches all contained files but this reach is not formalized in the DAG
  • Inverse reachability ("what tools can reach this specific file?") requires walking up the containment hierarchy, not just checking direct type matches
  • A virtual resource with multiple physical manifestations (git checkout, container mount, LSP document) may be reachable through tools bound to any ancestor chain
  • Access projection must translate between namespaces (filesystem paths, LSP document URIs, container-internal paths) when a tool accesses a descendant through an ancestor binding
  • Read and write operations benefit from different physical paths — semantic reads through LSP, sandbox-tracked writes through git — requiring explicit routing by richness and sandbox tracking capability

Decision

CleverAgents formalizes tool reachability as a transitive property through the resource DAG's contains edges, adds an access projection method to the ResourceHandler protocol, and defines a read/write routing algorithm that selects optimal tools and access paths based on virtual resource equivalence classes.

Design

Tool Reachability

Forward Reachability

Definition: The forward reachability set of a tool T bound to resource R is:

forward_reach(T, R) = {R}  {d : d is a descendant of R via 'contains' edges}

Every resource in the forward reachability set can be accessed by tool T through resource R, subject to:

  • The tool's access mode (read_only, read_write, write_only) constraining what operations are allowed.
  • The descendant resource's effective writability (see ADR-036: Access Control Propagation).
  • The access projection being defined for the (R.type, d.type) pair.

Example: A write_file tool bound to git-checkout at /repo has forward reachability over:

git-checkout "/repo"
  └── fs-directory "/repo" (worktree root)
       ├── fs-directory "/repo/src"
       │    ├── fs-file "/repo/src/main.py"     ← reachable
       │    └── fs-file "/repo/src/utils.py"     ← reachable
       └── fs-file "/repo/README.md"             ← reachable

Inverse Reachability

Definition: The inverse reachability set of a resource r is the set of all tools that can transitively reach r:

inverse_reach(r) = {(T, R, path) : T is a tool with resource slot bound to R,
                                    and r ∈ forward_reach(T, R),
                                    and path = containment_path(R, r)}

Each entry in the inverse reachability set is a triple of (tool, binding resource, containment path from binding resource to target).

Algorithm:

  1. Walk up contains edges from r to collect all ancestors: ancestors(r) = {r, parent(r), parent(parent(r)), ...}.
  2. For each ancestor a, find all tools with resource slots whose type is compatible with a.resource_type.
  3. Filter by access mode compatibility (e.g., if querying for write-reachability, only include tools with read_write or write_only access).
  4. Return the set of (tool, ancestor, path_from_ancestor_to_r) triples.

Complexity: O(depth × |tools|) where depth is the DAG depth from r to root. In practice, depth is 36 and tool count is bounded, making this efficient.

Cross-Equivalence Reachability

When a resource r has a virtual parent (equivalence link), the system can also find tools that reach equivalent physical manifestations of the same virtual resource:

cross_equiv_reach(r) = inverse_reach(r)
                        {(T, R, path) : for each sibling s of r under the same virtual parent,
                                          (T, R, path) ∈ inverse_reach(s)}

This answers: "What tools can reach the same logical resource through any physical path?"

Example: File src/main.py exists as:

  • fs-file under git-checkout (reachable by write_file, edit_file)
  • lsp-document under lsp-workspace (reachable by hypothetical lsp_hover, lsp_goto_def tools)
  • fs-file under container-mount (reachable by docker_exec tools)

Cross-equivalence reachability for any of these physical manifestations returns all tools from all three paths.

Access Projection

When a tool is bound to resource R and needs to access a descendant resource d, the access projection computes how d is identified within R's access space.

The project_access Handler Method

Every resource type handler implements:

class ResourceHandler(Protocol):
    # ... existing methods ...

    def project_access(
        self,
        binding_resource: Resource,
        target_resource: Resource,
        containment_path: list[Resource],
        sandbox: Sandbox | None,
    ) -> AccessProjection | None:
        """Compute how to reach target_resource from binding_resource.

        Args:
            binding_resource: The resource that the tool is bound to.
            target_resource: The descendant resource to be accessed.
            containment_path: The path of resources from binding_resource
                              to target_resource through 'contains' edges.
            sandbox: The active sandbox, if any. Used for path rewriting.

        Returns:
            An AccessProjection if the target is reachable through this
            handler, or None if the handler cannot project access to
            the target type.
        """
        ...

AccessProjection

@dataclass(frozen=True)
class AccessProjection:
    """How to reach a target resource from a bound resource."""

    # The access path in the binding resource's namespace
    access_path: str

    # The protocol/mechanism for access
    protocol: str

    # Whether this projection crosses a sandbox boundary
    # (i.e., the access path goes through a different sandbox
    # domain than the binding resource's domain)
    crosses_sandbox: bool

    # The sandbox boundary resource_id that the target
    # resides in (may differ from binding resource's boundary)
    target_sandbox_boundary_id: str | None

    # Access capabilities through this projection
    readable: bool
    writable: bool

    # Richness score for read routing (higher = more information)
    # Filesystem: 1, LSP: 10, semantic index: 5, etc.
    read_richness: int

Built-in Projection Examples

GitCheckoutHandler → fs-file:

def project_access(self, binding, target, path, sandbox):
    # Compute relative path from checkout root to target file
    rel_path = os.path.relpath(target.location, binding.location)
    sandbox_path = sandbox.get_path() if sandbox else binding.location
    return AccessProjection(
        access_path=os.path.join(sandbox_path, rel_path),
        protocol="filesystem",
        crosses_sandbox=False,  # same sandbox domain
        target_sandbox_boundary_id=binding.resource_id,
        readable=True,
        writable=True,
        read_richness=1,  # raw file content only
    )

LSPWorkspaceHandler → lsp-document:

def project_access(self, binding, target, path, sandbox):
    return AccessProjection(
        access_path=target.properties["document_uri"],
        protocol="lsp-textdocument",
        crosses_sandbox=True,  # LSP reads from its own buffer
        target_sandbox_boundary_id=None,  # no sandbox for LSP
        readable=True,
        writable=True,  # via workspace/applyEdit
        read_richness=10,  # semantic info: types, refs, diagnostics
    )

ContainerInstanceHandler → fs-file (via container-mount):

def project_access(self, binding, target, path, sandbox):
    mount = find_mount_in_path(path)  # find container-mount in containment path
    container_path = remap_to_container_path(target.location, mount)
    return AccessProjection(
        access_path=container_path,
        protocol="container-exec",
        crosses_sandbox=False,  # within container's sandbox domain
        target_sandbox_boundary_id=binding.resource_id,
        readable=True,
        writable=not mount.properties.get("read_only", False),
        read_richness=1,  # same as filesystem
    )

Read/Write Routing

When the system needs to access a virtual resource (or any resource with multiple physical manifestations), it must choose which physical path and tool to use.

Canonical Write Target

Each virtual resource designates one physical manifestation as the canonical write target — the manifestation through which writes should be routed to ensure sandbox and checkpoint tracking.

Selection rules (in priority order):

  1. If exactly one physical manifestation is sandboxable, it is the canonical write target.
  2. If multiple are sandboxable, prefer the one with the strongest sandbox strategy: git_worktree > snapshot > copy_on_write > transaction_rollback > none.
  3. If still ambiguous, prefer the manifestation linked to the plan's primary project.
  4. The user can override canonical write target via resource configuration.

The canonical write target is stored on the virtual resource as metadata: canonical_write_resource_id.

Read Source Ranking

When routing a read, physical manifestations are ranked by read richness (the read_richness score from AccessProjection):

Source read_richness Provides
lsp-document 10 Type info, symbol table, diagnostics, go-to-def, references, completions
Semantic index (future) 5 Pre-computed symbol index, dependency graph
fs-file via git-checkout 1 Raw file content, git history
fs-file via container-mount 1 Raw file content
git-tree-entry 1 File content at specific commit

The system selects the highest-richness source that is:

  1. Available (the source resource exists and its handler is accessible).
  2. Current (not stale — coherence checks pass, or staleness is acceptable for the query).
  3. Compatible with the query type (some queries require semantic info, others just need raw content).

Routing Algorithm

route_access(virtual_resource, access_mode, query_type):
    manifestations = physical_children(virtual_resource)

    if access_mode == WRITE:
        target = canonical_write_target(virtual_resource)
        tools = inverse_reach(target) filtered by writes=True
        return best_tool(tools), project_access(tool.binding, target)

    if access_mode == READ:
        ranked = sort(manifestations, key=lambda m: best_projection(m).read_richness, reverse=True)
        for m in ranked:
            if is_available(m) and is_current_enough(m, query_type):
                tools = inverse_reach(m) filtered by read_only compatible
                if tools:
                    return best_tool(tools), project_access(tool.binding, m)
        # Fall back to any available manifestation
        return fallback_read(manifestations)

Integration with Existing Binding Resolution

The access projection and routing system extends, not replaces, the existing BindingResolutionService. The flow becomes:

  1. Binding resolution (existing): Tool slots are resolved to concrete resources via contextual/static/parameter binding.
  2. Forward reachability (new): Once bound, the system computes the tool's transitive reachability set through the DAG.
  3. Access projection (new): When the tool accesses a specific descendant, the handler computes the access projection.
  4. Read/write routing (new): When the planner selects which tool to use for a specific virtual resource, the routing algorithm selects the optimal physical path.

Steps 1 is performed at tool activation time. Steps 24 are performed at execution time.

Constraints

  • Access projection must be O(1) per handler invocation (no recursive DAG traversal within the projection itself — the containment path is pre-computed and passed in).
  • Read/write routing must not introduce latency-sensitive decisions into the execution hot path. Routing can be pre-computed during the Strategize phase for known resource access patterns.
  • Cross-equivalence reachability may return large result sets for virtual resources with many physical manifestations. Results should be lazily computed and filtered early.
  • The read_richness score is a heuristic. Tool authors can override it for custom resource types.

Consequences

Positive

  • The system can answer "what tools can reach this file?" without the caller knowing the file's position in the DAG hierarchy.
  • Read/write routing through different physical paths enables using the best tool for each access pattern (semantic reads through LSP, sandbox-tracked writes through git).
  • Access projection formalizes the implicit knowledge that tools currently carry about how to reach descendant resources.

Negative

  • The project_access method adds a new responsibility to every resource handler. Existing handlers must be updated.
  • Routing introduces decision-making that could produce suboptimal choices if read_richness scores are miscalibrated.
  • Cross-equivalence reachability creates coupling between otherwise independent physical resource hierarchies.

Risks

  • The routing algorithm depends on LSP servers being available and current. If the LSP server is down, the system falls back to raw filesystem reads, but the fallback must be seamless.
  • Access projection assumes that containment paths are stable during execution. If the DAG is modified during execution (e.g., auto-discovery adds new resources), projections may become stale.

Alternatives Considered

Flat tool-resource matching (status quo) — Tools are matched to resources only by direct type compatibility. Simple but cannot support transitive reachability or semantic read routing.

Resource URL schemes for access projection — Instead of handler-based projection, use URL schemes (e.g., file://, lsp://, docker://) to encode access paths. This is more declarative but less flexible — URL schemes cannot encode sandbox-specific path rewriting or cross-boundary awareness.

Compliance

  • Forward reachability tests: Verify that a tool bound to a git-checkout correctly reaches all descendant fs-file resources.
  • Inverse reachability tests: Verify that querying inverse reach for a deeply nested file returns tools bound to all ancestors.
  • Cross-equivalence tests: Verify that querying reachability for one physical manifestation returns tools from equivalent manifestations.
  • Access projection tests: Each built-in handler's project_access method is tested for correct path computation, sandbox path rewriting, and protocol assignment.
  • Routing tests: Read routing prefers LSP sources over filesystem sources. Write routing uses the canonical write target.