# Canot: full documentation

# Canot: the commands

Canot rehearses your exit from a data platform, inside your own accounts. One command is enough:

```bash
uvx canot drill SOURCE DESTINATION
```

`SOURCE` is the platform you would leave (Snowflake, Databricks, BigQuery), or a dbt project that runs on one, `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.

```bash
# 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&region=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

# A dbt project: the tables it uses are copied and compared, then its models are replayed on the copy
uvx canot drill dbt://./my_dbt_project ./iceberg --target prod
```

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](scan.md). |
| `canot drill SOURCE DESTINATION` | Full exit exercise: copy, table-by-table comparison, query replay, signed evidence log. With `dbt://PROJECT` as the source: reads the warehouse named by the dbt profile, copies the relations the project uses, then replays its models on the copy (P1 to P3, one evidence folder). See [`canot drill dbt://`](#canot-drill-dbt). |
| `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](scan.md#p3-evidence-canot-replay-dbt). |
| `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](#using-it-with-an-agent-mcp). |

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). |
| `--lang en\|fr` | Language shown first in `report.html`. The report always holds both English and French, switched at the top of the page; this only picks the default. Default: your locale (`fr…` gives French), else English. The terminal output and `report.json` stay in English. Also accepted by `canot demo`. |
| `--key PATH` | Signing key (default `~/.canot/evidence.key`, or the `CANOT_EVIDENCE_KEY_PATH` variable). |
| `--profiles-dir FOLDER` | dbt project only: the folder that holds `profiles.yml`. |
| `--target NAME` | dbt project only: the profile target to read (also `dbt://PROJECT?target=NAME`). |

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 drill dbt://`

```bash
uvx canot drill dbt://PROJECT_PATH DESTINATION [--target NAME] [--profiles-dir FOLDER]
```

The exit exercise of a dbt project, in one command:

1. **Reads the project**: `dbt_project.yml` (the profile name), `profiles.yml` and `target/manifest.json` (`dbt parse` produces it from the project files, without connecting to the warehouse). `profiles.yml` is found with `--profiles-dir`, else `DBT_PROFILES_DIR`, else the project folder, else `~/.dbt/profiles.yml`; the target is `--target` (or `?target=NAME` in the URL), else the profile's default target. `{{ env_var('NAME') }}` and `{{ env_var('NAME', 'default') }}` are resolved; no other Jinja is evaluated and no code is run: a value that needs more is refused with the field named.
2. **Works out the warehouse and the relations**: the adapter of the target gives the platform; the manifest gives the relations, under the names dbt uses (database, schema, alias). Copied: every source, seed and snapshot, every model materialized as `table` or `incremental` (a Python model's table is copied and compared, the model is not replayed). Not copied, with the reason in the report: views (they hold no data; they are replayed and proven through the tables that read them), ephemeral models (no relation), materialized views and dynamic tables (maintained by the platform, to rebuild), relations in another database, and relations the warehouse does not show to the profile's role.
3. **Copies and compares** those tables to Apache Iceberg: the same copy, comparison and query replay as `canot drill`.
4. **Replays the models** on the copy and compares each output with the original table, as [`canot replay-dbt`](scan.md#p3-evidence-canot-replay-dbt) does: identical, different, not compared, not replayable.
5. **Writes one evidence folder** in `--out`: `report.html` and `report.json` (the drill, with a `dbt` section: relations copied and not copied, replay, the explanation of each difference), `replay-dbt.json`, one signed evidence log (`canot verify-evidence` checks it) and `reread.sql`.

| Adapter | Connection read from the profile | Credentials |
|---|---|---|
| `snowflake` | account, user, role, warehouse, database; the schemas of the relations | `private_key_path` (becomes `SNOWFLAKE_PRIVATE_KEY_PATH` unless already set) or a password from the environment; `externalbrowser` and inline keys are not supported yet |
| `bigquery` | project (`execution_project` for billing), location | `method: oauth` (application default credentials) or `service-account` (the keyfile path becomes `GOOGLE_APPLICATION_CREDENTIALS` unless already set) |
| `databricks` | host, http_path, catalog | token from the environment (`DATABRICKS_TOKEN`) |
| `duckdb` | path (relative to the project folder) | none |

Another adapter (postgres, redshift, sqlserver…) gets the same message as a platform without a connector, with how to ask for one.

**Your credentials, read-only.** Canot connects with the credentials and the role the profile names, only reads, and never adds a right. What the role cannot see is reported as not copied, never worked around. A secret (password, token, key passphrase) is accepted only as `{{ env_var('…') }}`: written in clear in `profiles.yml`, it is refused. Secrets are never printed nor written: the evidence log records the adapter, account or project, database, role and schemas, never the user, a secret or a key path.

**Reading the result.** A model that differs while its inputs were copied identically is explained with what the run measured, measured causes first: an input copied with a lossy conversion (a `TIMESTAMP_TZ` reduced to UTC: the replay applies its source zone, see [scan](scan.md#p3-evidence-canot-replay-dbt); dates that still differ point to per-value offsets the rule cannot recover), then a row access policy on its inputs when the row counts differ (Snowflake: policies read with the profile's role, or reported as not readable). Under such a policy the copy holds what the role can read: a table that dbt built with a role that saw more cannot be reproduced from it. That is the expected result for this reader, not a translation error (finding F10 of `canot scan`). Exit code: 0 when the copy is identical and no model differs, 1 otherwise.

Tested on 2026-10-19 on the Snowflake test bench (`lab/dbt`, target `snowflake`); BigQuery and Databricks profiles are written but not yet validated on a real account. Details: [scan](scan.md#exit-exercise-of-a-dbt-project-canot-drill-dbt).

## `canot scan`

```bash
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](scan.md).

## 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`. `canot scan` reads the artifacts offline; `canot drill dbt://PROJECT DESTINATION` (`?target=NAME`, `--profiles-dir`) reads the warehouse named by `profiles.yml` (Snowflake, BigQuery, Databricks, DuckDB) | scan: none, no network, no dbt command. drill: the credentials of the dbt profile, read-only; secrets only from the environment |

`canot connectors` shows, for each one, what Canot can do (full or light inventory, export, evidence) and whether it is tested. For dbt, `canot drill dbt://` gives P1 to P3 in one folder; `canot replay-dbt` replays the models on a copy that already exists.

## Local or on-prem exit

No cloud needed on arrival: a folder is enough.

```bash
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:

```bash
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&region=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](on-prem.md).

## 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](targets.md).

## 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:

```bash
claude mcp add canot -- uvx canot mcp
```

Or in your client's MCP configuration (Claude Desktop, Cursor, VS Code…):

```json
{
  "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; for a `canot drill dbt://` folder, the `dbt` section: relations not copied and why each model differs. |

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](https://canot.io/llms.txt) and [canot.io/llms-full.txt](https://canot.io/llms-full.txt).

## Going further

- [scan](scan.md): what is read, with which permissions, and the guarantees.
- [on-prem](on-prem.md): exit to a local folder or your own storage.
- [targets](targets.md): hosting providers and countries.
- [discrepancies](discrepancies.md): what two engines return differently, measured.
- Help running it on your scope: [canot.io](https://canot.io/#contact).

---

# `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.

`scan.html` is offered in English and in French: both versions are in the same file, switched by the English / Français links at the top of the page (no script). `--lang en|fr` picks the version shown first; without it, a French locale (`LANG=fr_FR.UTF-8`) shows French, anything else English. `scan.json`, the CSV files, `scan-share.json` and the command-line output stay in English.

## Guarantees

- **Read-only.** (dbt project: no queries, only files are read, see [dbt](#dbt).) Every query goes through a guard before it is sent: only `SELECT`, `SHOW`, `DESCRIBE` and `ALTER SESSION SET` are 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`, to `scan-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_SCHEMA` query per region (regions are not hierarchical: `EU` does not cover `europe-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_STORAGE` when it is readable, otherwise from the `tables.get` API. Canot never changes the project settings to enable it.
- Under a row access policy, `total_bytes_billed` is 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".

**Time zone of the replay.** A timestamp with time zone is copied to Iceberg as an instant in UTC; `canot drill` logs the source zone with the conversion (`tz` in the `conversions` of `report.json`). The replay applies it: when the inputs of the models carry exactly one source zone, the DuckDB session runs in that zone, so a date derived from a timestamp (`cast(... as date)`, `date_trunc`) falls on the same day as on the platform. With no source zone, the session runs in UTC, explicitly: the result no longer depends on the machine's zone. With several source zones, it runs in UTC and the report states it ("several source time zones"): the models that read them are marked not determined and never count as a complete proof. The zone applied and the columns it comes from are in `replay-dbt.json` (`timezone`) and in the drill report. The conversions come from the drill run itself (`canot drill dbt://`), from the drill's `report.json` (`--iceberg --drill-out`), and are absent with `--base` (UTC). What the rule does not cover: a `TIMESTAMP_TZ` keeps the offset of each value, and offsets that do not follow the zone's rules (a fixed offset across daylight saving time, or offsets that vary by row) are not recovered from the instant; BigQuery `TIMESTAMP` carries no zone (its dates are cut in UTC by default), so the replay falls back to UTC, which matches it.

### Exit exercise of a dbt project: `canot drill dbt://`

`canot drill dbt://PROJECT DESTINATION --target NAME` chains the two steps above with the copy in between: it reads `profiles.yml` to find the warehouse, copies the relations the manifest names (sources, seeds, snapshots, table and incremental models; views are replayed, not copied), compares them, replays the models on the copy and signs one evidence log for the whole. Unlike the scan, it connects to the warehouse: read-only, with the credentials and the role of the dbt profile. Profile resolution, adapters and what is not copied: [commands](commands.md#canot-drill-dbt).

Tested on 2026-10-19 on the test bench: `canot drill dbt://lab/dbt <folder> --target snowflake --profiles-dir lab/dbt`, Snowflake trial account, profile `canot_lab` (role `CANOT_LAB`, warehouse `CANOT_WH`, key pair from `SNOWFLAKE_PRIVATE_KEY_PATH`). Measured:

- **Relations**: 8 copied (4 sources in `RAW`, 4 mart tables in `DBT_dbt_mart`), 24.1 MB, 10.5 s for the whole exercise; 4 staging views not copied, with their reason. The 8 copies are identical to the platform (rows, statistics, fingerprint).
- **Policies**: read with the profile's role: `RAW.CUSTOMERS` carries the row access policy `RAP_COUNTRY` and the masking policy `MASK_EMAIL` on `EMAIL`. The report states that the copy holds what `CANOT_LAB` can read.
- **Replay (P3)**: `int_order_totals` identical, 4 views replayed but not compared, 3 marts different: `dbt_fct_orders` (150,000 rows on both sides, latest `ORDER_DATE` 2025-08-22 on the platform, 2025-08-23 on the replay), `dbt_customer_ltv` (19,924 rows on both sides, `FIRST_ORDER` and `LAST_ORDER` shifted), `dbt_daily_revenue` (14,399 rows on the platform, 14,423 replayed).
- **Why**: `RAW.ORDERS.ORDER_TS` is a `TIMESTAMP_TZ`; the copy keeps the instant in UTC (the drill logs this conversion as lossy) and `cast(order_ts as date)` in `stg_orders` gave a different day for evening orders, the replay then running in the machine's zone (Europe/Paris). Entered in the [discrepancy catalogue](discrepancies.md).
- **With the time zone rule** (same day, one more run): the replay ran in `America/Los_Angeles`, the zone logged for `RAW.ORDERS.ORDER_TS`. The 3 marts remain different but closer: `dbt_daily_revenue` has 14,409 replayed rows instead of 14,423 (platform 14,399); `dbt_fct_orders` and `dbt_customer_ltv` keep the platform's row counts, only the latest dates differ. Measured on the copy, offline: the platform's `ORDER_DATE` equals the date of `ORDER_TS` at a fixed offset of -08:00 for all 150,000 orders; in the `America/Los_Angeles` zone, 4,183 orders fall on another day (evening orders during daylight saving time, at -07:00); in UTC, 49,930. The lab script builds `ORDER_TS` as `dateadd(minute, …, '2024-01-01'::timestamp_tz)`: every value keeps the January offset, -08:00, all year. This is the case the rule does not cover (offsets that do not follow the zone's rules); the report says so for each of the 3 marts and no longer blames the row access policy, whose effect is not measured here.
- **Evidence**: one folder, `report.html`, `report.json`, `replay-dbt.json`, a log of 32 entries accepted by `canot verify-evidence`; no user name, secret or key path in any file or printed line.

This run differs from the first replay above: with the profile's role `CANOT_LAB`, the row access policy hides no customer that has orders (the replayed `dbt_fct_orders` has the platform's 150,000 rows), so the remaining differences come from the time zone. Both are the correct result for their reader; the evidence names the cause each time instead of hiding the difference.

## 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&region=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:

```bash
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

```bash
canot teardown --to "s3://bucket/canot?endpoint=https://s3.internal:9000&region=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).
