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:
- 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.
- 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. - Per-graph access control is its own Cedar dimension. Trails adds
the entity type
Trails::Graph::<graph_iri>, used alongsideTrails::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 explicit —
Model.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¶
- Replay interaction. How do
Trails::Graph::*policies behave under theReplayerror variant when a replay re-evaluates on a different principal's session? Needs an ADR-0013 cross-reference once replay semantics land. @policydecorator graph arg. Does@policyneed agraphkeyword analogous to@node_type's, enabling load-time policy reachability analysis over the touched graph set?- Write-side override. When
@node_typedeclaresgraph="urn:tenant-a"AND a capability callsctx.kg.add(instance, graph="urn:other")— who wins? Proposed: explicitctx.kg.add(graph=...)wins, but fires aTrails::Graph::*check against the override target. Confirm before Phase 3 ships.