Skip to main content

Age Policy Matrix (US)

Audit trail for the compliance_age_policies baseline seed data in apps/specifications/sql/bootstrap/compliance_age_policy_seed.sql. The table shape now lives in the SQLDelight schema baseline, while the curated legal matrix remains a post-reset bootstrap extra. This page records the legal sources consulted for each row, the effective date of the underlying statute, and the review cadence owners should observe.

  • Seed date: 2026-04-19
  • Scope: US states + DC. Non-US jurisdictions out of scope.
  • Categories covered: alcohol, tobacco, nicotine (vape/e-cig), lottery, cannabis, adult.
  • Recommended owner: TODO — Jack to assign. Suggested shape: "Compliance Lead" as primary, with an ops rotation (PM-on-call or a named compliance team member) as backup. The owner is responsible for the quarterly re-validation below and for approving per-merchant overrides before they are inserted into production.
  • Review cadence: Quarterly. The owner re-validates each row against the primary source on the first of each quarter (Jan/Apr/Jul/Oct). If a state changes its age rule or a new state legalizes recreational cannabis, land a live-data update path rather than silently editing already-applied rows in place.

How resolution works

CompliancePolicyService.resolve() (in both merchant-api and terminal-api) looks up the effective age policy for a product sale in this precedence order:

  1. Merchant override: match by (org_id = store.orgId, merchant_id = store.storeId, jurisdiction = store.state.uppercase(), compliance_category). Resolution source emitted as policy_merchant.
  2. State-jurisdiction baseline: match by (jurisdiction = store.state.uppercase(), compliance_category, org_id IS NULL, merchant_id IS NULL). Resolution source: policy.
  3. DEFAULT-jurisdiction baseline: match by (jurisdiction = 'DEFAULT', compliance_category, org_id IS NULL, merchant_id IS NULL). Resolution source: policy_default.
  4. Product flag fallback: over_21 → 21, over_18 → 18. Resolution source: product_flag.
  5. Request override: caller-supplied fallbackRequiredAge. Resolution source: request_fallback.

The bootstrap seed populates tiers 2 and 3. Tier 1 is populated by merchants at-will via the admin tooling (or by direct INSERT — see below). Uniqueness for tier 1 is enforced by idx_compliance_age_policy_merchant_scope and for tiers 2+3 by idx_compliance_age_policy_default_scope — both partial unique indexes scoped by merchant_id IS NOT NULL / IS NULL respectively.

Where notes fits

compliance_age_policies.notes is a free-text column that holds source attribution for each row. For seeded rows the notes mirror the SQL comments above each block in apps/specifications/sql/bootstrap/compliance_age_policy_seed.sql — the comments are human-readable; the column is machine-queryable by the audit tooling. For merchant overrides, notes should capture why the merchant installed the override (e.g. "merchant legal review 2026-04-19, file 12345"), and the reviewer should confirm the note is filled in before approving.

Jurisdiction format

compliance_age_policies.jurisdiction stores either:

  • 'DEFAULT' — federal/fallback baseline, applied when no state-specific row exists, or
  • A USPS two-letter state code, uppercase (e.g. 'CA', 'TX', 'DC').

This matches what CompliancePolicyService.normalizeJurisdiction() produces from the store's state column today.

Alcohol

JurisdictionMinimum ageSourceNotes
DEFAULT21National Minimum Drinking Age Act of 1984 (23 U.S.C. § 158)All 50 states + DC have complied since 1988. No state is stricter.

Tobacco

JurisdictionMinimum ageSourceNotes
DEFAULT21FDA Tobacco 21 (Pub.L. 116-94, Dec 20 2019, amending 21 U.S.C. § 387f)Federal floor. No state is stricter at the MLSA level.

Cross-check: CDC STATE System MLSA fact sheet.

Nicotine (vape / e-cig / ENDS)

JurisdictionMinimum ageSourceNotes
DEFAULT21FDA Tobacco 21Same statute as tobacco; explicitly covers ENDS.

Lottery

No federal law governs lottery age. Plurality rule is 18. Stricter state rows below.

JurisdictionMinimum ageSourceNotes
DEFAULT18State statutes (plurality)
AZ21A.R.S. § 5-515
IA21Iowa Code § 99G.31
LA21La. R.S. § 47:9025
MS21Miss. Code Ann. § 27-115-53
NE19Neb. Rev. Stat. § 9-810Only state with a 19 minimum.

Not seeded (match DEFAULT=18): all other states. Cross-check aggregator: LotteryBoost state-by-state guide.

Open question for reviewer: The NY lottery has a 21 minimum for Quick Draw when sold at alcohol-serving retailers; since we do not key by product SKU at resolve-time, we left NY at the default 18. Flag for follow-up if QuickDraw SKUs become material.

Cannabis

Cannabis remains Schedule I under the federal Controlled Substances Act, so there is no DEFAULT row. A missing cannabis row means "no state policy seeded" and the resolution service will fall through to the product flag / caller fallback — the intended behavior in prohibition states.

Seeded (all 21+, from the NORML adult-use jurisdictions list, cross-checked against MPP 2026 reform tracker):

AK, AZ, CA, CO, CT, DC, DE, HI, IL, MA, MD, ME, MI, MN, MO, MT, NJ, NM, NV, NY, OH, OR, RI, VA, VT, WA.

Caveats:

  • HI legalized adult-use in 2025 but retail framework is still being stood up; possession-age rules (21) are in effect now.
  • VA has had possession + home cultivation legal since 2021 but there is no legal adult-use retail pathway as of 2026-04-19.
  • DC is unusual: possession and "gifting" are legal at 21, but a commercial retail framework is blocked by the Harris Rider. We seed DC so that merchants in compliant retail arrangements (e.g. medical + I-71 gift-model) get a defensible 21 answer.
  • Medical-only states (e.g. FL, UT, AR, ND, SD, TX (limited), AL, GA, KY, LA (no adult-use), MS, PA, SC, TN, WV, WI …) are not seeded because age rules there are tied to medical-card issuance (often 18, sometimes 21 with parental consent at lower ages) and POS should not flatten those nuances into a single row. Model as per-merchant rules if a medical-dispensary merchant comes aboard.

Adult (18+ novelty)

JurisdictionMinimum ageSourceNotes
DEFAULT18Industry convention (no federal statute)Reviewer-flagged: not backed by a primary statute. Treat as best-effort and expect merchant overrides.

Per-merchant overrides

The current schema includes org_id, merchant_id, and notes columns on compliance_age_policies, plus partial unique indexes scoped on merchant_id IS NULL / IS NOT NULL. This allows the same table to hold both the curated DEFAULT/state legal baselines (seeded from the bootstrap file, with org_id and merchant_id NULL) and per-merchant overrides (with both populated).

How to add a per-merchant override

To install a stricter local rule for a single merchant — e.g. San Francisco's flavored-tobacco ordinance, a merchant's own "card everyone under 40" policy, or a Boulder cannabis product cap — insert a row with both org_id and merchant_id populated.

-- Example: merchant in California (org_id / merchant_id known) wants age 25
-- for tobacco due to a local ordinance. This beats the state-baseline row
-- (CA tobacco = 21) and the DEFAULT row (tobacco = 21).
INSERT INTO compliance_age_policies (
jurisdiction,
compliance_category,
minimum_age,
org_id,
merchant_id,
notes
) VALUES (
'CA',
'tobacco',
25,
'<org_uuid>', -- stores.org_id for the merchant's tenant
'<merchant_uuid>', -- stores.store_id for the merchant point-of-sale
'Override per San Francisco flavored-tobacco ordinance, approved by Compliance Lead on 2026-04-19.'
);

Requirements before approving an override:

  • notes must be filled in with a legally-defensible reason.
  • The merchant must have requested the override in writing (support ticket, email, or in-app submission captured in activity logs).
  • The owner (see top of this doc) signs off on the text of notes.

Scoping choice

We key merchant_id on stores.store_id (the "merchant point of sale") rather than organizations.org_id. This means multi-store merchants must install one override per store. Rationale: stores in the same org can be in different states (and therefore different state baselines), and physical stores are the granularity at which compliance officers think about local ordinances. An org-wide "all my stores" tier is intentionally not modelled today — if a merchant requests it, add a third partial unique index WHERE org_id IS NOT NULL AND merchant_id IS NULL and a fourth precedence tier in CompliancePolicyService.resolve. See V024 comments for the shape.

Category normalization

CompliancePolicyService.normalizeCategory() maps product type/category strings to the canonical category values used in this table. Notably:

  • vape, vapor, e-cigarette, e_cigarette, nicotine all map to category "nicotine". The DEFAULT row for "nicotine" (21, from federal T21) therefore governs sales of all non-tobacco nicotine products. When adding future categories, keep this synonym table in mind — e.g. a future hemp category would need a product-type key mapping.
  • alcohol, beer, wine, spirits all map to "alcohol".
  • tobacco, cigar, cigarette, smokeless_tobacco all map to "tobacco".
  • lottery maps to itself.

Adding a new category means updating both normalizeCategory (in both merchant-api and terminal-api) and shipping the corresponding live-data update path for DEFAULT/state rows.

Deferred categories

The following categories are intentionally deferred and are NOT seeded:

  • Firearms. Federal (18 for long guns, 21 for handguns, 18/21 for ammunition per 18 U.S.C. § 922) with state overlays. Not relevant to current convenience-store POS deployments but will be needed for any sporting-goods or firearms-specialist merchant onboarding. Follow-up scope: add firearms_long_gun and firearms_handgun as separate categories (the federal split in age is the load-bearing reason for two categories, not one) plus state overrides where stricter.
  • Medical-only cannabis. Medical programs in FL, AR, ND, UT, and the limited-legalization states (TX, AL, GA, KY, MS, PA, SC, TN, WV, WI) tie the age rule to medical-card issuance (often 18, sometimes 21, sometimes lower with parental consent). Flattening that into a single cannabis row would mis-answer in both directions. Model as per-merchant rules when a medical dispensary merchant onboards.

Tracking: file an issue titled "compliance: firearms + medical-cannabis categories" as a follow-up to #1084; link from this section when it exists.

Review log

DateReviewerNotes
2026-04-19seed author (Claude, for #1084)Initial seed.
2026-04-19follow-up (Claude, for #1084)Added per-merchant override support (V024), notes column, docs for overrides + deferred scope.