dqc CLI reference
Measured Cloud ships an operator CLI named dqc. It's installed by pip install -e '.[api]' (or uv sync) and lives on your PATH. Run dqc --help for the in-tree summary; this page is the prose version.
Subcommands
dqc export-schema
Dump the FastAPI OpenAPI schema as JSON.
dqc export-schema # to stdout
dqc export-schema --out api.json # to file
dqc export-schema -o api.json # short form
Use cases:
- Regenerate the agent client (
make generate-clientcalls this). - Pin the API contract for a downstream SDK build (FEATURE-32, FEATURE-33).
- Diff the schema between releases.
The FastAPI app is imported lazily inside this command, so dqc export-schema --help doesn't pay the SQLAlchemy / DB-URL import cost.
dqc provision-tenant ORG_ID
Create the tenant_<org_id> schema and bring it to the current tenant head.
dqc provision-tenant 8d4f7c8e-3b2a-4a1e-9c5d-3e2f8b1a4c5e
What it does:
- Validates
ORG_IDis a valid UUID. - Connects to Postgres using
DB_URL. - In one transaction, creates the schema (
CREATE SCHEMA IF NOT EXISTS tenant_<id>) and runs every tenant-branch Alembic revision against it. That builds every table and the row-level-security policies, and stamps the schema'salembic_version_tenantrow. Either all of it commits or none of it does. - Writes a
systemaudit event on thetenantresource:createwhen it built the schema,updatewhen it upgraded a schema that was behind head. A run that finds the schema already at head changes nothing and writes no audit event.
You don't normally need to run this. Signup, dqc seed / scripts/seed_first_admin.py, and the dev seed provision each new org's schema this way before the org is created. Use the command to repair an org that exists without its schema. It is idempotent. It refuses an ORG_ID that has no organisation, so a mistyped id can't create an orphan schema, and it refuses a schema that has tables but no version stamp (see migrate-all-tenants below).
Exit codes: 0 means the schema is at head. 1 means it was refused (an unknown org, or tables without a version stamp) and nothing was created. 2 means ORG_ID isn't a UUID.
Unlike migrate-all-tenants, it doesn't check the audit log for signs that a missing schema once held data. Running it is the deliberate decision to recreate the schema empty, which migrate-all-tenants refuses to make on its own.
dqc migrate-tenant ORG_ID
Brings one org's tenant schema to head. Each tenant schema carries its own alembic_version_tenant row, so Alembic computes the per-tenant diff. It applies the same schema-state checks as migrate-all-tenants (the table below), and it refuses an ORG_ID that has no organisation.
dqc migrate-tenant 8d4f7c8e-3b2a-4a1e-9c5d-3e2f8b1a4c5e
Exit codes: 0 means the schema is at head. 1 means it was refused or failed, and the reason is printed.
dqc migrate-all-tenants
Iterates every Organisation row and brings its tenant schema to head. This is the step the deploy pipeline runs before a new image serves traffic. Before touching a schema, the command classifies it:
| Schema state | What happens | Counts as |
|---|---|---|
Stamped (has an alembic_version_tenant row) | alembic upgrade head | success, or failure if the upgrade errors |
| Exists but empty (no tables, no stamp) | Built from the first revision | success |
| Missing, and the org's audit log shows no tenant data | Provisioned exactly as dqc provision-tenant would, reported with a WARN provisioned missing tenant schema for org … line and a system audit event | success (repaired) |
| Missing, but the org's audit log shows tenant data | Refused, nothing is created. The message reads possible data loss and says how to recover | failure |
| Tables but no stamp (left by older seed/reset tooling) | Refused and left untouched, with instructions to stamp it at the right revision or re-provision it | failure |
A missing schema. An org can be missing its schema for two reasons. It was created without one (older signup code did this), or the schema was lost (an accidental DROP SCHEMA, a partial restore). Recreating the schema empty is the right repair for the first and would hide the second. So the command checks public.audit_events for rows of that org on tenant resources (datasources, data assets, rules, schedules and the like). The API writes one when it creates or changes a tenant resource, and the rows live in the public schema, so they survive a dropped tenant schema. If there are any, the schema held data, and the command stops for that org. Restore the schema from a backup (point-in-time recovery) and re-run. If the org's data is disposable, run dqc provision-tenant ORG_ID to recreate it empty on purpose.
The check can't prove the absence of data. Audit rows are pruned after the org's audit retention window, and data the worker writes (validation results, profiles) isn't audited. The window is the plan's default (30 days for free and self-hosted orgs, 90 for pro, 365 for enterprise) unless the org sets its own. An org's own setting can be lower than the default, down to 0, which deletes all of the org's audit rows at the next daily prune. An org whose last audited tenant change is older than its retention window, or whose retention is 0, looks like one that never had data.
For that reason every missing schema, repaired or refused, also emits a tenant_schema_missing event: one JSON line on stderr that Cloud Logging reads as a structured entry with severity ERROR. Its jsonPayload carries event, org_id, schema, command, outcome (reprovisioned, reprovision_failed or refused_possible_data_loss) and the audit evidence it found (tenant_audit_events, tenant_resource_types, latest_tenant_audit_at). The command's other output is plain text, as before. Set up a log-based alert on the event, so that a repaired schema is looked at too. For the migrate job, use this filter:
resource.type="cloud_run_job" AND jsonPayload.event="tenant_schema_missing" AND severity>=ERROR
dqc seed and scripts/seed_first_admin.py emit the same event (with command seed), so drop the resource.type clause to catch every source. Outside Cloud Run, the JSON line is in the container's or job's log output.
Tables but no stamp. A schema with tables and no version stamp is never replayed and never stamped automatically. Replaying the chain would fail on tables that already exist. Nothing records which revision the tables match, so stamping the current head could silently skip data-only or RLS-only migrations. An operator has to decide.
Per-tenant failures are caught and reported in the summary. They don't stop the loop.
Exit codes: 0 means every org is at head, including any schema that was repaired on this run. 1 means at least one org failed, and a deploy should stop.
dqc migrate-all-tenants
dqc rotate-encryption-key ORG_ID [--new-dek]
Two modes:
- Default (rewrap). Re-wrap one tenant's Data Encryption Key (DEK) under the current
ENCRYPTION_KEY— unwrapping it under the old key supplied viaENCRYPTION_KEY_PREVIOUS. The customer ciphertexts (datasource credentials, alert-destination secrets, LLM API keys) are not touched, so this is the safe recovery path after the env key was rotated in place. One column write per org; nothing can fail to decrypt. --new-dek(full rotation). Generate a fresh DEK and re-encrypt every secret the tenant owns under it — datasource credentials, alert-destination secrets (Slack webhook, generic-webhook signing secret, PagerDuty routing key), and the org's LLM API keys (HARDEN-16). Operationally offline-tolerable: drain the API + worker for the tenant before running. The CLI does the rotation in one transaction per tenant — a crash mid-rotation rolls back to the old DEK and leaves the existing ciphertexts intact. A concurrent writer holding the old DEK could persist a secret that the new DEK can't decrypt, so you do need the drain.
# Recover after an ENCRYPTION_KEY env rotation (rewrap each DEK under the new key):
ENCRYPTION_KEY_PREVIOUS="<old-key>" dqc rotate-encryption-key 8d4f7c8e-3b2a-4a1e-9c5d-3e2f8b1a4c5e
# Full DEK rotation (re-encrypt every secret):
dqc rotate-encryption-key 8d4f7c8e-3b2a-4a1e-9c5d-3e2f8b1a4c5e --new-dek
dqc rotate-all-encryption-keys [--new-dek]
Iterate every org and rewrap (default) or fully rotate (--new-dek) its DEK. The default rewrap mode is the fleet-wide fix after an ENCRYPTION_KEY env rotation. Per-tenant failures are reported in the summary but don't stop the loop — rotation is independent per tenant. Exits non-zero if any tenant failed.
ENCRYPTION_KEY_PREVIOUS="<old-key>" dqc rotate-all-encryption-keys
dqc set-role EMAIL ROLE [--org SLUG] [--no-workspace]
Promote or demote a user's role in an org. ROLE is an org role: super_admin, technical_admin, editor, viewer or billing. The workspace and team roles (workspace_admin, team_admin) are granted per workspace or team from Settings → Members or Teams, so set-role rejects them, as it does the token-only pseudo-roles. Updates the user's org_memberships row and — unless --no-workspace is passed — every workspace_memberships row the user holds in that org, to the matching workspace role (super_admin becomes workspace_admin there; technical_admin and billing leave the workspace rows alone). Idempotent.
Pass --org SLUG to scope the change when the user belongs to more than one org (required in that case; a single-org user can omit it).
The intended admin@dqcloud.dev seed account is born super_admin. But an account that first signed in via IAP JIT-provisioning is minted at IAP_DEFAULT_JIT_ROLE (historically a hardcoded editor), so an IAP-provisioned super admin ends up an editor and fails every super-admin check (#336/#340/#343). This is the explicit one-time fix:
dqc set-role admin@dqcloud.dev super_admin
dqc seed
Non-interactively provision the canonical bootstrap state (#48). Everything is read from env — no flags, no prompts — so a reseed or CI job can run it as a one-shot container. Idempotent: each step skips itself when its resource already exists.
FIRST_ORG_NAME="Acme Inc" \
FIRST_ADMIN_EMAIL="admin@acme.example" \
FIRST_ADMIN_PASSWORD="<12+ chars>" \
FIRST_LLM_PROVIDER=anthropic \
FIRST_LLM_MODEL=claude-sonnet-4-5 \
FIRST_LLM_API_KEY="<provider key>" \
dqc seed > agent-token.txt
What it creates:
- Org + default workspace + admin user with the canonical Phase-1 roles: the org membership is
super_adminand the workspace membership is the derivedworkspace_admin— never the legacyadminstring. The tenant schema is created with RLS applied. Keyed onFIRST_ADMIN_EMAIL: when the user already exists, nothing is written, except that a missing tenant schema is recreated when the org's audit log shows it never had tenant data. When it did, the seed leaves the schema missing (possible data loss, as inmigrate-all-tenants) and still succeeds. Either way it emits thetenant_schema_missingevent on stderr. - One
is_defaultLLM config — only whenFIRST_LLM_PROVIDER+FIRST_LLM_MODELare set. The API key is AES-256-GCM-encrypted under the org DEK before it is stored; the plaintext never touches a column and is never echoed back. Skipped when the org already has a default config. - One
dqa_agent token — freshly generated (random, never a fixed literal), stored only as a SHA-256 hash. The plaintext is printed to stdout exactly once (all status lines go to stderr, sodqc seed > agent-token.txtcaptures just the token). It is not recoverable afterwards — revoke the token and re-run to mint a new one.
Environment variables:
| Variable | Required | Meaning |
|---|---|---|
FIRST_ORG_NAME | yes | Organisation name. |
FIRST_ADMIN_EMAIL | yes | Admin login email (the idempotency key). |
FIRST_ADMIN_PASSWORD | yes | Admin password, 12+ chars. Rotate after first login. |
FIRST_ORG_SLUG | no | Slug override; derived from the name when empty. |
FIRST_LLM_PROVIDER | no | openai / anthropic / azure_openai / ollama / custom. Set together with FIRST_LLM_MODEL. |
FIRST_LLM_MODEL | no | Model id for the default LLM config. |
FIRST_LLM_API_KEY | no | Provider API key; encrypted under the org DEK at rest. Omit for Ollama. |
FIRST_LLM_BASE_URL | no | Base URL for ollama / azure_openai / custom. |
FIRST_AGENT_TOKEN_NAME | no | Agent-token name (default seed-agent); also the re-run idempotency key. |
The same FIRST_ORG_NAME / FIRST_ADMIN_EMAIL / FIRST_ADMIN_PASSWORD / FIRST_ORG_SLUG vars drive the compose one-shot scripts/seed_first_admin.py init container, which shares the same implementation (org + admin only — no LLM config, no agent token).
dqc find-broken-row-conditions
Options: [--org ORG_ID] [--json]. Read-only. Lists stored rules whose row_condition Great Expectations can't run as written. The rule editor used to suggest SQL like status = 'shipped', and the API accepted it, but GX only understands its own syntax (col("status") == "shipped"). So those rules were saved and then skipped on every run. See Row conditions.
It scans every tenant's rules and rule templates, or only one org with --org. Each tenant is read inside a READ ONLY transaction that is always rolled back, so nothing is changed. For each finding it prints:
- the org, the rule or template, and the rule's position (
rules[3]; alwaysrules[0]for a rule) - the condition and why GX won't run it as written
- what the rule did at run time: it never ran; it ran only the first comparison (an
AND/ORtail is silently ignored); or it compared against a different value than the one written - for simple SQL-style conditions, the GX-syntax rewrite to save instead
dqc find-broken-row-conditions # every org, text report
dqc find-broken-row-conditions --org <ORG_ID> # one org
dqc find-broken-row-conditions --json > rc.jsonl # one JSON object per finding
To fix a finding, open the rule in the editor (the asset's Rules tab, or the template), which now flags the condition inline, and save the corrected text. Exit code is 0 whether or not anything was found. It's 1 only if a tenant couldn't be read.
dqc v2-reconcile
Options: [--org ORG_ID] [--json] [--limit N]. Read-only. Lists what needs a person after the upgrade that made every rule part of a validation (tenant migrations 057 and 058; see Asset defaults). Each tenant is read inside a READ ONLY transaction that is always rolled back. Per org it prints one line per section with a count (a ! marks a section that needs action), then up to --limit rows (default 200):
explicit_pins: rules added to a validation one by one. They don't run today and will start running in a coming release, so review them first.explicit_pins_in_defaults: a pin of another asset's rule inside an asset's default validation.disabled_rules: rules the upgrade disabled because they weren't in their asset's rule list. They don't run; re-enable or delete them.null_asset_suites_with_rules: legacy rule suites with no data asset, whose rules can't belong to an asset.assets_without_default,rules_missing_default_row,listed_rules_missing_asset_list_row: membership drift. The next rule change on the asset repairs it.default_workspace_mismatch: a default validation whose asset has moved to another workspace.bridged_validations_not_adopted: single-asset validations (from old checkpoints) that didn't become the asset's default, because they were limited to a batch, were in another workspace, belonged to a team, had a name other than the asset's, or weren't the first candidate. They still work as ordinary validations and stay fully editable. No action is needed; merge or delete them if the default now covers them.
dqc v2-reconcile # every org, text report
dqc v2-reconcile --org <ORG_ID> # one org
dqc v2-reconcile --json > v2.jsonl # one JSON object per org
Exit code is 0 unless a tenant couldn't be read (1).
Planned subcommands
dqc seed-dev
Status: planned (HARDEN-5).
Wraps python -m scripts.seed_dev for parity with the other operator subcommands. Until it ships, use python -m scripts.seed_dev directly.
How dqc is installed
The binary is declared in api/pyproject.toml via [project.scripts]. After uv sync or pip install -e '.[api]' it sits on $PATH. From a checked-out source tree without an install, run python -m dq_cloud_api.cli <subcommand>.
Exit codes
0— success.2— invalid argument (e.g. non-UUID passed toprovision-tenant, an unknown role passed toset-role, or an unresolvable email / ambiguous multi-org user).- Anything non-zero from inner Python exceptions — the traceback is printed; check the stack.