Skip to main content
canonic answers a question the compiler otherwise declines: given a resolved, compiled query, which rows and which metrics is this caller allowed to see? This governance layer exists for the case that motivates it: many merchants served from one warehouse and one daemon, each reaching only its own rows.

Opt-in, and total once opted in

The whole layer activates on the presence of contracts/policies/tenancy.yaml and/or contracts/policies/roles.yaml. Neither file exists in any of the shipped example projects today, and their absence is the current single-tenant behavior: nothing changes for an existing project until you add one. Once a tenancy.yaml is present, though, tenancy_for(source) is total over every source in the project: every source resolves to exactly one of scoped, shared, or Undeclared, a distinct sentinel, never silently “no restriction.” A source you forgot to classify is a policy hole, not an unfiltered pass-through. See undeclared_source below for what happens to it.
The tenant identity and role set are derived exclusively from the verified access token (or, for local dev, an explicit --tenant flag) and passed into the compiler as an explicit Principal argument, never read from the semantic query, a filter, or anything an agent or LLM can author. A merchant_id filter an agent supplies is a suggestion, but a Principal the daemon binds from a verified token is a constraint.

TenancyPolicy: contracts/policies/tenancy.yaml

shared_sources is a deliberate, reviewable act: declaring a table tenant-neutral belongs in a git diff with a reviewer on it, not an implicit default.

RolePolicy: contracts/policies/roles.yaml

Tenancy answers “which rows.” Roles answer “which metrics, dimensions, columns, and knowledge”, the RBAC half proper.

RolePolicy fields

RoleDef fields: one entry under roles

AllowDenyPolicy (shared by metrics and dimensions): allow: null (the default) means unrestricted, subject to deny. allow: [] is a deliberate allow-nothing declaration, distinct from “no allow list authored yet.” "*" in allow is the explicit wildcard used by exempt roles. deny always wins over allow. Inheritance is single-parent, field-level override, not list-merge. A role that authors dimensions: { deny: [] } replaces its parent’s deny list wholesale, but it does not subtract from it. This is why merchant_admin above re-opens every dimension merchant_viewer denies rather than needing to enumerate them: an empty deny on a field the role explicitly authors wins outright over the inherited list. A field a role leaves untouched inherits the parent’s value for that field unchanged. When a principal holds several roles, each field is the union of what every assigned role grants or denies, with deny winning in the final check regardless of how many roles are in play.
RoleDef.dimensions (allow/deny) is parsed, validated, and exposed via EffectivePolicy.dimension_allowed(), but nothing in the compiler, discovery, or MCP serving path actually calls that method. Declaring dimensions.deny on a role today has no runtime effect on what a query returns. The only mechanism that actually protects a column at query time is masking (below), which is narrower: it rewrites/nulls a column’s value, it does not prevent the column from being requested at all.
A role that declares no knowledge: block at all gets allow_tags: [] by default, meaning that role sees zero knowledge pages. This is the opposite of the “no role policy loaded” case, which is fully unrestricted. A role meant to see everything (an internal/platform role, say) must explicitly set knowledge: { allow_tags: ["*"] }. Simply omitting the block is a footgun, not a no-op.

Principal binding

A Principal (tenant + role set) is built from a verified token, never from anything caller-suppliable, and threaded into the compiler alongside as_of. MCP static tokens carry claims inline, since there’s no IdP to ask:
MCP OAuth carries claims in the verified JWT. claim_mapping renames a namespaced IdP claim key (e.g. https://example.com/merchant_id) to the policy’s own claim name before lookup: a claim absent from the mapping is looked up unchanged. Both are handled by canonic.mcp.auth.principal_from_token, which returns None (no principal to derive) only when neither tenancy.yaml nor roles.yaml is loaded at all. CLI --tenant binds a fixed principal with no roles, for local development and the platform-operator path. See --tenant CLI override below. SYSTEM_PRINCIPAL is the one caller-unreachable principal in the codebase: an unrestricted, tenancy_exempt principal used only by canonic’s own internal correctness checks, the assertion/CI harness (canonic assert, canonic eval) and static report validation (ReportService.validate_reports). Assertions check whether the compiler computes a metric correctly against a fixed expected value, a question orthogonal to tenant scoping, so they run against the full, unfiltered dataset, exactly the population an assertion’s expect was calibrated against. It’s never bound from a token, a CLI flag, or anything caller-controlled. The only project-configurable way to read cross-tenant is a role with tenancy_exempt: true.

Compiler enforcement

Tenancy and RBAC hook into the deterministic compiler pipeline at three points: Stage 0: bind the principal. Before any metric resolution, authz_for(principal) is resolved once into an EffectivePolicy. If tenancy is active, the principal isn’t tenancy_exempt, and no tenant resolved, the compiler raises TENANT_UNRESOLVED immediately: no SQL is emitted, and no error message can leak whether a named metric exists, because metric resolution hasn’t run yet. on_missing_principal: allow_unscoped avoids the error and appends a warning instead. A metric outside the effective policy is also folded into stage 1’s UNRESOLVED outcome, not a distinct forbidden code, the same “don’t let the error channel become an existence oracle” discipline used elsewhere in the compiler. Stage 2b: inject tenant predicates. For every source actually joined into a leaf’s plan (not just the metric’s own owning source), the compiler injects <source>.<column> = :tenant ahead of query filters, population_filter, and guardrails. This is what closes the leak a fact-table-only predicate would miss: filtering orders alone while joining an unfiltered customers still exposes another tenant’s customer rows through join-induced row multiplication. The predicate is built as a real sqlglot AST node (exp.EQ(...)) bound to the principal’s tenant value directly, never by parsing a string with the tenant value interpolated into it, so a crafted filters entry (x) OR (1=1-style) cannot escape its own predicate. A tenancy_exempt principal skips this stage entirely: no predicate, no Undeclared enforcement. An Undeclared source reached mid-leaf raises TENANT_SCOPE_MISSING (or warns, per undeclared_source: warn). metadata.scope (attached at result-metadata time): the sources that actually received a predicate, derived from what was actually emitted, not restated from the policy, so an unjoined scoped source correctly doesn’t show up:
This is what makes isolation provable to a reviewer, rather than merely asserted.

Masking

A role’s masking list rewrites a dimension’s SELECT/GROUP-BY expression at compile time, matched by canonical source.column (never a join alias, the same convention scoped_sources uses), so one rule applies to every join alias that resolves to that source, self-joins included: Masking applies after granularity bucketing (for time dimensions), so the SELECT list and the GROUP BY collapse on the same masked expression: merging distinct underlying values into one output row is the intended effect, not a side effect to guard against.

Discovery filtering

list_metrics and get_overview silently omit any metric outside the caller’s effective policy: an entity the caller cannot query is an entity the caller cannot see listed. describe_metric and resolve_metric on a denied metric name raise the identical Unresolved shape a genuinely nonexistent name produces, so there is no existence oracle here either: a caller can’t distinguish “this metric doesn’t exist” from “you’re not allowed to see this metric” by probing the API. Knowledge search and page reads are filtered by the caller’s allow_tags, per the footgun noted above.

The run_sql gate

run_sql bypasses the compiler entirely, no stage-2b predicate injection is possible on caller-supplied SQL, so it is gated fail-closed by two independent conditions, either of which raises TENANT_FORBIDDEN:
  1. The caller’s role has run_sql: false.
  2. Tenancy is active, the caller isn’t tenancy_exempt, and the target connection’s rls_enforced (canonic.yaml, connections[]) isn’t true.
rls_enforced: true is an operator attestation that the warehouse itself enforces the tenant boundary out of band: per-tenant credentials, or warehouse-native row-level security keyed on a session variable. canonic cannot verify this claim. It only trusts it. Setting it to true on a connection that doesn’t actually enforce RLS re-opens exactly the hole run_sql is otherwise gated against.

--tenant CLI override

canonic query, canonic sql, canonic report run, and canonic mcp start --transport stdio all accept a shared --tenant <id> flag: it binds a fixed, roleless principal for local development and the platform-operator path, and always prints a warning when used. It exists because stdio sessions have no per-request auth to derive a principal from. canonic mcp start --transport http refuses --tenant outright, exiting 1: an HTTP daemon already derives a fresh principal from each request’s verified token, so a single flag-supplied tenant applying to every caller regardless of who they are would be a much larger hole than the one --tenant exists to work around on stdio.

Instrumentation

AnswerEvent (the record written for every served answer and run_sql call, in .canonic/events.jsonl) carries three additional fields: See Instrumentation & evaluation for the rest of the event shape.

Further reading

  • Contract schema for the exhaustive field reference and validation rules on tenancy.yaml / roles.yaml.
  • Error codes for worked examples of tenant_unresolved, tenant_scope_missing, and tenant_forbidden.
  • Marketplace guide for a full worked multi-tenant example project.