Documentation
One command, what it can do, and access for your agent. Ask the MCP agent for the details, or read them here.
CommandsLocal or on-prem exitUsing it with an agent (MCP)The scanOn-prem arrivalHosts
Canot: the commands
Canot rehearses your exit from a data platform, inside your own accounts. One command is enough:
uvx canot drill SOURCE DESTINATION
SOURCE is the platform you would leave (Snowflake, Databricks, BigQuery), DESTINATION is where the copy is written. Replace the MY_… values with your own. Canot copies the tables to Apache Iceberg, compares everything, replays your most expensive queries and writes a signed evidence log. It sends nothing to us: no telemetry.
# Snowflake to a local folder
uvx canot drill "snowflake://MY_ACCOUNT/MY_DATABASE?user=MY_USER" ./iceberg
# Databricks to the S3 storage of your choice
uvx canot drill "databricks://MY_WORKSPACE.cloud.databricks.com/sql/1.0/warehouses/MY_WAREHOUSE_ID?catalog=MY_CATALOG" "s3://my-bucket/canot?endpoint=https://s3.internal:9000®ion=us-east-1"
# BigQuery to a European hosting provider
uvx canot drill "bigquery://MY_PROJECT/MY_DATASET?location=EU" scaleway:fr-par --bucket my-bucket
The Snowflake, BigQuery and Databricks connectors ship with Canot: nothing to add. Without uv, pipx run canot … or pip install canot work the same way.
The commands
| Command | What it does |
|---|---|
canot scan SOURCE | Read-only assessment: inventory, volumes, consumption, what does not move as-is, query replayability. Writes scan.html (to share), scan.json and CSV files. See scan. |
canot drill SOURCE DESTINATION | Full exit exercise: copy, table-by-table comparison, query replay, signed evidence log. |
canot demo | A 30-second demo, no account or data needed: the TPC-H dataset (the standard database benchmark, generated locally, 100 MB by default; --sf 1 for about 1 GB) and a full exercise. It prints the two commands it runs (demo-data, drill) and accepts a destination: canot demo aws:eu-west-3 --bucket MY-BUCKET. Offline, it falls back to a fictional dataset. |
canot verify-evidence FOLDER | Verifies the hash chain and signature of an evidence log. --expect-pub rejects a log signed by a different key. |
canot teardown DESTINATION | Deletes the copied data. |
canot snowflake-setup | Writes the read-only access SQL script for an administrator to review and run. --generate-key creates the key pair. |
canot bigquery-setup | Writes the gcloud script for the read-only custom role. |
canot init-tolerances | Writes a comparison tolerances file to review together. |
canot demo-data PATH | Creates a demo DuckDB database. |
canot replay-dbt dbt://PROJECT --base COPY.duckdb | P3 evidence on a dbt project: replays the SQL models in DuckDB from the copy of the sources (a DuckDB database, or --iceberg for the copy written by canot drill), compares each output to the original table (rows, statistics, fingerprint) and signs an evidence log. See scan. |
canot connectors | Lists the known platforms: URL format, inventory (scan), export (drill), evidence type, status (tested, or written but untested) and the pip extra to install. |
canot mcp | MCP server: the Canot documentation and explanations of your reports, for an agent. See Using it with an agent. |
Every command accepts --debug (full traceback on error).
canot drill
Useful options:
| Option | Purpose |
|---|---|
--bucket NAME | Required with a hosting provider shortcut (scaleway:fr-par, ovh:gra, outscale:eu-west-2, ionos:eu-central-1, stackit:eu01, aws:eu-west-3). |
--catalog URI | Iceberg catalog: sqlite:///…, or env:VARIABLE_NAME for PostgreSQL (canot[postgres]). Default: a SQLite file in the output folder. |
--out FOLDER | Where to write the report and the evidence log (default canot-out). |
--tables SCHEMA.TABLE … | Restrict to specific tables. |
--queries FILE.sql | Replay your own queries. Default: the most expensive ones from the platform's query history. |
--top N | Number of history queries to replay (default 20). |
--tolerances FILE.toml | Comparison tolerances (rounding, timestamps). |
--credit-price USD | The Snowflake credit price in your contract. Without it: a dated range based on the list price. |
--replay-max-gb N | Maximum size of a table loaded in memory to replay queries (default 2). |
--key PATH | Signing key (default ~/.canot/evidence.key, or the CANOT_EVIDENCE_KEY_PATH variable). |
Output in --out: report.html (readable by a committee), report.json, the evidence log (evidence.jsonl, evidence.sig, evidence.pub) and reread.sql. The signature attests that the log has not been modified since it was signed. It attests neither the author nor the date.
canot scan
uvx canot scan "snowflake://MY_ACCOUNT/MY_DATABASE?user=MY_USER" --level 2
--level 1|2|3 (default 3) sets how deep to read; --credit-price replaces the list price; --include-sql-text also writes the raw SQL to a separate file (never by default). Details of the reads, permissions and guarantees: scan.
The sources
| Source | Format | Authentication |
|---|---|---|
| BigQuery | bigquery://PROJECT/dataset1,dataset2?location=EU; without a dataset: all datasets in the project; another project: bigquery://BILLING_PROJECT/data-project.dataset | Google application default credentials (gcloud auth application-default login) |
| Snowflake | snowflake://ACCOUNT/DATABASE?user=…&warehouse=…&role=…&schemas=A,B | key pair: SNOWFLAKE_PRIVATE_KEY_PATH variable (recommended) or SNOWFLAKE_PASSWORD |
| Databricks | databricks://HOST/WAREHOUSE_HTTP_PATH?catalog=… | DATABRICKS_TOKEN variable (read token); connector not yet tested on a real account |
| DuckDB | house.duckdb | none |
| dbt (project) | dbt://PROJECT_PATH: the project, its target/ folder or manifest.json; scan only (artifacts read offline), evidence through canot replay-dbt | none: no network, no dbt command |
canot connectors shows, for each one, what Canot can do (full or light inventory, export, evidence) and whether it is tested. For dbt, P3 evidence goes through canot replay-dbt.
Local or on-prem exit
No cloud needed on arrival: a folder is enough.
uvx canot drill "snowflake://MY_ACCOUNT/MY_DATABASE?user=MY_USER" ./iceberg
Canot writes the copy in Iceberg format to ./iceberg and the evidence log to canot-out/. The copy does not depend on the tool: DuckDB, Spark or Trino can read it. The reread.sql file contains the DuckDB commands to recount each table:
duckdb < canot-out/reread.sql
| Destination | Command |
|---|---|
| Local or mounted folder (disk, NAS, network share) | canot drill SOURCE /path/to/folder |
| Self-hosted S3-compatible storage (MinIO, Ceph, NetApp…) | canot drill SOURCE "s3://bucket/canot?endpoint=https://s3.internal:9000®ion=us-east-1" |
| Corporate proxy, internal certificate authority | add &proxy=http://proxy.internal:3128 and &ca=/path/ca.pem to the S3 URL |
| PostgreSQL catalog | --catalog env:CANOT_CATALOG_URI (password in the variable, never on the command line) |
Storage keys go through CANOT_S3_ACCESS_KEY and CANOT_S3_SECRET_KEY (or the scw configuration). Status of each case (tested or only wired up): on-prem.
Hosting providers
scaleway:fr-par, ovh:gra, outscale:eu-west-2, ionos:eu-central-1, stackit:eu01, aws:eu-west-3, with --bucket. Check each endpoint in the provider's console. SecNumCloud qualification, when it matters to you, depends on the specific offering: see targets.
Using it with an agent (MCP)
Many teams work with an agent. canot mcp gives it the Canot documentation and explains your reports, without sending anything to anyone: the server runs on your machine.
Setup in Claude Code:
claude mcp add canot -- uvx canot mcp
Or in your client's MCP configuration (Claude Desktop, Cursor, VS Code…):
{
"mcpServers": {
"canot": { "command": "uvx", "args": ["canot", "mcp"] }
}
}
Exposed tools:
| Tool | What it does |
|---|---|
commands | Lists the commands and their options. |
search_docs(query) | Searches the documentation and returns the matching passages. |
read_doc(topic) | Returns a full page: commands, scan, on-prem, targets, discrepancies. |
capabilities(platform) | What Canot can do on each platform (inventory, export, evidence type), with the status (tested, or written but untested) and the recorded test date. Without an argument: all platforms. |
explain_scan(path) | Reads a scan.json and summarizes it: findings, reads not performed, estimated figures, what to look into. |
explain_report(path) | Reads an exercise report.json: discrepancies, tables and queries involved, known causes from the catalog. Also reads a replay-dbt.json (dbt P3 evidence): identical, different and non-replayable models, with their reasons. |
The tools read files you point them to. They open no connection to your platforms.
For agents that read the web: canot.io/llms.txt and canot.io/llms-full.txt.
Going further
- scan: what is read, with which permissions, and the guarantees.
- on-prem: exit to a local folder or your own storage.
- targets: hosting providers and countries.
- discrepancies: what two engines return differently, measured.
- Help running it on your scope: canot.io.
canot scan: what is read, with which permissions
The scan produces scan.html (to share), scan.json and CSV files (flags.csv, families.csv, reads.csv, tables.csv). It describes your platform; it recommends neither leaving nor staying.
Guarantees
- Read-only. (dbt project: no queries, only files are read, see dbt.) Every query goes through a guard before it is sent: only
SELECT,SHOW,DESCRIBEandALTER SESSION SETare allowed. Everything else is rejected without sending anything.SYSTEM$functions are rejected too. - Never the content of your tables. The scan reads metadata and execution history. It requests no read permission on the data.
- Raw SQL is not written. Literals are masked (
WHERE email = ?), and queries that contain a password or a secret are counted, then discarded. Raw text is written only with--include-sql-text, toscan-sql-raw.json(permissions 0600), and the report says so. - Pseudonymized identities. Query users and roles are replaced with a pseudonym (HMAC, with a salt drawn for each scan and never written). Email addresses, login names and IP addresses are never read. Storage paths are reduced to the bucket name.
- No read silently lost. Every read is recorded as "read" or "not read" with its reason. A value that was not read stays empty, never 0.
Levels (--level, 3 by default)
| Level | Content | Additional permissions |
|---|---|---|
| 1 | Inventory, column types, volumes, billed history, consumption, objects (views, routines, tasks, stages, shares…), dependencies | metadata and consumption |
| 2 | + normalized query shapes and their replayability, reads and writes per table, policies | access to query history |
| 3 | + owner roles, client tools, user types, reserved capacity | grants and sessions |
What your permissions do not allow is recorded as "not read" in the report (finding F12): the scan reads what it has access to and states what it did not see.
Snowflake
canot snowflake-setup --generate-key --base MY_DATABASE writes the script for an administrator to review and run. It creates a role, an XS warehouse that suspends after 60 s and a credit cap, and grants Snowflake's database roles:
| Database role | Level | Used for |
|---|---|---|
SNOWFLAKE.OBJECT_VIEWER | 1 | tables, columns, views, routines, tasks, stages, dependencies |
SNOWFLAKE.USAGE_VIEWER | 1 | volumes, credits, history, loads |
SNOWFLAKE.GOVERNANCE_VIEWER | 2 | query history, reads per table, policies, tags |
SNOWFLAKE.SECURITY_VIEWER | 3 | roles, users (name and type only), sessions |
Never requested: IMPORTED PRIVILEGES, MANAGE GRANTS, IMPORT SHARE, USAGE on functions, READ on stages.
Known limits: ACCOUNT_USAGE has a latency of 45 minutes to 3 hours (up to 2 days for load history). The report shows the latency of each read. ACCESS_HISTORY requires Enterprise edition; without it, reads per table are inferred from TABLE_QUERY_PRUNING_HISTORY with medium confidence.
BigQuery
canot bigquery-setup --project MY_PROJECT --member user:… writes the gcloud script for a canotScanner custom role. Required permissions: bigquery.jobs.create, bigquery.jobs.listAll, bigquery.datasets.get, bigquery.tables.list, bigquery.tables.get, bigquery.tables.getIamPolicy, bigquery.routines.list, bigquery.routines.get, bigquery.models.list, bigquery.models.getMetadata, bigquery.reservations.list, bigquery.reservationAssignments.list, bigquery.capacityCommitments.list, bigquery.bireservations.get, bigquery.config.get. Optional: bigquery.rowAccessPolicies.list, bigquery.transfers.get, bigquery.connections.list, bigquery.dataPolicies.list.
Never: bigquery.tables.getData, nor the bigquery.user or jobUser roles.
Method notes:
- One
INFORMATION_SCHEMAquery per region (regions are not hierarchical:EUdoes not covereurope-west1). BigQuery bills at least 10 MB per query of this type, without caching: the scan caps each query and runs a dry run first. - Volumes come from
TABLE_STORAGEwhen it is readable, otherwise from thetables.getAPI. Canot never changes the project settings to enable it. - Under a row access policy,
total_bytes_billedis empty: the report says so instead of showing 0. - The free tier is shared across the billing account: it cannot be attributed per project.
dbt
canot scan dbt://PROJECT_PATH reads the project's artifacts, with no network and no dbt command: target/manifest.json (required), plus target/run_results.json and target/catalog.json if they exist. PATH can be the project, its target/ folder or the manifest.json file; a project folder passed without dbt:// is recognized too. Without a manifest, dbt parse produces one by reading the project files, without connecting to the warehouse. A single level (1): --level is brought down to 1.
| Read | What is kept |
|---|---|
manifest.json | models (materialization, target relation, dependencies), tests, sources, seeds, snapshots, exposures, metrics, project macros, packages (number of macros), adapter and dbt version, dependency graph (liens: every ref() and source()) |
| Compiled SQL of models, tests, snapshots | sha256 fingerprint of the normalized shape (literals masked), line count, functions and constructs specific to the adapter's dialect (sqlglot), functions missing from DuckDB, DuckDB translation category (C0 to C7, same rules as for queries); never the text |
run_results.json | status of the last invocation per node, failures, failing rows of tests, last write date of tables |
catalog.json | columns and types (skeleton for the DuckDB EXPLAIN), rows and bytes if the adapter provides them, pseudonymized owner |
Portability of a transformation: traduisible (translatable) when sqlglot translates the SQL to DuckDB (accepted by EXPLAIN if catalog.json is read, "not tested" otherwise); a_reconstruire (to rebuild) only on a fact that was read: a function missing from DuckDB, an impossible translation, an adapter-specific materialization (for example dynamic_table); non_determine (undetermined) for a Python model, uncompiled SQL still full of Jinja, or SQL that contains a secret (counted, then discarded).
Findings: F13 for a transformation to rebuild (with the reason), F08 for a model whose SQL depends on Jinja and macros ({% %} blocks, calls other than ref, source, config; dbt's generic tests do not count), F11 for the project DAG (last invocation, failures; scheduling not read: it lives in the orchestrator), F01 for an external source (location reduced to the bucket), F12 for a missing artifact. Consumption, billed volumes and permissions are not in these files: the report marks them "not read".
Validated on 2026-10-19 on the test bench project lab/dbt (artifacts from a real dbt build, dbt 1.12.6, Snowflake adapter): 8 models (4 staging views, 4 mart tables), 8 tests, 4 sources, 19 links; findings F11 and F12 (no catalog.json). Copy of these artifacts and frozen outputs: tests/fixtures/dbt/banc, tests/golden/cli_scan_dbt.
P3 evidence: canot replay-dbt
canot replay-dbt dbt://PROJECT --base copy.duckdb (or --iceberg DESTINATION --drill-out FOLDER for the Iceberg copy written by canot drill) replays each SQL model in a local DuckDB, in graph order, from the source tables of the copy, then compares its output to the table the platform had materialized, using the same criteria as canot drill: row count, per-column statistics, content fingerprint. The types of the replayed output are aligned with those of the original table before the comparison; each conversion is recorded. The result (replay-dbt.json) and the signed evidence log (evidence.jsonl, to check with canot verify-evidence) classify each model:
| Status | Meaning |
|---|---|
identique (identical) | replayed, compared, no discrepancy within tolerances |
different | replayed, compared, discrepancy (rows, statistics or fingerprint) |
non_compare (not compared) | replayed, but no original table in the copy (a view); its output feeds the downstream models |
non_rejouable (not replayable) | with the reason: Python language, incremental (depends on prior state), ephemeral (replayed through the models that use it), adapter-specific function or materialization, translation rejected, table missing from the copy, dependency not replayed |
The compiled SQL must be in the manifest (dbt compile or dbt build puts it there). Tested on 2026-10-19 on the test bench artifacts and a DuckDB copy built by the tests (original tables written by hand, no network): 4 identical mart tables, 4 views replayed but not compared, an injected discrepancy detected, including when reading the Iceberg copy written by canot drill.
First replay on a real copy (2026-10-19): canot drill of the test bench Snowflake account (RAW and marts schemas) to a local Iceberg copy, then canot replay-dbt --iceberg, with no new query to the platform. Result: int_order_totals identical, 4 views replayed but not compared, 3 marts different (dbt_fct_orders: 150,000 rows originally, 25,325 replayed; dbt_customer_ltv; dbt_daily_revenue). These discrepancies are correct: the customers table is protected by a row access policy and the drill's read role only sees part of it. The copy of the inputs is incomplete and the replay shows it: this is exactly what P3 must detect (finding F10 of the Snowflake scan points out that a copy requires a principal not filtered by these policies).
The same run revealed a false discrepancy, since fixed: int_order_totals was classified as different although rows and statistics were equal. Cause, column by column: ORDER_ID and ITEMS equal (the recorded conversion ITEMS : HUGEINT -> INTEGER changes no value); BASKET_VALUE is a sum of floats (UNIT_PRICE as FLOAT) that Snowflake and DuckDB add up in a different order. 36,971 values differ in the last bit (121.79 versus 121.79000000000002, relative difference at most 4e-16), and the content fingerprint hashes the exact text. Now, when rows and statistics are equal and only the fingerprint differs, the replay recomputes the fingerprint on both sides with float columns rounded to 12 significant digits (finer than the relative tolerance of 1e-9). If it is then equal, the model is "identical within tolerance" and the report records the rounded columns, the number of values that differ in the last bit and the maximum measured relative difference (verification.empreinte_flottants). A larger discrepancy stays "different".
Reading the figures
Every figure carries its nature: measured (read as is), computed (derived from measurements, formula given), estimated (range and assumptions), not read or not estimated (with the reason). Prices are dated, sourced reference points; pass --credit-price with your contract price to replace them. The read cost and the duration of a copy are planning estimates: canot drill replaces them with measurements.
Replayability measures how many query shapes are translated to DuckDB and accepted by it, on an empty skeleton typed from your columns. Accepted does not mean identical result: only canot drill compares results. Queries on tables outside the scope that was read, queries whose text the platform does not provide and metadata queries are counted separately, outside the rate.
What the scan does not see
Reads through the Storage Read API and queries run from other projects (BigQuery), consumers of shares and RESULT_SCAN (Snowflake), jobs that run less often than the window that was read (365 days on Snowflake, 90 to 180 days on BigQuery). The actual window is shown next to each finding.
On-prem exit
Canot writes the copy in Apache Iceberg format. The destination can be a folder on your network, or S3-compatible storage that you host.
| Destination | Command | Status |
|---|---|---|
| Local or mounted folder (NAS, disk, network share) | --to /path/to/folder | tested |
| Self-hosted S3-compatible storage | --to "s3://bucket/canot?endpoint=https://s3.internal:9000®ion=us-east-1" | tested on a local S3 server (moto) |
| Custom catalog (SQLite elsewhere) | --catalog sqlite:///path/catalog.db | tested |
| PostgreSQL catalog | --catalog env:CANOT_CATALOG_URI with pip install 'canot[postgres]' | wired up, not yet tested against a real PostgreSQL |
| Corporate proxy | ...?proxy=http://proxy.internal:3128 in the S3 URL | wired up, not yet tested |
| Internal certificate authority | ...?ca=/path/ca.pem in the S3 URL (passed through AWS_CA_BUNDLE) | wired up, not yet tested |
Storage keys go through CANOT_S3_ACCESS_KEY and CANOT_S3_SECRET_KEY (or the scw configuration). A catalog password goes in an environment variable, never on the command line: --catalog env:VARIABLE_NAME.
Reading the copy without Canot
Each exercise writes a reread.sql file next to the report. It contains the DuckDB commands that count the rows of each copied table:
duckdb < canot-out/reread.sql
The copy therefore does not depend on the tool: any engine that reads Iceberg (DuckDB, Spark, Trino) can read it. For S3 storage, first create a DuckDB secret with your keys (see the file).
What is not supported
Writing directly to PostgreSQL, ClickHouse or another database engine: the copy stays in Iceberg, which these engines can read. No documented offline installation for now.
Cleaning up
canot teardown --to "s3://bucket/canot?endpoint=https://s3.internal:9000®ion=us-east-1"Targets: sovereign hosting providers and countries
Canot writes to any S3-compatible storage. Built-in shortcuts (--to <provider>:<region> --bucket <name>). Check each endpoint in the provider's console before use: they are still to be confirmed.
| Shortcut | Provider | Country | Note |
|---|---|---|---|
scaleway:fr-par (also nl-ams, pl-waw) | Scaleway | FR, NL, PL | not SecNumCloud-qualified (to be checked) |
ovh:gra | OVHcloud | FR | SNC offering qualified SecNumCloud (to be checked on the ANSSI list) |
outscale:eu-west-2 | Outscale | FR | qualification up for renewal at the end of 2026 (to be checked) |
ionos:eu-central-1 | IONOS | DE | |
stackit:eu01 | STACKIT (Schwarz Group) | DE | |
aws:eu-west-3 | AWS | FR | not a sovereign cloud; AWS European Sovereign Cloud also exists |
The same principle in other EU countries
The Data Act applies across the EU: same provider switching rules (Art. 23 to 31), same charges down to zero from 12 Jan 2027. What changes from one country to another: the financial supervisor that enforces DORA, the authority that oversees the Data Act, and national sovereignty requirements.
| Country | Why it is relevant | Local hosting providers |
|---|---|---|
| Germany | largest market; BaFin enforces DORA; strong sovereignty culture | STACKIT, IONOS, Open Telekom Cloud |
| Netherlands | financial center, many scale-ups on Snowflake | Leaseweb |
| Italy | many banks and insurers | Aruba, Seeweb |
| Spain | banks; Data Act through the CNMC | IONOS, Arsys |
| Poland, Nordic countries | heavy cloud use, local hosting providers | Scaleway Warsaw, UpCloud |
To confirm before selling: the competent authority designated in each country and the national qualification lists (SecNumCloud equivalents: C5 in Germany, ACN in Italy, ENS in Spain).
A question, or a test to suggest? Write to us