D124 — Provider grants inherit down the jurisdiction hierarchy, most-specific tier wins
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index.
Context
JurisdictionGate::permittedFeatureKeys() unions a jurisdiction's own feature grants with those of
every ACTIVE geographic ancestor and every ACTIVE bloc it (or an active ancestor) belongs to (D80/D81).
ProviderRouter::resolve() did not: it read jurisdiction_provider_grants for the tenant's own
resolved jurisdiction id only. Provider grants are declared on six literal codes (ZA, NG, KE, GB, EU,
US) and tenants.jurisdiction holds the signup COUNTRY code, so every EU member-country tenant (DE,
FR, IE…) and every subdivision tenant (US-NY) resolved no_grant for every capability group — a
403 on every RequiresProvider command. Confirmed read-only against the dev stack 2026-09-04 and
unchanged by D120 (docs/tracking/multi-provider/06-provider-eligibility-catalog.md §4). Two
hierarchy semantics coexisted silently; this ADR fixes the routing one and pins the rule.
Decision
- Candidate tiers. For a tenant jurisdiction J:
[J, active ancestor chain…]most-specific first, then ONE bloc tier (every active bloc of J or of an active ancestor) — the exact set the gate computes for features, extracted intoModules\Jurisdictions\Support\JurisdictionScopeand returned by a new publicJurisdictionGate::scopeFor().permittedFeatureKeys()is refactored onto the same value: ONE walk, one place. - Most-specific tier wins, no union across tiers. The grant set is that of the FIRST tier with
any row for the capability group. A country-level declaration fully SHADOWS the bloc's set, so a
narrower country carve-out is expressible. Rejected: unioning levels — it makes a carve-out
impossible and lets a demoted bloc key stay
permittedfor overrides. - Ambiguous bloc tier fails closed. If two or more distinct blocs carry rows for the group,
routing refuses with reason
ambiguous_bloc_grant. Rejected: an id/seed-order tie-break — a compliance route must never be decided by insertion order. The catalog puts this one line away, not in theory:JurisdictionSeeder::SUPRANATIONALplaces every EU member country in FOUR overlapping blocs —EU,EZ(Eurozone),EEAandSCHENGENall contain DE/FR/IE — so declaring a second of them for a capability group that already hasEUwould take every shared member to a 403 on every provider-routed command. OnlyEUis granted TODAY, and the control that keeps it that way is a pre-flight, not a convention:Modules\Features\Support\AmbiguousBlocGrantPreflightCheckrefusesfeatures:sync(one blocker per capability group, naming both blocs and the affected members) before a single grant row is written. It exists because the two directions disagreed on exactly this input: the DOWNWARD expansion (JurisdictionGate::inheritedCodesFor()) silently picks ONE declared code where the upward walk refuses the tie, so every other pre-flight passed green over a plan the router would refuse.JurisdictionGate::inheritanceFor()is the walk that reports both — the map AND the ambiguities (InheritedCodes) — and the check is the only consumer that readsProviderGrantPlan::$tuplesdirectly, because it asks whether the DECLARATION is self-consistent rather than how a tenant routes. - Overrides use the resolved set. A
TenantProviderOverrideis permitted iff its key is in the INHERITED grant set — one code path, no second rule;override_not_permittedotherwise. - Residency is asserted against the TENANT's jurisdiction, not the granting one: an EU-level
grant with
data_regionis validated against DE'sregion(the member-country subject D95 presumed). Blocs seedregion = null, so D120's "leavedata_regionnull" note stands for bloc-assigned tenants. - Inheritance is READ-TIME.
features:syncstill realizes rows on the six declared codes only. Rejected: materialising member/subdivision rows — hundreds of rows, a re-sync required before a new EU member can route, prune churn, and the loss of the declared-equals-realized property the D120 pre-flightProviderGrantPlandepends on. The plan instead carries aninheritedCodesmap (member code → declared code) so all eight pre-flight checks reason about effective routing whiletupleskeeps meaning "rows this run writes". - Fail-closed invariants unchanged. Blank →
blank_jurisdiction; the tenant's own inactive or unknown jurisdiction →inactive_jurisdiction; inheritance stops at the first inactive ancestor; inactive blocs contribute nothing; no grant on any tier →no_grant.
Consequences
-
The widened set, exactly. Routing reaches, in addition to the six declared codes: the 27 EU member countries the catalog models (AT, BE, BG, HR, CY, CZ, DK, EE, FI, FR, DE, GR, HU, IE, IT, LV, LT, LU, MT, NL, PL, PT, RO, SK, SI, ES, SE —
JurisdictionSeeder::SUPRANATIONAL['EU']), every ISO 3166-2 subdivision of each of those 27, and every subdivision of ZA, NG, KE, GB and US (US-NY and its ~56 siblings included). Nested subdivisions inherit through their parent. That is several thousand jurisdiction codes that answerbridgewhere they previously answeredno_grant— the ADR's whole point, and the reason the set is written out rather than described. Tenants on the six literal codes are unaffected;ProviderRouter::resolveForCountry()(pre-onboarding discovery) inherits identically, so a DE signup is no longer reported as an unsupported country. No existing test pinned the old exact-match behaviour. -
Per-country vendor licensing stays a DOCUMENTED go-live gate, not a code control. Nothing in this change asserts that the granted provider is licensed in each newly-reachable country — inheritance expresses "the EU declaration governs DE", not "Bridge may transact in DE". The provider's own country coverage is tracked in the provider landscape/eligibility docs (
docs/tracking/multi-provider/06-provider-eligibility-catalog.md); a country the vendor does not serve must be excluded by DECLARATION (a narrower set of declared codes) or by marking the jurisdictionInactive— there is no deny rule (see the next bullet). The human sign-off for the widened set is the merge of this ADR's PR; the per-facet controls that would make this mechanical (asset/chain/state/ product/end-customer axes) are the provider-offering catalog, §5 of the same tracking doc. -
Inheritance is GRANT-ONLY: there is no deny, so "US except NY" and "EEA but no USDT" remain inexpressible; absence at a subdivision no longer means refusal. The only kill-switch stays
Inactive. Deny/facet precedence is deferred to the provider-offering catalog (docs/tracking/multi-provider/06-provider-eligibility-catalog.md§5). -
Cost: 2 extra central reads for a country tenant, 3–4 for a subdivision; the grant lookup stays a single
whereInquery. No new cache — the bind-time seam already memoises per (tenant, group) (RoutedProviderKeyResolver), andCatalogCacheis the precedent if profiling ever demands one. -
The downward expansion (sync/pre-flight) and the upward walk (routing) must agree; a test asserts agreement over the real seeded catalog.
-
Observability. A refusal alone no longer says WHERE the router looked, so every
ProviderNotRoutableExceptioncarries an operator-onlycontext(jurisdiction_code,selected_jurisdiction_code,tier) alongside itsreason— off themetamap, exactly like the reason, so no routing topology reaches a 403 body. That context REACHES the durable trail the same way the reason does:Modules\Foundation\Exceptions\ProvidesDenialContextis the markerLaravelCommandBusreads to merge it into thecommand.provider_deniedaudit line (the fixedcapability_group/code/reasonkeys win a collision), so an operator triaging a refusal can tell a missing bloc declaration from a shadowing country carve-out from the log line alone — it no longer stops at the exception object.ProviderRouter::resolveForCountry(), whose contract is never-throws, likewise recordsrails.routing.country_refused{country, capability_group, reason}on theauditchannel before returning null, so a misconfigured catalog is no longer indistinguishable, in a discovery response, from an honestly unsupported country. Every SUCCESSFULresolve()writes anaudit-channelinfolinerails.routing.resolved{tenant uuid, capability_group, provider_key, jurisdiction_code, tier}(no PII beyond the tenant uuid).ambiguous_bloc_grantjoins the refusal reason vocabulary (blank_jurisdiction,inactive_jurisdiction,no_grant,override_not_permitted,no_primary,residency_conflict,missing_dpa,no_tenant_context). -
All eight pre-existing grant pre-flight checks now see inheriting tenants: three narrow their tenant sweep on
ProviderGrantPlan::jurisdictions()(payments in-flight payouts, onboarding open reviews, accounts active liquidation addresses) and four judge every tenant againstprimaryFor()(payments in-flight orders, funding awaiting-sender-info, custody-controls forwarded-on-other-provider, rails return-policy coverage) — all of which now resolve through the inherited map. The features tenant-override check was the one reading$plan->tuplesdirectly and is switched topermittedFor(); a check reasoning about a TENANT must never indextuplesagain. A NINTH check —Modules\Features\Support\AmbiguousBlocGrantPreflightCheck(Decision §3) — is the deliberate exception: it reasons about the DECLARATION rather than a tenant, sotuplesis exactly its input. -
The bind-time fail-safe now catches ~1,635 inheriting codes, not six (SE-F4).
RoutedProviderKeyResolver::route()swallows EVERYProviderNotRoutableExceptionat container bind time and falls back toconfig('rails.default_provider')—no_grant,override_not_permitted,no_primary, and equallyambiguous_bloc_grantandresidency_conflict. That seam enforces nothing by design; it picks which adapter a capability binds to, and the compliance gate is theRequiresProviderbus marker (app-modules/foundation/src/Bus/Contracts/RequiresProvider.php). The invariant that makes the fallback safe is stated there and is now LOAD-BEARING at a different scale: "the fail-safe-to-default is only safe while the default provider (bridgetoday) is UNIVERSALLY licensed." Before this ADR that sentence covered six declared jurisdiction codes; after it, the same sentence silently covers every inheriting code — the 27 EU members, every subdivision of those and of ZA/NG/KE/GB/US: ~1,635 codes in the seeded catalog. Blast radius, stated plainly: a routing refusal for ANY of those codes — including one caused by the catalog misconfiguration the new pre-flight exists to prevent — silently binds the DEFAULT adapter for any capability resolution that runs OFF the command bus (mirror reconcilers, queries, jobs), with no 403 and no refusal audit line. Nothing in this ADR changes that behaviour; committing a second vendor withbridgenot licensed somewhere is what turns it from fail-safe into a compliance breach, and the prerequisite recorded inRequiresProvider— every provider-sensitive command carries the marker, the off-bus paths are addressed — must be discharged first.
Related
- D120 — the grant flip whose routing text assumed exact-match.
- D80/D81 — the feature-grant inheritance this now mirrors.
- D123 — the preceding ADR in the decision log.