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.synq-recon on a reconciliation
suite. Read it from the top down.
A reconciliation compares one dataset in a source database with one dataset
in a target database. A suite is a YAML file with several reconciliations
and their connections. By default, only counts and checksums leave the
database, never row values.
1. The loop
Do these steps in order. Do not skip a step, because each step costs more than the step before it.Step 1: validate offline with check-config suite.yaml
The command checks the YAML and warns about time-dependent SQL. It connects to
no database. Continue when it prints Configuration check passed!. A warning
about NOW() or CURRENT_DATE is real, see Never do these.
Step 2: read the plan with plan suite.yaml
The command prints the exact SQL of each side after variables, table references
and time-travel snapshots resolve. It connects to no database. Continue when the
SQL is the SQL you meant. This is the cheapest place to find a wrong date or a
wrong schema. Values that a run derives from live data, such as a cutoff
watermark, are named but not resolved.
Step 3: validate against the databases with check-config suite.yaml --db
The command sends each query to the query planner (LIMIT 0). It reports the
columns, the table size, the keys and a suggested key column, and warns when
your key column has no index. Continue when every connection is OK and every
query validates. --db does not run the setup: SQL. If setup creates the
tables, every query fails here, so go to step 4.
Step 4: compare with run-check suite.yaml
The command compares each reconciliation without a drill, with one query per
side. If it reports MATCH, the datasets agree and you are done. If
it reports MISMATCH, go to step 5. Exit code 1 is a result, not a failure.
Exit code 2 means that a reconciliation could not run, so read its error first.
Step 5: locate with run suite.yaml --auto-drill
The command runs the check, then bisects the key space of each mismatch to find
the key ranges that differ. run-drill does the same without the check.
Continue when the drill reports usable key ranges. If it reports thousands of
leaves, stop and read Never do these.
Step 6: save to the workspace with upload-config suite.yaml
Run auth whoami first. The command stores a version of the suite in your
Coalesce Quality workspace, under Development, and prints the suite id.
Step 7: run on the backend with run-remote <suite-id> --wait
Coalesce Quality runs the suite against the workspace integrations. Connections
bind to integrations by name, or explicitly with
--map connection=integration_id. Pass --drill=false if you do not want a
drill.
Step 8: publish to production with promote <suite-id>
The command creates a deployment: a snapshot of the suite and an optional
schedule. Its results become assets, checks and issues. If you edit the suite,
production does not change until you promote again. Run promote --diff first,
see Editing a deployment.
After a run
recheck <run> runs the same queries again and reports what changed.
drill-deeper <run> continues the drill, see
Investigating a finished run.
2. Never do these
- Do not query the warehouse by hand to “check something first”. Write the question as a reconciliation, so that the result is recorded and reproducible. If the tool cannot express it, report a bug.
- Do not commit
.connections.yamlor any credential. Keep the real file in.gitignore. Commit only a.connections.yaml.examplewith${ENV_VAR}placeholders. Do notupload-configa suite that holds a literal secret. - Do not run a drill with threshold 1 on a table with tens of thousands of real
differences. Bisection is O(log n) only when most rows are identical. When
most rows differ, it sends thousands of queries that each scan a large part
of the table. First find the groups that differ with an aggregate comparison
(
mode: aggregate, aCOUNTmeasure grouped by the natural partition). Then drill a query that reads only those groups. - Do not omit
--drillonrun-remoteif you want no drill. An ad hoc run has no deployment to inherit from, so it drills every mismatch whose reconciliation hasbisection.enabled: true, the default. Pass--drill=false. Onlyrun-drillanddrill-deeperdrill a reconciliation withbisection.enabled: false. - Do not put
NOW(),CURRENT_DATEorGETDATE()in a query. The two sides evaluate them at different instants, which makes false differences. Use a template variable such as{{ today - 7d }}, which resolves once for both sides.strict_time_references: truemakes thecheck-configwarning an error. - Do not make a concatenated key such as
a || '|' || b. It cannot use an index, so each segment scans the full table. Writekey_columns: [workspace, path], and bisection filters on the column tuple. - Do not compare a replica that lags without a
cutoff:, see Avoiding false differences.
3. Confirm the target before any mutation
Each command that writes to a workspace acts on the workspace of your credential:upload-config, promote, unpromote, trigger, run-remote,
deployment …, suite delete and runs cancel.
workspace: line and confirm it is the correct
workspace. There is no --workspace flag. To change the target, change the
credential.
Credential order
The CLI uses the first credential it finds, so an automated pipeline never uses the browser login of a developer:- Client credentials:
--client-id/--client-secret, orQUALITY_CLIENT_ID/QUALITY_CLIENT_SECRET, or thesynq:block of the suite. - An API token:
QUALITY_TOKEN. - The browser login from
synq-recon auth login. The CLI refreshes it automatically.
synq-recon auth login --help. That
credential carries your Coalesce Quality roles, not the permissions below.
The --synq- flags and the SYNQ_ environment variables are older spellings
and still work.
Region and endpoint
--region eu|us|au and --endpoint (or QUALITY_API_ENDPOINT, for staging or
self-hosted) are root flags. They also select the stored credential of that
region, and a token never goes to another region. Usually you need neither:
auth login --region au records the deployment for later commands, and
auth use <region> or auth use --clear changes it. The order is
--endpoint, --region, the synq.endpoint of the suite,
QUALITY_API_ENDPOINT, QUALITY_REGION, the last login, then eu.
Permissions
SCOPE_RECON_READ lets you inspect. SCOPE_RECON_EDIT lets you
upload-config, edit suites and report the results of a local run.
SCOPE_RECON_PROMOTE lets you promote and trigger. A workspace API client can
have all three, so every command works without a person.
4. Authoring a suite
The smallest useful suite is a connection pair and one reconciliation:Rules that decide if a suite works
- Use
table:when you can. The tool then finds the columns, and you can narrow withcolumns:,exclude_columns:andwhere:. Usequery:only when the comparison needs SQL. Aquery:takescolumns:andexclude_columns:too. A cutoff filters the query as written, so its column need not be incolumns:.where:is for atable:only. A suite withwhere:beside aquery:does not load, so put the filter in the query. exclude_columns:matches a name in any case, quoted or bare. The run warns about an entry that matches no column, because it excludes nothing.- Key columns must be unique and should have an index, because bisection sorts
and filters on them. Keep them in the
columns:of each side and out ofexclude_columns:. If a side leaves a key column out, the drill fails at the first difference. - Each mode except
row_countneeds the same number of columns on both sides, because the checksum hashes the columns by position. A wider target fails withsource has N columns but target has M columns. Narrow both sides withcolumns:, or remove the extra columns withexclude_columns:.row_countreads no columns, so it needs only the key columns on both sides. - Names that differ only in case match automatically. For other names, use
column_mapping. - Write a name as you write it unquoted, and quote only to pin it. The tool
quotes each part of
table:and each entry ofcolumns:for its warehouse, so a reserved word such asorderworks. An unquoted name resolves as the warehouse resolves it:ordersisORDERSon Snowflake andorderson Postgres. For a name created with quotes, quote that part:table: 'DB.SCH."events"'. The outer single quotes are YAML syntax.key_columns,column_mappingandcutoff:match column names in any case. - Keep credentials in
.connections.yaml, keyed by the connection names. The CLI finds it automatically, or you pass--connections. In the workspace, the same names bind to integrations, so one suite runs in both places. - Do not set
motherduck_account, the CLI sets it. Write the MotherDuck account name indatabase:, and passmotherduck_token.
Finding the physical table path
If the warehouse is an integration of your workspace, the catalog finds the physicaldatabase.schema.table of a side:
database, schema and table, and the dialect and connection
id, to choose between warehouses with the same name. The catalog is optional. A
DuckDB file or a local container is not in it, and no other command reads it.
Avoiding false differences
Most “the data is wrong” findings are a problem with the comparison window. Try these first. Time windows and cutoffs explainscutoff:, window: and variables:.
Numeric and timestamp precision
Comparison modes gives the defaults, the precedence and examples. The traps:numeric_scale: <places>rounds half away from zero. It is not a tolerance:10.1249and10.1250round to different values at two places.numeric_scale: exactfails if the two sides declare different scales. On a floating-point column, it works only between two databases of the same kind.comparison:applies torow_checksumonly. Other modes ignore it with a warning.check-config --dbreports every column thatexactcannot compare. A run stops at the first one.
5. Reading the output
Output formats
Set the format explicitly.-o is table in a terminal and toon when the CLI
finds an AI agent environment. To parse output, pass -o json.
run, run-check and run-drill have only two formats: -o json and a text
report. --jq, --columns, --no-headers and --wide have no effect on them,
so send their -o json to jq. The workspace commands accept every format and
these four flags.
run --auto-drill -o json is not one JSON document. It prints the check
document, a text banner, then one drill document for each reconciliation. To
parse the results, run run-check -o json and run-drill -o json separately.
Each prints one document. The results of an aggregate reconciliation are a
separate array, so --include one mode at a time.
-o json, use
2>/dev/null, never 2>&1.
Exit codes
Each code has the same meaning in every command:
A local run with a difference and a failed reconciliation exits
2. Thus 1
always means that every reconciliation has a result. A remote run that
completes can still report a mismatch, which is normal. --fail-on takes
several statuses, separated by commas or with the flag repeated
(--fail-on mismatched,within_threshold). The list replaces the default.
--fail-on=passed makes a check for data that must differ.
Audit logs
--audit-log <path> writes the full record of a run as JSON: every query,
timing, count, checksum and mismatch leaf. For a file name made by the CLI,
give a directory with a trailing /. The format is the AuditLog schema,
rendered or as
audit-log.schema.json.
drill_stop_reason, diff_queries with SQL for both
sides, and all the mismatch_types that apply:
Do not classify a difference by the count only. A target with one more row can
still miss a row and hold two orphans. Read the types.
Two traps in the audit log:
- The local
--audit-logfile uses snake_case (bisection_result). The same log fromaudit-logs get <invocation-id> -o jsonuses camelCase (bisectionResult). Ajqpath for one returnsnullon the other. - Counts and checksums are 64-bit integers, which the audit log writes as
strings (
"source_count": "10").run-check -o jsonwrites numbers. Usetonumberto compare.
6. What each command costs
On a warehouse that charges for use, each query of a run costs money. Know the cost before you put a command in a loop.Cost traps
- The table analysis of
check-config --dbcan grow with the warehouse, not with the suite. On BigQuery, and other platforms without a metadata API per table, it reads the metadata of whole datasets. On a large warehouse this is slow and costs money. Thus onlycheck-config --dbruns it. --max-table-rowsand--max-table-bytesmakerun,run-checkandrun-drillestimate each scan first, with a dry run orEXPLAIN. The CLI warns when an estimate is above the limit. Both are0(off) by default. BigQuery and Snowflake estimate bytes. ClickHouse and PostgreSQL estimate rows. DuckDB, Athena and Databricks give no estimate, so no warning occurs.
Limiting the load of a local run
--concurrencysets how many reconciliations run at the same time. Each reconciliation queries both sides, and a drill can query many segments at the same time.connections.<name>.parallelismsets the maximum number of queries a local run sends to that connection at the same time. The default is8.connections.<name>.disabled: truestops a local run from running a reconciliation, setup or teardown that uses that connection. If--includenames such a reconciliation, the command fails.
Keeping a local run local
If a stored credential exists, a local run reports its results to Coalesce Quality, even when all its databases are local. It logs the endpoint atINFO,
and prints a link to the stored run to stderr. A reported run never sends row
values. To send nothing and resolve no credential, pass --no-report or set
RECON_NO_REPORT.
reporting.level: detailed is for local runs only. Coalesce Quality does not
save, promote or run a suite with detailed. In a workspace suite, use
with_keys or count_only. To keep detailed and local fixtures in the same
file, put them in a local: block, see
Settings for local runs only.
upload-config and Upload config in the app drop the local: block, so the
workspace never runs your fixtures.
7. Investigating a finished run
To get the SQL queries of a finished reconciliation run back, or to replay the run, useaudit-logs queries, recheck and drill-deeper. They take a finished
run, not a suite: a local audit log file or the invocation id of a stored run.
Getting the SQL back
audit-logs queries <run> prints the investigation queries of a finished run:
a stored run, a backend run, or a run whose output you lost. It connects to no
warehouse.
--all-leaves also prints the query of each segment. An aggregate
comparison gives one pair for each group that differs. A run that matched, or
did not drill, has no queries.
Replaying a run
--reresolve. An as_of snapshot needs --reresolve, or it can
never pass. An audit log holds connection names, never credentials, so pass
--connections. --remote sends the replay to the backend, and needs the
invocation id of a stored run. Re-check and Drill deeper
gives the other rules.
8. Command reference
synq-recon <command> --help is the full reference, and the
CLI reference page has the same
content. This section is the map.
Editing a deployment
promote on an existing deployment, and deployment update, change only the
settings you pass. Thus a new snapshot never removes the schedule or the API
trigger. To remove a schedule, pass deployment update --clear or
promote --clear-schedule. A first promote uses the defaults: no schedule, no
API trigger, and drill on.
Run promote <suite-id> --diff before each promote. A promote replaces what
production runs, and the only undo is another promote:
changed, unchanged,
preserved (flag not passed), or the default of a first promote. Without these
labels, an omitted --schedule and a removed schedule look the same.
The deployment id is part of the asset path of each reconciliation. A new
deployment with a new id moves its checks and drops their history. A promote of
the same suite uses the same id. To set the id when you rebuild a deployment,
use promote --deployment-id <id>.
Check categories
Set a check category on the deployment, not in the suite file, because a reconciliation is a check only after a promote.--category is the technical
category and --governance-category is what the check is for, see
Check categories.
--category "")
removes the declaration. A case overrides only the categories you name. Without
a declaration, a quality.check_category / quality.governance_category
annotation decides, else the categorisation rules of the workspace.
Flags with traps
--timeoutis the time limit of a local run. The default is theexecution.timeoutof the suite, else5m. The time limit of a backend run is--execution-timeout, which falls back to the deployment, then toexecution.timeout. For one reconciliation, setexecution.reconciliation_timeouton the suite ortimeouton the reconciliation. Such a reconciliation fails with a timeout, and the run continues.--concurrencyfalls back toexecution.concurrency, else1. Backend runs readexecution.concurrencyand can use a lower limit.--connectionsfinds.connections.yamlorconnections.yamlin the working directory automatically.--dbt-profilesreads connections from a dbtprofiles.ymlif the suite does not define them.--forceskips safety checks too, not only confirmation prompts.
9. Worked example
This example uses the DuckDB fixtures inexamples/ of a release archive. It
needs no warehouse, credentials or network. The suite has two differences on
purpose: one missing order and one wrong amount.
-
Validate:
-
Compare:
The exit code is 1: differences found, as expected.
-
Locate the differences and keep the audit log:
Order 1005 is missing on the target. Order 1010 is on both sides, but its checksum differs, so a value changed. Only two of ten rows needed a look.
-
Get the SQL that shows the rows, and run it on the two connections:
-
Check again after a fix. The suite defines its connection, so a replay needs
a connections file with that name:
Nothing changed because nothing was fixed.
-
Try the aggregate mode, which finds a difference by group, not by key range:
examples/ has one scenario for each business case.
./examples/test-all-examples.sh runs all of them.