Skip to main content
An end-to-end canonic project on a multi-merchant marketplace: one shared SQLite warehouse hosting 5 independent merchants, ~24 months of order history with real growth/decline trends, 4 metric contracts, one enforced guardrail, and a full tenant-scoping / role-based-authorization (RBAC) setup. Unlike the other example projects, this one ships thousands of rows so there is real seasonality, trend, and promotion activity to query.

Schema

5 merchants span 4 countries and 5 categories, with different sizes and trajectories: Byte Gadgets (US, electronics) is the largest and visibly growing. Urban Threads (GB, apparel) is visibly declining. Artisan Coffee Co. (US, coffee), Green Leaf Botanicals (DE, home & garden), and Cozy Candles (CA, home decor) round out the platform at smaller, roughly flat volumes. Seed data: 3,092 orders and 6,891 order line items across 1,180 customers, spanning 2024-08-17 through 2026-08-17 (~24 months, kept current: see END_DATE in the generator), generated deterministically by scripts/generate_marketplace_data.py (seeded RNG: reruns produce byte-identical output). Comparing the first full three calendar months against the last three (skipping the partial month at each end of the window): Byte Gadgets grew from 126 orders (Sep–Nov 2024) to 180 (May–Jul 2026). Urban Threads fell from 100 to 63 over the same comparison. Both trends are visible in a monthly order_count query.

Setup

Quickstart

canonic.yaml declares an llm: block (matching every other example’s shape), but this project’s semantics are already fully hand-curated. There is nothing left to draft. Without --headless, canonic ingest --bootstrap still constructs a real LLM drafter and tries to reach it (here, a local Ollama endpoint) even though grain is deterministic from the declared primary keys, and fails if that endpoint isn’t running. --headless (or CI=true) forces the deterministic NullLLMDrafter, zero model calls, which is what this guide’s Quickstart uses and what CI itself runs.
canonic status, canonic ingest --bootstrap --headless, canonic query, and canonic mcp start never call the LLM. --tenant is a local-development / platform-operator override: it always warns, and without it entirely, every query fails closed:
This happens because contracts/policies/tenancy.yaml sets on_missing_principal: deny.

Metrics

contracts/metrics/ ships 4 metric contracts.

Guardrail

contracts/guardrails/ ships one enforced guardrail, orders-excludes-cancelled: orders.revenue must never be summed without a status != 'cancelled' filter. Cancelled orders were never paid. The guardrail injects the filter automatically, visible in metadata.guardrails_fired on every compiled query.

Tenants & roles

contracts/policies/tenancy.yaml scopes orders, order_items, and customers on merchant_id. merchants, dim_date, and dim_currency are shared. contracts/policies/roles.yaml defines three roles, and canonic.yaml carries 5 MCP tokens against two featured merchants (byte-gadgets, urban-threads) plus one platform-wide token: merchant_admin inherits: merchant_viewer (field-level override, not a list-merge): it re-opens dimensions.deny to [] and adds a masking rule instead.

Querying as merchant A vs merchant B

Both queries use the identical metric/dimension shape, only --tenant differs, and return disjoint, differently-sized results because the compiler injects a merchant_id predicate for the resolved tenant:

Masking in action

canonic query has no --role flag. Roles are only ever bound from a verified MCP token’s roles claim, never a CLI flag. The results below come from the same CanonicService.query() call the MCP transport makes, with a Principal carrying each token’s actual claims:
merchant_viewer’s dimensions.deny: [customer_email, customer_phone] did not block the plain-text email. See the warning at the bottom of this page. merchant_admin’s masking: [{ column: customers.customer_email, strategy: partial }] is what actually redacted it.

A denied metric

items_sold is absent from merchant_viewer/merchant_admin’s metrics.allow list. Only platform_analyst’s wildcard (allow: ["*"]) reaches it:
A denied metric fails with the exact same unresolved shape as a nonexistent one. There is no separate “forbidden metric” error, so a caller can’t distinguish “doesn’t exist” from “exists but you can’t see it.”

The policy hole: promotions

promotions carries merchant_id but is declared in neither scoped_sources nor shared_sources, deliberately, to demonstrate undeclared_source. This project ships with undeclared_source: warn, so canonic validate stays clean and a query that reaches promotions is still served, with a warning attached:
Flipping undeclared_source to deny fails both gates instead of one. A policy hole this severe is now caught at authoring time, not just at query time:

run_sql’s two gates

run_sql is refused for two independent reasons, both real here:
canonic.yaml sets rls_enforced: false on purpose: this shared SQLite warehouse has no warehouse-native row-level-security layer, so gate 2 stays shut for every non-exempt role. platform_analyst (tenancy_exempt: true) bypasses gate 2 entirely and its run_sql succeeds.

Files

dimensions.deny on a role is validated but not yet enforced by the compiler or discovery. Only masking actually protects a column today.