introspect_schema, run_read_only_sql, extract_definitions, extract_evidence), never on vendor name, so adding a new source is a registration, not a core-code change.
The connector factory
canonic.yaml stores a connection as a descriptor: id + type + params (+ optional credentials_ref), not a live instance. At startup, the ConnectorFactory looks up type in its registry and builds the connector:
type raises UnknownConnectorType (exit 13) listing what is registered, never a silent fallback. Manage connections with canonic connection.
The credential provider registry
Credentials resolve through a second registry with the same shape. Acredentials_ref of env:, keyring: or file: resolves once to a fixed string. A provider:<name> ref instead selects a registered credential provider, which fetches a short-lived credential from a cloud issuer and reports its expiry:
UnknownCredentialProvider listing what is registered, mirroring UnknownConnectorType.
The consequence for a connector is when it resolves its credential. A static ref can be baked into a connection string at startup. A provider-backed one cannot: a 15 minute Redshift IAM credential is dead long before a daemon is, so the connector resolves on every connect instead. redshift is the first connector that does this, and any future provider-backed connector inherits the same rule. See dynamic credentials for the configuration surface.
Three classes of connector
Queryable (primary)
Implementintrospect_schema + run_read_only_sql: these feed the semantic layer and are executed against directly by canonic query / canonic sql.
Snowflake stores unquoted identifiers in upper case, and canonic quotes every identifier it emits. Relation and column names in a semantic source must therefore match the stored case, for example
PUBLIC.ORDERS. Introspection returns names exactly as Snowflake stores them, so sources generated from it work as is. Snowflake has no read-only session flag, so point read_only_role at a role that only holds SELECT grants.Definition
Implementextract_definitions: feed the semantic layer and canonical-binding candidates from modeling code, never the query path (no run_read_only_sql).
A definition connector is its own connection entry (own
id) but describes tables that live in a different, queryable connection. params.target_connection names that connection’s id, so the evidence is attributed to it rather than to the definition connector’s own id: this is what lets its RelationSchema proposals land on the same semantics/<connection>/<name>.yaml target as that connection’s live introspection and reconcile against it (see the worked example), instead of colliding on the project-wide unique source name. Omitting it falls back to the definition connector’s own id, fine for a definitions-only project with no paired physical connection, but such sources can never actually be queried (no run_read_only_sql). An ossie connection has no such fallback. Ossie has no notion of a connection, so target_connection is required and must name a queryable connection.
Ossie mapping
Ossie is read as an interchange format, never as canonic’s own format. Parsing is deterministic, no LLM is involved, and every item enters reconciliation asinferred at the modeling tier. The connector emits no RelationSchema: Ossie types are optional, so column types always come from the live schema of target_connection.
The expression variant written in the target’s own dialect is used first, then
OSSIE_SQL_2026, then ANSI_SQL, then any other SQL dialect sqlglot can read, transpiled to the target. Expressions only in MDX, TABLEAU, MAQL, DAX, SIGMA or THOUGHTSPOT, and expressions that do not parse, are unmappable. Every unmappable object is named in a warning by canonic connection test and canonic ingest, never dropped silently.
Binding candidates are proposals only, an import never makes a definition canonical. The ingest draft uses the Ossie measures, dimensions, joins and descriptions. A binding candidate becomes a proposed
contracts/metrics/<metric>.yaml at inferred, which never auto-applies. A ratio also proposes bindings for its two components, named after their measures, and metric synonyms become binding aliases. A candidate is never proposed over an existing binding, and only when every measure, column and component binding it references will exist after the run. Doc evidence is extracted but not yet drafted into knowledge/. A definition that cannot be placed, such as a measure with unknown additivity or a dimension on a column the live table lacks, is listed under skipped in the ingest output.Evidence
Implementextract_evidence: feed knowledge pages and reconciliation signal from docs and BI usage. Also never queryable.
A BI question’s SQL is only ever evidence, never executed. If a Metabase/Looker-encoded metric is adopted, it’s recompiled through the deterministic compiler like any other definition, never run as-is.
extract_evidence capability as notion and url today, and can be added as a new registered type without touching core logic.
Normalized evidence, one shape per kind
Every connector translates its native output into one of a few normalized shapes, so the ingestion engine and compiler never see vendor-specific structures:RelationSchema, a table/view: columns (normalized types), primary key, foreign keys, row-count estimate. From queryable connectors’introspect_schema.DefinitionEvidence, a named measure/dimension/join from modeling code, with its expression and additivity. Fromextract_definitions.DocEvidence, a title + body + candidate topic references, with ausage_hintthat maps to a knowledge page’susage_mode. Fromextract_evidencedoc sources.UsageEvidence, a BI artifact (question/dashboard), the metric it appears to define, and how often it’s used, always a candidate, never auto-promoted to canonical.
Read-only enforcement
For queryable connectors, read-only is defense in depth, not a convention: a read-only role/credential where the engine supports it, a parse-level check onrun_read_only_sql that rejects anything but a single SELECT/WITH…SELECT, and a hard row cap plus statement timeout on every execution. Any layer failing aborts with READ_ONLY_VIOLATION before the query runs.
How far the engine itself enforces read-only differs per connector, and canonic connection test reports it:
A connector without engine-side enforcement is still guarded by the parse check. The warning only says that nothing sits behind it.
Schema acquisition ladder
When live introspection is unavailable or partial, canonic descends a priority order. Every tier still emits the sameRelationSchema, tagged with which tier produced it:
- Live introspection: catalog views.
- Modeling code as schema: via a
dbt/definition connector. - Query-history inference (not yet implemented).
- Declarative import: user supplies DDL / a schema export.
- Sample-based inference (not yet implemented).
- Hand-authored
semantics/*.yaml: validated against the live source before being trusted (below).
dbt import still enters reconciliation at provenance inferred, exactly like raw introspection, so it never displaces a human_curated or board_approved fact. Its acquisition tier only breaks ties within the inferred band: when a modeling-code definition and a raw-introspection fact describe the same relation without disagreeing on type, the modeling-code evidence is preferred. Structural fields (grain, joins, measures) still always require review regardless of confidence. See a worked example. A fact only reaches human_curated through an explicit canonic review curate, never automatically from a higher acquisition tier.
Partial capability is never silent: if only some relations are introspectable, the gap is reported rather than omitted.
Schema validation probe
Whenever a schema is acquired declaratively or hand-authored (tiers 4–6), canonic issues a zero-data, read-only probe (SELECT <declared columns> FROM <relation> WHERE false) against the live source before trusting the evidence. A mismatch returns SCHEMA_MISMATCH with a diff of missing/extra columns and type conflicts, never a silent accept.