Full source:
examples/marketplace/Schema
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 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:
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:
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:
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.