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:
- Merchant override: match by
(org_id = store.orgId, merchant_id = store.storeId, jurisdiction = store.state.uppercase(), compliance_category). Resolution source emitted aspolicy_merchant. - State-jurisdiction baseline: match by
(jurisdiction = store.state.uppercase(), compliance_category, org_id IS NULL, merchant_id IS NULL). Resolution source:policy. - DEFAULT-jurisdiction baseline: match by
(jurisdiction = 'DEFAULT', compliance_category, org_id IS NULL, merchant_id IS NULL). Resolution source:policy_default. - Product flag fallback:
over_21→ 21,over_18→ 18. Resolution source:product_flag. - 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
| Jurisdiction | Minimum age | Source | Notes |
|---|---|---|---|
| DEFAULT | 21 | National Minimum Drinking Age Act of 1984 (23 U.S.C. § 158) | All 50 states + DC have complied since 1988. No state is stricter. |
Tobacco
| Jurisdiction | Minimum age | Source | Notes |
|---|---|---|---|
| DEFAULT | 21 | FDA 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)
| Jurisdiction | Minimum age | Source | Notes |
|---|---|---|---|
| DEFAULT | 21 | FDA Tobacco 21 | Same statute as tobacco; explicitly covers ENDS. |
Lottery
No federal law governs lottery age. Plurality rule is 18. Stricter state rows below.
| Jurisdiction | Minimum age | Source | Notes |
|---|---|---|---|
| DEFAULT | 18 | State statutes (plurality) | |
| AZ | 21 | A.R.S. § 5-515 | |
| IA | 21 | Iowa Code § 99G.31 | |
| LA | 21 | La. R.S. § 47:9025 | |
| MS | 21 | Miss. Code Ann. § 27-115-53 | |
| NE | 19 | Neb. Rev. Stat. § 9-810 | Only 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)
| Jurisdiction | Minimum age | Source | Notes |
|---|---|---|---|
| DEFAULT | 18 | Industry 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:
notesmust 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,nicotineall 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 futurehempcategory would need a product-type key mapping.alcohol,beer,wine,spiritsall map to"alcohol".tobacco,cigar,cigarette,smokeless_tobaccoall map to"tobacco".lotterymaps 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_gunandfirearms_handgunas 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
cannabisrow 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
| Date | Reviewer | Notes |
|---|---|---|
| 2026-04-19 | seed author (Claude, for #1084) | Initial seed. |
| 2026-04-19 | follow-up (Claude, for #1084) | Added per-merchant override support (V024), notes column, docs for overrides + deferred scope. |