From 304c01e516a18b46afe01772ec914def8be6677b Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Wed, 10 Jun 2026 23:48:30 +0300 Subject: [PATCH 1/2] fix(gates): deny default-tier wildcard bypass of more-specific gated tiers `@eep-dev/gates` (TypeScript) and `eep-gates` (Python) let a no-requirements default tier silently bypass a gated tier through a broader wildcard. A gate config commonly publishes a broad scope on the default tier and carves out a narrower, gated path, for example: default "public": access = ["content.*", "profile.*"] "paid": access = ["content.premium.*"], requirements = [trust>=20] Because `content.*` covers `content.premium.X`, `resolveAccess` / `resolve_access` granted the gated resource via the default tier's broad match even when the gated tier's requirements were unmet, so a request with no proofs read premium content (the same class of bypass already patched in the more.md data plane). The resolver now applies a specificity override: when the winning tier is the default tier and a gated (requirements-bearing) tier targets the same resource with an equal-or-more-specific access pattern, the default grant is suppressed and the request is denied with the gated tier's unmet requirements (a 402 instead of a leak). Behaviour is preserved everywhere else: - a strictly more specific default pattern keeps its grant (owners can still open a narrower path explicitly); - resources no gated tier touches stay public; - resources the default tier does not cover at all fail closed; - resource-less resolution and tiers satisfied by valid proofs are unchanged. Specificity ranks an exact pattern (`a.b.c`) above any scope wildcard (`a.b.*`, longer prefix wins) above the universal wildcard (`*`). The equal-or-more-specific (`>=`) tie-breaking matches the deployed more.md Go and TypeScript data-plane implementations, so re-vendoring the library does not change enforcement. New exported helpers: `patternSpecificity`, `bestSpecificityFor`, `defaultTierOverriddenByGatedTier` (TypeScript) and `pattern_specificity`, `best_specificity_for`, `default_tier_overridden_by_gated_tier` (Python). Tests: dedicated unit suites in both languages (100% of the changed lines), plus a shared cross-language fixture (`tests/parity/gate-resolution-specificity-fixtures.json`) executed by both the TypeScript and Python suites so the two resolvers cannot drift. The SPECIFICATION gains a non-normative tier-matching-precedence note; both gates READMEs and the CHANGELOG document the new behaviour. Surfaced by the EEP protocol audit. Signed-off-by: Ugur Cekmez --- CHANGELOG.md | 23 ++ docs/current/SPECIFICATION.md | 2 + packages/@eep-dev/gates/README.md | 30 +++ .../@eep-dev/gates/src/access-resolver.ts | 66 +++++- packages/@eep-dev/gates/src/index.ts | 4 +- .../gates/src/resolution-parity.test.ts | 31 +++ .../@eep-dev/gates/src/resource-matcher.ts | 36 ++- .../gates/src/specificity-override.test.ts | 223 ++++++++++++++++++ packages/eep-gates-python/README.md | 29 +++ .../eep-gates-python/eep_gates/__init__.py | 10 +- .../eep_gates/access_resolver.py | 71 +++++- .../eep_gates/resource_matcher.py | 38 ++- packages/eep-gates-python/tests/test_all.py | 197 +++++++++++++++- .../tests/test_resolution_parity.py | 43 ++++ .../gate-resolution-specificity-fixtures.json | 119 ++++++++++ 15 files changed, 902 insertions(+), 20 deletions(-) create mode 100644 packages/@eep-dev/gates/src/resolution-parity.test.ts create mode 100644 packages/@eep-dev/gates/src/specificity-override.test.ts create mode 100644 packages/eep-gates-python/tests/test_resolution_parity.py create mode 100644 tests/parity/gate-resolution-specificity-fixtures.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3817429..4f00994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ All notable changes to this repository are documented here. The format is loosel ### Security +- **`@eep-dev/gates` (TypeScript) and `eep-gates` (Python) — closed a + default-tier wildcard specificity bypass in `resolveAccess` / + `resolve_access`.** A gate configuration commonly publishes a broad scope on + the no-requirements default tier and carves out a narrower, gated path, e.g. + default `public` with `access: ["content.*", "profile.*"]` plus a `paid` tier + with `access: ["content.premium.*"]` and `requirements: [...]`. Because + `content.*` covers `content.premium.X`, the resolver granted access to the + gated resource through the default tier's broad match even when the gated + tier's requirements were unmet, so a request with no proofs read premium + content. The resolver now applies a specificity override: when the winning + tier is the default tier and a gated (requirements-bearing) tier targets the + same resource with an **equal-or-more-specific** access pattern, the default + grant is suppressed and the request is denied with the gated tier's unmet + requirements (a 402 instead of a leak). A *strictly more specific* default + pattern keeps its grant (owners can still open a narrower path explicitly), + resources no gated tier touches stay public, and resources the default tier + does not cover at all fail closed. New helpers `patternSpecificity`, + `bestSpecificityFor`, `defaultTierOverriddenByGatedTier` (TypeScript) and + `pattern_specificity`, `best_specificity_for`, + `default_tier_overridden_by_gated_tier` (Python) are exported for reuse. + Resource-less resolution and tiers satisfied by valid proofs are unchanged. + Surfaced by the EEP protocol audit. + - **`@eep-dev/middleware` (TypeScript) and `eep-middleware` (Python) — auth adapters now fail closed and verify credentials.** The `JWTAuthAdapter` previously decoded the JWT payload and emitted `did_verified` identity and diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index ee5d58b..ddbc863 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -227,6 +227,8 @@ Proof validation is two-step: The protocol defines structural validation. Implementing platforms provide a `ProofVerifier` for semantic checks. +**Informative (non-normative) — tier matching precedence:** When more than one tier's access patterns match a requested resource, implementations SHOULD resolve to the **most specific** matching tier rather than the most permissive one. In particular, a `default_tier` wildcard (for example `content.*`) MUST NOT silently grant access to a resource that a gated tier protects with an equal-or-more-specific pattern (for example `content.premium.*`): an agent that has not satisfied the gated tier's requirements is denied (402) instead of falling through to the default tier. Pattern specificity ranks an exact pattern (`a.b.c`) above any scope wildcard (`a.b.*`, longer prefix more specific) above the universal wildcard (`*`). Resources that only the default tier covers stay public. The reference `@eep-dev/gates` / `eep_gates` libraries implement this as `defaultTierOverriddenByGatedTier` / `default_tier_overridden_by_gated_tier`. + --- ## 4. Layer 2: signal stream (SSE) diff --git a/packages/@eep-dev/gates/README.md b/packages/@eep-dev/gates/README.md index baec60d..1c6d2e1 100644 --- a/packages/@eep-dev/gates/README.md +++ b/packages/@eep-dev/gates/README.md @@ -98,6 +98,36 @@ await resolveAccess(proofs, gateConfig, resourcePath, registry, { }); ``` +#### Tier specificity (default-tier wildcard override) + +When resolving a specific `resource`, a more-specific gated tier always wins +over a broader default-tier wildcard. With the common shape below, an agent +with no proofs is denied `content.premium.*` (a 402 carrying the gated tier's +unmet requirements) even though the default `content.*` pattern technically +covers it: + +```typescript +const config = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.*', 'profile.*'] }, + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['content.premium.*'] }, + }, +}; + +await resolveAccess([], config, 'content.premium.x'); // { granted: false, tier: 'public', unmet: [trust] } +await resolveAccess([], config, 'content.blog.post'); // { granted: true, tier: 'public' } — public stays public +``` + +The default tier's grant is suppressed only when a gated (requirements-bearing) +tier matches the resource with an **equal-or-more-specific** access pattern. +A strictly more-specific default pattern keeps its grant, and resources the +default tier does not cover at all fail closed. Specificity ranks `"*"` below +any scope wildcard (`"a.b.*"`, longer prefix wins) below any exact pattern. The +helpers `patternSpecificity`, `bestSpecificityFor`, and +`defaultTierOverriddenByGatedTier` are exported for callers that re-implement +the hot path. + ### Proof Validation ```typescript diff --git a/packages/@eep-dev/gates/src/access-resolver.ts b/packages/@eep-dev/gates/src/access-resolver.ts index d1b1eef..197640c 100644 --- a/packages/@eep-dev/gates/src/access-resolver.ts +++ b/packages/@eep-dev/gates/src/access-resolver.ts @@ -6,7 +6,7 @@ */ import type { GateConfig, GateProof, AccessResult, UnmetRequirement, Requirement, CombinedRequirement } from './types.js'; -import { matchesAny } from './resource-matcher.js'; +import { matchesAny, bestSpecificityFor } from './resource-matcher.js'; import { validateProofStructure } from './proof-validator.js'; import type { ProofVerifierRegistry } from './proof-validator.js'; @@ -77,6 +77,24 @@ export async function resolveAccess( if (resource) { const bestTierConfig = config.tiers[bestTier]; if (bestTierConfig && matchesAny(bestTierConfig.access, resource)) { + // Specificity override. Reaching here with `bestTier` still equal to + // the default tier means no gated (requirements-bearing) tier was + // satisfied. The default tier should NOT grant the resource through + // a broad wildcard (e.g. "content.*") when a gated tier targets the + // same resource with an equal-or-more-specific pattern + // (e.g. "content.premium.*"); otherwise the broad public grant + // silently bypasses the gate. Deny and surface the gated tier's + // unmet requirements instead. + if ( + bestTier === config.default_tier && + defaultTierOverriddenByGatedTier(config, resource) + ) { + const unmet = await getUnmetForResource( + config, resource, proofsByType, verifierRegistry, + strictSemanticVerification, + ); + return { granted: false, tier: bestTier, unmet }; + } return { granted: true, tier: bestTier, unmet: [] }; } @@ -91,6 +109,52 @@ export async function resolveAccess( return { granted: true, tier: bestTier, unmet: [] }; } +/** + * Decide whether the default tier's grant of `resource` must be suppressed + * because a gated, non-default tier targets the same resource with an + * equal-or-more-specific access pattern. + * + * Gate configs are commonly shaped so the default tier publishes a broad scope + * and a gated tier carves out a more specific path: + * + * default "public": access = ["content.*", "profile.*"] + * "paid": access = ["content.premium.*"], requirements = [...] + * + * Here `content.*` in the public tier covers `content.premium.X`, so a request + * with no proofs would otherwise leak through the default tier. The owner's + * intent is "premium is more specific than public, so it gates" — this returns + * true in that case so the resolver denies and emits a 402 instead. + * + * The default's grant is suppressed when a gated tier's best matching pattern + * is at least as specific as the default tier's best match. A *strictly more + * specific* default pattern keeps its grant (the owner explicitly opened that + * narrower path), and a resource the default tier does not cover at all is + * treated as authoritatively owned by the gated tier (fail closed). + */ +export function defaultTierOverriddenByGatedTier(config: GateConfig, resource: string): boolean { + const defaultTier = config.tiers[config.default_tier]; + if (!defaultTier) return true; + + const defaultBest = bestSpecificityFor(defaultTier.access, resource); + if (defaultBest < 0) return true; + + for (const [tierKey, tier] of Object.entries(config.tiers)) { + if (tierKey === config.default_tier) continue; + // A non-default tier with no requirements is just another free tier; it + // does not gate anything, so it cannot override the default's grant. + if (!tier.requirements || tier.requirements.length === 0) continue; + + const gatedBest = bestSpecificityFor(tier.access, resource); + if (gatedBest < 0) continue; + + // At least as specific as the default's best match: the gated tier wins, + // so the default tier must not bypass its requirements. + if (gatedBest >= defaultBest) return true; + } + + return false; +} + /** * Check whether a single requirement (including nested combined) is satisfied. */ diff --git a/packages/@eep-dev/gates/src/index.ts b/packages/@eep-dev/gates/src/index.ts index 6a90f08..2e1cb2b 100644 --- a/packages/@eep-dev/gates/src/index.ts +++ b/packages/@eep-dev/gates/src/index.ts @@ -102,10 +102,10 @@ export type { export { parseGateConfig, serializeGateConfig, getUsedRequirementTypes, GateConfigError } from './gate-config.js'; // ── Resource Matching ───────────────────────────────────────────────────────── -export { matchResource, matchesAny, findTiersForResource } from './resource-matcher.js'; +export { matchResource, matchesAny, findTiersForResource, patternSpecificity, bestSpecificityFor } from './resource-matcher.js'; // ── Access Resolution ───────────────────────────────────────────────────────── -export { resolveAccess, type ResolveAccessOptions } from './access-resolver.js'; +export { resolveAccess, defaultTierOverriddenByGatedTier, type ResolveAccessOptions } from './access-resolver.js'; // ── Proof Validation ────────────────────────────────────────────────────────── export { diff --git a/packages/@eep-dev/gates/src/resolution-parity.test.ts b/packages/@eep-dev/gates/src/resolution-parity.test.ts new file mode 100644 index 0000000..7c29287 --- /dev/null +++ b/packages/@eep-dev/gates/src/resolution-parity.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { resolveAccess } from './access-resolver.js'; +import type { GateConfig } from './types.js'; + +interface ResolutionFixture { + name: string; + config: GateConfig; + resource: string | null; + expected_granted: boolean; + expected_tier: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const fixturePath = resolve(here, '../../../../tests/parity/gate-resolution-specificity-fixtures.json'); +const fixtures = JSON.parse(readFileSync(fixturePath, 'utf8')) as ResolutionFixture[]; + +// Cross-language parity for the default-tier wildcard specificity override. +// These cases use no proofs (deterministic; no semantic verifier needed), so +// the TS and Python resolvers must agree on granted/tier for each fixture. +describe('Gate resolution specificity parity fixtures (TS)', () => { + for (const fixture of fixtures) { + it(fixture.name, async () => { + const result = await resolveAccess([], fixture.config, fixture.resource ?? undefined); + expect(result.granted).toBe(fixture.expected_granted); + expect(result.tier).toBe(fixture.expected_tier); + }); + } +}); diff --git a/packages/@eep-dev/gates/src/resource-matcher.ts b/packages/@eep-dev/gates/src/resource-matcher.ts index 75df0b3..99865fc 100644 --- a/packages/@eep-dev/gates/src/resource-matcher.ts +++ b/packages/@eep-dev/gates/src/resource-matcher.ts @@ -41,6 +41,36 @@ export function matchesAny(patterns: string[], resource: string): boolean { return patterns.some(p => matchResource(p, resource)); } +/** + * Comparable specificity score for an access pattern (higher = more specific): + * + * "*" → 0 (universal wildcard, least specific) + * "a.b.*" → pattern.length (scope wildcard, longer prefix wins) + * "a.b.c" → pattern.length+1000 (exact literal, always beats any wildcard) + * + * The score is only meaningful for a pattern that already matches the resource; + * callers should guard with `matchResource` first (see `bestSpecificityFor`). + */ +export function patternSpecificity(pattern: string): number { + if (pattern === '*') return 0; + if (pattern.endsWith('.*')) return pattern.length; + return pattern.length + 1000; +} + +/** + * Specificity of the most specific pattern in `patterns` that matches + * `resource`, or -1 when none of the patterns match. + */ +export function bestSpecificityFor(patterns: string[], resource: string): number { + let best = -1; + for (const pattern of patterns) { + if (!matchResource(pattern, resource)) continue; + const score = patternSpecificity(pattern); + if (score > best) best = score; + } + return best; +} + /** * Find all tiers that grant access to a specific resource. * Returns tier keys sorted by specificity (exact matches first, then wildcards). @@ -54,11 +84,7 @@ export function findTiersForResource( for (const [key, tier] of Object.entries(tiers)) { for (const pattern of tier.access) { if (matchResource(pattern, resource)) { - // Higher specificity = more specific pattern - const specificity = pattern === '*' ? 0 : - pattern.endsWith('.*') ? pattern.length : - pattern.length + 1000; // Exact matches are most specific - matches.push({ key, specificity }); + matches.push({ key, specificity: patternSpecificity(pattern) }); break; // One match per tier is enough } } diff --git a/packages/@eep-dev/gates/src/specificity-override.test.ts b/packages/@eep-dev/gates/src/specificity-override.test.ts new file mode 100644 index 0000000..8ed4c18 --- /dev/null +++ b/packages/@eep-dev/gates/src/specificity-override.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { resolveAccess, defaultTierOverriddenByGatedTier } from './access-resolver.js'; +import { patternSpecificity, bestSpecificityFor } from './resource-matcher.js'; +import { ProofVerifierRegistry } from './proof-validator.js'; +import type { GateConfig, GateProof } from './types.js'; + +// ── Fixtures ──────────────────────────────────────────────────────────────── +// +// The bypass: a default (no-requirements) tier publishes a broad wildcard +// (`content.*`) that *covers* a resource which a gated tier targets with a +// strictly-more-specific pattern (`content.premium.*`). Before the fix the +// resolver granted access via the default tier's broad match even when the +// gated tier's requirements were unmet, silently bypassing the gate. + +const BYPASS_CONFIG: GateConfig = { + default_tier: 'public', + tiers: { + public: { + requirements: [], + access: ['content.*', 'profile.*'], + }, + paid: { + label: 'Premium', + requirements: [{ type: 'trust', min_score: 20 }], + access: ['content.premium.*'], + }, + }, +}; + +const EQUAL_SPECIFICITY_CONFIG: GateConfig = { + default_tier: 'public', + tiers: { + // Default lists the *same* pattern as the gated tier — public must not + // win the tie and bypass the gated tier's requirements. + public: { requirements: [], access: ['content.premium.*'] }, + paid: { + requirements: [{ type: 'trust', min_score: 20 }], + access: ['content.premium.*'], + }, + }, +}; + +function allowRegistry(types: string[] = ['trust', 'payment', 'identity', 'credential', 'connection']): ProofVerifierRegistry { + const registry = new ProofVerifierRegistry(); + for (const type of types) { + registry.register({ supportedTypes: [type], verify: async () => true }); + } + return registry; +} + +// ── resolveAccess: default-tier specificity override ────────────────────────── + +describe('resolveAccess — default-tier wildcard specificity override', () => { + it('denies a gated resource that the default tier covers via a broader wildcard (the bypass)', async () => { + const result = await resolveAccess([], BYPASS_CONFIG, 'content.premium.eep-whitepaper'); + expect(result.granted).toBe(false); + expect(result.tier).toBe('public'); + expect(result.unmet.some((u) => u.type === 'trust')).toBe(true); + }); + + it('still grants the gated resource once the gated tier requirements are met', async () => { + const proofs: GateProof[] = [{ type: 'trust', self_attested: true }]; + const result = await resolveAccess(proofs, BYPASS_CONFIG, 'content.premium.eep-whitepaper', allowRegistry()); + expect(result.granted).toBe(true); + expect(result.tier).toBe('paid'); + }); + + it('keeps non-premium content public (the default wildcard is authoritative there)', async () => { + const result = await resolveAccess([], BYPASS_CONFIG, 'content.blog.hello-world'); + expect(result.granted).toBe(true); + expect(result.tier).toBe('public'); + }); + + it('still grants resources the default tier covers and no gated tier touches', async () => { + const result = await resolveAccess([], BYPASS_CONFIG, 'profile.bio'); + expect(result.granted).toBe(true); + expect(result.tier).toBe('public'); + }); + + it('denies on an equal-specificity tie between the default and a gated tier', async () => { + const result = await resolveAccess([], EQUAL_SPECIFICITY_CONFIG, 'content.premium.x'); + expect(result.granted).toBe(false); + expect(result.tier).toBe('public'); + }); + + it('grants the equal-specificity resource once requirements are met', async () => { + const proofs: GateProof[] = [{ type: 'trust', self_attested: true }]; + const result = await resolveAccess(proofs, EQUAL_SPECIFICITY_CONFIG, 'content.premium.x', allowRegistry()); + expect(result.granted).toBe(true); + expect(result.tier).toBe('paid'); + }); + + it('does not change resource-less resolution (no override when no resource is given)', async () => { + const result = await resolveAccess([], BYPASS_CONFIG); + expect(result.granted).toBe(true); + expect(result.tier).toBe('public'); + }); +}); + +// ── patternSpecificity ──────────────────────────────────────────────────────── + +describe('patternSpecificity', () => { + it('ranks the universal wildcard as least specific', () => { + expect(patternSpecificity('*')).toBe(0); + }); + + it('ranks scope wildcards by length', () => { + expect(patternSpecificity('content.*')).toBe('content.*'.length); + expect(patternSpecificity('content.premium.*')).toBe('content.premium.*'.length); + expect(patternSpecificity('content.premium.*')).toBeGreaterThan(patternSpecificity('content.*')); + }); + + it('ranks exact patterns above any wildcard', () => { + expect(patternSpecificity('content.premium.x')).toBe('content.premium.x'.length + 1000); + expect(patternSpecificity('a')).toBeGreaterThan(patternSpecificity('verylongprefix.*')); + }); +}); + +// ── bestSpecificityFor ──────────────────────────────────────────────────────── + +describe('bestSpecificityFor', () => { + it('returns the specificity of the best matching pattern', () => { + expect(bestSpecificityFor(['content.*', 'profile.*'], 'content.premium.x')).toBe('content.*'.length); + }); + + it('prefers the more specific of several matching patterns', () => { + expect(bestSpecificityFor(['content.*', 'content.premium.*'], 'content.premium.x')).toBe('content.premium.*'.length); + }); + + it('keeps the higher score when a later matching pattern is less specific', () => { + // First match is most specific; a later, broader match must not lower it. + expect(bestSpecificityFor(['content.premium.*', 'content.*'], 'content.premium.x')).toBe('content.premium.*'.length); + }); + + it('returns -1 when no pattern matches', () => { + expect(bestSpecificityFor(['profile.*', 'video.*'], 'content.premium.x')).toBe(-1); + }); + + it('returns -1 for an empty pattern list', () => { + expect(bestSpecificityFor([], 'content.premium.x')).toBe(-1); + }); +}); + +// ── defaultTierOverriddenByGatedTier ────────────────────────────────────────── + +describe('defaultTierOverriddenByGatedTier', () => { + it('is true when a gated tier targets the resource more specifically than the default', () => { + expect(defaultTierOverriddenByGatedTier(BYPASS_CONFIG, 'content.premium.x')).toBe(true); + }); + + it('is true on an equal-specificity tie with a gated tier', () => { + expect(defaultTierOverriddenByGatedTier(EQUAL_SPECIFICITY_CONFIG, 'content.premium.x')).toBe(true); + }); + + it('is false when no gated tier covers the resource', () => { + expect(defaultTierOverriddenByGatedTier(BYPASS_CONFIG, 'content.blog.post')).toBe(false); + }); + + it('fails closed when the configured default tier is missing from tiers', () => { + const config = { + default_tier: 'ghost', + tiers: { + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['content.premium.*'] }, + }, + } as unknown as GateConfig; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(true); + }); + + it('fails closed when the default tier does not cover the resource at all', () => { + const config: GateConfig = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['profile.*'] }, + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['content.premium.*'] }, + }, + }; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(true); + }); + + it('ignores non-default tiers that have no requirements (free tiers do not gate)', () => { + const config: GateConfig = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.*'] }, + open: { requirements: [], access: ['content.premium.*'] }, + }, + }; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(false); + }); + + it('ignores non-default tiers whose requirements field is absent', () => { + const config = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.*'] }, + weird: { access: ['content.premium.*'] }, + }, + } as unknown as GateConfig; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(false); + }); + + it('ignores gated tiers that do not cover the resource', () => { + const config: GateConfig = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.*'] }, + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['video.*'] }, + }, + }; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.x')).toBe(false); + }); + + it('is false when the default tier is itself more specific than the gated tier', () => { + const config: GateConfig = { + default_tier: 'public', + tiers: { + public: { requirements: [], access: ['content.premium.docs.*'] }, + paid: { requirements: [{ type: 'trust', min_score: 20 }], access: ['content.*'] }, + }, + }; + expect(defaultTierOverriddenByGatedTier(config, 'content.premium.docs.readme')).toBe(false); + }); +}); diff --git a/packages/eep-gates-python/README.md b/packages/eep-gates-python/README.md index fdc734d..cd56428 100644 --- a/packages/eep-gates-python/README.md +++ b/packages/eep-gates-python/README.md @@ -50,6 +50,35 @@ if not result.granted: return JSONResponse(resp, status_code=402) ``` +### Tier specificity (default-tier wildcard override) + +When resolving a specific resource, a more-specific gated tier always wins over +a broader default-tier wildcard. With the common shape below, an agent with no +proofs is denied `content.premium.*` (a 402 carrying the gated tier's unmet +requirements) even though the default `content.*` pattern covers it, while +plain `content.*` resources stay public: + +```python +config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*", "profile.*"]}, + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["content.premium.*"]}, + }, +} + +await resolve_access([], config, "content.premium.x") # granted=False, tier="public", unmet=[trust] +await resolve_access([], config, "content.blog.post") # granted=True, tier="public" +``` + +The default tier's grant is suppressed only when a gated (requirements-bearing) +tier matches the resource with an **equal-or-more-specific** access pattern. A +strictly more-specific default pattern keeps its grant, and resources the +default tier does not cover at all fail closed. The helpers +`pattern_specificity`, `best_specificity_for`, and +`default_tier_overridden_by_gated_tier` are exported for callers that +re-implement the hot path. + ## Modules | Module | Description | diff --git a/packages/eep-gates-python/eep_gates/__init__.py b/packages/eep-gates-python/eep_gates/__init__.py index 60f1a04..6a39fc4 100644 --- a/packages/eep-gates-python/eep_gates/__init__.py +++ b/packages/eep-gates-python/eep_gates/__init__.py @@ -29,10 +29,13 @@ from .gate_config import parse_gate_config, serialize_gate_config, get_used_requirement_types, GateConfigError # ── Resource Matching ────────────────────────────────────────────────────────── -from .resource_matcher import match_resource, matches_any, find_tiers_for_resource +from .resource_matcher import ( + match_resource, matches_any, find_tiers_for_resource, + pattern_specificity, best_specificity_for, +) # ── Access Resolution ────────────────────────────────────────────────────────── -from .access_resolver import resolve_access +from .access_resolver import resolve_access, default_tier_overridden_by_gated_tier # ── Proof Validation ─────────────────────────────────────────────────────────── from .proof_validator import ( @@ -67,8 +70,9 @@ "parse_gate_config", "serialize_gate_config", "get_used_requirement_types", "GateConfigError", # Resource Matching "match_resource", "matches_any", "find_tiers_for_resource", + "pattern_specificity", "best_specificity_for", # Access Resolution - "resolve_access", + "resolve_access", "default_tier_overridden_by_gated_tier", # Proof Validation "validate_proof_structure", "validate_proofs", "delegation_permits_data_request", "ProofVerifier", "ProofVerifierRegistry", diff --git a/packages/eep-gates-python/eep_gates/access_resolver.py b/packages/eep-gates-python/eep_gates/access_resolver.py index e76713e..6784417 100644 --- a/packages/eep-gates-python/eep_gates/access_resolver.py +++ b/packages/eep-gates-python/eep_gates/access_resolver.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional from .models import AccessResult, UnmetRequirement -from .resource_matcher import matches_any +from .resource_matcher import matches_any, best_specificity_for from .proof_validator import validate_proof_structure, ProofVerifierRegistry @@ -64,6 +64,19 @@ async def resolve_access( if best_tier_obj: bt_access = best_tier_obj.access if hasattr(best_tier_obj, "access") else best_tier_obj.get("access", []) if matches_any(bt_access, resource): + # Specificity override. Reaching here with ``best_tier`` still + # equal to the default tier means no gated (requirements-bearing) + # tier was satisfied. The default tier must NOT grant the resource + # through a broad wildcard (e.g. "content.*") when a gated tier + # targets the same resource with an equal-or-more-specific pattern + # (e.g. "content.premium.*"); otherwise the broad public grant + # silently bypasses the gate. Deny and surface the gated tier's + # unmet requirements instead. + if best_tier == default_tier and default_tier_overridden_by_gated_tier(config, resource): + unmet_for_resource = await _get_unmet_for_resource( + config, resource, proofs_by_type, verifier_registry, strict_semantic_verification + ) + return AccessResult(granted=False, tier=best_tier, unmet=unmet_for_resource) return AccessResult(granted=True, tier=best_tier, unmet=[]) unmet_for_resource = await _get_unmet_for_resource( @@ -74,6 +87,62 @@ async def resolve_access( return AccessResult(granted=True, tier=best_tier, unmet=[]) +def default_tier_overridden_by_gated_tier(config: Any, resource: str) -> bool: + """Decide whether the default tier's grant of ``resource`` must be suppressed + because a gated, non-default tier targets the same resource with an + equal-or-more-specific access pattern. + + Gate configs are commonly shaped so the default tier publishes a broad scope + and a gated tier carves out a more specific path:: + + default "public": access = ["content.*", "profile.*"] + "paid": access = ["content.premium.*"], requirements = [...] + + Here ``content.*`` in the public tier covers ``content.premium.X``, so a + request with no proofs would otherwise leak through the default tier. The + owner's intent is "premium is more specific than public, so it gates" — this + returns ``True`` in that case so the resolver denies and emits a 402 instead. + + The default's grant is suppressed when a gated tier's best matching pattern + is at least as specific as the default tier's best match. A *strictly more + specific* default pattern keeps its grant (the owner explicitly opened that + narrower path), and a resource the default tier does not cover at all is + treated as authoritatively owned by the gated tier (fail closed). + """ + tiers = config.tiers if hasattr(config, "tiers") else config.get("tiers", {}) + default_tier = config.default_tier if hasattr(config, "default_tier") else config.get("default_tier", "") + + default_tier_obj = tiers.get(default_tier) + if not default_tier_obj: + return True + + default_access = default_tier_obj.access if hasattr(default_tier_obj, "access") else default_tier_obj.get("access", []) + default_best = best_specificity_for(default_access, resource) + if default_best < 0: + return True + + for tier_key, tier in tiers.items(): + if tier_key == default_tier: + continue + # A non-default tier with no requirements is just another free tier; it + # does not gate anything, so it cannot override the default's grant. + tier_reqs = tier.requirements if hasattr(tier, "requirements") else tier.get("requirements", []) + if not tier_reqs: + continue + + tier_access = tier.access if hasattr(tier, "access") else tier.get("access", []) + gated_best = best_specificity_for(tier_access, resource) + if gated_best < 0: + continue + + # At least as specific as the default's best match: the gated tier wins, + # so the default tier must not bypass its requirements. + if gated_best >= default_best: + return True + + return False + + async def _check_one_requirement( req: Dict[str, Any], proofs_by_type: Dict[str, List[Dict[str, Any]]], diff --git a/packages/eep-gates-python/eep_gates/resource_matcher.py b/packages/eep-gates-python/eep_gates/resource_matcher.py index 1e5ba5b..7614591 100644 --- a/packages/eep-gates-python/eep_gates/resource_matcher.py +++ b/packages/eep-gates-python/eep_gates/resource_matcher.py @@ -30,6 +30,36 @@ def matches_any(patterns: List[str], resource: str) -> bool: return any(match_resource(p, resource) for p in patterns) +def pattern_specificity(pattern: str) -> int: + """Comparable specificity score for an access pattern (higher = more specific). + + "*" -> 0 (universal wildcard, least specific) + "a.b.*" -> len(pattern) (scope wildcard, longer prefix wins) + "a.b.c" -> len(pattern) + 1000 (exact literal, always beats a wildcard) + + The score is only meaningful for a pattern that already matches the resource; + callers should guard with ``match_resource`` first (see ``best_specificity_for``). + """ + if pattern == "*": + return 0 + if pattern.endswith(".*"): + return len(pattern) + return len(pattern) + 1000 + + +def best_specificity_for(patterns: List[str], resource: str) -> int: + """Specificity of the most specific pattern in ``patterns`` that matches + ``resource``, or ``-1`` when none of the patterns match.""" + best = -1 + for pattern in patterns: + if not match_resource(pattern, resource): + continue + score = pattern_specificity(pattern) + if score > best: + best = score + return best + + def find_tiers_for_resource( tiers: Dict[str, Dict], resource: str, @@ -44,13 +74,7 @@ def find_tiers_for_resource( access = tier.get("access", []) if isinstance(tier, dict) else tier.access for pattern in access: if match_resource(pattern, resource): - if pattern == "*": - specificity = 0 - elif pattern.endswith(".*"): - specificity = len(pattern) - else: - specificity = len(pattern) + 1000 - matches.append((key, specificity)) + matches.append((key, pattern_specificity(pattern))) break matches.sort(key=lambda m: m[1], reverse=True) diff --git a/packages/eep-gates-python/tests/test_all.py b/packages/eep-gates-python/tests/test_all.py index 242e2b8..35ce625 100644 --- a/packages/eep-gates-python/tests/test_all.py +++ b/packages/eep-gates-python/tests/test_all.py @@ -5,12 +5,13 @@ from eep_gates import ( # Resource matcher match_resource, matches_any, find_tiers_for_resource, + pattern_specificity, best_specificity_for, # Gate config parse_gate_config, serialize_gate_config, GateConfigError, # Proof validator validate_proof_structure, validate_proofs, ProofVerifier, ProofVerifierRegistry, # Access resolver - resolve_access, + resolve_access, default_tier_overridden_by_gated_tier, # HTTP 402 build_402_response, is_gated_resource, # Commerce @@ -366,3 +367,197 @@ def test_rejects_out_of_range_score(self): "service_id": "svc_test", }) assert r.valid is False + + +# ── Default-tier wildcard specificity override ──────────────────────────────── +# +# The bypass: a default (no-requirements) tier publishes a broad wildcard +# (``content.*``) that *covers* a resource a gated tier targets with a +# strictly-more-specific pattern (``content.premium.*``). Before the fix the +# resolver granted access via the default tier's broad match even when the +# gated tier's requirements were unmet, silently bypassing the gate. + +BYPASS_CONFIG = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*", "profile.*"]}, + "paid": { + "label": "Premium", + "requirements": [{"type": "trust", "min_score": 20}], + "access": ["content.premium.*"], + }, + }, +} + +EQUAL_SPECIFICITY_CONFIG = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.premium.*"]}, + "paid": { + "requirements": [{"type": "trust", "min_score": 20}], + "access": ["content.premium.*"], + }, + }, +} + + +class _AllowVerifier(ProofVerifier): + @property + def supported_types(self): + return ["trust", "payment", "identity", "credential", "connection"] + + async def verify(self, proof, requirement): + return True + + +def _allow_registry() -> ProofVerifierRegistry: + registry = ProofVerifierRegistry() + registry.register(_AllowVerifier()) + return registry + + +class TestPatternSpecificity: + def test_universal_wildcard_is_least_specific(self): + assert pattern_specificity("*") == 0 + + def test_scope_wildcards_ranked_by_length(self): + assert pattern_specificity("content.*") == len("content.*") + assert pattern_specificity("content.premium.*") == len("content.premium.*") + assert pattern_specificity("content.premium.*") > pattern_specificity("content.*") + + def test_exact_patterns_beat_wildcards(self): + assert pattern_specificity("content.premium.x") == len("content.premium.x") + 1000 + assert pattern_specificity("a") > pattern_specificity("verylongprefix.*") + + +class TestBestSpecificityFor: + def test_returns_best_matching_specificity(self): + assert best_specificity_for(["content.*", "profile.*"], "content.premium.x") == len("content.*") + + def test_prefers_more_specific_match(self): + assert best_specificity_for(["content.*", "content.premium.*"], "content.premium.x") == len("content.premium.*") + + def test_keeps_higher_score_when_later_pattern_is_less_specific(self): + # First matching pattern is the most specific; a later, broader match + # must not lower the running best (exercises the score<=best branch). + assert best_specificity_for(["content.premium.*", "content.*"], "content.premium.x") == len("content.premium.*") + + def test_returns_negative_one_when_nothing_matches(self): + assert best_specificity_for(["profile.*", "video.*"], "content.premium.x") == -1 + + def test_returns_negative_one_for_empty_list(self): + assert best_specificity_for([], "content.premium.x") == -1 + + +class TestDefaultTierOverriddenByGatedTier: + def test_more_specific_gated_tier_overrides(self): + assert default_tier_overridden_by_gated_tier(BYPASS_CONFIG, "content.premium.x") is True + + def test_equal_specificity_tie_overrides(self): + assert default_tier_overridden_by_gated_tier(EQUAL_SPECIFICITY_CONFIG, "content.premium.x") is True + + def test_no_gated_tier_covers_resource(self): + assert default_tier_overridden_by_gated_tier(BYPASS_CONFIG, "content.blog.post") is False + + def test_missing_default_tier_fails_closed(self): + config = { + "default_tier": "ghost", + "tiers": { + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["content.premium.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is True + + def test_default_tier_not_covering_resource_fails_closed(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["profile.*"]}, + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["content.premium.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is True + + def test_free_non_default_tier_does_not_override(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*"]}, + "open": {"requirements": [], "access": ["content.premium.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is False + + def test_tier_without_requirements_field_does_not_override(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*"]}, + "weird": {"access": ["content.premium.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is False + + def test_gated_tier_not_covering_resource_is_ignored(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.*"]}, + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["video.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is False + + def test_more_specific_default_tier_keeps_grant(self): + config = { + "default_tier": "public", + "tiers": { + "public": {"requirements": [], "access": ["content.premium.docs.*"]}, + "paid": {"requirements": [{"type": "trust", "min_score": 20}], "access": ["content.*"]}, + }, + } + assert default_tier_overridden_by_gated_tier(config, "content.premium.docs.readme") is False + + def test_parsed_model_config_overrides(self): + config = parse_gate_config(BYPASS_CONFIG) + assert default_tier_overridden_by_gated_tier(config, "content.premium.x") is True + + +class TestSpecificityOverrideResolution: + @pytest.mark.asyncio + async def test_bypass_resource_is_denied(self): + result = await resolve_access([], BYPASS_CONFIG, "content.premium.eep-whitepaper") + assert result.granted is False + assert result.tier == "public" + assert any(u.type == "trust" for u in result.unmet) + + @pytest.mark.asyncio + async def test_bypass_resource_granted_when_requirements_met(self): + proofs = [{"type": "trust", "self_attested": True}] + result = await resolve_access(proofs, BYPASS_CONFIG, "content.premium.eep-whitepaper", verifier_registry=_allow_registry()) + assert result.granted is True + assert result.tier == "paid" + + @pytest.mark.asyncio + async def test_non_premium_content_stays_public(self): + result = await resolve_access([], BYPASS_CONFIG, "content.blog.hello-world") + assert result.granted is True + assert result.tier == "public" + + @pytest.mark.asyncio + async def test_default_only_resource_still_granted(self): + result = await resolve_access([], BYPASS_CONFIG, "profile.bio") + assert result.granted is True + assert result.tier == "public" + + @pytest.mark.asyncio + async def test_equal_specificity_tie_is_denied(self): + result = await resolve_access([], EQUAL_SPECIFICITY_CONFIG, "content.premium.x") + assert result.granted is False + assert result.tier == "public" + + @pytest.mark.asyncio + async def test_resource_less_resolution_unchanged(self): + result = await resolve_access([], BYPASS_CONFIG) + assert result.granted is True + assert result.tier == "public" diff --git a/packages/eep-gates-python/tests/test_resolution_parity.py b/packages/eep-gates-python/tests/test_resolution_parity.py new file mode 100644 index 0000000..94fd1c6 --- /dev/null +++ b/packages/eep-gates-python/tests/test_resolution_parity.py @@ -0,0 +1,43 @@ +# Copyright 2026 EEP Contributors — Apache-2.0 +"""Cross-language parity fixtures for the default-tier specificity override. + +These cases use no proofs (deterministic; no semantic verifier needed), so the +Python and TypeScript resolvers must agree on granted/tier for each fixture. +The shared fixture file is also consumed by the TypeScript suite +(``packages/@eep-dev/gates/src/resolution-parity.test.ts``). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from eep_gates import resolve_access + + +def _fixtures(): + fixture_path = ( + Path(__file__).resolve().parents[3] + / "tests" + / "parity" + / "gate-resolution-specificity-fixtures.json" + ) + return json.loads(fixture_path.read_text(encoding="utf-8")) + + +@pytest.mark.asyncio +async def test_gate_resolution_specificity_parity_fixtures(): + fixtures = _fixtures() + assert fixtures, "expected at least one resolution parity fixture" + for fixture in fixtures: + result = await resolve_access([], fixture["config"], fixture["resource"]) + assert result.granted == fixture["expected_granted"], ( + f"fixture={fixture['name']} granted expected={fixture['expected_granted']} " + f"got={result.granted}" + ) + assert result.tier == fixture["expected_tier"], ( + f"fixture={fixture['name']} tier expected={fixture['expected_tier']} " + f"got={result.tier}" + ) diff --git a/tests/parity/gate-resolution-specificity-fixtures.json b/tests/parity/gate-resolution-specificity-fixtures.json new file mode 100644 index 0000000..ad88d89 --- /dev/null +++ b/tests/parity/gate-resolution-specificity-fixtures.json @@ -0,0 +1,119 @@ +[ + { + "name": "bypass_premium_denied", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*", "profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "content.premium.eep-whitepaper", + "expected_granted": false, + "expected_tier": "public" + }, + { + "name": "non_premium_content_stays_public", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*", "profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "content.blog.hello-world", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "default_only_resource_granted", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*", "profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "profile.bio", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "equal_specificity_tie_denied", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.premium.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "content.premium.x", + "expected_granted": false, + "expected_tier": "public" + }, + { + "name": "more_specific_default_keeps_grant", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.premium.docs.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.*"] } + } + }, + "resource": "content.premium.docs.readme", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "default_not_covering_resource_fails_closed", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": "content.premium.x", + "expected_granted": false, + "expected_tier": "public" + }, + { + "name": "gated_tier_not_covering_resource_ignored", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["video.*"] } + } + }, + "resource": "content.premium.x", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "free_non_default_tier_does_not_gate", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*"] }, + "open": { "requirements": [], "access": ["content.premium.*"] } + } + }, + "resource": "content.premium.x", + "expected_granted": true, + "expected_tier": "public" + }, + { + "name": "resource_less_resolution_granted_default", + "config": { + "default_tier": "public", + "tiers": { + "public": { "requirements": [], "access": ["content.*", "profile.*"] }, + "paid": { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] } + } + }, + "resource": null, + "expected_granted": true, + "expected_tier": "public" + } +] From d4f06ca00a6f30e7e466db6fcb62ba38f8748aba Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Wed, 10 Jun 2026 23:52:11 +0300 Subject: [PATCH 2/2] docs(spec): phrase tier-precedence note as non-normative The tier-matching-precedence note added in the previous commit was labelled "Informative (non-normative)" but used RFC 2119 keywords (SHOULD / MUST NOT), which is contradictory in an informative note. Reword it as plain descriptive guidance (what the reference implementations do, with an encouragement for custom resolvers) so it carries no normative weight. Signed-off-by: Ugur Cekmez --- docs/current/SPECIFICATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index ddbc863..ab04fb7 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -227,7 +227,7 @@ Proof validation is two-step: The protocol defines structural validation. Implementing platforms provide a `ProofVerifier` for semantic checks. -**Informative (non-normative) — tier matching precedence:** When more than one tier's access patterns match a requested resource, implementations SHOULD resolve to the **most specific** matching tier rather than the most permissive one. In particular, a `default_tier` wildcard (for example `content.*`) MUST NOT silently grant access to a resource that a gated tier protects with an equal-or-more-specific pattern (for example `content.premium.*`): an agent that has not satisfied the gated tier's requirements is denied (402) instead of falling through to the default tier. Pattern specificity ranks an exact pattern (`a.b.c`) above any scope wildcard (`a.b.*`, longer prefix more specific) above the universal wildcard (`*`). Resources that only the default tier covers stay public. The reference `@eep-dev/gates` / `eep_gates` libraries implement this as `defaultTierOverriddenByGatedTier` / `default_tier_overridden_by_gated_tier`. +**Informative (non-normative) — tier matching precedence:** When more than one tier's access patterns match a requested resource, the reference implementations resolve to the **most specific** matching tier rather than the most permissive one. In particular, a `default_tier` wildcard (for example `content.*`) does not silently grant access to a resource that a gated tier protects with an equal-or-more-specific pattern (for example `content.premium.*`): an agent that has not satisfied the gated tier's requirements is denied (402) instead of falling through to the default tier. Pattern specificity ranks an exact pattern (`a.b.c`) above any scope wildcard (`a.b.*`, longer prefix more specific) above the universal wildcard (`*`). Resources that only the default tier covers stay public. The reference `@eep-dev/gates` / `eep_gates` libraries implement this as `defaultTierOverriddenByGatedTier` / `default_tier_overridden_by_gated_tier`; publishers that author their own resolvers are encouraged to follow the same precedence so a broad public tier cannot bypass a narrower gate. ---