Documentation

Getting started with LakeHold

LakeHold is a self-hostable, tenant-aware lakehouse built on DuckDB and DuckLake. This guide takes you from an empty checkout to a running warehouse, then walks every feature — what it does, how to reach it, and what it is for. Tenant identity and credentials are scoped today; the current node-local session and artifact layout is for trusted evaluation or a one-tenant production profile, not shared adversarial multi-tenancy when tenants reuse a catalog name. See the production-readiness roadmap.

Running a production node is a separate discipline from learning the features. Operators should start with the operations hub, then assign and test the linked incident-response, disaster-recovery, and monitoring runbooks before accepting traffic.

This one file is the single source for both the in-app /docs page and the copy read on GitHub.


Quick start

You need Docker. The .NET 10 SDK and Node.js 20 or newer are needed only if you run the API and the Angular dev server on the host — the all-in-Docker path below compiles both inside the stack. Either way the backing services (PostgreSQL, MinIO, and a trace viewer) run in Docker.

1. Clone and configure secrets

.env holds secrets only and is gitignored. Copy the checked-in template and fill in what it documents.

git clone https://github.com/skuirrels/LakeHold.git
cd LakeHold
cp .env.example .env

2. Choose how to run it

Everything in Docker. One command brings up the backing services, the API, and the dev server. The website is served at http://localhost:5399; the API is on :5200.

docker compose up

SQL is always available. Add the isolated C# LINQ planner with a Compose profile:

docker compose --profile linq up

Removing the profile returns the same installation to SQL-only operation. Production deployments must supply LAKEHOLD_LINQ_PLANNER_KEY; see the complete LINQ Workbench guide.

Or — app on the host. Start only the backing services in Docker, then run the two app processes yourself. This is the faster inner loop for development.

docker compose up -d postgres minio minio-bucket jaeger   # backing services
dotnet run --project src/Lakehold.Api                      # API on :5200
npm start --prefix web/lakehold-ui                         # UI on :5399

Same URLs either way. The dev server proxies /api, /mcp, and MCP authorization metadata to NG_API_URL, which falls back to localhost:5200 when nothing sets it.

3. Open the workbench

Visit http://localhost:5399 and click Open the workbench. In development it ships with a seeded demo catalog — a demo workspace with an analytics catalog of 250,000 events and 5,000 customers — so there is something to run against before you load any data of your own.

Build and test

dotnet build Lakehold.slnx        # restore + build every project
dotnet test Lakehold.slnx         # integration tests skip unless their service is configured
npm test --prefix web/lakehold-ui
npm run build --prefix web/lakehold-ui

The tools you'll use

There are five ways into a LakeHold catalog. They resolve through the same capability and session boundaries, so you can mix them freely. MCP is stricter than the development HTTP API: it always requires a credential.

Tool What it is Best for
The workbench A browser query IDE with built-in SQL and an optional isolated C# LINQ planner, catalog-aware completion, generated SQL, diagnostics, history, snapshots, and maintenance. Ships seeded. Exploration, authoring, and operations.
A Postgres client LakeHold speaks the PostgreSQL wire protocol, so psql, DBeaver, or Npgsql connect to a catalog with no driver or plugin. The user is the tenant and the database is the catalog. Existing SQL clients and streamed results.
.NET & EF Core Through DuckDB.EFCoreProvider your application model and your lake tables are one model. .NET applications on the same schema.
The HTTP API Minimal-API endpoints for queries, schemas, history, snapshots, maintenance, eject, backup/restore, and change-feed subscriptions. Automation and integration.
MCP An authenticated Model Context Protocol server with tenant discovery, schema, snapshots, changes, query resources/tools, and optional operator-gated writes. Development enables it by default; an instance operator changes the live controls under System Settings with no restart. AI agents that need discoverable, capability-scoped lakehouse access.

The workbench, feature by feature

The browser workbench is the fastest way to explore a catalog and run maintenance. It is laid out top to bottom: a workspace and catalog picker, a schema explorer on the left, a language-aware editor, and a tabbed output pane below it with eight panels — Results, Query history, Data history, Storage, Changes, Backups, Eject, and Schedule.

Workspace and catalog pickers — top bar

A workspace is a tenant; a catalog is the isolated data unit attached to your session. Pick one of each — every query, history entry, and maintenance run is scoped to that pair. Isolation comes from which catalog is attached, not from anything in the SQL you submit.

Catalog explorer — left sidebar → Catalog

Browses the selected catalog's schemas, tables, and columns. Click an item to insert its name into the editor, so you can build a query without retyping identifiers.

Saved queries and published views — left sidebar → Saved queries

Save authored SQL or C# LINQ with its language, name, and description. SQL definitions accept one SELECT, query-producing WITH, or VALUES statement; DuckDB's parser rejects WITH-prefixed inserts, updates, and deletes before they are saved. A saved query belongs to the selected catalog, so another catalog may reuse its name; it carries an optimistic revision and always runs through a read-only catalog attachment—even when its author is an owner. Readers can list, open, and run definitions; editors and owners can create, revise, publish, unpublish, and delete them.

Publish exposes a saved query as a DuckLake view for PostgreSQL-wire, BI, and other SQL clients. The first publication refuses to overwrite an existing object. Editing the saved SQL does not silently change that external contract: the library marks the view republish needed until an editor explicitly republishes the new revision. A published definition must be unpublished before it can be deleted or moved to a different schema/name. Concurrent edits and publication operations are serialised before view DDL begins, and a failed metadata finalisation reconciles the live target so the next attempt remains recoverable. A published LINQ definition records its translation schema fingerprint; a changed catalog is shown as schema drift and requires review and republish. A LINQ plan with bound parameters can be saved and run but cannot become a view, because DuckDB view DDL has no parameter-binding lifetime.

POST /api/tenants/{tenant}/catalogs/{catalog}/saved-queries
Content-Type: application/json

{
  "name": "Revenue by country",
  "description": "Stable reporting grain",
  "sql": "SELECT country, sum(revenue) AS revenue FROM events GROUP BY country",
  "language": "sql"
}

The returned revision is required by update, publish, unpublish, and delete operations. A stale revision receives 409 Conflict rather than overwriting another author's edit.

SQL and C# LINQ editor — centre pane

The CodeMirror editor provides syntax highlighting, indentation, search, catalog/table/column completion, and ⌘⏎ (Ctrl+Enter) execution. SQL accepts arbitrary DuckDB/DuckLake statements; statements LakeHold does not specifically recognise fall back to the ordinary streaming path.

When the optional planner is healthy, choose C# LINQ from the language selector. The Workbench loads a catalog-aware starter and keeps a separate buffer for each language. It accepts one side-effect-free query expression and shows compiler diagnostics at their source positions. The result can be an IQueryable or Count, LongCount, Any, Min, Max, Sum, or Average. Generated parameterized DuckDB SQL is visible with the result. Removing or losing the planner keeps saved LINQ source readable but disables execution and authoring until the language returns; it is never relabelled as SQL. Read the complete C# LINQ guide for examples, supported types, deployment, security, API contracts, and troubleshooting.

File import — centre pane → Import data

Choose a browser-local CSV or XLSX file and create a new table without copying the file onto the API host yourself. For CSV, Automatic mode lets DuckDB detect the delimiter, quoting, header, line endings, and types. If a malformed row appears after detection, LakeHold rolls the strict attempt back and automatically retries the same request-scoped upload with a full-file scan, reject capture, and malformed-row skipping. The completed import clearly reports any rejected rows.

Advanced CSV settings exposes the parser choices and starts with the production-export profile:

delim = ';', quote = '"', escape = '', new_line = '\r\n',
header = true, sample_size = -1, ignore_errors = true, store_rejects = true

XLSX import uses DuckDB's native Excel reader. Leave Worksheet empty to import the first worksheet, or enter a worksheet name explicitly. LakeHold supports the modern .xlsx format; legacy .xls workbooks are not supported.

The upload and import are one streamed request. LakeHold writes the request body directly into disposable node-local scratch space, creates the table through the selected catalog's DuckDB session, and removes the scratch file before returning. On Unix hosts the scratch directory and files are owner-only. Durable rows go to the catalog's configured local or object storage; no later request depends on the node that accepted the upload. Imports require editor or owner access and never replace an existing table.

When CSV rejects are enabled—explicitly or through automatic recovery—DuckDB skips malformed rows and LakeHold returns up to the first 100 parser errors to the dialog. Capture is deliberately capped at one sentinel beyond that preview, so a hostile or badly malformed file cannot create an unbounded reject table; the dialog marks the report as truncated when more errors exist. Temporary reject tables are uniquely named and removed after the response is assembled.

The default per-file and aggregate node scratch ceilings are both 5 GiB, with two concurrent uploads and a 1 GiB free-space floor. Configure them with Lakehold:CsvImport:MaxBytes, MaxAggregateScratchBytes, MaxConcurrentUploads, MinimumFreeBytes, and ScratchRoot. LakeHold also scavenges scratch files older than StaleFileAge during coordinator startup. Reverse proxies must allow the same per-request size.

Results grid — Output → Results

Streams the result of the last statement into a grid, with the row count and elapsed time shown in the toolbar. Integers and decimals beyond JavaScript's safe range are transported losslessly as strings so nothing is silently rounded.

Results are capped by LakehouseOptions.MaxRowsPerResult so a large query cannot exhaust memory. When the cap is hit you see a "Row limit reached" tag. The Postgres wire endpoint streams rows to the socket and does not apply the cap.

Query history — Output → Query history

Every statement the workspace has run, successful or failed, with its row count, duration, and timestamp. Click a row to replay it into the editor. Postgres-wire traffic shows up here too, so client queries are visible for the first time.

Data history and time travel — Output → Data history

A table-oriented history browser over the catalog's DuckLake snapshots. The timeline shows commit time, commit message, schema version, and schema transitions. Choose a table, then:

  • Browse reads its rows inline exactly as they stood at that snapshot.
  • Changes drills into only the row-level inserts, deletes, and update pre/post-images committed in that snapshot.
  • Compare changes compares two table states. Because the change feed is inclusive, the browser reads after the baseline through the target rather than counting the baseline commit again.
  • Restore… requests a read-only server plan with live and historical row counts plus schema differences. Only Confirm atomic restore applies it.

Historical previews display at most 500 rows and fetch one additional sentinel row, so the UI marks a prefix as truncated instead of presenting it as complete. They can be opened in the SQL editor for further filtering. Read-only users can browse and compare but are not offered restore. Increase the Timeline limit when the desired snapshot is older than the newest 100. See Time travel below for the underlying SQL.

Storage — Output → Storage

What each table physically weighs: live rows, total size, Parquet file count, average file size, delete-file overhead — and, in the last column, whether the maintenance buttons above are worth pressing.

Two advisories appear there:

  • Flush pending — the table holds rows that are committed but not yet written to Parquet. This is also the only thing separating a table whose data is entirely inlined from an empty one: both report zero files, so without the row count a table with data in it would read as empty.
  • Fragmented — the table has drifted into the small-file problem: more than one file, averaging below the catalog's target_file_size (or the deployment's advisory floor when it has never set one). A single small file is never flagged, because a lone file cannot be merged with anything.

Select a table — from this rollup or with Inspect in the catalog tree — to open one inspector:

  • Overview combines the logical schema with the storage figures and the current DuckLake partition keys. If partitioning evolved, its earlier snapshot-bounded specifications remain available as history.
  • Files shows real paths and byte counts, with each data file paired to the delete file that applies to it.
  • Columns computes a live profile only when opened: exact null counts, min/max, approximate distinct counts and quartiles, followed by an on-demand bounded distribution for one selected column.

Files and Columns share an as of selector, so both the physical layout and the values can be read at an earlier snapshot. Views remain inspectable and profileable, but correctly show no files or partition layout of their own.

Every figure comes from DuckLake's own catalog, never from listing the data path. A directory listing would show orphaned files and live data identically, and enumerating an object store is an unbounded LIST that times out at scale. A snapshot predating the table's creation is reported as an error rather than an empty list — an empty list would be a different and false statement.

Changes — Output → Changes

The row-level change feed and the webhook subscriptions that push it, in one place: a subscription's last delivered snapshot only means something next to the snapshots the feed is showing.

Pick a table and a starting snapshot to read what changed. An update arrives as a pre-image and post-image pair sharing a row id, styled differently from a delete, so you can take either the net effect or the diff. Below the feed, create a subscription with an endpoint URL and a signing secret, or delete one — deletion asks first. Consecutive failures and the last error are shown per subscription, because a subscription you cannot observe is one you cannot trust.

Backups — Output → Backups

Every backup generation, newest first, with its snapshot and table count. A complete generation offers Restore…; an incomplete one is marked and offers nothing, because a generation with no manifest died partway through and restoring it could silently reinstate deleted rows.

Restore always writes a new catalog file and never overwrites, so the catalog you are rescuing is untouched whatever happens. A bare file name lands beside the server's other catalogs; an absolute path is used as given. The result reports the absolute path it actually wrote.

Eject — Output → Eject

The bundles written by the exit path, newest first. Expand one to see its per-table attestation: row counts and SHA-256 digests, and whether the bundle is signed. A bundle with no manifest is marked untrusted rather than listed as if it were usable.

Eject has no dry run — it only ever writes a new bundle and never touches the catalog — and an include history option decides whether the metadata catalog travels with the data so time travel survives the export.

Schedule — Output → Schedule

The scheduled maintenance run log: which job ran against which workspace and catalog, when, how long it took, and whether it succeeded. Scoped to what your credential is allowed to see.

Maintenance operations — top bar → Maintenance

Five one-click operations:

Operation What it does Destructive?
Flush Writes inlined commits out as Parquet. No
Compact Merges small Parquet files. No
Backup Exports the metadata catalog to Parquet under the backup root. No
Expire Drops snapshots older than seven days. Yes — dry-run by default
Cleanup Deletes data files no longer referenced by any snapshot. Yes — dry-run by default

The two destructive operations return what they would do and change nothing until you click Apply for real. Flush and Compact are the only operations that commit — they run inside a transaction labelled lakehold maintenance: … so platform writes stay distinguishable from a tenant's own.

The first three also run on a schedule, so you rarely need this menu — see Automatic maintenance.


Automatic maintenance

Flush, backup, and compact run on a cron schedule against every writable catalog in every workspace, so a lakehouse stays healthy with nobody clicking anything. Read-only shares are skipped: they have nothing to maintain.

Job Runs by default Why that cadence
Flush Every 15 minutes — 0 0/15 * * * ? The consequential one. Inlined commits that have never been flushed are the one category of data that is permanently unrecoverable if the catalog is lost, so this interval is the upper bound on that exposure.
Backup Hourly, on the hour — 0 0 * * * ? Bounds how much snapshot history a catalog loss costs.
Compact 02:30 daily — 0 30 2 * * ? Non-destructive but I/O heavy, so it stays off-peak.

Expire and cleanup are never scheduled. They delete data, and automating an irreversible deletion would quietly undo the safety the rest of the product argues for. They stay manual and dry-run-by-default, whatever else you configure.

Changing the schedule

Everything lives under Lakehold:Maintenance in appsettings.json:

{
  "Lakehold": {
    "Maintenance": {
      "Enabled": true,
      "FlushCron": "0 0/15 * * * ?",
      "BackupCron": "0 0 * * * ?",
      "CompactCron": "0 30 2 * * ?",
      "NodeId": "", // defaults to the machine name
      "LeaseDuration": "00:30:00",
    },
  },
}
Setting Default Meaning
Enabled true Whether the scheduler runs at all. false makes every operation manual.
FlushCron · BackupCron · CompactCron above When each job fires.
NodeId machine name Identifies this node when claiming a maintenance lease.
LeaseDuration 30 minutes How long a lease stays valid if the node holding it never releases it.

Any of them can be set as an environment variable instead, which is usually how a container is configured — double underscores separate the levels:

Lakehold__Maintenance__FlushCron="0 0/5 * * * ?"
Lakehold__Maintenance__Enabled=false

These are Quartz cron expressions, not Unix cron. The first field is seconds, so there are six, and 0 0/15 * * * ? means every 15 minutes, not every 15 hours. Day-of-month and day-of-week cannot both be constrained, which is why one of them is ?. Paste a five-field Unix expression such as */15 * * * * and the API will not start: it fails with System.FormatException: Unexpected end of expression. Times are the server's local time zone, so the 02:30 compaction follows the host's clock rather than UTC.

A missed window is skipped rather than replayed: if the host was asleep or busy past the trigger time, the job waits for the next one instead of firing a backlog the moment it wakes.

Seeing what actually ran

A schedule you cannot observe is a schedule you will not rely on, so recent runs are readable:

curl -s http://localhost:5200/api/maintenance/schedule

Each entry gives the job, workspace, catalog, start time, elapsed milliseconds, whether it succeeded, and a detail line. It is an in-memory ring of the last 100 runs — an operational read-out, not an audit trail; query history covers durable auditing, and the list starts empty after a restart. The effective schedule is also logged once at start-up.

The list is scoped to your credential, the same way the tenant listing is: an instance token sees every workspace's runs, a tenant token sees only its own, and a catalog-narrowed token sees only that catalog's.

A catalog skipped because another node was doing the work is recorded as exactly that, rather than being left out: "nothing ran" and "another node ran it" have to be tellable apart when you are working out why a backup is missing.

Running more than one node

Schedules are held in memory on each node, so every node fires the same sweep at the same moment. Duplicate work is excluded by a lease taken per job, per catalog — not by centralising the schedule, which is identical everywhere because it comes from configuration.

The lease only engages where a catalog can genuinely be shared, which is where its metadata is in PostgreSQL; a local file catalog cannot be opened twice at once anyway. Two things are worth setting deliberately: NodeId, if several containers share a host and would otherwise present the same identity, and LeaseDuration, which only needs to exceed the longest plausible single run so a slow compaction is never mistaken for a dead node.


Time travel

Every write to a catalog — an INSERT, UPDATE, DELETE, a schema change, or a maintenance flush or compaction — is committed as a snapshot: an immutable, numbered version of the whole catalog. Nothing is overwritten in place, so every past state is still readable. Time travel is simply querying a table as it was at one of those snapshots.

How it works

DuckLake records a snapshot for each commit, identified by a monotonic snapshot id and stamped with its commit time and schema version. Deletes and updates are stored as merge-on-read changes rather than by rewriting data, so an earlier snapshot reconstructs exactly: the rows a later snapshot deleted are still on disk, only masked at the current version. That is why history is real rather than a convention — and why copying raw Parquet files resurrects deleted rows, which is what Eject exists to avoid.

Finding a snapshot

Open Data history in the workbench. It lists snapshots newest first and lets you browse a selected table at that version, inspect one commit, or compare two versions without first writing SQL. The same timeline is available over the API at GET …/snapshots, and in SQL through the catalog's snapshots() function:

SELECT snapshot_id, snapshot_time, schema_version
FROM analytics.snapshots()
ORDER BY snapshot_id DESC;

Querying the past

Add an AT (…) clause to a table reference. By snapshot id:

-- The events table exactly as it was at snapshot 42
SELECT * FROM events AT (VERSION => 42);

Or by a point in time — DuckLake resolves it to whichever snapshot was current then:

-- The events table as of a moment
SELECT * FROM events AT (TIMESTAMP => TIMESTAMP '2026-07-20 09:00:00');

The clause attaches per table reference, so a single query can read two points at once. To see what a later snapshot removed:

-- Rows that existed at snapshot 42 but are gone now
SELECT * FROM events AT (VERSION => 42)
EXCEPT
SELECT * FROM events;

Everything else is unchanged — joins, filters, aggregates, and LIMIT all work against the historical version, and the read is never destructive.

Restoring table data safely

Time travel reads the past; Restore… in Data history returns a read-only plan before changing anything. It shows the live and historical row counts, the shared columns that will be restored, current-only columns that will receive current defaults or nullability, and historical-only columns that will be ignored. Confirm atomic restore is then a separate action.

Apply stages the historical rows before deleting live rows and inserts them through the existing table definition inside one labelled transaction. The current defaults, nullability, and constraints remain authoritative; if any historical row is incompatible, the transaction rolls back completely. The successful restore records a new snapshot, so earlier row states remain available. Confirmation also carries the live snapshot id from the reviewed plan: if another commit lands in between, apply refuses and requires a fresh review.

The same unversioned API used by the workbench is available to an editor or owner:

POST /api/tenants/{tenant}/catalogs/{catalog}/snapshots/42/restore-table
Content-Type: application/json

{ "schema": "main", "table": "events", "apply": false }

Send the same request with "apply": true and the returned "currentSnapshotId" as "expectedCurrentSnapshotId" only after reviewing the plan. A snapshot covers the whole catalog, but this operation restores one base table at a time and refuses a snapshot from before the table existed.

Do not substitute CREATE OR REPLACE TABLE … AS SELECT … AT (…): CTAS discards the current table's defaults and nullability. DELETE followed directly by INSERT … AT (…) is also unsafe because the historical read can resolve through the pending delete. The restore operation exists to stage first and guarantee rollback.

What it's good for

Goal How
Recover rows deleted by mistake Read the old version and write the rows back: INSERT INTO events SELECT * FROM events AT (VERSION => 41) WHERE ….
Audit how a value changed Query the same key across several snapshot ids.
Reproduce a past report Run the report's query with AT (TIMESTAMP => …) set to the report date.
Diff two versions EXCEPT or INTERSECT between two AT (…) reads.

History is not kept forever. Snapshots accumulate until you expire them. Expire (Maintenance) drops snapshots older than seven days and Cleanup deletes the files they alone referenced — both are irreversible and dry-run by default, so time travel to an old snapshot keeps working until you explicitly apply them. To carry history across an export, back up or eject with the metadata catalog; the data files alone recover the latest state only. See Catalog backup and restore.


Data operations in depth

These are the features that make LakeHold a lakehouse rather than a query box. Each has a panel in the workbench, described above; this section is what those panels are doing and the routes to drive them from your own code. They all sit under /api/tenants/{tenant}/catalogs/{catalog}/.

Storage and table detail

GET …/storage · GET …/storage/files?schema=&table=&snapshot=&limit=
GET …/table-detail?schema=&table= · GET …/table-profile?schema=&table=&snapshot=
GET …/column-distribution?schema=&table=&column=&snapshot=&limit=

The figures behind the Storage panel, from DuckLake's own metadata rather than a directory listing. The rollup carries per-table row counts, inlined rows, file counts and sizes, delete-file overhead, and the two advisories; the response also reports the threshold each compaction verdict was computed against, so a caller can check the reasoning rather than trust it. Schema and table are query parameters, not path segments, because a table name may contain a dot or a slash.

All are reads and need only TenantData — knowing how large a table is or what values an authorised reader can query is not the owner's decision to authorise, even though pressing Compact is. Encryption-key columns are never projected into a response.

The detail response combines the schema, one table's existing storage projection, and current plus historical partition specifications under one Duckling gate. The profile reads the table's logical rows, not ducklake_file_column_stats: physical stats can omit inlined rows and retain values later removed by merge-on-read deletes. Distinct counts and quartiles are labelled approximate. A distribution is requested separately and capped at 50 buckets; no raw sample rows are returned.

Caveat. The file list returns one row per file and is capped, reporting truncated when it stops short. A snapshot predating the table's creation is a 400 carrying the engine's own message, not an empty list.

Eject — the exit path in one call

POST …/eject · GET …/ejects

Re-materialises every table through the catalog into ordinary Parquet, plus the metadata catalog when you want history. Because it re-reads rather than copying files, merge-on-read deletes are applied, superseded update rows are gone, inlined commits are included, and DuckLake's internal columns never leak. Every table is counted back through a plain Parquet reader and compared to the catalog before the manifest is written; the manifest carries per-table row counts, SHA-256 digests, and an HMAC signature when a key is configured.

Caveat. A copy of the data path is not an eject — copying files resurrects deleted rows and duplicates updated ones. Eject is read-only and works on a read-only share, so it needs no flush first. See docs/EXIT-PATH.md for the procedure eject automates.

Catalog backup and restore

GET …/backups · POST …/backups/restore

The metadata catalog is exported to Parquet on a schedule and can rebuild a working catalog from that export — row counts, deletions, updated values, views, and AT (VERSION => n) time travel all intact. A catalog whose metadata lives in PostgreSQL restores into a plain DuckDB file, so it is an exit path from the catalog database, not just a copy.

The engine supports an s3:// backup root, but the packaged API currently resolves the backup root beneath Lakehouse:StateRoot; in the production Compose stack that is the same state volume as the catalog. A catalog backup is therefore not protection from losing the whole volume. Export a consistent state archive off-host and follow the disaster-recovery runbook.

A relative target path is resolved against the server's metadata root, so a bare file name lands beside the catalogs it belongs with; an absolute path is used as given, and the response reports the absolute path that was actually written.

Caveat. Restore never overwrites an existing catalog, and refuses an export with no completion manifest — a partial export missing ducklake_delete_file would silently reinstate deleted rows.

Change data capture

GET …/cdc/snapshots/{snapshot}/changes · GET …/changes · …/subscriptions · …/cdc/consumers

DuckLake already records what each snapshot changed, so LakeHold exposes it directly: a typed pull API for opaque cursor-paged change pages, and outbound webhooks fired per new snapshot and signed with HMAC-SHA256. Updates arrive as a paired pre-image and post-image sharing a row id, so you can take net effect or diff them. No Debezium, no Kafka, no second pipeline.

Caveat. Delivery is at-least-once. PostgreSQL keeps one stable delivery id/body and expiring worker lease per subscription and snapshot; the cursor advances only after a 2xx, so a crash can replay but cannot skip. Each attempt has a fresh timestamp and signature over the v1.<timestamp>.<delivery-id>.<body> base. Deduplicate by delivery id, and use the pull cursor when an inline payload is truncated. Live subscriptions, including paused subscriptions, and active registered consumers block snapshot expiry from removing changes they still need.

PostgreSQL wire endpoint

TCP :5433 (off by default)

Connect any Postgres client straight to a catalog — the user is the tenant, the database is the catalog. Statements resolve through the same tenant check, session gate, and query history as an HTTP query. Results stream to the socket rather than being materialised, so the row cap does not apply.

Caveat. psql, DBeaver, and Npgsql work today; Power BI does not yet (it reads a type catalogue DuckDB leaves empty). Enabling the port is a deliberate choice: it is off by default, with TLS and per-tenant credentials. See docs/POSTGRES-WIRE.md.


.NET and EF Core

LakeHold is built on DuckDB.EFCoreProvider, so your application model and your lakehouse tables can be the same EF Core model — no transformation layer, no schema drift. The split is by whether the workload has a known shape:

  • Modelled state — tenants, catalogs, saved queries, and audit history — uses ControlPlaneContext on native DuckDB, which needs sequences and RETURNING.
  • Arbitrary result shapes — anything you query against a lake catalog — stream through the provider's dynamic path, capped and cancellable, without inventing fake entity types.

The repository now includes Lakehold.Client and a first-party DuckDB replication worker. The initial worker is intentionally narrow: configured keyed or append-only tables, scalar types, and fail-closed handling for schema drift or snapshot gaps. It is an executable deployment component, not yet a published general-purpose NuGet change-stream package.


Authentication

LakeHold authenticates with API tokens. A token names one tenant, may be narrowed to a single catalog, carries a role (owner, editor, reader), and can be revoked — which closes the HTTP API and the PostgreSQL wire endpoint together.

Enforcement is opt-in, and off by default. Until you turn it on, a request with no token still works and is trusted to name its own tenant — which keeps local development frictionless and is exactly what you must not deploy. To close the door:

{ "Lakehold": { "Auth": { "RequireAuthentication": true } } }

Getting your first token

A node with no tokens mints one on start-up, logs it once, and never again. It is instance-scoped: it provisions tenants, catalogs, and other tokens, but deliberately cannot read data — so a leaked admin credential is a visible provisioning problem rather than a silent data breach.

The workbench does this for you. Open a fresh deployment and it asks for that bootstrap token, then for a workspace and catalog name, creates both, and mints the token that can query them — showing it once, because the server keeps only a hash. Adopting it is one click. The steps below are the same thing by hand, for a script or a deployment with no browser in reach.

docker compose -f compose.production.yaml up -d
docker compose -f compose.production.yaml logs api | grep -i bootstrap

API=http://localhost:8080/api
ADMIN='lkh_admin_…'

curl -X POST $API/tenants -H "Authorization: Bearer $ADMIN" \
  -H 'Content-Type: application/json' -d '{"slug":"acme","displayName":"Acme"}'

curl -X POST $API/tenants/acme/catalogs -H "Authorization: Bearer $ADMIN" \
  -H 'Content-Type: application/json' -d '{"name":"analytics"}'

curl -X POST $API/tenants/acme/tokens -H "Authorization: Bearer $ADMIN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"bi","role":"reader","catalogName":"analytics"}'

Mind the port. The production stack does not publish the API — nginx serves only the private Workbench at :8080/workbench and proxies /api on the same origin, so provisioning goes there. The public website routes are enabled only by make demo. On the development stack the API is published directly and the base URL is http://localhost:5200/api instead. Every $API in this section is one or the other.

Set Lakehold__BootstrapToken if your platform injects credentials and cannot scrape a log.

A token is shown once at creation and stored only as a SHA-256 hash, so it cannot be recovered — from the API or the database. admin is a reserved tenant slug: a tenant of that name would mint tokens indistinguishable from instance-scoped ones.

Roles

Role Query Write Maintenance, restore, eject Manage tokens
reader yes no — catalog is attached read-only no no
editor yes yes no no
owner yes yes yes yes

A token created without role is a reader, and so is one naming a role that is not recognised — so the cost of a typo is a credential that can read rather than one that can do everything. Pass "role":"owner" or "role":"editor" deliberately when you mean them, and confirm what you issued with GET /api/tenants/{tenant}/tokens, which shows the role and never the secret.

Capability comes from attachment

A reader token does not get a permission check that clever SQL might route around — its catalog is attached read-only, so a write fails in the engine itself. That is the same reasoning behind LakeHold's isolation model: a session can only reference the catalog attached to it.

Revoking

curl -X DELETE $API/tenants/acme/tokens/2 -H "Authorization: Bearer $ADMIN"

Revocation is immediate and closes both surfaces: the HTTP API and the PostgreSQL wire endpoint resolve against the same store, so a revoked token stops a BI tool as well as a script. There is no un-revoke — issue a new token.

Signing in to the workbench

Where OIDC is configured, Continue with your identity provider starts an authorization-code + PKCE login. The API returns an HttpOnly session cookie; provider tokens never enter browser JavaScript. A person carrying the configured system-admin claim opens System Settings and can change MCP controls and issue, review, or revoke client credentials.

The token field remains the machine and break-glass path. A pasted token is held for that browser session only and sent as a bearer token on API calls. With neither OIDC nor a token, the workbench behaves anonymously only where the deployment has explicitly left authentication optional.

When a deployment requires authentication and the browser has no usable token, the workbench says so and asks for one instead of showing empty pickers — the same panel that runs the first-workspace flow above. A token that has been revoked, or one from a rebuilt node, lands in the same place: the API answers 401, and the panel is the remedy rather than an error.

Connecting a BI tool with a token

Set Lakehold:PgWire:AllowTokenAuthentication and give the token as the password, with the tenant as the user and the catalog as the database. Because a hashed store cannot answer PostgreSQL's MD5 challenge, this uses the cleartext exchange — so the API refuses to start unless TLS is required or cleartext is explicitly acknowledged, rather than quietly putting a credential on the wire.

When a request is refused

Code Meaning
401 No credential when one is required, or one that did not resolve. Malformed, unknown, revoked, and expired are reported identically — you never learn which.
404 Valid credential, but it does not name that tenant or catalog. Deliberately not 403: a 403 would confirm the tenant exists.
403 Right subject, wrong capability — a reader running maintenance, or a tenant token calling provisioning.

Signing in as a human

Humans can use OIDC (Keycloak, Entra, Authentik, Auth0). Configure an authority and client id, register /auth/callback, and map either tenant/role claims or the system-admin claim. Leave it unset and the whole path stays off, so an air-gapped install never takes an identity-provider dependency. Tokens and OIDC resolve to the same principal and capability policy.

The full design, the endpoint and capability tables, and the runbook for closing the door on a fresh deployment are in docs/AUTHENTICATION.md.


Reference

The design docs go deeper than this guide.

Document Covers
Operations Production ownership, entry gates, routine work, and the incident, disaster-recovery, and monitoring runbooks.
docs/ARCHITECTURE.md Architectural rationale and current product boundaries.
docs/EXIT-PATH.md The verified open-format exit procedure that eject automates.
docs/POSTGRES-WIRE.md The wire protocol surface and what is deliberately unimplemented.
docs/AUTHENTICATION.md The phased plan for API authentication and tenant identity.
C# LINQ in the Workbench Enablement, editor model, API contract, isolation, supported types, and troubleshooting.
docs/UI.md The web surfaces around the query Workbench, and why a raw object browser is not one of them.
docs/MCP.md The MCP server, the tools an agent gets, and the ones deliberately withheld.
docs/PROVIDER-FEEDBACK.md Provider capabilities and why the data plane uses its dynamic API.
README.md Build, run, and the full set of environment variables and test commands.

Ready to run a query?

The development stack ships with a seeded catalog — 250,000 events, queryable immediately.

Open the workbench