Skip to content

ADR-0022: Cedar unified matcher — strongest-available type

  • Status: Accepted (2026-04-14)
  • Date: 2026-04-14
  • Extends: ADR-0006 (Cedar as policy engine)
  • Implements: ADR-0021 (Progressive enhancement, not tiered surfaces)
  • Supersedes: — (see ADR-0006a, withdrawn under ADR-0021)
  • Superseded by:

Context

ADR-0021 retired the tier model. Under that ADR, a resource entering the PDP may carry only a label today, gain a @node_type declaration next week, and acquire an RDF class three months later. The Cedar policy that governs it should not have to be rewritten each time.

The withdrawn ADR-0006a proposed three parallel Cedar entity-type prefixes — Trails::Label::*, Trails::Type::*, Trails::Class::* — one per tier, with cross-tier policies described as "deliberately verbose." In practice that meant every non-trivial permission had to be written once per prefix and re-audited on every typing-feature addition. Under ADR-0021's progressive-enhancement model, the tier boundary that justified three prefixes no longer exists.

What does exist is one resource that happens to have more or fewer types attached to it depending on how mature the app is. The policy question ("may this principal invoke this capability on this resource?") is the same question in all three cases. The matching mechanism should be the same too.

Decision

Cedar entity typing uses a single prefix Trails::Resource::*. The type suffix is the resource's strongest-available type name at request time. One code path. One prefix. No tiers.

The matcher

At policy-evaluation time, the PEP annotates each resource entering the PDP with its strongest-available type. Strength order, highest first:

  1. RDF class — if the resource has one or more rdf:type triples naming an owl:Class / rdfs:Class, pick the most specific class (leaf in the rdfs:subClassOf DAG materialised at policy-load time). Suffix = the IRI local name of that class (schema:CreativeWorkCreativeWork).
  2. @node_type — if no RDF class is declared but the resource carries a @node_type annotation, suffix = the @node_type name (@node_type("Evidence", ...)Evidence).
  3. Label — if neither of the above is present but the resource has one or more labels, suffix = the label string (labels=["Note"]Note). If multiple labels exist, the first declared label wins; trails doctor flags ambiguity (see §6).
  4. Unknown — no typing information at all. Suffix = Unknown. Rare in practice; seen mostly in REPL usage and during trails.kg.node() calls before any annotation has been declared.

The Cedar entity type is always Trails::Resource::<StrongestType>. Policies name this type directly; there is no wrapping namespace per tier.

Three example policies — same policy, different typing maturity

The same view-note policy survives three typing-feature additions without edits. Each snippet is the only policy; the PEP's matcher is what changes which path the resource takes through it.

Label-only (labels=["Note"], nothing else):

permit (
    principal,
    action == Action::"view",
    resource is Trails::Resource::Note
) when {
    resource.owner == principal
};

After adding @node_type("Note", fields={...}):

permit (
    principal,
    action == Action::"view",
    resource is Trails::Resource::Note
) when {
    resource.owner == principal
};

After declaring RDF schema:Note a owl:Class:

permit (
    principal,
    action == Action::"view",
    resource is Trails::Resource::Note
) when {
    resource.owner == principal
};

Same six lines, three typing regimes. No rewrite, no dual policies, no tier migration. Under ADR-0006a's three-prefix scheme, two of those three snippets would have been required as separate Trails::Label::Note / Trails::Type::Note / Trails::Class::Note stanzas and re-audited on every feature addition.

Entity hierarchy

When RDF classes with rdfs:subClassOf exist, Trails materialises that hierarchy into Cedar's entity-type hierarchy at policy-load time — unchanged from ADR-0006. A policy on Trails::Resource::CreativeWork thus matches a resource typed as schema:Article when schema:Article rdfs:subClassOf schema:CreativeWork is declared in the ontology.

For resources whose strongest-available type comes from a label or @node_type, the hierarchy is flat — there is no subtype graph to materialise. Trails::Resource::Note matches resources with suffix Note exactly, and nothing else. This is a direct consequence of ADR-0021: subtyping is a feature you opt into by declaring an ontology, not a universal property of the surface.

Disambiguation rule

The strength order is total, not advisory. If a resource carries both a label Note and an RDF class schema:CreativeWork:

  • Strongest-available = CreativeWork (RDF class wins).
  • Policies targeting Trails::Resource::Note will NOT match.
  • Policies targeting Trails::Resource::CreativeWork — and any ancestor reachable via rdfs:subClassOf — will match.

This is intentional: once a user declares RDF classes, the semantic layer is the authoritative type source and label-level policies should no longer be decisive. Silent mismatch between label and class name is the main failure mode this ordering creates, so trails doctor lints for label / RDF-class-local-name divergence and warns when a resource's label differs from its class's IRI local name (e.g., label Note + class schema:CreativeWork — not wrong, but often an unintended rename that invalidates a label-scoped policy).

Relationship to ADR-0005

Unchanged. The canonical CapabilityDescriptor gains no new fields for typing regime selection. Cedar matches against whatever typing exists on the resource at request time; there is no manifest-level declaration that a capability "consumes labels only" versus "consumes RDF classes." That metadata, if surfaced at all, is derived at handler registration (per ADR-0017 §derive-don't-declare) — not authored.

Consequences

Positive

  • One prefix, one code path. The matcher is a single function: inspect the resource, pick the strongest available type, form Trails::Resource::<suffix>. No per-tier dispatch.
  • Policies travel as features are added. A policy written against Trails::Resource::Note on day one keeps matching when the team adds @node_type("Note", ...) on day thirty. It only stops matching if the team adds an RDF class whose local name differs from Note — at which point trails doctor flags the divergence before the policy silently stops firing.
  • Cross-tier policy duplication disappears. ADR-0006a's "written twice, audited twice" cost goes to zero.
  • Aligns with ADR-0021. The policy surface mirrors the framework surface: one, with features added additively.

Negative

  • Feature-add order can silently change policy matches. Declaring an RDF class whose local name differs from the existing label shifts that resource from Trails::Resource::Note to Trails::Resource::CreativeWork without any policy edit. Mitigated — not eliminated — by the trails doctor divergence lint and by the recommendation that teams keep class local names aligned with existing labels when first introducing an ontology.
  • Policy reviewers must know the strength order. A security auditor reading a Trails::Resource::Note policy in isolation cannot tell whether a given production resource actually routes through it without checking the resource's annotations. Mitigated by trails doctor's per-resource "this would match policies: …" dry-run explainer.

Neutral

  • Cedar crate dependency unchanged.
  • Decision-log shape unchanged; the logged entity type just uses the single Trails::Resource::* prefix.
  • rdfs:subClassOf materialisation unchanged from ADR-0006.

Alternatives considered

  1. Three prefixes (ADR-0006a, withdrawn). Rejected. Forces every cross-tier policy to be written and audited N times, with N growing as typing features are added. The tier boundary that justified this no longer exists under ADR-0021.
  2. Flat matching on first declared type — ignore RDF class when a label exists. Rejected. Inverts the strength order, pinning policies to the weakest typing the resource ever had. Defeats the whole point of progressive enhancement: users who add an ontology would find their semantic declarations ignored by the PDP.
  3. Explicit Trails::Strongest::* alias. Rejected. Verbose, and the prefix name "Strongest" leaks a mechanism that should be implicit. Trails::Resource::* is the resource; "strongest available" is how the suffix is picked, not something policy authors should have to spell.
  4. Let authors pick the prefix per policy (opt-in tier targeting). Rejected. Same failure mode as Alternative 1, just moved to the policy author. Adds decision friction at exactly the point ADR-0021 removed it.

Open questions

  • IRI local name collisions across ontologies. If an app mixes schema:Article and bibo:Article, both reduce to suffix Article. Proposed stance: last-wins at policy-load time with a trails doctor error; revisit if real apps hit this.
  • Materialising sub-class hierarchy at policy-load versus request time. ADR-0006 says load-time; a large evolving ontology may push toward request-time materialisation with memoisation. Defer until benchmarks show a need.
  • How does Unknown interact with deny-by-default policies? A resource with no typing information currently matches only Trails::Resource::Unknown — meaning policies written without that suffix silently deny. Is that the desired default, or should the PEP surface a distinct "untyped resource" diagnostic before deny? Likely the latter, but out of scope for this ADR.