Skip to main content

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

  1. 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 into Modules\Jurisdictions\Support\JurisdictionScope and returned by a new public JurisdictionGate::scopeFor(). permittedFeatureKeys() is refactored onto the same value: ONE walk, one place.
  2. 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 permitted for overrides.
  3. 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::SUPRANATIONAL places every EU member country in FOUR overlapping blocs — EU, EZ (Eurozone), EEA and SCHENGEN all contain DE/FR/IE — so declaring a second of them for a capability group that already has EU would take every shared member to a 403 on every provider-routed command. Only EU is granted TODAY, and the control that keeps it that way is a pre-flight, not a convention: Modules\Features\Support\AmbiguousBlocGrantPreflightCheck refuses features: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 reads ProviderGrantPlan::$tuples directly, because it asks whether the DECLARATION is self-consistent rather than how a tenant routes.
  4. Overrides use the resolved set. A TenantProviderOverride is permitted iff its key is in the INHERITED grant set — one code path, no second rule; override_not_permitted otherwise.
  5. Residency is asserted against the TENANT's jurisdiction, not the granting one: an EU-level grant with data_region is validated against DE's region (the member-country subject D95 presumed). Blocs seed region = null, so D120's "leave data_region null" note stands for bloc-assigned tenants.
  6. Inheritance is READ-TIME. features:sync still 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-flight ProviderGrantPlan depends on. The plan instead carries an inheritedCodes map (member code → declared code) so all eight pre-flight checks reason about effective routing while tuples keeps meaning "rows this run writes".
  7. 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 answer bridge where they previously answered no_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 jurisdiction Inactive — 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 whereIn query. No new cache — the bind-time seam already memoises per (tenant, group) (RoutedProviderKeyResolver), and CatalogCache is 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 ProviderNotRoutableException carries an operator-only context (jurisdiction_code, selected_jurisdiction_code, tier) alongside its reason — off the meta map, 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\ProvidesDenialContext is the marker LaravelCommandBus reads to merge it into the command.provider_denied audit line (the fixed capability_group/code/reason keys 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 records rails.routing.country_refused {country, capability_group, reason} on the audit channel before returning null, so a misconfigured catalog is no longer indistinguishable, in a discovery response, from an honestly unsupported country. Every SUCCESSFUL resolve() writes an audit-channel info line rails.routing.resolved {tenant uuid, capability_group, provider_key, jurisdiction_code, tier} (no PII beyond the tenant uuid). ambiguous_bloc_grant joins 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 against primaryFor() (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->tuples directly and is switched to permittedFor(); a check reasoning about a TENANT must never index tuples again. A NINTH check — Modules\Features\Support\AmbiguousBlocGrantPreflightCheck (Decision §3) — is the deliberate exception: it reasons about the DECLARATION rather than a tenant, so tuples is exactly its input.

  • The bind-time fail-safe now catches ~1,635 inheriting codes, not six (SE-F4). RoutedProviderKeyResolver::route() swallows EVERY ProviderNotRoutableException at container bind time and falls back to config('rails.default_provider')no_grant, override_not_permitted, no_primary, and equally ambiguous_bloc_grant and residency_conflict. That seam enforces nothing by design; it picks which adapter a capability binds to, and the compliance gate is the RequiresProvider bus 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 (bridge today) 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 with bridge not licensed somewhere is what turns it from fail-safe into a compliance breach, and the prerequisite recorded in RequiresProvider — every provider-sensitive command carries the marker, the off-bus paths are addressed — must be discharged first.

  • 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.

← Engineering wiki index