Skip to content

feat: strict mode — test each Trouve before it is published - #12

Draft
OmerBaddour wants to merge 2 commits into
mainfrom
worktree-strict-mode
Draft

feat: strict mode — test each Trouve before it is published#12
OmerBaddour wants to merge 2 commits into
mainfrom
worktree-strict-mode

Conversation

@OmerBaddour

Copy link
Copy Markdown
Member

Why

A table can only be tested once it has been materialized. In a normal clair run that means the target is written first and the tests tell you afterwards — the table is already wrong, and anything reading it has already read the wrong numbers.

What

clair run --strict builds each Trouve into a run-scoped staging object, tests it there, and only then publishes it.

Per node:

  1. Build into staging<table>__clair_strict_<run_id>, a sibling object in the same database and schema.
  2. Test the staging object — the Trouve's data quality tests run against it, not against the target.
  3. Promote on passALTER TABLE ... SWAP WITH ... for tables (metadata-only; cost does not scale with table size), then drop the superseded copy. Views are promoted with CREATE OR REPLACE VIEW. A target that does not exist yet is RENAMEd into place instead of swapped.
  4. Discard on fail — the staging object is dropped, the target is left exactly as it was, the node is reported as FAILED, and its dependents are skipped.

Promotion happens per node, immediately after that node's tests and before the next node starts, so dependents always read their upstreams under the real names their SQL references. Nothing in user-written SQL ever mentions a staging name.

Incremental Trouves

An incremental build needs its prior state before INSERT/MERGE can apply. clair seeds the staging table with CREATE OR REPLACE TABLE ... CLONE ... — Snowflake clones are metadata-only, so this is constant-time regardless of target size. If the target does not exist, the existing full-refresh fallback kicks in and the clone is skipped.

Also

  • clair compile --strict writes the full plan (clone, staging build, test checkpoint, promotion) to _clairtifacts/ for review without a connection.
  • --strict is rejected alongside --no-test — the whole point is to gate publishing on tests.
  • A name within 47 chars of Snowflake's 255-char identifier limit fails at the naming step with a clear error rather than mid-run.

Failure modes

What failed Target Staging object
The build itself Untouched Dropped
A data quality test Untouched Dropped
The swap or rename Untouched Retained — it holds tested data and is the only record of the run

Implementation notes

  • New clair.core.strict holds the naming and SQL construction; the runner orchestrates.
  • Trouve.build_sql() gained an optional target_name override. References to upstream Trouves are untouched — only the object being written changes. The UPSERT merge-staging table still derives from the real name, so the two suffixes never stack.
  • run_tests() gained an optional physical_names map so tests can query the staging object while results still report the routed name. --sample follows the override.
  • after_node_success now takes (node_name, physical_name). Non-strict behaviour is unchanged: the target is written directly, tests run afterwards, and a failing test cuts off dependents without failing the node itself.

Testing

555 tests pass (520 existing, 35 new in tests/unit/test_strict.py) — covering staging naming and its limits, promote/rename/view/cleanup SQL, the target-name override, and runner behaviour across full refresh, incremental (clone ordering and fallback), views, pandas Trouves, failed builds, failed tests, failed promotion, downstream skipping, and the non-strict path.

Verified end to end against example_projects/example_2 (full refresh) and example_3 (append + upsert) via clair compile --strict. Not yet exercised against a live Snowflake account — the SWAP/CLONE/RENAME statements are unit-tested through a mock adapter, so a real run is worth doing before merge.

🤖 Generated with Claude Code

OmerBaddour and others added 2 commits August 1, 2026 11:25
A table can only be tested once it has been materialized, so a normal run
writes production first and finds out afterwards. `clair run --strict`
closes that window.

Per Trouve:
- Build into `<table>__clair_strict_<run_id>`, a sibling in the same schema
  (incremental Trouves first zero-copy CLONE the target so the INSERT/MERGE
  has a base to apply to).
- Run the Trouve's tests against that staging object.
- On pass, promote: `ALTER TABLE ... SWAP WITH ...` for tables, which is
  metadata-only and does not scale with table size; `CREATE OR REPLACE VIEW`
  for views. A target that does not exist yet is RENAMEd into place instead.
- On fail, drop the staging object. The target is untouched, the node is
  reported as FAILED, and its dependents are skipped.

Promotion happens per node, immediately after its tests, so dependents always
read their upstreams under the real names their SQL references.

`clair compile --strict` writes the same plan to _clairtifacts/ for review.
`--strict` is rejected alongside `--no-test`, since it gates on tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against a live Snowflake account that ALTER TABLE ... SWAP WITH
attaches grants to the object rather than the name: after a swap, the
production name carries only the staging object's grants and any privilege
granted directly on the target is silently revoked.

Promotion is now:

  CREATE OR REPLACE TABLE <target> CLONE <staging> COPY GRANTS

which is equally metadata-only, copies every privilege except OWNERSHIP from
the object being replaced, and -- because COPY GRANTS falls back to the clone
source when there is no object to replace -- removes the SWAP-vs-RENAME
branch entirely. Views get CREATE OR REPLACE VIEW ... COPY GRANTS, which fixes
the same defect on the view path.

Also:

- Staging objects are no longer dropped on failure. A rejected candidate is the
  only copy of what the run produced and reproducing it means re-running
  everything upstream, so it is retained and its name reported in the error.
  Staging is dropped only after a successful promotion.
- Shorten the suffix from __clair_strict_ to __clair_ (47 -> 40 chars of
  identifier budget).
- Record at the length check that Snowflake's 255-char limit applies per object
  name, not per fully-qualified path -- a 767-char database.schema.table is
  legal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant