At a glance. One append-only fact row — metric_observation — is exactly one observed number with
its full dimensional context (metric · period · place · market segment · product category · value · unit) and
its provenance. You can compare "flower sales" across states and recover that Massachusetts called it
Buds; values keep their native units (conversion is a query-layer concern, never at write); and
every cross-state crosswalk carries a confidence label so the data never silently lies. The spec and
its JSON Schemas are licensed CC BY 4.0 — free to implement, adapt, and cite, even commercially, with
attribution (§0); the public government data they normalize is owned by no one. Stability and versioning are in §2; conformance in §8.
Example — one Florida weekly medical THC-dispensed observation:
{
"jurisdiction_code": "FL",
"period_start": "2026-06-06", "period_end": "2026-06-12", "cadence": "weekly",
"geo_level": "state", "is_aggregate": false, "operator_name_raw": "Trulieve",
"market_segment": "medical", "product_category": "all",
"metric": "thc_dispensed", "value": 184500000, "unit": "mg",
"suppressed": false, "is_latest": true, "restated": false
}
0. License
The specification — this document (SPEC.md) and the JSON Schema files served at
/static/spec/*.json — is licensed under Creative Commons Attribution 4.0 International
(CC BY 4.0). You may freely use, implement, adapt,
and redistribute it — including in commercial or competing products — as long as you give
appropriate credit to DevCannabis and link back to this spec.
That open license covers the standard only. The DevCannabis platform — its code, normalization pipeline, compiled datasets, editorial analysis, and brand — is source-available but all rights reserved (not open-source). In short: the method is open; the implementation and the dataset are not. The underlying government records are public facts owned by no one.
1. Purpose & positioning
Every U.S. cannabis regulator publishes market data in its own shape: Florida reports trailing weekly milligrams of THC dispensed per medical operator; Massachusetts publishes daily sales per product category; Colorado and Oregon publish monthly dollars by county; Illinois splits resident vs non-resident; Washington reports fiscal years. The measures, cadences, geographies, market channels, and even the word for "the licensed business" all disagree.
This spec defines one append-only, long/narrow fact record — metric_observation — where one row
is exactly one observed number with its full dimensional context and provenance. It normalizes the
shared concepts (a metric, a period, a place, a market segment, a product category, a value, a unit) into
a small canonical vocabulary, while preserving every jurisdiction's local terminology and verbatim
labels alongside the normalized values. A consumer can compare "flower sales" across states and
recover that Massachusetts called it Buds and Florida called it Marijuana in a Form for Smoking.
Two design commitments make this honest rather than merely tidy:
- Native units, no forced conversion at storage.
value/unitstore exactly what the source published. Conversions live in the query layer. - Crosswalks are per-jurisdiction and confidence-labeled. The system can say "MA vape vs OR vape is not comparable" instead of silently lying.
The underlying regulator records are public government data and are not owned by DevCannabis; this
specification covers only the normalization schema and vocabulary. See LICENSE.
Positioning relative to existing docs
This spec is the schema + normalization reference. It does not replace the deeper operational docs; it ties them together and points to them:
| Concern | Authoritative artifact |
|---|---|
| Adapter field rules (the spec for contributors) | docs/adapter-contract.md |
| Executable contract (the enforcer) | app/tests/test_state_adapters.py |
| Build-order tutorial for a new jurisdiction | app/docs/NEW_STATE_GUIDE.md |
| Gateway+adapter mental model, raw-vs-normalized, alias-vs-lineage | app/docs/ARCHITECTURE.md |
| Citation/CitationLink standard | docs/source-citation-standard.md |
| Confidence taxonomy + PDF integrity gate | INTEGRITY.md, app/DATA_NOTES.md |
Where this spec and a doc disagree on adapter field rules, docs/adapter-contract.md and the test
module win. This spec restates concepts for portability and does not redefine them.
2. Status, stability & versioning
- Status: v0.1 draft. The record shape, enums, and metric/category taxonomies below are
implemented and live in the codebase (Alembic revision
015), but the spec itself is not yet stable. - Coexistence note: the canonical forward model is
metric_observations. Florida's legacyweekly_reportstable (app/models/report.py) still coexists under a dual-write parity gate; retirement is gated on multiple clean cycles and has not occurred. A spec-conformant consumer readsmetric_observations. - Versioning policy: SemVer-style
MAJOR.MINOR.- Open vocabularies —
metric,product_category,unit— grow additively (MINOR) and are forward-compatible without a schema bump: the JSON Schema constrains them totype: stringplus a non-validatingx-known-valueslist, so a consumer validating today's schema against a future dataset that adds a metric/category/unit does not break. Tolerate unseen members of these three. - Closed enums —
cadence,geo_level,market_segment,buyer_residency— and the field set are bounded. Adding a member or a new optional field is still MINOR, but the schema does constrain these, so a consumer must adopt the newer schema version to validate the new data. Pin the$idschema version you build against. - Breaking changes (removing/renaming a field or value, changing nullability or the unique key) are MAJOR.
- Enum values are persisted as their lowercase string
.value(e.g.cadence="weekly",unit="usd"), never the Python member name. The string values are the contract.
- Open vocabularies —
- Minimum conformance revision: Alembic
015. An environment stuck at012has a narrower unique key (it lacks theproduct_category_rawterm — see §3.4) and is not conformant.
2.1 Single source of truth
The Python enums in app/models/metric_observation.py and the metric catalog
(METRIC_DEFINITION_SEEDS) are the source of truth. The published JSON Schemas and the §3.1 / §4 /
§5.1 / §6.4 vocabularies are kept in lockstep by app/tests/test_spec_taxonomy_drift.py, which fails CI
on any divergence — so this document and the schemas cannot silently drift from the running platform.
3. The normalized record: metric_observation
Table metric_observations. Append-only: published numbers are never UPDATEd in place;
corrections append a new row (see §3.3). Source: app/models/metric_observation.py.
3.1 Field table
value is fixed-precision Numeric(20, 4) (20 total digits, 4 decimal places) — model it as a fixed
decimal, never an integer or arbitrary-precision float. Enum columns persist the lowercase string value.
| Field | Type | Null | Req | Semantics |
|---|---|---|---|---|
id |
integer (PK) | no | system | Surrogate key, autoincrement. |
jurisdiction_code |
string(20) | no | yes | State/jurisdiction code (FL, MA, CO…). Plain string, not an FK. First column of the unique key. |
source_snapshot_id |
int FK → source_snapshots.id |
yes | opt | Provenance: immutable content-addressed capture of the raw payload this number was parsed from. |
citation_id |
int FK → citations.id |
yes | opt | Provenance: canonical citation supporting the public claim. |
period_start |
date | no | yes | Inclusive start of the coverage window. Part of unique key. |
period_end |
date | no | yes | Inclusive end. Part of unique key; secondary indexes key on period_end. |
cadence |
enum cadence |
no | yes | Reporting cadence. See §3.5. |
period_label |
string(40) | yes | opt | Human label for the period (e.g. fiscal-year tag). Not indexed. |
published_at |
date | yes | opt | Date the source published the number. Distinct from created_at. |
geo_level |
enum geo_level |
no | yes (default state) |
Geographic granularity. Part of unique key. |
geo_code |
string(20) | yes | opt | Geo code (documented as county FIPS). NULL for state-level. Coalesced to '' in the unique key. |
geo_name_raw |
string(120) | yes | opt | Verbatim geography name as published. Descriptive provenance only — not keyed. |
operator_id |
int FK → operators.id |
yes | opt | Resolved canonical operator. NULL when alias resolution failed OR the row is an aggregate. Not in the unique key. |
operator_name_raw |
string(200) | yes | opt | Verbatim operator name before resolution. Coalesced to '', this — not operator_id — is the operator dimension in the unique key. |
is_aggregate |
boolean | no | yes (default false) |
true = a state/market rollup rather than one operator's row. Part of unique key. |
market_segment |
enum market_segment |
no | yes (default combined) |
Market channel segmentation. Part of unique key. |
buyer_residency |
enum buyer_residency |
no | yes (default all) |
Resident/non-resident split (exists for IL). Part of unique key. |
product_category |
enum product_category |
no | yes (default all) |
Canonical cross-state category (§5). Part of unique key. |
product_category_raw |
string(120) | yes | opt | Verbatim source category label. Coalesced to '' in the unique key (added by migration 015). |
metric |
string(60) FK → metric_definitions.code |
no | yes | The metric code being observed (§4). Length inherited from metric_definitions.code = String(60). Part of unique key. |
value |
numeric(20,4) | yes* | opt | The observed number, native value as published. NULL only when suppressed=true (CHECK). |
unit |
enum unit |
no | yes | Native source unit of value. No default — must be supplied. |
suppressed |
boolean | no | yes (default false) |
true = source withheld this number (e.g. CO NR). Suppression is data, not a missing row. |
suppression_reason |
string(80) | yes | opt | Free-text reason for suppression. |
is_latest |
boolean | no | yes (default true) |
Current-vintage flag (§3.3). The unique index is partial on WHERE is_latest. |
restated |
boolean | no | yes (default false) |
true = this row has been superseded by a later observation. |
restates_observation_id |
int FK → metric_observations.id (self) |
yes | opt | On a revision row, points at the older row it supersedes. |
created_at |
datetime | no | system | DB insert timestamp (app default utcnow). Distinct from published_at and the period bounds. |
* value nullability is conditional: enforced by CHECK ck_observation_value_or_suppressed:
value IS NOT NULL OR suppressed.
Three distinct time concepts must never be conflated: period_start/period_end (coverage window),
published_at (source publication date), created_at (DB insert time).
Enumerated value sets. The closed enums (changed only by a versioned bump — see §2):
cadence—daily·weekly·monthly·quarterly·annualgeo_level—state·county·province·municipalitymarket_segment—medical·adult_use·combined·delivery·wholesale·unknownbuyer_residency—resident·non_resident·all
The open vocabularies (§2 — grow without a schema bump) are: metric (§4), product_category (§5.1),
and unit — usd · count · receipts · mg · g · oz · lb · usd_per_g · usd_per_lb.
MetricDefinition.value_kind — flow · stock · price · ratio — governs aggregation (§3.5).
3.2 The is_latest restatement rule (current vintage)
Current-vintage uniqueness is enforced by a partial unique index
uq_observation_dimensions_latest, WHERE is_latest (postgresql_where and sqlite_where), over the
full dimensional key (§3.4). Superseded history rows (is_latest=false) are exempt, so a key's full
revision history lives alongside its current vintage.
3.3 Restatement semantics (append-only)
A revision is never an in-place UPDATE. It is one new row plus two flag flips:
- Append the new observation with
restates_observation_id= the old row'sid(forward pointer). - On the old row, set
is_latest = false. - On the old row, set
restated = true.
3.4 The dimensional key & the coalesce invariant
The unique key, in order:
jurisdiction_code, metric, period_start, period_end, geo_level, coalesce(geo_code,''), coalesce(operator_name_raw,''), is_aggregate, market_segment, buyer_residency, product_category, coalesce(product_category_raw,'').
Two invariants are load-bearing and must not be "simplified":
- Verbatim keying. The key uses
operator_name_raw(+is_aggregate) — notoperator_id— because every alias-resolution failure carriesoperator_id = NULLand would otherwise collide. It likewise usesproduct_category_raw— not just canonicalproduct_category— so native sub-grain rows (MAConcentratevsConcentrate (Bulk)vsConcentrate (Each), all canonicallyconcentrate) coexist without forced aggregation. - The coalesce invariant. The three nullable key parts (
geo_code,operator_name_raw,product_category_raw) are coalesced to''inside the index so aNULLparticipates as a concrete value (PostgresNULLs are otherwise distinct and defeat uniqueness).
Schema-source drift gotcha: tests build the schema via SQLAlchemy
create_all(reads the ORM__table_args__); production uses Alembic migrations012+015. Theproduct_category_rawcoalesce term in the unique key exists only because migration015recreated the index. An environment stuck at012has a narrower unique key. The ORM and migration015are defined identically and are authoritative.
3.5 is_aggregate and aggregation safety
is_aggregate=true marks a state/market rollup with no single operator. Whether a value may be
summed is NOT stored on the observation — it lives on MetricDefinition.value_kind
(flow/stock/price/ratio), reached via the metric FK. Flows sum; stocks and prices never
do. Consumers MUST join to metric_definitions and check value_kind before aggregating.
3.6 Worked examples
A suppressed Colorado county row — the source withheld a small-county figure; suppression is data,
not a missing row (value is NULL only because suppressed is true):
{
"jurisdiction_code": "CO", "period_start": "2026-03-01", "period_end": "2026-03-31", "cadence": "monthly",
"geo_level": "county", "geo_code": "08097", "geo_name_raw": "Pitkin", "is_aggregate": false,
"market_segment": "medical", "product_category": "all", "metric": "sales",
"value": null, "unit": "usd", "suppressed": true, "suppression_reason": "confidentiality_nr",
"is_latest": true, "restated": false
}
A statewide aggregate rollup (is_aggregate: true, no operator dimension):
{
"jurisdiction_code": "CO", "period_start": "2026-03-01", "period_end": "2026-03-31", "cadence": "monthly",
"geo_level": "state", "is_aggregate": true, "market_segment": "adult_use", "product_category": "all",
"metric": "sales", "value": 99145201, "unit": "usd", "suppressed": false,
"is_latest": true, "restated": false
}
A restatement pair (§3.3) — the regulator revised a prior figure; the old row flips to
is_latest: false / restated: true, and the replacement points back via restates_observation_id:
[
{ "id": 5012, "metric": "sales", "value": 42000000, "unit": "usd", "is_latest": false, "restated": true },
{ "id": 6240, "metric": "sales", "value": 41880000, "unit": "usd", "is_latest": true, "restated": false, "restates_observation_id": 5012 }
]
A per-jurisdiction crosswalk row (product_category_map, §5.2) mapping a verbatim source label to a
canonical category with a confidence — lossy flags that the rollup loses detail:
{ "jurisdiction_code": "MA", "native_category": "Concentrate (Each)", "canonical_category": "concentrate", "confidence": "lossy" }
4. Metric taxonomy (canonical metric codes)
The canonical catalog is METRIC_DEFINITION_SEEDS in app/services/metric_observations.py (the live
source of truth, 14 codes). Migration 012 froze a 13-code subset; the only delta,
avg_price, lives only in the service list and reaches migrated databases at boot via
ensure_metric_definitions(). Each row is (code, label, default_unit, value_kind).
code |
Label | default_unit |
value_kind |
|---|---|---|---|
sales |
Retail sales (dollars) | usd |
flow |
units_sold |
Units/items sold | count |
flow |
transactions |
Transactions / receipts | receipts |
flow |
weight_dispensed |
Product weight dispensed | oz |
flow |
thc_dispensed |
THC dispensed (milligrams) | mg |
flow |
cbd_dispensed |
CBD dispensed (milligrams) | mg |
flow |
median_price |
Median retail price per gram | usd_per_g |
price |
avg_price |
Average retail price per gram | usd_per_g |
price |
wholesale_market_rate |
Average wholesale market rate per pound | usd_per_lb |
price |
excise_tax |
Excise tax collected (dollars) | usd |
flow |
harvest_weight |
Harvest weight | lb |
flow |
patient_count |
Registered patients | count |
stock |
physician_count |
Authorized physicians/providers | count |
stock |
location_count |
Licensed/operating locations | count |
stock |
value_kind is the aggregation contract, not decoration. ratio exists in the enum but no current
seed uses it (reserved). metric_observations.metric is an FK to metric_definitions.code; adding a
metric means adding a MetricDefinition row, not a free string.
5. Product-category taxonomy & the fail-loud rule
5.1 Canonical categories (product_category, 18 values)
all, flower, shake_trim, kief, pre_roll, infused_pre_roll, concentrate, vape, edible,
beverage, tincture, topical, capsule, suppository, seed_clone, biomass,
other, unknown.
unknown and other are distinct sinks: unknown = unmapped / needs-review (the fail-loud target);
other = a deliberately mapped misc bucket (e.g. MA Waste → other).
5.2 Per-jurisdiction crosswalk (product_category_map)
Native source category → canonical, keyed per jurisdiction. Each row carries a confidence
(CrosswalkConfidence: exact / approximate / lossy) so the UI can badge lossy comparisons rather
than present them as exact. Columns: jurisdiction_code, native_category (verbatim),
canonical_category, includes_notes, confidence, effective_from/effective_to, citation_id.
Unique on (jurisdiction_code, native_category, effective_from). Seeding via
ensure_product_category_map() is non-overwriting: a shipped mapping is only corrected via a new
effective window or explicit migration. Coverage today: FL and MA ship crosswalk seeds; CO and OR do
not populate the table (their statewide series use product_category=all).
5.3 The unmapped-category-fails-loud rule
An unmapped native string MUST be handled loud, never silent: store the observation as canonical
product_category=unknown, preserve the verbatim string in product_category_raw, AND emit a loud
alert (logger.error + stdout). Never drop, never guess. Fail-loud does NOT mean crash. The reference
implementation is the Massachusetts loader (app/services/ma_open_data.py), which collects unmapped
strings and logs the sorted set:
[ma_open_data] UNMAPPED categories (stored as 'unknown'): ….
6. The adapter contract
A jurisdiction adapter is a Python package under app/states/<jurisdiction>/ (config.py,
operators.py, regulatory.py, __init__.py). Its __init__.py assembles a single top-level
ADAPTER dict — the only object the rest of the platform reads (via states/registry.py).
Authority: the executable contract is app/tests/test_state_adapters.py (the REQUIRED_*_KEYS
sets); the prose spec is docs/adapter-contract.md. scripts/validate_state_data.py is a secondary,
looser CI check over operators/aliases/lineage/STATE_INFO only — not the source of truth.
6.1 Required top-level ADAPTER keys (exactly 12)
code, name, status, state_info, jurisdiction_profile, data_access_profile, program_model,
research_profile, capabilities, config, entities, regulatory. Extra keys are allowed; missing
any fails. adapter['code'] == state_info['code'] and adapter['name'] == state_info['name'] are also
asserted.
| Required sub-structure | Required inner keys |
|---|---|
state_info |
code, name, operator_term (contract-test minimum) |
config |
data_sources, active_years, refresh |
entities |
operators, aliases, lineage, junk_names |
jurisdiction_profile |
13 keys incl. country_code, jurisdiction_type, local_operator_term, normalized_operator_term |
data_access_profile |
9 keys incl. access, source_types, granularity, caveats |
program_model |
13 keys incl. model_version, module_gates, temporal_axes |
research_profile |
12 keys incl. profile_version, modules, source_collections, page_copy |
Profile dicts are serialized from frozen dataclasses (states/adapter.py), so every dataclass field
is a required key by construction. See app/tests/test_state_adapters.py for the exact REQUIRED_*_KEYS
sets — reference them, do not re-list.
6.2 Status lifecycle
status is exactly one of two values:
| Status | Meaning |
|---|---|
planned |
Template default and safe ship state. Metadata/profiles resolve; /<state> serves a cited Coming-Soon page; nav gating and scheduled ingestion stay OFF; is_active=False. |
active |
Publicly live; dashboard lit; is_active=True. Must have an ingestion path: either config['refresh'] is a WeeklyPdfRefreshProfile-shaped dict, OR code.upper() is registered via @register_ingestor. An active adapter with refresh=None and no registered ingestor fails the contract test. |
Keep planned until a maintainer validates and flips to active.
6.3 Normalization invariants
jurisdiction_profile.normalized_operator_termmust beoperator(hard-asserted by the contract test).state_info['canonical_entity']must also beoperator, but that one is enforced only by the looserscripts/validate_state_data.py, not the contract test. The local term (MMTC, Licensee, ATC, Dispensary) lives inoperator_term/local_operator_term/terminology['operator'].- Canonical operator records must use neutral field names; state-specific column names
(
mmtc_name,mmtc_count,licensee_name,atc_name) are banned. active_yearsmust be ascending-sorted and every listed year must have adata_sourcesentry.module_gatesANDresearch_profile['modules']must each include the five module keys:timeline,licensing_rounds,claim_ledger,jurisdiction_blueprint,source_workbench.- Alias vs lineage are different: an alias maps a raw-name variant to a canonical operator; lineage
records corporate continuity. Lineage records require
{predecessor, operator, change_type, transition_date}whereoperatoris the successor and exists inOPERATORS, andchange_type∈{acquisition, rebrand, acquisition_rebrand, merger, spinoff, license_transfer}. (The_templateLINEAGE comment is stale — follow the validator and Florida.)
6.4 capabilities
capabilities is a machine-readable feature map: top-level flags are booleans (or, where a feature
has sub-facets, a small dict). The contract test requires it to be a dict and capabilities['sales']
to be a dict; an adapter may omit any flag.
The allowed top-level flag names are a canonical catalog — CANONICAL_CAPABILITY_FLAGS in
states/adapter.py, enforced by tests/test_state_adapters.py. An adapter must not invent new
top-level flags ad hoc; adding a genuinely new capability means extending the catalog first, so the
vocabulary stays documented and consistent across jurisdictions. The catalog (v0.1):
| Group | Flags |
|---|---|
| Operators & locations | operator_metrics, locations |
| People | patient_counts, physician_counts, demographics |
| Sales & market | sales (nested channels: thc, cbd, flower, dollars, units), channels, county_sales, price_data, production_supply, tax_revenue |
| Sources & legal | source_documents, source_registry, open_data_portal, reports, legal |
| Program operations | application_pipeline, enforcement, lab_testing_data |
Nested sub-keys under sales are likewise catalogued (CANONICAL_SALES_FLAGS). Cataloguing the
sub-keys of the other nested dicts (reports, legal, price_data, channels) is deferred to a
later version (§11).
6.5 Registration
Three edits in states/__init__.py: the from . import … line; an AVAILABLE_STATES entry; a
STATE_CODE_TO_MODULE entry. The JURISDICTION_* names are read-only = aliases — do not edit them.
Registration only makes metadata resolvable; public liveness is governed by status. See
app/docs/NEW_STATE_GUIDE.md for the full build order.
7. Provenance & confidence model
A published number carries two provenance pointers, both nullable:
source_snapshot_id (what was fetched) and citation_id (the claim's canonical source).
7.1 Source snapshots (content-addressed capture)
SourceSnapshot is an immutable capture of one raw payload, content-addressed by content_sha256;
refetching identical bytes records nothing new (idempotent, deduped by
(source_key, collection_key, jurisdiction_code, content_sha256)).
content_format is the stored PAYLOAD enum — what the bytes are:
json / html / pdf / csv / text / binary / other (default other).
Two
content_formatnamespaces must not be confused.SourceSnapshot.content_formatis the payload enum above. A separate fetcher dispatch key (SourceRef.content_format:csv/json/socrata/xlsx/tableau_csv/html) describes how bytes are retrieved and is a free string, not the enum. They are bridged by_PAYLOAD_FORMAT, which maps the cross-namespace cases (socrata→json,tableau_csv→csv,xlsx→binary, unknown→other) and the identity cases (csv→csv,json→json,pdf→pdf,html→html). Writing a fetcher key into the snapshot column is an out-of-enum value Postgres rejects and SQLite silently accepts — a documented prod-only bug class.
7.2 Citations (the durable claim layer)
The canonical model is Citation + CitationLink (see docs/source-citation-standard.md). A Citation
is a reusable source record; a CitationLink ties one citation to one claim/target. Citation.confidence
is the machine enum CitationStatus: source_confirmed / parser_validated / needs_review /
inferred / rejected (default needs_review).
7.3 The four human-facing confidence labels
The public, prose confidence taxonomy (INTEGRITY.md, app/DATA_NOTES.md):
| Label | Meaning |
|---|---|
| Confirmed from source | Verified against a primary source URL/PDF and a checked date. |
| Parser-validated | Reproduced by repo scripts/queries, but still subject to source/report/extraction caveats. |
| Interpretation | Plain-English analysis/explanation built from confirmed/parser-validated facts. Kept separate from verified events. |
| Unverified / TODO | Open question, lead, placeholder, or anomaly needing verification. |
These four prose labels do NOT map 1:1 onto the 5-value
CitationStatusenum.Interpretationhas no enum equivalent;Unverified/TODOmaps loosely toneeds_review;inferredandrejectedhave no prose counterpart. Do not present them as a single canonical enum. (There are also distinct verification fields elsewhere —OfficialSource.verification_status,SourceChange.confidence— that must not be conflated with citation confidence. A canonical reconciliation is an open question, §11.)
7.4 The integrity gate (PDF reconciliation)
For sources with a summed-detail-plus-total shape (FL weekly reports), validate_report_rows
(app/services/report_integrity.py) reconciles the sum of parsed detail rows against the source total
row per metric (MetricCheck, within tolerance). A total_mismatch is severity error and blocks
import unless a pre-reviewed entry matches in data/reference/report_integrity_exceptions.json (exact
numeric equality on both detail and total values), which downgrades it to info. See INTEGRITY.md.
8. Conformance
8.1 A spec-conformant dataset
- Every record is a
metric_observationwith all required fields present and enum values drawn from this spec's value sets (lowercase strings). valueisNULLonly whensuppressed=true.metricreferences a cataloguedMetricDefinition; consumers honorvalue_kindbefore summing.- The dimensional uniqueness + coalesce invariant holds (at Alembic
015or later); restatements are append-only via theis_latest/restated/restates_observation_idmechanism, never in-place updates. - Verbatim labels (
operator_name_raw,geo_name_raw,product_category_raw) are preserved alongside their normalized columns; unmapped categories are stored asunknownwith the verbatim label kept and an alert emitted.
8.2 A spec-conformant adapter
- Exposes a top-level
ADAPTERdict with all 12 required keys and the required inner key sets, passingapp/tests/test_state_adapters.py. status∈ {planned,active}; anactiveadapter has a refresh profile or a registered ingestor.- Honors the normalization invariants in §6.3 (canonical
operatorentity, neutral field names, sortedactive_yearswith matching data sources, the five required module gates/modules). - Ships a confidence-labeled product-category crosswalk for any category-split source, and fails loud on unmapped categories.
9. How to cite this spec
DevCannabis Cannabis Regulatory Data Normalization Specification, Version 0.1 (draft). Canonical record:
metric_observation. Reference implementation: Alembic revision015.
Pair the spec version with the codebase revision when citing a dataset produced under it.
10. Pointers to deeper docs
docs/adapter-contract.md— the adapter field-rule spec (required vs optional keys, refresh fields, source-registry, market-event overlay, public API/route policy).app/docs/NEW_STATE_GUIDE.md— build-order tutorial for adding a jurisdiction.app/docs/ARCHITECTURE.md— gateway+adapter model, raw-vs-normalized, alias-vs-lineage, route compatibility policy.docs/source-citation-standard.md— Citation/CitationLink standard.INTEGRITY.md(repo root) andapp/DATA_NOTES.md— the four confidence labels and the PDF integrity gate.app/tests/test_state_adapters.py— the executableREQUIRED_*_KEYScontract.
11. Open questions for v0.2
These are known gaps the draft does not yet resolve; flagged honestly rather than papered over:
- Generalized refresh shape.
config.refresh's required keys are PDF/Florida-shaped (report_type == "weekly_reports", an{date}archive template). Modern adapters (MA/CO/OR/MI) use@register_ingestorwithrefresh=Noneinstead. A generalized, non-PDF refresh descriptor is the likely v0.2 direction. - Capability catalog — top level done; nested keys remaining. The top-level
capabilitiesflags and thesaleschannels are now a canonical, enforced catalog (§6.4,states/adapter.py). Still open: cataloguing the sub-keys of the other nested capability dicts (reports,legal,price_data,channels) so the whole feature map — not just its top level — is a closed vocabulary. - Confidence-taxonomy reconciliation. The four prose labels,
CitationStatus(5 values),OfficialSource.verification_status, andSourceChange.confidencecoexist and do not map 1:1 (§7.3). A canonical crosswalk is unresolved. data_sources[*].formatvocabulary. The contract test does not validate it; shipped adapters usecsv,json,socrata,xlsx,tableau_csv,tableau_twbx,pdf,html,google_sheets,html_index_pdf. The JSON Schema treatsformatas an unconstrained string by design.ratiovalue_kind is reserved in the enum but unused by any catalogued metric.