Skip to content

docs: DuckDB 1.x to 2.x upgrade notes, and what a project's .env may set (Trello 1062, 845) - #166

Merged
fas89 merged 2 commits into
mainfrom
docs/release-leftovers
Oct 27, 2026
Merged

fas89 merged 2 commits into
mainfrom
docs/release-leftovers

Conversation

@fas89

@fas89 fas89 commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Two release tasks that were never written up. Branched off agenticstiger/main, not on top of #165 — one commit per card, and the two branches do not touch the same lines (git merge-tree reports no conflict either way round, so they can land in either order).

Closes Trello 1062 (P1) and Trello 845 (P2).

Trello 1062 — DuckDB 1.x to 2.x upgrade notes

0.22.0 raised the local engine to duckdb>=1.5.0 with no upper bound. The reason this is P1 is that nobody has to decide to upgrade: pip install -U "data-product-forge[local]" on a fresh environment, a rebuilt CI image, or another tool in the same environment pulling DuckDB 2.x in will all do it. A new DuckDB 1.x to 2.x section in upgrading.md says what that costs, with the table-of-three-columns shape the rest of the page uses.

Sourced from CHANGELOG.md's 0.22.0 upgrade notes and Fixed entries, and checked against build_runners/duckdb/runner.py, build_runners/_schema_evolution.py and providers/_duckdb_sandbox.py.

What breaks for someone still on 1.x, and what they change

  • Database files split. A DuckDB file another 2.x tool wrote — dbt-duckdb, dlt, or forge-cli 0.21.0 or older running on DuckDB 2.0 — cannot be opened on 1.5. The conversion (ATTACH … (STORAGE_VERSION 'v1.0.0'), COPY FROM DATABASE) is on the page. A file forge-cli itself writes on 2.x is in the format 1.5 writes, which DuckDB 1.0 and later open, and a file that already exists keeps its format — so a type needing a newer format, such as VARIANT, is refused in it on 2.x exactly as on 1.5.
  • Contract SQL runs on the installed DuckDB, and 2.0 changed some: x -> x + 1 is an error (write lambda x: x + 1), and integer // and % by zero raise instead of returning NULL (wrap in TRY(...)).
  • A DuckDB acquisition build now resolves a Postgres source's connection.secretRef. It ignored it before and libpq's PGPASSWORD or ~/.pgpass supplied the password, so a reference that does not resolve on the host running fluid apply now fails the build. Remove the secretRef to keep using PGPASSWORD; an inline password still wins.
  • Python 3.10 installs stay on 1.5.x, because DuckDB 2.0 needs 3.11, and nothing in the section applies to them.

The type and time-zone drift that landed after the 0.22.0 cut

Written as shipping in the release after 0.22.0 (forge-cli #749, on main). The page names the release rather than a number, and says in the admonition that the number is not settled — so there is nothing to go back and edit except that one sentence.

  • An incremental timestamp cursor written on 1.5 and read on 2.0 silently dropped or duplicated rows. The cursor is stored as text and pasted into WHERE <field> > '<text>' as an untyped literal, and the two versions disagree about the type it is coerced to: read_json_auto over a timestamp carrying Z gives TIMESTAMP on 1.5 and TIMESTAMP WITH TIME ZONE on 2.0, where a naive literal is read in the session time zone — the host's. On a host at a non-zero UTC offset the > boundary moved: a run that wrote its cursor on 1.5 and continued on 2.0 landed 5 of 6 rows and advanced the cursor past the row it skipped, at exit code 0 with the stream recorded succeeded. Both versions satisfy duckdb>=1.5.0, so one install can write state another reads. CSV sources were never affected.
  • A pipeline discovered on 1.5 failed SchemaDriftError on 2.0 on a false positive: the gate compared type names, and 2.0 infers HUGEINT where 1.5 infers DOUBLE for the same integer literal, and TIMESTAMP WITH TIME ZONE where 1.5 infers TIMESTAMP. Upgrading DuckDB — no contract edit, no new data — broke a working pipeline.

Your two accuracy notes are both on the page, and both check out in the code:

  • No state file needs rewriting — _canonical_cursor_text normalises a cursor a shipped 2.0 install already wrote, because that text carries an explicit offset.
  • Instants, not a session-zone pin — _cursor_comparison's own docstring gives the reason: TimeZone is ICU-backed, the sandbox forbids autoloading extensions, and without ICU DuckDB does not recognise the option at all, so a pin would fail the connection in exactly the hardened environments the sandbox exists for.

Three things the page adds that you did not ask for, because leaving them out would have overstated the fix:

  • The instant comparison is used only for a column whose type epoch_us accepts (_EPOCH_US_TYPES: the TIMESTAMP family, DATE, TIME). Integer, date-as-integer and opaque string cursors keep the plain comparison, since epoch_us raises BinderException on VARCHAR, INTEGER and DECIMAL. The choice is made from the column's type, never the cursor text's shape.
  • The accepted cost: wrapping the column in a function can stop the predicate being pushed into the Parquet or JSON reader, so a large source scans more.
  • DOUBLE and HUGEINT are not value-identical — for the same literal 1.5 landed a lossy 17-significant-digit float where 2.0 lands the exact 128-bit integer — so the non-failing path reports that the physical type moved. The widening/narrowing ladder is unchanged; the family check runs after it, so type_widened and type_narrowed still fire for every ordered numeric pair.

#749 also fixes a third defect you did not mention, and it is on the page: fluid init --discover emitted contracts its own fluid validate rejected (type: hugeint, type: '"null"'). Validating DuckDB's own duckdb_types() against the bundled schema found 35 rejected names, not the two reported, and uinteger and integer[] were rejected on 1.5 as well — so this was never only a 2.0 problem. Discovered types are canonicalised at the emitter, an unsigned or 128-bit type mapping to a width that holds its range (uinteger → bigint, hugeint → numeric).

Trello 845 — what a project's .env may set

New page: advanced/project-dotenv-trust.md, in Govern & secure in the sidebar and in the folder index.

Written from the code before a word of prose: credentials/protected_env.py, credentials/dotenv_store.py, cli/_common.hydrate_dotenv, build_runners/dlt/runner.py, and the tests/test_dotenv_* suite including the two you named.

  • Which files, in which order, and that only the three project files are filtered. FLUID_SECRETS_FILE counts only when the operator exported it before the call, so a path a project file sets is ignored and the file it names is never read. .env.<environment> is the --env value: hydrate_dotenv is called with environment=getattr(args, "env", None) from apply and publish.
  • Credentials load; settings do not. The five categories from the module's own classification — widening a sandbox or confinement root, moving where trusted configuration is read or state/audit/logs written, changing an authentication or trust mode, changing where data and credentials are sent, choosing which program or library runs — plus the fifth-and-a-half case, letting another tool re-read the files without the filter (PYTHON_DOTENV_DISABLED, LITELLM_MODE). And what does still load: credentials, the values the contract already decides, the budgets and timeouts.
  • The shape rule, so an unlisted FLUID_* name is protected when its suffix or fragment says it decides something. The page notes _MCP_ENV rather than a bare _ENV, because FLUID_ENV is the environment's name and loads.
  • Both warnings as they are actually worded, naming variables and the file that set each and never a value, and the detail that a name the operator already exported to a non-blank value is left out of the warning because their value is the one in effect.
  • The pairing rule, including why a decoy pairs nothing: a project credential a reader consults only after the keyring, or never, is not what goes out. The refusal advice only offers "put the credential in the same project file" where a credential there would in fact pair the endpoint.
  • dlt destinations are default-deny, at any depth and under every section path dlt reads a field under, with the in-account exceptions as a table taken from runner._DLT_IN_ACCOUNT_FIELDS rather than from prose.
  • Why dbt and litellm no longer re-read the project's files, and the three ways to get a variable to the dbt container.

Values read literally (interpolate=False) and names compared upper-cased are both on the page, because ${VAR} expansion and a case-differing name were how the original bug was reached.

Cross-links, which is where a reader actually arrives from: FLUID_DUCKDB_ALLOWED_DIRS on the sandbox page now says a project .env cannot set it either, and environment-variables.md carries the same warning at the top.

The sandbox caveat

A bullet in the DuckDB sandbox page's Limits list. lock_configuration does not hold on DuckDB 1.5.x for DuckDB's logging settings when they are reached as a table function rather than with SET; the sandbox's own path refusal then comes out of DuckDB's logger write path as an uncaught C++ exception that aborts the process (SIGABRT, reproduced on 1.5.1 and 1.5.6). It widens no grant — availability, not confidentiality — and the engine's own SQL never reaches it. What is exposed is a surface that runs SQL somebody else wrote, such as the MCP output port's query_sql under policy.allow_free_form_sql, whose SELECT-only gate a table function passes. DuckDB 2.0 refuses it cleanly, so the mitigations are 2.x where you expose free-form SQL, and otherwise leaving that policy off.

The bullet states the limitation, the blast radius and the two mitigations, and spells no reproduction.

Premise correction

Trello 845's feature shipped in 0.20.0, not at the 0.22.0 cut. credentials/protected_env.py was created on 2026-10-16 (29a46215, a fork merge), which is an ancestor of the 0.20.0 release commit 6b263f04 and not of 0.19.0's. It is advisory B of that release, GHSA-58px-v64p-6ppm. So it is a genuine leftover — it just has been undocumented for two releases longer than the card implies, and the page is marked Since 0.20.0 rather than deferred to the coming release.

What is post-0.22.0 there is two more protected names, FLUID_ACQUISITION_SECRET_REF_ALLOWLIST and TESTCONTAINERS_HOST_OVERRIDE (forge-cli #751). They get their own admonition, so they are easy to fold in when the release lands.

Trello 1062's premise holds as given: the floor moved in 0.22.0 (#740, in that release), and #749's three fixes are on main after the cut.

Verified locally

Reproduced from the workflow files, on python3.12 with data-product-forge==0.18.1 in a venv:

Command From Result
check_cli_docs.py --version-only cli-consistency.yml OK (0.18.1)
check_cli_docs.py cli-consistency.yml OK — 69 subcommands, 1333 invocations, every version claim
check_providers.py cli-consistency.yml OK
gen_contract_reference.py --check cli-consistency.yml OK
npm run docs:build docs-pr-check.yml exit 0, 243 pages (the new page is emitted)
node scripts/check-dist-links.mjs docs-pr-check.yml Clean — 138,929 absolute references, 243 pages
base-prefix-check link-check.yml OK, self-test passed
lychee-source link-check.yml 0 errors over 908 unique links; canaries 0 / 2 as required

cli-version.json is untouched in both commits. Nothing added here says "unreleased", so this branch also passes the release-marker sweep from #165 if that lands first.

Not claimed

The page does not say forge-cli validates anything against a released Bitol schema, does not name the coming release's version, and does not document the acquisition secretRef host policy itself (#750 / #751) beyond the two variable names above — that is a page of its own once the release is out, and it is on the follow-up list rather than widening this one.

fas89 and others added 2 commits October 27, 2026 00:25
0.22.0 raised the local engine's DuckDB range to `duckdb>=1.5.0` with no
upper bound, so `pip install -U "data-product-forge[local]"` can resolve
DuckDB 2.x — and so can a rebuilt CI image or another tool sharing the
environment, without anyone deciding to upgrade. The notes were never
written, so nothing told a reader what that costs.

A new "DuckDB 1.x to 2.x" section in the upgrade guide, sourced from
forge-cli's 0.22.0 upgrade notes and `Fixed` entries and from the code
(`build_runners/duckdb/runner.py`, `build_runners/_schema_evolution.py`,
`providers/_duckdb_sandbox.py`):

- The database-file split. A file another 2.x tool wrote — dbt-duckdb,
  dlt, or forge-cli 0.21.0 or older on DuckDB 2.0 — does not open on
  1.5. A file forge-cli itself writes on 2.x is in the format 1.5
  writes, which 1.0 and later open, and an existing file keeps its
  format. The conversion SQL is on the page.
- Contract SQL runs on the installed DuckDB, and 2.0 changed some:
  `x -> x + 1` is an error, and integer `//` and `%` by zero raise
  instead of returning NULL.
- A DuckDB acquisition build now resolves a Postgres source's
  `connection.secretRef`, so one that does not resolve on the apply host
  fails the build where libpq's `PGPASSWORD` used to cover for it.
- Python 3.10 installs stay on 1.5.x, because DuckDB 2.0 needs 3.11.

Then the two drift defects, as shipping in the release after 0.22.0
(forge-cli #749, on main). The page names the release rather than a
number, because the number is not settled.

- An incremental timestamp cursor written on 1.5 and read on 2.0
  silently dropped or duplicated rows: the cursor is text in an untyped
  literal, and a `Z` timestamp binds as TIMESTAMP on 1.5 and TIMESTAMP
  WITH TIME ZONE on 2.0, where a naive literal is read in the host's
  zone. A run landed 5 of 6 rows at exit 0 with the stream `succeeded`.
  It now compares instants. No state file needs rewriting, only a
  column type `epoch_us` accepts takes that path, and pinning the
  session zone was rejected because `TimeZone` is ICU-backed and the
  sandbox forbids autoloading extensions — a pin would fail the
  connection in exactly the hardened environments the sandbox is for.
  The accepted cost, lost predicate pushdown, is on the page too.
- A pipeline discovered on 1.5 failed `SchemaDriftError` on 2.0 because
  the gate compared type names, and 2.0 infers HUGEINT where 1.5 infers
  DOUBLE. Those are now one physical type under two names, with the
  widening/narrowing ladder unchanged and a note that the stored width
  does move, so the precision change is reported rather than hidden.
- `fluid init --discover` emitted contracts its own `fluid validate`
  rejected; 35 of DuckDB's type names are rejected by the schema, not
  the two reported, and two of those failed on 1.5 as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ello 845)

Advisory B of 0.20.0 stopped a project's `.env`, `.env.<environment>` and
`.env.local` from setting operator-only variables, and nothing on the
site said so. A reader whose committed `.env` carried
`FLUID_DUCKDB_ALLOWED_DIRS` or `HTTPS_PROXY` got a warning naming it and
no page explaining why, or where to put it instead.

A new page, `advanced/project-dotenv-trust.md`, written from
`credentials/protected_env.py`, `credentials/dotenv_store.py`,
`cli/_common.hydrate_dotenv`, `build_runners/dlt/runner.py` and the
`tests/test_dotenv_*` suite:

- Which files load, in which order, and that only the three project
  files are filtered — `FLUID_SECRETS_FILE` counts only when the
  operator exported it, so a path a project file sets is ignored.
- Credentials still load, and so do the values the contract already
  decides and the budgets and timeouts. Five categories do not: widening
  a sandbox or confinement root, moving where trusted config is read or
  state written, changing authentication or trust, changing where data
  and credentials go, and choosing which program runs.
- The shape rule, so an unlisted `FLUID_*` name whose suffix or fragment
  says it decides something is refused before anyone lists it.
- Both warnings as they are actually worded, naming variables and files
  and never values, and that a name the operator already exported is
  left out because their value is the one in effect.
- The pairing rule for third-party endpoints, including why a decoy
  credential pairs nothing: a project credential a reader consults after
  the keyring, or never, is not what goes out.
- dlt destinations are default-deny at any depth and under every section
  path dlt reads, with the in-account exceptions as a table
  (`runner._DLT_IN_ACCOUNT_FIELDS`).
- Why dbt and litellm no longer re-read the project's files, and the
  three ways to get a variable to dbt.

Values are read literally and names compared upper-cased; both are on
the page, because `${VAR}` expansion and a case-differing name were how
the original bug was reached.

The DuckDB sandbox page gains the 1.5.x caveat: `lock_configuration`
does not hold there for DuckDB's logging settings reached as a table
function rather than with `SET`, and the sandbox's own path refusal then
aborts the process from DuckDB's logger write path. It widens no grant —
availability, not confidentiality — and the engine's own SQL never
reaches it; what is exposed is a surface running SQL somebody else
wrote, such as the MCP output port's `query_sql` under
`policy.allow_free_form_sql`. DuckDB 2.0 refuses it cleanly, so the
mitigations are 2.x or leaving that policy off. The bullet states the
limitation and the fix and deliberately spells no reproduction.

`FLUID_DUCKDB_ALLOWED_DIRS` now says on the sandbox page that a project
`.env` cannot set it either, which is the consequence a reader hits
first, and the environment-variables page carries the same warning at
the top. Sidebar and folder index updated.

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