Skip to content

ADR-0017a: Property-path semantics across multi-valued hops (amendment to ADR-0017)

  • Status: Accepted (2026-04-14)
  • Amends: ADR-0017 — resolves Open Question #4
  • Related: ADR-0021 (Progressive enhancement)

Context

ADR-0017 §Phase 3 commits the ORM to lower Django-style traversal (Patient.where(care_team__lead__name="Alice")) onto SPARQL property paths. Open Question #4 in that ADR flags an ambiguity the surface alone cannot resolve: when an intermediate hop is multi-valued (care_team declared with max_count > 1 on the @node_type, per ADR-0021), one filter expression has two readings:

  • Existential — match Patients where AT LEAST ONE care-team member has a lead named Alice. Lowers to a plain triple pattern in WHERE (or EXISTS { … } when wrapped).
  • Universal — match Patients where EVERY care-team member has a lead named Alice. Vanilla SPARQL has no forall; the only faithful lowering is double-negation (FILTER NOT EXISTS { … FILTER NOT EXISTS { … } }).

The Phase 3 sprint plan (internal planning §Risks) blocks on this decision: _compile cannot emit property-path SPARQL until the ORM agrees which reading is the default.

Decision

Existential is the default. Universal is opt-in via an __all__ modifier inserted at the multi-valued hop.

  • Patient.where(care_team__lead__name="Alice") — existential. Plain property-path triple under WHERE; one matching member satisfies.
  • Patient.where(care_team__all__lead__name="Alice") — universal. Nested FILTER NOT EXISTS (negation-of-negation) anchored on the multi-valued hop.

__all__ is reserved at the segment level — not a filter suffix, not a field name. _resolve_association_chain MUST reject any @node_type declaring all as a literal field.

Worked example

# Existential (default). Generated SPARQL:
#   ?patient a :Patient ; :care_team ?m .
#   ?m :lead ?l . ?l :name "Alice"^^xsd:string .
hits = Patient.where(care_team__lead__name="Alice").fetch(ctx)

# Universal. Generated SPARQL:
#   ?patient a :Patient .
#   FILTER NOT EXISTS {
#     ?patient :care_team ?m .
#     FILTER NOT EXISTS { ?m :lead ?l . ?l :name "Alice"^^xsd:string . }
#   }
strict = Patient.where(care_team__all__lead__name="Alice").fetch(ctx)

Consequences

Positive. Matches SQL/ActiveRecord muscle memory: JOIN is existential by default — what users from Rails, Django, Prisma expect. Existential is also the cheaper SPARQL (a single BGP walk), so the default is the fast path.

Negative. Universal lowers to double-negation: slow on large graphs and hard for engine optimisers. The Phase 3 tutorial addendum (docs/tutorials/growing-your-kg-app.md) MUST flag this beside the N+1 callout ADR-0017 §Mitigations already requires. Universal is vacuously true when the multi-valued edge is empty; combine with an existential filter when "non-empty AND universal" is needed.

Neutral. Naming follows Django filter-suffix convention (__in, __contains, now __all__), lowering friction for Python developers. Single-valued hops accept __all__ as a no-op (the lowering collapses to existential because forall over a singleton is exists), so shape evolution narrowing a hop does not break existing queries.

Alternatives considered

  • Universal as default. Rejected: surprises every user with JOIN intuition; makes the common case the slow lowering.
  • Forbid traversal across multi-valued hops. Rejected: defeats the property-path traversal Phase 3 exists to deliver; pushes users back to sparql"…" for exactly the case the ORM should make ergonomic.
  • ANY(...) / ALL(...) wrappers around the chain. Rejected: more verbose than __all__, breaks chain-as-string composition, invents a Trails-only idiom where Django convention covers the case.

Open questions

  • __in over a multi-valued field. Does where(care_team__name__in=[…]) mean "any member's name is in the list" (Cartesian) or "the set of member names equals the list" (element-wise)? Current stance matches the existential default (Cartesian); the set-equality reading may force its own amendment.
  • Reasoner interaction. When property paths cross edges materialised by ADR-0004 query-time reasoning, does __all__ quantify over the inferred closure or the asserted base only? Needs a worked example before the Phase 3 gate.
  • Cardinality detection. Must max_count > 1 be declared on @node_type for __all__ to be accepted at that segment, or can the resolver detect multi-valuedness dynamically from the store? Static matches ADR-0021's feature-detection rule; dynamic is more permissive for label-only graphs.