Upgrading to CLI 0.18.0
Release dates: 0.18.0 on October 3, 2026; 0.18.1 on October 5, 2026. Status: Current docs baseline, CLI 0.18.1. Supersedes 0.17.0. Coming from an older release? Start with the upgrade guide.
Headline
A contract can no longer make the engine read the machine it runs on.
$refcomposes only files inside the contract's own directory tree. (forge-cli #687)- Contract SQL runs inside DuckDB's own sandbox. (forge-cli #689)
- A new public API loads a contract exactly as
fluid plansees it. (forge-cli #688)
These changes matter most to services, CI jobs or shared hosts that run contracts other people wrote: before 0.18.0, such a contract could make the engine read files and URLs its operator never meant to expose.
0.18.1 fixes one thing: fluid diff reads a live target whose binding names its project or path through {{ env.* }}. See What changed in 0.18.1.
What changed in 0.18.1
fluid diff's live check now resolves {{ env.* }} in the contract, and in the last applied contract, before it reads the target, as fluid apply and fluid verify already did (forge-cli #692).
Before 0.18.1, the live check read the placeholder itself. A BigQuery binding whose project is {{ env.FLUID_GCP_PROJECT }} was refused as "the binding's BigQuery project, dataset or table is not a valid id", so fluid diff --exit-on-drift, stage 5 of a pipeline generated by fluid generate ci, failed every build of that product while fluid apply and fluid verify read the real table. It was found on 4 October 2026, on the first run of a gcp overlay against real BigQuery.
Action: upgrade. Nothing in a contract needs to change. A generated Jenkinsfile installs the version that generated it, so regenerate the pipeline or set its FLUID_PACKAGE_SPEC parameter to 0.18.1.
Do I need to change anything?
| If your contracts… | Then |
|---|---|
use $ref only to files under the contract's own directory | nothing |
use $ref: ../… to reach another product's or a shared directory | widen the ref root |
use a $ref with a URL (file://, https://) or an absolute path | copy the fragment in |
ship OpenAPI documents in a bundle that $ref another file or URL, or carry a $ref key in an example or x-* payload | inline the schemas or rename the key |
| run SQL only on files under the contract's directory or workspace | nothing |
declare inputs, outputs or acquisition sources at absolute paths elsewhere (/data/landing/*.csv) | allow the directory |
read an http(s)://, gs:// or Azure URL directly in contract SQL (read_csv('https://…')) | land the data first |
SET or PRAGMA a DuckDB setting in contract SQL (memory_limit, threads, TimeZone, …) | remove the statement |
call read_xlsx, sqlite_scan, ST_Read, delta_scan or iceberg_scan in contract SQL | convert or land the data |
| install DuckDB yourself at a version below 1.5.0 | upgrade DuckDB |
name a binding's project or path through {{ env.* }} and are checked by fluid diff --exit-on-drift | upgrade to 0.18.1 |
Breaking changes and migration
$ref to files outside the contract directory
Every $ref must name a file inside the ref root, by default the directory that holds the root contract. URL refs (file:// included) and absolute paths are refused; .. and symlink escapes are refused after resolution. The check applies to fluid validate, plan, apply and bundle.
$ fluid validate contract.fluid.yaml
❌ Validation error: contract_load_failed
error: $ref '../shared/policy.yaml#/gold' at JSON pointer '/exposes/0/policy' in
/work/orders/contract.fluid.yaml escapes the ref root /work/orders (after resolving
'..' and symlinks); refs may only name files inside it. ...
Migrate a monorepo that shares fragments across products: set FLUID_REF_ROOT to the narrowest directory that holds the products and their shared fragments, per command:
FLUID_REF_ROOT="$(git rev-parse --show-toplevel)" \
fluid validate products/orders/contract.fluid.yaml
From Python, pass ref_root= to load_contract, load_with_overlay or compile_contract in fluid_build.loader.
- A
FLUID_REF_ROOTthat does not contain the contract is ignored for that contract, with aref_root_env_ignoredwarning. - Do not
exportit: every contract under it is widened without a warning. - URL and absolute-path refs cannot be allowed by any setting. Copy the fragment under the ref root and refer to it by a relative path.
RefConfinementError subclasses RefResolutionError, so existing except RefResolutionError handlers still catch it.
Full reference: Composing a contract with $ref.
SQL or declarations that read outside the contract directory
SQL in an embedded-SQL build on the local provider can read and write only:
- the contract's directory and its FLUID workspace;
./runtimeand the run's scratch directory;- the locations the contract declares, and only those inside the roots above, the upstream roots in
FLUID_UPSTREAM_CONTRACTS, or a directory the operator lists inFLUID_DUCKDB_ALLOWED_DIRS.
A DuckDB acquisition build is narrower: its declared sources and landings may sit only in the contract's directory, its workspace, or a FLUID_DUCKDB_ALLOWED_DIRS directory (not ./runtime, the scratch directory or a FLUID_UPSTREAM_CONTRACTS root).
A declared input, output or acquisition source outside its allowed roots is refused before any SQL runs:
$ fluid apply contract.fluid.yaml --mode amend-and-build --yes
🔷 Build 'summarise' (embedded-SQL / local DuckDB)
❌ Failed: 1 action(s) failed
The contract declares '/work/reference/rates.csv' (/work/reference/rates.csv),
outside the directories it may read and write (/work/orders,
/work/orders/runtime, $TMPDIR/fluid_jl4bqbtc). The operator can allow a directory
with FLUID_DUCKDB_ALLOWED_DIRS.
Migrate either by moving the data under the contract's directory or workspace, or by allowing its directory in the environment that runs fluid (absolute paths, :-separated), and declaring the file in the contract:
FLUID_DUCKDB_ALLOWED_DIRS=/work/reference \
fluid apply contract.fluid.yaml --mode amend-and-build --yes
SQL that reads a local path the contract does not declare (read_csv('/etc/passwd'), ../) is refused by DuckDB itself. Declare the location as an input. For a URL, declaring it helps only for s3://; see URLs other than s3://.
A declared glob grants the directory above its first wildcard, because DuckDB expands the glob again when the SQL runs; that directory must itself be inside the allowed roots.
Full reference: DuckDB sandbox for contract SQL.
URLs other than s3:// in contract SQL
Before 0.18.0, read_csv('https://…') in an embedded-SQL build worked because DuckDB autoloaded httpfs. With autoloading off it fails:
❌ Failed: 1 action(s) failed
File https://… requires the extension httpfs to be loaded
That is the error when the build declares no s3:// location. When it declares one, httpfs is loaded and DuckDB refuses the URL as outside allowed_directories instead: Permission Error: Cannot access file "https://…" - file system operations are disabled by configuration.
Declaring the URL does not fix it. The local provider treats only s3:// locations as remote: for those it loads httpfs and creates a credential secret before the sandbox closes. Any other declared URL is taken as a local path, so an https:// input fails with Input file not found. Embedded SQL can reach declared s3:// locations only, and no contract setting brings back http(s)://, gs:// or Azure reads.
Migrate by landing the data with a DuckDB acquisition build first (pattern: acquisition, engine: duckdb, source.kind: http with source.connection.uri), then reading the landed file. Data in GCS or Azure can be copied to the contract's directory, its workspace, or an s3:// location.
SET and PRAGMA in contract SQL
The sandbox locks DuckDB's configuration before contract SQL runs, so a SET or PRAGMA statement that changes a setting (memory_limit, threads, TimeZone, …) now fails the action:
$ fluid apply contract.fluid.yaml --mode amend-and-build --yes
🔷 Build 'summarise' (embedded-SQL / local DuckDB)
❌ Failed: 1 action(s) failed
Invalid Input Error: Cannot change configuration option "memory_limit" - the
configuration has been locked ...
The SQL was SET memory_limit='1GB'; SELECT …. PRAGMA threads=2 and SET TimeZone='UTC' fail the same way, naming threads and TimeZone. Migrate by removing the statement from the contract SQL. Write timestamps with an explicit offset (or convert with AT TIME ZONE) instead of setting TimeZone.
OpenAPI fragments with an external $ref
fluid validate on a bundle (fluid bundle --format tgz) now reports an OAS-REF-EXTERNAL error for any $ref in a bundled OpenAPI document that is not a same-document #/… pointer:
file://andhttp(s)://refs used to be followed by openapi-spec-validator. They are now reported, not followed.- Relative refs such as
./schemas.yaml#/Orderwere already reported as unresolvable (OAS001); they are now reported asOAS-REF-EXTERNAL. - A
$refkey inside anexample,examples.*.valueorx-*payload is now reported too.
A bundle that validated before can now fail if it holds a resolvable file:// or http(s):// ref, or a $ref key in an example or x-* payload.
Migrate by inlining the referenced schemas under components, and by renaming a $ref key in an example payload (for example to ref) or dropping the example. See OpenAPI fragments inside a bundle.
Functions DuckDB used to autoload
sqlite_scan, read_xlsx, ST_Read, delta_scan and iceberg_scan no longer work in contract SQL. Extension autoloading is off, and contract SQL cannot INSTALL or LOAD an extension. Migrate by reading CSV, Parquet or JSON instead, or by landing the data with an acquisition build first.
Upgrade DuckDB
The local extra now requires duckdb>=1.5.0, and the engine refuses to open DuckDB on anything older (DuckDB 1.4.4 is too old to sandbox contract SQL). Older versions let <dir>/./../ and symlinks escape the allowlist.
pip install -U 'data-product-forge[local]'
A DuckDB database file can be open once per process
A file DuckDB database already open in the same process can no longer be opened a second time. It raises a clear DuckDBSandboxError instead of sharing the instance. Two MCP DuckDB drivers bound to the same .duckdb file in one process, or two concurrent persist=True local runs, hit this.
Persistent DuckDB secrets (~/.duckdb/stored_secrets) are no longer loaded; object-store builds use the credential-chain secret the engine creates.
Security
$refresolution refuses remote, absolute and escaping targets, before it tests whether the target exists (#687).fluid validateon a bundle no longer hands an OpenAPI fragment with an external$refto openapi-spec-validator, which followedfile://refs. It reportsOAS-REF-EXTERNALinstead (#687).- Every DuckDB connection in the engine goes through one helper,
secure_duckdb_connect(#689). It applies DuckDB's built-in sandbox:allowed_directories/allowed_paths,enable_external_access = false, autoload and autoinstall off, no persistent secrets, no community extensions, andlock_configuration = true. Contract SQL can no longer read host files, URLs or other databases, or change those settings. A guard test fails if a newduckdb.connectbypasses the helper. - Each action's DuckDB connection is closed when the action ends, including when it fails (#689).
- The legacy local-provider module
fluid_build.contract_tests, which nofluidcommand uses, confines each action's DuckDB connection to the files that action declares, under the working directory or aFLUID_DUCKDB_ALLOWED_DIRSdirectory (#689).fluid contract-testsdoes not run DuckDB and is unchanged.
Added
fluid_build.api.load_contract,load_contract_from_textandload_contract_from_dict(#688). They return aLoadedContractwhosecontractis the dictfluid planplans, after parsing,$refcomposition, the env overlay and the engine's alias and legacy-build:rewrites, plus its plan-digest canonicalisation (.digest). Failures raise a typedContractLoadError. Thefluid_build.apiversion is now1.1. See Contract loading API.