Opt-in, and total once opted in
The whole layer activates on the presence ofcontracts/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.
Principal binding
APrincipal (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:
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:
Masking
A role’smasking 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:
- The caller’s role has
run_sql: false. - Tenancy is active, the caller isn’t
tenancy_exempt, and the target connection’srls_enforced(canonic.yaml,connections[]) isn’ttrue.
--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, andtenant_forbidden. - Marketplace guide for a full worked multi-tenant example project.