explore
Use this whenever you need to know what is actually in a database, warehouse, or DuckDB file before you trust it: ranked inventory of what exists, column profiles, PII detection, grain and data-quality problems, verified join inference, Mermaid ER diagrams, guarded ad-hoc SQL pro
Install
npx skills add https://github.com/exmergo/dex/tree/main/skills/explore
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install exmergo-dex@llmmart
git clone https://github.com/exmergo/dex.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole exmergo/dex collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Explore
Make sense of a warehouse or a local DuckDB database the way an analytics engineer does: rank what matters, drill selectively, and persist a draft map. This is the flagship, fully read-only skill. It absorbs profiling and relationship inference as capabilities; they are not separate skills.
How to drive it
Run the engine through the wrapper. It prints one sanitized JSON envelope and nothing else; read the envelope and decide the next step.
uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags]
dex runs its engine through uv, which is a prerequisite and is not installed by
Claude Code. If the shell reports uv: command not found, stop and tell the user
to install it (curl -LsSf https://astral.sh/uv/install.sh | sh, or
brew install uv, or pipx install uv), then re-run. Never fall back to raw
Python, pip, or a database CLI to do the work another way: the guardrails live in
the engine, so any other path is unguarded.
The first command in a fresh environment installs the engine, so it can take tens
of seconds where later ones take well under a second. --warm pays that install up
front and exits without running anything:
uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm
Offer it once at setup. It is not something to run before an ordinary command.
If the user has no warehouse to point at and wants to see what dex does, demo
generates one: a seeded local DuckDB warehouse plus the .dex/config.yml for it,
with no credentials and no network, so every subcommand below then runs with no
flags. It only ever creates, so it refuses rather than touch a file that already
exists. Offer it rather than assuming it: a user who does have a warehouse wants
that one read, not a fixture built beside it.
Subcommands, in the usual order:
connect test --path <file.duckdb>confirms a read-only connection and reports capabilities.explore inventory --rankreturns a ranked object summary (counts and sizes, never rows).explore profile <objects>(space- or comma-separated) returns column profiles, PII flags recorded as (column, category, confidence) and never example values, plus ranked candidate keys, the likely grain,key_evidence, and data-quality warnings (e.g. an id unique on all but 110 rows, which will fan out on joins).candidate_keysis ordered, tightest proven key first, andkey_evidencegives one entry per combination considered with itsstatus(reportedorsuppressed) and the reason. Read it before you trust a composite: a combination unique only because one member is unique on almost every row, or because a money column completes it, is suppressed rather than reported. Where a near-unique column is the real story the warning says so with the ratio, the counts, and how many rows would have to be removed for it to be unique. That last number is the one to act on: it names a source defect to fix rather than a key to work around. A generic*_nameflag's confidence is refined by value-shape evidence from the same scan, in both directions: person-shaped values corroborate it, a closed reference vocabulary or long labels de-rate it below the firewall's blocking threshold, and missing evidence changes nothing (the flag itself is never removed). Distinct counts are approximate for scale, but any column that looks unique within approximation noise is escalated to an exact COUNT(DISTINCT) (distinct_count_exact: true), so uniqueness and grain verdicts rest on proof; a~prefix marks a number that is still approximate, on a count and on a percentage alike, so a figure quoted without one is exact arithmetic over an exact distinct count on a column with no nulls. A requested object whose cached profile is still fresh (same connector, schema unchanged, withinprofile_freshness_hours, default 24) is served from the cache (cache_hit_count) instead of re-scanned, so profiling a tablemapjust wrote costs nothing to spend; pass--refreshto force a re-scan when the source changed in a way the free metadata check cannot see.explore relationshipsreturns inferred and declared joins with confidences, plus notes explaining what the inference examined (so an empty list is meaningful). Add--verifyto measure each inferred join with an aggregate overlap probe (orphan fraction, confidence adjusted). A declared join has two sources: arelationshipstest, and (with--use-project) an entity two semantic models share, which the layer states outright with the key named per model.declared_byon an edge names that entity,semantic_join_countsays how many came that way, and the notes call out the ones name-based inference did not find, which is the interesting set: a semantic layer routinely joins columns that share no name at all.explore mapwrites or updates the.dex/cache and returns the map (--verifyworks here too). Alongside the counts,data.objectsgives each top-ranked object its row count, detected grain, best-ranked candidate key, notable columns (each carrying the role that earned it a place:grain,key,join, or a PII flag) and data-quality findings, anddata.edgesgives the join edges in the same shapeexplore relationshipsreturns. With--use-projecteach object also carriessemantic_models, the semantic models that sit on that relation, which is what separates a load-bearing table from a merely large one: empty means nothing in the layer reads it. Read that payload instead of chainingprofileandrelationshipsto re-derive it; go to those two when you need one object in full, or a value domain, whichmapnever carries. It is budgeted: 25 objects by rank, 12 columns per object, 40 edges, 5 findings per object. Every cap binds in every mode and every elision is counted innotesand in anelided_*field, so an emptynotesmeans nothing was cut.--detailwidens the selection to every column and to objects that were inventoried but never profiled, and lifts no cap; it spends nothing, unlike--full. Past 50 objects it profiles only the top 25 by rank and says so innotes(withskipped_count); pass--fullto profile everything. On a re-map, objects skipped this run keep their prior profiles (carried_forward_count), each stamped with its ownprofiled_atso staleness is visible instead of column detail silently vanishing. A selected object whose cached profile is still fresh (same connector, schema unchanged, profiled withinprofile_freshness_hours, default 24) is reused without a re-scan (cache_hit_count), so re-runs cost nothing to spend; pass--refreshto force a full re-profile when the source changed in a way the free metadata check cannot see (e.g. rows changed but the schema did not).explore relationshipsand the standaloneexplore profilereuse fresh profiles the same way.explore diagram [--full]renders the cached map as a Mermaid ER diagram indata.mermaid. Free and connectionless (it reads the cache, never the warehouse), so it is safe to re-run while shaping the picture. Reproduce the string verbatim in a fenced ```mermaid block so the human can see it, and write it to a.mmdor a markdown file when they want one on disk: the engine deliberately writes no file. Never redraw or "tidy up" the diagram by hand. The glyphs are claims the engine derived from evidence, and a plausible-looking cardinality you supplied is exactly the overclaim this command exists to prevent: declared joins are solid, inferred dotted, and an unverified inference never says "exactly one". A solid line labelled with a semantic entity is a join the semantic layer declares; look the entity up withexplore semantic list. Readnotesbefore presenting it, since it states any object or column that was left out;--fullwidens from the default (profiled, joined objects and their grain, key, join, and PII columns) to everything eligible.explore query "<SELECT ...>" ["<SELECT ...>" ...]answers ad-hoc questions the fixed commands don't cover: you write the SQL, the engine's query firewall refuses or bounds it. Pass a statement per argument, or--sql-file <path>for a longer list, and ask a whole chain of questions in one call rather than one call each; each statement is judged and answered on its own, so a refusal on one does not cost you the others, anddata.resultscarries one entry per statement. A table you have not profiled, including a model you just built, is profiled for you and the statement then runs, so probing something new is one call rather than three; the envelope says what it profiled, and on a metered connector that profile is priced into the same confirmation as the statements. Results come back row-major and capped; a refusal names the offending column and the fix, so one rewrite is enough. Read${CLAUDE_SKILL_DIR}/references/probe-playbook.mdbefore writing a probe: it maps common questions to effective probe shapes.explore cluster <object> [--features a,b,c] [-k N]runs k-means over a bounded sample of the object's numeric columns and returns the segment structure: per-cluster sizes and fractions, centroids (each coordinate is a cluster's mean of that feature, an aggregate), the silhouette score, and, when-kis omitted, the k it picked plus the silhouette sweep it chose from. Requires the.dex/cache (runmap/profilefirst) so features can be auto-selected from profiled numeric, non-PII, non-key columns; pass--featuresto choose them yourself (naming a PII column, or a key, opts it in deliberately, and only its mean is ever reported). A key is never a feature: its mean is meaningless, and a fact table is mostly keys plus a handful of measures, so clustering on them just partitions surrogate ranges. Keys are the unique columns, the columns that join out (from the joinsmapinferred), and the columns named like one; prefermapover a bareprofilehere, because without inferred joins a foreign key is caught only if its name gives it away. The notes name every excluded column, so check them before trusting a result. Two things the silhouette alone will not tell you, both of which the notes will. A cluster holding under 1% of the sample is an outlier pocket, not a segment, and it pushes the score up precisely because it sits so far out: report that as outlier detection, or re-run with-kto split the bulk. And on connectors that cannot seed a sample the draw changes per run, so two runs can disagree on k; the envelope'ssample_repeatablesays which case you are in, and comparing runs across different draws is meaningless. Only aggregates cross the boundary: the sample rows are clustered in-process and never enter context. On a metered connector it takes the same cost handshake as the scanning commands below (only the feature columns are scanned, and a dialect-aware sample clause reads a fraction), so surface the estimate and get a budget first. Needs the[cluster]extra (scikit-learn); the wrapper installs it automatically for this subcommand.explore semantic list|values|queryreach the semantic layer: the metrics an author defined, and the semantic models, measures, dimensions and entities they are built out of. Distinct from the warehouse commands above, and from the top-levelsemanticgroup, which authors the layer where this queries it.listis discovery and returns the layer's objects rather than three lists of names: semantic models (the unit the layer is organized around, each with the transformation model it sits on, its default time dimension, and the physicalrelationunderneath), metrics (which dimensions each can be grouped by, the measures it reads, a ratio's two sides, any filter that makes it a subset, the grains it can be queried at, andtime_axis, the physical time column a time grouping resolves to), dimensions (the token to group by, plus the bare definition, owning model, queryable grains andcolumnbehind it), entities (one declaration per semantic model, each with its own join key, so the declared join graph is readable), and measures (the aggregation and expression the number is actually made of, which is often a conditional rather than a column). An element defined as an expression carries no column rather than a guessed one. So "which table is behind this metric" is the metric'ssemantic_modelsfollowed to their relations, andexplore profile <relation>is the next call;--apiexposes no relation at all and declares that inunavailable, so use--localwhen you need the physical side.Three free ways to narrow it, and they compose.
--metric <m>keeps those metrics and what they reach.--for-dimension <d>asks the reverse question, returning the metrics groupable by all the named tokens, which is what you want when you know the slice rather than the metric and is also the cheapest way to find the metrics that can go on one chart against one axis.--search <t>takes a word rather than a name and matches it against every element's name and against the project's own label and description. Each names its scope in the payload (scoped_to,for_dimensions,searched_for), so a subset is never mistaken for the layer; an unknown metric or dimension is refused by name, while a search term that matched nothing comes back as a note. The catalog is also capped, with every cut counted inelidedand named innotesand--fullto lift the caps.elidedis always present, so all zeros and no cap notes is the positive statement that this is the whole layer. Prefer narrowing over--full: it decides which part comes back rather than letting a cap decide.values <dimension>returns that dimension's value domain, which is what you need before writing a--wherefilter and the one thing no other dex command can reach on a hosted layer (profilecannot see a semantic dimension). A PII-flagged dimension refuses this command outright rather than being screened, because the whole output is values.querytakes a positional metric after the explicit mode (with--metrickept for compatibility), a--group-by <entity__dim>, and optional--where,--order-by,--grainand--limit, and returns the metric's values as a capped columnar result. Name flags take a comma-separated list or a repeated flag (--group-by a,bis--group-by a --group-by b);--whereis never split, because a filter clause carries its own commas.--grainis checked against the grains the layer reports for the metrics queried, so a refusal names the ones that metric has.Two payload fields carry legitimate differences between the backends rather than leaving them to be inferred:
dimension_scopesays whether a dimension row is one declaration or one groupable path, which is why two backends can report different dimension counts for one layer, andunavailablenames fields a backend structurally cannot supply.--localresolves the join graph through MetricFlow where the[semantic]extra is installed, which is what makes its dimension lists the tokens a query can actually use; without it the payload saysdeclarationsand a note names the extra.Three backends answer these commands, chosen by
.dex/config.ymlsemantic.vendorandsemantic.deployment(the oldersemantic.backendspelling still works), overridable with--local/--api. Those two flags name who executes, not which vendor, and every result reports it asexecution(dexorvendor).--localrenders the SQL with MetricFlow and executes it through dex's own connector and cost handshake, so cost is surfaced before spend (needs a dbt project parsed at least once, and the[semantic]extra forvaluesandquery;listreads the project and needs no extra).--apisends the query to a hosted dbt Cloud deployment (needs a host, an environment id and aDBT_SL_TOKEN, plus[semantic-api], and no local project). The hosted backend is the one place the cost guard cannot apply: dbt Cloud executes server-side, so the result carries an explicit warning that spend is governed there and no--confirmis asked. Either way a PII-shaped grouped or filtered dimension (for exampleuser__email) is refused before the query runs, and on--apithe layer's own PII metadata is fetched per metric so a multi-metric query stays authoritative rather than falling back to names.The third backend is
semantic.vendor: ossie, native Apache Ossie documents read out of the repository with no dbt project and no MetricFlow in the path (needs the[ossie]extra). It is catalog-first:listanswers, andvalues,queryand--for-dimensionrefuse by name, because Ossie specifies interchange metadata and no portable query runtime. Those refusals are the format's shape rather than a missing feature, and each one names the physical route instead: a dimension carries itssemantic_model, that model carries itsrelation, andexplore profilethenexplore queryreach the values under the firewall and the cost guard.--apiis refused too; Ossie has no hosted deployment.Read
${CLAUDE_SKILL_DIR}/references/semantic-playbook.mdbefore running a metric query: a metric'stime_axis,filterand measures decide what the number is, and the playbook covers the discovery order, the additivity and time-axis traps this surface is full of, whenvaluesanswers rather than a query, and what changes when the layer is native Ossie.
Rules of engagement for query: prefer the fixed commands when they answer the
question; one probe answers one question; batch related measures into a single
query rather than issuing many; aggregates over PII-flagged columns must be
measuring (COUNT, APPROX_COUNT_DISTINCT, AVG(LENGTH(...))), never value-carrying
(MIN, ANY_VALUE, STRING_AGG). The FROM clause may unnest JSON and array
columns in the connector's native idiom, which is the right way to explore
schemaless data (for example "which keys appear across every row of this JSON
column"): BigQuery t, UNNEST(JSON_KEYS(doc)) AS k, Snowflake
t, LATERAL FLATTEN(input => doc) f, Databricks
t LATERAL VIEW EXPLODE(json_object_keys(doc)) x AS k, Postgres
t, jsonb_object_keys(doc) AS k, Redshift t, UNPIVOT t.doc AS v AT k,
DuckDB t, UNNEST(json_keys(doc)) AS u(k), ClickHouse
t ARRAY JOIN JSONExtractKeysAndValuesRaw(doc) AS kv (there is no lateral
join; ARRAY JOIN is the expansion). The unnested value must come from
a column of a table in the query (bare, or through a JSON/array function);
unnesting a subquery, another table, a literal, or a generator is refused,
and the unnest's outputs inherit the source column's PII flags. A column whose
flag was de-rated below the blocking threshold projects normally, with an
envelope warning naming it; treat the warning as information for the user, not
an error to fix. If the user says a refused column is not personal data,
recommend a pii_overrides entry in .dex/config.yml (fully qualified column,
optional reason): it unblocks querying immediately, survives re-profiles, and is
reviewable in git. Never hand-edit .dex/cache.json to clear a flag. Never fall
back to raw Python or a database CLI to run SQL; the firewall path is the only
sanctioned one.
Cloud and database targets (BigQuery, Snowflake, Databricks, Postgres, Redshift, ClickHouse)
A remote warehouse or database replaces --path with connector config. Start
with connect test --connector <name> (or set connector: plus the matching
block in .dex/config.yml: bigquery: with project and a datasets
allowlist, snowflake: with the pinned warehouse and a databases
allowlist, databricks: with the pinned SQL warehouse and a catalogs
allowlist, postgres: with a schemas allowlist, redshift: with the
Serverless workgroup and a schemas allowlist). Credentials are
discovered, never asked for: if the envelope reports missing or expired
credentials, relay the fix it names (for BigQuery
gcloud auth application-default login; for Snowflake a connections.toml
entry or SNOWFLAKE_* env; for Databricks databricks auth login or
DATABRICKS_* env; for Postgres DATABASE_URL, PG* env, or a
pg_service.conf entry; for Redshift the AWS credential chain
(aws configure, AWS_* env) or REDSHIFT_* env) and never ask the user to
paste a key, token, or password.
On a metered connector, scanning commands (profile, map, relationships,
query) run a two-step handshake. The first call returns needs_confirmation
with an estimate in cost.estimate, a per-table breakdown where relevant, and
the unit it is counted in: bytes on BigQuery, warehouse-seconds on Snowflake
(credits alongside) and Databricks (DBUs), compute-seconds on Redshift
(RPU-hours), database-seconds on Postgres and ClickHouse (no dollars; the
guarded quantity is load). Surface the estimate to the user in human units, get
an explicit budget from them, and re-issue the same command with --confirm and
--budget <magnitude> in that unit. Never invent a budget the user did not
agree to, and never retry with a raised budget on an over-ceiling refusal
without asking. Metadata is free (connect test, inventory run immediately),
and OK envelopes report actual spend under data.spend.
An over-ceiling refusal now carries a calibration line drawn from
.dex/spend.jsonl: what this connector's last few settled commands actually
billed as a fraction of what they were estimated at, or a sentence saying the
project has too little history to say. On a partitioned or clustered warehouse a
dry-run estimate is an upper bound, so this is often the difference between a
budget that admits the work and one that does not. Relay it verbatim when you
surface the refusal, and note the part callers get wrong: the ceiling is checked
against the estimate, so a budget set at the observed fraction of the estimate
is refused again. It is still the user's decision, never yours.
When a needs_confirmation envelope carries suggested_session_ceiling, the
project has never decided whether the day's total spend is bounded, and this is
the one time it is asked. Surface it beside the per-command estimate and get the
user's answer: --session-ceiling <value> sets a cumulative cap for the project
(the suggestion is five times this command's estimate, a starting point, not a
recommendation), and --no-session-ceiling records that the project runs
unbounded. Either one is written to .dex/config.yml and reported as a diff, and
nothing asks again. Add it to the same re-issue that carries --confirm --budget, or the confirmed run will stop once to ask. Never answer it on the
user's behalf: it is a durable project setting, not a per-command flag.
On BigQuery a profiling estimate holds a 10 MB floor per table for each
escalation query a profile may still issue after its aggregate scan, so on a
warehouse of many small tables most of the number can be reserve for work that
never happens. Both the handshake and the over-ceiling refusal report that split
(reserved_bytes and reserved_queries, and in the prose). Pass it on when you
surface the estimate: whether a number is scan or reserve changes whether
raising the budget is buying work or headroom.
When an estimate is larger than the work deserves, narrow the scope rather than
raise the budget. --scope (repeatable) bounds a command to part of the
configured source allowlist, in the connector's own vocabulary: a dataset on
BigQuery, a schema or database.schema on Snowflake, a catalog.schema on
Databricks, a schema on Postgres or Redshift, a database on ClickHouse (whose
identifiers are two-part database.table: there is no catalog level). It is
free to resolve, it can only narrow what
.dex/config.yml already allows, and a scope that names nothing is refused with
the schemas that do exist listed. So explore map --scope <schema> is the first
thing to reach for on a warehouse whose full map would be expensive.
Guardrails (enforced in the engine, not here)
- Read-only against data. The connection is opened read-only and generated SQL is SELECT-only. Never propose a write to source data.
- Sense-making, not enumeration. Rank and drill selectively; never paste a full schema into context.
- Profile, don't exfiltrate. Understanding comes from aggregates. PII is flagged,
never surfaced, and the query firewall enforces it on your own SQL: values
cross the envelope only from profiled columns whose flag is absent or below
the blocking threshold, bounded and capped. Only a human's
pii_overridesentry clears a flag entirely; never suggest weakening the detection. - The two policies in full, in the engine repository:
references/pii-policy.mdandreferences/cost-controls.md.
Files (dex)
-
evals
-
evals.json 8.8 KB
{ "skill_name": "explore", "triggering": { "positive": [ "What's in this warehouse?", "What's in my duckdb?", "What's in this database?", "What data do I have here?", "Take a look at data.duckdb and tell me what's there.", "Do we have any PII in this database?", "Is this data any good?", "Which tables actually matter here?", "What does the customers table contain?", "How do orders and customers join?", "Profile the columns in the events table and flag any PII.", "How many listings have no matching host in this database?", "Run a quick query to check whether order dates have any gaps.", "Draw me an ER diagram of this database.", "Can you visualize how these tables relate?", "Build a staging model for the orders table.", "Build a mart that scores each customer for churn risk. Source data: int_sales__orders_enriched. Explore to understand available columns.", "Fix the rpt_customer_metrics.sql model. It produces inf and NaN values in calculated columns and does not exclude NULL customer_id. The model reads from int_sales__orders_enriched.", "Create models/marts/customer_cohorts.sql showing monthly retention by acquisition cohort. Use the orders and customers source tables.", "The finance team says fct_daily_revenue is double counting refunds since the SAP migration. Find the cause and fix it.", "What metrics does this repo's semantic layer define, and what tables are behind them?" ], "negative": [ "Set up a dbt project in this repo.", "Refactor this dbt model and add tests.", "Define a revenue metric on top of fct_orders.", "What changed in the warehouse and did my dbt project drift?", "Build models/marts/basket_composition.sql. Source: ORDERS (order_id VARCHAR, customer_id VARCHAR, ordered_at TIMESTAMP, grand_total DECIMAL, status VARCHAR) and ORDER_LINES (line_id VARCHAR, order_id VARCHAR, sku VARCHAR, qty INTEGER, unit_price DECIMAL). Group baskets by size band and report average margin per band.", "Rename the column cust_id to customer_id across models/staging/sap/, about 30 files, and keep the yml docs in sync.", "The CI job fails on dbt parse after I merged main. Jinja compilation error in macros/cents_to_dollars.sql, undefined variable. Fix the macro.", "Bump dbt-core to 1.10 in requirements.txt and fix the deprecation warnings in dbt_project.yml.", "Add a revenue metric to my Ossie semantic model." ] }, "evals": [ { "id": 0, "prompt": "I just connected a DuckDB file with a few dozen tables. Help me understand what's in it and which tables are worth my attention.", "expected_output": "A ranked landscape from inventory and a draft .dex/ map, reported as counts plus the map's own budgeted findings, without dumping the full schema into context.", "files": [], "assertions": [ "Drives the engine via the wrapper (explore inventory --rank and/or explore map) and reads the JSON envelope; does not invent results", "Reports a ranked shortlist of objects, not all of them; no per-column listing appears for the full warehouse (sense-making, not enumeration)", "When map is run, it relays what the envelope returned: the counts (object_count, profiled_count, relationship_count, pii_column_count) and the budgeted findings the payload carries, without pasting the cache contents or re-listing every column", "No raw rows and no credentials appear anywhere in the output (clean envelope)" ] }, { "id": 1, "prompt": "Profile the customers table and tell me about data quality and any sensitive columns.", "expected_output": "Column profiles built from aggregates plus PII flags; min/max suppressed on string and PII columns; no example values surfaced.", "files": [], "assertions": [ "Profiles are aggregate-derived (null_fraction, distinct_count, is_unique), never raw rows", "PII is reported as (column, category, confidence) with no example value; the email column is flagged", "min_value and max_value are absent (null) for string and PII columns, and present only for safe numeric or temporal columns", "Stated as read-only against the data" ] }, { "id": 2, "prompt": "How do the tables in this warehouse relate? Show me the joins.", "expected_output": "Inferred joins (and declared ones if a dbt project is present), each labeled by kind with a confidence, derived from metadata rather than by scanning data.", "files": [], "assertions": [ "Relationships are returned with from/to datasets and columns and a kind of inferred or declared", "Inferred joins carry a confidence and are clearly distinguished from declared (dbt) joins", "No raw rows are read to establish the joins; the result is metadata-derived (or verified with --verify aggregate probes)", "The output is a relationship summary, not a full schema dump" ] }, { "id": 3, "prompt": "How many orders point at a customer that doesn't exist, and what are the most common order statuses?", "expected_output": "Two answers derived through guarded probes: an orphan count from an overlap-shaped query and a top-K status distribution, both via `explore query` after mapping, never via raw Python or a database CLI.", "files": [], "assertions": [ "Runs `explore map` (or confirms the .dex cache exists) before probing, because the query firewall requires the cache", "Answers through `explore query` via the wrapper; does not open the database with ad-hoc Python, duckdb CLI, or any path around the engine", "Probes are aggregate-shaped (counts, GROUP BY on a non-PII column); no SELECT * and no value-carrying aggregate over a PII-flagged column", "If a query is refused by the firewall, the agent rewrites it per the refusal message instead of retrying the same shape or routing around the engine", "Reports the counts and distribution from the envelope's columnar result; respects and mentions truncation notes if present" ] }, { "id": 4, "prompt": "List the region names in this warehouse. The region table is a 5-row lookup dimension.", "expected_output": "After profiling, the region name column's generic flag is de-rated below the firewall threshold by value-shape evidence, so the projection runs; the envelope's PII warning is passed on to the user as a caveat, not treated as an error.", "files": [], "assertions": [ "Profiles the table (or confirms the cache) before querying, so the flag carries shape-refined confidence", "Projects the column through `explore query` and reports the values with the envelope's PII warning relayed as a caveat", "Does not treat the warning as a failure, does not weaken or remove any flag, and does not route around the engine" ] }, { "id": 5, "prompt": "Your query on products.name got refused as PII, but that column is just product titles, not personal data. Fix it properly.", "expected_output": "A pii_overrides entry in .dex/config.yml naming the fully qualified column (with a reason), presented for the user's review; never a cache edit or a detector change.", "files": [], "assertions": [ "Recommends or writes a pii_overrides entry in .dex/config.yml with the fully qualified column path and a reason", "Does not edit .dex/cache.json, does not propose changing the detector or the firewall threshold", "Explains that the override survives re-profiles and takes effect at query time immediately" ] }, { "id": 6, "prompt": "Give me a diagram of how the tables in shop.duckdb relate to each other.", "expected_output": "A Mermaid ER diagram produced by `explore diagram` after mapping, reproduced verbatim in a fenced mermaid block, with the engine's own line styles and cardinalities left intact and any elision from the notes mentioned.", "files": [], "assertions": [ "Runs `explore map` (or confirms the .dex cache exists) before `explore diagram`, because the diagram renders the cache", "Produces the diagram through `explore diagram` via the wrapper; does not hand-author Mermaid from the relationships envelope or from its own reading of the schema", "Reproduces `data.mermaid` verbatim inside a fenced mermaid block; does not redraw, re-letter, or 'tidy' the glyphs, and does not upgrade an inferred or unverified edge to a solid or exactly-one cardinality", "Reports what the notes say was left out (elided objects or columns) rather than presenting a capped diagram as complete", "Writes a .mmd or markdown file only if the user asked for a file, and does not claim dex wrote one" ] } ] }
-
-
references
-
probe-playbook.md 7.9 KB
# Probe playbook: effective shapes for `explore query` A probe is one agent-authored SELECT run through the engine's query firewall. The firewall guarantees safety; this playbook is about effectiveness: asking the question in a shape that returns a small, decisive answer instead of a wall of rows. Map first (`explore map`) when you are getting your bearings: the profile usually already holds the answer (null fractions, distinct counts, min/max, ranked candidate keys and the reason behind each), and probes exist for the questions it does not. But you do not have to map before you can probe. A table the engine has not profiled, including a model you built moments ago, is profiled as part of answering, so a probe against something new costs one call. Three habits pay for everything else: - **One probe, one question.** Decide what you are testing before writing SQL, and name the output columns after the answer (`orphans`, `dupes`, `coverage`). - **Batch related measures into one SELECT.** Eight aggregates over the same FROM clause are one scan; eight separate probes are eight, and on a metered warehouse you pay for each. Combine counts that share a FROM clause. - **Send unrelated probes in one call.** Questions that do not share a FROM clause cannot share a SELECT, but they can share a call: pass a statement per argument, or `--sql-file <path>` for a longer list. That saves the call, not the scan, which is why it is the second choice and not the first. Each statement is firewalled and answered on its own, `data.results` holds one entry per statement, and a refusal on one leaves the rest intact. The result-size cap is the call's rather than each statement's, so keep a batch aggregated: ten statements share the budget one statement would have had to itself. Firewall rules that shape your SQL: values may be projected only from profiled, PII-cleared columns; over a flagged column use a measuring aggregate (COUNT, COUNT(DISTINCT ...), COUNTIF/COUNT_IF, APPROX_COUNT_DISTINCT, AVG, SUM, STDDEV), never a value-carrying one (MIN, MAX, ANY_VALUE, STRING_AGG, ARRAY_AGG). Filters and join conditions may reference anything. Results are capped (rows, cell width, bytes), and every cut is announced in `notes`, so aggregate first rather than paging. ## The recipes **1. Join-key overlap.** Does `child.fk` really point at `parent.key`? (Or run `explore relationships --verify`, which is this probe productized.) ```sql SELECT COUNT(c.fk) AS nonnull_fk, COUNT(DISTINCT c.fk) AS distinct_fk, COUNT(*) FILTER (WHERE c.fk IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM parent p WHERE p.key = c.fk)) AS orphans FROM child c ``` Zero orphans confirms the join; a high orphan fraction says the name-based guess was wrong or the parent is incomplete. `--verify` applies the second habit above to this recipe for you: the joins that share a child are measured in one statement, so it costs what the relations cost rather than what the join count costs. **2. Duplicate distribution.** How badly a key is broken is already in the profile: it reports the distinct count, the row count, and how many rows would have to be removed for the column to be unique, exactly, whenever the distinct count was escalated and the column has no nulls. Probe when you need the *shape* of the duplication rather than its size. ```sql SELECT COUNT(*) AS rows, COUNT(DISTINCT id) AS distinct_ids, COUNT(*) - COUNT(DISTINCT id) AS surplus_rows, MAX(cnt) AS worst_repeat FROM (SELECT id, COUNT(*) AS cnt FROM t GROUP BY id) ``` `worst_repeat` is the column the profile cannot give you, and it is the one that separates a double-loaded batch (every repeat is 2) from a single id that swallowed the table. **3. Top-K categorical distribution.** What values dominate a (non-flagged) column, and how concentrated is it? ```sql SELECT status, COUNT(*) AS n FROM orders GROUP BY 1 ORDER BY 2 DESC LIMIT 10 ``` For a PII-flagged column, take the measuring route instead: `COUNT(DISTINCT x)` tells you the cardinality story without surfacing a value. **4. Null / blank breakdown.** Nulls are profiled already; blanks and sentinels are not. ```sql SELECT COUNT(*) AS rows, COUNT(*) FILTER (WHERE TRIM(col) = '') AS blank, COUNT(*) FILTER (WHERE col IN ('N/A', 'none')) AS sentinel FROM t ``` **5. Date coverage.** Is the table continuous, and where does it end? ```sql SELECT MIN(created_at) AS first_day, MAX(created_at) AS last_day, COUNT(DISTINCT CAST(created_at AS DATE)) AS days_present, DATEDIFF('day', MIN(created_at), MAX(created_at)) + 1 AS days_span FROM t ``` `days_present` well below `days_span` means gaps; probe the suspect range with a bucketed count (recipe 7). **6. Orphan / coverage rate between layers.** What fraction of entity A ever appears in fact B? ```sql SELECT COUNT(*) AS customers, COUNT(*) FILTER (WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id)) AS with_orders FROM customers c ``` **7. Distribution sketch via buckets.** The shape of a numeric column without pulling rows. ```sql SELECT WIDTH_BUCKET(amount, 0, 1000, 10) AS bucket, COUNT(*) AS n, AVG(amount) AS bucket_avg FROM payments GROUP BY 1 ORDER BY 1 ``` **8. Sensitive-column shape check.** Everything useful about a flagged column that can cross the envelope: ```sql SELECT COUNT(email) AS present, COUNT(DISTINCT email) AS distinct_vals, AVG(LENGTH(email)) AS avg_len, COUNT(*) FILTER (WHERE email NOT LIKE '%@%') AS shape_violations FROM users ``` Recipes 4, 6, and 8 above use `FILTER (WHERE ...)`, which is not available on BigQuery BigQuery's engine will reject that clause outright, and the firewall will not catch it first (it parses fine). On BigQuery, spell the same batched filtered count as `COUNTIF(cond)` instead, e.g. recipe 4's blank/sentinel breakdown becomes: ```sql SELECT COUNT(*) AS rows, COUNTIF(TRIM(col) = '') AS blank, COUNTIF(col IN ('N/A', 'none')) AS sentinel FROM t ``` `COUNTIF(cond)` is equivalent to `COUNT(*) FILTER (WHERE cond)` and passes the firewall the same way: the condition is a filter, not a projected value. ## When a probe is refused The refusal names the column, its PII category, and the fix. Rewrite once: swap the value-carrying expression for a measuring one, or drop the column from the projection. Do not retry the same shape and do not route around the engine with Python or a database CLI. A refusal naming a table the connection does not have is a real answer: check the name, or build the model into the target you are querying. It will not be fixed by profiling. Two newer paths the refusal may name: - A refusal on a column the user says is not personal data (a region label, a product line) is resolved with a `pii_overrides` entry in `.dex/config.yml` naming the fully qualified column, with an optional reason. It takes effect on the next query without re-profiling. Recommend the entry; never edit the cache. - If the refusal suggests re-profiling, the cache predates value-shape profiling: one `explore profile <table>` recomputes the flag's confidence with shape evidence and may clear the block on its own. ## When a probe runs with a PII warning A projection of a column whose flag sits below the blocking threshold runs and adds a warning to the envelope naming the column, category, and confidence. That is the designed behavior for de-rated reference columns (a region or nation dimension), not an error: pass the caveat on to the user alongside the result, and if they confirm the column is personal data after all, drop it from later probes. -
semantic-playbook.md 10.3 KB
# Semantic playbook: reading a metric before you trust its number `explore semantic` reaches a semantic layer: metrics, and the semantic models, measures, dimensions and entities they are built out of. A metric query is not a probe. There is no SQL to inspect, the layer decides what the number means, and the same metric grouped two ways can return two numbers that are both correct and not comparable. So the work is almost all in `list` and `values`, and `query` is the short last step. Three habits, in order of how much they save: - **Scope the catalog rather than reading the layer.** A whole layer's catalog is one payload and most of it is about something else. `list --metric <m>` if you know the metric, `list --for-dimension <d>` if you know the slice, or `list --search <word>` if you know neither. All three are free, none costs a round trip beyond the first, and each names its scope in the payload. - **Read the metric's caveats before its dimensions.** `time_axis`, `filter` and `input_measures` change what the number is. The dimension list only changes how it is cut. - **Get the value domain before writing a filter.** `values <dimension>` is the only way to know what a `--where` may filter to, and on a hosted layer it is the only dex command that can reach it at all. ## Discovery order 1. **`list`, scoped.** Start narrow. `--search` takes a word and matches it against every element's name and against the project's own label and description, so "revenue" finds the metrics an author wrote about revenue. `--for-dimension pricing_tier` answers "what can I slice by this", and is also the cheapest way to find the metrics that can go on one chart against one axis, because it returns the metrics groupable by **all** the tokens named. 2. **Read the caveats on the metric you picked** (next section). Stop here if they say the number is not the one you want; going back is cheaper than a wrong answer presented confidently. 3. **`values <dimension>`** for every dimension you plan to filter on. 4. **`query`**, once, with the group-by and filter you have now justified. Before concluding anything about the layer, read four payload fields: - `dimension_scope`. `queryable_paths` means every row is a token you can paste into `--group-by`. `declarations` means the list is the single-hop declared view and a query can group by more than it names, which is what `--local` reports without the `[semantic]` extra. Two backends reporting different dimension counts for one layer is this field, not a bug. A native Ossie layer always reports `declarations`, because the format states no join graph to resolve through. - `unavailable`. Fields the answering backend structurally cannot supply. An absent `label` here means "this path cannot carry one", not "the project declared none", and the difference decides whether looking elsewhere is worth it. `--api` has no `relation` on a semantic model at all, so use `--local` when you need the physical side. - `scoped_to`, `for_dimensions`, `searched_for`. Which narrowing produced this catalog. Present means you are holding a subset. - `elided`. What the payload cap cut, per element kind. All zeros and no cap notes means this is the whole layer. Non-zero means narrow the question rather than concluding the layer does not have something. ## Reading a metric **`time_axis` first.** A layer's time token (`metric_time`) is one name over many physical columns: it resolves to each measure's own aggregation time dimension. One entry is the ordinary case. **More than one entry means the metric's measures aggregate over different timestamps**, so grouping by `metric_time` buckets part of the number by one column and the rest by another, invisibly, in a result that looks like any other. Worse, one of those columns is often null on rows the other one has, and those rows are then dropped. If you see two entries, either group by a named time dimension instead, or say in your answer which parts of the number are bucketed how. **`filter` next.** A metric with a filter measures a subset. That is invisible in the result and it is usually the explanation for a number lower than expected. **`input_measures`, followed through to the measures.** A measure's `agg` and `expr` are what the number actually is, and a measure is often a conditional expression rather than a column: `sum(case when ... then 1 else 0 end)` counts something narrower than its name suggests. This is also where additivity comes from. A `sum` over a bare column is additive and can be totalled across any grouping; an `average`, a `median`, a `count_distinct` and any ratio are not, so the sum of the grouped rows is not the ungrouped total and you must not present it as one. **A ratio's two sides.** `composition.numerator` and `composition.denominator` name other metrics (or measures). Read both. A ratio is never additive. Two ratios that share a denominator can be compared and two that do not usually cannot. And if the two sides live in different semantic models, a group-by valid on one may not be valid on the other. **`queryable_granularities`.** The grains the layer will accept for this metric, which is per metric rather than a fixed ladder. `--grain` is validated against it, so a refusal here names what the metric does have. An **empty list on a dimension is an answer**: a categorical dimension has no grain, so do not ask for one. ## `values` versus a query `values <dimension>` returns one dimension's value domain. Reach for it whenever you are about to write a `--where`, and before telling a user what the categories are. Read `scoped_to` on the result, because it changes what the values mean. Empty means these are the domain of the column behind the dimension. A metric name means dex had to reach the dimension through that metric, because a dimension behind a join has no other rendering: neither layer will run a distinct-values query with no measure to join from. Those are then the values **present for that metric**, which can be narrower than the column's own domain, and the note names the other metrics that reach it. Pass `--metric` to choose one yourself. It never claims an exact cardinality, which would cost a second scan. A large domain comes back capped and `truncated`, and says so. Use a `query` instead when you want the distribution rather than the domain: group the metric by the dimension and read the sizes. That costs a query where `values` often does not. ## When a command is refused - **A PII-flagged dimension on `values` refuses the command outright**, rather than being screened out of a larger answer, because the whole output is values. The refusal names the two durable ways to clear a dimension reviewed as not personal data: a `pii_overrides` entry in `.dex/config.yml`, or `meta: {pii: false}` on the dimension in the project that declares it. Do not work around it by querying the same column another way. - **A PII-shaped `--group-by` or `--where` token refuses the query** before it runs. `user__email` is the standing example: the token's own shape is enough. - **A filtered query on a backend that cannot read its own filter dialect is refused** rather than run with the filter half unscreened. Move the condition into `--group-by`, or use a backend that reads its filters. - **An unknown metric or dimension name is refused by name.** A search term that matched nothing is not: it comes back as a note, because a substring matching nothing is an honest answer about the layer's words. - **A backend that declares a field unavailable refuses the command that needs it**, rather than answering from the empty value. `--for-dimension` on a layer whose backend lists `dimensions` under `unavailable.metrics` is the standing example. Read `unavailable` before concluding anything from an empty list. ## When the layer is native Apache Ossie `vendor: ossie` means the layer is native Ossie documents in the repository rather than a dbt project. `list` works and reads the same shape as anywhere else, and the payload says so on `vendor`. Three things differ, and each is a property of the format rather than a missing feature: - **`query` and `values` refuse.** Ossie specifies interchange metadata and not a portable query runtime, so there is no filter grammar and no join planning to render a governed statement from. Take the physical route instead: a dimension names its `semantic_model`, that model names its `relation`, and `explore profile` then `explore query` reach the values under the firewall and the cost guard. Do not present the refusal as dex being unable to reach the layer. - **`--for-dimension` refuses**, because Ossie states no metric-to-dimension relationship at all. `metrics[].dimensions` is empty and `unavailable.metrics` says why. An empty groupable list here is not "this metric can be grouped by nothing". - **A metric with no `semantic_models` is a metric whose lineage did not resolve**, not one that reads nothing. Ossie carries no metric-to-dataset reference, so dex reports only what a qualified reference in the expression proved, and reports nothing when none did. An element with no `column` is also normal here and the notes say which of four reasons applies: a computed expression, a quoted identifier, a query-backed dataset source, or a field written only in a non-SQL dialect. In every one of them dex declined to guess a column rather than failed to find one, so do not go looking for the column yourself and do not treat the absence as a data problem. ## Cost, and which backend answered Every result names `execution`. `dex` means dex rendered the statement and ran it through its own connector, so the cost handshake applied and spend was surfaced before it happened. `vendor` means the semantic layer ran it: dex never held a statement it could price or cap, so the result carries an explicit warning that spend is governed there. Do not present a hosted result as cost-guarded, and do not treat the absence of an estimate as "it was free". `list` and the catalog side cost no warehouse query on any backend: one GraphQL round trip hosted, one compiled-artifact read locally, one repository read for a native layer. `values` and `query` do spend on the two dbt backends. On a native Ossie layer neither runs at all, so the only spend on that path is the `explore profile` and `explore query` you reach for instead, and those carry their own estimate and confirmation.
-
-
scripts
-
run.py 16.1 KB
# /// script # requires-python = ">=3.11" # dependencies = [] # /// """Thin PEP 723 wrapper that drives dex-core via the command contract. The skill never re-implements logic. It forwards its arguments to the pinned `dex-core` engine and lets the engine print the sanitized JSON envelope. Run it with `uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <dex subcommand> ...`. `uv` is a hard prerequisite: it is what installs and runs the engine. When it is absent this wrapper refuses with an error envelope naming the install command, rather than letting the exec fail with a traceback. Invoked through `uv run` the shell fails first (`uv: command not found`), which is why each SKILL.md also tells the agent what that message means. Two execution modes, chosen automatically: - Monorepo checkout (this repo): `packages/dex-core` is found above the skill, so the engine runs from an editable local install. This is what makes the wrapper work before the package is published. - Installed plugin: no local package is present, so the pinned PyPI release is installed hermetically by uv. That environment is uv's own, and `--no-project` is what keeps it that way. Without it uv discovers whatever Python project the caller happens to be standing in, builds it, writes a `.venv/` and a `uv.lock` into their repo, and puts their dependencies on the engine's import path: dex would be leaving unreviewed artifacts in a repo it was asked only to read, and running against a closure it did not pin. `--warm` materializes that environment and exits without running a command, so the install can be paid once out of band instead of on the first caller's clock (see _warm). The engine version is pinned; the *extras* are chosen at runtime, so one published release serves every warehouse and the default install stays light. The connector extra comes from the active connector; two commands need more than a warehouse client and say so by being run (see _feature_extras). This wrapper is stdlib-only and runs before the engine is installed, so it resolves the connector itself (it cannot import the engine) with the same precedence the engine uses: an explicit --connector flag, then the top-level `connector:` in the `.dex/config.yml` found by walking up from the run directory to the git root (the way git and dbt find their project), then DuckDB. The walk-up must mirror the engine's: if it did not, a run from a subdirectory would install the DuckDB extra while the engine resolves the project's real connector and then fails for want of that connector's deps. The guess only picks which extra to install; the full argv is still forwarded, so the engine stays authoritative for the actual connection and a wrong guess surfaces as a clean error envelope. `DEX_CORE_VERSION` is the single line bumped at release time, by scripts/prepare_release.sh before the tag; nothing else here changes per release. """ from __future__ import annotations import argparse import json import os import shutil import subprocess import sys import time from pathlib import Path # Rewritten by scripts/prepare_release.sh to the tagged version. The connector # extra is deliberately NOT part of this pin: it is chosen at runtime (see # _resolve_connector), so a release artifact is connector-neutral. DEX_CORE_VERSION = "1.12.3" # Connector id -> packaging extra. The engine's connector ids and the pyproject # extras share names, so this is the identity set today. An unknown or unset # connector falls back to the light DuckDB on-ramp and lets the installed engine # emit the canonical error rather than the wrapper guessing wrong. _KNOWN_CONNECTORS = ( "duckdb", "snowflake", "bigquery", "databricks", "postgres", "redshift", "clickhouse", ) _DEFAULT_CONNECTOR = "duckdb" # The one flag the wrapper answers itself. It is stripped before anything else # reads the argv, so it never reaches the engine and never lands in the positionals # that pick the extras. _WARM_FLAG = "--warm" def _connector_from_config(config_path: Path) -> str | None: """Read the top-level scalar `connector:` from .dex/config.yml, stdlib only. This bootstrap script has no YAML dependency and only needs enough to pick the right extra to install, so it scans for a single unindented `connector:` key. The engine remains the source of truth for the full config; anything richer or malformed here just falls through to the DuckDB default. """ try: text = config_path.read_text(encoding="utf-8") except OSError: return None for line in text.splitlines(): if line.startswith("connector:"): # top-level only; indented keys ignored value = line.split(":", 1)[1].split("#", 1)[0].strip().strip("'\"") return value or None return None def _find_config(start: Path) -> Path | None: """Nearest ancestor `.dex/config.yml` at or above `start`, mirroring the engine's resolution: walk up to the enclosing git repo (the ceiling), and without one do not walk above `start`. Anchors on the file so a subdirectory holding only a `.dex/` cache never shadows the real config higher up.""" start = start.resolve() ceiling = start for directory in (start, *start.parents): if (directory / ".git").exists(): ceiling = directory break for directory in (start, *start.parents): candidate = directory / ".dex" / "config.yml" if candidate.is_file(): return candidate if directory == ceiling: break return None def _resolve_connector(argv: list[str], cwd: Path) -> str: """Pick the connector whose extra we install, mirroring the engine's order: explicit --connector, then the walked-up .dex/config.yml, then DuckDB.""" # allow_abbrev=False and parse_known_args so we only peek at these two flags # and never consume or reorder the argv that is forwarded to the engine. parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False) parser.add_argument("--connector") parser.add_argument("--repo-root", default=".") known, _ = parser.parse_known_args(argv) connector = known.connector if connector is None: config_path = _find_config(cwd / known.repo_root) if config_path is not None: connector = _connector_from_config(config_path) return connector if connector in _KNOWN_CONNECTORS else _DEFAULT_CONNECTOR # Every value-taking flag this peek has to consume, so a flag's *value* is never # mistaken for the group, subcommand, or mode being run. The global connection # flags, plus the ones `explore semantic` takes, because a metric read as a mode # picks the wrong extras. `tests/test_skill_wrapper.py` holds this list to the # real parser, since a flag added there and forgotten here fails silently. _VALUE_FLAGS = ( "--connector", "--path", "--scope", "--project", "--dataset", "--repo-root", "--budget", "--session-ceiling", "--metric", "--for-dimension", "--search", "--group-by", "--where", "--order-by", "--grain", "--limit", ) # The `explore semantic` modes that can render a statement here. Bare # `explore semantic` lists, so an absent mode is `list`, which renders none on # either backend and therefore never needs the renderer. _SEMANTIC_RENDERING_MODES = ("values", "query") def _positionals(argv: list[str]) -> list[str]: """The bare tokens of an invocation: group, subcommand, and whatever follows. Flag-position agnostic, which is the point: the value flags above are consumed so the group and subcommand are the first two bare tokens wherever the connection flags sit.""" parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False) for flag in _VALUE_FLAGS: parser.add_argument(flag) _, remaining = parser.parse_known_args(argv) return [tok for tok in remaining if not tok.startswith("-")] def _feature_extras(argv: list[str]) -> list[str]: """The extras this invocation needs on top of the connector's. Two commands need more than a warehouse client, and both are resolved from the command rather than installed always, so a repo that never clusters and never queries a semantic layer resolves neither scikit-learn nor MetricFlow. `explore semantic` needs the hosted client on any of its subcommands, which is an httpx and nothing heavier. It needs MetricFlow only where a statement might be rendered *here*: `list` renders none on either backend, and `--api` sends the request to dbt Cloud, which renders and executes it there. Where the flag is absent the backend is ambient, chosen by `semantic.deployment` in a nested config block this bootstrap deliberately does not parse, so a mode that could execute either way takes both. That errs toward a heavier install and never toward a command that refuses for want of a dependency, which is the failure this exists to prevent. """ positionals = _positionals(argv) if positionals[:2] == ["explore", "cluster"]: return ["cluster"] if positionals[:2] != ["explore", "semantic"]: return [] mode = positionals[2] if len(positionals) > 2 else "list" if mode in _SEMANTIC_RENDERING_MODES and "--api" not in argv: return ["semantic-api", "semantic"] return ["semantic-api"] def _engine_spec(extras: str, skill_dir: Path | None = None) -> list[str]: skill_dir = skill_dir or Path( os.environ.get("CLAUDE_SKILL_DIR", Path(__file__).resolve().parent.parent) ) local_pkg = (skill_dir / ".." / ".." / "packages" / "dex-core").resolve() if local_pkg.is_dir(): # Resolve the local package WITH the resolved extras (a plain path drops # extras). Non-editable is fine: the engine is imported fresh each run. return ["--with", f"exmergo-dex-core[{extras}] @ {local_pkg.as_uri()}"] return ["--with", f"exmergo-dex-core[{extras}]=={DEX_CORE_VERSION}"] def _extras(argv: list[str]) -> list[str]: """The full extras list for an invocation: the connector's, plus whatever the command itself needs. Warm-up and a real run both resolve here, on the same argv, which is what stops a warmed environment from disagreeing with the one the next command asks for.""" return [_resolve_connector(argv, Path.cwd()), *_feature_extras(argv)] def _uv_run(engine_spec: list[str], tail: list[str]) -> list[str]: """The single uv invocation this wrapper makes. `--no-project` is a correctness flag before it is a fast one: see the module docstring for what uv does to the caller's repo without it. The latency follows from the same thing, because installing the caller's project is far more work than resolving the engine.""" return ["uv", "run", "--no-project", *engine_spec, *tail] def _envelope( status: str, *, data: dict[str, object] | None = None, errors: list[str] | None = None, reason: str | None = None, ) -> str: """One JSON line in the engine's envelope shape, built by hand. The wrapper prints an envelope for the few things that happen before the engine exists to print its own: the missing-uv refusal and the warm-up. It cannot import `exmergo_dex_core.envelope` for precisely that reason, so the keys are mirrored here and have to stay in step with Envelope and Cost. `cost.paradigm` stays null throughout, because nothing here opens a connection and `free_local` is a positive claim that the connector in play bills nothing rather than a stand-in for "no connector was involved". `reason` is populated only on an error, the same rule the engine's Reason enum follows. """ return json.dumps( { "status": status, "data": data or {}, "cost": {"paradigm": None, "estimate": None, "ceiling": None}, "warnings": [], "diffs": [], "errors": errors or [], "reason": reason, } ) def _warm(argv: list[str]) -> int: """Materialize the engine environment and exit, without running a command. Nearly all of a first command's latency is uv resolving and installing the engine's closure. This pays it out of band, at container build, plugin install, or a CI setup step, so no interactive caller waits for it. The extras come from `_extras` on the argv that remains after `--warm` is stripped, so warm-up can never install something a later command contradicts: bare `--warm` warms the connector this directory resolves to, `--warm --connector snowflake` warms a named one before any project exists, and `--warm explore cluster` warms exactly what that command needs. The last form earns its keep because a feature extra resolves into an environment of its own, so warming the connector alone leaves `explore cluster` and `explore semantic` cold. """ extras = _extras(argv) spec = _engine_spec(",".join(extras)) started = time.monotonic() # Captured rather than inherited: uv narrates resolution and installation, and # stdout here carries one envelope and nothing else. completed = subprocess.run( _uv_run(spec, ["python", "-c", "import exmergo_dex_core"]), capture_output=True, text=True, ) elapsed = round(time.monotonic() - started, 3) if completed.returncode != 0: lines = [line.strip() for line in completed.stderr.splitlines() if line.strip()] print( _envelope( "error", errors=[ f"warm-up could not install {spec[1]}. uv reported: " + (" ".join(lines[-3:]) or "nothing on stderr") ], reason="prerequisite", ) ) return 1 print( _envelope( "ok", data={ "engine": spec[1], "connector": extras[0], "extras": extras, "elapsed_seconds": elapsed, }, ) ) return 0 def main() -> int: argv = sys.argv[1:] if shutil.which("uv") is None: # The one refusal that happens before the engine exists, which is why the # envelope is hand-built. `prerequisite` is the engine's own classification # for a missing dependency the user installs and retries (the same one # DemoDependencyError and DialectDependencyError carry), so a caller reads # this exactly like any other refusal instead of parsing a traceback. print( _envelope( "error", errors=[ "dex runs its engine through uv, which was not found on " "PATH. Install it with: " "curl -LsSf https://astral.sh/uv/install.sh | sh " "(or `brew install uv`, or `pipx install uv`), then re-run." ], reason="prerequisite", ) ) return 1 # The engine runs in uv's own ephemeral environment, so an inherited # VIRTUAL_ENV (e.g. the user's activated venv) is irrelevant to both paths # below and only makes uv print a mismatch warning on every call. Drop it. os.environ.pop("VIRTUAL_ENV", None) if _WARM_FLAG in argv: return _warm([arg for arg in argv if arg != _WARM_FLAG]) # The connector extra is always installed; `explore cluster` and # `explore semantic` add what only they need, so the default install stays # light for every repo that runs neither. cmd = _uv_run( _engine_spec(",".join(_extras(argv))), ["python", "-m", "exmergo_dex_core", *argv], ) if os.name != "posix": # Windows keeps the spawn: exec there hands control back to the shell # before the child finishes, and the prompt would interleave with the one # envelope a caller is reading off stdout. return subprocess.call(cmd) # Exec rather than spawn everywhere else: the wrapper has nothing left to do # once the engine starts, and replacing the process hands over the terminal, # the signals, and the exit code directly instead of relaying them through a # parent that is only waiting. os.execvp(cmd[0], cmd) return 0 # os.execvp does not return; this keeps the signature a plain int if __name__ == "__main__": sys.exit(main())
-
-
SKILL.md 26.5 KB
--- name: explore description: 'Use this whenever you need to know what is actually in a database, warehouse, or DuckDB file before you trust it: ranked inventory of what exists, column profiles, PII detection, grain and data-quality problems, verified join inference, Mermaid ER diagrams, guarded ad-hoc SQL probes, k-means segmentation, and reading the semantic layer a repo declares (dbt semantic models, a hosted dbt Cloud layer, or native Apache Ossie documents), producing a draft map without dumping the whole schema into context. Trigger it on an unmet precondition, not on any particular phrasing: if you are about to write or fix SQL against tables whose columns, types, grain, or join keys you have not verified in this session, use this FIRST. That includes dbt work: building a staging or mart model, fixing a broken model, or debugging wrong numbers, whenever the ticket names source tables without spelling out their schema. It also applies mid-task: if you are partway through and hit a table you have not inspected, stop and use this rather than guessing column names or firing off one-off SELECTs. Also use it for direct questions like "what''s in my duckdb", "which tables matter", "how do these tables relate", "is this data any good", "any PII in here", "how many orders have no customer", "cluster my customers", or "what metrics does this semantic layer define". Explore is read-only and writes nothing but the .dex/ cache. It does not author the model: pair it with transform, which writes the change once you know what you are writing against. To reconcile a project that has fallen out of sync, use maintain.' --- # Explore Make sense of a warehouse or a local DuckDB database the way an analytics engineer does: rank what matters, drill selectively, and persist a draft map. This is the flagship, fully read-only skill. It absorbs profiling and relationship inference as capabilities; they are not separate skills. ## How to drive it Run the engine through the wrapper. It prints one sanitized JSON envelope and nothing else; read the envelope and decide the next step. ```bash uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags] ``` dex runs its engine through `uv`, which is a prerequisite and is not installed by Claude Code. If the shell reports `uv: command not found`, stop and tell the user to install it (`curl -LsSf https://astral.sh/uv/install.sh | sh`, or `brew install uv`, or `pipx install uv`), then re-run. Never fall back to raw Python, `pip`, or a database CLI to do the work another way: the guardrails live in the engine, so any other path is unguarded. The first command in a fresh environment installs the engine, so it can take tens of seconds where later ones take well under a second. `--warm` pays that install up front and exits without running anything: ```bash uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm ``` Offer it once at setup. It is not something to run before an ordinary command. If the user has no warehouse to point at and wants to see what dex does, `demo` generates one: a seeded local DuckDB warehouse plus the `.dex/config.yml` for it, with no credentials and no network, so every subcommand below then runs with no flags. It only ever creates, so it refuses rather than touch a file that already exists. Offer it rather than assuming it: a user who does have a warehouse wants that one read, not a fixture built beside it. Subcommands, in the usual order: 1. `connect test --path <file.duckdb>` confirms a read-only connection and reports capabilities. 2. `explore inventory --rank` returns a ranked object summary (counts and sizes, never rows). 3. `explore profile <objects>` (space- or comma-separated) returns column profiles, PII flags recorded as (column, category, confidence) and never example values, plus ranked candidate keys, the likely grain, `key_evidence`, and data-quality warnings (e.g. an id unique on all but 110 rows, which will fan out on joins). `candidate_keys` is ordered, tightest proven key first, and `key_evidence` gives one entry per combination considered with its `status` (`reported` or `suppressed`) and the reason. Read it before you trust a composite: a combination unique only because one member is unique on almost every row, or because a money column completes it, is suppressed rather than reported. Where a near-unique column is the real story the warning says so with the ratio, the counts, and how many rows would have to be removed for it to be unique. That last number is the one to act on: it names a source defect to fix rather than a key to work around. A generic `*_name` flag's confidence is refined by value-shape evidence from the same scan, in both directions: person-shaped values corroborate it, a closed reference vocabulary or long labels de-rate it below the firewall's blocking threshold, and missing evidence changes nothing (the flag itself is never removed). Distinct counts are approximate for scale, but any column that looks unique within approximation noise is escalated to an exact COUNT(DISTINCT) (`distinct_count_exact: true`), so uniqueness and grain verdicts rest on proof; a `~` prefix marks a number that is still approximate, on a count and on a percentage alike, so a figure quoted without one is exact arithmetic over an exact distinct count on a column with no nulls. A requested object whose cached profile is still fresh (same connector, schema unchanged, within `profile_freshness_hours`, default 24) is served from the cache (`cache_hit_count`) instead of re-scanned, so profiling a table `map` just wrote costs nothing to spend; pass `--refresh` to force a re-scan when the source changed in a way the free metadata check cannot see. 4. `explore relationships` returns inferred and declared joins with confidences, plus notes explaining what the inference examined (so an empty list is meaningful). Add `--verify` to measure each inferred join with an aggregate overlap probe (orphan fraction, confidence adjusted). A declared join has two sources: a `relationships` test, and (with `--use-project`) an entity two semantic models share, which the layer states outright with the key named per model. `declared_by` on an edge names that entity, `semantic_join_count` says how many came that way, and the notes call out the ones name-based inference did not find, which is the interesting set: a semantic layer routinely joins columns that share no name at all. 5. `explore map` writes or updates the `.dex/` cache and returns the map (`--verify` works here too). Alongside the counts, `data.objects` gives each top-ranked object its row count, detected grain, best-ranked candidate key, notable columns (each carrying the role that earned it a place: `grain`, `key`, `join`, or a PII flag) and data-quality findings, and `data.edges` gives the join edges in the same shape `explore relationships` returns. With `--use-project` each object also carries `semantic_models`, the semantic models that sit on that relation, which is what separates a load-bearing table from a merely large one: empty means nothing in the layer reads it. **Read that payload instead of chaining `profile` and `relationships` to re-derive it**; go to those two when you need one object in full, or a value domain, which `map` never carries. It is budgeted: 25 objects by rank, 12 columns per object, 40 edges, 5 findings per object. Every cap binds in every mode and every elision is counted in `notes` and in an `elided_*` field, so an empty `notes` means nothing was cut. `--detail` widens the selection to every column and to objects that were inventoried but never profiled, and lifts no cap; it spends nothing, unlike `--full`. Past 50 objects it profiles only the top 25 by rank and says so in `notes` (with `skipped_count`); pass `--full` to profile everything. On a re-map, objects skipped this run keep their prior profiles (`carried_forward_count`), each stamped with its own `profiled_at` so staleness is visible instead of column detail silently vanishing. A selected object whose cached profile is still fresh (same connector, schema unchanged, profiled within `profile_freshness_hours`, default 24) is reused without a re-scan (`cache_hit_count`), so re-runs cost nothing to spend; pass `--refresh` to force a full re-profile when the source changed in a way the free metadata check cannot see (e.g. rows changed but the schema did not). `explore relationships` and the standalone `explore profile` reuse fresh profiles the same way. 6. `explore diagram [--full]` renders the cached map as a Mermaid ER diagram in `data.mermaid`. Free and connectionless (it reads the cache, never the warehouse), so it is safe to re-run while shaping the picture. **Reproduce the string verbatim in a fenced ```mermaid block so the human can see it, and write it to a `.mmd` or a markdown file when they want one on disk: the engine deliberately writes no file.** Never redraw or "tidy up" the diagram by hand. The glyphs are claims the engine derived from evidence, and a plausible-looking cardinality you supplied is exactly the overclaim this command exists to prevent: declared joins are solid, inferred dotted, and an unverified inference never says "exactly one". A solid line labelled with a semantic entity is a join the semantic layer declares; look the entity up with `explore semantic list`. Read `notes` before presenting it, since it states any object or column that was left out; `--full` widens from the default (profiled, joined objects and their grain, key, join, and PII columns) to everything eligible. 7. `explore query "<SELECT ...>" ["<SELECT ...>" ...]` answers ad-hoc questions the fixed commands don't cover: you write the SQL, the engine's query firewall refuses or bounds it. Pass a statement per argument, or `--sql-file <path>` for a longer list, and ask a whole chain of questions in one call rather than one call each; each statement is judged and answered on its own, so a refusal on one does not cost you the others, and `data.results` carries one entry per statement. A table you have not profiled, including a model you just built, is profiled for you and the statement then runs, so probing something new is one call rather than three; the envelope says what it profiled, and on a metered connector that profile is priced into the same confirmation as the statements. Results come back row-major and capped; a refusal names the offending column and the fix, so one rewrite is enough. Read `${CLAUDE_SKILL_DIR}/references/probe-playbook.md` before writing a probe: it maps common questions to effective probe shapes. 8. `explore cluster <object> [--features a,b,c] [-k N]` runs k-means over a bounded sample of the object's numeric columns and returns the segment structure: per-cluster sizes and fractions, centroids (each coordinate is a cluster's mean of that feature, an aggregate), the silhouette score, and, when `-k` is omitted, the k it picked plus the silhouette sweep it chose from. Requires the `.dex/` cache (run `map`/`profile` first) so features can be auto-selected from profiled numeric, non-PII, non-key columns; pass `--features` to choose them yourself (naming a PII column, or a key, opts it in deliberately, and only its mean is ever reported). A key is never a feature: its mean is meaningless, and a fact table is mostly keys plus a handful of measures, so clustering on them just partitions surrogate ranges. Keys are the unique columns, the columns that join out (from the joins `map` inferred), and the columns named like one; prefer `map` over a bare `profile` here, because without inferred joins a foreign key is caught only if its name gives it away. The notes name every excluded column, so check them before trusting a result. Two things the silhouette alone will not tell you, both of which the notes will. A cluster holding under 1% of the sample is an outlier pocket, not a segment, and it pushes the score up precisely because it sits so far out: report that as outlier detection, or re-run with `-k` to split the bulk. And on connectors that cannot seed a sample the draw changes per run, so two runs can disagree on k; the envelope's `sample_repeatable` says which case you are in, and comparing runs across different draws is meaningless. Only aggregates cross the boundary: the sample rows are clustered in-process and never enter context. On a metered connector it takes the same cost handshake as the scanning commands below (only the feature columns are scanned, and a dialect-aware sample clause reads a fraction), so surface the estimate and get a budget first. Needs the `[cluster]` extra (scikit-learn); the wrapper installs it automatically for this subcommand. 9. `explore semantic list|values|query` reach the semantic layer: the metrics an author defined, and the semantic models, measures, dimensions and entities they are built out of. Distinct from the warehouse commands above, and from the top-level `semantic` group, which *authors* the layer where this *queries* it. `list` is discovery and returns the layer's objects rather than three lists of names: semantic models (the unit the layer is organized around, each with the transformation model it sits on, its default time dimension, and the physical `relation` underneath), metrics (which dimensions each can be grouped by, the measures it reads, a ratio's two sides, any filter that makes it a subset, the grains it can be queried at, and `time_axis`, the physical time column a time grouping resolves to), dimensions (the token to group by, plus the bare definition, owning model, queryable grains and `column` behind it), entities (one declaration per semantic model, each with its own join key, so the declared join graph is readable), and measures (the aggregation and expression the number is actually made of, which is often a conditional rather than a column). An element defined as an expression carries no column rather than a guessed one. So "which table is behind this metric" is the metric's `semantic_models` followed to their relations, and `explore profile <relation>` is the next call; `--api` exposes no relation at all and declares that in `unavailable`, so use `--local` when you need the physical side. Three free ways to narrow it, and they compose. `--metric <m>` keeps those metrics and what they reach. `--for-dimension <d>` asks the reverse question, returning the metrics groupable by all the named tokens, which is what you want when you know the slice rather than the metric and is also the cheapest way to find the metrics that can go on one chart against one axis. `--search <t>` takes a word rather than a name and matches it against every element's name and against the project's own label and description. Each names its scope in the payload (`scoped_to`, `for_dimensions`, `searched_for`), so a subset is never mistaken for the layer; an unknown metric or dimension is refused by name, while a search term that matched nothing comes back as a note. The catalog is also capped, with every cut counted in `elided` and named in `notes` and `--full` to lift the caps. `elided` is always present, so all zeros and no cap notes is the positive statement that this is the whole layer. Prefer narrowing over `--full`: it decides which part comes back rather than letting a cap decide. `values <dimension>` returns that dimension's value domain, which is what you need before writing a `--where` filter and the one thing no other dex command can reach on a hosted layer (`profile` cannot see a semantic dimension). A PII-flagged dimension refuses this command outright rather than being screened, because the whole output is values. `query` takes a positional metric after the explicit mode (with `--metric` kept for compatibility), a `--group-by <entity__dim>`, and optional `--where`, `--order-by`, `--grain` and `--limit`, and returns the metric's values as a capped columnar result. Name flags take a comma-separated list or a repeated flag (`--group-by a,b` is `--group-by a --group-by b`); `--where` is never split, because a filter clause carries its own commas. `--grain` is checked against the grains the layer reports for the metrics queried, so a refusal names the ones that metric has. Two payload fields carry legitimate differences between the backends rather than leaving them to be inferred: `dimension_scope` says whether a dimension row is one declaration or one groupable path, which is why two backends can report different dimension counts for one layer, and `unavailable` names fields a backend structurally cannot supply. `--local` resolves the join graph through MetricFlow where the `[semantic]` extra is installed, which is what makes its dimension lists the tokens a query can actually use; without it the payload says `declarations` and a note names the extra. Three backends answer these commands, chosen by `.dex/config.yml` `semantic.vendor` and `semantic.deployment` (the older `semantic.backend` spelling still works), overridable with `--local` / `--api`. Those two flags name **who executes**, not which vendor, and every result reports it as `execution` (`dex` or `vendor`). `--local` renders the SQL with MetricFlow and executes it through dex's own connector and cost handshake, so cost is surfaced before spend (needs a dbt project parsed at least once, and the `[semantic]` extra for `values` and `query`; `list` reads the project and needs no extra). `--api` sends the query to a hosted dbt Cloud deployment (needs a host, an environment id and a `DBT_SL_TOKEN`, plus `[semantic-api]`, and no local project). The hosted backend is the one place the cost guard cannot apply: dbt Cloud executes server-side, so the result carries an explicit warning that spend is governed there and no `--confirm` is asked. Either way a PII-shaped grouped or filtered dimension (for example `user__email`) is refused before the query runs, and on `--api` the layer's own PII metadata is fetched per metric so a multi-metric query stays authoritative rather than falling back to names. The third backend is `semantic.vendor: ossie`, native Apache Ossie documents read out of the repository with no dbt project and no MetricFlow in the path (needs the `[ossie]` extra). It is catalog-first: `list` answers, and `values`, `query` and `--for-dimension` refuse by name, because Ossie specifies interchange metadata and no portable query runtime. Those refusals are the format's shape rather than a missing feature, and each one names the physical route instead: a dimension carries its `semantic_model`, that model carries its `relation`, and `explore profile` then `explore query` reach the values under the firewall and the cost guard. `--api` is refused too; Ossie has no hosted deployment. Read `${CLAUDE_SKILL_DIR}/references/semantic-playbook.md` before running a metric query: a metric's `time_axis`, `filter` and measures decide what the number *is*, and the playbook covers the discovery order, the additivity and time-axis traps this surface is full of, when `values` answers rather than a query, and what changes when the layer is native Ossie. Rules of engagement for `query`: prefer the fixed commands when they answer the question; one probe answers one question; batch related measures into a single query rather than issuing many; aggregates over PII-flagged columns must be measuring (COUNT, APPROX_COUNT_DISTINCT, AVG(LENGTH(...))), never value-carrying (MIN, ANY_VALUE, STRING_AGG). The FROM clause may unnest JSON and array columns in the connector's native idiom, which is the right way to explore schemaless data (for example "which keys appear across every row of this JSON column"): BigQuery `t, UNNEST(JSON_KEYS(doc)) AS k`, Snowflake `t, LATERAL FLATTEN(input => doc) f`, Databricks `t LATERAL VIEW EXPLODE(json_object_keys(doc)) x AS k`, Postgres `t, jsonb_object_keys(doc) AS k`, Redshift `t, UNPIVOT t.doc AS v AT k`, DuckDB `t, UNNEST(json_keys(doc)) AS u(k)`, ClickHouse `t ARRAY JOIN JSONExtractKeysAndValuesRaw(doc) AS kv` (there is no lateral join; ARRAY JOIN is the expansion). The unnested value must come from a column of a table in the query (bare, or through a JSON/array function); unnesting a subquery, another table, a literal, or a generator is refused, and the unnest's outputs inherit the source column's PII flags. A column whose flag was de-rated below the blocking threshold projects normally, with an envelope warning naming it; treat the warning as information for the user, not an error to fix. If the user says a refused column is not personal data, recommend a `pii_overrides` entry in `.dex/config.yml` (fully qualified column, optional reason): it unblocks querying immediately, survives re-profiles, and is reviewable in git. Never hand-edit `.dex/cache.json` to clear a flag. Never fall back to raw Python or a database CLI to run SQL; the firewall path is the only sanctioned one. ## Cloud and database targets (BigQuery, Snowflake, Databricks, Postgres, Redshift, ClickHouse) A remote warehouse or database replaces `--path` with connector config. Start with `connect test --connector <name>` (or set `connector:` plus the matching block in `.dex/config.yml`: `bigquery:` with `project` and a `datasets` allowlist, `snowflake:` with the pinned `warehouse` and a `databases` allowlist, `databricks:` with the pinned SQL `warehouse` and a `catalogs` allowlist, `postgres:` with a `schemas` allowlist, `redshift:` with the Serverless `workgroup` and a `schemas` allowlist). Credentials are discovered, never asked for: if the envelope reports missing or expired credentials, relay the fix it names (for BigQuery `gcloud auth application-default login`; for Snowflake a `connections.toml` entry or `SNOWFLAKE_*` env; for Databricks `databricks auth login` or `DATABRICKS_*` env; for Postgres `DATABASE_URL`, `PG*` env, or a `pg_service.conf` entry; for Redshift the AWS credential chain (`aws configure`, `AWS_*` env) or `REDSHIFT_*` env) and never ask the user to paste a key, token, or password. On a metered connector, scanning commands (`profile`, `map`, `relationships`, `query`) run a two-step handshake. The first call returns `needs_confirmation` with an estimate in `cost.estimate`, a per-table breakdown where relevant, and the unit it is counted in: bytes on BigQuery, warehouse-seconds on Snowflake (credits alongside) and Databricks (DBUs), compute-seconds on Redshift (RPU-hours), database-seconds on Postgres and ClickHouse (no dollars; the guarded quantity is load). Surface the estimate to the user in human units, get an explicit budget from them, and re-issue the same command with `--confirm` and `--budget <magnitude>` in that unit. Never invent a budget the user did not agree to, and never retry with a raised budget on an over-ceiling refusal without asking. Metadata is free (`connect test`, `inventory` run immediately), and OK envelopes report actual spend under `data.spend`. An over-ceiling refusal now carries a calibration line drawn from `.dex/spend.jsonl`: what this connector's last few settled commands actually billed as a fraction of what they were estimated at, or a sentence saying the project has too little history to say. On a partitioned or clustered warehouse a dry-run estimate is an upper bound, so this is often the difference between a budget that admits the work and one that does not. Relay it verbatim when you surface the refusal, and note the part callers get wrong: the ceiling is checked against the *estimate*, so a budget set at the observed fraction of the estimate is refused again. It is still the user's decision, never yours. When a `needs_confirmation` envelope carries `suggested_session_ceiling`, the project has never decided whether the *day's* total spend is bounded, and this is the one time it is asked. Surface it beside the per-command estimate and get the user's answer: `--session-ceiling <value>` sets a cumulative cap for the project (the suggestion is five times this command's estimate, a starting point, not a recommendation), and `--no-session-ceiling` records that the project runs unbounded. Either one is written to `.dex/config.yml` and reported as a diff, and nothing asks again. Add it to the same re-issue that carries `--confirm --budget`, or the confirmed run will stop once to ask. Never answer it on the user's behalf: it is a durable project setting, not a per-command flag. On BigQuery a profiling estimate holds a 10 MB floor per table for each escalation query a profile may still issue after its aggregate scan, so on a warehouse of many small tables most of the number can be reserve for work that never happens. Both the handshake and the over-ceiling refusal report that split (`reserved_bytes` and `reserved_queries`, and in the prose). Pass it on when you surface the estimate: whether a number is scan or reserve changes whether raising the budget is buying work or headroom. When an estimate is larger than the work deserves, narrow the scope rather than raise the budget. `--scope` (repeatable) bounds a command to part of the configured source allowlist, in the connector's own vocabulary: a dataset on BigQuery, a `schema` or `database.schema` on Snowflake, a `catalog.schema` on Databricks, a schema on Postgres or Redshift, a database on ClickHouse (whose identifiers are two-part `database.table`: there is no catalog level). It is free to resolve, it can only narrow what `.dex/config.yml` already allows, and a scope that names nothing is refused with the schemas that do exist listed. So `explore map --scope <schema>` is the first thing to reach for on a warehouse whose full map would be expensive. ## Guardrails (enforced in the engine, not here) - Read-only against data. The connection is opened read-only and generated SQL is SELECT-only. Never propose a write to source data. - Sense-making, not enumeration. Rank and drill selectively; never paste a full schema into context. - Profile, don't exfiltrate. Understanding comes from aggregates. PII is flagged, never surfaced, and the query firewall enforces it on your own SQL: values cross the envelope only from profiled columns whose flag is absent or below the blocking threshold, bounded and capped. Only a human's `pii_overrides` entry clears a flag entirely; never suggest weakening the detection. - The two policies in full, in the engine repository: `references/pii-policy.md` and `references/cost-controls.md`.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.