# Changelog

## 0.45.0

- **Fix: `check` no longer double-counts root files in a single-package workspace.** 0.42.0 added a lint pass for workspace-root files no module covers. It compared module paths as raw strings, so a module declared at `.` matched nothing — and a workspace whose only module *is* `.` had every one of its files linted and counted twice, reading `Lint (1 module + K root files)` where K was the module's own file count. The verdict was always right; the number was not. A module at the workspace root now owns the root, and `./pkg` is recognised as the same module as `pkg`.

## 0.44.0

- **Fix: a cached lint verdict no longer survives the tsconfig change that invalidates it.** `check`'s eslint cache was keyed on file content, the eslint config, the eslint version and the pipework version — not on the tsconfig the typescript project service resolves. But the type-aware rules produce their verdict from the program that tsconfig defines, and a *parse* error is a verdict about the tsconfig alone. So a parse error cached under one tsconfig state survived a `git checkout` that fixed it, and `check` went on reporting a red that nothing in the working tree explained until someone found `rm -rf node_modules/.cache/pipework`. Two changes, because a stale red should be impossible rather than unlikely: the cache path is now stamped with a hash of the resolved tsconfig chain — the nearest `tsconfig.json` above each lint target and everything its `extends` reaches — so any change to it points eslint at a fresh cache file; and a run that hit a parse error **deletes its cache file** rather than writing the error into it. An ordinary rule failure is a fact about the file's content and is still cached, so the cache keeps doing its job.

  Not covered by the fingerprint: tsconfig `references`, and a tsconfig outside the lint directory. Those move the program too; they are named here rather than implied to be handled.

## 0.43.0

- **`pnpm@10.33.0` is now the version pipework itself builds and tests on.** 0.41.0 made it the default written into a generated `package.json` while this repo still ran 9.15.4 — a default nothing exercised. The repo's own `packageManager` now matches, and the full gate (type-check, lint, boundaries, unit/contract, isolation) runs under pnpm 10. The lockfile is unchanged: pnpm 10 reads and writes the same `9.0` format, and no dependency needed a build-script allowance. Nothing about the generated output changed — this is the witness for the default 0.41.0 already ships.

## 0.42.0

- **Fix: `check`'s files-linted count now covers workspace-root files.** `runLint` iterated modules only, so a lintable file at the workspace root — outside every module — was never linted by `check` and never appeared in its count. The reading said "N files linted" over a population that silently excluded them, and the lint verdict for those files had to be produced outside pipework with a direct `eslint` run. Root files no module covers are now linted as their own pass and counted: `✓ Lint (2 modules + 3 root files)`, with the per-pass line naming them like any module's. Where eslint cannot reach them, the existing coverage refusal names each file and its reason instead of passing. Which files count as root sources follows `lint.include` (or `lint.modules["."].include`), defaulting to `src` as elsewhere.

## 0.41.0

- **New: `packageManager` is a `createManifold` field, and the default is now `pnpm@10.33.0`.** The generated root `package.json` carried `pnpm@9.15.4` as a literal with no way to change it. A consumer already on pnpm 10 could not pin their own: hand-editing the file and recomputing `_checksum` lasted exactly one run, because the next `pipework install` regenerated 9.15.4 and `pipework-guard` then refused the file as stale. The pnpm version on your box is a fact about your box, not about pipework. Set `packageManager: 'pnpm@10.33.0'` (or whatever you run) in `pipework.config.ts` and the generated file — and its checksum — follow. Unset, pipework writes `pnpm@10.33.0`. Only pnpm is accepted: the generated workspace is pnpm-shaped (`pnpm-workspace.yaml`, `pnpm install`), so an npm or yarn value is refused by name rather than written into a workspace that cannot use it.

## 0.40.0

- **Fix: `pipework install` no longer prints `✓ Install complete` over an install that did not happen.** The pnpm step was judged by its exit code alone. pnpm asks for confirmation before purging a modules directory, and with no TTY it takes the abort branch and still exits 0 — so under CI, a container, or any non-interactive shell, `install` reported success while `node_modules` sat at whatever version was there before. `install`, `add`, and `remove` now run pnpm with the modules-purge confirmation answered (`npm_config_confirm_modules_purge=false`), then **read `node_modules/pipework/package.json` back** and refuse when it is missing or older than the version the generated `package.json` asked for, naming both versions and the directory. Success prints the version actually resolved: `✓ Install complete — pipework@0.40.0 in node_modules.` An exit code cannot tell a real install from a green no-op; the version on disk afterwards can.

## 0.39.0

- **Fix: the orphan reaper drops only databases it created.** It swept `pg_stat_database` for every name matching `<project>_test_%` and dropped each row with `numbackends = 0`. A name without a run nonce got no advisory-lock check at all — it was treated as ownerless and dropped unconditionally — so any idle database that merely shared the prefix went with it. Observed on a host cluster carrying per-slot control databases named `<project>_test_<slot>`: all of them were dropped at once, the moment one run started while its neighbours sat idle. Sharing a prefix is not evidence of ownership. The reaper now requires the 12-hex run nonce every name it writes ends with; anything else inside the prefix is left alone and **reported** — `ReapResult.skippedDatabases` lists it, `pipework test --reap` prints it under "Left alone — not created by pipework", and a test run logs it once under `pipework:test-reap`. A deliberate skip that says nothing is indistinguishable from a reaper with nothing to do.

  The same reporting now covers the template pass, which already declined to touch a name in the template namespace with no content fingerprint but did so in silence.

  Two limits, stated rather than hidden. A foreign database in this project's prefix whose name happens to end in exactly 12 hex characters is indistinguishable from a clone and is still eligible — `PIPEWORK_TEST_NAMESPACE` is the way out of a shared prefix. And the check is the trailing nonce alone, not the fuller `_<seq>_<pid>_<nonce>` tail current names carry, so that an orphan left by an older pipework whose names had no clone sequence is still reachable by this reaper rather than stranded forever.

## 0.38.0

- **Fix: a setup connection that will not close is named, not swallowed.** `closeSetupConnections()` wrapped each close in `try {} catch {}` and cleared its registry entry regardless, so a connection that refused to close left its backend held with no record anywhere. The failure surfaced much later and in a shape that pointed at the wrong thing — a foreign pid in a `pg_stat_activity` witness, or a setup hook that ran out its timeout — both of which read as a test or box fault rather than as a close that failed. A failed close now throws `SetupConnectionCloseError`, naming every database whose connection would not close and the underlying error for each, and saying that those backends hold their locks until the process exits. Every connection that *can* close still does; the registry is still cleared, so a later `useSetupDb()` gets a fresh connection rather than the broken one.

  The harness's own `afterAll` is now `teardownAfterAll()` (exported from `pipework/test`): it closes the setup connections, the pool, and the test databases, and raises the close failure at the end. A connection that will not close must not cost the pool and the databases their teardown — that trades one leak for many — so every step runs and the file still fails by name.

## 0.37.0

- **Fix: `vitest.coverage` now lands where vitest reads it, and `coverage.enabled` exists.** Coverage was written into each *project*, and vitest treats coverage as a root-level option — so every modeled coverage setting was discarded in silence. `coverage.enabled` was not in the schema at all, which meant a preset that gates collection on an environment variable (`enabled: process.env.X !== undefined`) had no modeled home and was reachable only through `vitest.extra.test.coverage`. `vitest.coverage` is now applied at the config root, `enabled` is a modeled field, and a `coverage` key on a module or a profile is **refused by name** — with the module (and profile) it was found on, where to move it, and how to gate it. A setting that cannot take effect is worse than one that is rejected.

- **New: `vitest.pool` and `vitest.isolate` are settable, per workspace, module and profile.** Pipework forced `pool: 'forks'` and `isolate: false` into every project, so a suite that had been running under vitest's own default `isolate: true` quietly changed isolation the moment its config moved into `pipework.config.ts` — and nothing in `pipework check` said so. Both are now schema fields at all three levels — an explicitly set one wins over `extra.test`, the way `include`/`exclude`/the timeouts already do, while an unset one leaves pipework's default in place for `extra.test` to override as before — pipework's values remain the defaults (forks because a test that sets a process-wide Postgres session must not share a worker; no worker isolation because pipework's per-test database isolation already separates test state), and `pipework check` prints the effective pool and isolation on the Vitest config line, naming any module or profile that overrides them.

- **Fix: `vitest.exclude` adds to vitest's defaults instead of replacing them.** Vitest replaces `exclude` outright, so a config that excluded one glob silently stopped excluding `node_modules` and `.git` — and the suite would try to run every test in every installed package. `exclude` is now merged with `configDefaults.exclude` and deduplicated, at every level. A config that already re-spread the defaults by hand keeps working; the duplicate entries collapse.

## 0.36.0

- **New: `lint.include` — a module says where its sources are.** `createLintConfig` accepted an `include` option and `resolveLintConfig` never passed one, so `lint.include` in `pipework.config.ts` was inert and the file globs were always rooted at `src`. A package whose code lives under `app/src` could not be linted from its own root: the generated `eslint.config.js` matched nothing there, eslint answered "File ignored because no matching configuration was supplied", and the run exited 0. Hand-editing was no fix either, because the same resolver serves the root and the per-module pass. `lint.include` is now read, and `lint.modules.<path>.include` overrides it for one module; either replaces the derived default (which stays `src` plus the `src` of each workspace package under the module). `pipework check` reads the same setting, so the directories it points eslint at and the globs in the config no longer disagree.

- **Fix: a lint pass that read zero files fails instead of reporting ✓.** `check` returned success when a module had no `src` directory, and eslint exits 0 when its globs match nothing — so a module with no sources, a mistyped `include`, or a glob pointing at a directory that no longer exists all reported Lint green. That is the silent-green class: a check that ran over nothing is not a check that passed. A module whose globs reach no file on disk, match no file, or match only ignored files now fails, printing each glob it tried, why it came up empty, and the `lint.include` key to set. If the module genuinely has no sources, remove it from `modules` rather than letting an empty pass stand in for a real one.

## 0.35.0

- **Fix: a nested workspace package's sources are linted, and `pipework check` refuses a Lint pass that covered nothing.** In a repo where a workspace package is itself a workspace root — `packages/backend` declaring its own `app/*` — nothing under `packages/backend/app` was ever linted, and the package still reported Lint ✓. Two independent causes, both now closed. eslint resolves a flat-config `files` glob against the directory holding `eslint.config.js`, and the generated config carried the single glob `src/**/*.ts`; from `packages/backend` that covers `packages/backend/src` and nothing else, so every file under `app/*/src` matched no configuration block and eslint answered "File ignored because no matching configuration was supplied". Separately, `pipework check` pointed eslint at `src` alone, so those files were never even offered to it. `resolveLintConfig` now emits one glob per workspace package under the config's directory, nested workspaces included, and `check` lints each of those `src` directories. The generated `eslint.config.js` passes its own directory (`import.meta.dirname`) rather than relying on the process cwd, so the globs are rooted correctly however eslint is invoked — **the generated file has changed, so `check` will report it stale until you run `pipework install` once.**

  The second half is the verdict. A lint pass that linted zero files used to be indistinguishable from a clean one, which is how this survived: the exit code was 0 and nothing said what had been examined. `check` now reads eslint's report rather than only its exit code, prints the file count and the directories each module's pass covered, and **fails** when a source file in scope was not linted, naming each one. A file left out by a deliberate `ignores` pattern is a choice, not a failure: `check` asks eslint for its own reason before refusing, reports the count of pattern-ignored files, and fails only on the ones eslint could not configure at all. A green Lint now means the files were read.

## 0.34.0

- **`jobs.execute` takes an `AbortSignal`, and a worker's job timeout now cancels the job instead of walking away from it.** There was no way to end a running job from outside it. A caller that wrapped `jobs.execute` — a test bridge, a request that hands work to a job and gives up on it, a shutdown path — could stop *waiting*, but the job kept running and its Postgres backend stayed connected until the process exited; measured downstream at two orphaned backends per wedged test, each holding its transaction. 0.33's per-clone `statement_timeout` / `idle_in_transaction_session_timeout` bounded that at two minutes, which is a backstop, not a deadline. `ExecuteJobOptions.signal` is now the deadline: on abort, the job's in-flight statement is cancelled on the server (`pg_cancel_backend`, issued from a second pooled connection), so the query fails with `query_canceled`, the transaction rolls back and the connection returns to the pool — and `execute` rejects at the caller's moment, not the handler's. It rejects with `signal.reason` when the caller aborted with an `Error`, otherwise with a `JobAbortedError` (`name` is `'AbortError'`, so the ordinary `err.name === 'AbortError'` check works) naming the job's type and id. A signal that is already aborted rejects before the handler runs at all. Every job path honours it: no-tenant, single database, multiple databases, and manual-checkpoint handlers.

  The same signal fixes the worker's own timeout, which was advisory. `.timeout(ms)` raced a timer against the handler and rejected the wait — the handler carried on, so the job pipework had just recorded as failed was still holding a transaction and a backend, and the timer itself was never cleared on the normal path. The worker now aborts the job with that timeout error as the reason, so the timeout ends the work rather than only ending the wait; the failure message a job is recorded with is unchanged. The deadline still holds either way — a handler that ignores its cancelled query does not keep the worker slot — but what it now costs the database is nothing.

  One honest limit: cancellation reaches a handler that is *in a query*. A handler spinning in JavaScript with no statement outstanding cannot be interrupted from the database side; the rejection still arrives on time and the transaction unwinds when the handler eventually returns.

## 0.33.0

- **Fix: an update that names a column the table definition does not have is refused instead of writing nothing.** `.set()` was mapped to SQL by walking the *table's* columns and keeping those with a value in the set object — so a key with no column of that name was never looked at. The write vanished: no error, no warning, and the statement still reported rows updated. The way to reach it is ordinary — a column added by a migration and not yet folded into the definition, or a typo'd key in a patch object that arrived as `Record<string, unknown>`, which is exactly when the type system is not watching. The physical column exists, so nothing downstream complains either. `pipe.update(...).set(...)` and the `.set(...)` of `pipe.upsert(...)` now throw on such a key, naming the key, the table, and the columns the table does carry, and saying that a migration-added column has to be added to the definition before it can be written. A set that mixes a known key with an unknown one writes nothing rather than writing the known half. An `undefined` value is still ignored for an unknown key exactly as it is for a known one, so a spread of a partly-empty patch object behaves as before.

- **New: `pipe.sql`, the raw statement tag.** It was documented as part of the namespace and was not exported — the tag existed internally and pipework's own modules used it, but no consumer could reach it. The effect was the opposite of the intent: with no tag, `db.execute()` could not be given a statement, so a consumer needing something pipework does not model (an extension call, a `set_config`, an advisory-lock variant) opened a raw connection with `tap()` — outside the request transaction, outside the tenant's `set_config`, and outside audit. `pipe.sql` runs on the caller's connection, so inside a request it is the request's transaction, and interpolated values are parameters. What stays deliberate: writes go through `pipe.update / insert / delete / upsert` so audit can hook them, and the boundary check still refuses raw drizzle writes — exporting the tag does not touch that.

- **Per-test databases now carry a statement timeout and notice a dead client.** Teardown terminates a test file's backends before dropping its database, which covers an orderly finish. A test process killed by pid does not get that far: its backends keep running whatever statement they are inside, because Postgres learns the client is gone only when it next writes to the socket — and a long statement never gets there. The orphan holds locks, keeps `CREATE DATABASE ... WITH TEMPLATE` raising `55006` against the template, and blocks the drop until it finishes on its own. Every per-test clone is now created with `statement_timeout` (`test.statementTimeoutMs`, default 120000), `idle_in_transaction_session_timeout` (`test.idleInTransactionTimeoutMs`, default 120000) and `client_connection_check_interval` (`test.clientCheckIntervalMs`, default 1000) — the last is what aborts a backend mid-statement when the peer is gone, on Linux; elsewhere Postgres accepts the setting and ignores it. Any of them set to 0 is left alone. These are set on the clone only, never on the template: a migration replay on the template is legitimately long, and per-database settings do not copy with `WITH TEMPLATE`. A suite with a test that legitimately runs a single statement past two minutes should raise `test.statementTimeoutMs` to fit it.

## 0.32.0

- **A no-transaction migration interrupted mid-file now reruns to completion instead of becoming hand surgery.** A file carrying `-- pipework:no-transaction` runs its statements outside a transaction, so there is no rollback: a runner killed between statements left the database half-changed with no tracker row — the row is written last — and the rerun replayed from the top and died on the first already-applied statement (`relation already exists`). Nor could the file be edited to add `IF NOT EXISTS` guards, because a hash mismatch is refused as `Applied migrations are immutable history`. There was no way forward from the runner. Each statement's completion is now recorded as it lands, in a `<migrationsTable>_progress` companion table, and a rerun starts from the first statement not recorded — statements that already ran are never replayed, the tracker row lands, and the migration becomes ordinary applied history. The mark is written *after* the statement it describes, deliberately: marking first would let a death between the mark and the statement skip work silently, which is worse than replaying one. So one statement — the one whose mark had not landed yet — can still be replayed, instead of all of them. The progress row is deleted the moment the tracker row is written, so the two records can never disagree about whether a migration is done, and the table is created only for databases that actually have a no-transaction migration. The content guard still holds and is now specific: a recorded partial whose file has since changed is refused with its own message, saying how many statements ran, that the count indexes into the file as it was, and the two ways out (restore the file and let the resume finish, or delete the progress row if the partial work has been undone by hand) — rather than the generic immutability error, which named a cure that did not apply. `applyMigrations` takes an `onResume` callback, `MigrateResult` carries `resumed`, and `pipework migrate` prints a warning naming the tag and the statement it resumed from — finishing a half-applied file says so out loud, because it means a previous run died mid-file.

## 0.31.0

- **A finished test file no longer waits out a cluster-wide checkpoint to drop its database.** `CREATE DATABASE ... WITH TEMPLATE` was suspected of forcing checkpoints; it does not. Since PG15 its default strategy is `WAL_LOG`, which needs no checkpoint, and pipework issues no `STRATEGY` clause — measured on PG17, forty clones force zero. Every `DROP DATABASE` forces one: Postgres's own `dropdb()` calls `RequestCheckpoint(CHECKPOINT_IMMEDIATE | FORCE | WAIT)`, so the dropping backend blocks until the whole dirty cluster is synced. Teardown dropped one database per test file, which put that wait inside every `afterAll`; on a saturated cluster single checkpoints were measured at 47.1 s / 28.6 s / 24.4 s, which is a teardown hook timing out on a run that leaked nothing. Teardown now terminates the file's backends and **queues** the database instead of dropping it. The queue is emptied when it reaches `test.dropBatchSize` (default 8) and again at worker shutdown, so one teardown in eight pays the wait rather than all of them, and the rest of a worker's drops settle after its files are done instead of in the middle of a wide start burst. The number of checkpoints is fixed by the number of databases and batching does not change it — what changes is which hook is standing there waiting. Each flush logs one `pipework:test-drop <batch|shutdown> queued=… dropped=… elapsed_ms=…` line, so what the drops cost is a number rather than an inference. Two supporting changes: a clone's name now carries a per-clone sequence number (`<project>_test_app_3_<pid>_<nonce>`) so the next file in a worker never asks for a name still queued for drop, and the create path's own `DROP DATABASE IF EXISTS` is gone — with unique names it could only ever be a no-op, and a non-no-op would have been a checkpoint on the create path too. Nothing leaks: a worker killed with drops still queued loses its run lock with its connection, and the next run's reaper drops them exactly as it always has. `flushPendingDrops()` is exported from `pipework/test` for a consumer whose own teardown wants the queue settled before it looks at `pg_database`. Set `test.dropBatchSize: 1` to restore the drop-per-file behaviour.

## 0.30.0

- **Test setup narrates its steps, and its own bound is on progress rather than on a wall clock.** `setupTestDatabases()` was a single awaited call: a consumer's `beforeAll` had nothing to log while it ran, and nothing to say when it did not finish. Under a width-40 start burst, a setup that was merely queued behind its peer forks — advancing the whole time — was killed by vitest's 60 s hook timeout and reported as `Hook timed out in 60000ms` with no test assertion attached, a reading about the host rather than about the suite. `setupTestDatabases(instance, { onProgress })` now reports every step it reaches: `rls-probe`, `run-prepare`, `template-pin`, `template-wait` (once per poll while a peer builds), `template-build`, `template-migrate` (once per migration file), `template-ready`, `queue-check`, `clone-start`, `clone-done`, `ready`. Each report names the phase, the database it is about, the total elapsed time, and the phase it displaced with how long that held — so a caller can log the steps, or time them, without parsing prose. Setup also bounds itself now, and the bound is on PHASE ADVANCE, not on total elapsed time: a setup that keeps reaching steps finishes late and green however long the whole thing takes, while one that stops advancing for `test.setupStallMs` (default 120 s) fails as `SetupStalled`, naming the last phase reached, the database, how long it has been stuck, and the total elapsed. The default setup file installs a listener that stays silent while setup is quick and writes one `pipework:test-setup slow phase=… db=… held_ms=… next=… elapsed_ms=…` line per phase that held 10 s or longer — so a healthy run gains no output and a late one explains itself. `applyMigrations()` takes a matching `onMigration` callback, which is what keeps a long migration replay from looking like a stall. Existing callers are unaffected: the options argument is optional and setup's behavior without it is unchanged.

## 0.29.0

- **New: `closeSetupConnections()` lets a consumer close the harness's setup connections before their own `afterAll` looks at the database.** The connections `useSetupDb()` opens are closed by `setupPipeworkTests`' own `afterAll`, and under vitest's `sequence.hooks: 'stack'` that hook runs *after* every setupFile `afterAll` — so a teardown-time observer sampling `pg_stat_activity` to name leaked backends saw every setup connection still open and could not tell them from a real leak. The internal `_closeSetupConnections` was not reachable through the `pipework/test` entry, leaving copying `useSetupDb` app-side purely to win the hook order as the only way out. `closeSetupConnections()` is now exported from `pipework/test`: close first, then sample, and what remains is a true survivor set. It is idempotent, a connection that throws on close is still tolerated, and calling it early does not disturb the harness's own teardown — the pool still closes and the databases still drop afterwards. A later `useSetupDb()` opens a fresh connection. Hook order is unchanged for everyone else.

## 0.28.0

- **Fix: a queue column of the wrong type or nullability is caught at startup, not by a fence that silently never matches.** 0.27.0 made the queue check compare column names, so a table missing `claim_token` fails loudly. A column can still be present under the right name and be the wrong column: `ALTER COLUMN ... SET DATA TYPE` and `DROP NOT NULL` are migration steps a consumer can skip exactly as they can skip an `ADD COLUMN`, and the result is quieter than a missing column — `claim_token` as `text` compares wrong against a `uuid` rather than erroring, so a fence built on it never matches and nothing says why. The check now compares each required column's type and nullability too, and reports a drifted column the way it reports a missing one: which table, which database, which column, what the definition requires, what the database has, and the generate-then-migrate cure. What counts as compatible is deliberate. Type is canonical identity rather than spelling — the definition's declared type is resolved through Postgres's own `to_regtype` and the OID compared, so `timestamptz` and `timestamp with time zone` agree without a hand-maintained alias table. Length and precision are ignored: `varchar(64)` against `varchar(128)` is not drift, because a check that reports false drift on a healthy table gets switched off. Nullability is reported in one direction only — nullable where the definition requires NOT NULL breaks a promise the definition makes; stricter than the definition asks cannot, and may be deliberate. Defaults are not compared. Still two queries whatever the number of queues, and still read from `pg_attribute`, so an unprivileged role is not told to migrate when the cure is a GRANT. Note this is newly blocking on upgrade: a consumer whose queue table has drifted starts on 0.27.x and gets a startup refusal on 0.28.0 — that is the point of the check, and the cure it names is `pipework generate` then `pipework migrate`.

## 0.27.1

- **The missing-test-URL refusal says where to set the variable.** Configuring a database in test mode without its test URL threw `Missing test database URL: environment variable "X" is not set` with the hint `Set X in your .env file or environment` — wrong on both counts, since `.env` is the last file the loader reads and the wrong tier for a test URL. The hint now names `.env.test`, with `.env.test.local` for a machine-local value — the tiers the loader actually reads first.

## 0.27.0

- **Fix: a queue table that exists with the wrong shape is now caught at startup instead of at the first `claim()`.** The queue check probed `to_regclass` and nothing else, so it proved a registered queue's table existed and never that it had the columns the definition requires. 0.26.0 added `claim_token`, which made that gap reachable: a consumer who upgraded without re-running `pipework generate` and `pipework migrate` validated clean, then failed at the first claim with a raw `42703 column "claim_token" does not exist` from inside the queue's own UPDATE, with nothing pointing at the cure. The check now compares the table's columns against the ones the queue definition builds — read off that definition, so it cannot drift from it — and a table missing any of them fails by name, saying which table, which database, which columns, and that the cure is generate then migrate. A missing table still produces exactly the message it did before. Extra columns are ignored on purpose: a consumer may add their own to a queue table, and the check cannot tell those from stale ones without making a destructive suggestion. The whole check is two queries whatever the number of queues, down from one round trip per table.

## 0.26.0

- **New: `claim()` mints a per-claim token, and every release clears it.** A consumer that fences a claimant's writes on the row it claimed had nothing exact to compare against: `attempt_count` is reset to zero by `requeueDeadLetter`, and `claimed_at` is `timestamptz(6)` read back as a millisecond JS `Date` — so an equality check never holds, and rendering it as text depends on the `TimeZone` and `DateStyle` GUCs. The queue table now carries a nullable `claim_token` uuid, set to `gen_random_uuid()` inside `claim()`'s own `UPDATE` and returned on the claimed job as `claimToken`. Because it is minted by that one statement, it cannot be observed or forged between the claim and its return, and a second claim of the same job — after a retry — always mints a different one. Every path that ends a claim nulls it: `complete`, `cancel`, `fail` (both the retry and the dead-letter branch), `reap`, and `requeueDeadLetter`. `heartbeat` does not release a job and leaves it alone. So a fence is an exact uuid comparison with no grain and no rendering: consumers holding a millisecond-window comparison as a bridge can drop it. The column is nullable with no default — a default would mint a token at enqueue and break the rule that only `claim()` mints one — and existing queue tables gain it through the ordinary generate path as `ALTER TABLE ... ADD COLUMN`, keeping their rows.

## 0.25.1

- **A wide test run now says when it is waiting on template contention.** `CREATE DATABASE ... WITH TEMPLATE` retries SQLSTATE 55006 (object_in_use) across a ladder of delays totalling ~9.6 s, and until now it logged nothing for any of them: a run whose forks were burning seconds waiting on the shared template and a run with no contention at all produced identical output, and only exhausting the ladder said anything. There are now three lines. Each retry logs the SQLSTATE, which retry this is out of how many the ladder allows, the delay about to be slept, and the wall-clock milliseconds elapsed so far. An operation that succeeded only after retrying logs one `recovered` line naming how many retries it cost and the total wall-clock milliseconds it took. One that runs the ladder out logs one `exhausted` line carrying the same two numbers before the error propagates — exhaustion states itself instead of being inferred from a missing `recovered` line, which a fork killed mid-retry by a hook timeout or an OOM would look exactly like. Elapsed is measured from before the first attempt, so it counts the failed round-trips and not just the sleeps. Two operations retry 55006 and each gets its own prefix: `pipework:template-clone` for a test file cloning the shared template, and `pipework:template-drop` for dropping a dead builder's leftover template before rebuilding it. So a tally of clone contention counts only clones, while drop contention is visible under its own name rather than conflated with it. Every line starts with its prefix and carries `key=value` fields, so a run's output can be grepped and the numbers pulled out directly. An operation that succeeds on its first attempt is still silent, so this adds no line per test file to an uncontended run, and an error that is not 55006 logs nothing here at all. The retry ladder and the errors raised are unchanged.

## 0.25.0

- **Fix: a wide test run no longer serializes ~80 forks on the shared template's build lock.** Every fork that found the template unbuilt took the exclusive build key in turn — probing, releasing, and handing it to the next — so a batch arriving together traversed one lock queue end to end, each waiter's admin connection parked in `wait_event_type=Lock` with no bound on the wait. Measured on two width-40 vitest chains sharing one cluster, that queue was the serializer: `pg.lock_waiters` climbed 15→29 and the longest query aged past 187 s, and because the parked connection is the fork's own admin client, `teardownTestDatabases` queued behind it until vitest's 60 s hook timeout fired — reported as "Hook timed out" in an `afterAll` on files that had leaked nothing. A fork that finds the template ready now never touches the build key at all; a fork that finds it unbuilt *tries* the key without blocking, and the one that wins builds while the rest wait by re-asking whether the template is ready, on the shared use-lock alone. One build, no replay, no lock queue. A builder that dies still hands off — its lock goes with its connection and the next poller wins the key. The wait is bounded by `test.templateBuildWaitMs` (default 120 s): overrunning it fails right there, naming the build key, the builder's pid, and how long it waited, instead of surfacing as a hook timeout somewhere unrelated. The ordering that protects a template from the reaper is unchanged — the shared use-lock is still taken first and held for the process's lifetime — as is the rule that a transient connection failure is never read as "not ready".

## 0.24.1

- **Fix: a route screen can read the database.** The screen ran as a Fastify `onRequest` hook, before any pipework context existed, so `pipe.system()` (or any `pipe()`) inside it threw and every screened request answered `500 ScreenFailed` — a screen could only look at headers it was handed. The screen now runs inside its own minimal request context, so `pipe.system()` just works: a screen can look a header up against a table and answer `409 { reason }` before the body is read. That context carries no auth and opens no transaction — the screen is still pre-parse and pre-auth, its reads see committed data only, and the body is still never buffered for it. The handler's own context, auth chain, and transaction-per-request are unchanged.

## 0.24.0

- **New: a route can refuse a request before its body is read.** Fastify's body limit answered an oversized submission with a bare 413 and no way to say anything else: the Fastify instance is hidden behind `PipeworkServer`, and `.rawBody()` buffers the whole body before a handler sees it. `.route(method, path, { screen })` takes a pipework-owned function that runs before the body is parsed and before the body limit applies. It is shown the method, url, headers, and the declared `content-length` — never the body, which is not buffered for it — and either returns nothing to let the request through or returns `{ status, reason }` to answer it there and then, with any extra fields merged into the response body alongside `reason`. So a verify door can answer a >64 MiB submission carrying a lagging base header with `409 { reason: 'WALK-LAG' }`, while the same oversized submission without that header still meets the ordinary 413. A screen that throws answers 500 with a message naming the route, quoting the error, and saying what a screen must return. Routes without a screen are unchanged.

## 0.23.0

- **Fix: a worker whose claims are failing is now loud instead of silent.** The claim loop caught every error from `queue.claim()` with a bare `catch` and slept the poll interval, so expired credentials, schema drift, a poisoned claim query, and a lost SERIALIZABLE all looked exactly like an idle queue — no log line to grep for, nothing that moved. Each failing claim is now logged through the base logger with its Postgres SQLSTATE and a running count of consecutive failures; a successful claim clears the count. After `maxConsecutiveClaimFailures` (new `surface.worker()` option, default 10) the surface logs fatal, stops its claim loops, and sets a non-zero process exit code, so a worker that can never claim again dies instead of polling forever. A claim aborted by shutdown is not a failure and is not counted.

- **Fix: `pipe.serializable()` inside an open transaction is refused by name rather than silently weakened.** Postgres rejects `SET TRANSACTION ISOLATION LEVEL` on a transaction that has already run a query (25001), and a nested transaction is a savepoint that inherits the outer, weaker isolation level. Both cases now throw a pipework error naming the condition and pointing at the outermost transaction. This is what the exclusion claim needs: it requires SERIALIZABLE to avoid write skew, and it can no longer run at a weaker level — or fail with a bare 25001 on stderr — without saying so.

## 0.22.0

- **New: `PIPEWORK_CONFIG` names the config file to load, for callers that deliberately set it.** A consumer's vitest fork had no way to learn its database URLs without importing the whole application graph through the real `pipework.config.ts` — measured at roughly 150x the cost of the template clones the run actually needed. Setting `PIPEWORK_CONFIG` points `discoverInstance` at a specific config file (resolved against the cwd when relative), so a test runner can hand its forks a slim test-only manifold while every non-test path still discovers the real config by walking up for `pipework.config.ts`. A variable naming a file that does not exist is a `ConfigError` quoting the variable and the resolved path — never a silent fall-through to discovery. Nothing in pipework sets or defaults it, so the lint resolver, `check`, and `generate` are untouched; with the variable unset, behavior is unchanged. The test fork consumes the variable: `discoverForTestFork` unsets it as soon as discovery succeeds, so a test that spawns another package's `pipework migrate` cannot inherit this fork's config and migrate the wrong package against the wrong manifold.

- **Fix: a wide test run can no longer destroy the shared template it is cloning from.** Before each `CREATE DATABASE … WITH TEMPLATE`, setup terminated every *idle* client backend on the template. A peer fork's readiness probe is exactly that for the few milliseconds between connecting and disconnecting, so on a run several files wide the probe was killed mid-connection, its `catch` answered "template not ready", and that fork took the build lock and dropped and rebuilt the template every other fork was cloning from — measured on an 8-vCPU runner as 13 red files that were green serially, with `terminating connection due to administrator command`, `database "…tmpl_app_…" does not exist`, and `read ECONNRESET` in the logs. Two halves are fixed. The readiness probe now distinguishes an answer from a failure: absent database, missing marker table, or a fingerprint mismatch is `false` and grounds for a rebuild, while a terminated or reset connection is retried and, if it never gets through, throws — a blip can no longer trigger a rebuild. And the pre-clone terminate now targets only backends idle for more than five seconds — real stragglers, not live peers — with the `55006` clone backoff extended to about ten seconds so it waits out realistic contention.

## 0.21.0

Five defects in scoped `check`/`test`, all reported from the field with recurrence evidence. The connecting fault: what a scoped run *claimed* was never pinned to what it actually *evaluated*.

- **Fix: affected-module narrowing now works when `pipework.config.ts` sits below the git repository root.** Three path coordinate systems met in the diff-to-module mapping: `git diff --name-only` prints repo-root-relative paths regardless of cwd, `git ls-files --others` prints cwd-relative paths, and module paths are config-dir-relative. They only agree when the config dir *is* the repo root — in a monorepo layout (`<repo>/packages/backend/pipework.config.ts`) every changed-file path carried the `packages/backend/` prefix, never matched any module, and the "root file changed" fallback silently widened every scoped run to **all** modules. Affected-narrowing was dead in practice in exactly the layouts that need it most. Every git query now runs from the repo toplevel and every path is rebased into the config dir's coordinates before matching. A change genuinely outside the config dir's subtree still fails wide to all modules — that behavior was intentional and remains.

- **Behavior change: bare `pipework check` / `pipework test` now scope to the branch diff (merge-base against the base branch, through the working tree), not the dirty tree alone.** The old bare form evaluated only uncommitted changes, so on a branch with committed work and a clean tree it evaluated *nothing* and exited 0 — `No affected modules` — a vacuous green recorded three times by one consumer's CI-shaped flows. The bare form now sees committed, uncommitted, and untracked work since the merge-base. Unlike `--fast`, it keeps every test tier. `--staged` (pre-commit) and `--all`/`--full` are unchanged; when no base branch can be found, the run fails open to all modules, loudly, rather than silently checking nothing.

- **Every scoped run now prints its population line.** `Scope: diff vs merge-base with 'integration' — 4 changed files`, `Scope: staged changes — 0 changed files`, `Scope: every module (--all/--full)` — and the "No affected modules" exit now states the basis it rested on. Scope-of-invocation is always visible next to scope-of-claim, so a green can never silently rest on an empty basis.

- **New: `fastPath.baseBranches`.** The branch-diff base list was hardcoded to local `integration`/`main`; a repo with a different trunk name, or a worktree whose local base refs are stale, failed wide on every fast run with no recourse. Configure any committish priority list — `baseBranches: ['origin/main']` diffs against the remote-tracking ref regardless of local branch state.

- **Missing database env is now refused in one batch, and only by runs that actually connect.** Previously the first unset variable aborted config load — fix it, rerun, hit the next — and the refusal fired at load time, so test legs that never open a connection (type-only suites) still demanded a full database environment. URL resolution is now attempted for every database up front but thrown on first URL *access*, naming every missing variable at once. `pipework test` skips database setup entirely when every selected project opts out via `database: false` (profile/module/workspace chain) — a DB-free leg no longer costs a Postgres provision. Safety refusals (testUrl identity, RLS-defeating shapes) are unchanged in content; they now fire at first URL access, which every connecting run performs.

## 0.20.0

- **The test template is now shared across processes and keyed by its contents, so a suite pays for its migration stack once per run instead of once per test file.** The per-file test database has been a `CREATE DATABASE … WITH TEMPLATE` clone since 0.16 (#321), but the template it cloned from was cached in a module-local map and named after `process.pid` plus a per-process nonce. Both are process-scoped, and a consumer running vitest with `pool: 'forks'` and `isolate: true` gets a fresh process per test file — so every file did an unconditional `DROP DATABASE` / `CREATE DATABASE` / full migration replay for a byte-identical result. One reporting consumer measured 504 files × (111 migrations + extensions + framework tables); on a constrained box the suite did not finish at all.

  A template is now named for the hash of everything that determines its contents: the migrations folder's bytes, the migrations table, the extensions, the trace and audit config, the connecting role, the pipework version, and the calendar day. Any process — in this run or a later one — that wants that exact content clones the template that is already there. Per-file **clone** databases are unchanged: still keyed by pid and run nonce, still dropped at teardown. Nothing about isolation between test files changes; only the read-only template stops being rebuilt.

  Content keying makes invalidation automatic rather than manual. Edit a migration and the hash changes, so you ask for a template that does not exist yet and it is built; the old one is named something nobody will ask for again, and the reaper drops it. The day is part of the hash because trace and audit day partitions are provisioned relative to "today" — a template built yesterday is missing today's partitions.

  Concurrency is handled with two advisory locks in the same family as the existing reap machinery. Every process holds a SHARE-mode lock on the template's content key for its whole life, and the reaper drops a template only under an EXCLUSIVE one, so a template can never be pulled out from under a process sitting between its readiness check and its clone. A separate EXCLUSIVE build lock means N processes starting together produce one template and one migration replay, not N. A template records a marker row as the last act of a successful build: a database without it is the wreckage of a builder that died mid-migration, and is rebuilt rather than cloned.

  There is deliberately no fallback. If the shared template cannot be built or validated, test setup fails loudly — quietly reverting to a private per-process build would re-hide exactly the cost this removes. Issue #340.

- **Test databases and templates now live in separate namespaces**: clones stay under `<project>_test_`, templates move to `<project>_tmpl_`. The app role a test connects as is content-keyed to its template (`<project>_app_<database>_<fingerprint>`) rather than carrying a pid. If you assert on these names, they have changed. Old-format leftovers from previous runs are reaped normally.

- **Fix: `pipe.sql` no longer overflows the stack or spends quadratic time on deeply composed queries.** Building a query by folding fragments — `acc = sql\`${acc} union all ${next}\`` — nests one `SQL` per fragment, and the builder recursed once per level: past a few thousand fragments it died with `RangeError: Maximum call stack size exceeded`, and before reaching that limit it accumulated the query text with repeated `+=` across the recursion, which is quadratic in total string work. One reported case burned 26 minutes at 100% CPU and 44 GB RSS before throwing. The builder now traverses with an explicit work stack and joins the text once, so depth costs heap instead of call frames and assembly is linear. A 200,000-fragment composition builds in well under a second. Emitted SQL, parameter order, and typings are unchanged. Issue #339.

## 0.19.1

- **Fix: `pipework check` no longer exits 0 when `pipework.config.ts` cannot be loaded.** A config file that exists but throws on import — a missing `DATABASE_URL`, a bad import, any evaluation error — was swallowed by a bare `catch` in module-scope resolution. `check` then fell back to filesystem discovery, and in a package with no `pnpm-workspace.yaml` that resolves to *no* modules, so lint, type check, doctrine and the flow checks never ran and the run still printed `All checks passed` and exited 0. As a gate, that is the worst possible failure direction: a broken config, or any environment that cannot resolve one, passed silently, and nothing distinguished "lint ran clean" from "lint never ran". The load failure is now its own reported step (`Config load`) that fails the run; the remaining steps still execute so one run surfaces everything it can, but none of them can green it. Issue #335.
- **Fix: an unknown `--module` is now a hard error rather than a silent rescope.** `pipework check --module typo` raises `Unknown module "typo"` with the known modules listed — the same bare `catch` used to swallow that error too, and check quietly ran over a module set nobody asked for.
- **`check` now warns when it resolves no modules at all** outside a `--staged`/diff scope, instead of letting the absence of every per-module step read as a clean pass.

## 0.19.0

- **Breaking: `--allow-destructive` and `--acknowledge-lock` are removed and now fail loudly. Authorization for a hazardous migration is declared in that migration's own file.** A flag authorizes an *invocation*, and invocations live in deploy workflows: the first destructive migration that has to ship puts the flag into CI permanently, and from that moment every later migration is pre-approved by a decision nobody revisits, invisibly, in a file no migration review ever opens. 0.18.0 made the gate see correctly; this makes the gate's answer mean something in an unattended pipeline, which is where migrations actually run.

  A pragma authorizes a *file*:

  ```sql
  -- pipework:expect-destructive
  DROP TABLE "graph_co_change_edges";
  ```

  It is written by the person writing the destructive statement, at the moment they write it. It appears in the diff, so it is reviewed. It is versioned, so history records who approved which drop. It cannot go blanket — a pragma in `0065` says nothing about `0071`, and each hazard class needs its own (`expect-destructive`, `expect-locking`, `expect-dynamic`); declaring one does not declare the others. The deploy command stays a bare `pipework migrate` forever, so there is no flag left to erode. Pragmas are recognized only on their own comment line, so prose mentioning one authorizes nothing, and the tracker's existing content-hash check means one cannot be added to an already-applied migration unnoticed.

  Passing either flag is a hard error naming the pragma to use instead, rather than a silent no-op: a pipeline still passing them is a pipeline that has been running ungated and should find out. `MigrateOptions.allowDestructive` / `.acknowledgeLock` are gone from the programmatic API for the same reason.

- **Behavior change: a migration containing `EXECUTE` is reported as a new `dynamic` hazard.** SQL assembled at runtime is not analyzable by a gate that reads text, so rather than pretend to see through it, the gate names it and asks for `-- pipework:expect-dynamic`. This is what makes the literal handling below safe.
- **Fix: DDL keywords in string literals no longer raise false hazards.** `INSERT INTO notes VALUES ('a; DROP TABLE decoy;')` drops nothing, and 0.18.0 flagged it. Literal *contents* are now blanked before rules match, because a literal is data — what makes literal text dangerous is something `EXECUTE`ing it, which is structural and now has its own rule, rather than a guess about the text. Dollar-quoted bodies are still scanned (`DO $$ BEGIN DROP TABLE x; END $$` executes during the migration), while literals *inside* those bodies are blanked like any other.
- **`Severity` gains `'dynamic'`; `MigrationCheckResult` gains `hasDynamic` and `declared`, and each entry in `fileHazards` gains `undeclared`** naming the hazard classes that file raises but does not declare. `formatCheckResult` reports per-file and prints the exact line to add to each migration.

  Upgrading: run `pipework migrate` with no flags. It names every pending migration that needs a declaration and the line to paste. Applied migrations are untouched — the tracker still scopes the scan to pending entries, so history needs no annotation.

## 0.18.0

- **Fix: the migration safety gate no longer skips every statement that has a comment above it.** `splitStatements` split on `;` and then discarded any fragment beginning with `--`. A statement and its preceding comment land in the *same* fragment, so a comment-led statement was thrown away before any hazard rule ran and `checkMigrations` reported the file **clean** — the failure ran in the fail-open direction, and `pipework migrate` applied a comment-led `DROP TABLE` with no `--allow-destructive`. Reported against a codebase where 70 of 93 committed migrations lead with a comment, i.e. most statements in most migrations were never scanned. Comments are now stripped by a scanner rather than used to discard fragments. Issue #334.
- **Behavior change: upgrading will surface hazards in migrations that previously reported clean.** Nothing about the existing rules changed — these are findings the gate should always have reported. Pending migrations that were passing unattended deploys may now require `--allow-destructive` or `--acknowledge-lock`. Already-applied migrations stay quiet, as before: the tracker scopes the scan to pending entries only.
- **Fix: statement splitting is now correct inside string literals, quoted identifiers and dollar-quoted bodies.** `;` inside `'…'`, `"…"` or `$$…$$` no longer splits a statement (a function body was previously chopped into fragments), and `--` or `/* */` inside them is no longer mistaken for a comment. Conversely an apostrophe inside a comment (`-- the migration's ancestors`) no longer opens a phantom string literal that swallows the rest of the file — comment and quote state are tracked in one pass, which is the only ordering that gets both right. Block comments nest, per Postgres. `$1` positional parameters are not read as dollar quotes. DDL inside a string literal is still matched, deliberately: `EXECUTE 'DROP TABLE …'` is a real drop.
- **Feature: `ADD COLUMN … NOT NULL` without a `DEFAULT` is now reported** as a locking hazard (`--acknowledge-lock`), not a destructive one — on a non-empty table it aborts the deploy rather than losing data. The gate previously covered only `ALTER COLUMN … SET NOT NULL`, leaving it narrower than "block destructive DDL in production" implied.
- **Fix: hazard excerpts now show the DDL instead of the comment above it**, and a final statement with no trailing semicolon is scanned rather than dropped.
- **Fix: the migration *runner* no longer shreds a statement containing a `;` inside a block comment or a quoted identifier.** `parseStatements` learned string literals and dollar-quoted bodies in 0.15.x (#317) but not these two, so `ALTER TABLE "weird;name" …` and `… /* one; two */ …` were split into fragments Postgres rejects. Runner and gate now share one scanner, which is why the two could disagree about statement boundaries in the first place.
- **Fix: `pipework generate` now rewrites a commented `CREATE INDEX` to `CONCURRENTLY`.** The post-processor skipped any `;`-fragment beginning with `--`, so an index creation with an explanatory comment above it kept its exclusive lock — silently losing the safety property that pass exists to add. It now matches against comment-stripped SQL, and a `CREATE INDEX` mentioned only inside a comment is (correctly) not rewritten.

## 0.17.1

- **Fix: 0.16.0's identity refusal no longer fires on an `appUrl`/`appTestUrl` value that transaction isolation never dials.** The refusal's claim — "test isolation is defeated" — was untrue for that arm under the default strategy: transaction isolation registers its single rolled-back connection as the pool override under the database's *base* key, and `getOrCreate(config, 'app')` falls back to that base override when no role-specific one is registered, so **the app-role pool is never created** and the app-test value only ever has to satisfy config resolution. Refusing a provably-inert value is a false positive, and it blocked consumers whose committed `.env.example` deliberately sets the app-test var equal to the app var for exactly this reason. Identical app-role values now resolve when the strategy never creates that pool, and are refused wherever it does — the refusal message then names the carve-out and the strategy that voided it, so a reader can see why the other case passes.
  - **Scoped to the app-role pair's *value* identity.** The owner `url`/`testUrl` pair has no carve-out — that connection is dialed under every strategy — and **name**-identity (one env var serving both fields) still refuses in every mode, for both pairs: a single var carries no statement of intent and cannot be told from a typo, where two vars deliberately set equal is a documented decision.
  - **The config layer catching up to a boundary the validation layer already drew.** 0.16.0 shipped `rlsProbeTarget`, which picks the RLS probe target by the same fact ("transaction isolation collapses every role onto the resolved `url`"). That fact is now named once, as `appPoolCreated(strategy)`, and both the new carve-out and `rlsProbeTarget` are expressed in terms of it so the two spellings cannot drift.
  - **Nothing is claimed here about runtime dialing under `database`/`container` isolation.** Those strategies are declared in the config schema and are not yet honored by the test harness; the refusal they keep is the config-level one, which is what config resolution enforces. Relaxing only the provably-inert case is the conservative direction on purpose.
  - **Known edge, named rather than left silent: `validateRlsRoles` is strategy-blind.** The *startup* leg (`manifold.start()`) probes `appUrl ?? url` unconditionally, so with `rls` configured under `PIPEWORK_ENV=test` it does open a connection to the app URL even under transaction isolation — for an identical value, that reaches the database `appUrl` points at. It is a read-only `pg_roles` probe, and the asymmetry is the one 0.16.0 stated from the other side ("At startup the probed connection is `appUrl ?? url`; at test bootstrap it is the connection tests actually execute on"). Making the startup leg strategy-aware is tracked separately.
  - **These are the first `pnpm test` pins on either identity refusal.** 0.16.0 shipped both with no unit or contract coverage — the only assertion lived in `scripts/accept-demand-signals.sh`, which is not part of `pnpm test` and covers the owner pair alone. Both refusals, the carve-out, and its boundaries are now pinned at the `resolveDbUrl` and `loadConfig` levels.

- **Fix: config refusals on the app-role pair now name `appUrl`/`appTestUrl` instead of always saying `url`/`testUrl`.** The missing-test-var and name-identity messages hardcoded the owner field names, so a reader whose `appTestUrl` was wrong was told to go edit `testUrl` — including the code sample in the hint. The relaxation above makes deliberate app-var configuration more common, so these refusals get read more often.

## 0.17.0

- **Feature: `observability.onStatement` — a caller-supplied observer that fires once per statement written to the wire, on every connection the manifold owns.** The callback receives `{ connectionId, query, parameters }` and is pure observation: the return value is discarded, the statement is never mutated, and nothing is installed when the option is unset. It exists for exact wire-level statement accounting, which no application-level hook can do — the driver issues its own `BEGIN`/`SAVEPOINT`/`COMMIT`/`ROLLBACK`, and those are observed too.
  - **Honored on both connection arms.** Pooled connections (`createManagedConnection`) *and* the test harness's own connections — both the per-test rolled-back connection and the setup connection behind `useSetupDb()`. The test harness builds its postgres.js clients directly rather than through the pool, so a config-only hook would have been silently dead under `pipework test`; that arm is wired explicitly and covered by its own tests.
  - **Composes with `logAllQueries`.** Either alone installs the driver hook; with both configured both fire. Query logging keeps its 500-character truncation; the observer receives the query untruncated, because a counter must not see mangled SQL.
  - **`connectionId` is the driver's process-local connection ordinal, not the PostgreSQL backend pid.** Unique and stable per connection for that connection's lifetime within the process, meaningless across processes. Correct for per-connection accounting — every statement of one transaction carries the same id, which is asserted by a test — and wrong for correlating with `pg_stat_activity`.
  - **Counts wire truth, not application intent.** Two things a total will include that callers should expect: driver-issued transaction control, and one `pg_catalog.pg_type` lookup per connection the first time that connection is used.
  - Admitted through zod with `z.custom`, deliberately not `z.function()` — in zod 4 the latter returns a validating wrapper, so the parsed value is not the function the caller passed (identity is lost) and every call pays argument validation, on a hook that fires once per statement.

- **Observation adds no leak surface: a driver error boundary keeps the observer from widening what query errors serialize.** postgres.js defines `stack`/`query`/`parameters`/`args`/`types` on every query error and marks them **enumerable** exactly when its `debug` option is truthy — and installing an observer requires turning `debug` on. Without a boundary, merely observing statements would start leaking bind parameters into every log drain that serializes errors. Errors from observed connections are now put back to their unobserved shape: observed and unobserved errors have identical own-key sets, and the values stay reachable by property access so debugging is unaffected. `instanceof`, `message`, `code` and every server field survive. Applies to every statement path pipework mediates — `pipe()`, every `pipe.*` write, `connection.drizzle`, transactions and savepoints. `logAllQueries: true` is untouched and behaves exactly as before; that mode already opted into verbose errors.
  - **The one gap, deliberate and announced: `ManagedConnection.client`.** The raw driver door stays raw — that is what it is for — so queries issued through it are outside the boundary and their errors do serialize bind parameters. Reading `.client` on an observed connection now logs a warning saying so, once per database, and the contract is stated on `StatementObserver` and on `ManagedConnection.client`.
  - **Wrapping the `Sql` client to close that gap was considered and rejected.** It would mean proxying a callable tagged-template whose `begin`/`savepoint` hand out fresh client instances needing recursive re-wrapping, on the hottest path in the system — more risk than the narrow, documented gap it closes. Recorded here so it is not re-proposed as cleanup.

- **Fix: the migration safety tests no longer depend on a virgin database.** `migrateOne` integration tests assert on hazards in *pending* migrations, but their migration tags are fixed while the tracker outlives the run — so from the second run onward those tags read as already-applied, dropped out of the pending set, and `hasDestructive` flipped to false. The tracker is now dropped before each of those tests. (Surfaced by 0.16.0's move to tag membership; under the old timestamp high-water mark the stale rows were inert.)

- **Fix: `docker-compose.test.yml`'s published port is parameterized (`PIPEWORK_TEST_PORT`, default 5432).** Where 5432 is already held, compose silently failed to publish and the suite ran against whatever else answered there — which matters because the harness creates and drops databases and provisions BYPASSRLS fixture roles, so it must talk to a disposable cluster where its own role is the superuser. Stock machines are unaffected.

## 0.16.0

- **Fix: a queue registered by a module reached only through the schema glob now makes it into `pipework generate`'s output.** The CLI used to snapshot internal definitions (including registered queues) *before* the schema files were imported, so a `createQueue()` living in a glob-matched module — with no side-effect import in the config — was silently dropped from the migration, and consumers worked around it by importing the queue module from `pipework.config.ts`. Internal definitions are now collected after the schema-file imports. Breaking for API consumers: `generateForDatabase`'s `internalDefinitions` parameter is now a thunk (`() => Promise<Record<string, unknown>>`).
- **Feature: a registered queue whose table is missing from the database is now a loud, actionable failure at startup and at test bootstrap — not a crash at first insert.** Startup validation (`pipework serve`/`dev`, `manifold.start()`) and the test harness's template provisioning both verify every `createQueue()`-registered table exists in the default database (`to_regclass` probe), and refuse with a message naming the table, the schema-glob rule, and the fix (`pipework generate`, then `pipework migrate`).
- **Behavior change: a database whose `testUrl` resolves to the same connection string as its `url` is refused at config resolution in the test environment.** Identical values mean "tests" run against the real database: the test harness creates/drops databases and rolls back transactions on what it assumes is a dedicated instance, and RLS-inertness goes unnoticed — with zero signal from the framework. The refusal names both env vars and the fix (point the test var at a separate database); `testUrl` naming the *same env var* as `url` is reported as its own case. Applies to both the url/testUrl and appUrl/appTestUrl pairs.
- **Fix: `pipework generate` surfaces the real config-load error instead of misreporting every failure as "No pipework.config.ts found".**
- **Behavior change: a database with `rls` configured whose runtime role is SUPERUSER or BYPASSRLS now fails startup validation and test bootstrap.** RLS policies never apply to such a role, so the entire tenant-isolation policy set is silently inert — the framework now says so, naming the database, the role, the flag, and the fix (provision a non-bypassing application role; point the runtime connection at it). At startup the probed connection is `appUrl ?? url`; at test bootstrap it is the connection tests actually execute on — transaction isolation collapses every role onto the resolved `url`, database/container isolation resolves roles normally. Consumers whose test role is the compose superuser are refused until they provision a non-bypassing test role — deliberate: that is exactly the world where an RLS test suite proves nothing.
- **Feature: `.env.local` and `.env.test.local` are loaded alongside `.env`/`.env.test`, with the `.local` (machine-local, never-committed) variant taking precedence** — the standard dotenv convention.
- **Docs: the `env` config field is now a first-class section in REFERENCE.md** — every declaration shape (`string`/`number`/`boolean`/`string[]`), `required`/`default`/per-environment `defaults`/`sensitive`, typed `manifold.env` access, and a complete `createManifold` example. Previously it appeared only inside a type signature, leaving the feature undiscoverable while consumers accumulated ad-hoc `process.env` reads. The reference generator now renders multi-line JSDoc and `@example` blocks as fenced code instead of flattening them to one line.
- **Behavior change: relative `migrations`/`schema` paths now resolve against the directory of `pipework.config.ts`, never against the process working directory.** Previously every consumer of these fields (`pipework generate`, `pipework migrate`'s safety gate, test template provisioning) resolved them against cwd — so `pnpm --filter <module> test` (which cd's into the module) failed at `beforeAll` with "No migration journal found", and the only workaround was hand-absolutizing the paths with `import.meta.url`. `ResolvedDatabase.migrations` is now absolute (resolved once, at config load), `ResolvedDatabase` carries the new `baseDir` field (the config file's directory), and schema globs are matched against `baseDir`. Already-absolute consumer paths are unchanged (`resolve(base, abs) === abs`). Breaking for API consumers: `generateForDatabase` loses its `cwd` parameter — the resolved config is the single source of path truth.

- **Fix: the migration tracker now records WHICH migrations have been applied (tag membership), replacing the `max(created_at)` timestamp high-water mark that silently skipped any unapplied migration dated earlier than the newest applied one.** Two real production hits of the old model: (1) two manifolds sharing one physical database under the same tracker table name (both declared database key `app`) — the second manifold's entire journal predated the first's newest entry, so its migrations were silently skipped and its schema never materialized; (2) a migration merged from a branch with an earlier date than migrations already applied was silently skipped ("relation does not exist" at runtime, in CI). The tracker table gains a `tag` column (unique) and `applyMigrations` decides "already applied" by membership: tag recorded → skip (after verifying the recorded content hash still matches — see below), tag absent → apply, in journal order. Existing deployed tracker tables are upgraded in place on the next `migrate`: the `tag` column is added and each legacy row's identity is recovered by matching its recorded content hash against the journal's files; rows matching no current journal entry keep a NULL tag and never participate in membership.
- **Fix: editing an already-applied migration file is now a loud error instead of silently ignored.** Membership verifies the recorded sha256 against the file: a tag recorded with a different hash aborts with an error naming the migration, the two hashes, and the fix (put schema changes in a NEW migration; if two manifolds share a database and collide on a tag, that is a schema-level collision no tracker can reconcile — separate databases or distinct `migrationTable` names).
- **Breaking: `readAppliedThrough` is replaced by `readAppliedState`, and `checkMigrations`' second parameter is now the `AppliedState` it returns (previously a bare timestamp).** `readAppliedState` returns `{ kind: 'tags', tags }` for an upgraded tracker, `{ kind: 'legacy-threshold', threshold }` for a tracker `applyMigrations` has not upgraded yet (so pre-apply safety scoping still works on first contact with an old database), or `null` when no tracker table exists. The safety gate's semantics are unchanged: hazards in applied migrations are history and stay quiet; hazards in pending ones still require opt-in.

## 0.14.1

- **Feature: `tap` exported from the package root.** `tap(url, options?)` — the standalone managed connection (own `postgres` pool, `max: 1` default, `close()`) — is now part of the public surface, with `ManagedConnection`/`ConnectOptions` types. This is the sanctioned door for contexts outside the manifold's AsyncLocalStorage, where `pipe()`/`pipeSystem()` structurally cannot resolve: `worker_threads` (a fresh V8 isolate inherits no ALS — e.g. a heartbeat thread that must keep ticking while the main thread does synchronous work), detached monitors, one-off scripts. Consumers no longer need a direct `postgres` import (which the WRAPPED_PACKAGES boundary rule rightly bans) or a blocked deep import into `dist/`.

## 0.14.0

- **Feature: `peerDependencies` for shared "leaf" libraries.** A pipework-aware library consumed by more than one workspace (shared schema/strategies/errors, linked into each consumer) must resolve ONE pipework instance, not bundle its own. It now declares this in config — `modules.<name>.peerDependencies: { pipework: '^0.14.0' }` — and `pipework sync` projects it into the generated `package.json` (previously this had to be hand-patched back after every sync). When a module declares pipework as a peer, the runtime self-dependency added in 0.13.0 defers to it (no duplicate regular `dependencies.pipework`). Together with the 0.12.0 runtime broker — which makes same-version physical copies share one `AsyncLocalStorage` — this is what lets a shared leaf work across N consumers with a single pipework identity. Non-pipework peers pass through too.

## 0.13.0

- **Feature: `pipework serve` — a supported production entrypoint.** Previously pipework shipped only `dev` (`node --watch` over discover→start), so production deployments hand-rolled a launcher that imported pipework's internal `dist/core/config/discover.js` — an unstable path that breaks on any internal restructure. `pipework serve` is exactly `dev` minus the file-watcher: it discovers the manifold and binds every surface as a stable long-running process. Replace `node ./serve.mjs` (and its `dist` reach) with `pipework serve`.
- **Fix: `pipework sync` now declares `pipework` in a module's dependencies when that module imports pipework at runtime.** Generated module `package.json`s omitted pipework entirely, so a module importing it resolved only via root hoisting — working today but not self-contained, and fragile if hoisting changes. The generator now scans each module's `src/` for a runtime (non-type-only) pipework import and adds `pipework` to that module's `dependencies`. Modules that don't import it (e.g. a channel `tool`/plugin) are unchanged; an explicit pipework dep in module config is never clobbered.
- **Fix: `pipework sync` preserves a module's `files` and `publishConfig`.** Dual-distributed modules (published to a registry *and* linked locally) need npm `files`/`publishConfig`, but sync regenerated the module `package.json` and stripped them, forcing a re-patch after every sync. Both are now first-class `modules.<name>` config fields and are projected into the generated `package.json` (joining `exports`, which already passed through).
- **Feature: `pipework check` fails on a multi-instance install.** A new integrity check scans the resolved install tree (hoisted + the pnpm virtual store) for physical copies of pipework and fails when more than one *version* is present — the multi-instance hazard where a shared library resolves a different pipework than its workspace and their AsyncLocalStorage / error classes fork (no active context; 403→500). Multiple copies of the *same* version are fine (the runtime broker shares one ALS across them). Catches version skew at check/CI time instead of at first request.
- **Docs: the `env` config field is documented as the canonical home for runtime config** (ports, service tokens, bucket names, flags) — read once, validated/typed onto `manifold.env` — instead of scattered ad-hoc `process.env` reads.

## 0.12.0

- **Fix: a `PipeworkError` thrown by a shared library that resolved its own copy of pipework now maps to its real HTTP status (403/401/…) instead of a generic 500.** When a self-contained pipework workspace consumes a shared leaf library that also imports pipework at runtime, pnpm resolves two physical copies of pipework — so the library's `ForbiddenError` and the workspace's `ForbiddenError` are distinct class objects and `instanceof` between them is `false`. The HTTP error mapper keyed on `instanceof PipeworkError`, so a leaf-thrown error was unrecognized and fell through to 500 (and `.rejects.toThrow(ForbiddenError)` across the boundary failed even though the right error was thrown). Error identity is now carried on a global-`Symbol` brand (`Symbol.for('pipework.error')`) — the same cross-realm technique drizzle uses for table identity — and the mapper checks the brand, not the constructor. A new public predicate **`isPipeworkError(error)`** is exported for consumers whose own handlers have the same `instanceof` exposure. Issue #333.
- **Fix: a shared leaf library and its consuming workspace now share one request context (AsyncLocalStorage), and two incompatible pipework versions in one process fail loudly instead of silently.** The request ALS was a plain module singleton, so the two physical copies above each had their own `AsyncLocalStorage` — a context entered by the workspace was invisible to `pipe()` called from the leaf ("No active context" on a live request). The ALS (and the version that owns it) is now pinned to a process-global slot keyed by `Symbol.for('pipework.runtime')`: same-version copies share one ALS (N copies behave as one), and a second copy of a *different* version throws an actionable error naming both versions rather than forking silently. The connection pool was never a separate singleton (it hangs off the context's instance), so it is fixed transitively.
- **Fix: `pipe.insert` / `pipe.update` / `pipe.upsert` / `pipe.remove` now accept a concrete table.** Their generic was constrained to `DefinedTable<FieldRecord> & PgTable`, but a concrete `pipe.define(...)` result (`DefinedTable<{ specific fields }>`) is not assignable to `DefinedTable<FieldRecord>` (the projected columns are invariant under the field generics) — so `pipe.insert(myTable)` failed to type-check for callers. The constraint is relaxed to `DefinedTable<any> & PgTable`, matching the pattern already used elsewhere for the same reason. Row-type inference is unchanged (the concrete table type still flows through).
- **Fix: manual-transaction jobs (`.transaction('manual')`) no longer expose the database handles at the top level of the handler deps, and handler deps no longer carry a `[string]: never` index signature.** The base builder deps type was `Record<string, never>`, an index signature that (a) made `Omit<TDeps, keyof TDb>` unable to remove the declared databases — so a manual-mode handler's deps type still appeared to carry `app` (a non-transactional handle that is a footgun, the exact thing the mode prevents) — and (b) made `deps.anyTypo` resolve to `never` instead of a compile error for every handler. The base is now `Record<never, never>`: manual mode correctly strips the db deps (they appear only inside `checkpoint`'s callback), and a typo'd dependency is now a type error.

## 0.11.0

- **Feature: jobs can own their transaction boundaries — `fitting.job(...).transaction('manual')` opts a job out of the single framework-owned outer transaction.** Every tenant job was wrapped, by the framework, in one outer transaction around its whole `.fit` run (`src/async/jobs/execute.ts`), making the job strictly all-or-nothing: a long sequence of independently-valid work either committed as one transaction or was lost entirely — a failure near the end rolled back the whole run, and the single long-open transaction pinned a snapshot (blocking vacuum) and held its connection for the job's full duration. A job that genuinely wants **one warm process** (to amortize in-memory state across a long run) but **many transactions** (to checkpoint progress durably) had no way to express it: an in-handler `pipe.transaction()` nests as a savepoint inside the outer tx, so it only commits when the whole job commits. `.transaction('manual')` separates the transaction axis from the process axis. In manual mode the framework builds the job context (tenant, trace, resolved databases) but opens **no** outer transaction; instead the handler receives a `checkpoint(fn)` primitive — each call opens a fresh top-level, tenant-scoped transaction (re-establishing the `SET LOCAL pipework.tenant_id` / custom session vars so database-side RLS sees the tenant on every checkpoint), runs `fn` with the declared databases bound as **raw** transactions, and commits it independently. The databases declared with `.use()` move out of the top-level deps (a non-transactional handle there would be a footgun) and into `checkpoint`'s callback; non-database deps (`auth`, `input`, …) stay at the top level. Multi-DB jobs get one independent transaction per database inside each checkpoint, and an outbox enqueue issued inside a checkpoint (`queue.enqueue(db, …)` with the checkpoint's handle) rides that checkpoint's commit. The contract shifts deliberately: a manual-mode job is **at-least-once / idempotent-on-resume**, not atomic — a crash after committing checkpoints 0..N leaves them committed, the job is re-claimed, and the handler must **resume** (it owns its idempotency key), not restart. Default jobs are unchanged (single outer transaction). Misuse fails at definition: `.transaction('manual')` without `.job(...)`, or without any `.use(...)`, throws an actionable error from `.fit()`. New `JobTransactionMode` and `Checkpoint<TDb>` exported types. Issue #332.

## 0.10.8

- **Fix: a multi-database app could not make a bare `db()` / `pipe()` call — every no-name database resolution threw `ConfigError: Multiple databases configured` at runtime.** With >1 database declared, `defaultDatabaseName()` and `database(undefined)` had no way to know which database was primary, so they threw on *any* ambiguity. That throw is correct safety for a genuinely-ambiguous config, but there was no way to *resolve* the ambiguity short of naming the database at every call site — turning the addition of a second (e.g. read-only) database into a sweep across every bare `db()`/`pipe()` in the app. Configs now declare the default once: a top-level `defaultDatabase: '<name>'` (a key of `databases`). When set, both no-name resolvers return that database; the throw is preserved only for multi-DB configs with *no* default declared (still genuinely ambiguous) and single-DB configs are unchanged (the sole database is always the default, no declaration needed). `defaultDatabase` naming an undeclared database is a load-time `ConfigError` with a closest-match suggestion. Complements 0.10.6, which fixed the parallel throw on the *test-harness* template path; this fixes the runtime resolution path.

## 0.10.6

- **Fix: a multi-database app could not run a single DB-backed test — the test harness threw `ConfigError: Multiple databases configured` at setup.** `ensureTemplate` (`src/test/setup.ts`) computed the `isDefaultDatabase` argument to `provisionFrameworkTables` via `config.defaultDatabaseName()`, whose throw-on-ambiguity is *intentional* for the CLI/validate/jobs paths but wrong here: the harness is asking "which database carries the framework trace/audit tables", whose answer is simply the first-declared (primary) database. So any app declaring >1 database (e.g. `app` + a read-only `control`) failed identically at test setup, before a single test ran; single-DB apps were unaffected, masking it. The decision now uses `config.databaseNames()[0]` — identical for single-DB configs, never throws for multi-DB, and correctly targets the primary declared database. `defaultDatabaseName()` itself is unchanged (its throw is correct for the unambiguous-default paths). Issue #331.

## 0.10.5

- **Fix: a dead-lettered (or retried) job's `error_payload` recorded only the top-level `message`+`stack`, silently dropping the root cause.** `fail()` serialized errors as `{ message, stack }`, so when a job threw a wrapper error — most importantly Drizzle's `DrizzleQueryError`, whose `.message` is just the failed SQL text and whose real `PostgresError` (`code`/`detail`/`constraint`/`table`) lives on `.cause` — the actual reason for the failure was thrown away. A `graph_versions` insert that dead-lettered surfaced only the query, not *why* it was rejected, making job failures undiagnosable from the queue table alone. `fail()` now serializes via the new `serializeError` (`src/async/jobs/serialize-error.ts`), which walks the full `cause` chain and copies each error's own-enumerable properties (where both Drizzle's `query` and Postgres' fields live), breaks circular `cause` references, and passes non-Error throws through unchanged.

## 0.10.4

- **Fix: `pipework dev` (and every config-discovery command) could not load real consumer TypeScript.** Both paths ran under `--experimental-strip-types`, which only *erases* annotations — it hard-stops with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` on any syntax that needs code emit (parameter properties like `constructor(private readonly x)`, enums, namespaces), and its resolve hook only rewrote `.js`→`.ts`, never extensionless bundler-style imports (`import { x } from './serialize-expr'` → `ERR_MODULE_NOT_FOUND`). Since config discovery uses the same loader, this also broke `upgrade`/`generate`/`migrate` as soon as `pipework.config.ts` imported real surfaces. The loader's `load` hook now *fully transpiles* via Node's bundled `stripTypeScriptTypes` in `transform` mode (parameter properties, enums, and namespaces all emit correctly), and `resolve` probes extensionless candidates (`.ts`, `.js`, `/index.ts`, `/index.js`) before falling through. No new dependency — `stripTypeScriptTypes` ships with Node ≥ 22.13. The now-obsolete `--experimental-strip-types` flag is dropped from the `dev` spawn.
- **Fix: HTTP surfaces were hard-capped at Fastify's 1 MiB default body limit.** `surface.http({...})` exposed no `bodyLimit` and nothing forwarded one to Fastify, so any request body over 1 MiB was rejected with `FST_ERR_CTP_BODY_TOO_LARGE` (413) — every upload-bearing route was unusable. `http`/`createServer` config now accept `bodyLimit` (bytes), forwarded to Fastify, plus a per-route override via `fitting.route(method, path, { bodyLimit })` so a single large-upload route can open up without raising the global cap (new optional `RouteMeta.bodyLimit`).

## 0.10.3

- **Fix: no pipework app could start as a live server — startup validation rejected pipework's own internal trace tables.** The brand-uniqueness validator (`validateDomainIntegrity`) flags a brand reused across two tables that aren't linked by an FK, to catch accidental id-type collisions. But the built-in cold-mirror tables `trace_retained` / `trace_step_retained` reuse the hot tables' `TraceId` / `TraceStepId` brands *by design* — a retained row is a copy holding the same id value — declared as plain PKs with no FK. So as soon as `trace: {}` was enabled, startup validation threw `StartupValidationError: brand "TraceId" used by both trace.id and trace_retained.id` and the server refused to boot. The retained mirrors now declare `id` as an `enforced: false` reference to the source table's `id` — the same mechanism `trace-fields` already uses for `rootId`/`parentId`: no physical FK is emitted (retention may still drop the hot partition out from under the copy), but the validator resolves the reference and recognises the brand reuse as intentional (`isInheritedFromFK`). Latent because `pipework test` (transaction-isolation harness) and `pipework migrate` never run startup validation — only the dev/live-serve path does.
- **Fix: `pipework dev` resolved its TS loader to a path the published package doesn't contain.** `dev.js` resolved the loader at `dist/config/ts-register.js`, but the source lives at `src/core/config/ts-register.ts` → `dist/core/config/ts-register.js`; `dist/config/` is absent from the tarball, so `pipework dev` died with `ERR_MODULE_NOT_FOUND` on a consumer install. Both `dev` resolves (surface-aware and legacy) now point at `dist/core/config/ts-register.js` via a shared `resolveRegisterPath` helper.
- **Fix: `pipework dev` launched its own bootstrap as TypeScript from inside `node_modules`.** `dev.js` spawned `node ... dist/cli/dev-bootstrap.ts` — but the `.ts` isn't shipped, and Node refuses to strip types for any file under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so a consumer install could never run it (it only worked in pipework's own repo, where the source isn't under `node_modules`). The bootstrap now launches as the compiled `dist/cli/dev-bootstrap.js` via `resolveBootstrapPath`; the compiled file runs natively and the `--import` loader still strips types for the consumer's own TS (which is not under `node_modules`).

## 0.10.2

- **Fix: `pipework check` lint false-greened pre-existing violations across a pipework ruleset bump.** ESLint's `--cache` keys each file on (its content + the local `eslint.config` + the eslint version) — but NOT on the transitive pipework ruleset the config imports. So bumping pipework to tighten a rule (e.g. `strict-boolean-expressions`) left every unchanged file's cached "pass" verdict in place; each `check` only re-linted cache-invalidated files, surfacing a different subset of errors per run and reading like flaky lint while real violations shipped behind green gates. The eslint cache now lives at `<root>/node_modules/.cache/pipework/eslint-<pipework-version>-<module>`: **version-stamped** so a bump points eslint at a fresh cache file (cold lint, no stale verdicts), and **under `node_modules`** so it is gitignored — a stale cache can no longer be committed or travel between clones. (Previously the cache was written as `.eslintcache` in each module root, covered by no `.gitignore`, so caches were being committed and the staleness traveled with the repo.) New exported `eslintCacheLocation(cwd, version, label)`. **Consumers: `git rm` any committed `.eslintcache` files after upgrading — they are now dead.**
- **Fix: clone-per-test databases and roles now use a project-scoped namespace.** The test harness hardcoded a `pipework_test_` database prefix and `pipework_app_` role prefix for *every* consuming project, so on a shared Postgres cluster all of them (and pipework itself) collided in one namespace: one project's orphan-reaper dropped another's databases, a clone could end up owned by a foreign role, and `CREATE` on the cloned `public` schema was denied because the connecting role was not the owner. The prefix is now `<project>_test_` / `<project>_app_`, resolved from `PIPEWORK_TEST_NAMESPACE` (explicit override for CI or an already-polluted shared cluster), else the consumer's `package.json` name, else `pipework` — sanitized to a legal identifier. The reaper is scoped to its own namespace, so it can never touch another project's databases. New `src/test/namespace.ts` (`resolveNamespace`, `projectNamespace`, `testDbPrefix`, `appRolePrefix`, `testDbLikePattern`).

## 0.10.1

- **New: `pipe.system()` — the sanctioned channel for cross-tenant system access.** Surfaces that legitimately operate without a tenant in context — pre-tenant auth (resolving org membership during login), public webhook ingress, admin tooling — previously had no honest route: `pipe()` without a tenant returns the guard proxy that throws on any tenant-scoped table, and hand-rolling `instance.pool.getOrCreate(...)` in the consumer bypasses the framework entirely. `pipe.system(name?)` returns an unscoped, unguarded handle on the **owner-role** pooled connection, outside the request/job transaction. Owner routing is deliberate: the request's bound transaction authenticates as the runtime app role and is RLS-bound fail-closed, so an unscoped query inside it would silently return zero rows once role separation (`appUrl`) is on — the owner connection is the only carrier that keeps working across that activation. Queries through it see committed data only and do not participate in request atomicity. Under collapsed test isolation (a pool override is registered) it joins the bound transaction instead — the override client is reserved by the open transaction, so joining is the only non-deadlocking route, and the owner/app distinction is already collapsed there. New `pool.isOverridden(name)` predicate. The tenant-guard error message now points at `pipe.system()` instead of "file an issue".

## 0.10.0

- **Breaking: `createQueue` tables are now emitted by `pipework generate`.** A queue's table was previously hand-written DDL (`schema.sql`) that `generate` never saw, so the table existed nowhere in a consumer's migration baseline — a latent footgun: any test or fresh database that exercised the queue hit `relation "<table>" does not exist` (42P01). The queue table is now built as a `pipe.define()` definition registered at `createQueue` construction and merged into `loadInternalDefinitions`, so `generate` emits its DDL like any other table. **Consumers must regenerate migrations** after upgrading so their queue tables (and the new exclusion columns/indexes) land in the baseline.
- **New: structured cross-job exclusion for queues.** `createQueue({ exclusion: { arrayColumn, arrayElementType?, blocksAllColumn, tenantColumn } })` — strictly generic, the consumer names the conflict columns; no domain vocabulary in pipework. A job participates when its array is non-null or its blocks-all flag is true; a blocks-all candidate waits for every participating concurrent job, an array candidate is blocked by concurrent blocks-all jobs and by `&&`-overlapping arrays, all tenant-scoped when `tenantColumn` is set. When exclusion is configured the claim runs under `SERIALIZABLE` to defeat write-skew between two claimers passing the same `NOT EXISTS` snapshot, with a partial GIN index (`fastupdate = off`, scoped to claimed/running rows) on the array column and a partial blocks-all index so the predicate locks stay granular at scale. `enqueue` takes `exclusion: { array, blocksAll, tenant }`; claimed jobs carry their exclusion values back.
- **New: `claim()` is starvation-proof and shutdown-cancellable.** The claim no longer gives up after a fixed retry budget — a serialization failure carries Postgres's safe-retry guarantee (the conflicting transaction committed, so progress was made), so `claim()` retries to a clean attempt with full-jitter backoff and `null` means exactly "nothing claimable," never "gave up under contention." `claim(db, workerId, signal?)` takes an `AbortSignal`; on a worker `stop()` the signal cancels both the retry loop and the poll sleeps so an in-flight or idle worker exits immediately, and `stop()` bounds the wait by `shutdownDeadlineMs` (its deadline timer is cleared the instant the loops exit, so a clean shutdown leaves no armed timer holding the event loop open).
- **New: filter/expression primitives.** `caseWhen`, JSONB `extract`/`contains` aliases through `filter`, `walReplayLsn`/`replicaFresh` for WAL-LSN read-your-writes checks (typed `SQL<string | null>` / `SQL<boolean | null>` — both are NULL on a primary), and `pipe.field.pgLsn()`. `identifier()` accepts multiple parts and emits a dot-qualified reference — `identifier('chain', 'depth')` renders `"chain"."depth"` so recursive-CTE consumers can qualify CTE columns (which have no `Column` object for `aliasedTableColumn`); single-part behavior unchanged.
- **Migrate emit fixes (required to express the queue indexes):** `emitCreateIndex` now emits index `WITH (...)` storage options (previously captured in the snapshot but dropped on emit), `indexEqual` compares them (a storage-param change now diffs), and the snapshot no longer emits `ASC NULLS LAST` for non-btree access methods (GIN rejects it — the existing hnsw/ivfflat special-case is generalized to btree-only).
- **Removed: `clearQueueDefinitions`** (zero callers; was never in the published exports map). Registry access is direct-module only.
- **Fix: `dropTraceTables` deadlocked against stray fire-and-forget trace writes** in test teardown — teardown now retries on `40P01`/`40001`, symmetric with how the write side already survives.

## 0.9.0

- **New: runtime role separation — RLS is now actually enforced.** Until now the app connected to Postgres as the database owner, and an owner is `BYPASSRLS` — so every generated RLS policy was inert in production: the 144 policies isolated nothing. This release splits the single per-database role into two. `db setup` now provisions an **owner** role (`<name>_owner`, `BYPASSRLS`) that owns the database, runs migrations, and bootstraps; and a non-owner **runtime** role (`<name>_app`, `NOBYPASSRLS`) that runtime request/job transactions authenticate as and that RLS policies enforce against. Isolation now comes from Postgres itself on a role that cannot bypass it, not from app-side filtering alone. The runtime DML grants (`SELECT, INSERT, UPDATE, DELETE` on tables, `USAGE, SELECT` on sequences) are applied at provision time on an owner-authenticated connection, plus `ALTER DEFAULT PRIVILEGES FOR ROLE <owner>` so every future migration-created table is auto-granted to the runtime role — an RLS-enabled table can never be left ungranted (which would blank rows the app is entitled to). New `buildRuntimeGrants` helper.
- **Breaking: `db setup` role naming and new config fields.** The single per-database role is gone; `db setup` now creates `<name>_owner` and `<name>_app`. Two new optional `database` config fields name the env vars for the runtime connection URL: `appUrl` (and `appTestUrl` for the test database). When `appUrl` is unset the runtime falls back to the owner `url` and `db setup` warns — RLS is then inert, preserving the old single-role behavior, but role separation is off. To get enforcement, set `appUrl`/`appTestUrl` and re-run `db setup`. Any deployment that hard-coded the old role name must update to `<name>_owner`.
- **New: principal context (user + as-of) propagation for user-level and temporal RLS.** `Flow` now carries `user` (the acting principal's id) and `asOf` (the instant access is evaluated against; null = current), threaded through `createRequestContext` / `createJobContext` / `createTestContext`. The runtime chokepoint propagates them to the connection as the `pipework.user_id` and `pipework.asof` GUCs alongside `pipework.tenant_id`, so RLS policies can enforce per-user and temporal (as-of) visibility, not just tenant. `verifyPrincipalContext` cross-checks the GUCs against the active Flow, fail-closed.

## 0.8.20

- **Fix: test-database cloning aborted when it couldn't terminate an autovacuum backend** — `cloneTestDatabase` ran `pg_terminate_backend` over *every* session attached to the per-process template before `CREATE DATABASE ... WITH TEMPLATE`. On a shared cluster an autovacuum worker attaches to every database and runs as superuser, so a non-superuser test role (`*_test`) hit `permission denied to terminate process` (42501) and the whole clone — and the test file — failed intermittently. The terminate is now scoped to `backend_type = 'client backend'` (never a background worker) and is best-effort, and the `CREATE DATABASE` retries on SQLSTATE 55006 (`object_in_use`) with capped backoff, waiting out any session it couldn't close rather than depending on superuser termination rights. New `withTemplateRetry` helper, unit-tested.

## 0.8.19

- **Fix: the flow-cardinality check false-failed under a scoped run** — `pipework check --staged` (and any diff-scoped run) hands the cardinality analyzer only the affected modules, and dropping modules can only shorten the longest `step`-edge chain, so the derived `trace.structuralMax` is a *lower bound* on the true global max. The config-vs-derived comparison used exact equality, so a staged change to a module shallower than the global cap hard-failed the pre-commit hook with `trace.structuralMax is stale — config has 4, analyzer derived 3` and told the user to lower the cap — which would then break the full-scope check (it correctly derives 4) and truncate legitimate depth-4 traces at runtime. The comparison is now scope-aware: `derived > configured` is a hard error under any scope (a subset that exceeds the cap proves the whole graph does), while `derived < configured` is only flagged as staleness when the analyzer saw every module (`fullScope`); a scoped run defers that verdict to the full-scope pre-push check. `resolveModulePaths` now returns whether the scope covered all modules (#328).

## 0.8.18

- **Fix: `pipework check` OOM on memory-constrained hosts** — `runTypeCheck` and `runLint` spawned every module's `tsc` / `eslint` concurrently via `Promise.all`; each `tsc` instance can consume 800 MB–1.5 GB, so two concurrent type-checks exceeded the 2 GB ceiling. Both now run modules serially — one child process at a time. Wall-clock cost is negligible for 2–3 modules; peak memory drops from N×(process size) to 1×(process size).

## 0.8.17

- **Fix: the #326 teardown fix released the run's advisory lock per file, opening an orphan-reaper race** — `teardownTestDatabases` runs in a per-file `afterAll`, not once per worker. The 0.8.16 fix closed `adminClient`/`lockClient` there, so under `pool: 'forks'` it released the run's advisory lock between every test file. In that window the per-process template database is backend-less and unlocked — indistinguishable from an orphan — so a concurrent worker's `reapOrphanTestDatabases` drops it, and the next file's `cloneTestDatabase` fails with `3D000 template database … does not exist`. `teardownTestDatabases` now drops only the per-file clones; the persistent connections are closed exactly once, at worker shutdown, by a `process.on('disconnect')` handler — the once-per-worker signal vitest emits for a forked worker — so the advisory lock is held for the worker's whole life (no race) while the worker still exits (#326's hang stays fixed). A non-forked run has no `disconnect` signal but also no sibling worker and so no concurrent reaper, so it closes the connections per file (#327).

## 0.8.16

- **Fix: `pipework test` hung after teardown** — the #321 template-DB redesign added two run-lifetime postgres connections, `adminClient` and `lockClient` (the latter opened with `idle_timeout: 0`, `max_lifetime: 0` so it holds the run's advisory lock for the whole process). `teardownTestDatabases` dropped the per-file clone databases but never closed either connection, and the `test` CLI command never force-exits — two open, ref'd sockets with no idle timeout kept Node's event loop alive, so a fully-passing run printed `Tearing down test databases...` and then never exited. `teardownTestDatabases` now closes both connections after dropping the clones; closing `lockClient` also releases the run's advisory lock, which is correct at teardown — every database it guarded is dropped or is now a reapable orphan (#326).

## 0.8.15

- **Fix: `pipework generate` emitted framework-owned partitioned tables into application migrations** — the `trace` family is `PARTITION BY RANGE`; a bare `CREATE TABLE` of one in an app migration leaves a partitioned table that rejects every insert. `generate` now keeps framework-owned partitioned tables out of the emitted SQL (they stay in the snapshot), and the test harness provisions them — parent tables plus day partitions — itself via `provisionFrameworkTables`; `syncTracePartitions` skips parents that were never provisioned (#313).
- **Fix: the test harness re-provisioned a database from scratch for every test file** — `setupTestDatabases` ran `DROP/CREATE DATABASE` → `applyMigrations` → framework provisioning → grants per file. It now builds one template database per process and clones each file's database with `CREATE DATABASE ... WITH TEMPLATE` — a filesystem copy instead of a migration replay. The run's advisory lock is held for the whole process so the orphan reaper never drops a live template (#321).
- **Fix: `pipework check` did redundant work** — per-module `tsc` and `eslint` ran in a serial `execSync` loop; they now run concurrently, `tsc` is resolved once instead of through `npx` per module, and `eslint` runs with `--cache`. `discoverInstance` is memoized per `cwd` — one config load per `check` run instead of four. `collectFlowStepSites` is memoized by source text, so the flow, cardinality, and coverage checks parse each file once (#322).
- **New: fast path for `check` / `test` (#325).** Test databases are now provisioned durability-off — `synchronous_commit = off` per database and `CREATE UNLOGGED TABLE` for test schema — a free speedup with no semantic change (`docker-compose.test.yml` ships a matching `fsync`-off Postgres profile). `pipework check`/`test` accept `--fast` (scope to the modules changed on the branch, diffed against the merge-base with the base branch; `test` also trims to the fast tiers) and `--full` (every module, every tier). `--fast` fails open — it widens to all modules, never silently scopes to nothing, when the affected set cannot be computed. A `fastPath` blueprint block configures the un-flagged default and the fast tier set.

## 0.8.14

- **Fix: `pipework test` could not resolve wildcard export subpaths across workspace packages** — 0.8.11's cross-package resolver (#301) matched a workspace import against an exact `workspacePackages` key. A package that declares wildcard subpath exports (`./events/*`) contributes a single `*` entry, so an import of a concrete subpath (`@scope/pkg/events/list`) never matched and fell through to `dist/`, failing at module load with `Cannot find package`. The `pipework()` vitest plugin now splits the map into exact and wildcard entries and substitutes the matched segment for `*`, resolving to the `src/` file when it exists (#311).
- **Fix: `pipework generate` emitted from-scratch baselines that could not be applied** — each table's foreign-key `ALTER TABLE ... ADD CONSTRAINT` was emitted immediately after that table's `CREATE TABLE`, so a baseline creating many tables in one migration failed on the first forward FK reference (`relation "…" does not exist`). `emitSQL` now hoists every foreign key after all `CREATE TABLE`s. The `NOT VALID` / `VALIDATE CONSTRAINT` decision also moved out of the regex post-process into `emitSQL`: Postgres rejects `NOT VALID` foreign keys on partitioned tables, and only the op metadata knows a table is partitioned, so a partitioned table's FK is now emitted already-valid with no `VALIDATE` follow-up. `postProcessMigration` no longer rewrites foreign keys (#312).
- **Fix: the migration runner mangled plpgsql function bodies** — `parseStatements` split migration SQL on every `;`, including `;`s inside a `$$ ... $$` dollar-quoted body, so any migration defining a `CREATE FUNCTION` or `DO $$ ... $$` block was shredded into unrunnable fragments (`unterminated dollar-quoted string`). The splitter is now a character scanner: a `;` separates statements only outside `'...'` string literals and `$tag$ ... $tag$` dollar-quoted blocks, `--` comment stripping is quote-aware, and `--> statement-breakpoint` is still honored as an explicit separator (#317).

## 0.8.13

- **New: durable valve crossings — provenance, idempotency, and retried execution (#307)** — completes the valve subsystem deferred from 0.8.12. A new internal table, `valve_crossing`, is the crossing ledger: every crossing and non-crossing leaves an immutable provenance row (#307 field 6), and a keyed crossing is replayed against it at most once (#307 field 9). It is opted into generated migrations by a `valve: {}` blueprint block, the same way `trace` and `audit` are. `recordCrossing` / `findCrossing` are the ledger's write and idempotency-lookup. `executeSupplyCrossing` / `executeGateCrossing` wrap the raw crossing executor with the idempotency check and the provenance write, transactionally — a keyed handoff whose `crossed` row already exists resolves as `skipped` rather than re-running. `enqueueCrossing` puts a handoff crossing on the job queue and `runCrossingJob` is the handler body, so a crossing runs durably: the queue retries it with backoff and dead-letters it on exhaustion, making failure-to-cross a terminal job outcome rather than a lost exception. All exposed on the `valve` namespace.

## 0.8.12

- **New: `valve` — typed, directional, gated boundaries between code silos (#307)** — a valve makes a cross-silo data boundary first-class. It is declared as a `supply` (handoff producer) or `gate` (transition-gate producer) and consumed as a `faucet`, carrying the topology, contract, projection, validation, lineage, and invariants the boundary depends on. `supply()` builds a handoff — source trigger, payload contract, guard, projection, idempotency key, and in-process or durable crossing. `gate()` builds a transition gate — a trigger set, inbound-faucet context, declared signals, fan-out, and an async `.decision()` hook that returns a partitioned `cross | held | failed` outcome rather than a bare verdict. `crossSupply` / `crossGate` execute a crossing in the caller's transaction, validating the projected payload against the contract and re-validating at each faucet. The valve graph (`valve.graph()`) is a directional data-dependency graph with crossing, gate-context, and retraction edges, supporting downstream and affected-analysis queries and contract diffing; `emitArtifact` projects a supply to a lightweight JSON-Schema contract artifact. A new `no-cross-silo-deep-import` boundary rule rejects relative imports that reach into another package's `src/` tree. Exposed as the `valve` namespace. Durable-async crossing execution and the database-backed provenance and idempotency stores are deferred to a follow-on.

## 0.8.11

- **Fix: `pipework generate` emitted invalid SQL for HNSW and IVFFlat vector indexes** — `serializeIndexColumns` copied drizzle's default `order: 'asc'` / `nulls: 'last'` onto every index column, so a vector index rendered `USING hnsw ("embedding" ASC NULLS LAST vector_cosine_ops)` — a syntax error, since those access methods accept an operator class only. The snapshot now suppresses `order`/`nulls` for `hnsw`/`ivfflat` indexes; btree indexes are unchanged (#298).
- **Fix: `pipework dev` rewrote `.js` → `.ts` without checking the source exists** — 0.8.9 fixed this for the config-loader resolve hook (`discover.ts`), but a second copy of the hook in `ts-resolve-hooks.ts` — the one registered into `pipework dev` child processes — still rewrote unconditionally, breaking real `.js` dynamic imports of prebuilt ESM-only packages. That copy now applies the same `existsSync` guard before rewriting (#292).
- **Fix: `pipework test` failed to resolve bare cross-package workspace imports** — an import of `@scope/pkg` (or one of its export subpaths) resolved through the package's `exports` field to `dist/`, which is not built during `pipework test`, and vitest does not honour tsconfig `paths`. The `pipework()` vitest plugin now resolves known workspace specifiers straight to their `src/` source, via the same package→source map that drives `tsconfig.check.base.json` (#301).

## 0.8.10

- **Fix: `lint.extra` was silently ignored for every per-module lint pass** — `pipework check` lints each declared module by running eslint with `--config <pipework>/lint/preset.js` whenever the module has no local `eslint.config.js` (the normal case — pipework only generates one at the repo root). That preset was a static `createLintConfig()` that never read `pipework.config.ts`, so `lint.extra` (consumer plugins, rules, extra flat-config blocks) reached only the root `eslint.config.js` — which a workspace repo never lints, since `check` skips the root pass once any module is declared. Custom rules declared via `lint.extra` looked correct and `pipework check` passed green, but nothing they added was ever enforced. The preset now calls `resolveLintConfig()` — the same resolver the generated root `eslint.config.js` uses — so `lint.extra` applies to every module (#299).
- **New: `lint.modules`** — per-module lint overrides, keyed by module path or glob (`"packages/warehouse"`, `"packages/*"`), resolved the same way as the top-level `modules` map. Each entry carries `rules` and `configs` that apply only to the matching module. When `pipework check` lints a module, `resolveLintConfig()` layers that module's entries on top of `lint.extra`; rules merge after the global ones, so a module rule overrides a global one, and an explicit module entry refines a glob match. Plugins stay global — register them under `lint.extra.plugins` and reference their rules from any module's `rules` (#299).

## 0.8.9

- **Fix: `pipework generate` left duplicate `define()` registrations when re-run in the same process** — the generator imported the consumer's `schema.ts` with a query-string cache buster (`?t=…`) to force a re-read on subsequent runs, but ESM treats each unique specifier as a distinct module, so every run re-evaluated the schema file and pushed a second copy of every definition into the global registry. The next operation that walked the registry (validation, migration diff) saw duplicates and exploded. The cache-bust is dropped; the standard ESM module cache is now the source of truth (#295).
- **Fix: pipework's `--loader` rewrote `.js` → `.ts` even when no `.ts` source existed** — when a consumer imported a built artifact (`require('foo/dist/bar.js')`) that genuinely lived only in `dist/`, the loader rewrote the specifier to `foo/src/bar.ts` and failed the resolution. The loader now checks for the `.ts` source before rewriting and falls through to the default resolver when none exists, so prebuilt-only deps continue to resolve through `dist/` (#292).
- **Fix: vitest dropped `vi.mock` interception inside workspace packages with both `src/` and `dist/`** — vitest resolved some intra-package imports through `dist/*.js` and others through `src/*.ts`, splitting the module identity. A `vi.mock('foo/bar')` call interned one identity while the handler imported the other, so the mock silently never fired. The `pipework()` vitest plugin now accepts a `packageRoot` option; when set, any resolution that lands in `<root>/dist/X.js` is rewritten to `<root>/src/X.ts` (or `.tsx`) when the source exists, collapsing the two identities. `pipework`'s own `resolveVitestConfig()` threads the package root through automatically (#274).
- **New: `http.exposeErrorDetails`** — when enabled, the internal-error response body includes `details.name` and `details.message` from the thrown error (and walks `error.cause` to surface the root cause). Default is off; existing responses are unchanged. Useful for development and trusted internal deployments where the surface area of internal errors is acceptable; do not enable on a public-facing surface (#297).
- **New: typed return for `def.insertShape()` / `selectShape()` / `updateShape()`** — these returned `z.ZodTypeAny`, so `fitting.input(def.insertShape())` typed the handler's `input` as `unknown`. The shape builders now return `z.ZodType<InsertProjection<TFields>>` (and the analogous projections for select/update), with `InsertProjection` deriving required-vs-optional from the field's `.primaryKey()`, `.defaultRandom()`, `.nullable()`, `.default()`, `.readOnly()`, and `autoManaged` flags. End-to-end: `fitting.input(IngestInput.insertShape()).fit(({ input }) => …)` now types `input` precisely. Type-level change only — runtime parsing is unchanged (#296).
- **Security: bump `fast-uri` to `>=3.1.2`** — `fast-uri@3.1.1` decoded percent-encoded authority delimiters (`%40` → `@`, `%3A` → `:`) inside the host component and serialized them back as raw characters, allowing `http://trusted.com%40evil.com/` to normalize to `http://trusted.com@evil.com/`. Apps that allowlist hosts off the parsed URI could be steered to a different authority than the original URL appeared to contain. Pulled in transitively through `fastify`'s ajv/fast-json-stringify chain; pinned via `pnpm.overrides` (GHSA-v39h-62p7-jpjc, CVE-2026-6322).

## 0.8.8

- **Fix: pipework's internal tables were never delivered to consumer databases** — configuring `trace: {}` or `audit: {}` in a blueprint left the supporting tables (`trace`, `trace_step`, `trace_retained`, `trace_step_retained`, `audit_record`) absent from the database. The migration generator only walked the consumer's `schema.ts` exports and never saw pipework's own definitions, so the first traced HTTP request or audit emit crashed against a missing table. `pipework generate` now auto-includes the trace tables when `blueprint.trace` is present and `audit_record` when `blueprint.audit` is present. Internal tables only ride on the default database — trace and audit both target it at runtime.

## 0.8.7

- **Fix: pipework's startup validator rejected its own internal tables** — booting a manifold with pipework-only definitions (`audit_record`, `trace`, `trace_step`) crashed with seven `brand "TraceId"/"TraceStepId" used by both …` errors. `audit_record.traceId`/`traceStepId` are deliberately branded but unreferenced (the audit partition outlives its source trace partition, so an FK would prevent independent retention). The validator's brand-reuse check had no way to express "logical reference without enforced FK" and flagged the columns as conflicting brand definitions. The startup tests missed it because `tests/validation/startup.test.ts` wipes the registry in `beforeEach`, so the validator only ever saw synthetic fixtures.
- **New: `.references(table, 'field', { enforced: false })`** — declares a logical reference. The brand inherits from the target field exactly like a normal `.references()`, and the validator treats the column as FK-derived for brand-reuse purposes, but no FK constraint is emitted in the generated DDL. Use for cross-partition references where an FK would prevent independent partition lifecycle.
- **New: thunk form `.references(() => table, 'field', …)`** — for self-references and forward-references where the target table is defined later in an import cycle. The thunk is resolved at validation / table-build time. Brand inference is skipped (the target can't be read at construction time), so the caller must chain `.brand('Foo')` explicitly. Composite `foreignKeys` accept the same thunk form on `references.definition`.

## 0.8.6

- **Expose `pipework/package.json` in the `exports` map** — bundlers (Vite, Webpack, esbuild), framework version probes, and monorepo tools often `require('pipework/package.json')` for introspection. With `exports` defined and `./package.json` absent, Node refused the resolution (`ERR_PACKAGE_PATH_NOT_EXPORTED`). Adding the explicit subpath restores the standard read.

## 0.8.5

- **Fix: broken install of 0.8.4** — 0.8.4 declared `@pipework/flow-step-parser@0.1.0` as a dependency, but the `@pipework` npm scope does not exist and the workspace package was never published, so `pnpm add pipework@0.8.4` failed with a 404. The workspace package is now `pipework-flow-parser` (unscoped) and the release script publishes workspace packages before the root, so the dependency resolves.

## 0.8.4

- **Top-level eslint config is now consumer-supplied via `pipework.config.ts`** — pipework's generated `eslint.config.js` had no extension point for consumer plugins, additional rules, or extra flat-config blocks; `pipework install`/`sync` overwrote any hand-edits. Discipline rules like `react/forbid-elements`, `no-restricted-syntax` selectors, custom local plugins (`plumb/no-classname-on-registry`) were unreachable through pipework. The blueprint now accepts `lint.extra` with three named slots — `plugins` (registered in pipework's main block so rules can reference them as `name/rule`), `rules` (merged into pipework's main block, can override defaults), and `configs` (appended as additional flat-config entries for per-glob overrides). The generated `eslint.config.js` shrinks to a single `export default await resolveLintConfig()` and is now byte-verified by `pipework check` for parity with `vitest.config.ts` (#280).
- **Breaking: `LintConfig` type renamed to `LintOptions`** to free `LintConfig` for the blueprint-level type (mirrors `VitestConfig`). The only public consumer of the type is the `createLintConfig` argument; the type is rarely imported by name since `createLintConfig` accepts an object literal directly (#280).

## 0.8.3

- **Top-level vitest config is now consumer-supplied via `pipework.config.ts`** — pipework's generated `vitest.config.ts` rejected any consumer edits to it, which made vitest features that only take effect outside per-project config (reporters, sequencer, `cache`, `bail`, Vite-level `resolve`/`plugins`, …) unreachable. The blueprint now accepts `vitest.extra`, whose contents flow straight into `defineConfig(...)`: keys under `extra.test` spread into the `test` block (reporters, sequence, …), keys at the top of `extra` spread at the outer Vite level (resolve, plugins, cacheDir, …). The generated `vitest.config.ts` shrinks to a single `defineConfig(await resolveVitestConfig())` and stays byte-stable. `test.projects` is owned by pipework — `vitest.modules` remains the way to declare them, and `extra.test.projects` is rejected with a pointer to `vitest.modules` (#279).
- **Breaking: `resolveVitestWorkspace()` renamed to `resolveVitestConfig()`** with a new return shape (full `UserConfig` instead of just the projects array). The only place this is imported is the generated `vitest.config.ts` — upgrading pipework will mark it stale; run `pipework install` to regenerate (#279).

## 0.8.2

- **Fix: `pipework check` no longer needs a pre-built workspace** — `check` type-checked each module by resolving cross-package imports through `package.json` `exports` → `dist/`, so every dependency package had to be built with `tsc` first. On a fresh checkout the type check failed project-wide until a full build was run, which also made `--staged` / `--module` scoping misleading. `pipework install` now generates a `tsconfig.check.json` per module (and a workspace-level `tsconfig.check.base.json`) that resolve sibling packages straight from their `src/`, so `check` type-checks from a clean checkout with no prior build. The build path (`tsconfig.json`) is unchanged — `pnpm -r build` still resolves through `dist/` (#270).

## 0.8.1

- **Fix: refresh-token reuse detection did not actually revoke anything** — replaying a rotated refresh token was supposed to revoke the entire token family, but two bugs made the protection inert: rotated tokens were issued into a brand-new family instead of inheriting the family of the token they replaced, and even the direct revocation was undone because it ran inside the transaction whose rollback signalled the reuse. A stolen refresh token could be replayed indefinitely. Rotation now threads the family through, and revocation is committed before the error is raised.
- **Fix: `pipework check` lint step fails in monorepos** — `check` resolved the eslint binary to a relative path (as returned by `which eslint` under pnpm), which failed to spawn once lint ran with the working directory set to each module. The binary is now resolved to an absolute path from the project root, and eslint's stderr is surfaced on failure (#269).
- **Fix: diff scoping inspected the wrong repository inside a git hook** — `pipework test --staged` / `pipework check` shell out to `git` to compute the changed-file set. Git resolves the repository from `GIT_DIR` (and related env vars) in preference to the working directory, and a git hook exports those vars — so running the diff-scoped commands from a `pre-push`/`pre-commit` hook read a different repository's index than the one being checked. The git invocations now strip the inherited location vars so the target directory is authoritative.
- **Fix: `insertShape()` rejected an explicit `null` for nullable fields** — a `field.text().nullable()` column accepts `NULL` on `INSERT`, but the generated insert validator only applied `.optional()` (omission), so `insertShape().parse({ notes: null })` failed even though `updateShape()` and `selectShape()` accepted it. Nullable fields are now `.nullable().optional()` in the insert shape, matching the update and select shapes.

## 0.8.0

- **Fix: `pipework test` with zero affected modules exits 0 instead of crashing** — a diff-scoped run (`--staged` / `--uncommitted`) that matches no modules now prints `No affected modules — nothing to test` and exits 0, matching how `pipework check` already degrades. Previously it emitted a bogus `--project` filter that vitest rejected as a startup error — which broke every pre-push hook running `pipework test --staged`, since the git index is always empty after commit. Diff scoping in single-package repos also correctly resolves the implicit root module instead of always falling back to "run everything" (#266).
- **`pipe.define()` composite keys and partitioning** — table definitions accept `primaryKey` (composite primary key over multiple columns), `foreignKeys` (composite/named foreign key constraints), and `partitionBy` (declarative `PARTITION BY` — range/list/hash). The migration diff emits these inline on `CREATE TABLE` and as safe `ALTER TABLE` statements for existing tables (#265).
- **`field.date()`** — calendar-date column type (`date`, no time component), with the standard modifier chain (#265).
- **`field().traceSafe()`** — field facet marking a column safe to capture into trace input snapshots. Fail-closed: fields are trace-unsafe unless explicitly opted in, so a new field is never captured by accident (#265).
- **Trace foundations (internal)** — trace/trace-step tables with day partitioning, partition-lifecycle maintenance, copy-on-error retention, and the flow-step annotation parser landed as internal substrate for upcoming tracing and auditability features. No public API yet (#265).

## 0.7.26

- **Removed: `defineTestConfig()`** — test configuration is now centralized under the `vitest` key of `pipework.config.ts`, the single allowed home for test config. `pipework install` generates a pipework-owned `vitest.config.ts` from it. **Migration:** add a `vitest` section to `pipework.config.ts` (a single-package repo can start with `{ modules: { '.': {} } }`), delete every hand-written `vitest.config.ts` / `vitest.*.config.ts`, then run `pipework install` to regenerate (#261).
- **Centralized test config now covers single-package repos** — a repo with no declared `modules` is treated as one implicit module at the repo root, addressed as `'.'`. Single-package projects can express their test setup centrally instead of being forced into a hand-written config (#261).
- **Fix: `pipework check` no longer silently skips single-package repos** — a repo with no declared `modules` (the default `pipework init` layout) now resolves to the implicit root module, so doctrine validation, lint, and typecheck run against the root `src/`. Previously `check` resolved zero modules and exited green having checked nothing (#262).
- **`pipework check` hard-errors on stray vitest configs** — a hand-written `vitest.config.ts` (when the blueprint has no `vitest` key) now fails the check with migration instructions; a generated config that has drifted from the template also fails. Previously the check was skipped (#261).
- **Fix: `extra.test` overrides pipework's pool defaults** — per-module/profile `extra.test` now overrides pipework's `pool` and `isolate` defaults as documented. Project identity and isolation-critical keys (`name`, `root`, `setupFiles`, inline-deps `server` config) still take precedence over `extra.test` (#260).

## 0.7.25

- **Fix: `pipework migrate` safety gate over historical migrations** — the destructive/locking safety check now only scans migrations that have not yet been applied. A fully up-to-date database no longer aborts with `UnsafeMigrationError` over migrations that ran long ago (#256).
- **Fix: `jobs.execute({ tenant })` database-level tenant scoping** — tenant jobs now run inside a transaction with `pipework.tenant_id` (and any custom `tenantConfig.sessionVars`) propagated via `SET LOCAL`, matching the HTTP surface. RLS-protected writes from tenant jobs no longer fail with `new row violates row-level security policy`. `jobs.execute` accepts `tenantConfig` and `transactionTimeoutMs` options (#257).
- **Removed: git hooks scaffolding** — `pipework sync` no longer generates `.githooks/`, and `pipework init` no longer sets `git config core.hooksPath`. Wire up hooks with your own tooling; run `pipework check` / `pipework test` directly (#258).

## 0.7.24

- **Vitest 4 compatibility** — `defineTestConfig()` now injects `pipework/test/setup` directly instead of relying on a plugin `config()` hook that Vitest 4 silently ignores. Consumer `setupFiles` are merged, not replaced (#255).

## 0.7.23

- **`pipework db` command** — database lifecycle management: `pipework db setup` creates databases, roles, and extensions; `pipework db teardown` drops them; `pipework db reset` does both. Supports `--test` for isolated test databases, `--db` to scope to a single database, and `--admin-url` for admin connection override. Config accepts `adminUrl` per database (#236).
- **`field.money()`** — fixed-precision numeric column type for financial data. Default precision 18, scale 4. `field.money(10, 2)` for custom precision. Supports all standard modifiers including `.min()`, `.max()`, `.brand()` (#244).
- **Dependency boundary enforcement** — `pipework check` now scans workspace packages for direct dependencies on packages pipework wraps (postgres, drizzle-orm, fastify, @fastify/*, zod, pino, jose). Per-package exemptions via `pipework.boundaries.dependencies.allow` in package.json (#237).
- **`pipework/no-reexports` lint rule** — built-in ESLint rule that flags `export { x } from` and `export * from` re-exports. Type-only re-exports (`export type { x } from`) are allowed. Enabled by default in `createLintConfig()`, overridable via `extraRules` (#238).
- **Git hooks scaffolding** — `pipework sync` generates `.githooks/pre-commit` (runs `pipework check --staged`) and `.githooks/pre-push` (runs `pipework check --all` + `pipework test`). `pipework init` configures `git config core.hooksPath .githooks` (#246).
- **Worker orchestration** — worker surface gains `listen: true` for LISTEN/NOTIFY job completion, `reaper: true | { intervalMs }` for periodic stale-job cleanup, `timeouts: { [priority]: ms }` for priority-based job timeouts, and `shutdownDeadlineMs` for cooperative drain on shutdown (#235).
- **Shutdown coordination** — `manifold.stop()` enforces configurable `drainTimeoutMs` (default 30s), logs structured drain progress, and times out gracefully. Worker surface drains in-flight jobs before closing (#245).

## 0.7.22

- **`pipe.filter.cast()`** — PostgreSQL type cast: `cast(expr, 'uuid')` produces `(expr)::uuid`. Generic return type follows the target type (#247).
- **`pipe.filter.interval()`** — interval literal: `interval(5, 'minutes')` produces `interval '5 minutes'` (#247).
- **`pipe.filter.dateTrunc()`** — truncates a timestamp to the given precision: `dateTrunc('day', col)` produces `date_trunc('day', col)` (#247).
- **`pipe.filter.add()` / `subtract()`** — binary arithmetic for two SQL expressions. For column ± literal, use `increment`/`decrement` (#247).
- **`pipe.filter.rowNumber()` / `rank()` / `denseRank()`** — window functions. Use with `pipe.filter.over(fn, { partitionBy, orderBy })` to apply a window specification (#247).
- **`pipe.filter.over()`** — applies `PARTITION BY` / `ORDER BY` to any window function or aggregate (#247).
- **`pipe.filter.identifier()`** — quoted PostgreSQL identifier. Promotes the internal `sql.identifier()` to a composable primitive (#247).
- **`pipe.filter.withRecursive()`** — recursive CTE: `withRecursive(alias, seed, step, finalQuery?)` produces `WITH RECURSIVE alias AS (seed UNION ALL step) finalQuery` (#248).
- **`pipe.filter.ago()`** — composed helper: `ago(5, 'minutes')` produces `now() - interval '5 minutes'` (#247).
- **`pipe.filter.castUuid()` / `castJsonb()` / `castTimestamp()` / `castBoolean()` / `castNumeric()` / `castInteger()` / `castText()`** — shorthand cast helpers (#247).
- **`pipe.filter.nextSequence()`** — composed helper: `nextSequence(db, col, where?)` produces `COALESCE((SELECT MAX(col) FROM table WHERE ...), 0) + 1`. Zero raw SQL — composes from `scalar`, `coalesce`, `max`, `increment` (#252).
- **`pipe.serializable()`** — runs a function in a `SERIALIZABLE` transaction with automatic retry on serialization failures (`40001`) and deadlocks (`40P01`). Configurable `maxAttempts` and `backoffMs` with exponential jitter (#250).
- **`pipe.values()`** — typed `VALUES` clause for bulk operations. Base primitive for `bulkSet` and composable in raw SQL (#247).
- **`pipe.advisoryLock()` — numeric namespace overload** — `advisoryLock(db, 1001, 'key')` uses a literal integer namespace instead of hashing. Existing string-key signatures unchanged (#247).
- **`jsonb.path()` / `pathText()`** — nested JSONB extraction: `path(col, 'a', 'b')` produces `col->'a'->'b'`; `pathText` uses `->>` on the final key (#247).
- **`jsonb.boolean()` / `number()` / `integer()`** — typed JSONB field extraction with automatic cast (#247).
- **Type exports** — `WindowSpec`, `ValueColumnDef`, `SerializableOptions` exported from `pipework` (#247).

## 0.7.21

- **Fix: vitest workspace TS resolution** — `resolveVitestWorkspace()` now registers `.js` → `.ts` resolve hooks before importing `pipework.config.ts`, matching what `discoverInstance()` already does (#225).
- **Fix: `pipework test` sets `PIPEWORK_ENV=test`** — the test command now sets `PIPEWORK_ENV=test` in both its own process and the spawned vitest process, ensuring `resolveDbUrl()` picks the test database URL (#227).
- **Fix: vitest 4 config format** — `pipework sync` now generates `vitest.config.ts` with `test.projects` instead of the removed `vitest.workspace.ts` file (#228).

## 0.7.20

- **Fix: migration semicolons** — `pipework generate` now emits semicolons between DDL statements. Previously, multi-table migrations produced a single concatenated statement that failed on execution (#217).
- **Fix: JSONB default values** — JSONB columns with array or object defaults (e.g. `.default([])`, `.default({})`) now emit valid `DEFAULT '[]'::jsonb` instead of `DEFAULT ,` (#216).
- **Fix: pragma comment filtering** — `-- pipework:no-transaction` no longer causes the first SQL statement in the migration to be silently dropped (#215).

## 0.7.19

- **`pipe.filter.increment()` / `decrement()`** — atomic column arithmetic for `.set()` clauses. `increment(column, amount)` produces `column + amount`; negative amounts or `decrement()` for subtraction (#218).
- **`pipe.filter.coalesce()`** — wraps any expression with `COALESCE(expr, default)`. Typed — return type matches the default value (#219).
- **`pipe.filter.now()`** — returns a `SQL<Date>` for PostgreSQL `now()`. Uses the database transaction timestamp, not the application clock (#220).
- **`jsonb.text()` / `jsonb.json()`** — JSONB field extraction. `text(column, key)` produces `column->>'key'` (text); `json(column, key)` produces `column->'key'` (JSONB). Composable with `pipe.filter` operators (#221).
- **`pipe.advisoryLock()`** — transaction-scoped advisory locks via `pg_advisory_xact_lock(hashtext(...))`. Accepts one or two string keys; released automatically at commit/rollback (#222).
- **`pipe.filter.scalar()`** — wraps a select query as a scalar subquery expression for use in `.values()` and `.set()` (#223).
- **`pipe.bulkSet()`** — bulk `UPDATE ... FROM (VALUES ...)` in a single statement. Updates multiple rows with per-row values, with typed column casts (#224).

## 0.7.18

- **Root-level `scripts` in config** — `createManifold({ scripts: { prepare: '...' } })` merges custom npm scripts into the generated root `package.json`. The `preinstall` key is reserved and cannot be overridden (#211).

## 0.7.17

- **Native migration runner** — `pipework migrate` now uses its own migration runner instead of the forked drizzle-orm chain. Supports `-- pipework:no-transaction` pragma for `CREATE INDEX CONCURRENTLY` migrations (#205).
- **HNSW/IVFFlat vector indexes emit operator class** — vector columns indexed with `hnsw` or `ivfflat` now default to `vector_cosine_ops` (or the appropriate operator class for `halfvec`, `bit`, `sparsevec`) when no explicit `.op()` is specified. Explicit `.op()` calls are preserved (#206).
- **`withFlow({ tenant })` propagates `SET LOCAL` for RLS** — the test framework now executes `SET LOCAL <sessionVar> = <tenant>` on each test connection when a tenant is provided, matching the HTTP server's `propagateLocals` behavior. `withFlow` is now async (#207).
- **`pipework migrate` surfaces real config errors** — import errors from `pipework.config.ts` now propagate instead of being masked as "config not found" (#202).

## 0.7.16

- **Scoped `check` and `test` commands** — `pipework check` and `pipework test` now default to affected modules only, determined by git diff and the workspace dependency graph. `--all` runs everything, `--staged` scopes to staged changes (for pre-commit hooks), `--module` targets a specific module, and `--suite` runs a specific test profile. Profiles marked `manual: true` are excluded from default and `--all` runs (#203).

## 0.7.15

- **Own migration generation engine replaces drizzle-kit** — `pipework generate` now uses pipework's own snapshot/diff/SQL-emit pipeline instead of drizzle-kit. Eliminates the 10MB drizzle-kit dependency. 153 new tests cover snapshot building, schema diffing, and SQL statement generation. Existing migrations and journal files are fully compatible.

## 0.7.14

- **Query engine forked from drizzle-orm** — pg-core and postgres-js driver source copied into `src/data/query/`, stripped of non-Postgres dialects, and fixed for `exactOptionalPropertyTypes`. All internal imports repointed to the fork; `drizzle-orm` removed from runtime dependencies. Pipework now owns its query engine source (#187).

## 0.7.13

- **Source reorganized by concern** — `src/` directories grouped under `core/`, `data/`, `request/`, `auth/`, `async/`, `infra/`. No public API changes — all exports and namespace objects remain identical (#190).
- **Config loader no longer rewrites `.js→.ts` inside `dist/` directories** — the ESM resolve hook now skips compiled output, fixing CLI commands for workspace packages with compiled dependencies (#193).

## 0.7.12

- **Branded types flow through `select()`/`returning()` automatically** — `DefinedTable` now parameterizes `PgTable` with `ProjectedColumns`, so Drizzle's column metadata carries named, branded types. Query results are correctly typed without casts. `eq()` enforces brand safety at compile time (#186).
- **`.references()` works under `exactOptionalPropertyTypes`** — structural `{ $fields }` constraint replaces `DefinedTable<FieldRecord>`, eliminating index-signature variance mismatch (#185).
- **`excluded()` helper for upsert conflict clauses** — `pipe.excluded(table)` returns typed `EXCLUDED."column"` references for `onConflictDoUpdate` (#184).
- **CLI loads `.env` automatically** — all `pipework` commands load env files before dispatching, no manual `source .env` needed.
- **`log.create()` / `log.get()` accessible on log proxy** — namespace functions exposed on the proxy target so they're callable without context.
- **`toTsvector` accepts `Column` argument** — in addition to `string | SQL`.
- **REFERENCE.md ships with the package** — `node_modules/pipework/REFERENCE.md` and `CHANGELOG.md` are available to tools and sessions without needing the source repo.
- **`/pipework` skill refined** — focused on workflow and mental model, not implementation detail. Points to REFERENCE.md and CHANGELOG.md for deeper lookup.
- **CI runs checks once per PR** — removed redundant `push` trigger that doubled CI on merge.

## 0.7.11

- **Config loader resolves `.js` → `.ts` imports across workspace packages** — `pipework.config.ts` can now import surfaces and other TypeScript source from workspace packages using standard `.js` extension imports. Registers an ESM resolve hook before loading the config. `pipework dev` gets the same resolution (#181).

## 0.7.10

- **`pipework check` is workspace-aware** — type-checks each module via `tsc --noEmit -p <module>/tsconfig.json` instead of failing when no root `tsconfig.json` exists. Lint runs per-module `src/`. Boundary check only runs when `scripts/check-boundaries.js` exists (#170).
- **Doctrine validator flags raw `pgTable()` imports** — new `define/no-raw-pgTable` rule catches direct `pgTable` imports from `drizzle-orm/pg-core` or workspace re-exports. Points adopters to `pipe.define()`, which provides branded types, shape validators, and tenant isolation (#177).
- **Query result types carry branded fields** — `select().from(DefinedTable)`, `.returning()`, and `eq()` all infer branded types from `DefinedTable`'s `ProjectedColumns` intersection. No casts needed — use the definition directly, never `.table()`.

## 0.7.9

- **Pipework owns linting** — ESLint and typescript-eslint are now real dependencies, not phantom `npx` hopes. `pipework check` invokes ESLint directly. `pipework/eslint` exports `createLintConfig()` for adopter projects to extend. Workspace generation produces an `eslint.config.js` that uses the pipework preset. Projects no longer need to install ESLint separately or bring their own config (#175).

## 0.7.8

- **Trait return types include trait fields** — `.audited()` returns `DefinedTable<TFields & AuditedFields>`, `.effectiveDated()` returns `DefinedTable<TFields & EffectiveDatedFields>`, `.softDeleted()` returns `DefinedTable<TFields & SoftDeletedFields>` (#173). `Select` types, `$fields`, and column access (`Doc.createdAt`) all include trait fields without casts. Chained traits accumulate correctly.

## 0.7.7

- **`surface.http().test(instance)`** — creates a `TestClient` for inject()-based integration tests without opening a TCP listener (#169). Shares the exact surface definition with production (auth, tenant, handlers, validation) so config can't drift. `TestClient` exposes `inject(opts)` and `close()`. Production validation is skipped and logger defaults to `false`.

## 0.7.6

- **Lazy secret resolution for webhook verifiers** — `webhook.verify.hmac`, `webhook.verify.stripe`, and `webhook.verify.github` now accept `string | (() => string)` for the secret parameter. Resolves at verification time, breaking the circular import when handler modules need `instance.env` at module scope (#164).

## 0.7.5

- **`pipework upgrade [version]`** — self-update command. Regenerates workspace files with the target pipework version and runs the full install workflow. Resolves the bootstrap deadlock where upgrading pipework required editing the generated `package.json` (blocked by the checksum guard). Omit the version to upgrade to latest.

## 0.7.4

### Full-text search abstractions

Typed helpers for PostgreSQL FTS operators that Drizzle doesn't abstract:

- **`vector.plainToTsquery(query, config?)`** — `plainto_tsquery` for natural language input (no operator syntax required).
- **`vector.matches(column, query)`** — the `@@` match operator for tsvector/tsquery filtering.
- **`vector.headline(column, query, config?, options?)`** — `ts_headline` for search result snippets with configurable highlighting (`startSel`, `stopSel`, `maxWords`, `minWords`, etc.).
- **`vector.rank()` now accepts pre-built `SQL` tsquery** — pass the result of `plainToTsquery()` or `toTsquery()` directly instead of re-specifying the query string.

### JSONB query operators

New `jsonb` namespace for PostgreSQL JSONB operators:

- **`jsonb.contains(column, value)`** — the `@>` containment operator. Tests whether a JSONB column contains the given object/array.
- **`jsonb.containedBy(column, value)`** — the `<@` operator. Tests whether a JSONB column is contained by the given value.
- **`jsonb.hasKey(column, key)`** — the `?` operator. Tests whether a JSONB column has the given top-level key.

## 0.7.3

### Doctrine validation

`pipework check` now scans adopter source files for Pipework doctrine violations. Three rules ship in this release:

- **`define/prefer-pipe-define`** — flags `schema.table()` calls that should use `pipe.define()` for branded types, shape validators, test factories, and tenant isolation.
- **`define/brand-primary-key`** — flags `pipe.define()` primary keys missing `.brand('EntityNameId')` for nominal type safety.
- **`define/use-traits`** — flags hand-written `createdAt`, `updatedAt`, `effectiveFrom`, `effectiveTo`, `deletedAt` fields that should use `.audited()`, `.effectiveDated()`, or `.softDeleted()` traits.

Validation runs automatically during `pipework check` on managed workspaces. Violations are grouped by rule with file:line locations.

## 0.7.2

- **Fix: pnpm installs zod and drizzle-orm** — removed `peerDependenciesMeta: { optional: true }` which caused pnpm to skip installing direct dependencies.
- **Fix: `log.create()` reachable** — Proxy `get` trap now checks own properties before delegating to Pino, so `log.create`, `log.configure`, `log.get`, `log.getBase`, and `log.REDACT_PATHS` work.
- **Fix: CLI surfaces real errors** — when `pipework.config.ts` exists but `createManifold()` throws (e.g. missing env var), the CLI now prints the actual error instead of "No pipework.config.ts found."
- **Fix: `pipework add` targets root dependencies** — `addDependencies()` now walks brace depth to find root-level `dependencies` inside `createManifold({...})`, instead of matching the first regex hit (which could be inside `modules`).
- **Fix: `pipework sync` respects config-declared version** — `sync` now reads pipework version from config's `dependencies` block before falling back to the installed version, breaking the bootstrap deadlock when upgrading pipework itself.

## 0.7.1

- **`pipe.transaction()` accepts `TransactionConfig`** — set isolation level, access mode, and deferrable on top-level transactions. Nested transactions silently drop config (Postgres savepoints don't support it).
- **`pipe.field.serial()`** — integer auto-increment primary keys for adopter apps migrating from serial IDs. Excluded from insert shapes, generates `0` in test factories.
- **`FieldColumn` nullability fix** — `FieldColumn<F>` now propagates `notNull: true` into `AnyPgColumn`, so select inference no longer produces false `T | null` on non-nullable fields.
- **Pool override for test isolation** — `pool.override(name, conn)` and `pool.clearOverrides()` let test harnesses inject ephemeral connections. `server.inject()` now routes through overridden pools instead of bypassing test isolation.

## 0.7.0

### Tenant isolation — single source of truth

- **`pipe()` always returns a proxy** — tenant-scoped tables get automatic filtering via `createScopedDb`; non-tenant requests get `createTenantGuardDb` that throws on tenant-scoped table access. Raw DB is never exposed.
- **`execute()` and `$with()` blocked on scoped DB** — prevents bypassing tenant filtering with raw SQL or CTEs.
- **Startup validation** — tenant config without `sessionVars` is rejected at boot.

### Fixture pipeline rebuild

- **Fixtures go through the handler pipeline** — `resourceToHandlers()` converts `Resource` into standard `Handler[]` resolved via `resolveAndExecute()`. Auth, input validation, tenant scoping, and output validation apply uniformly.
- **Removed parallel Fastify registration** — fixtures no longer register routes directly; they flow through the same pipeline as all other handlers.

### Escape hatch removal

- **Removed from public API**: `pipe.sql`, `http.createServer`, `fitting.resolve`, `flow.createRequest`, `flow.createJob`, `flow.bindTransaction`. Internal code still uses these via direct module imports.
- **`manifold.seed()`** — managed entry point for seed scripts and one-off tasks. Creates a job context with `jobType: 'seed'` and runs the callback within it. Replaces manual `flow.createJob()` usage.
- **`pipework run`** now uses `manifold.seed()` internally.

### Handler pipeline improvements

- **Output validation** — `resolveAndExecute()` validates handler return values against `.output()` schemas. `HandlerResponse` envelope (`{ status, body }`) validated against status-specific schemas.
- **Multi-DB `httpOptions`** — per-database HTTP options now merge correctly instead of last-wins.
- **Enum DB constraints** — `pipe.define()` supports enum columns with database-level CHECK constraints.

### Housekeeping

- **Surface validators consolidated** — `src/validation/surface.ts` merged into `src/surface/validate.ts`.

## 0.6.4

- Fix index builder crash — `pipe.define()` with `indexes` option passed column builders instead of built columns to Drizzle, causing `SyntaxError: "undefined" is not valid JSON`. Indexes callback now receives the real Drizzle table object.

## 0.6.3

- Export `Branded`, `Brand`, `InferSelect`, `InferFieldOutput` types from top-level entry point — completes the set of domain types that leak through `pipe.define()` return types.
- Add `zod` and `drizzle-orm` as optional peer dependencies — consumers with `declaration: true` need these in their type resolution path since `schema.check` passes through zod types.

## 0.6.2

- Export `FieldModifiers` type from top-level entry point — fixes TS2742 when consumers emit declarations from `pipe.define()` results.

## 0.6.0

### Domain Traits

- **`.effectiveDated()`** — adds `effectiveFrom`, `effectiveTo`, `version` columns. Compatible with temporal queries.
- **`.audited()`** — adds `createdAt`, `updatedAt`, `createdBy`, `updatedBy` columns.
- **`.softDeleted()`** — adds `deletedAt`, `deletedBy` columns.
- **Composable** — chain multiple traits (`.effectiveDated().audited()`). Field collisions caught at define-time.
- **`autoManaged` fields** — trait columns are omitted from `insertShape()` and `updateShape()`, included in `selectShape()`.

### Temporal Query Helpers

- **`temporal.asOf(db, definition, date)`** — query records effective at a specific point in time.
- **`temporal.allVersions(db, definition)`** — query all versions without temporal filtering.
- **`temporal.effectiveUpdate(options)`** — close current version and insert a new one via `revise()`.
- All three validate that the definition has the `.effectiveDated()` trait and throw actionable errors if not.

### Safe Migration Post-Processing

- **`pipework generate`** now post-processes drizzle-kit output automatically:
  - `CREATE INDEX` → `CREATE INDEX CONCURRENTLY` (with `-- pipework:no-transaction` pragma)
  - `ADD CONSTRAINT ... FOREIGN KEY` → `NOT VALID` + separate `VALIDATE CONSTRAINT` statement
- Migrations with `CONCURRENTLY` execute outside a transaction (required by PostgreSQL).

### RLS Auto-Generation

- **`pipework generate`** emits RLS policies for tables with a `.tenant()` field when `rls.sessionVar` is configured.
- `tenantIsolationPolicy()` now accepts a configurable `sessionVar` parameter.

### Type Fixes

- **`DomainDefinition.table()`** returns `Table` instead of `unknown` — eliminates `as any` cascade in consumer code.
- **`DomainDefinition` publicly exported** — consumers can emit `.d.ts` declaration files.

## 0.5.0

### Domain Definitions

- **`pipe.define()`** — single definition projects to Drizzle table, zod validators (insert/select/update), and test factory. Fields are NOT NULL by default. camelCase field names map to snake_case columns automatically.
- **`pipe.field.*` builders** — `uuid()`, `text()`, `integer()`, `bigint()`, `boolean()`, `timestamp()`, `enum()`, `jsonb()`, `decimal()`, `custom()` with immutable chaining: `.nullable()`, `.primaryKey()`, `.defaultRandom()`, `.brand()`, `.references()`, `.tenant()`, `.unique()`, `.min()`, `.max()`, `.email()`, `.url()`, `.regex()`, `.precision()`, `.serializedAs()`, `.default()`.
- **Branded types** — `Branded<T, B>` nominal types via phantom unique symbol. `.brand('UserId')` on a field makes its output type incompatible with other branded strings at compile time. `.references(User, 'id')` auto-inherits the brand.
- **Virtual definitions** — `pipe.define('Config', fields, { virtual: true })` for definitions that produce validators and factories but no table.

### Workspace Management

- **`pipework sync`** — generates `package.json`, `pnpm-workspace.yaml`, and `pipework-guard.cjs` from config. Files are checksummed; manual edits are detected.
- **`pipework install`** — sync + `pnpm install`.
- **`pipework add <pkg@ver>`** / **`pipework remove <pkg>`** — manage dependencies in `pipework.config.ts`, then sync and install.
- **`pipework check`** — verifies workspace checksums before running type-check, lint, and boundary enforcement.
- **Preinstall guard** — generated CJS script blocks `pnpm install` if workspace files have been manually modified.

### Startup Validation

- **`manifold.start()` validation** — domain definitions (FK references, brand uniqueness, tenant field count, database affinity), surface integrity (database availability, job type conflicts), and database connectivity (parallel connection test) are all validated before any surface starts. All issues collected across phases before throwing `StartupValidationError`.

### Test Infrastructure

- **`defineTestConfig({ database: false })`** — skips manifold discovery, DB provisioning, and per-test isolation. Use for pure unit test configs that never call `pipe()` or `withFlow()`. Safe in CI without `DATABASE_URL_TEST`.

### Documentation

- **Removed `docs/` directory** — hand-written guides and recipes replaced by JSDoc on all public exports (enforced by `pnpm lint:jsdoc`), auto-generated `REFERENCE.md`, and the `/pipework` Claude Code skill.
- **Updated README** — streamlined to show `pipe.define()`, `defineTestConfig({ database: false })`, and point to REFERENCE.md for API docs.

## 0.4.1

- **Fix**: 0.4.0 shipped stale dist — rebuilt from source.

## 0.4.0

### Surfaces

- **Declarative surface system** — `surface.http()`, `surface.worker()`, `surface.script()` define typed entry points. The manifold validates database dependencies and job type uniqueness at startup.
- **Job handler builder** — `.job("type-name")`, `.retry({ maxAttempts, backoff })`, `.timeout(ms)` on the fitting builder for declarative job configuration.
- **Process lifecycle** — `manifold.start()` / `manifold.stop()` with ordered startup (scripts → workers → http), reverse-order drain, rollback on failure, and signal handling (`SIGINT`, `SIGTERM`).
- **`pipework dev`** — starts all surfaces with `node --watch` file watching. Falls back to `--entry` mode for non-surface projects.

### Test infrastructure

- **`useSetupDb()`** — non-isolated database proxy for `beforeAll`/`afterAll` fixture seeding. Works outside the per-test transaction lifecycle.

### Bug fixes

- **`loadEnvFiles()` directory walking** — now walks up from cwd to find `.env`/`.env.test` at the monorepo root, matching `discoverInstance()` behavior.
- **`useTestDb()` error message** — now suggests `useSetupDb()` when called outside the test lifecycle.

## 0.3.1

- **Fix**: `pipework/globals` export now ships `globals.d.ts` in the published package.

## 0.3.0

### Auth

- **Auth strategy DB access** — `AuthContext` interface gives `extract()`/`verify()` a `db(name?)` accessor so strategies can query the database (e.g., API-key lookup).

### Test infrastructure

- **Restricted role provisioning** — `TestDatabase` gains `appUrl`/`appRole` for a DML-only Postgres role, enabling real RLS and session-variable isolation tests.

### CLI

- **`pipework generate`** — Drizzle migration generation command.


## 0.2.0

### DI Builder

- **`.use()` with no argument** -- defaults to the single configured database. Handler receives it as `{ db }`.

### CLI

- **`pipework run <script>`** -- execute scripts inside a Flow context. Supports `--tenant` and `--job-type` flags.
- **`pipework test`** -- wraps vitest with automatic database setup/teardown. Pass args with `--`.

### Test infrastructure

- **`pipework/vitest-plugin`** -- vitest plugin replaces manual `setupFiles` configuration.
- **`pipework/vitest`** -- `defineTestConfig()` provides shared vitest defaults (timeouts, pool, plugin).
- **`db` global** -- single-DB apps get `db` as a vitest global. Add `"pipework/globals"` to tsconfig types.

### Observability

- **Query instrumentation** -- `observability` config section: `slowQueryMs` (default 500), `logAllQueries`, `poolMetricsInterval`. Deadlock detection with contextual logging.

### Boundaries

- **`no-raw-route-handlers`** -- boundary rule flags direct Fastify route registration. All routes must go through the fitting builder.

## 0.1.0 (2026-05-06)

Initial release.

### Highlights

- **Pipework** — TypeScript framework for multi-tenant SaaS applications. PostgreSQL-only. Owns the wiring between Drizzle, Fastify, Vitest, and Zod so your application code never has to.

### Core

- **`createManifold(config)`** — creates a `Manifold` instance with named database connections, pooling, and lifecycle management
- **`pipe`** — unified namespace for database operations: `pipe()` context-aware accessor, `pipe.sql`, `pipe.table()`, column types via `pipe.col.*`, indexes via `pipe.idx.*`, operators and aggregates
- **`fitting`** — type-safe DI builder: `.use()`, `.auth()`, `.input()`, `.output()`, `.route()`, `.fit()`
- **`schema`** — validation namespace wrapping Zod: `schema.object()`, `schema.string()`, `schema.parse()`, `schema.branded()`
- **`flow`** — AsyncLocalStorage context management: `flow.run()`, `flow.require()`, `flow.createRequest()`, `flow.createJob()`, `flow.createTest()`

### HTTP & Auth

- **`http.createServer()`** — Fastify wrapper with auto request IDs, transaction-per-request, production startup validation
- **`auth`** — pluggable strategies, JWT session management with refresh rotation, cookie-based token delivery, multi-org support via `auth.createMultiOrg()`
- **CORS, Helmet, rate limiting** — configured via pipework-owned types, no direct `@fastify/*` dependency needed

### Multi-tenant

- **Tenant scoping** — extraction from auth, `SET LOCAL` propagation, UUID validation
- **Defense-in-depth** — context fingerprinting (nonce), bound transactions, Postgres-side RLS verification
- **`tenant.rls()`** / **`tenant.policy()`** — generate row-level security policies

### Data

- **`fixture`** — REST resource builder with cursor-based pagination, batch operations (preview + execute), CRUD
- **`temporal`** — SCD Type 2 versioning with `temporal.revise()`, `temporal.close()`, `temporal.getCurrent()`, point-in-time queries
- **`behavior`** — composable behaviors: versioned + audited + cached on any resource
- **`state`** — validated state machine transitions with guards and audit integration
- **`pipeline`** — ordered step execution with persistent state and resume

### Infrastructure

- **`jobs`** — Postgres-backed queue with `SKIP LOCKED`, heartbeat, reaper, `LISTEN/NOTIFY`, synchronous wait, cron scheduling
- **`cache`** — in-memory cache with TTL, tenant-scoped variant
- **`rbac`** — hierarchical scope resolution, permission caching, DI integration via `.permission()`
- **`audit`** — structured audit trail emission
- **`vector`** — pgvector column types, distance functions, full-text search helpers
- **`openapi`** — automatic schema generation from handler metadata

### DX

- **`log`** — context-aware structured logging with automatic correlation fields, configurable redaction
- **`config`** — environment-aware configuration with validation, `testUrl` enforcement in test mode
- **`errors`** — `PipeworkError` base class, every error tells the developer what went wrong and how to fix it
- **Boundary enforcement** — static analysis prevents raw third-party imports, enforces architectural layers
- **Four-tier testing** — unit/contract, isolation integrity, property-based (fast-check), concurrency stress
