Deployment & Packaging Specification
Status: v1.61 Owner: datapipelines.co core Depends on: all other specs Last updated: 2026-11-01
1. Purpose
This spec defines how datapipelines.co is packaged, distributed, and deployed in self-hosted environments. The product is open-source and self-hosted; this document covers the artifacts we produce and the deployment patterns we support.
This is lighter than the core specs — most operational concerns are the operator's responsibility (we ship the artifact, they run it). We document our artifacts, required infrastructure, configuration surface, and recommended deployment patterns.
2. Design Principles
- One artifact, many deployments. A single Docker image runs in dev, staging, prod. Configuration via env vars + mounted files; no build-time decisions.
- Stateless app, externalized state. The app has no local persistent state. Metadata DB (Postgres) and Redis (result store, idempotency keys, cancellation flags, post-completion event log) are external. H2 staging is per-execution in-memory.
- Container-first. Primary distribution is a Docker image. JVM-only deployments (no container) are supported but secondary.
- k8s-native but not k8s-required. Kubernetes is the recommended target. Docker Compose works for small / single-node deployments. Bare JVM works for development.
- Configurable without forking. Every deployment-relevant knob is an env var or config-file value. Operators do not edit source code to deploy.
- Open-source distribution. The image and JARs are published to public registries / Maven Central. No proprietary distribution channel.
3. Build Artifacts
3.1 Docker image
Published to ghcr.io/datapipelines/datapipelines:{version} and docker.io/datapipelines/datapipelines:{version} (mirror).
- Base image:
eclipse-temurin:21-jre(the Ubuntu glibc JRE 21 image, chosen over a smaller alpine base because argon2-jvm loads its native library through JNA, which ships glibc binaries). - Layers: single stage: the boot jar is built on the host (
./gradlew :modules:app:bootJar) and copied in; the image adds the DuckDB extension bundle, the GraalJS isolate with its pre-installed resource cache, and the third-party notices file. - Bundled DuckDB extensions (dp-lake):
httpfs,aws,icebergandavrofor DuckDB core v1.5.5, pre-populated under/opt/duckdb/extensions/v1.5.5/<platform>/(<platform>=linux_amd64orlinux_arm64, following the build'sTARGETARCH) and activated by the image'sENV DATAPIPELINES_DUCKDB_EXTENSION_DIRECTORY— a LAKE datasourceLOADs them from disk and neverINSTALLs at runtime, so no egress toextensions.duckdb.orgis needed (Configuration §3.25). Adds ~108 MB uncompressed (~39 MB downloaded at build). The eight decompressed binaries (four per platform) are pinned indeploy/duckdb-extensions.sha256; the extension build layer runssha256sum -c --ignore-missing --strictbeforeUSERand refuses a mismatch./app/THIRD-PARTY-NOTICESincludes the extensions at their footer commits, their copyrights and licences, and their pinned linked closure: direct and transitive vcpkg ports, the DuckDB corethird_partycomponents, and code bundled inside ports (the committed closure files and re-pin procedure are described below). - Size target: < 250 MB compressed.
- User: non-root (
datapipelines, a system account created withuseradd --system; no fixed UID — a chart or composerunAsUsermust not assume 1000). - Entrypoint:
["java", "-Dpolyglot.engine.userResourceCache=/opt/graaljs/resources", "-jar", "/app/datapipelines.jar"](exec form). The image defines no JVM-flags hook: a JVM option variable (JAVA_TOOL_OPTIONSand friends) would reach the JVM without one, which is why the shipped deploy files refuse those names (§6.4, §9).
To re-pin on a DUCKDB_CORE_VERSION bump, update three inputs together: the
Dockerfile core ARG, deploy/duckdb-extensions.sha256 (eight sums plus the four
# <name> commit=<hash> annotations), and the commit-bound licence inventory in
buildSrc/src/main/kotlin/DuckDbExtensionLicenses.kt. In a temporary directory,
for each of linux_amd64 and linux_arm64 and each of httpfs, aws, iceberg,
avro, fetch https://extensions.duckdb.org/v<core>/<platform>/<name>.duckdb_extension.gz,
decompress with gunzip, and run sha256sum on the decompressed file. Write
sorted sha256sum -c lines relative to /opt/duckdb/extensions; inspect each
binary's final 512 bytes (tail -c 512 <file> | strings) for its extension
repository commit, core and platform. Both platforms must report the same commit
per extension. Refresh each extension's linked closure,
deploy/duckdb-extension-ports/<name>.tsv, at those commits and the core version, as
DEVELOPMENT.md's third-party notices section describes: the vcpkg port closure, direct
and transitive, plus the DuckDB core third_party code and port-vendored code that
both binaries' symbols witness. Review every new component's LICENSE, update its
licence in DuckDbExtensionLicenses.kt and vendor any new SPDX text from the snapshot
named in the allowlist. Run :modules:app:generateThirdPartyNotices, the buildSrc licence
fixtures and DockerfileDuckDbExtensionPinTest, rebuild the jar, and prove one
bad sum refuses the image build before building the restored pins successfully.
-Duser.timezone=UTC is normative, not a suggestion. The type system assumes a UTC JVM: ingest normalizes timezone-aware source values to UTC and treats naive source timestamps as already-UTC (Type System §8.4), and every internal TIMESTAMPTZ is stored and rendered in UTC. A container whose JVM default zone is anything else silently shifts rendered timestamps. No image entrypoint sets it: every deployment — container, bare JVM or JAR (§3.2, §6.5) — MUST pass -Duser.timezone=UTC itself.
Heap and container-memory sizing: §6.6.
Native thread stacks. The image sets GLIBC_TUNABLES=glibc.pthread.stack_cache_size=0 before its build-time
and application JVMs start (#685). This disables reuse of native thread-stack mappings when the JVM hosts the
script isolate. An operator-supplied GLIBC_TUNABLES replaces the image value: retain
glibc.pthread.stack_cache_size=0 when adding other colon-separated tunables. DockerfileGraalJsPinTest pins
the declaration and its position before the JVM launches.
3.2 JAR distribution
- Published to Maven Central:
co.datapipelines:datapipelines-app:{version}. - Executable Spring Boot fat JAR (
./gradlew bootJar). - Runnable directly:
java -jar datapipelines-app.jar.
3.3 Module artifacts (for embedding)
- Each module published to Maven Central:
co.datapipelines:typesystem,co.datapipelines:dag, etc. - Allows third parties to embed parts of datapipelines.co in their own products (e.g., use our type system, or our MCP server in a different application).
- Marked "experimental API" until v1.1 stabilizes module boundaries.
3.4 Source distribution
- GitHub release per version, with signed tag.
- Source tarball attached to the release.
- Reproducible builds (Gradle lockfile committed, exact JDK pinned via toolchain).
3.5 JDBC driver matrix (what ships in the image)
Which drivers are present is a packaging property of the artifact, not a configuration one. The licensing rationale and the driver-class lookup live in Datasources §10; this table is the operator's view of the published image.
| Dialect | In the published image? | How to get it otherwise |
|---|---|---|
POSTGRES |
Yes (bundled) | — |
MSSQL |
Yes (bundled) | — |
H2 |
Yes (bundled — also the staging engine) | — |
DUCKDB |
Yes (bundled) | — |
SQLITE |
Yes (bundled) | — |
LAKE |
Yes (bundled — the same duckdb_jdbc jar as DUCKDB) |
— |
ORACLE |
No (OTN license) | Rebuild with ./gradlew -Poracle bootJar, or drop ojdbc11.jar into lib/ |
MYSQL |
No (GPL-2.0 + FOSS exception, redistribution unverified) | Rebuild with ./gradlew -Pmysql bootJar, or drop mysql-connector-j.jar into lib/ |
The lib/ drop-in. Spring Boot's PropertiesLauncher adds every jar in loader.path to the application classpath (Module Structure §5.4.1). For the container, mount the DRIVER jar read-only into /app/lib/: the image ships that directory empty, root:root mode 0555, and sets ENV LOADER_PATH=/app/lib,/opt/graaljs itself: the drop-in directory, then the directory holding the image's JavaScript isolate jar, as one list because a -Dloader.path would override the variable, and each entry absolute because a relative entry resolves against the working directory and the image sets no WORKDIR. Neither deploy/compose.yml nor the Helm chart sets LOADER_PATH. For bare JVM, place it in a lib/ directory beside datapipelines-app.jar and start with -Dloader.path=lib (relative to the working directory) or an absolute path. This is the no-rebuild path — the operator accepts the driver's license by supplying it.
Registering a datasource whose dialect has no driver on the classpath fails at save time with datasource.driver_not_loaded (Datasources §9) — a deployment/packaging error, not a bad payload. The same payload succeeds after the driver is supplied.
4. Required Infrastructure
4.1 Postgres (metadata DB)
- Version: 14+ recommended.
- Purpose: persistent storage for pipelines, templates, datasources, executions, audit log.
- Provisioning: operator-managed. We support connecting to an existing Postgres instance.
- Database size: small (a typical deployment has < 1 GB metadata).
Connection keys (URL, credentials, Hikari pool size) are defined in Configuration §2 / §3.13.
4.2 Redis (result store and coordination)
- Version: 6+ recommended.
- Provisioning: operator-managed.
- Purpose — four distinct workloads share one store:
- Caller results. Every completed execution's caller result is materialized to Redis and read through the cursor (REST API §7). There is no inline-vs-large split.
- Idempotency keys (
Idempotency-Keyrecords, retained perdatapipelines.idempotency.ttl-seconds). - Cancellation flags (
dp:cancel:{execution_id}) — howDELETE /executions/{id}reaches the executing instance (DAG Executor §8.3.1). - Post-completion event log, 1 hour, backing SSE replay (REST API §10.3).
Connection keys are defined in Configuration §2 / §3.1; TTL and size limits in Configuration §3.5.
4.2.1 Required Redis configuration
maxmemory-policy noeviction. This is not tuning advice — it is a correctness requirement. Any LRU/LFU eviction policy lets Redis silently discard keys under memory pressure, and every key class above is load-bearing:
- an evicted result turns a completed execution into a spurious "expired" 404 before its TTL;
- an evicted idempotency key lets a client retry execute the pipeline a second time;
- an evicted cancellation flag makes
DELETE /executions/{id}a no-op, leaving the execution running; - an evicted event-log entry silently truncates SSE replay.
Every key the app writes carries an explicit TTL, so noeviction does not leak: the store drains on its own schedule. Under genuine memory exhaustion the app fails loudly (result.storage_unavailable on the write path) rather than corrupting semantics quietly.
Where the password lives (#189, #196). In the reference compose files the Redis password has one home inside the container: the service's environment (REDISCLI_AUTH). The healthcheck's redis-cli reads it from there, and the server reads its requirepass from the same variable through stdin — redis-server - takes its configuration from standard input, and a sh -c wrapper feeds it a heredoc, then execs so redis-server stays PID 1. Nothing carries the value on a command line (docker inspect … {{.Config.Cmd}} and ps inside the container show the variable's NAME only), and nothing writes it to a file. --maxmemory and --maxmemory-policy noeviction stay on argv; options after - are read after stdin and win. The wrapper is sh, so the service declares user: redis — the image's entrypoint drops privileges only when argv[0] is redis-server. Operators: the next app.sh --start (or docker compose up -d) after this change recreates the Redis container; what that loses is exactly what any Redis restart loses (§7: unexpired results, idempotency records, cancellation flags, the event log — all volatile by design).
4.2.2 Sizing
Budget for the peak sum of all four workloads:
peak ≈ (datapipelines.result.max-size-bytes × concurrent recent executions) ← dominant term
+ idempotency keys (small, × datapipelines.idempotency.ttl-seconds window)
+ 1h of post-completion event logs (small, proportional to nodes × executions)
+ cancellation flags (negligible)
The result term dominates and is the only one worth arithmetic. Results live for their TTL (datapipelines.result.ttl-default-seconds, clamped between ttl-min/ttl-max — Configuration §3.5), so "concurrent recent executions" means executions completed within one TTL window, not executions running right now. Worst case with defaults (100 MB cap, 300 s TTL) is dominated by how many callers actually pull 100 MB results; the practical lever is lowering result.max-size-bytes and pointing bulk workloads at output.target: datasource instead — the explicit NOT-goal of result delivery is bulk data transfer.
512 MB is a reasonable starting point for small deployments; raise it before raising result.max-size-bytes.
4.3 Network egress
The app must reach:
- Configured datasources (PG, Oracle, MSSQL, MySQL, DuckDB files, SQLite files).
- The metadata Postgres.
- Redis.
- (Optional) OTLP collector, Sentry, KMS — if those features are configured.
The app does not require inbound network access beyond the HTTP/MCP ports it serves.
5. Configuration
configuration.md is the single authority for every configuration key — YAML path, env var name, default, and description. This spec deliberately does not restate key names or defaults: a duplicated default is a default that drifts. What follows is only the operator's startup checklist and the file-mounting mechanics.
Use the exact environment placeholders in Configuration §5. Most names use upper snake case, but provider, bootstrap, promotion and dashboard settings have explicit exceptions; deriving a name from the YAML path can leave a setting unchanged.
Before any of this, two variables: DATAPIPELINES_ENV is your organisation's name for this deployment and DATAPIPELINES_POSTURE is development or hardened. A named environment with no posture does not start. Environments is the page for that decision; Configuration §3.23 is the key reference.
5.1 What the app requires to start
The app fail-fasts on startup if any of the following is missing. Full definitions: Configuration §2.
-
Metadata Postgres:
spring.datasource.url,spring.datasource.username,spring.datasource.password. -
Redis:
datapipelines.redis.host. -
datapipelines.jwt.secret— internal JWT signing secret. -
datapipelines.db.encryption-key— AES-256 master key for datasource credentials. There is no fallback source: no KMS lookup, no auto-generated key file. Lose it and every stored datasource credential is unrecoverable, so it belongs in a secret manager and in the operator's backup plan. (KMS sourcing is a ROADMAP §2 item.) -
At least one authentication method: a fully-configured OIDC provider under
datapipelines.auth.oidc.providers(each withclient-id,client-secret, andissuer-uri), or local password accounts (datapipelines.auth.local.enabled=true, Auth §5A). The first admin comes fromdatapipelines.auth.bootstrap-admin-emaileither way (Auth §4.4). A provider entry whoseclient-idis empty is ignored with a WARN, not counted. The client-id/secret env var names are chosen by the deployment (GOOGLE_CLIENT_ID,OKTA_CLIENT_ID, …) — they are the one deliberate exception to the derivation rule above.The operator's first-admin story with local accounts (no IdP): set
datapipelines.auth.bootstrap-admin-emailto the admin address, enabledatapipelines.auth.local.enabled, and seed the one-time credential — preferably as a pre-computed hash from./gradlew :modules:auth:hashPasswordintodatapipelines.auth.local.bootstrap-password-hash(the plaintext form exists for demos). Hand the password to the admin out-of-band; the app forces a change at first login (Auth §5A.2), and a deployment still running the seeded credential announces itself with a startup WARN. Every account after the first is created by an admin on the user-administration screen — there is no self-registration and no self-service email reset: a forgotten password means an admin resets it there, which issues a new one-time credential under the same forced-change rule. With outbound mail configured (DATAPIPELINES_MAIL_HOST+DATAPIPELINES_MAIL_FROM— any SMTP server, Postmark's included; the nine variables are Configuration §3.27 anddeploy/secrets.env.examplenames them) that credential is emailed to the user and sys-ops (DATAPIPELINES_MAIL_OPS_TO) hears about every new user, local or first social login (Auth §5A.8); the admin screen then shows where the password went, not what it was. Mail is on exactly when those two are set — there is no flag — and it then also requiresDATAPIPELINES_AUTH_BASE_URL(the login URL in the mail).
Everything else has a default and is optional. One optional key adds a startup requirement of its own: datapipelines.scheduler.notifications.enabled=true (scheduler notification mail, off by default — Scheduler §2.3, #442b) refuses to start, naming the keys, unless the shared mail transport (datapipelines.mail.host and .from, Configuration §3.27) and datapipelines.auth.base-url (the link every notice carries) are set; under the development posture it also sends nothing until datapipelines.scheduler.notifications.recipient-domains lists a domain.
5.2 Everything else
Optional keys — executor concurrency, staging memory, result TTLs and caps, SSE heartbeat and disconnect grace, rate limits, idempotency TTL, template cache, UI theme, retention windows, observability — are cataloged with their defaults in Configuration §3. Resolution precedence (env > profile YAML > base YAML, plus the two per-entity runtime overrides) is Configuration §4.
The keys an operator most often changes at deploy time are datapipelines.result.max-size-bytes (Redis sizing, §4.2.2), datapipelines.executor.max-concurrent-executions-per-instance and datapipelines.staging.h2.max-memory-mb (heap sizing, §6.6), and datapipelines.executor.execution-timeout-seconds (the wall clock that bounds any single execution).
Log output format — turning JSON on (#337)
DATAPIPELINES_OBSERVABILITY_LOGGING_FORMAT decides what stdout carries (Configuration §3.15, Observability §3.1): json — one structured JSON object per line, the machine-parsed format a log collector wants — or console, the human-readable development format. The value is resolved before logging initialises; the set is closed, and a mistyped value refuses startup with the offending value named rather than silently falling back. Redaction (Observability §9.2) runs under both values — there is no format that bypasses it.
What each deploy path resolves today, with nothing set beyond what the repo ships:
| Path | Resolves | Because |
|---|---|---|
app.sh deploy, the VPS included |
console |
deploy/env/defaults.env sets it; unchanged until the operator moves it. Turn JSON on by setting DATAPIPELINES_OBSERVABILITY_LOGGING_FORMAT=json in deploy/secrets.env (or the environment the compose stack reads) and redeploying. |
Raw docker compose run |
json |
deploy/compose.yml passes ${DATAPIPELINES_OBSERVABILITY_LOGGING_FORMAT:-json} through. |
Bare jar (java -jar) |
json |
The shipped application.yml default. |
A container deployment that turns JSON on changes nothing else: the container runtime already collects stdout, and the JSON line carries correlation_id/execution_id at the root — the fields an operator quotes in an incident (Observability §3.3). Log levels stay Spring Boot's own logging.level.* (Observability §9.1).
5.3 Full config file
Every setting is an environment variable, and deploy/env/defaults.env lists all the non-secret ones with their shipped values (deploy/secrets.env.example names the rest) — Environments is the operator's page for what to set and where. Operators who prefer a YAML file can mount application.yml at /etc/datapipelines/application.yml (configurable via SPRING_CONFIG_ADDITIONAL_LOCATION); this is the supported route for more than one OIDC provider, whose nested list is awkward as variables. Every key is expressible either as YAML or as its derived env var; the OIDC provider list is a nested structure and is normally supplied as YAML with ${...} placeholders for the secrets. A complete annotated template is in Configuration §5.
6. Deployment Patterns
6.1 Single-instance (dev / small team)
One instance handles everything — UI, API, MCP, pipeline execution. Docker Compose or bare JVM.
Suitable for: dev, evaluation, small teams (< 20 users).
6.2 Multi-instance horizontal scaling (production)
The application is stateless for all CRUD operations, UI, MCP, and auth. Multiple instances run behind a load balancer and serve requests interchangeably.
┌─────────────┐
│ Load Balancer│
└──┬───┬───┬──┘
│ │ │
┌────────┘ │ └────────┐
↓ ↓ ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│Instance A│ │Instance B│ │Instance C│ ← stateless web/API/MCP/UI
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└────────────┴────────────┘
│ │
┌───────┘ └────────┐
↓ ↓
┌──────────┐ ┌──────────┐
│ Postgres │ │ Redis │
│ (shared) │ │ (shared) │
└──────────┘ └──────────┘
No sticky sessions required. Each request is independent.
Client addresses behind the load balancer (datapipelines.auth.trusted-proxies).
Every instance sees the LB's address as remoteAddr, so anything keyed or recorded on
it — the per-IP login rate limiter, every audit source_ip — collapses to one
deployment-wide value: without configuration, the login budget (default 10/min) is a
single shared bucket any client can exhaust for everyone. Set the key to the CIDRs of
proxies you control (a bare IP is a host CIDR):
datapipelines:
auth:
trusted-proxies: 10.0.0.0/8 # the LB's network
The resolution is spoof-safe: when the direct peer is NOT in the list, the
X-Forwarded-For header is ignored entirely — an untrusted peer cannot forge its way
past the limiter by setting it. When the peer IS trusted, the client is the rightmost
header entry that is not itself trusted. Each entry must parse as a CIDR or startup is
refused (Configuration §3.4/§7) — a typo'd range must not
silently widen proxy trust. The shipped default is EMPTY: a deployment with no proxy
in front behaves exactly as before, and the header stays ignored.
What the edge sets, and what it must not touch (188, #188)
The app states its own security posture on every response — HTML, JSON, SSE and the
/mcp refusal alike — and pins it with tests (SecurityHeadersTest, the wire-level case
in AuthHttpBoundaryTest, ApplicationSmokeTest). What it sends:
| Header | Value | Why |
|---|---|---|
Content-Security-Policy |
default-src 'self'; script-src 'self'; style-src 'self' 'sha256-pgvDUBa4IjFA2yuSJ2cqcyxmNYJMborsd0ORcRv9vw8='; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none' |
Enforced, no 'unsafe-inline', no nonce, and — since #195 — no 'unsafe-eval' on ANY route: every script and stylesheet is a file the app serves. img-src names no external host (#197): the OIDC profile picture is fetched server-side by GET /avatar from an operator-allowlisted host (datapipelines.auth.oidc.providers[*].picture-hosts, Configuration §3.4), and the page's <img> points at the proxy — the provider's picture URL never reaches the browser. The one exception 188 shipped (GET /pipelines/{id}/editor carried script-src 'self' 'unsafe-eval' for Alpine's standard build, owner ruling 2026-09-21) is retired (#195): the editor's page runs Alpine's CSP build (the same 3.14.1, same path — the vendor manifest records the npm tarball), its expressions are pure property paths, and the editor-only policy is deleted; SecurityHeadersTest pins the directive's absence on the editor route, and the browser suite's zero-violation collector is the live proof the page needs no eval. style-src carries one SHA-256 hash — the exact <style> element Cytoscape injects on the editor page at init ('sha256-pgvDUBa4IjFA2yuSJ2cqcyxmNYJMborsd0ORcRv9vw8=', printed in full here; it is DERIVED, not typed: SecurityHeadersTest computes it from the vendored sheet SecurityHeaders.CYTOSCAPE_STYLESHEET carries verbatim, so a Cytoscape bump that changes the sheet goes red rather than silently unblocked); a hash names that sheet and nothing else, so on routes that never load Cytoscape it permits a style element that never occurs, which is what keeps ONE policy for every policed route. /sitemap.xml carries no policy (an XML data document; the browser's own viewer is the only style it ever has). |
X-Frame-Options |
SAMEORIGIN |
The product's intent — embedded dashboards on its own origin are on the roadmap; nothing is meant for a foreign frame. frame-ancestors 'self' says the same in CSP terms. |
X-Content-Type-Options |
nosniff |
|
Referrer-Policy |
strict-origin-when-cross-origin |
|
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
The product uses none of these. |
X-XSS-Protection |
0 |
The legacy filter off, as every current guide recommends; the CSP is the control. |
Cache-Control |
no-cache, no-store, max-age=0, must-revalidate on authenticated responses; the public site and docs set their own public windows |
The app deliberately sends no Strict-Transport-Security. It cannot know it is
behind TLS — it sees plain HTTP from the edge — and an HSTS header on a plain-HTTP
deployment locks a browser out of it. The edge (the reverse proxy / load balancer that
terminates TLS: Caddy, nginx, an ALB) owns it. What the product EXPECTS of any edge in
front of it:
- HSTS, one year, subdomains included, no
preload:Strict-Transport-Security: max-age=31536000; includeSubDomains. Preload is a registry submission the operator makes deliberately for their own domain, not a header the product should imply on their behalf. - Pass the app's headers through untouched. An edge MUST NOT weaken
Content-Security-Policy(no rewriting to add'unsafe-inline', no stripping) and MUST NOT relaxX-Frame-OptionsbeyondSAMEORIGINor dropframe-ancestors. An edge that sets its ownReferrer-Policyshould set the same value, not a looser one. - TLS termination and redirect: HTTP → HTTPS redirect at the edge; the app is reached
over the private network only. The app never needs to know the public scheme for its own
redirects: every
Locationit sends is RELATIVE (/dashboard,/login?error=…—server.tomcat.use-relative-redirects, #207) and resolves against the origin the browser used, so noX-Forwarded-Protohandling is required for redirects, and an edge that forwards it changes nothing. (Before #207 the container built absolutehttp://Locations behind the edge, and the browser refused to follow them:form-action 'self'for plain forms, mixed content for htmx requests.) - Forward the client address (
X-Forwarded-For) and declare the edge indatapipelines.auth.trusted-proxies— the paragraph above.
The reference edge for datapipelines.co lives in its own repository by design; this section is the contract it implements, not its configuration.
What's stateless (any instance serves any request)
- REST API (all CRUD, list, detail, execute endpoints)
- MCP server
- UI (all screens — Thymeleaf server-rendered)
- Auth (JWT sessions, API key validation — both backed by Postgres)
- Template rendering (reads from Postgres, per-instance LRU cache)
- Pipeline validation (reads from Postgres)
What's instance-local (tied to the instance that started it)
- In-flight pipeline executions. When
POST /pipelines/{id}/executehits Instance A, the execution runs on Instance A's JVM (coroutines + per-execution H2 staging). The SSE stream is a direct connection from Instance A to the client. - Implication: if the SSE connection drops mid-execution, there is no reconnection or resumption path. Instance A starts a grace timer (
datapipelines.sse.disconnect-grace-seconds, default 30) and, if the execution has not reached a terminal event by the time it elapses, cancels it — the execution endsABORTED(REST API §6.8). A client that loses its stream should assume the abort and re-execute. This is deliberate: an execution nobody is waiting for must not keep holding source-database connections and staging memory. - Cross-instance cancel works anyway.
DELETE /api/v1/executions/{id}may land on Instance B, which has never heard of the execution. Instance B writes a Redis cancellation flag; Instance A honors it on its next heartbeat tick or node boundary — worst-case latency ≈ one heartbeat interval (REST API §10.4, DAG Executor §8.3.1). No sticky sessions are needed for cancellation to be reliable. - Completed executions are not instance-local at all. Results live in Redis for their TTL and are readable from any instance via the cursor (REST API §7); execution metadata is in Postgres; the 1-hour event log is in Redis. Only the running execution is pinned to one JVM.
- Instance crash: loses only that instance's in-flight executions (H2 staging is in-memory and non-recoverable). Their rows are swept to
ABORTEDby the stale-execution sweep (markedpipeline.execution.instance_lostonce they are older thandatapipelines.executions.stale-timeout-minutes; every replica runs the idempotent sweep, so no surviving-instance coordination is needed). Completed results and history are unaffected; the lost work must be re-executed.
Multi-instance checklist
| Requirement | How |
|---|---|
| Shared Postgres | All instances connect to the same external Postgres. |
| Shared Redis | All instances connect to the same Redis (results, idempotency keys, cancellation flags, event log, datasource pool invalidation) with maxmemory-policy noeviction — §4.2.1. A per-instance Redis breaks cross-instance cancel, result reads and pool invalidation. |
| Identical image | Same Docker image on every instance — design system CSS, JS, templates baked in. |
| DB migrations | Flyway uses Postgres advisory locks — concurrent startup is safe (one runs, others wait). |
| SSE heartbeat | Server sends : heartbeat comments every 15s to prevent LB idle-timeout kills. See REST API §6.6. |
| LB idle timeout | Configure to ≥ 120s (or rely on heartbeat). |
| Health checks | /health and /ready work independently per instance. |
| Size per-instance limits by replica count | The execution-slot ceiling datapipelines.executor.max-concurrent-executions-per-instance (default 100) is per instance (050/R2): N replicas admit N × the setting in total against the source databases, and the tempdb heap multiplier of Configuration §3.2 applies per box the same way. Raise replicas with that multiplication in mind, not just the per-instance number. |
6.2A Vendored browser assets — provenance and the rebuild recipe (#10 L3a)
Every third-party file under static/vendor/ is recorded in
static/vendor/design-system/vendor-manifest.json — per file: version, sha256, source, licence — and
pinned by the audit tests (VendoredAlpineAuditTest, VendoredHtmxAuditTest,
VendoredPlotlyAuditTest, …): a served byte that stops matching its recorded hash is a red build,
and so is a manifest entry nobody serves. The manifest also carries the FIRST-PARTY client runtime and
its adapters (js/datapipelines-dashboard*.js, the same sha256 discipline — a hand edit must move the
manifest in the same commit).
generateVendoredAssetNotices (:modules:web:generateVendoredAssetNotices) generates the offline
licence inventory and full texts served at /vendor/THIRD-PARTY-NOTICES.txt and copied beside the website export assets.
Plotly 4.1.1 ships as two custom bundles built with the package's own procedure (its
CUSTOM_BUNDLE.md), from the pinned tag, plus source patches (patches/0001-colorbar-style-reset-without-attribute.patch:
the colorbar's fill reset wrote .attr('style', ''), an inline-style attribute the enforced CSP refuses, and now removes
the attribute — #525). The second patch, patches/0002-heatmap-pixelated-hint-via-cssom.patch, sets the default heatmap
image’s image-rendering fallbacks through the CSSOM in their original order, ending in pixelated (#531).
The build is path-independent since patches/0003-inline-css-id-independent-of-clone-directory.patch
(#571): the inline-CSS plugin hashed the clone's absolute path (#530); the in-tree plugin hashes the
root-relative CSS path under the historical directory as a constant salt, keeping the committed bytes unchanged.
The salt is historical; the next Plotly upgrade may keep or drop it, since the pins move then anyway.
Patch patches/0004-drop-unreachable-maplibre-css-require.patch removes the maplibre-gl stylesheet
require from the registry: neither custom trace list includes a map trace, so the stylesheet is
unreachable; drop this patch when a map trace joins a trace list.
To rebuild any bundle — a trace added, a security fix adopted — run exactly this and commit the
OUTPUTS with their new hashes. The #531 rebuild used Node 24.21.0 + npm 11.19.0 + npm ci
(the earlier #525 build also matched Node 22.23.3 + npm ci and Node 24.21.0 + npm i).
cd "$(mktemp -d)"
git clone --depth 1 --branch v4.1.1 https://github.com/plotly/plotly.js.git plotly.js
cd plotly.js
# the numbered vendor patches, applied in order; a new patch is a new file here
git apply <repo>/modules/web/src/main/resources/static/vendor/plotly/patches/*.patch
npm ci --no-audit --no-fund
# the 2D bundle (the default): scatter, bar, pie, histogram, box, heatmap
npm run custom-bundle -- --traces scatter,bar,pie,histogram,box,heatmap --strict --out 2d
# the 3D bundle (dashboards with a 3D trace): the six above + scatter3d, surface, mesh3d
npm run custom-bundle -- --traces scatter,bar,pie,histogram,box,heatmap,scatter3d,surface,mesh3d --strict --out 3d
cp dist/plotly-2d.min.js dist/plotly-3d.min.js <repo>/modules/web/src/main/resources/static/vendor/plotly/
A patch is git format-patch -1 against the tagged source with preceding patches applied, so git apply takes the
numbered series on a pristine clone; never hand-edit the
minified text. Run scripts/verify-plotly-bundles.sh (exit 0) before copying a rebuild in; the lander runs it
on any diff under static/vendor/plotly/ or to the manifest's plotly entry. The verifier rebuilds in a fresh
temporary directory and compares both bundles: exit 0 means a match, 1 a byte mismatch, 2 a failed rebuild.
It is not part of the gate, pregate or CI. Remove the manual recipe's temporary clone after use.
plotly.css is the npm package's own dist/plotly.css — the release build's strict-CSP sheet, the
same rules the bundle would inject at runtime. It is part of the CSP DESIGN-AROUND (Dashboards
§6.6): each host pre-places the empty
<template id="plotly.js-style-global" class="no-inline-styles"> marker Plotly checks (the runtime's
fallback creates the same template for a host without one), the page loads this file instead, and style-src stays 'self' plus Cytoscape's one hash — no policy change, no
'unsafe-hashes', no 'unsafe-eval'. Sizes (4.1.1 with patches 0001–0004, #525, #531, #571 and #570): 2D 1,170,988 B (406,683 gz), 3D
1,748,030 B (576,983 gz), plotly.css 7,467 B; gzip uses zlib deflate level 9, gzip framing, mtime 0
(no filename), as reproduced by the JDK's GZIPOutputStream with Deflater.BEST_COMPRESSION; the npm tarball's sha1
f797e8f2d54603fd9d11b08aae8eb31147200b67 verifies the source against the tag. Node 22+ per
Plotly's own build matrix (the manifest's build field records the exact versions). After ANY rebuild: update the manifest's sha256 map, the sizes, and the
trace lists if they changed — VendoredPlotlyAuditTest guards all three raw byte counts, both gzip counts
and their pinned method, as well as hashes and trace lists, and fails the build on a mismatch.
6.3 Docker Compose (dev / evaluation)
Reference compose file provided in deploy/compose.yml. Single instance + Postgres + Redis.
Compose is one loader of the environment-variable contract, not the contract itself (Environments §3): the same settings drive a bare JAR, systemd, Kubernetes, ECS and Nomad. It reads TWO env files, in this order: deploy/env/defaults.env, which is tracked and carries every non-secret default, then deploy/secrets.env, which is git-ignored and holds every credential plus this deployment's own overrides (DATAPIPELINES_POSTURE=hardened, DATAPIPELINES_ENV=prod, the real database URL, the OIDC client).
docker compose -f deploy/compose.yml \
--env-file deploy/env/defaults.env \
--env-file deploy/secrets.env up -d
The app service passes every DATAPIPELINES_* variable the app binds, each with
the same default application.yml ships — so a key that works against a host-run app
(by exporting the variable) reaches the container too, and leaving it unset yields the
shipped default. scripts/compose-env-audit.sh diffs the compose block against
application.yml's placeholders — and the two tracked env files — and fails on a missing
pass-through, a diverged default, a variable declared in both tracked files or in neither,
or a posture-dependent key passed with a default instead of in the valueless form. It runs
on every ./gradlew build, not when someone remembers to run it.
It also inventories every app-container environment name, refuses JVM code-loading names,
and requires a reasoned allowlist for names outside DATAPIPELINES_*, rejecting stale entries.
6.3A Promotion: a receiver deployment (055)
A second deployment that receives released content from the first (Versioning §10). Both sides are the same image in different postures — there is no separate build.
On the RECEIVER (e.g. uat):
DATAPIPELINES_ENV=uat
DATAPIPELINES_POSTURE=hardened # authoring defaults OFF here
DATAPIPELINES_DEPLOYMENT_PROMOTION_SERVER_KEY=<the shared secret>
On the SENDER (e.g. dev):
DATAPIPELINES_ENV=dev
DATAPIPELINES_POSTURE=development # this is where content is built
DATAPIPELINES_DEPLOYMENT_PROMOTION_TARGET_URL=https://uat.example.com
DATAPIPELINES_DEPLOYMENT_PROMOTION_TARGET_KEY=<the same shared secret>
DATAPIPELINES_ENV is your name for each deployment and nothing branches on it; the receiver records the sender's label as the source_env of what it received. DATAPIPELINES_POSTURE is what actually changes behaviour — hardened defaults authoring-enabled to false, which is the receiver's whole configuration. Set DATAPIPELINES_DEPLOYMENT_AUTHORING_ENABLED explicitly if this one box must do both (Environments §1).
Generate the secret the way every other one here is generated — openssl rand -base64 32 — and set the identical value on both sides. It is a bearer credential: it belongs in deploy/secrets.env with the rest of the secrets, never inline in a compose file or a chart's values.
What each setting buys, and what goes wrong without it:
| Setting | Consequence if wrong |
|---|---|
posture: hardened (or an explicit authoring-enabled: false) on the receiver |
With it true, promotion into it is refused (pipeline.promotion.target_is_authoring) — deliberately, because drafts belong in the authoring environment. Startup also REFUSES on a receiver that already holds drafts, naming them (Configuration §7) |
| A promotion credential on the receiver | Nothing configured means promotion is refused, always. Fail closed. Since 091 the credential is a server-kind API key an admin mints on the receiver's API screen (Auth §7.7); the older server-key config value still works for one release and WARNs at boot |
target.base-url + target.server-key on the sender |
A base-url without a key refuses startup, naming both. A key that does not match the receiver's gets 401 auth.promotion.key_invalid at push time |
| Matching workspace NAMES on both | Promotion addresses workspaces by name (names are a global namespace; ids are not). A workspace that does not exist on the receiver is 404 workspace.not_found — create it there first |
| The datasources the promoted pipelines reference, registered on the receiver | Pre-validated before anything is pushed; a missing one fails the whole batch with pipeline.promotion.missing_datasources and leaves the receiver untouched. Register them with the receiver's own credentials — a pipeline body never carries environment-specific connection details |
The RECEIVER's request-body cap, datapipelines.web.max-request-bytes (default 2 MiB, at most 64 MiB — Configuration §3.31) |
A promotion is ONE push body (POST /api/v1/promotion/push); over the receiver's cap it is refused 413 request.body_too_large (details.limit_bytes) before anything applies — atomic, nothing lands — and the sender reports the receiver's refusal verbatim. Measured 2026-09-28 (#291): the product's whole shipped example content — 40 templates and pipelines across the nyc, trade and lake families — is 73 KiB, 3.6% of the default; its largest document is 5.6 KiB. A single template may hold up to 256K characters (datapipelines.templates.max-body-chars), so a batch of several near-maximum templates can pass 2 MiB: raise the RECEIVER's key (the sender's cap is not involved) rather than splitting the promotion. |
Minting the receiver's key (091; the page is /api-keys since 179): on the RECEIVER, sign in as a super admin → the avatar menu's API keys → New API key → kind Server → name it after the sender (dev → uat) → copy the plaintext, shown once. Paste it into the SENDER's DATAPIPELINES_DEPLOYMENT_PROMOTION_TARGET_KEY. Rotation: mint a second server key on the receiver, set it in the sender configuration, restart the sender to bind the new credential, then revoke the first key. The receiver does not need a restart for stored-key issuance or revocation. (With the deprecated config value instead: set the new value on both sides and restart both.) The stored credential authenticates as its key identity rather than a human account; offboarding a human does not revoke that promotion credential.
Direction is one-way by construction. The receiver validates; it never calls the sender. A compromised receiver cannot reach back into the authoring environment.
Running two stacks on one machine (a rehearsal, or CI): APP_COMPOSE_PROJECT gives ./app.sh a second isolated compose project with its own image tag, volumes and containers, and APP_HOST_PORT moves its published port off the first one's 8080 (the container port never moves). Both are lane knobs, not app config — that is why neither carries the DATAPIPELINES_ prefix.
./app.sh --start # dev, the sender, on :8080
APP_COMPOSE_PROJECT=uat APP_HOST_PORT=8081 ./app.sh --start # uat, the receiver, on :8081
The sender reaches the receiver from INSIDE its container, so target.base-url is not the localhost URL your browser uses: on Docker Desktop it is http://host.docker.internal:8081, and on Linux it is the host's address on the docker bridge. Two compose projects are two networks; a service name from one does not resolve in the other.
6.4 Kubernetes (recommended for production)
Reference Helm chart in deploy/helm/. Includes:
Deployment(N+ replicas, behind aService;terminationGracePeriodSeconds: 60— #251, #277, #266: thepreStopsleep (5 s) plus the scheduler's admission wait (up to 15 s) plus the drain's flush (up to 20 s) plus Tomcat's graceful shutdown (10 s) plus the persistence drain (up to 10 s) must complete before the pod is killed; the shippeddeploy/compose.ymlsets the matchingstop_grace_period: 60s— §8.3.2 has the arithmetic).- Externalized Postgres (managed recommended).
- Externalized Redis (managed recommended).
HorizontalPodAutoscaler(scales on CPU).PodDisruptionBudget(availability during node drains).- Management port exposure, all of it OFF by default (the render is unchanged
until you opt in — Configuration §3.14):
management.exposeadds the managementcontainerPort(frommanagement.port, default 9090) and pins bothMANAGEMENT_SERVER_*env entries — including the address opt-open0.0.0.0— and a headlessService(clusterIP: None: the scraper targets pod IPs, not a cluster VIP);management.networkPolicy.enabledadds the chart's one ingressNetworkPolicyfor the app pods — the scraper peers on the management port AND your declared peers on the app port (below);management.serviceMonitor.enabledadds aServiceMonitorfor the headlessService. AnyextraEnv.MANAGEMENT_SERVER_ADDRESS/_PORTdouble mapping is refused (Kubernetes honours the last duplicate env name silently).
The management NetworkPolicy is a complete ingress policy for the app pods (#712). A
NetworkPolicy that selects a pod isolates that pod for every ingress, not only for the ports
its rules name, and the allow rules of all policies selecting the pod are unioned
(Kubernetes: Network Policies).
A policy that admitted only the scraper would therefore cut the app port off — so the chart's
policy, rendered only with both management.expose and management.networkPolicy.enabled,
carries two rules, and the render refuses to enable it without either:
- Scraper → management port: one peer per non-empty scraper selector
(
scraperNamespaceSelector,scraperPodSelector). The two are separate peers, OR'd: a pod in a matching namespace OR a matching pod in the app's own namespace. (One peer carrying both anamespaceSelectorand apodSelectorwould be their AND — the Kubernetes rule.) Both empty is refused: an emptyfrom:admits every source. - Declared peers → app port (8080):
appIngress.from, a list ofNetworkPolicyPeerobjects (namespaceSelector/podSelector/ipBlock) copied verbatim — typically your ingress controller's namespace. Instead of a list,appIngress.allowAll: trueis the explicit acknowledgement that every source may reach the app port; it renders the rule without afromand prints the acknowledgement as a comment in the rendered policy. Neither set is refused; both set is refused as a contradiction. AnipBlockof0.0.0.0/0is allow-all by another name.
Every selector is a full LabelSelector object (matchLabels / matchExpressions), copied
verbatim. A bare label map is refused at render time: it is not a selector, the API schema
rejects it, and an apply path that drops unknown fields would leave an empty selector, which
selects every namespace.
management:
expose: true
networkPolicy:
enabled: true
scraperNamespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
appIngress:
from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
With policies you already own. Policies are additive. If your own policies already select
these pods, they keep every allowance they grant, and the chart's policy adds exactly its
declared peers to that union. It never narrows your policy, and it widens it by precisely
what you declare. To leave an existing stricter app-port policy unwidened, declare no peer it
does not already admit. allowAll: true widens the app port to every source. Egress is not
touched (policyTypes: [Ingress]): §9's egress policy stays yours. With
management.networkPolicy.enabled off (the default) the chart renders no policy at all.
The pod keeps its root filesystem read-only and mounts a disk-backed emptyDir at /tmp.
This supports JVM native libraries and the default LAKE spill directories. Set
temporaryStorage.sizeLimit to bound the volume (empty by default), and set ephemeral-storage
requests/limits through resources for the workload. Do not use a memory-backed volume for
large LAKE spills: that would consume the memory budget spilling is intended to relieve.
Each pod has its own volume; Kubernetes removes it with the pod. Size it for concurrent
queries and see Configuration §3.24
for explicit paths and cleanup after a process crash. Docker Compose uses the image's writable
/tmp; custom read-only deployments must mount a writable temporary directory too.
JavaScript (#8 lane 8b) uses /opt/graaljs/js-isolate.jar in the image, selected for the image
platform and checksum-verified at build. DATAPIPELINES_SCRIPT_JS_ISOLATE_DIR=/opt/graaljs
must name one directory in the launcher's effective loader.path system property or, when
unset, LOADER_PATH environment list (the image: /app/lib,/opt/graaljs). Startup refuses a
configured directory outside that list or without js-isolate.jar; the check never adds a
class-path entry. A bare jar without the isolate starts with JavaScript disabled, said once
at INFO; a JavaScript save/evaluate refuses script.js.unavailable, while JSONata works.
The nine JavaScript runtime keys (configuration.md §3.28) are named chart values under
script.js: isolateDir, maxHeapMb, isolateMemoryMb, maxStatements, maxAstDepth,
maxStackFrames, maxOutputBytes, maxResultMb, maxInputMb (env
DATAPIPELINES_SCRIPT_JS_*, the mechanical camelCase map). All nine default to empty, and an
empty or null value renders NO env entry — the image's own isolate directory and the app's derived
isolate memory stay in force, exactly like the DuckDB pair below; a rendered "" would override
the image's ENV and fail the startup agreement above. script.js.isolateDir is not an
arbitrary path: the chart refuses, at render time, any value outside the image's launcher-path
entries (/app/lib, /opt/graaljs) — the same entries the startup rule admits — and an
extraEnv double mapping of any of the nine fails helm template like the DuckDB keys do. The
remaining eight keys are operator sizing: the chart renders them verbatim and does not validate
numbers — explicitly supplied zero values render as quoted "0", and an out-of-bound value fails the pod's startup, by name (configuration.md §3.28, §7).
The image pre-installs native resources in the root-owned, read-only
/opt/graaljs/resources cache and passes
-Dpolyglot.engine.userResourceCache=/opt/graaljs/resources. This is why the read-only root
remains supported: no language resources are installed at runtime. A Docker tmpfs at /tmp
must permit native-library mapping (for example --tmpfs /tmp:rw,exec); noexec fails in JNA
before JavaScript starts. Non-Docker deployments
must pre-install resources for their platform using JavaScriptResourcesMain and set the
same cache property before starting with a read-only root.
One Engine per JVM shares its isolate heap across fresh per-evaluation Contexts (8a's measured
D1 arm; author Sources are not cached). The default isolate ceiling is
pool-size × js.max-heap-mb + 256 MiB (1280 at 4 × 256), outside -Xmx; an explicit ceiling
must exceed all pool slots' heaps. A tenant's hard isolate-heap trip can cancel another
tenant's evaluation once; subsequent evaluations rebuild the Engine. Include both heaps in
container sizing; each Context's heap limit is soft.
The two DuckDB operator keys (configuration.md §3.25) are named chart values:
duckdb.extensionDirectory (env DATAPIPELINES_DUCKDB_EXTENSION_DIRECTORY) and
duckdb.memoryLimit (env DATAPIPELINES_DUCKDB_MEMORY_LIMIT). Both default to empty, and an
empty value renders NO env entry — the image's own bundled extension directory stays in force, so
LAKE datasources keep their zero-egress LOAD behavior; a rendered "" would override the
image's ENV and push every LAKE pool back to INSTALL+LOAD, which needs egress and a
writable home the read-only root filesystem does not provide. Every other non-secret env key goes
through extraEnv; an extraEnv key that double-maps either DuckDB key fails helm template,
because Kubernetes does not reject duplicate env names and would silently honor the last one.
CI renders the chart on every push and pull request: the gate job runs scripts/helm-check.sh,
a pinned helm (version and checksum pin bumped together per DEVELOPMENT.md §10.2) running
helm lint --strict plus eighty-nine verdicts — defaults render no env: block or driver volume, both
DuckDB values render exactly their two quoted entries, all nine script.js values render exactly their
nine quoted entries (each sizing key alone also renders its explicit zero; null renders no entry), each extraEnv double mapping
(the two DuckDB keys, the nine JavaScript keys and the two MANAGEMENT_SERVER_* keys) is refused
with the template's own message, a missing existingSecret, extraEnv.LOADER_PATH, the three JVM option variables and a
script.js.isolateDir outside the image's launcher-path entries are refused,
an enabled driver volume renders its read-only mount (with additional PVC/source checks),
and the management block renders nothing by default (no 9090, no MANAGEMENT_* env entry, no
NetworkPolicy, no ServiceMonitor) while management.expose renders the four objects — the
management containerPort, both MANAGEMENT_SERVER_* entries (0.0.0.0 opt-open), the headless
Service and the NetworkPolicy, whose parsed rules must admit the scraper peers on the
management port AND the declared peers (or, under allowAll, every source) on the app port —
with management.serviceMonitor.enabled adding exactly one ServiceMonitor, and each of the
policy's refusals (both scraper selectors empty, no app-port declaration, allowAll with a
from, a bare label map, an unknown or empty peer, a quoted allowAll) exercised with its own
message. Every successful render is also validated -strict against the Kubernetes 1.35.0 API
schema (and the CRDs-catalog schema for the ServiceMonitor) by a pinned kubeconform
(DEVELOPMENT.md §10.2), with every object accounted for. It exits 0 when every case passes,
1 when a chart check fails, and 2 on a tooling failure (a download, a checksum-pin mismatch, a
schema fetch, or a missing python3/PyYAML — never a chart verdict).
The lib/ driver drop-in (§3.5) is opt-in: set libVolume.enabled: true and supply exactly one Kubernetes volume source in libVolume.source (for example, {configMap: {name: drivers}}); the chart mounts it at /app/lib with readOnly: true, and a persistentVolumeClaim source must itself set readOnly: true. Populate the source only through a trusted image, init container or CI-written ConfigMap, never through the app (§9). The default is disabled; an extraEnv key naming the launcher path in any spelling the launcher reads (LOADER_PATH, loader_path, loader.path) is refused at render time, and so are JAVA_TOOL_OPTIONS, JDK_JAVA_OPTIONS and _JAVA_OPTIONS (the JVM reads -D options from them, and a system property would win over the image's value), so the image's launcher path stays in force against extraEnv. Keys inside the existingSecret reach the container through envFrom and cannot be checked at render time: the Secret holds the §5.1 startup contract and never the launcher path or a JVM option variable.
No sticky session affinity needed. Standard ClusterIP service with round-robin or random load balancing.
6.5 Bare JVM
For development:
GLIBC_TUNABLES="${GLIBC_TUNABLES:+${GLIBC_TUNABLES}:}glibc.pthread.stack_cache_size=0" \
java -Duser.timezone=UTC -jar datapipelines-app.jar \
--spring.datasource.url=jdbc:postgresql://localhost:5434/datapipelines \
--datapipelines.redis.host=localhost \
--datapipelines.redis.port=6381
-Duser.timezone=UTC is required here too (§3.1): every deployment — container, bare JVM or JAR — must pass the flag explicitly.
On glibc, also set the native thread-stack policy from §3.1 before starting Java; setting an environment value
inside an already-running JVM is too late. Keep other required tunables and replace any conflicting stack-cache
entry with glibc.pthread.stack_cache_size=0.
Not recommended for production.
6.6 Resource sizing
Two numbers matter: JVM heap, and the container memory limit that must contain it.
Heap. The dominant consumer is H2 staging — each executing pipeline holds up to datapipelines.staging.h2.max-memory-mb (default 1024 MB), or its own settings.tempdb.config.max_memory_mb override:
heap ≥ (staging max-memory-mb × concurrent executions on THIS instance) + ~512 MB baseline
The multiplier is the per-instance execution ceiling max-concurrent-executions-per-instance (050/R2): every execution runs on exactly one instance, and this instance can legitimately hold the full setting's worth at an unbalanced moment. The default 100 × 1024 MB is the worst case ONE box must absorb — size the container for it (with N replicas behind a round-robin LB the typical share is setting / N, but size for the ceiling, not the share; cap the exposure by lowering the setting — or max-memory-mb — per box, or scale on smaller per-instance limits). The ~512 MB baseline covers Spring context, Hikari pools, the template cache, and SSE buffers.
Worked example — 4 concurrent executions per instance at the 1024 MB default: 4 × 1024 + 512 ≈ 4.6 GB heap.
Container memory limit.
container limit ≈ heap × 1.5
The 0.5 covers what the heap number does not: metaspace, code cache, thread stacks, and — significant here — JDBC direct/native buffers, which scale with result-set width and fetch size. A limit set equal to -Xmx gets the container OOM-killed by the kernel rather than getting a clean OutOfMemoryError, which is strictly worse to diagnose. For the example above: ~7 GB limit.
JVM flags. Either -XX:MaxRAMPercentage=65 (heap tracks the container limit — preferred for k8s, where limits change without an image rebuild) or an explicit -Xmx. Do not set both. Always -Duser.timezone=UTC (§3.1).
Servlet threads (MCP blocking calls). An MCP pipelines_execute call (MCP Server §6.2.3) is a single blocking HTTP request that holds one servlet thread until the execution reaches a terminal state or datapipelines.executor.execution-timeout-seconds (default 600) elapses. Per-user concurrency is bounded (max-concurrent-executions-per-user, default 10) but the default Tomcat pool is 200 threads, so on the order of ~20 concurrent long-running MCP callers can saturate it and starve REST/UI traffic on the same instance for minutes. Size server.tomcat.threads.max at or above the expected count of simultaneously-blocking MCP executions plus normal REST concurrency, or isolate /mcp on its own connector/instance. This is in addition to raising proxy/LB idle timeouts above execution-timeout-seconds (MCP Server §6.2.3).
CPU. Execution is coroutine-based and largely I/O-bound on source databases; 2 vCPU per instance is a reasonable floor. Scale out on max-concurrent-executions-per-instance pressure, not CPU.
6.7 Marketing site & in-product docs
Since v1.4 the app serves the marketing site and the documentation itself — the site and the product are ONE deployment (owner decision 2026-08-31). There is no separate static deploy to keep available, and the docs shipped in the jar always match the version running them.
GET /— the marketing site (public). Templatetemplates/site/index.html, assets understatic/site/**, referencing the app's vendored design system at/vendor/design-system/**(the retiredwebsite/directory carried a second vendored copy — the app copy is now the single sync target ofscripts/sync-design-system.sh). The only dynamic fact (the MCP tool count) is a compile-time constant baked at render time; public routes touch no database.GET /dashboard— the signed-in dashboard, moved off/. There is no auto-redirect: signed-in users hitting/get the marketing page.GET /docs,GET /docs/{slug}— the packaged spec set (docs/*.mdminus the contributor/research exclusions, packaged byprocessResourcesinmodules/web/build.gradle.kts). Public since 073: the viewer renders Markdown out of the jar and reaches no principal, no workspace and no datastore, and the identical content is already public in the AGPL repository — so exposing it moves ~25 pages of documentation into this domain's search index instead of GitHub's. Anonymous readers get the public site chrome; signed-in readers keep the application chrome.GET /mcp-server-for-sql-databases,/mcp-server/{engine},/add-mcp-server-to-claude-code,/ai-data-pipeline,/text-to-sql-agent,/compare/{airflow,dbt},/federated-query— the intent-cluster pages (073), same shape as/: GET-only, anonymous, constant content, no database. See UI Screens §4.15.GET /robots.txt,GET /sitemap.xml— crawler surfaces (073).robots.txtis a static file;sitemap.xmlis generated from the page registry and the packaged doc slugs, with nolastmod(145 §7 — a build timestamp on every URL read as "every page changed" on every deploy).GET /llms.txt,GET /llms-full.txt,GET /docs/{slug}.md— the agent surface (173): the llmstxt.org index and full text generated from the same two registries as the sitemap, and every packaged doc as raw Markdown with its links rewritten. Same shape as the sitemap — GET-only, anonymous, no database — and in the allowlist with their reasons (Auth §8.3). See UI Screens §4.15a.- Public-surface defence is cache headers, not a rate limiter.
/isCache-Control: public, max-age=300;/site/**is public with a 1-hour TTL plusLast-Modifiedrevalidation. The login rate limiter is deliberately NOT applied here (033/D1): the content is constant between deploys, so a shared-cache TTL costs nothing per request — and the T46 remoteAddr-keying concern is closed regardless: the limiter now resolves the CLIENT address throughdatapipelines.auth.trusted-proxies(§6.2), so pointing it at/would no longer create an LB-address-wide bucket. - Allowlist.
/and/site/**join thepermitAlllist inSecurityConfigwith their reasons inline; 073 added the seven cluster-page matchers (enumerated, not globbed),/docs+/docs/*,/robots.txtand/sitemap.xml, each with its own comment. Nothing else was widened, andanyRequest().authenticated()still closes the list.
Search Console and the sitemap (operator note, 073)
Indexing is not automatic and is not a deploy step — it is a one-time verification plus an occasional check.
- Verify the domain in Google Search Console (and Bing Webmaster Tools, if you care about it). Use the DNS TXT method: it verifies the whole domain including subdomains, and it survives redeploys — an HTML-file method would need the file added to
static/, which is a code change for a DNS problem. Do this before any launch announcement, so the first crawl after the first inbound links is already attributed. - Submit
https://<your-host>/sitemap.xmlonce. It regenerates on every request from the page registry and the packaged docs, so a page added in a later release is in it the moment that release is deployed — there is nothing to resubmit./llms.txtneeds no submission anywhere: an agent or an AI crawler fetches it by convention at the root, and it is generated from the same registries, so it is current the moment the sitemap is. robots.txtallows everything, and deliberately lists no private paths: it is a public file, so naming internal routes in it advertises them. What keeps the private surface private isSecurityConfig'sanyRequest().authenticated(), not this file.- A self-hosted deployment emits
datapipelines.cocanonicals. That is deliberate —rel="canonical"names the one address that should be indexed, and a private instance asking Google to index it is the failure mode the tag exists to prevent. If you WANT your own deployment indexed under your own domain, changeSITE_ORIGINinmodules/web/.../ui/site/SitePages.ktand rebuild. - What to look at afterwards. Coverage (are the doc pages indexed?), Performance filtered to
/mcp-server/(are the engine pages taking their measured queries?), and any Core Web Vitals note. Nothing here needs a schedule; a look a month after launch and a look a quarter later is the whole practice.
S3/CloudFront cold fallback (kept, drop if it rots unused). If the app is down but marketing must stay up, the same template renders to static files with facts baked:
./gradlew :modules:web:websiteExport # → modules/web/build/website-export/
aws s3 sync modules/web/build/website-export/ s3://YOUR-BUCKET/ --delete
aws cloudfront create-invalidation --distribution-id YOUR_DIST_ID --paths "/*"
The export renders /-rooted links (/site/..., /vendor/design-system/...), which the bucket layout mirrors exactly — upload the CONTENTS of website-export/, not the folder. S3 static website hosting (or CloudFront with an origin access control) serves index.html as the index document. Nothing fingerprinted: keep TTLs short for index.html and longer for vendor/** (those files change only when the design system is re-vendored). This is an emergency procedure, not a second primary deploy — the app is the primary. The exported pages need nothing the app's Content-Security-Policy (§6.2) forbids — no inline script, handler or style; their only <script> blocks are JSON-LD data — so the bucket's edge (CloudFront response-headers policy) can and should set the same CSP verbatim; static hosting sends no headers of its own (188).
7. Persistence & Backup
7.1 Metadata DB
- Backup: operator's standard Postgres backup practice (
pg_dump, managed snapshots, etc.). - Restore tested regularly by operator (runbook provided).
- Critical data: pipelines, templates, datasources (with credentials), audit log.
- Disposable data: executions table (can be truncated; only used for history).
7.2 Redis
- Volatile by design. Every key is TTL-bounded: results, idempotency records, cancellation flags, the 1-hour event log (§4.2).
- No backup needed. A Redis restart loses unexpired results (clients re-execute), unexpired idempotency records (a retry may execute a second time), and pending cancellation flags. All are recoverable by re-running; none is a durable record. The durable records — pipelines, templates, datasources, execution history and
execution_events— are all in Postgres. - Persistence (RDB/AOF) is not required and not recommended; it buys nothing that a re-run does not.
maxmemory-policy noevictionis required regardless (§4.2.1). "Volatile" means TTL-expiring, not evictable — eviction breaks correctness in a way that restart does not, because it happens silently during normal operation.
7.3 H2 staging
- In-memory only. Per-request. No persistence. No backup. No recovery.
7.4 Restore drill
Operators should run a quarterly restore drill: restore metadata DB from backup to a sandbox instance, verify pipelines still execute. Documented in the runbook (future).
8. Upgrades
8.1 Versioning
- Semantic versioning:
MAJOR.MINOR.PATCH. - PATCH: bug fixes only. Safe to upgrade in place.
- MINOR: new features, backward-compatible. Schema migrations applied automatically on app startup.
- MAJOR: breaking changes. Migration guide published.
8.2 Database migrations
- Flyway for metadata DB migrations.
- Migrations are versioned SQL files in
app/src/main/resources/db/migration/. - Applied automatically on app startup.
- Forward-only; no rollback (restore from backup if needed).
8.3 Upgrade procedure (recommended)
-
Review release notes for breaking changes.
-
Backup metadata DB.
-
Pre-deploy check for builds after 2026-09-19 (R-EP5, #274): a database holding a
published_endpointsrow saved under the old two-segment grammar used to refuse the boot. Since V41 the migration retires such rows (disabled,retired_reason = 'pre-R-EP5 path', never deleted) and the repository skips them regardless, so the upgrade is safe either way; run this to know beforehand what you will see flagged in the API console afterwards:SELECT path_pattern FROM published_endpoints WHERE array_length(string_to_array(trim(both '/' from path_pattern), '/'), 1) < 3;Zero rows = nothing for V41 to retire (the two-segment shape of the incident; a row malformed another way is mapped legacy by the new code, never served, and needs no SQL — on a build OLDER than #274 it broke the boot just the same). Rows listed = they come back disabled and flagged after the upgrade; unpublishing one is the fix. An operator in a hurry on a build OLDER than V41 can delete the rows by hand — the demo seeder republishes the demo endpoint at its current path on the next boot.
-
Pre-deploy check for builds after 2026-09-26 (#194, #268): two behaviours that stored pipeline bodies can meet for the first time. (a) A
DECIMAL/BIGDECIMALparameter's declaredscalenow binds its values, so a storeddefaultwith more places than the scale is refused when it applies (pipeline.execution.parameter_constraint_violation). (b) A body saved before #194 could carry aconstraintsblock or acardinalitythatParameterthen ignored; those are read now, and one save refuses today is answered409 pipeline.execution.parameter_declaration_invalidwhere it is used (a supplied value or an applied default), never a 500. Run both against the metadata DB to know beforehand (read-only; since #298 the first also LISTS a storedscalethat is not an integer —"2.0","x"— rather than stopping on the cast):-- pre-deploy #194: stored DECIMAL/BIGDECIMAL defaults with more places than their scale SELECT p.name AS pipeline, v.version, v.status, e.key AS parameter, e.value->>'default' AS default_value, e.value->>'scale' AS scale FROM pipeline_versions v JOIN pipelines p ON p.id = v.pipeline_id CROSS JOIN LATERAL jsonb_each(CASE WHEN jsonb_typeof(v.body_json->'parameters') = 'object' THEN v.body_json->'parameters' ELSE '{}'::jsonb END) e WHERE e.value->>'type' IN ('DECIMAL', 'BIGDECIMAL') AND e.value ? 'default' AND e.value ? 'scale' AND CASE WHEN (e.value->>'scale') ~ '^-?[0-9]{1,9}$' THEN length(split_part(trim(trailing '0' from (e.value->>'default')), '.', 2)) > (e.value->>'scale')::int ELSE TRUE -- a scale that is not an integer is itself a defect: listed, never a cast error END ORDER BY 1, 2, 4;-- pre-deploy #268: stored parameters declaring constraints or a cardinality SELECT p.name AS pipeline, v.version, v.status, e.key AS parameter, e.value->'constraints' AS constraints, e.value->>'cardinality' AS cardinality, v.created_at FROM pipeline_versions v JOIN pipelines p ON p.id = v.pipeline_id CROSS JOIN LATERAL jsonb_each(CASE WHEN jsonb_typeof(v.body_json->'parameters') = 'object' THEN v.body_json->'parameters' ELSE '{}'::jsonb END) e WHERE e.value ? 'constraints' OR e.value ? 'cardinality' ORDER BY 1, 2, 4;Zero rows from each = nothing stored meets the new behaviour. Rows from the first = defaults to correct (a new draft with the default at the declared scale). Rows from the second whose version was written BEFORE the #194 build was deployed were never checked at save: open the pipeline and re-save it — the save names what is wrong (a
cardinalityother thanSINGLEis refused outright; a pipeline parameter isSINGLE). Rows written after it passed the save check and need nothing. Both scans read every stored version, drafts and discarded ones included; neither covers a schedule's stored inputs.PreDeployScanQueriesTestruns the two blocks above, verbatim, against the shipped schema. -
Signal shutdown and let the instance drain (§8.3.1 — this is automatic, not a manual step).
-
Start the new version (migrations apply on startup).
-
Verify
/healthreturns UP. -
Restore traffic.
For k8s: rolling update via kubectl rollout. Each terminated pod flips its readiness and cancels its in-flight executions on the way out (§8.3.1); the preStop and terminationGracePeriodSeconds settings in §8.3.2 keep that orderly.
Scheduler notification kinds (V51).
- Keep
datapipelines.scheduler.notifications.enabled=false(the default) throughout the first rollout containing V51; with mail off this is the ordinary rolling update. - Enable notifications only after every instance runs the new image. A new sender writes
notification_*trail kinds which an older image cannot read: its strictTrailKind.fromWirethrows whenScheduleRunRepositoryreads that run's detail. Widening the database CHECK is not reader compatibility. Do not enable during the mixed-version fleet.
8.3.1 Graceful shutdown mechanism
Shutdown is a defined sequence, not "stop the process and hope". On SIGTERM the instance, in this order:
- Fails readiness first.
ReadinessState.REFUSING_TRAFFICis published before anything is cancelled, so/readystarts returning 503 while the process is still fully up and the load balancer / k8s Service bleeds traffic off. The order is the contract: flipping after the drain would keep fresh work flowing into an instance that is already cancelling it./healthkeeps reporting UP, so nothing kills the pod for being unhealthy mid-drain. - Cancels every in-flight execution through the ordinary cancellation path (DAG Executor §8.3):
Statement.cancel()on every registered statement first — which is what actually stops the query on the source database, so the drain is more than a status update — then the rootJobcancellation,execution_aborted(reason: "shutdown") emitted to connected streams, tempdb dropped and connections released infinally, and the row writtenABORTED. The drain cancels; it does not wait for executions to finish — an execution in flight at SIGTERM endsABORTED, notSUCCESS. - Waits for the flush, bounded (20 seconds). An execution leaves the live count only after its
ABORTEDstatus and events are written, so the wait means the bookkeeping reached Postgres and Redis before exit; the bound means a wedged execution meets the kubelet's deadline rather than hanging shutdown forever. - Exits. The web server's own graceful shutdown (
server.shutdown: graceful) runs after the drain, so SSE clients are still connected while their terminal events arrive, and in-flight ordinary requests complete rather than being cut off. Then the batching writers drain (#266, bounded byshutdown-drain-ms), and last the scheduled jobs' scheduler stops (#316): no tick starts once shutdown began, a tick already running is awaited while the metadata pool is still open, thenshutdown.scheduled_jobs_stoppedis logged.
The accepted loss, stated plainly: an execution running when an instance stops is cancelled, not preserved. It ends ABORTED with reason: "shutdown", is visible as such in execution history, and its client must re-execute. There is no execution hand-off to another instance and no resumption — in-memory H2 staging makes migration impossible, and pretending otherwise would be worse than the honest abort. Bounded, visible loss beats a silent hang.
One residual race, honestly: a request that reaches the instance between the readiness flip and the web server stopping can still launch an execution. The drain re-cancels on every flush tick, so such an execution is cancelled within ~100ms of starting — but the right answer is that traffic should have stopped at the readiness flip, which is what the preStop in §8.3.2 exists to give the endpoints controller time to do.
8.3.2 Kubernetes pod lifecycle
terminationGracePeriodSeconds: 60 # preStop 5 + admission gate ≤ 15 + drain flush 20 + Tomcat 10 + persistence drain 10
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
terminationGracePeriodSeconds: 60— the sum of the sequence at its configured maximum (#277, #266): thepreStopsleep (5 s) + the scheduler's admission gate, which waits up todatapipelines.scheduler.shutdown-wait-seconds(default 5, at most 15) for launches in progress + the drain's bounded flush (20 s, §8.3.1 step 3 — the drain cancels rather than waits, so the flush is its only clock) + 10 s for Tomcat's graceful phase (Spring bounds that phase itself atspring.lifecycle.timeout-per-shutdown-phase, default 30 s; a request still running after the 10 s is cut by the kill, which costs that request and none of the bookkeeping the drain already wrote).deploy/compose.ymlhas nopreStopand uses the same 50 s (15 s left for Tomcat).ShutdownGraceArithmeticTestderives the sum from the code's own bounds and fails a smaller value. Anything shorter risks the kubeletSIGKILLing mid-flush: executions die without theirfinallyblocks, so noexecution_abortedevent and no status update — rows leftRUNNINGuntil the stale-execution sweep marks themABORTED(§6.2). The persistence drain (#266,datapipelines.persistence.shutdown-drain-ms, 10 s by default) follows Tomcat's phase and is in the sum since the 316 review; the scheduled jobs' scheduler (#316) stops after every drain and is NOT — a tick in flight is awaited up tospring.lifecycle.timeout-per-shutdown-phase(30 s) plus 2 s, and a kill during that wait costs the hung tick (redone next tick) and bean destruction, nothing the drains own.preStop: sleep 5closes the standard k8s race: endpoint removal andSIGTERMare concurrent, so without it the pod can flip readiness microseconds before the Service stops routing to it. Five seconds of overlap is enough for Endpoints propagation — and it is what makes step 1's readiness flip actually take traffic off the pod before step 2 cancels.- A
PodDisruptionBudget(§6.4) keeps node drains from taking every replica's drain at once.
8.4 Rollback
Scheduler notifications (V51): older-image boundary. Before downgrading to an image older than V51, disable notifications on every instance and let the current sender settle the backlog: SELECT count(*) FROM schedule_notification_deliveries WHERE state = 'pending' must be zero. Disabling stops new enqueue; the current sender settles existing rows as failed/disabled, so this drain is not a promise to send them. Already recorded kinds remain: SELECT DISTINCT run_id FROM schedule_run_events WHERE kind LIKE 'notification\_%' identifies runs whose detail is unreadable by the older image until it is upgraded again. The reader stays strict (Enums additive-only wire convention); this feature promises no rolling reader compatibility, and an additive CHECK alone does not make an old artifact safe. Do not delete historical trail rows to hide the boundary.
- App rollback: redeploy previous image. Schema migrations are forward-only — if a migration was applied, you can't run the old app version against the new schema.
- For MINOR upgrades: schema changes are additive; rollback usually works.
- For MAJOR upgrades: take a DB backup before upgrade; restore it to roll back.
9. Security Hardening Checklist (Deployment)
- [ ] TLS termination at load balancer / proxy (let it handle cert renewal), with HSTS set THERE (one year, no preload) — the app sends none, by design (§6.2 "What the edge sets").
- [ ] The edge passes the app's security headers through untouched — the app's
Content-Security-Policy(enforced; no'unsafe-inline'),X-Frame-Options: SAMEORIGIN,Referrer-Policy,Permissions-Policy— and never weakens one (§6.2). - [ ] Metadata DB password rotated and not in source control.
- [ ]
DATAPIPELINES_JWT_SECRETis high-entropy (≥ 32 bytes random). - [ ]
DATAPIPELINES_DB_ENCRYPTION_KEYis high-entropy (32 bytes random) and stored in a secret manager, not a plaintext env file. - [ ] Redis password set if Redis is networked (
requirepasson the server,datapipelines.redis.passwordon every app instance — they must match). Under thehardenedposture an empty password with a non-loopback Redis host is REFUSED at boot (Configuration §3.23, #189);developmentwarns. - [ ] No credential on a command line inside a container: the reference compose files give
redis-cliits password throughREDISCLI_AUTHin the redis service's environment, the Redis server itsrequirepassfrom that same variable through stdin (redis-server -, §4.2.1 — #196 took the last argv credential off the reference stack), andmysqladminthroughMYSQL_PWD— an argv password is readable inpsby every process in the container. Check it:docker inspect <redis> --format '{{.Config.Cmd}}'anddocker exec <redis> ps -o argsname the variable, never its value. - [ ] Redis
maxmemory-policy noeviction(§4.2.1) — correctness, not tuning. - [ ] OIDC client secrets from a secret manager, not a plaintext env file; at least one provider configured (§5.1).
- [ ] NetworkPolicy restricts app's egress.
- [ ] Service account / IAM role has least privilege.
- [ ] Container runs as non-root user (enforced in Dockerfile).
- [ ] Container filesystem read-only except for configured volume mounts.
- [ ] Resource limits set (CPU + memory) per deployment.
- [ ] Audit log retained per compliance policy.
- [ ] No
/actuator/*path reachable on the application port;/actuator/prometheuson the management port (management.server.port), cluster-internal only (Observability §4.2). The management port is never published to a host or load balancer;MANAGEMENT_SERVER_ADDRESSdefaults to loopback — setting it to0.0.0.0(required for k8s scraping) demands an accompanying NetworkPolicy that admits only the scraper to the management port. Because that policy selects the app pods, it isolates their app port too, so it must also carry the app-port rule you declare. The reference chart implements this check as themanagementblock:management.exposeis the opt-open switch (it pins0.0.0.0and renders the port and headlessService), andmanagement.networkPolicy.enabledrenders the policy. That policy carries the scraper rule on the management port plus the app-port rule fromappIngress.from, or every source under the explicitappIngress.allowAll: true. The render refuses both scraper selectors empty, no app-port declaration, and a bare label map in place of aLabelSelector. Everything stays OFF by default (§6.4). - [ ] Internet-exposed deployments may prefer to omit
-Pdatapipelines.commitat build time — a public/infocommit hash maps the instance to exact source revisions. - [ ] The
lib/driver drop-in mount is read-only in the container, populated at image build or by a trusted init container, never writable by the app user;LOADER_PATH, if set, comes from the image — never inherited from the deployment environment (a writablelib/is code-execution-by-file-drop). - [ ] Production Redis requires
requirepass(or ACLs) and TLS, or is confined to a private network segment with a NetworkPolicy — it holds fully materialized caller results for up to an hour (D9). The app logs a structured WARN at startup when the Redis password is empty and the host is not loopback underdevelopment, and refuses to start underhardened(Configuration §7).
10. Distribution License
- Code: AGPL-3.0 (see LICENSE; contributions under the CLA).
- Third-party runtime notices:
:modules:app:generateThirdPartyNoticesreads the resolvedproductionRuntimeClasspathPOMs (walking parents for inherited licences), plus Spring Boot's packaged jar-mode tools and both Linux GraalJS isolate POMs. The isolate versions come from the Dockerfile'sGRAALJS_VERSIONARG. The generated file lists artifact coordinates, POM licence names, SPDX identifiers and vendored licence texts; unknown, missing or stale licence mappings fail the build. - Distribution locations: the boot jar contains
META-INF/THIRD-PARTY-NOTICES; the image also contains/app/THIRD-PARTY-NOTICES, readable by the application user.LICENSEat the repository root covers the product's own code. The generated notices are build outputs, not a hand-maintained rootNOTICEfile. - Optional drivers:
-Pmysqland-Poracleinclude their drivers and corresponding notices only in those builds. MySQL declares GPL-2.0-only with the Universal FOSS exception; Oracle's FUTC is identified asLicenseRef-Oracle-FUTC(no SPDX-listed identifier). Operators remain responsible for their use and redistribution terms, including drivers added throughlib/after the build. - Coverage boundary: these notices cover Maven runtime components and the isolate, not the base operating system, separately downloaded DuckDB extensions (#692), or vendored browser assets (#693). See Development §10.2 for the generation and update procedure.
11. What's Out of Scope for v1
- Managed / SaaS deployment: future commercial offering.
- Multi-tenant isolation: single-tenant v1.
- High-availability Postgres in our chart: operator provides managed PG.
- Backup automation: operator responsibility; we provide runbook.
- Air-gapped deployment: should work (no phoning-home telemetry) but not explicitly tested in v1.
- Federated deployments (independent installations sharing state or federating queries across each other): not supported. Multi-instance horizontal scaling (§6.2) is a different thing and is supported.
- Execution hand-off / resumption: an execution is pinned to the instance that started it and is aborted rather than migrated (§6.2, §8.3.1).
Appendix A: Reference compose.yml Sketch
This sketch boots — it satisfies every §5.1 startup requirement. Removing any of the marked items produces a container that exits during context startup, not one that runs degraded. deploy/compose.yml is the real file; every secret below comes from deploy/secrets.env under the same name the app binds, so the same file is sourceable by a bare java -jar, a systemd EnvironmentFile or a Kubernetes Secret (Environments §4).
services:
datapipelines:
image: ghcr.io/datapipelines/datapipelines:1.0.0
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/datapipelines
SPRING_DATASOURCE_USERNAME: datapipelines
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
DATAPIPELINES_REDIS_HOST: redis
# REQUIRED whenever redis runs with --requirepass. Must be the SAME value
# the redis service below is started with, or every Redis call fails at runtime.
DATAPIPELINES_REDIS_PASSWORD: ${DATAPIPELINES_REDIS_PASSWORD}
# REQUIRED, no fallback. Generate once: openssl rand -base64 32
DATAPIPELINES_JWT_SECRET: ${DATAPIPELINES_JWT_SECRET}
# REQUIRED, no fallback. Exactly 32 bytes base64: openssl rand -base64 32
# Losing this makes every stored datasource credential unrecoverable.
DATAPIPELINES_DB_ENCRYPTION_KEY: ${DATAPIPELINES_DB_ENCRYPTION_KEY}
# REQUIRED: at least ONE authentication method (ConfigValidator §7) — an OIDC
# provider configured below, or local accounts (auth.md §5A: set
# DATAPIPELINES_AUTH_LOCAL_ENABLED=true plus a one-time seed for
# DATAPIPELINES_AUTH_BOOTSTRAP_ADMIN_EMAIL, which names the first admin either way).
# These names are referenced by the providers list in application.yml below;
# Google is only an example — Microsoft/Okta/Keycloak/any OIDC IdP works.
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
volumes:
# The OIDC provider list is a nested structure — supply it as YAML.
- ./application.yml:/etc/datapipelines/application.yml:ro
depends_on:
- postgres
- redis
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: datapipelines
POSTGRES_USER: datapipelines
POSTGRES_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
# noeviction is REQUIRED (§4.2.1): eviction silently destroys results,
# idempotency keys, and cancellation flags. Same password as the app above,
# in the environment only: the server reads it from stdin, redis-cli from
# REDISCLI_AUTH — never on argv (§4.2.1, #196). `$$` keeps the expansion in
# the shell; `user: redis` because the wrapper is sh, not redis-server.
user: redis
environment:
REDISCLI_AUTH: ${DATAPIPELINES_REDIS_PASSWORD}
command:
- sh
- -c
- |
exec redis-server - --maxmemory 512mb --maxmemory-policy noeviction <<EOF
requirepass $$REDISCLI_AUTH
EOF
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres-data:
The mounted application.yml needs only the provider list (everything else has a default or is set above):
datapipelines:
auth:
oidc:
providers:
- name: google
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
issuer-uri: https://accounts.google.com
display-name: "Sign in with Google"
Format and further provider examples: Auth §5.1 and §11.1. Full key template: Configuration §5.
Appendix B: Demo Quickstart — the Published Sample Data
The sample data is published, versioned artifact sets (one per family), not
something the app downloads: loading it is a deployment step (sample-data design
D5). Any deployment that pulls the same version gets the same databases, byte for
byte in content. The build scripts that produce them live at
scripts/sample-data/ (nyc family),
scripts/sample-data-trade/ (trade
family) and scripts/sample-data-lake/
(dp-lake); everything below is the consuming side.
Three independent families, each behind its own compose profile and app.sh flag — an engineer spins up exactly the data they need:
| Family | Flag / profile | What you get |
|---|---|---|
| nyc (mobility) | --demo nyc / profile demo-nyc |
NYC TLC yellow-taxi trips on Postgres (~4.9M sampled rows, plus rollups), NOAA weather on MySQL, TLC reference on SQLite — 3 datasources + 6 example pipelines |
| trade (trade/v4) | --demo trade / profile demo-trade |
US Census monthly imports/exports at HS-6 grain on DuckDB (2.4M rows), UN Comtrade mirror statistics on MySQL, Federal Reserve H.10 exchange rates on SQLite — 3 datasources + 3 example pipelines |
| lake (dp-lake) | --demo lake / profile demo-lake |
NYC TLC high-volume for-hire trips as Parquet and Iceberg on S3, read in place — 1 LAKE datasource (sample-lake) whose registry is seeded from the published manifest.json, + 1 four-engine example pipeline. No loader, nothing downloaded at start |
Any combination is fine — the app's bootstrap keys are comma-separated lists built from the active families, and the MySQL service is shared.
The public marketing site's /demo-data page
renders these published manifests as documentation — every table, row count, source
and licence per family, generated from the same manifest files the loader verifies
(vendored at modules/web/src/main/resources/site/demo/, pinned to the versions
above). It is the page to hand anyone who asks what the demo contains.
The demo API: every seeded pipeline is a published endpoint (#224)
Seeding a demo family now also publishes each seeded pipeline as a GET
endpoint under /demo/… and binds them all to ONE public api_caller key —
demo-public-key, minted at boot from datapipelines.bootstrap.demo-api-key
(default dpk_DEMOPUBLIC42.…, configuration.md §3.18). The key is public by
design: the same value renders on the /demo-data page's "Call it as an API"
section, and an outsider can call any demo endpoint with it and no account
(rest-api.md §19.8 is the worked example, including the per-key request budget —
60 requests per 60 s per key, 429 rate_limit.exceeded — and the lake family's
gate: the lake endpoint pays S3 egress per call, so it is published only while a
budget stands behind it). Blank the setting in deploy/secrets.env and the next
boot retracts the public API — no key, no demo endpoints, no page section; change
the value and the next boot rotates the key. Nothing changes for keys real users
mint on the /api-keys page.
The lake family is different in kind from the other two and the difference is
worth stating before the commands: it has a compose profile and a --demo flag
like its siblings, but no loader service, because nothing is loaded. What
--profile demo-lake turns on is the datasource registration and the manifest
import; the data itself stays in the bucket and is read at query time — see
"dp-lake — the family with no loader" below.
One command
The base URLs and the pinned versions live in deploy/env/defaults.env, which is
tracked — a data change is a new version directory and a commit, so what a
deployment loads is visible in the repository. From a checkout that builds its own
image, the whole thing is:
./app.sh --start --demo nyc,trade,lake
For the raw compose path, set the family ON markers your invocation wants and pass
the same env files app.sh does — secrets last:
SAMPLE_NYC_ON=1 SAMPLE_TRADE_ON=1 SAMPLE_LAKE_ON=1 docker compose \
-f deploy/compose.yml -f deploy/compose.local-build.yml \
--env-file deploy/env/defaults.env \
--env-file deploy/secrets.env \
--profile demo-nyc --profile demo-trade --profile demo-lake up -d --wait
--wait returns when the app is healthy, and by then the artifacts have been
downloaded, every checksum verified, the databases restored, the SELECT-only demo
login created, and the families' datasources registered.
The nyc and trade families also build the jar with -Pmysql (see the driver
note below); lake alone does not need it.
./app.sh --stop and --status take the same --demo list, so the demo services
are not left running invisibly. ./app.sh --clean [--yes] is the clean slate: it
stops and removes the project's containers, then deletes the METADATA volume
(<project>-postgres-data — pipelines, templates, executions, users, API keys,
workspaces, datasource rows) by explicit name, and keeps <project>-mysql-data (demo
source data) and <project>-sample-data (the downloaded artifacts). It asks for the
project name back on a terminal, or needs --yes without one; the next --start
migrates a fresh database and re-seeds the demo. Sign-in users are re-created on their
next login; API keys must be minted again. Demo is a flag, not an environment
(Environments §5): DATAPIPELINES_DEMO is the source of
truth, --demo merely sets it, and the hardened posture refuses it at boot. The
per-family --demo-nyc / --demo-trade switches are gone; the families are
nyc, trade and lake, and an unknown name is refused by name.
MySQL driver. MySQL Connector/J is GPL with a FOSS exception and is not in
the default build (§3.5, Datasources §10.2). The
sample-weather and sample-trade-world datasources are MYSQL, and bootstrap
registration fail-fasts startup with datasource.driver_not_loaded without it.
Build with the driver:
./gradlew -Pmysql :modules:app:bootJar && docker build -t datapipelines:local .
or drop the jar into lib/. ./app.sh --start --demo nyc[,trade[,lake]] does the
-Pmysql build for you.
Point an agent at it — three steps
- Log in with the account
./app.sh --startprints. The demo needs no OIDC client at all (Auth §5A): local password accounts are enabled and the first admin gets a GENERATED one-time password, written todeploy/secrets.envat first scaffold and forced to change at first sign-in.app.shreads the seeded account back out of the database after the stack is healthy and prints the login that actually exists — the seed fires once, at row creation, so aDATAPIPELINES_AUTH_BOOTSTRAP_ADMIN_EMAILchanged later names an account that was never created (Environments §8). The first login lands you in the shippeddemoworkspace — workspaces are created by super admins, not provisioned per user (Auth §4.2 step 4, D-R11) — which is where the example pipelines are seeded. - Create your MCP key after signing in: open the Keys page
(UI Screens §4.19)
at
/api-keys, choose New API key, select kindmcp, and choose a role the creator may give under the subset rule. The demo's bootstrap super admin may choose any offered MCP role; authors, promoters and workspace admins may also create MCP keys, but viewers cannot. Copy the plaintext from the one-time reveal. To rotate a key, revoke it on the Keys page and create another. REST clients can create one withPOST /api/v1/auth/api-keys(Auth §7.4, REST API §16.1). - Give the agent the MCP endpoint
http://localhost:8080/mcpand that key. It can list the seeded pipelines, read the three sample datasources' schemas, and executenyc/mobility/revenue_by_boroughornyc/mobility/rainy_vs_dry_ridershipimmediately — see MCP Server.
What the demo profile turns on
The profiles add a mysql service and the families' one-shot loaders, and point
the app at the files they place on a read-only volume. The scaffolded
deploy/secrets.env enables local password accounts so the demo needs no OIDC
client, and names the account the credential lands on; the password itself is a
secret and is GENERATED into that same file — never a constant in a tracked file, on
an app that binds every interface (Auth §5A.2).
There is now exactly ONE place the credential is written and ONE place it is read
back from, which is why app.sh can print a login that works. It also sets the §7
demo posture: member-datasources-enabled=false (an
open datasource form on a public server is an SSRF and port-scan primitive —
demo users get the seeded datasources only). Without --profile demo none of
it exists: both datapipelines.bootstrap.* keys are paths and empty means
off, so the non-demo stack is configured exactly as it was.
The demo datasources are protected in three independent layers: workspace-scoped
access, the is_readonly flag on the datasource row
(Datasources §5.7),
and a database login granted SELECT and nothing else — created by the loader,
never assumed. The SQLite entry additionally opens its file with the driver's
read-only mode (Datasources §8A.4).
On a public demo, add the §9 hardening item that belongs with this posture:
egress-restrict the app container's network, so a datasource anyone can reach
cannot become a path to anywhere else.
The loader
deploy/sample-data/load.sh <base-url> <version> is what the one-shot services
run. Its contract, in order: download every artifact the manifest names, verify
every checksum, and only then touch an engine — a corrupted download can
never leave a half-loaded database behind. Each engine then gets a
_sample_meta(version) marker, written last, so a re-run of a loaded
deployment skips it and a failed engine is retried cleanly on the next start.
It runs as two services because no pinned image carries both a Postgres and a MySQL client, and installing one at container start would put an unpinned package fetch in the one place that has to be reproducible.
Demo Postgres sizing (T200, 109 §E)
The postgres service that the nyc family restores into carries the demo's
biggest table — trips, ~804 MB, ~4.9M sampled rows plus rollups — against the
Postgres image's compiled defaults (shared_buffers=128MB, random_page_cost=4.0
tuned for spinning disks). Under load, a simple year-2024 aggregate therefore
ran in ~21 s: nearly every page came from the OS cache, but the planner kept
choosing plans as if each random page cost a seek. The compose file sets:
command: postgres -c shared_buffers=512MB -c effective_cache_size=1536MB
-c work_mem=32MB -c random_page_cost=1.1
with the container's memory reservation raised to 1 GB to match. The numbers are
shaped by the data: shared_buffers ≈ half the restored set, effective_cache_size
≈ shared_buffers plus the OS page cache the box actually has, work_mem enough for
the demo's hash joins and sorts, and random_page_cost=1.1 because the volume sits
on SSD. This is demo sizing, not production advice — the operator's own
datasource is their own DBA's, and the app never tunes a database it did not bring.
Measured on a quiet box (109 §E), year-2024 airport-count query
(pu_location_id IN (1, 132, 138), three runs cold→warm): compiled defaults
42.7 s → 23.9 s → 8.6 s (buffers read=43,871 — nearly every page from disk);
sized flags 20.5 s → 9.6 s → 8.1 s (buffers hit=46,795, read=0 — the working set
fits the 512 MB). The win is the cold/cache-pressured run, ~2.1×; warm steady-state
is unchanged, as expected when the OS cache already holds the table. A composite
index (pu_location_id, pickup_date) on the artifact itself (the datasource is
readonly to agents, so the artifact is the only place an index can come from) takes
the same query to ~0.95 s cold / 0.09 s warm (index-only scan, 196 buffer hits)
— see the 109 handback for the artifact decision.
dp-lake — the family with no loader
sample-data/lake/<version>/ is different in kind from the two above and the
difference is the point: it is read in place. Nothing is downloaded at demo
start and nothing is restored into an engine. The sample-data-lake one-shot
service runs deploy/sample-data/place-lake.sh to place the repository's bootstrap
and example files on the shared volume; it does not download the dataset.
The query engine reads the Parquet objects over HTTPS and fetches only
the ones a query's predicates name.
What is published (build runbook:
scripts/sample-data-lake/README.md):
| Object set | Shape | Read when |
|---|---|---|
hvfhv_zone_day, hvfhs_companies |
one Parquet file each, single-digit MB | every run of the showcase pipeline |
hvfhv_trips_sample |
Parquet, one file per month, ~500 MB total | a trip-level question; one month is ~21 MB |
hvfhv_trips |
Parquet partitioned by pickup_date, ~7 GB over ~730 objects |
only the day partitions a query names |
hvfhv_trips_iceberg |
an Apache Iceberg table, ~500 MB | the Iceberg read path |
manifest.json |
— | the registry seed: tables[] carries name, format, path, partition column, row count |
Egress is the operating cost to watch, and it is bounded by the query, not by
the artifact. A demo session running the shipped pipeline at its default
one-month window reads a few MB. The exposure is a visitor who writes
SELECT * FROM hvfhv_trips with no date predicate — a ~7 GB full scan at S3
egress prices, per run. Set a bucket request-rate or budget alarm before the lake
demo is announced.
Because there is no loader, the consuming side of this family is a datasource
registration — shipped in round 089 as the lake demo family: the sample-lake
LAKE datasource is registered create-if-absent from
deploy/sample-data/bootstrap-datasources-lake.yml, which also imports the
published manifest.json's tables[] into the dp-catalog registry
(Datasources §8A.1, §8C). The showcase pipeline
ships in its own examples file (scripts/sample-data/content/examples-lake.json)
and the seeder loads it exactly when sample-lake is registered (the
requires_datasources gate — environments.md §5).
It needs egress to S3 (or a configured mirror) whenever a lake query runs.
Resetting an engine volume desyncs the demo login
The loader creates the dp_demo_ro login with the passwords from the env files,
and bootstrap registration is create-if-absent — it never updates an existing
datasource row (§8A). So if you delete an engine's data volume
(docker volume rm dp-mysql-data) or rotate a SAMPLE_* password, the
freshly created login no longer matches the credential the app stored at the
original bootstrap, and that datasource fails validation in the UI while the
engine itself is fine. The repair is to re-run registration for the stale entry
only:
docker exec dp-postgres-1 sh -c \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "delete from datasources where name='"'"'sample-weather'"'"';"'
docker restart dp-datapipelines-1 # bootstrap re-registers with the current env password
(2026-08-30: hit live — a recreated MySQL volume left sample-weather failing
UI validation until its row was re-registered this way.)
Re-publishing is a new version
Version directories are immutable (design §4). Any change to the data — a wider
window, a different sample, a corrected lookup, even a typo — is published as
v2 under a new prefix; v1 is never edited. Consumers pin the version
(SAMPLE_VERSION), and nothing references a latest alias. This is what makes
"the same version means the same data" a fact rather than a hope, and it is why
the loader refuses a manifest whose version does not match the directory it
came from.
Publish confirmation & release rehearsal — the drift guards
Two guards bracket every publish and every release rehearsal. Both exist
because of T70 (2026-09-02): the published v1 examples.json still carried
the ${} interpolations 042 had already migrated out of the repo copy, the
demo 500ed on every fresh first login for two days, and nothing compared the
published bytes with the repo's.
-
Before confirming an upload (and in every release rehearsal), run the published-drift guard for the version the demo pins:
./scripts/sample-data/check-published.sh v2 # nyc (mobility), the default family ./scripts/sample-data/check-published.sh --family trade v2 # the trade family ./scripts/sample-data-lake/check-published.sh --family lake v1 # dp-lake (a different contract, below)It fetches the published manifest and
examples.json, and fails unless the published copy, the published manifest's declared checksum, and the family's repocontent/examples.jsonall agree.--familyselects all three of the repo copy, the base-URL variable (SAMPLE_BASE_URLfor nyc,SAMPLE_TRADE_BASE_URLfor trade) and the default published prefix. Against an unpublished version it fails on the manifest fetch — which is the upload gate itself. Set the family's base-URL variable to check a mirror or a locally staged build (file://…/scripts/sample-data/work, or the local-serve recipe inapp.sh). Network by nature, so it is a rehearsal step, never part of./gradlew build. The lake family's guard is a separate script because it checks a different thing. That family has noexamples.json— its content lives with the nyc family — so what can drift is not content but the objects themselves. It fetches the published manifest, refuses a manifest whoseversiondisagrees with its directory, fails if any provenance row still carrieslicense_verified: null, samples one object per table and compares SHA-256, and fetches the Iceberg metadata file to confirm every location it records sits inside the published prefix — an Iceberg table that still names a build machine's directory is unreadable, and nothing about an object listing shows it. Folding two unrelated contracts behind one--familyflag would make "check-published passed" mean two different things. -
The repo copy is validated in
build— the templates module'sSampleDataExamplesContentTestruns every shipped template and pipeline through the app's own save-time validators (049 C1), so content the seeder would refuse cannot merge.verify.shstep 5 remains structural only; the two checks are different jobs and each says which it does.
Licence gate — before serving this data publicly
Every provenance row in manifest.json ships license_verified: null. The
build verifies no licence and claims none; the licence strings are research
claims carried with their evidence links in each family's sources.lock
(scripts/sample-data/, scripts/sample-data-trade/,
scripts/sample-data-lake/).
Publishing with any license_verified still null blocks go-live (design §8).
Verify each source's current terms, record the date, and swap — do not ship — any
dataset that fails. This applies to datapipelines.co; a self-hosted evaluation
loading the artifacts for its own use is a different question, and one for the
operator.
Appendix C: Change Log
| Date | Version | Author | Change |
|---|---|---|---|
| 2026-11-01 | v1.61 | #685 merge follow-up | §3.1 / §6.5: the image disables glibc's native thread-stack cache before its JVMs start; overrides retain that entry, and bare JVM launches set it before Java starts. The Dockerfile drift guard pins the declaration and launch order. |
| 2026-10-28 | v1.60 | #747 | §3.1: the DuckDB re-pin recipe refreshes each extension's linked closure, deploy/duckdb-extension-ports/<name>.tsv (the vcpkg port closure, direct and transitive, plus the DuckDB core third_party code and port-vendored code both binaries' symbols witness), before reviewing every new component's licence; the generator refuses a closure bound to another commit, a closure entry without a reviewed licence and a reviewed component missing from the closure. |
| 2026-10-27 | v1.59 | #743 — renumbered at merge after #692’s v1.58 | §3.1: the four image bullets state what the Dockerfile builds — base image eclipse-temurin:21-jre (Ubuntu glibc; argon2-jvm loads its native library through JNA), a single-stage build (the boot jar is built on the host and copied in; the image adds the DuckDB extension bundle, the GraalJS isolate + resource cache and the third-party notices file), the useradd --system account with no fixed UID, and the exec-form entrypoint naming the GraalJS resource cache and /app/datapipelines.jar; the timezone paragraph no longer claims the entrypoint bakes the UTC flag in — every deployment passes -Duser.timezone=UTC itself. DeploymentImageBulletsSpecDriftTest pins the bullets to the Dockerfile (one FROM tag, the ENTRYPOINT array verbatim, the USER/useradd --system account) and refuses the retired claims — the base tag, the builder-stage build, the builder-image pin, the JVM-flags variable, the fixed UID, the wrong jar path and the baked-in-UTC claim — described here rather than quoted, so the retired-words grep over this file stays empty. The Dockerfile's healthcheck comment now names the Ubuntu base, not alpine. |
| 2026-10-26 | v1.58 | #692 | §3.1: checksum-pin all eight decompressed DuckDB extension binaries, include commit-bound extension and direct vcpkg component notices, and document the three-input re-pin procedure. |
| 2026-10-26 | v1.57 | #693 — renumbered at merge after #570’s v1.56 vendored browser-asset notices | §6.2A: the offline :modules:web:generateVendoredAssetNotices task renders the vendor manifest and upstream licence files to /vendor/THIRD-PARTY-NOTICES.txt, packaged in the web jar and website export. Missing coverage, unknown or mismatched licences and stale allowances fail the build. |
| 2026-10-26 | v1.56 | #570 unused MapLibre CSS | §6.2A: patch 0004 drops the unreachable maplibre-gl stylesheet require from the non-map custom bundles; both bundles rebuilt and hashes and sizes re-pinned, with the rule to drop the patch when a map trace joins a trace list. |
| 2026-10-25 | v1.55 | #702 | Appendix B step 2: replaced the top-bar chip, login mint and GET /api/v1/auth/api-keys/mine flow retired by keys v2 (#233, Auth v3.5) with the Keys page / POST /api/v1/auth/api-keys, the role chosen under the subset rule and the one-time reveal. The v1.28 row remains as history. Prose only. |
| 2026-10-25 | v1.54 | #712 — renumbered at merge after #442’s v1.52/v1.53 | §6.4: the management port's chart exposure (configuration.md §3.14) — a management values block, everything OFF by default (byte-identical default render): management.expose adds the management containerPort (from management.port), pins both MANAGEMENT_SERVER_* env entries (address 0.0.0.0 — the opt-open switch) and a headless Service (clusterIP: None); management.networkPolicy.enabled adds the chart's one ingress NetworkPolicy for the app pods, which carries two rules: the scraper peers on the management port, and the app-port rule from appIngress.from (NetworkPolicyPeers, verbatim) or every source under the explicit appIngress.allowAll: true. Every selector is a full LabelSelector. The render refuses both scraper selectors empty, no app-port declaration, allowAll with a from, a bare label map, an unknown or empty peer and a quoted allowAll; the isolation and union semantics, and how the policy combines with operator-owned ones, are stated. management.serviceMonitor.enabled adds a ServiceMonitor. Any extraEnv.MANAGEMENT_SERVER_* double mapping is refused (Kubernetes honours the last duplicate env name silently). Chart 0.2.0 → 0.2.1. helm-check.sh grows to lint plus eighty-nine verdicts ((h)–(h5h)), with a pinned kubeconform validating every successful render against the Kubernetes 1.35.0 schema and a parsed NetworkPolicy contract. §9: the management-port sentence names the app-port rule. HelmManagementValuesSpecDriftTest pins the values, the expose guards, the refusals, the three templates' flag lines, the shared app port, the matchLabels example and the configuration.md §3.14 row. |
| 2026-10-25 | v1.53 | 442c (#442) | §8.3: keep scheduler mail off until every instance understands V51 notification trail kinds. §8.4: disable, drain and account for old readers refusing historical notification kinds before downgrade. |
| 2026-10-24 | v1.52 | 442b (#442) | §5.1: the one optional key with a startup requirement of its own — datapipelines.scheduler.notifications.enabled=true needs the mail transport and auth.base-url or startup refuses; under development nothing sends until the recipient-domain allowlist names a domain. |
| 2026-10-24 | v1.51 | #688 (Helm portion) — renumbered at merge after #677’s v1.50 | §6.4: the nine JavaScript runtime keys (configuration.md §3.28) are named chart values under script.js, all empty by default — an empty value renders no env entry, inheriting the image's own isolate directory and the app's derived isolate memory. script.js.isolateDir is refused at render time outside the image's launcher-path entries (/app/lib, /opt/graaljs), and an extraEnv double mapping of any of the nine is refused like the DuckDB keys. Number validation stays the app's (startup, by name). Chart 0.2.0; helm-check.sh grows to lint plus ten template cases (all-nine render, the single-key or-clause proof, the nine double-mapping refusals, the isolateDir refusal and its two admitted entries); HelmScriptJsValuesSpecDriftTest pins doc, chart, values and the Dockerfile's launcher-path list together. |
| 2026-10-24 | v1.50 | #677 third-party notices | §10 documents generated Maven/isolate notices in the jar and image, the unresolved/stale licence guards, optional-driver terms and the remaining distribution boundaries. |
| 2026-10-22 | v1.49 | 8b (#8) | Documents the isolate directory/loader agreement, pre-installed resources on read-only roots, shared-isolate failure mode and memory sizing. |
| 2026-10-21 | v1.48 | #8 lane 8a the JavaScript isolate (owner ruling 2026-10-21) | §3.5 the lib/ drop-in paragraph quotes the image's value as it now is, ENV LOADER_PATH=/app/lib,/opt/graaljs: the drop-in directory, then /opt/graaljs, where the image fetches its platform's SHA-256-pinned GraalJS isolate jar. One list in one ENV because PropertiesLauncher reads a -Dloader.path before the variable — an ENTRYPOINT -Dloader.path=/opt/graaljs would have silently dropped the drop-in (measured in lane 8a). The mount point, its mode and the never-from-the-environment rule are unchanged. ImageLoaderPathSpecDriftTest pins the new value. Renumbered at merge after #666's v1.47. The engine's operator-facing configuration and the rest of the image's JavaScript story are lane 8b's. |
| JavaScript isolate | js-isolate.jar is in datapipelines.script.js.isolate-dir, which is one loader-path entry; the native resource cache is pre-installed and read-only; startup logs one isolate-loaded line |
||
| 2026-10-21 | v1.47 | #666 Helm driver drop-in | §6.4: opt-in libVolume.enabled and one operator-supplied libVolume.source mount trusted drivers read-only at /app/lib; PVC sources must also be read-only. extraEnv.LOADER_PATH is refused with the image-owned-path message, in every spelling the launcher reads (loader_path, loader.path: Spring Boot's SystemPropertyUtils tries the key as given, dots to underscores, then upper-cased). JAVA_TOOL_OPTIONS, JDK_JAVA_OPTIONS and _JAVA_OPTIONS in extraEnv are refused too, as the compose audit's class 8 refuses them (#679). Chart 0.1.2; helm-check covers the default, refusal, ConfigMap mount and PVC/source boundaries; the deploy-file drift guard admits exactly the template refusal and requires it once. |
| 2026-10-21 | v1.46 | #657 the image's driver drop-in | §3.5 the lib/ drop-in paragraph now matches the image: the Dockerfile creates /app/lib empty, root:root mode 0555, before USER, and sets ENV LOADER_PATH=/app/lib — until now it did neither, so a mounted driver was never on the classpath. The value is absolute because PropertiesLauncher resolves a relative loader.path against loader.home (default ${user.dir}) and the image has no WORKDIR: LOADER_PATH=lib adds /lib. The operator mounts the DRIVER jar read-only at /app/lib/; neither compose nor the chart sets LOADER_PATH (§9). §6.4 states that the chart does not support the drop-in today (no volume knob; extraEnv does not refuse LOADER_PATH). ImageLoaderPathSpecDriftTest pins the Dockerfile, this paragraph and Module Structure §5.4.1 to one value and refuses a deploy file that names LOADER_PATH. |
| 2026-10-19 | v1.45 | #571 path-independent Plotly rebuilds | §6.2A: patch 0003 salts root-relative inline-CSS paths with the historical build directory, preserving every committed byte from any clone directory. The recipe uses a temporary directory; scripts/verify-plotly-bundles.sh rebuilds and compares both bundles for the lander on Plotly vendor or manifest-entry diffs, with distinct match, mismatch and rebuild-failure exits. |
| 2026-10-16 | v1.44 | #538 audit manifest sizes | §6.2A: the vendored Plotly audit now compares all three raw byte lengths and both gzip counts with the manifest, and pins zlib deflate level 9 with gzip framing and mtime 0 in each bundle's size string. Size-only and method-only drift fail the build. |
| 2026-10-15 | v1.43 | #531 the default heatmap keeps crisp cells under the CSP | §6.2A: a second numbered Plotly source patch sets the heatmap’s image-rendering fallbacks through the CSSOM instead of a style attribute; both custom bundles are rebuilt at the fixed path and their hashes and sizes move together in the manifest. The unminified comparison contains only the heatmap import and write changes; minified identifier allocation also changes. |
| 2026-10-14 | v1.42 | #525 the Plotly recipe carries a vendor patch and a fixed build directory | §6.2: the Plotly rebuild gains git apply of static/vendor/plotly/patches/*.patch (the colorbar fill reset removes its style attribute instead of writing an empty one — two source lines, +4 bytes per bundle) and npm ci; the clone directory is fixed at /tmp/dp-plotly-4.1.1/plotly.js because the minified output depends on the clone's absolute path (#530; the 2026-09-30 build did not record its directory and is not reproducible, so both bundles were rebuilt, not patched in place). Sizes and hashes are in the vendor manifest; VendoredPlotlyAuditTest pins the bytes. #525 merge follow-up (2026-10-15): §6.2 names the marker each host pre-places as the empty template (#456) that the runtime's fallback also creates (#526), not a style element; the manifest's plotly note says the same. |
| 2026-10-03 | v1.41 | 411 (#411) CI renders the reference chart | §6.4: the chart is rendered by CI — the gate job runs scripts/helm-check.sh, a pinned helm (v4.3.0; the release's checksum file SHA-256-pinned in-repo, bumped with the version in one commit) running helm lint --strict plus four helm template cases: defaults render no env: block, both duckdb.* values render exactly their two quoted entries, each extraEnv double mapping is refused with the template's own message, and a missing existingSecret is refused. Exit contract: 0 all cases green, 1 a chart check failed, 2 tooling. Also corrects this section's HPA bullet — the chart scales on CPU only (hpa.yaml carries one cpu metric; there is no memory metric). No chart change. |
| 2026-10-02 | v1.40 | 140 (#140) the Helm chart maps the DuckDB operator keys | §6.4: the reference chart gains a duckdb values block — duckdb.extensionDirectory and duckdb.memoryLimit (configuration.md §3.25's two env vars) — each rendered as an env entry ONLY when non-empty: an empty default inherits the image's bundled extension directory, and a rendered "" would override the image's ENV and break zero-egress LAKE under the read-only root filesystem. extraEnv stays the escape hatch for every other non-secret key; an extraEnv double mapping of either DuckDB key is refused at render time. Chart version 0.1.0 → 0.1.1. HelmDuckDbValuesSpecDriftTest pins doc = chart values = env entries. |
| 2026-09-30 | v1.39 | L3a (#10) the dashboards client runtime — renumbered at merge after 337's v1.38 | New §6.2A Vendored browser assets — where the vendor manifest and its audit tests live, and the Plotly 4.1.1 provenance: the two custom bundles' exact rebuild recipe (clone the pinned tag, npm run custom-bundle per trace list, commit the outputs), the sizes and the npm tarball sha1, and the plotly.css design-around that keeps style-src at 'self' plus Cytoscape's one hash. No code, route or policy change. |
| 2026-09-30 | v1.38 | 337 (#337) structured local logging | §5.2 gains "Log output format — turning JSON on": what each deploy path resolves today (app.sh/VPS console from defaults.env, raw compose and bare jar json), the operator recipe for turning JSON on (set the env in secrets.env, redeploy), the startup refusal on an unknown value, and the statement that redaction runs under both values. No deploy file changed — the declared env var just works now. |
| 2026-09-29 | v1.37 | the 316 merge's review (#316, #266) | §6's Deployment bullet and §8.3.2: the grace is 60 s (chart and compose) — the sum had left out 266's persistence drain (shutdown-drain-ms, 10 s), so at the configured maxima the runtime could kill mid-drain; §8.3.1 step 4 names the persistence drain and the scheduled jobs' scheduler stop (#316), which sits after every drain and outside the sum. ShutdownGraceArithmeticTest carries the new term (red at 50 s: compose needed 55, Helm 60). |
| 2026-09-28 | v1.36 | 301 (#305) | §6.2's CSP row prints the style-src hash LITERALLY again — 195's rewrite had left a '<hash>' placeholder where the value stood: 'sha256-pgvDUBa4IjFA2yuSJ2cqcyxmNYJMborsd0ORcRv9vw8=', the SHA-256 of the one <style> element Cytoscape injects on the editor page. The value is derived, not typed: SecurityHeadersTest computes it from SecurityHeaders.CYTOSCAPE_STYLESHEET (the vendored sheet verbatim), and ApplicationSmokeTest asserts the header the way an operator reads it, literal included — a Cytoscape bump that changes the sheet goes red rather than documented wrong. No code, route or policy change. |
| 2026-09-28 | v1.35 | 195 (#195) the editor's eval exemption retired — renumbered at merge after 298's v1.33/v1.34 | §6.2's CSP row rewritten: the route-scoped 'unsafe-eval' exception 188 carried on GET /pipelines/{id}/editor is GONE — the editor runs Alpine's CSP build (@alpinejs/csp 3.14.1, same vendored path, the vendor manifest records the npm tarball's sha256) and its expressions are pure property paths. One policy covers every policed route; style-src carries Cytoscape's one sheet hash on every route (a hash admits exactly that sheet, so no route's posture is weakened). SecurityHeaders.kt lost CSP_POLICY_EDITOR/isEditorRoute; SecurityHeadersTest pins the directive's absence on the editor route (falsified by re-adding it), and the browser suite's zero-violation collector is the live proof. No route, permission or role changed. |
| 2026-09-28 | v1.34 | 298 (#291 item 3) the promotion batch and the body cap | §6.3A's settings table gains the receiver's datapipelines.web.max-request-bytes: a promotion is one push body, refused atomically with 413 request.body_too_large past the RECEIVER's cap. Measured over the shipped example content (all three demo families, 40 documents): 73 KiB, 3.6% of the 2 MiB default — a split is not warranted; a batch of several near-256K-character templates is the case that needs the knob raised on the receiver. |
| 2026-09-28 | v1.33 | 298 (#298) the #194 scan survives a malformed scale | §8.3 step 4's first scan guards its ::int cast (CASE WHEN scale ~ '^-?[0-9]{1,9}$'): a stored scale that is not an integer ("2.0", "x", or past int) is LISTED — a malformed declaration is what the operator runs the scan to find — where the bare cast stopped the whole read-only scan with an error. PreDeployScanQueriesTest runs the block verbatim over a fixture that now carries both. |
| 2026-09-28 | v1.32 | 286 (#277) the stop grace's arithmetic | §6's Deployment bullet and §8.3.2: the chart's terminationGracePeriodSeconds and compose's stop_grace_period are 50 s, written as the sum of the shutdown sequence at its configured maximum — preStop 5 + the scheduler's admission gate ≤ 15 + the drain flush 20 + Tomcat 10. At 40 s (#251) the three scheduler phases alone reached 40 under Helm and left Tomcat nothing; §8.3.2's snippet and bullet still said 30. ShutdownGraceArithmeticTest pins both files and every value this document states. |
| 2026-09-28 | v1.31 | 286 (#268, #194) pre-deploy scans | §8.3 gains step 4, the pre-deploy check for builds after 2026-09-26: 194a's over-scale-default scan (it lived in that lane's handback as an untested sketch — now made robust to a body with no parameters object) and #268's scan for stored parameters declaring constraints or a cardinality, each tagged and run verbatim by PreDeployScanQueriesTest against the shipped schema; the later steps renumber. The Status line read v1.28 behind the v1.30 row; it now reads v1.31. |
| 2026-09-24 | v1.30 | 224 (#224) demo API | New Appendix B subsection "The demo API": seeding a demo family publishes every seeded pipeline under /demo/… and binds them to one public api_caller key minted from DATAPIPELINES_DEMO_API_KEY (blank = off, changed = rotation); the per-key request budget and the lake family's budget gate are the same act (rest-api.md §19.8). |
| 2026-09-22 | v1.28 | key wording | Appendix B step 2 says what the product does since R3: the MCP key is created at sign-in and copied from the top bar; nothing is "minted" by hand and there is no create endpoint. app.sh's demo message says the same. |
| 2026-09-22 | v1.27 | #207 relative redirects | §8 edge contract: every app Location is RELATIVE (server.tomcat.use-relative-redirects=true), so the public scheme never leaks into a redirect and the edge needs no X-Forwarded-Proto handling for them. Behind a TLS-terminating edge the absolute http:// Locations Tomcat built by default were refused by the browser (CSP form-action on plain forms, mixed content on htmx requests) after the server had already acted — logout, workspace switch and member removal on datapipelines.co, 2026-09-22. |
| 2026-09-21 | v1.26 | 199 (#196) the Redis password leaves argv | §4.2.1 gains "Where the password lives": the reference redis services (deploy/compose.yml, deploy/compose.laptop-infra.yml, Appendix A) start redis-server - — configuration from stdin, requirepass fed by a heredoc from REDISCLI_AUTH, exec so the server stays PID 1, user: redis because the wrapper is sh — so no argv, and no file, carries the value; --maxmemory/noeviction stay on argv. §9's argv line closes the "tracked separately" remainder and names the two commands that check it. Operators: the next start recreates the Redis container (§7 says what a restart loses). Numbered on base db12e043; the merger renumbers if another lane took v1.26. |
| 2026-09-21 | v1.25 | 188 (#188) the app states its headers | §6.2 gains "What the edge sets, and what it must not touch": the header table the app sends on every response (an enforced CSP with no 'unsafe-inline', X-Frame-Options: SAMEORIGIN, Referrer-Policy, Permissions-Policy; the editor route's 'unsafe-eval' exception, #195), why the app sends no HSTS, and the four things the product expects of an edge (HSTS one year without preload, headers passed through unweakened, TLS + redirect, the forwarded address). §9: two checklist lines for the same. |
| 2026-09-21 | v1.24 | 188 (#189) Redis credentials | §9: the hardened posture refuses a passwordless non-loopback Redis; the reference compose files pass the healthcheck password as REDISCLI_AUTH (environment), never redis-cli -a (argv); the server's --requirepass argv named as the remaining exposure. |
| 2026-09-19 | v1.23 | 173 (#173) the agent surface before indexing | §6.7: /llms.txt, /llms-full.txt and /docs/{slug}.md listed beside the crawler surfaces; the Search Console note says llms.txt needs no submission; the sitemap bullet corrected — lastmod has been absent since 145, not read from build-info.properties. |
| 2026-09-17 | v1.22 | Writable temporary storage (#133) | §6.4: disk-backed /tmp emptyDir with optional size limit while preserving the read-only root filesystem; LAKE disk sizing and cleanup requirements. |
| 2026-09-14 | v1.21 | 137 mail notices | §5.1: the first-admin story no longer says "no SMTP" — with DATAPIPELINES_MAIL_HOST + _FROM set the one-time credential is emailed to the user and sys-ops (DATAPIPELINES_MAIL_OPS_TO) is told about every new user (Auth §5A.8); the variables are catalogued in Configuration §3.27 and named in deploy/secrets.env.example, per this doc's no-restated-keys rule. |
| 2026-09-10 | v1.20 | 109 §E mobility v7 | The nyc family pins SAMPLE_VERSION=v7 — a new immutable version directory carrying ONE change over v6: the trips table gains the composite index (pu_location_id, pickup_date) (idx_trips_pu_location_pickup_date, DDL beside the two singles), measured ~90× on the year-2024 airport shape (8.1 s sized seq scan → 0.09 s index-only). The other three artifacts are byte-identical v6 copies and every table content-checksum is unchanged — the v2 precedent's restore-and-redump shape. The v7 set (pg-trips.dump 117,733,060 bytes, sha256 02627d0846…, plus manifest) is handed to the owner in handbacks/109-artifacts/mobility-v7/ for publishing; a fresh --demo nyc start needs v7 published to load. |
| 2026-09-09 | v1.19 | 109 §E demo Postgres sizing | New "Demo Postgres sizing" subsection (Appendix B): the nyc family's trips table (~804 MB) meets sized-for-the-data flags — shared_buffers=512MB, effective_cache_size=1536MB, work_mem=32MB, random_page_cost=1.1 (SSD) — with the container memory reservation raised to 1 GB and the "demo sizing, not production advice — the operator's own datasource is their own DBA's" boundary stated. The compose change itself is cfb65c9c; this section is the rationale the compose comment points at. |
| 2026-09-08 | v1.18 | 093 announcement-day truth pass | Appendix B's demo-families preamble corrected against the merged 089 product. It said dp-lake was published but not yet consumable with "no compose profile, no loader and no --demo flag" while app.sh already accepts --demo lake and adds --profile demo-lake (app.sh lines 144/209, SAMPLE_LAKE_ON) — the paragraph was written at 088 and 089 shipped the consuming side. The families table gains a lake row; "Two independent families" becomes three; the nyc row's "2 example pipelines" becomes 6 (scripts/sample-data/content/examples.json holds six); the one-command and raw-compose recipes gain the third family; the -Pmysql sentence now says which families need it. The "family with no loader" section below is unchanged — it was already correct — and the distinction it draws (a profile and a flag, but nothing to load) is now stated in the preamble too, which is where the contradiction was read. |
| 2026-09-07 | v1.16 | 089 dp-lake §D extension bundling | §3.1 gains the bundled DuckDB extensions bullet: the image now downloads httpfs/aws/iceberg/avro for DuckDB core v1.5.5 at build (+108 MB uncompressed, ~39 MB downloaded), gunzips them into /opt/duckdb/extensions/v1.5.5/<linux_amd64|linux_arm64>/ (the build's TARGETARCH), and exports DATAPIPELINES_DUCKDB_EXTENSION_DIRECTORY=/opt/duckdb/extensions — a LAKE datasource then LOADs from disk and never INSTALLs, so a hardened dp-lake deployment needs no egress to extensions.duckdb.org (the 089 §7.3 spike's CAN verdict, verified on this exact base image with --network none). deploy/compose.yml defaults the variable to the same path (declared in compose-env-audit.sh's new IMAGE_DEFAULT_VARS, the first compose default that mirrors the image rather than application.yml). The operator key itself is Configuration §3.25. |
| 2026-09-07 | v1.15 | 088 dp-lake data | Appendix B gains a third sample-data family, dp-lake — NYC TLC High Volume FHV (Uber/Lyft/Via/Juno) trips published at s3://datapipelines-co/sample-data/lake/<version>/ as Parquet partitioned by pickup_date, a 1-in-16 sample, a zone/day pre-aggregate and an Apache Iceberg copy of the sample. New section "dp-lake — the family with no loader": it is read in place, so there is no compose profile, no loader service and no --demo flag — the datasource that reads it (dialect lake, sample-lake) is round 089, and the showcase pipeline ships in its own scripts/sample-data/content/examples-lake.json that no deployment loads yet. The drift-guards section gains scripts/sample-data-lake/check-published.sh --family lake <version> and says why it is a separate script (no examples.json; it checks object hashes, the licence gate and the Iceberg metadata's recorded locations). Egress is stated as the operating cost: bounded by a query's date predicate, unbounded for an unfiltered scan of the ~7 GB table. No application code, no deployment change. |
| 2026-09-07 | v1.15 | dp-lake v1 published (088) | s3://datapipelines-co/sample-data/lake/v1/ — 855 objects, 7.57 GB: hvfhv_trips (471,851,707 NYC rideshare trips, day-partitioned Parquet), hvfhv_trips_sample (1-in-16), hvfhv_zone_day, hvfhs_companies, hvfhv_trips_iceberg (the sample as an Iceberg table). Read in place by the dp-lake datasource (round 089) — no loader, no download at demo start; licence verified 2026-09-07 (NYC Open Data, FHV disclaimer quoted in the manifest). check-published.sh v1 in the lake family is the proof. |
| 2026-09-07 | v1.14 | mobility v6 (082) | The demo briefing gains a CALCULATOR node (fiscal_quarter → run_fiscal_quarter, bound as the first caller column) so the feature is visible out of the box; data files unchanged; baselines re-keyed. Pin → SAMPLE_VERSION=v6 in deploy/env/defaults.env; check-published.sh v6 byte-identical. |
| 2026-09-06 | v1.13 | mobility v5 + trade v4 (077 mandatory folders) | Both artifact sets republish with every template id under a folder (nyc/mobility/…, nyc/reference/…, nyc/weather/…, trade/…) — 13 of 25 demo templates were flat and are refused by 077's rule at seed time. Data files unchanged in content. Pins → SAMPLE_VERSION=v5, SAMPLE_TRADE_VERSION=v4; check-published.sh v5 / --family trade v4 byte-identical on publish. |
| 2026-09-06 | v1.13 | 081 one env file | The deploy/ layout is two env files: tracked deploy/env/defaults.env (every non-secret variable with the value this deployment ships) then git-ignored deploy/secrets.env, in that order under every loader, with deploy/secrets.env.example as the template that names every variable. The five files 075 put under deploy/env/ (laptop.env, demo.env, example.env, posture/development.env, posture/hardened.env) are deleted; §6.2, §6.3A and Appendix B's raw-compose recipe use the two-file list. deploy/compose.laptop-infra.yml starts its Postgres and Redis from SPRING_DATASOURCE_PASSWORD / DATAPIPELINES_REDIS_PASSWORD, removing the laptop's load-order inversion. ./app.sh --scaffold writes deploy/secrets.env from the template and stops, so the admin address can be set before the one-time seed; --start waits until the app is actually healthy (up to HEALTH_WAIT_SECONDS, default 360) instead of reporting the HEALTHCHECK deadline as a failure, and --status prints the seeded login too. |
| 2026-09-05 | v1.12 | 075 environments and posture | Environments joins the spec set — the operator page for DATAPIPELINES_ENV (the org's label) and DATAPIPELINES_POSTURE (development | hardened), with the normative posture table, the environment-variable contract, and loader recipes for Compose, a bare JAR, systemd, Kubernetes, ECS and Nomad. The deploy/ layout is renamed with no shims: docker-compose.yml → compose.yml, docker-compose.local.yml → compose.local-build.yml, docker-compose.dev.yml → compose.laptop-infra.yml; deploy/application.yml is folded away (every line was already in the image's own application.yml behind the same placeholders) and orgs wanting a yml use SPRING_CONFIG_ADDITIONAL_LOCATION; deploy/.env/.env.demo/.env.example and the root .env.example are replaced by tracked deploy/env/** (posture, demo, laptop, and example.env — every variable with its shipped default) plus git-ignored deploy/secrets.env. Secrets carry the app's own variable names (DATAPIPELINES_JWT_SECRET, SPRING_DATASOURCE_PASSWORD, …) instead of compose-only renames, so one file feeds every loader. §6.3A's promotion example is now env+posture. Demo is a flag: DATAPIPELINES_DEMO=nyc,trade (./app.sh --demo nyc,trade) replaces --demo-nyc/--demo-trade, with the versions in tracked deploy/env/demo.env, and hardened refuses it. scripts/compose-env-audit.sh now runs on every ./gradlew build (it had drifted: 074's three endpoints keys shipped with no compose pass-through) and also checks deploy/env/example.env. Compose volume names derive from COMPOSE_PROJECT_NAME, so -p <lane> alone scopes a second copy's data. |
| 2026-09-05 | v1.11 | mobility v4 + trade v3 (067 pipeline folders) | Both artifact sets republish with examples.json carrying the folder convention (nyc/…, trade/…); data files unchanged in content (every pinned table checksum re-derived identical; the DuckDB file's bytes differ as DuckDB files are not byte-deterministic). Pins move to SAMPLE_VERSION=v4, SAMPLE_TRADE_VERSION=v3. Until an operator moves the pin, a fresh demo seeds the old flat names — expected, version pins are operator config. |
| 2026-09-04 | v1.10 | trade/v2 — Binance out, Federal Reserve H.10 in | The trade family republishes as trade/v2. The Binance market slice is removed entirely (its Vision terms are CC BY-NC-SA with an explicit no-hosting-of-derivative-feeds clause and a separate enterprise licence for commercial use — owner ruling 2026-09-04); the SQLite artifact is now fx_rates.db, built from the Federal Reserve H.10 daily noon buying rates and their G.5 monthly averages for the five reconciled partners' currencies (a US Government work — no copyright), and the datasource is renamed sample-market → sample-fx. A third example pipeline, imports_in_partner_currency, restates US import value in the partner's own money across three engines (DuckDB facts → SQLite rates → H2 join). Two licence conditions that were never in the tree now ship with the data: the verbatim Census notice ("This product uses the Census Bureau Data API but is not endorsed or certified by the Census Bureau.") in the family README, the sample-trade-us datasource description and the manifest's Census provenance entry, and the UN Comtrade citation with the under-100,000-record note. check-published.sh learns --family trade; SAMPLE_TRADE_VERSION defaults to v2 in app.sh, deploy/.env.example and the compose services. An existing deploy/.env.demo pinning SAMPLE_TRADE_VERSION=v1 is not rewritten by app.sh (version pins are operator config) — edit it, or the loader fetches a version whose market object no longer exists. |
| 2026-09-04 | v1.10 | mobility v3 (070 showcase pipelines) | Mobility artifact v3 published — v2's data files byte-identical, examples.json now 17 templates / 6 pipelines (the 070 showcase set, baselined by check-baselines.sh); SAMPLE_VERSION default → v3. Operators with an existing .env.demo set SAMPLE_VERSION=v3 by hand — app.sh never overwrites present keys. |
| 2026-09-04 | v1.9 | two-family demo split | The demo splits into two independent sample-data families — demo-nyc (mobility) and demo-trade (trade/v1), each with its own compose profile, loader services, bootstrap datasources file and examples file; the app's bootstrap keys are now comma-separated lists and the seeder runs all templates before any pipelines. app.sh gains --demo-nyc/--demo-trade (the old --demo dies with a hint); the ON markers (SAMPLE_NYC_ON/SAMPLE_TRADE_ON) compose the active-family lists. The trade family adds the demo's fourth engine (DUCKDB, sample-trade-us — driver ships in core, read-only via the probed properties.jdbc.access_mode: READ_ONLY, datasources.md §8A.5). Appendix B rewritten for the two flags; the compose project is renamed deploy→dp (containers/volumes carry the dp- prefix; the metadata-DB volume was migrated, dp-postgres-data). |
| 2026-09-02 | v1.7 | 051 auth/config sweep | §6.2 gains datapipelines.auth.trusted-proxies (R8/T46): behind the LB the login limiter and every audit source_ip must resolve the client through the trusted-proxy list — empty default keeps bare deployments on remoteAddr, header ignored. §6.7's homepage note updated (T46 closed; no-limiter decision stands on its own grounds). §6.3 documents the compose env contract with its scripts/compose-env-audit.sh guard (T32). Appendix B tells the truth about the demo password (T47/T73): on a clean checkout the scaffolded GENERATED password wins over the demo-admin seed — grep DATAPIPELINES_AUTH_LOCAL_BOOTSTRAP_PASSWORD deploy/.env. app.sh's up --wait timeout now reports "still starting" and exits 0 when the app container is running (T75; a cold JVM can outrun the HEALTHCHECK window — 243s in the 2026-09-02 rehearsal) |
| 2026-09-02 | v1.9 | multi-instance round 2 (050) | §6.2 checklist gains the per-instance-limits row (R2/M4/M7): max-concurrent-executions-per-instance is per instance and N replicas admit N × it — the multiplication stated once, plainly; the Redis row names the datasource pool-invalidation channel. §6.6 heap paragraph rewritten around the per-instance multiplier (the old text read the key as a "cluster-wide ceiling" — false at N > 1, and the exact trap the rename closed); §5.2 deploy-time keys updated to the renamed key. |
| 2026-09-02 | v1.8 | demo artifact v2 + guards (049) | Appendix B gains "Publish confirmation & release rehearsal — the drift guards": scripts/sample-data/check-published.sh (published examples.json vs repo vs manifest, 049 C2) is now a named pre-upload / rehearsal step, and the repo copy's semantic validation is pinned in build (SampleDataExamplesContentTest, 049 C1) — the two guards T70 (published-v1 drift → first-login 500 ×2 days) proved missing. A v2 artifact is staged from unchanged pins with the licence gate re-stamped 2026-09-02; this row's commit is the held version bump — it moved the demo pin (SAMPLE_VERSION=v2) and every current-version citation, and merges only after the owner's upload is confirmed live (check-published.sh v2 against the bucket). |
| 2026-09-04 | v1.8 | SEO (073) | §6.7 rewritten for the widened public surface: /docs and /docs/{slug} are public (the viewer renders packaged Markdown and reaches no principal, workspace or datastore; the same content is public on GitHub), the intent-cluster pages join /, and /robots.txt + a generated /sitemap.xml ship. The allowlist bullet records exactly what was added. New operator note: Search Console and the sitemap — verify the domain by DNS TXT before the launch announcement, submit the sitemap once (it regenerates per request, so later releases need no resubmission), and why a self-hosted deployment emits datapipelines.co canonicals unless SITE_ORIGIN is changed. |
| 2026-09-01 | v1.7 | multi-instance readiness (036) | The stale-execution sweep now ships (ARCH-AUDIT M2), and the §6.2 crash bullet is true again. Every replica runs the idempotent sweep on a one-minute cadence (the project's first @Scheduled); RUNNING rows older than datapipelines.executions.stale-timeout-minutes are marked ABORTED with pipeline.execution.instance_lost. §8.3.2's SIGKILL-mid-flush consequence restored to match. Side effect: DELETE /executions/{id} on a crashed instance's stale row stops being a silent no-op — once swept, the row is terminal and the cancel is refused pipeline.execution.not_running instead of returning 204. |
| 2026-09-01 | v1.6 | multi-instance readiness (036) | The drain now ships (ARCH-AUDIT M1), and §8.3.1/§8.3.2 describe it. On SIGTERM: readiness flips to REFUSING_TRAFFIC first, every local execution is cancelled through the ordinary path (Statement.cancel() first, execution_aborted with reason: "shutdown", row ABORTED), the flush is awaited with a 20s bound, and server.shutdown: graceful lets in-flight requests end. The drain CANCELS rather than draining-to-completion — the v1.2 text's "drain up to execution-timeout-seconds" behavior is deliberately not what shipped; terminationGracePeriodSeconds: 30 (not 630) and preStop: sleep 5 are the matching pod settings, and the Helm chart carries both. The §6.2 crash bullet is still honest (the sweep lands next). |
| 2026-09-01 | v1.5 | multi-instance readiness (036) | Honesty fix (ARCH-AUDIT M11): the doc promised three things the code does not do. §8.3.1/§8.3.2 rewritten as shutdown behavior as shipped — no drain, no server.shutdown: graceful, no readiness flip, no sweep; the old drain-to-timeout/cancelAll(shutdown) sequence and the terminationGracePeriodSeconds: 630 + preStop guidance described unimplemented code. §6.2 instance-crash bullet no longer claims rows are "swept to ABORTED" (the sweep exists but has no caller). §5.2's "shutdown grace" gloss on execution-timeout-seconds removed. §6.4's deploy/helm/ reference made real: a minimal chart (Deployment, Service, optional HPA/PDB) now ships. The drain and sweep claims return with the code that implements them. |
| 2026-08-31 | v1.4 | website + docs in-app (033) | New §6.7: the app serves the marketing site (/, public) and the packaged spec set (/docs, session-only); the dashboard moved to /dashboard; the standalone website/ static deploy retires to the websiteExport cold-fallback procedure; public surface defended by cache headers, NOT the login rate limiter (OPEN-ITEMS T46). Header version corrected (v1.3's entry had not bumped it). |
| 2026-08-05 | v1.0 draft | initial draft | Initial deployment spec sketch — Docker image, infra requirements, configuration, deployment patterns, upgrade/rollback, security checklist |
| 2026-08-05 | v1.1 | horizontal scaling | Added multi-instance horizontal scaling section. Application is stateless for all CRUD/UI/MCP/auth. In-flight executions are instance-local (acceptable for short-running pipelines). No sticky sessions required. Added multi-instance checklist. Added LB idle-timeout + SSE heartbeat note. |
| 2026-08-07 | v1.2 | consistency campaign | Applied SPEC-REVIEW-2026-08 §2.15: §5 env-var tables replaced by the startup-requirements list + pointer to configuration.md; the inline-vs-claim-check threshold key (superseded by the D9 result keys) and every other key configuration.md does not define were deleted [D8]; §4.2 rewritten as the result store with required maxmemory-policy noeviction and a sizing model [D9]; §8.3.1/§8.3.2 graceful-shutdown mechanism (readiness fail → drain to execution-timeout-seconds → cancelAll(shutdown) → exit) with k8s preStop + terminationGracePeriodSeconds, accepted loss stated [D7]; §6.2 instance-local story updated to cancel-on-disconnect + cross-instance cancel via Redis flag [D7]; Appendix A compose made bootable (OIDC provider env vars, Redis password wired to requirepass, noeviction, mounted provider YAML); new §3.5 JDBC driver matrix (bundled vs -Poracle/-Pmysql vs lib/ drop-in); new §6.6 resource sizing (heap, container limit, -XX:MaxRAMPercentage); -Duser.timezone=UTC made normative in the image and bare-JVM entrypoints (Type System §8.4); §6.2 diagram residue and §11 malformed bullet fixed |
| 2026-08-29 | v1.3 | local password auth | §5.1 item 5 becomes "at least one authentication method": OIDC provider OR local accounts (auth.md §5A), with the operator's first-admin story for the no-IdP case (hash-seeded one-time credential, forced first-login change, admin resets — no SMTP, no self-registration). Appendix A compose comment and Appendix B quickstart updated: the demo now logs in with a local account (demo-admin@demo.local / demo-admin, one-time) and needs no OIDC client. |
| 2026-09-07 | v1.8 | 087 connector seams | §3.5 driver matrix gains LAKE — bundled, and the same duckdb_jdbc jar as DUCKDB. It is a distinct dialect rather than a mode because the two need opposite §5.6 postures (Datasources §4.1); nothing about packaging changes, since there is no second driver to ship. |
| 2026-09-08 | v1.17 | 089 dp-lake consuming side shipped | Appendix B's dp-lake section: the "that is round 089 / until it lands, the published objects are inert" paragraph rewritten as shipped — the lake demo family registers sample-lake from deploy/sample-data/bootstrap-datasources-lake.yml (which imports the published manifest's tables[] into the dp-catalog registry), and the seeder loads examples-lake.json exactly when sample-lake is present (the requires_datasources gate, environments.md §5). |