Skip to main content
This page is the published copy of the operating guide that ships with the tool as AGENTS.md — in the release archive, in getsynq/synq-recon, and at /opt/synq-recon/AGENTS.md inside the Docker image. It is deliberately denser than the rest of this section. Start at Getting started if you want the guided version, and see the CLI reference for every flag.
This is the operating guide for a coding agent using synq-recon to author, validate, run, promote and investigate a reconciliation suite: the loop to follow, the mistakes that cost real time, how to read the output, and what each command spends. It is written to be acted on top-down. A reconciliation compares one dataset in a source database against one in a target database and reports whether they agree. A suite is a YAML file holding several reconciliations plus the connections they use. Only counts and checksums leave the database by default — no row values.

1. The loop

Work through these in order. Each step has a condition for moving on; do not skip ahead, because every later step is more expensive than the one before it. 1. Validate offline — check-config suite.yaml Parses the YAML, checks required fields, and warns about time-dependent SQL. Costs nothing, touches no database.
Move on when it prints Configuration check passed!. Fix errors here rather than discovering them mid-run. Warnings about NOW() / CURRENT_DATE are real — see Never do these.
2. Read what it will do — plan suite.yaml Prints the resolved execution plan: every reconciliation the run covers, which connection each side uses, and the exact SQL both sides will execute after template variables, table references and time-travel snapshots resolve. Costs nothing, touches no database.
Move on when the quoted SQL is the SQL you meant to run. This is the cheapest place to catch a variable that resolved to the wrong date or a table reference pointing at the wrong schema — both of which otherwise surface as a confusing MISMATCH after you have paid for the scan. Values a run derives from live data — a cutoff watermark, the boundaries a drill picks — are named but not resolved; deriving them needs a query.
3. Validate against the databases — check-config suite.yaml --db Connects to every connection, runs each query through the database’s query planner (LIMIT 0), and reports the resolved columns. It also reports table size, primary/partition keys, a suggested key column, and warns when the key column you chose is not indexed.
Move on when every connection is OK and every query validates. A failure here is a wrong table name, a missing grant, or a column that does not exist — all of which would otherwise surface as a confusing mid-run error. --db does not run the suite’s setup: SQL, so a suite whose tables are created by setup will report every query as failed. That is expected; skip to step 4 for such suites.
4. Compare — run-check suite.yaml One query per side: total row count and checksum. Fast and cheap regardless of table size.
If it reports MATCH, the datasets agree and you are done. If it reports MISMATCH, go to step 5. Exit code 1 means “differences found”, which is a result, not a failure.
5. Locate — run suite.yaml --auto-drill Runs the quick check, then bisects the key space on whatever mismatched to narrow the difference to specific key ranges. run-drill does the same without re-running the quick check.
Move on when the drill reports mismatch leaves with usable key ranges. If it reports thousands of leaves, stop and read Never do these — you are drilling something that should be localised with an aggregate comparison first.
6. Save to the workspace — upload-config suite.yaml Stores and versions the suite in your Coalesce Quality workspace, where it appears under Development. Requires SCOPE_RECON_EDIT.
Run auth whoami first. Move on when it prints the suite id.
7. Run it on the backend — run-remote <suite-id> --wait Executes the suite in Coalesce Quality against workspace integrations rather than against databases this machine can reach. Connections bind to integrations by name; override with --map connection=integration_id.
Move on when the run completes. --wait exits 0 if everything passed, 1 if a --fail-on condition was hit, 2 if execution itself failed. Pass --drill=false explicitly if you do not want a drill — omitting the flag does not mean “off”.
8. Publish to production — promote <suite-id> Creates a deployment: an immutable snapshot of the suite plus an optional schedule, so it runs on its own and its results become platform assets, checks and issues. Requires SCOPE_RECON_PROMOTE.
A deployment is a snapshot, not a pointer: editing the suite afterwards changes nothing in production until you re-promote. Re-promoting preserves settings you omit; a fresh promote applies defaults, and the drill default on a fresh promote is on. Run promote <suite-id> --diff with the same flags first — it shows what would change in production and promotes nothing.
Any time after a run: recheck <run> re-executes what it ran and reports what moved; drill-deeper <run> continues its drill from where it stopped. Both take a local audit-log file or the invocation id of a stored run. See Investigating a finished run.

2. Never do these

Each of these has already cost someone real time. Do not hand-query the warehouse to “check something first.” Express the question as a reconciliation and let synq-recon run it — that way the result is recorded, reproducible, and comparable to the next run. If the tool cannot express what you need, that is a bug worth reporting, not a reason to open a SQL client. Do not commit .connections.yaml, or any credential, ever. Keep the real file git-ignored and commit only a .connections.yaml.example whose values are ${ENV_VAR} placeholders. A suite YAML with a literal secret in it is also a suite you cannot safely upload-config. Do not run a threshold-1 drill over a table with tens of thousands of genuine differences. Bisection is O(log n) when the data is mostly identical; when most rows differ it cannot halve, and it degenerates into thousands of queries that each rescan a large slice of the table. Localise first: an aggregate comparison (mode: aggregate, a COUNT measure grouped by the natural partition) tells you which groups diverge in roughly one query per side. Then drill a query narrowed to those groups. Do not omit --drill on run-remote and expect no drill. Unlike promote, where an omitted flag preserves the stored setting, an ad-hoc run has no deployment to inherit from, so an omitted --drill falls through to each reconciliation’s own bisection.enabled in the YAML. Pass --drill=false. Do not put NOW(), CURRENT_DATE or GETDATE() in a query. They evaluate at slightly different instants on the two sides, which manufactures differences that are not there. Use a template variable, resolved once and interpolated into both sides:
check-config warns about this; strict_time_references: true makes it an error. Do not synthesise a concatenated key. For a multi-column natural key, declare key_columns: [workspace, path]. Bisection then orders and range-filters on the column tuple, so a matching primary key or index still prunes each segment. A synthetic a || '|' || b key cannot use the index and forces a full scan per segment. Do not compare a lagging replica without a cutoff. If the target trails the source, rows still in flight look like missing rows. A cutoff: derives a watermark from the data itself and filters both sides to at-or-below it. See Cutoffs.

3. Confirm the target before any mutation

Every command that writes to a workspace — upload-config, promote, unpromote, trigger, run-remote, deployment …, suite delete, runs cancel — acts on whichever workspace your credential resolves to.
Read the workspace: line and confirm it is the one you mean before the mutation, not after. There is deliberately no --workspace override: your credential determines the target, so the way to change it is to change the credential. Credentials resolve in a fixed order, so an automated pipeline never silently picks up a developer’s browser login:
  1. Client credentials — --client-id / --client-secret, or QUALITY_CLIENT_ID / QUALITY_CLIENT_SECRET, or the synq: block in the suite.
  2. An API token — QUALITY_TOKEN.
  3. The browser login cached by synq-recon auth login, refreshed automatically.
--region eu|us|au selects the deployment, and --endpoint (or QUALITY_API_ENDPOINT) points at a staging or self-hosted one. Both are root flags, so they work on every command. Whichever you use also selects which region’s stored credential is used — a token minted for one region is never sent to another. You rarely need either: synq-recon auth login --region au records the deployment, and later commands resolve to it. synq-recon auth use <region> switches between deployments you are already logged in to, and auth use --clear goes back to the default (eu). Resolution order is --endpoint > --region > the suite’s synq.endpoint > QUALITY_API_ENDPOINT
QUALITY_REGION > the last login > eu.
The --synq--prefixed flags and SYNQ_-prefixed variables are the previous spellings and still work. Permissions: SCOPE_RECON_READ to inspect, SCOPE_RECON_EDIT for upload-config, suite edits and reporting a locally executed run’s results, SCOPE_RECON_PROMOTE to promote or trigger. All three can be granted to a workspace API client, so every command works unattended.

4. Authoring a suite

The smallest useful suite is a connection pair and one reconciliation:
Points that decide whether it works:
  • table: beats query: when you can use it. With table: the tool resolves the columns for you and you can narrow with columns:, exclude_columns: and where:. Reach for query: only when the comparison genuinely needs SQL.
  • Key columns drive everything. Bisection orders and range-filters on them, so they must be unique and, ideally, indexed. check-config --db suggests one and warns if yours is not indexed.
  • Modes. row_count compares counts only (fastest, misses modified values); row_checksum compares counts and a checksum of every column (the default, catches any difference); aggregate compares grouped measures like SUM and COUNT with a tolerance, and drills down through a group_columns hierarchy. full is an accepted legacy spelling of row_checksum; write row_checksum.
  • Column names differing only in case match automatically. For genuinely different names use column_mapping.
  • Keep credentials out of the suite. Put them in a git-ignored .connections.yaml (auto-discovered, or passed with --connections) keyed by the same connection names. This is also what lets the same suite run locally and in the workspace, where the names bind to integrations instead.

Finding the physical table path

Both sides of a reconciliation are named by their physical database.schema.table, and on a warehouse whose naming you do not control that is the part you may not know. If the warehouse is connected to your workspace as an integration, ask the catalog:
Both print the SQL-addressable database, schema and table — exactly what goes into a table: block — plus the dialect and connection id, which is how you tell apart the several warehouses one db.schema.table can legitimately match. This is a convenience and never a prerequisite. The catalog holds only warehouses connected to the workspace as integrations, so a database you reconcile locally — a DuckDB file, a container, anything reachable from this machine but never integrated — will not appear, and that says nothing about whether a suite naming it runs. Nothing else in this tool consults the catalog: check-config, plan and every run command work entirely from the suite and its connections file, with or without a workspace. The exhaustive field list is the published schema, at a stable versioned URL:
One field there is not yours to set: motherduck_account appears under the DuckDB connection because the loader populates it internally. Write database: with the MotherDuck account name, and pass motherduck_token.

Avoiding false differences

Comparing two live systems is a moving target, and most “the data is wrong” findings are really a comparison window problem. Reach for these first.

5. Reading the output

Pick your format explicitly. -o defaults to table for a human terminal and to toon when an AI-agent environment marker is present — so as an agent you will get toon unless you say otherwise. Anything parsing output must pass -o json. run, run-check and run-drill render only two shapes: -o json, and a text report for every other value. --jq, --columns, --no-headers and --wide do not apply to them; pipe their -o json through jq yourself. The workspace commands (suite, deployment, runs, audit-logs, connections, auth) honour all six formats and all four flags. run --auto-drill -o json is not a single JSON document. It emits the quick-check document, then a plain-text banner, then one drill document per drilled reconciliation. To parse the results, run the two stages separately — run-check -o json then run-drill -o json — each of which is one valid document.
For a run that already finished, audit-logs queries <run> gets the same SQL out of its audit log without re-running anything — see Investigating a finished run. Logs go to stderr, results to stdout. Redirect with 2>/dev/null, never 2>&1, when piping -o json. Exit codes. Locally executed commands: 0 everything matched, 1 differences were found or the command failed. For run-remote --wait and trigger --wait the codes are data-driven and finer: 0 passed, 1 a --fail-on condition was met (default mismatched,failed), 2 the run itself failed. A run can succeed and still report a mismatch — that is the normal case. --fail-on=passed inverts the test for a “these should differ” canary. Audit logs. --audit-log <path> writes the full record of a run — every query, timing, count, checksum and mismatch leaf — as JSON. Pass a directory (trailing /) for an auto-named file. Its shape is the published AuditLog schema — rendered, or audit-log.schema.json to validate against. Useful paths, using the local file’s casing:
Each mismatch leaf also carries a drill_stop_reason saying why drilling stopped there, diff_queries with ready-to-run SQL for both sides, and every mismatch_types that applies — so a segment differing in both size and content reports two: Count direction alone does not classify a difference: a target with one row more can still be missing a live row and carrying two orphans. Read the types. Two traps in the same file:
Casing. The local --audit-log file is snake_case (bisection_result, mismatch_leaves). The same log fetched from the workspace with audit-logs get <invocation-id> -o json is camelCase (bisectionResult, mismatchLeaves). A jq path written for one source silently returns null against the other.
Counts are strings. Row counts and checksums are 64-bit integers, which JSON encodes as quoted strings in the audit log ("source_count": "10") — unlike run-check -o json, where they are numbers. Compare with tonumber.

6. What each command costs

Reconciliation runs real queries against real warehouses, and on a consumption-priced warehouse those queries cost money. Know which commands spend before you put one in a loop. Two things worth knowing specifically: check-config --db’s table analysis can scale with warehouse size, not suite size. On platforms with no table-scoped metadata API — BigQuery in particular — enumerating table metadata walks the datasets rather than jumping straight to the tables your suite names. On a large warehouse that is slow and not free. This is why the analysis is confined to the interactive check-config --db and does not run before every reconciliation. Gate a large run before it happens. --max-table-rows and --max-table-bytes make run, run-check and run-drill estimate each reconciliation’s scan first — a dry run or EXPLAIN, not a scan — and warn when an estimate exceeds the threshold. Both default to 0 (off) so automated runs incur no extra queries. BigQuery and Snowflake report bytes; ClickHouse and PostgreSQL report planner rows; DuckDB, Athena and Databricks advertise no estimate support, so a gated run there simply emits no warnings. Keep a local run local. With a stored credential present, a locally executed run reports its results to Coalesce Quality so they appear in the workspace alongside backend runs — even when every database in the suite is local. It says so at INFO, naming the endpoint. Pass --no-report (or set RECON_NO_REPORT) to resolve no credential and open no connection at all.

7. Investigating a finished run

audit-logs queries, recheck and drill-deeper all take a finished run instead of a suite — either a local audit-log file or, for a run stored in the workspace, its invocation id. Get the SQL back — audit-logs queries <run>. A locally executed run prints its investigation queries as it finishes; this is how you get them afterwards, from a stored run, from a backend run you never watched, or from a terminal whose scrollback is gone. It reaches no warehouse.
What comes out follows how the difference was found. A bisection drill yields merged key-range queries — one per run of adjacent mismatched segments, since neighbouring bad segments are one range rather than five; --all-leaves also prints the per-segment queries those cover. An aggregate comparison yields one pair per divergent group. A run that matched, or never drilled, has none.
Four things govern how they behave:
  • Queries are replayed, not re-derived. The audit log holds each query after variable interpolation and cutoff resolution, so a re-check compares the same window the original run did — which is what makes “did the gap move?” a meaningful question. It also means a replay will not pick up an edited query or variable until you pass --reresolve. --reresolve is required for an as_of snapshot, which can otherwise never turn green.
  • Only what was left open resumes. drill-deeper picks up the segments the previous run stopped at on its row threshold or depth limit; --depth counts from the depth they already reached, so it means “this many more levels”. Segments that could not be split further, and aggregate groups present on only one side, are reported and skipped rather than silently dropped. An aggregate drill already visits every configured group column, so resuming one needs at least one --add-group-column.
  • recheck re-runs only the reconciliations that did not pass. --all re-runs every one.
  • Credentials never come from an audit log — it records connection names only. Pass --connections, exactly as for a normal run.
Add --remote to either command to hand the replay to the backend instead, running it against workspace integrations. --remote needs a stored run’s invocation id (a local file has nothing server-side to reference) and accepts --include but not --exclude.

8. Command reference

synq-recon <command> --help is authoritative. This is the map.

Local

Workspace

Deployment edits preserve what you omit. promote on a re-promote, and deployment update always, change only the settings you actually pass — so refreshing a suite snapshot never silently unschedules the deployment or disables API triggers. Removing a schedule is therefore explicit: deployment update --clear or promote --clear-schedule. A fresh promote applies defaults instead: no schedule, not triggerable, drill on. Declare a check category on the promotion, not in the suite file. A reconciliation becomes a check only once it is promoted, so where it sits in the catalog is a property of the deployment — the same reason the schedule lives there. Two independent dimensions are available, and both are free-form strings using whatever vocabulary the workspace already has:
  • --category is the technical dimension: what kind of check this is, mechanically. It is what makes the same check comparable across tools, so a reconciliation, a dbt test and a SQLMesh audit that all compare values can share one category.
  • --governance-category is what the check is for — the dimension a data-governance function tracks coverage against.
Both dimensions and the vocabulary in use are described in Check categories.
Each dimension is patched on its own: a flag you omit keeps its stored value, and passing an empty value clears the declaration. So --category "" stops declaring a technical category while leaving the governance one alone. Cases of one suite are the same kind of check but do not always serve the same governance purpose, so either dimension can be overridden per case. A case overrides only the dimensions you name and inherits the rest:
Declaring nothing is the default, and it is not the same as declaring nothing for a case. With no declaration, a category is decided by a reserved quality.check_category / quality.governance_category annotation on the suite or case if one is set, and otherwise by the workspace’s own categorisation rules — so leaving these flags alone keeps whatever already categorises your checks. Passing --case-category customers= declares that this case has no category, which overrides the deployment’s declaration rather than inheriting it. Annotations reach the check too, so annotations: in the suite file drives annotation-matching categorisation rules without any of these flags. Check a promote before making it. Promotion replaces what production runs, and a deployment is a snapshot rather than a pointer, so there is no undo short of promoting again. promote <suite-id> --diff runs the exact invocation you are about to run and applies nothing:
It prints a YAML diff of the deployed snapshot against the stored suite, then every deployment setting labelled with what this invocation does to it — changed, unchanged, preserved (flag not passed), or the fresh-promote default. That labelling is the point: an omitted --schedule and a removed schedule are indistinguishable from the command line alone. A deployment’s id is part of every reconciliation’s asset path, so it is worth keeping stable — rebuilding a deployment from scratch under a new id re-homes its checks and drops their history. Re-promoting the same suite reuses its id automatically; promote --deployment-id <id> pins one explicitly when you are rebuilding.

Flags accepted by every command


9. Worked example

Runnable end to end against the DuckDB fixtures shipped beside the binary — no warehouse, no credentials, no network. The suite deliberately contains a difference: one missing order and one with a wrong amount. Run it from an unpacked release archive, which carries examples/ next to the binary. Every published build has DuckDB linked in, so the fixtures work as they are. 1. Validate.
2. Compare.
Exit code 1 — differences found, as intended. 3. Locate them, keeping the audit log.
Order 1005 is missing from the target; order 1010 exists on both sides but its checksum differs, so a column value changed. Two rows out of ten needed looking at — that is the whole point of the bisection. 4. Get the SQL that shows the actual rows.
Run those against the two connections to see the differing rows themselves. 5. Re-check after a fix. The example’s connection is defined in the suite, so replaying it needs a connections file naming it:
Nothing changed because nothing was fixed — but that line is the shape of the answer you are looking for after a real remediation. 6. Try the aggregate mode on the same suite, which localises a difference by group rather than by key range:
More scenarios live in examples/, one per business case. ./examples/test-all-examples.sh runs every one.

10. When something goes wrong