D80 — Jurisdictions become the full ISO hierarchy (supranational → country → subdivision) + gate grant-inheritance (RESOLVED, extends D34)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
The thin working jurisdiction list (ZA/NG/KE/GB/US/EU — the module README's deferred "full jurisdiction
catalog" note) is realized as the complete ISO catalog, hierarchical and keyed by ISO notation: every ISO
3166-1 country (249, keyed by alpha-2) and every ISO 3166-2 subdivision (5 046, keyed by the full US-NY
code), under the one modeled supranational bloc — the European Union (its 27 members as children). So a
tenant's tenants.jurisdiction can now name any actual jurisdiction we operate in (US-NY, IN-GJ, FR),
the "and so on" of EU → France → Île-de-France, USA → New York, India → Gujarat.
Schema (additive, central). jurisdictions gains parent_id (self-FK — a subdivision's country, a
member country's bloc; NULL for a root), type (Enums\JurisdictionType: supranational/country/subdivision,
defaulted to country so features:sync's stub lands sanely), and the extra ISO notations iso_alpha3 /
iso_numeric (country-only) + subdivision_category (the ISO 3166-2 category word). code stays the
canonical unique key, so an existing tenants.jurisdiction = 'ZA' resolves unchanged. parent_id points at
jurisdictions, not tenants, so it creates no RLS path — the module stays central reference data
(tenants:verify-isolation still excludes it).
Data provenance. Countries + subdivisions are a committed snapshot of the Debian iso-codes project
(app-modules/jurisdictions/database/data/iso-3166-*.json) — the same dataset the ISO PHP packages vendor,
committed rather than pulled as a heavy runtime dependency (regeneration recipe in the module README;
JurisdictionCatalogDataTest guards its integrity). Bloc membership is curated in the seeder (not part of
ISO). JurisdictionSeeder is idempotent + bulk (chunked insert() of missing codes + drift reconcile; NOT
upsert(), which cannot target the partial unique index … WHERE deleted_at IS NULL) and never resets an
operator-managed status.
Gate change — grants inherit DOWN the tree. Support\JurisdictionGate::permittedFeatureKeys() now returns
the union of a jurisdiction's own allow-list and that of every active ancestor (leaf → country → bloc),
walked with Eloquent only (no DB facade — within the sanctioned Support carve-out) and memoised by the
Features CatalogCache exactly as before. So a regulatory rule expressed once at bloc/country level
(permitIn(['EU'], …)) reaches every descendant without enumerating them. Still fail-closed: a
null/blank/unknown/inactive jurisdiction permits nothing, and inheritance stops at the first inactive
ancestor (a suspended country's licence is not bypassed via its bloc) — compliance still beats commerce
(D34), and the change is backward-compatible (a root with no parent yields exactly its own grants, as before).
Proven by JurisdictionGateTest (own + inherited union, deep bloc→country→subdivision chain, inactive-ancestor
cut-off, fail-closed), JurisdictionSeederTest (full-catalog counts + ISO fields + hierarchy wiring +
idempotency + inheritance on the seeded tree), and the unit data/enum guards. Multi-bloc membership (a single
parent_id can't express EU and Schengen) and multi-jurisdiction tenants remain the tracked follow-ups
(docs/tracking/feature-entitlements-rollout.md item 4).