Skip to main content

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


← Decision log index