Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fb9a1bb
feat!: scope audits, schemas, and named hooks and run seed audits
kvlonge Sep 27, 2026
4855e21
docs: document scoped audits, schemas, hooks, and seed audits
kvlonge Sep 27, 2026
97f2dab
test: cover top-level singular audits over sub-folder models with sou…
kvlonge Sep 27, 2026
b38b860
feat!: bump scope JSON schema versions and document enforce_placement
kvlonge Sep 27, 2026
bea2bd7
test: stop generating unused hooks in compile benchmark projects
kvlonge Sep 27, 2026
b881978
test: run scoped declaration E2E tests in the build-core shard
kvlonge Sep 27, 2026
46de61b
feat!: gate targets on attached audits that read other resources
kvlonge Sep 27, 2026
12c64ef
docs: document attached audits that read other resources
kvlonge Sep 27, 2026
793b6fa
fix: order source-audit reads before every model that triggers them
kvlonge Sep 27, 2026
6f91e7b
fix: attach singular audits using lineage only
kvlonge Sep 27, 2026
96ffde6
docs: state that gating audits follow the target's promotion mode and…
kvlonge Sep 27, 2026
1470d3b
test: load seeds before planning in the Dagster selection parity test
kvlonge Sep 27, 2026
b3d09e5
fix: report warehouse errors in source, seed, and end-of-build audits
kvlonge Sep 27, 2026
c6f6e18
feat: name the audit behind S301 misses that only an audit read needs
kvlonge Sep 27, 2026
644fcfb
docs: explain audit reads in S301 and audits whose query raises
kvlonge Sep 27, 2026
d29eb5d
test: set the fresh 5000 compile budget above its observed CI spread
kvlonge Sep 27, 2026
39cec81
test: make fresh-process and cache compile budgets loose ceilings
kvlonge Sep 27, 2026
dab8920
test: pre-create the hook log table and loosen tight dense budgets
kvlonge Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ E2E_DUCKDB_PYTEST_ARGS := -m "$(E2E_DUCKDB_MARKERS)" -vv --color=yes -n auto --d

E2E_DUCKDB_BUILD_CORE_PATHS := \
tests/e2e/src/sqlbuild/cli/commands/main/build/no_tests_no_audits \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_attached_audit_gates.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_audit_execution_errors.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_audit_factory_failures.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_audit_failures.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_build.py \
Expand All @@ -81,6 +83,7 @@ E2E_DUCKDB_BUILD_CORE_PATHS := \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_reusable_model_schemas.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_runtime_artifact_preservation.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_schema_backfill_behavior.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_scoped_declarations.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_selector_surface.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_table_function_dependency.py \
tests/e2e/src/sqlbuild/cli/commands/main/build/test_template_expressions.py \
Expand Down
2 changes: 1 addition & 1 deletion crates/sqlbuild-rules-native/src/constants.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
pub(crate) const API_VERSION: u32 = 1;
pub(crate) const SCOPE_METADATA_SCHEMA_VERSION: u32 = 2;
pub(crate) const SCOPE_METADATA_SCHEMA_VERSION: u32 = 3;
pub(crate) const BUILT_IN_RULE_NAMESPACE: &str = "SQBR";
pub(crate) const PIPELINE_SUBJECT: &str = "pipeline";
pub(crate) const GENERIC_TEST_NAME: &str = "test";
Expand Down
2 changes: 1 addition & 1 deletion crates/sqlbuild-rules-native/src/engine/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ pub(crate) fn threshold_request(

pub(crate) fn scope_index() -> Value {
json!({
"schema_version": 2,
"schema_version": 3,
"ownership_roots": [{"path": "models", "resource_kind": "model"}],
"resources": [{
"identity": "model:orders",
Expand Down
8 changes: 6 additions & 2 deletions crates/sqlbuild-rules-native/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,9 @@ pub(crate) enum ResourceKind {
Model,
Test,
Scenario,
Hook,
Function,
Audit,
Source,
Seed,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
Expand All @@ -271,6 +270,11 @@ pub(crate) enum DeclarationKind {
Macro,
Enum,
Constant,
Audit,
SingularAudit,
Schema,
SqlHook,
PythonHook,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,11 @@ fn container_limit(evaluation: &ProjectEvaluationRequest<'_>, kind: &Declaration
DeclarationKind::Macro => "max_macro_container_files",
DeclarationKind::Constant => "max_constant_container_files",
DeclarationKind::Enum => "max_enum_container_files",
DeclarationKind::Audit
| DeclarationKind::SingularAudit
| DeclarationKind::Schema
| DeclarationKind::SqlHook
| DeclarationKind::PythonHook => return DEFAULT_MAX_CONTAINER_FILES,
};
threshold(evaluation, name, DEFAULT_MAX_CONTAINER_FILES)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Bundled copies of every page on the SQLBuild documentation site, matching the in
- [Execution Observability](concepts/observability.md) (`concepts/observability`) - Choose authoritative lifecycle facts, readable logs, or command-output records.
- [Typed Sinks](concepts/observability/sinks.md) (`concepts/observability/sinks`) - Export lifecycle facts and command output through project-owned providers.
- [Execution history](concepts/observability/execution-history.md) (`concepts/observability/execution-history`) - Store and query lifecycle events and run history from Python, with SQLite locally or PostgreSQL when deployed.
- [Overview](concepts/declaration-scopes.md) (`concepts/declaration-scopes`) - Limit enums, constants, and macros to the parts of a project that use them.
- [Overview](concepts/declaration-scopes.md) (`concepts/declaration-scopes`) - Limit enums, constants, macros, audits, schemas, and named hooks to the parts of a project that use them.
- [How Visibility Works](concepts/declaration-scopes/visibility.md) (`concepts/declaration-scopes/visibility`) - See which enums, constants, and macros are available to each SQL file.
- [Where to Put Declarations](concepts/declaration-scopes/placement.md) (`concepts/declaration-scopes/placement`) - Choose the narrowest folder that contains every real use.
- [Scope Explorer](concepts/declaration-scopes/explorer.md) (`concepts/declaration-scopes/explorer`) - Inspect visibility, explain resolution, browse declarations, and preview moves offline.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ This replaces the former `sqb run` command. The full lifecycle (tests + audits)
5. Models are materialized in DAG topological order
6. Error-severity audits run against the staging table before promotion to the target (unless `--no-audits`)

A resource with an attached audit also waits for every other model, seed, or table function that
audit reads, so the audit can gate the resource like its other audits. See
[Audits that read other resources](../concepts/audits.md#audits-that-read-other-resources).

## Output

```
Expand Down
10 changes: 7 additions & 3 deletions src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@ my-project/
```

Empty directories contain `.gitkeep` files so the scaffold can be committed. Reusable attached
audit definitions belong in `audits/generic/`; standalone audits belong in `audits/singular/`.
Add reusable SQL lifecycle hooks to `hooks/sql/` and decorated Python lifecycle hooks to
`hooks/python/`; see [Hooks](../concepts/models/hooks.md).
audit definitions belong in `audits/generic/`; cross-resource singular audits belong in
`audits/singular/`. Add reusable SQL lifecycle hooks to `hooks/sql/` and decorated Python lifecycle
hooks to `hooks/python/`; see [Hooks](../concepts/models/hooks.md).

These top-level roles are project-wide. Once an audit, schema, hook, or macro is used only under
one folder, such as `models/marts/`, SQLBuild requires it to move into that folder's `_sqlbuild/`
directory; see [Where to Put Declarations](../concepts/declaration-scopes/placement.md).

The generated project uses DuckDB, creates a named `local` connection shared by `dev` and
`prod`, and defaults models to table materialization. Its configuration follows this shape:
Expand Down
12 changes: 12 additions & 0 deletions src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,15 @@ error[S301]: cannot build selected scope: 3 missing upstream dependencies (stg_o
```

Build the upstream chain first, or select it along with the model: `sqb build --select +fact_orders`.

The check also covers resources that an attached audit on a selected resource reads, even when the
model SQL does not read them. Selecting `+fact_orders` follows data lineage only, so it does not
pull those resources in. When a missing resource is needed only by an audit, the error names the
audit, its target, and the resource:

```
error[S301]: cannot build selected scope: audit 'relationships' on 'stg_orders' reads 'waffle_types', which is not selected and does not exist in the warehouse; select it or build it first
```

Load or build the resource first (for a seed, `sqb seed`), or add it to the selection. When both
kinds are missing, the error lists the missing upstream dependencies and then the audit reads.
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ sqb scope model:stg_orders \
in text output. `none` omits declaration definition locations and replaces structural and header
paths with `(hidden)`. Paths in stable machine output never expose an absolute workspace root.

`--json` emits the canonical schema rather than serializing the visual tree. The top-level `schema_version` is currently `1`. Reports include the target, scope chain, declaration sections, applied filters, section totals, collapsed and truncated flags, cursors, diagnostics, and aggregate and section-level completeness. Move previews and explanations appear when requested.
`--json` emits the canonical schema rather than serializing the visual tree. The top-level `schema_version` is currently `2`. Reports include the target, scope chain, declaration sections, applied filters, section totals, collapsed and truncated flags, cursors, diagnostics, and aggregate and section-level completeness. Move previews and explanations appear when requested.

JSON is deterministically ordered, ASCII, newline-terminated, and byte-stable for identical inputs. It contains no ANSI formatting. Consumers should check `schema_version` before relying on fields.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Online: https://sqlbuild.com/docs/concepts/audits/
- Custom generic audits
- Singular audits
- Source audits
- Seed audits
- Severity
- Run scope
- Running audits standalone
Expand Down Expand Up @@ -157,7 +158,27 @@ warning[P003]: project audit 'not_null' overrides built-in audit 'not_null'

## Custom generic audits

Beyond the built-ins, you can define reusable SQL templates under `audits/generic/`. They use `@parameter` placeholders that are resolved by the audit engine at compile time.
Beyond the built-ins, you can define reusable SQL templates in a generic audit role. They use `@parameter` placeholders that are resolved by the audit engine at compile time.

| Location | Who can attach it |
|----------|-------------------|
| `audits/generic/` | Resources anywhere in the project |
| `<folder>/_sqlbuild/audits/generic/` | Resources in `<folder>` and below |
| `<folder>/_sqlbuild/_audits/generic/` | Resources directly in `<folder>` only |

A generic audit must live under the nearest folder that contains every model, source, seed, or
audit factory that attaches it. A top-level generic audit used only by models under
`models/marts/` fails to compile and names the folder it belongs in:

```
error[P001]: Invalid declaration scope index:
[S024] Declaration 'audit:non_negative' is currently project at 'audits/generic' ...
Move it to 'models/marts/_sqlbuild/_audits/generic/'
```

Attaching a scoped generic audit from a folder that cannot see it fails with `S006`. Built-in
audits are available everywhere and are unaffected. See
[Where to Put Declarations](declaration-scopes/placement.md).

```sql
-- audits/generic/expression_is_true.sql
Expand All @@ -179,8 +200,11 @@ Generic audit SQL uses `@name` for parameter placeholders. These are resolved by
| `@'name'` | A quoted parameter passed from the audit declaration (e.g. `@'values'`) |
| `@name` | An unquoted parameter passed from the audit declaration (e.g. `@expression`) |

Generic and singular audit SQL uses macros, constants, and enums available from the audit file under
`audits/`, not from a model or source that uses the audit. See
Generic and singular audit SQL uses macros, constants, and enums available from the folder that owns
the audit, not from a model or source that uses the audit. An audit in
`models/marts/_sqlbuild/_audits/generic/` sees the declarations a model in `models/marts/` sees; an
audit in the top-level `audits/` roles sees only project-wide declarations. Using a declaration
that is not visible from there is a compile error. See
[How Visibility Works](declaration-scopes/visibility.md).

### Attaching custom generic audits
Expand All @@ -197,15 +221,61 @@ MODEL (
);
```

### Audits that read other resources

An attached audit can read other models, sources, seeds, and table functions besides its target,
for example to check that every value appears in a lookup table:

```sql
-- audits/generic/known_status.sql
AUDIT ();

SELECT o.*
FROM @relation o
LEFT JOIN __seed("order_statuses") s USING (status)
WHERE s.status IS NULL
```

An attached audit always gates its target. SQLBuild waits for everything the audit reads, then
runs the audit exactly where any other audit on that target runs:

- On a model, it follows the model's table promotion mode and the audit's run scope. With `staged`
promotion, a `final` audit checks the staged table before promotion; with `immediate` promotion,
or for final-phase audits on incremental and snapshot models, it runs after the write, like any
other audit there. See [Run scope](#run-scope) and
[Table promotion mode](project-configuration.md#table-promotion-mode).
- On a source or seed, it runs before any model that depends on it is built.

An `error` failure blocks everything downstream of the target, and where the target's other audits
would keep it unpublished, this one does too. A `warn` failure is reported and the build continues.
The audit is reported under its target. If a resource the audit reads fails to build, or a source
it reads fails its own `error` audit, the target is blocked as well.

In a partial build such as `sqb build -s orders`, resources the audit reads but that are not
selected are not built; the audit reads their existing tables. Selectors such as `+orders` follow
data lineage only, so they do not add the audit's reads to the selection. If an unselected read
does not exist in the warehouse yet, planning fails with `S301` and names the audit and the read;
see [Missing upstream dependencies](../cli/plan.md#missing-upstream-dependencies).

An attached audit cannot read a resource built from its own target, because the target would have
to wait for its own dependants. That fails to compile with `P005`:

```
error[P005]: Audit 'known_status' on model 'orders' reads model 'order_summary', which depends on
'orders'; an attached audit gates its target, so it cannot wait for a resource built from that
target
= help: write a singular audit under audits/singular/ that references both 'orders' and
'order_summary'; it runs once both are built
```

## Singular audits

Singular audits are standalone SQL files. Their canonical home is `audits/singular/`, and they
reference models directly. They're useful for one-off checks that don't fit a reusable template.
For backward compatibility, singular audits directly under `audits/` or another non-`generic`
child directory continue to compile.
Singular audits are standalone SQL files that check a relationship between resources. They must be
cross-resource: a singular audit references two or more models, or at least one model plus a
source, seed, or table function, through `__ref()`, `__source()`, `__seed()`, or `__table_fn()`.

```sql
-- audits/singular/orders_have_payments.sql
-- models/marts/_sqlbuild/audits/singular/orders_have_payments.sql
AUDIT (
name "completed_orders_have_payments",
severity error
Expand All @@ -218,7 +288,29 @@ WHERE p.payment_id IS NULL
AND o.order_status = 'completed'
```

SQLBuild automatically infers which model a singular audit attaches to based on the `__ref()` calls in the query. If the audit references a single model, it attaches to that model. If it references multiple models, SQLBuild attaches it to the latest (most downstream) model in the DAG. If attachment can't be inferred, the audit runs at the end of the build.
Other shapes fail with `P004`:

| Singular audit references | Use instead |
|---------------------------|-------------|
| One model only, including a self-join of that model | A generic audit attached to the model that selects `FROM @relation` |
| Sources or seeds only | YAML audits on the source or seed |
| No SQLBuild resource, such as hard-coded relation names | Reference resources with `__ref()`, `__source()`, or `__seed()` |

Singular audits live only in a `singular/` role:

| Location | What it may reference |
|----------|-----------------------|
| `audits/singular/` | Resources whose nearest shared folder is the project root, such as models in sibling folders or a model and a source |
| `<folder>/_sqlbuild/audits/singular/` | Resources in `<folder>` and below |

There is no `_audits/singular/` role, because singular audits are never attached by name. A
singular audit must live under the nearest folder containing everything it references. Audit files
directly under `audits/` or in any other `audits/` child are compile errors.

SQLBuild infers where a singular audit runs from its references. When one referenced model is
downstream of every other referenced model, the audit attaches to that model. Otherwise it runs at
the end of the build, after the resources it references are published. Only singular audits run at
the end of the build; attached audits always gate their target.

## Source audits

Expand All @@ -240,13 +332,44 @@ sources:

If a source audit with `error` severity fails, all downstream models that depend on that source are blocked. This lets you catch data quality issues at the source before any transformations run.

A source audit that reads other resources waits for them before any model that depends on the
source is built. It may read a model that is not built from the source; reading one that is fails
with `P005`. See [Audits that read other resources](#audits-that-read-other-resources).

## Seed audits

Seeds use the same YAML audit syntax as sources, at table and column level:

```yaml
seeds:
- name: waffle_types
columns:
- name: waffle_type_id
type: INTEGER
audits:
- not_null
- unique
audits:
- expression_is_true:
expression: "price_cents >= 0"
severity: error
```

Seed audits run in `sqb build` after the seed loads and after everything the audit reads is built.
An `error` failure blocks every model that depends on the seed; a `warn` failure is reported and
the build continues. A seed audit cannot read a model built from that seed (`P005`).

## Severity

| Severity | Behavior |
|----------|----------|
| `error` | Blocks the build. Staging table is not promoted, DML is not applied. |
| `warn` | Reports a warning but allows the build to continue. |

An audit whose query raises a warehouse error, for example because a relation it reads is missing,
fails whatever its severity: the build reports the error message and fails, and everything
downstream of the audit's target is blocked, the same as when a model audit raises.

Set the default severity in `sqlbuild_project.toml`:

```toml
Expand Down
Loading
Loading