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:
- RDF class — if the resource has one or more
rdf:typetriples naming anowl:Class/rdfs:Class, pick the most specific class (leaf in therdfs:subClassOfDAG materialised at policy-load time). Suffix = the IRI local name of that class (schema:CreativeWork→CreativeWork). @node_type— if no RDF class is declared but the resource carries a@node_typeannotation, suffix = the@node_typename (@node_type("Evidence", ...)→Evidence).- 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 doctorflags ambiguity (see §6). Unknown— no typing information at all. Suffix =Unknown. Rare in practice; seen mostly in REPL usage and duringtrails.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::Notewill NOT match. - Policies targeting
Trails::Resource::CreativeWork— and any ancestor reachable viardfs: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::Noteon 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 fromNote— at which pointtrails doctorflags 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::NotetoTrails::Resource::CreativeWorkwithout any policy edit. Mitigated — not eliminated — by thetrails doctordivergence 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::Notepolicy in isolation cannot tell whether a given production resource actually routes through it without checking the resource's annotations. Mitigated bytrails 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:subClassOfmaterialisation unchanged from ADR-0006.
Alternatives considered¶
- 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.
- 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.
- 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. - 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:Articleandbibo:Article, both reduce to suffixArticle. Proposed stance: last-wins at policy-load time with atrails doctorerror; 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
Unknowninteract with deny-by-default policies? A resource with no typing information currently matches onlyTrails::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.