Skip to content

ADR-0017b: Cross-graph property-path traversal under Cedar (amendment to ADR-0017)

  • Status: Accepted (2026-04-15)
  • Amends: ADR-0017 — resolves Open Question #3
  • Related: ADR-0017a (property-path semantics), ADR-0022 (Cedar unified matcher), ADR-0006 (Cedar policy), ADR-0021 (progressive enhancement)

Context

ADR-0017a's Open Question #2 flagged an ambiguity: when an ADR-0017 Phase 3 property path leaves the originating named graph mid-walk (e.g., a Patient in tenant-A's graph traverses :provider to a Provider in a shared policy: graph), which Cedar policy applies — source, destination, both, or a composite?

Two real callers force the decision: the multi-tenant testgraph needs cross-tenant reads against a shared reference graph without silently leaking authority, and the reference compliance application will hit it end-to-end (Evidence lives per tenant but cites Provider/Study in a shared corpus graph). ADR-0022 resolved the type dimension (single Trails::Resource::* prefix); the graph dimension is unresolved.

Decision

Three rules, deliberately narrow:

  1. The originating principal's policy always evaluates. The user issuing the query has one identity; Cedar policy is on that principal × the resource. Crossing a graph boundary does not re-attribute the query mid-walk.
  2. Cross-graph traversal is NOT a tier boundary. The Trails::Resource::* matcher (ADR-0022) reads strongest-available type per entity; graph membership is not a type attribute. Type and graph are orthogonal axes.
  3. Per-graph access control is its own Cedar dimension. Trails adds the entity type Trails::Graph::<graph_iri>, used alongside Trails::Resource::*. permit(principal, action, resource) when { resource in Trails::Graph::"urn:tenant-a" } restricts the principal to one tenant's graph. Cross-graph traversal fires a Cedar check per graph touched; first denial short-circuits.

ORM surface. A @node_type may declare a home graph (graph="urn:tenant-a"). Writes go there; reads scope there. Cross-graph queries must be explicitModel.where(...).across_graphs([iri1, iri2]). No implicit federation. Default graph (no graph= declared) remains the common case.

Worked example

@node_type("Evidence", fields={"provider": Provider, "claim": str},
           graph="urn:tenant-a")
class Evidence: ...

# Cross-graph read — explicit opt-in.
hits = (Evidence
        .where(provider__name="Alice")
        .across_graphs(["urn:tenant-a", "urn:shared"])
        .fetch(ctx))

Generated SPARQL (one FROM NAMED per graph in across_graphs):

SELECT ?ev ?name FROM NAMED <urn:tenant-a> FROM NAMED <urn:shared> WHERE {
  GRAPH <urn:tenant-a> { ?ev a :Evidence ; :provider ?p . }
  GRAPH <urn:shared>   { ?p :name ?name . FILTER(?name = "Alice") }
}

Cedar checks fired by the PEP: two, one per graph — (principal, action, Trails::Graph::"urn:tenant-a") then (principal, action, Trails::Graph::"urn:shared"). First denial short-circuits before SPARQL runs; the resource-level Trails::Resource::Evidence check still fires as today.

Consequences

Positive. Explicit graph-scoping is a feature, not a surprise. The new Trails::Graph::* dimension sits beside Trails::Resource::* without overloading either prefix. The reference application's tenant/shared split has a declared lowering before its first capability ships.

Negative. Cross-graph queries pay N Cedar evaluations instead of 1. Mitigation: cache (principal, action, graph) decisions per request — a pure key, stable across a request snapshot.

Neutral. No change to the common case: no graph= on the @node_type, no across_graphs(...), no extra Cedar checks.

Alternatives considered

  • Per-graph principal — rejected. Principals are graph-independent; graphs are data scoping. Splitting principals per graph re-invents tenant federation at the wrong layer and breaks the one-decision-log-entry-per-request invariant.
  • Implicit federation via FROM NAMED — rejected. Bypasses Cedar (the matcher sees only the originating graph) and creates silent data-leak risk when a path happens to traverse a shared graph.
  • Graph as a tier in the matcher — rejected. ADR-0021 killed tier framing and ADR-0022 collapsed three prefixes to one; treating graph membership as a tier re-introduces the dual-write cost ADR-0006a was withdrawn to eliminate.

Open questions

  1. Replay interaction. How do Trails::Graph::* policies behave under the Replay error variant when a replay re-evaluates on a different principal's session? Needs an ADR-0013 cross-reference once replay semantics land.
  2. @policy decorator graph arg. Does @policy need a graph keyword analogous to @node_type's, enabling load-time policy reachability analysis over the touched graph set?
  3. Write-side override. When @node_type declares graph="urn:tenant-a" AND a capability calls ctx.kg.add(instance, graph="urn:other") — who wins? Proposed: explicit ctx.kg.add(graph=...) wins, but fires a Trails::Graph::* check against the override target. Confirm before Phase 3 ships.