Module Structure Specification
Status: v1.8 (frozen contract — additive-only changes after this point) Owner: datapipelines.co core Depends on: (all other specs — this spec operationalizes them into code structure) Last updated: 2026-10-26
1. Purpose
This spec defines how the datapipelines.co codebase is organized into Gradle subprojects (modules), the dependency direction between them, the version catalog for pinned dependencies, and the build / test conventions that apply to every module.
A multi-module structure (vs. a single monolithic codebase) gives us:
- Clear ownership boundaries — each spec maps to one module.
- Independent testability — modules are unit-tested in isolation.
- Future artifact extraction —
typesystemcould be published as a client library;mcp-servercould be embedded in another product. - Build incrementality — change in
templatesdoesn't rebuilddag.
2. Design Principles
- One module per spec. Each major spec becomes a Gradle module. The spec is the contract; the module is the implementation.
- Layered dependencies, no cycles. Foundational modules (typesystem) depend on nothing internal. Higher-level modules depend on lower. The dependency graph is a DAG, like pipelines.
- Kotlin-first. Kotlin DSL for Gradle (
build.gradle.kts). Kotlin code for implementations. Java interop where libraries require it. - Version catalog. All third-party dependency versions pinned in
gradle/libs.versions.toml. No version literals in module build files. - Convention over configuration. Common build settings (Kotlin version, JVM target, test framework, linting) defined once in the root
build.gradle.kts, applied viacommon-conventionsplugin. - Test parallelism. Each module's tests run independently. Root
testaggregates results. Integration tests in a separate module to keep unit-test runs fast.
3. Module List
datapipelines/
├── settings.gradle.kts
├── build.gradle.kts # root build, applies conventions
├── gradle/
│ └── libs.versions.toml # version catalog
├── buildSrc/
│ └── src/main/kotlin/
│ ├── CommonConventionsPlugin.kt
│ └── KotlinConventionsPlugin.kt
├── modules/
│ ├── typesystem/ # [Type System spec]
│ ├── graph/ # the generic Dag<T> primitive, layer 0 (§5.17)
│ ├── persistence/ # the generic batching writer — ordered group commit, layer 0 (§5.19)
│ ├── calculators/ # [Calculators catalog] — pure kinds, layer 0 (§5.14)
│ ├── scripting/ # Script engine seam, layer 0 (§5.15)
│ ├── pipeline-contract/ # [Pipeline Contract spec]
│ ├── templates/ # [Templates spec]
│ ├── datasources/ # [Datasources spec]
│ ├── staging/ # [Staging spec]
│ ├── dag/ # [DAG Executor spec]
│ ├── parameters/ # the parameter engine — parameter sets, #194 (§5.18)
│ ├── visualization/ # visualizations and dashboards — the two documents and their lifecycle, #10 (§5.20)
│ ├── auth/ # [Auth spec]
│ ├── scheduler/ # [Scheduler reference] — durable occurrences, #9 (§5.16)
│ ├── application/ # cross-aggregate use cases (§5.13)
│ ├── mcp-server/ # [MCP Server spec]
│ ├── web/ # REST API, SSE, Thymeleaf UI
│ └── app/ # Spring Boot entry point, assembles everything
├── tests/
│ ├── integration-tests/ # cross-module integration tests
│ └── browser-tests/ # Playwright browser suite — runs in `build`/`check` like every module's `test` (`./gradlew browserTest` runs it alone)
└── docs/ # these specs
3.1 Module responsibility matrix
| Module | Spec | Responsibility | Owns persistence for |
|---|---|---|---|
typesystem |
type-system.md | The 11 canonical types, per-dialect mappers, H2 mapping, schema envelope. Foundation. | — (no persistence) |
graph |
dag-executor.md §3 (this spec, §5.17) | The generic DAG primitive — Dag<T>, DagBuilder<T> (topological order, cycle detection, reverse index). Stdlib only, no internal dependencies: moved out of dag (#194) so a module below the executor can build a graph without inheriting the executor's dependency set. Package co.datapipelines.dag, kept on purpose. |
— (no persistence) |
persistence |
DAG Executor §10, Auth §10 (this spec, §5.19) | The generic batching writer (#266) — BatchingWriter<T>: an ordered, bounded, partitioned group commit in front of ONE store, with a bounded wait that falls back to the caller's own write, a singles retry that isolates a poison row, and a bounded shutdown drain. Knows no store: each supplies a BatchSink. Stdlib, coroutines and slf4j only. |
— (writes whatever its sinks write; owns no table) |
scripting |
(this spec, §5.15) | The script engine seam: ScriptEngine (JSONata in-process; JavaScript in a GraalJS isolate, #8), one registry wired in web and injected into the validator, test runner and node support, the evaluation pool, the type gate, canonical JSON. Pure library — evaluates untrusted script bodies with no I/O surface. |
— (no persistence) |
pipeline-contract |
pipeline-contract.md | Pipeline JSON model, validation, ExecutionContext type. | PipelineRepository → pipelines, pipeline_versions |
templates |
templates.md | Freemarker integration, library macros, template registry, versioning. | TemplateRepository → templates, template_versions |
datasources |
datasources.md | Connection registry, HikariCP pools, dialect adapters, credential encryption. | DatasourceRepository → datasources |
staging |
staging.md | H2 lifecycle, staging interface, type-aware batch inserts. | — (tempdb is per-execution, never persisted) |
dag |
dag-executor.md | DAG data structure, executor (coroutines), node runner, SSE event emitter interface, Redis-backed result store. | ExecutionRepository → pipeline_executions; ExecutionEventRepository → execution_events; Redis keys for results / idempotency / cancel flags |
auth |
auth.md | Users, workspaces + membership (resolution, provisioning, CRUD/member rules — 019's recorded placement: the workspace is an identity concept, membership-checked on every authenticated request exactly like users), API keys, JWT sessions, scopes, audit log. |
UserRepository → users; WorkspaceRepository → workspaces, workspace_members (metadata-db §4.11/§4.12); ApiKeyRepository → api_keys; AuditLogger → audit_log |
scheduler |
scheduler.md | The pipeline-agnostic scheduler (#9): schedules, their occurrence function, the one dispatcher over db-scheduler, runs and their append-only trail, the generic executor port, capacity admission and reconciliation. Knows no pipeline semantics — the executor adapter lives in web (§5.16). |
ScheduleRepository → schedules; ScheduleRunRepository → schedule_runs, schedule_run_events; db-scheduler owns scheduled_tasks (created by app's V38) |
parameters |
parameter-engine design record (this spec, §5.18) | The parameter engine (#194): the parameter-set definition model and its strict binding, the save-time validator (the record's §4), the expression AST, the set's dependency graph (over graph's Dag<T>), the versioned lifecycle, the parameter.* codes and datapipelines.parameters.*. A single-aggregate module: the selector runtime arrives in lane C, the surfaces and the templates reverse arrow in lane D (application). |
ParameterSetRepository → parameter_sets, parameter_set_versions (created by app's V39) |
visualization |
dashboards.md, the dashboard implementation spec (this spec, §5.20) | Visualizations and dashboards (#10): the two documents and their strict binding, the save-time validators, the name grammars, the versioned lifecycle (draft/release with the two cascades, discard, restore, purge, switch, import), the transfer service (export/import envelopes), the visualization.*/dashboard.* codes and datapipelines.visualization.*. A two-aggregate module whose cross-aggregate facts — a pinned pipeline release, a pinned set's parameters, a script's contract, the release evidence — arrive through ports it declares. |
VisualizationRepository → visualizations, visualization_versions; DashboardRepository → dashboards, dashboard_versions (created by app's V42, with the two test-evidence tables L4 writes) |
application |
(this spec, §5.13) | Cross-aggregate use cases — the ones that need more than one domain module and so belong to none of them. Sits below web and mcp-server so both surfaces share one implementation (ARCH-AUDIT-2026-08 S4, ruling R6). |
— (delegates to the owning modules' repositories) |
mcp-server |
mcp-server.md | MCP transport (Streamable HTTP), tool/resource/prompt definitions. Thin adapter over the same services the REST layer uses. | — (delegates to the owning modules' repositories) |
web |
rest-api.md | Spring Boot REST controllers, SSE endpoints, Thymeleaf UI, error handling, CORS. | — (delegates); Redis keys for the post-completion SSE event log and per-user rate-limit counters |
app |
(this spec) | Spring Boot main(), assembles all modules, configuration, runnable JAR, Flyway dependency + migration scripts. |
Owns schema creation (Flyway), not data access |
tests/integration-tests |
(this spec) | Cross-module integration tests (Testcontainers for real databases). | — |
tests/browser-tests |
(this spec §5.12) | Mechanical browser E2E of the UI golden paths (Playwright for Java, chromium-only). Runs in build/check like every module's test; ./gradlew browserTest invokes it ALONE. Only siteShots (a demo deployment needed) is outside build/check. |
— |
Persistence ownership rule (normative)
- Repositories live in their owning domain module, never in a shared
persistencemodule and never inweb. Each owning module takesorg.springframework:spring-jdbc(viaspring-boot-starter-jdbc) and usesNamedParameterJdbcTemplateper §8.1. - The schema is owned by one module:
app. The Flyway dependency (flyway-core+flyway-database-postgresql) and every migration script undersrc/main/resources/db/migration/live inapponly. Domain modules read and write tables; they never create or alter them. DDL authority is Metadata DB §4; the migration file layout is Metadata DB §7.1. - Redis is a dependency of exactly two modules:
dag(result store, idempotency keys, cancellation flags) andweb(post-completion SSE event log, per-user rate-limit counters, and — since 050 — the datasource pool-invalidation pub/sub channel, the one Redis message beside all the Redis state). Both takespring-boot-starter-data-redis(Lettuce client, the starter's default). No other module talks to Redis. - The
DataSourcebean for the metadata DB is a single app-level bean (Spring Boot autoconfiguration from thespring.datasource.*keys defined in configuration.md §3). Modules injectNamedParameterJdbcTemplate, never construct their own pool. This is distinct from the user datasource pools owned byConnectionPoolManagerin thedatasourcesmodule.
4. Dependency Direction
4.1 Layered dependency graph
This diagram is a rendering of the normative table in §4.2 — it carries no information the table does not. When the two disagree, §4.2 wins and the diagram is the bug. verifyModuleDependencies checks that every module the table names appears below; edge fidelity (the ← lists) is a human read against the table, because ASCII arrows cannot be parsed honestly.
layer 0 — no internal deps
┌──────────────┐ ┌─────────┐ ┌───────────────┐
│ typesystem │ │ graph │ │ persistence │
└──────────────┘ └─────────┘ └───────────────┘
layer 1 — typesystem (auth also persistence)
┌──────────────┐ ┌─────────────┐ ┌──────────────┐ ┌───────────┐ ┌────────┐
│ calculators │ │ scripting │ │ datasources │ │ staging │ │ auth │ ← typesystem
└──────────────┘ └─────────────┘ └──────────────┘ └───────────┘ └────────┘ auth ← typesystem, pipeline-contract (the SearchNeedle rule only), persistence
layer 2
┌───────────────────┐
│ pipeline-contract │ ← typesystem, calculators
└───────────────────┘
layer 3
┌──────────────┐ ┌─────────────┐
│ templates │ │ scheduler │ templates ← typesystem, pipeline-contract, scripting
└──────────────┘ └─────────────┘ scheduler ← typesystem, pipeline-contract (the name grammar only)
layer 4
┌──────────────┐ ┌──────────────┐
│ dag │ │ parameters │ dag ← typesystem, calculators, pipeline-contract, templates,
│ (executor) │ │ │ datasources, staging, scripting, graph, persistence
└──────────────┘ └──────────────┘ parameters ← typesystem, graph, pipeline-contract, templates, datasources
layer 5
┌─────────────────┐
│ visualization │ ← typesystem, pipeline-contract, templates, parameters
└─────────────────┘
layer 6
┌──────────────┐
│ application │ ← typesystem, scripting, pipeline-contract, templates,
│ (use cases) │ datasources, dag, auth, parameters, visualization
└──────────────┘
layer 7
┌──────────────┐
│ mcp-server │ ← typesystem, calculators, pipeline-contract, templates,
│ │ datasources, dag, auth, application, parameters, visualization
└──────────────┘
layer 8
┌──────────────┐
│ web │ ← typesystem, calculators, scripting, pipeline-contract, templates,
│ │ datasources, staging, dag, auth, application, mcp-server, scheduler,
│ │ parameters, persistence, visualization
│ │ (declared explicitly, not transitively)
└──────────────┘
layer 9
┌──────────────┐
│ app │ ← web only
└──────────────┘
layer 10 — the test suites
┌─────────────────────────┐ ┌─────────────────────┐
│ tests/integration-tests │ │ tests/browser-tests │ ← app (browser-tests: + web)
└─────────────────────────┘ └─────────────────────┘
4.2 The dependency rule (machine-checkable)
There is one layering rule, and it is a table lookup, not a judgment call:
A module's
dependencies { implementation(project(...)) }block MUST list a subset of its row below, and every module it uses at compile time MUST be listed explicitly (no reliance on transitiveapileakage).
| Module | Allowed internal dependencies (exhaustive) |
|---|---|
typesystem |
(none) |
graph |
(none) |
persistence |
(none) |
calculators |
typesystem |
scripting |
typesystem |
pipeline-contract |
typesystem, calculators |
templates |
typesystem, pipeline-contract, scripting |
datasources |
typesystem, persistence |
staging |
typesystem |
auth |
typesystem, pipeline-contract, persistence |
scheduler |
typesystem, pipeline-contract |
dag |
typesystem, calculators, pipeline-contract, templates, datasources, staging, scripting, graph, persistence |
parameters |
typesystem, graph, pipeline-contract, templates, datasources |
visualization |
typesystem, pipeline-contract, templates, parameters |
application |
typesystem, scripting, pipeline-contract, templates, datasources, dag, auth, parameters, visualization |
mcp-server |
typesystem, calculators, pipeline-contract, templates, datasources, dag, auth, application, parameters, visualization |
web |
typesystem, calculators, scripting, pipeline-contract, templates, datasources, staging, dag, auth, application, mcp-server, scheduler, parameters, persistence, visualization |
app |
web |
tests/integration-tests |
app |
tests/browser-tests |
app, web |
Notes on the shape (explanatory, not additional rules):
- The table is acyclic by construction, so "no cycles" needs no separate rule — Gradle enforces it anyway.
calculators' row is the shortest one in the table on purpose (072, calculators design §0.4/C12). A calculator kind is a pure function of its inputs; a row that admitteddatasourcesordagwould make that a hope rather than a fact, and the executor's freedom to evaluate a kind anywhere, in any order, rests on it. Adding an entry to that row is the edit a reviewer must refuse.schedulerlistspipeline-contractfor exactly ONE thing, the publishedPipelineNameGrammar(a schedule is named like a pipeline — scheduler design revision §6, A2); itsSchedulerBoundaryTestfails on any otherco.datapipelines.pipelineimport, so the edge cannot quietly become pipeline knowledge. It lists nodag,authorapplication: the executor adapter, the capacity lease and the system principal live onweb's side of its port.auth(#504b) listspipeline-contractfor exactly ONE thing, the shared search-needle rule —SearchNeedleand its typed refusal, the same rule every search-owning service applies at its door; itsAuthBoundaryTestfails on any otherco.datapipelines.pipelineimport, so the edge cannot quietly become pipeline knowledge.parameters(#194) declares all five of its edges —typesystem(the shared value validator,LogicalType),graph(Dag<T>),pipeline-contract(TemplateRef, the name grammars, theTemplateDryRenderer/DatasourceRegistry/TemplateVersionStatuses/TemplateReleaserports it validates and releases through,ReadLens,AuthoringGuard), and since lane Ctemplates(the selector runtime renders a pinned template throughWorkspaceTemplateEngines) anddatasources(it runs through the registry's pools,ReadOnlyStatementLeaseandResultRowReader). Since lane D theparametersedges ofapplication,mcp-serverandwebare DECLARED (the record's §2.4): the surfaces compile against the engine, the reverse-arrow composition inapplication, the six tools inmcp-server, the routes and the wiring inweb.persistence(#266) is the second empty row besidegraph, for the same reason: it is a primitive every store-owning module can sit on.authlists it for the audit log's writer andwebfor the execution-event record's and the replay log's;dagdoes NOT — its repository offers the batch statement (ExecutionEventRepository.appendAll) and knows nothing of the queue in front of it, which isweb's wiring.visualization(#10) sits aboveparametersand belowapplication: it declarestypesystem,pipeline-contract(the name grammar,ReadLens,AuthoringGuard, the template ports it validates and releases through) andparameters(the productionParameterSetFactsreads a pinned set release throughParameterSetRepository).templatesis allowed and NOT declared — every template fact reaches it throughpipeline-contract's ports. The pipeline facts a dashboard needs (released? read-only? the caller output columns) arrive through itsPipelineReleaseFactsport, whichapplicationimplements overReadOnlyPipelineRule(L1b), so nodagedge exists. Since L1b thevisualizationedges ofapplication(PipelineReleaseFactsReader, that port's implementation),mcp-server(the elevenvisualizations_*/dashboards_*tools) andweb(the two controllers andVisualizationConfiguration, the module's only Spring wiring) are DECLARED.dagdoes not listauth: the executor is handed an already-authenticated principal by its caller.mcp-serverdoes listauth(it authenticates its own transport, MCP Server §3.2) anddag(thepipelines_execute/executions_*tools drive the executor directly rather than looping back through HTTP).weblists everything it touches explicitly. It could reach most of these transitively throughmcp-server; declaring them is what makes the table checkable.applicationis where a use case goes when it needs MORE THAN ONE aggregate. The rule, in one sentence: cross-aggregate use cases live inapplication; single-aggregate ones live with the aggregate that owns them.PipelineServiceis therefore inpipeline-contract, andExecutionLauncher— which needs the pipeline aggregate ANDdag's reservation store — is inapplication. Nothing inapplicationmay import awebormcptype;ArchitectureGuardTestfails the build on one.applistswebonly. It containsmain(), configuration (including the singlemetadataTransactionManager, §8.5), logback config, and the Flyway migrations (§3.1) — no domain code.- A module may use
api(project(...))instead ofimplementation(...)only where its own public API exposes the other module's types (e.g.pipeline-contractexposesColumnSchemafromtypesystem). The allowed-set is the same either way.
Enforcement: a Gradle verification task compares each subproject's declared project dependencies against this table and fails the build on any extra entry. Adding an edge means editing this table first — that is the review gate.
4.3 Cross-cutting concerns
Some concerns touch every module:
- Logging — SLF4J + Logback (or structured logging via
minlog/logstash-logback-encoder). Each module logs via SLF4J API; the actual logback config lives inapp. - Error handling — every module's exceptions extend a base
DatapipelinesException, which lives intypesystem. There is deliberately nocommonmodule: the §4.2 table is exhaustive, and a catch-all module is where layering rules go to die. - Configuration — typed config classes per module, composed into the global
appconfig. - Metrics — Micrometer API in modules, actual metrics registry configured in
app.
5. Module Specs (Detailed)
5.1 typesystem
Dependencies (internal): none.
Dependencies (external): com.fasterxml.jackson.module:jackson-module-kotlin (JSON for the schema envelope). Jackson is the project-wide JSON library — the @JsonValue / @JsonCreator enum mapping in Enums §1 is normative, so there is no second serialization stack.
Public API:
LogicalTypeenumColumnSchemadata classIngressTypeMapperinterface + per-dialect implementations (PostgresTypeMapper,OracleTypeMapper,MssqlTypeMapper,MysqlTypeMapper,H2IngressMapper,DuckDbTypeMapper,SqliteTypeMapper)H2IngressMapper(H2 JDBC metadata → canonicalColumnSchema) andH2EgressMapper(canonical → H2 DDL type string +java.sql.Typescode) — two objects, not inverses; signatures in Staging §5.3JsonEncoder(canonical value → wire representation)SchemaEnvelopedata class
Tests: unit tests for every mapper; round-trip tests for every type.
5.2 pipeline-contract
Dependencies (internal): typesystem.
Dependencies (external):
com.fasterxml.jackson.module:jackson-module-kotlin— Pipeline JSON ser/deser (Pipeline Contract §17.1 specifies Jackson).org.springframework.boot:spring-boot-starter-jdbc(bringsorg.springframework:spring-jdbc) — forPipelineRepository.
Public API:
Pipelinedata class (top-level entity)Node,NodeType,NodeSourcedata classesNodeOutput— flat sealed interface (Tempdb,Caller,Datasource)PipelineSettings(with nestedTempdbSettings)Parameterdata classTemplateRefdata class ({id, version})PipelineValidator— runs all §12 validations from the specPipelineSerializer/PipelineDeserializer(Jackson; an omittedoutputon a DQL node deserializes toNodeOutput.Caller)CallerNodeResolver— resolves the caller node per Pipeline Contract §9. Replaces topology-based terminal-node detection, which no longer exists in any form (D1).ExecutionContext— runtime mutable mapPipelineRepository—NamedParameterJdbcTemplateaccess topipelines/pipeline_versions(§8.1)
Tests: unit tests for validator (every check + every code path); serialization round-trip tests; PipelineRepository integration tests against a Postgres Testcontainer.
5.3 templates
Dependencies (internal): typesystem, pipeline-contract (for Parameter shape), scripting (script bodies parse and evaluate through its seam, never through Freemarker — transform-nodes design §2.1, D-T9).
Dependencies (external):
org.freemarker:freemarker(pinned).org.springframework.boot:spring-boot-starter-jdbc— forTemplateRepository.
Public API:
Template,TemplateVersiondata classesTemplateImportdata class ({id, version, alias}) — D12TemplateRegistryinterface — lookup byid@version, cachingRegistryTemplateLoader— the FreemarkerTemplateLoader; resolves only"{id}@{version}"keys against the registryTemplateEngine— wraps Freemarker; owns the render guards (Templates §4.3)TemplateValidatorLibraryResolver— transitive import resolution (depth cap, cycle detection, alias uniqueness)TemplateRepository—NamedParameterJdbcTemplateaccess totemplates/template_versions(§8.1)
The module's public API carries no parameter-schema types. The template entity's parameter-schema field was removed entirely (D3) — pipeline parameters is the single declaration point, so there is no ParamsSchema, ParamSpec, or equivalent type here. Matches Templates §12.1.
Tests: unit tests for validator; round-trip tests for sample templates; security tests for forbidden-construct rejection; TemplateRepository integration tests against a Postgres Testcontainer.
5.4 datasources
Dependencies (internal): typesystem (for IngressTypeMapper per dialect).
Dependencies (external):
com.zaxxer:HikariCP— connection pooling.org.postgresql:postgresql— bundled PG driver.com.microsoft.sqlserver:mssql-jdbc— bundled MSSQL driver.com.h2database:h2— bundled H2 driver (also for staging).org.duckdb:duckdb_jdbc— bundled DuckDB driver.org.xerial:sqlite-jdbc— bundled SQLite driver.org.springframework.boot:spring-boot-starter-jdbc— forDatasourceRegistry'sDatasourceRepository.
BouncyCastle removed (2026-08-07, security review MEDIUM-6). The AES-256-GCM the credential store needs (Datasources §7) is fully served by the JDK's SunJCE (
AES/GCM/NoPadding); no spec names a primitive requiring an external provider, and carrying an 8 MB provider with a steady advisory cadence for an unnamed capability fails the dependency rules. If a future implementation genuinely needs one, it comes back through the spec-deviation flow with the primitive named.
- Optional:
com.oracle.database.jdbc:ojdbc11(via-PoracleGradle property). - Optional:
com.mysql:mysql-connector-j(via-PmysqlGradle property).
Public API:
Datasourcedata classDialectenum — declared intypesystem(2026-08-08: the single authoring authority is Type System §5 per enums.md §5, andTypeMappers.forDialect(dialect: Dialect)lives in typesystem, which depends on nothing internal — so the type cannot be declared here). This module consumes and re-exposes it through its typesystem dependency.DatasourceRegistryinterfaceDialectAdapterinterface + per-dialect implementationsJdbcDrivers— driver class lookup / availability checkCredentialEncryptor— AES-256-GCMConnectionPoolManager— HikariCP wrapper for user datasources (distinct from the metadata-DB pool, §3.1 rule 4)ConnectionPool.discardandReadOnlyStatementLease/LeasedStatement(#194 lane C) — a connection that must never return to its pool (HikariCP'sevictConnection), and the lease the parameter engine's selector runner reads through: the §7D gate before any lease, the clamped statement timeout, and anabandon()(cancel, then discard) callable from another thread (Datasources §5.3)DatasourceRepository—NamedParameterJdbcTemplateaccess todatasources(§8.1)
5.4.1 Optional driver profiles — implementation sketch
Both optional drivers are runtimeOnly (nothing compiles against them; JdbcDrivers resolves them reflectively by class name — Datasources §10.3). They are gated on Gradle project properties, not Gradle profiles (Gradle has no profiles):
// modules/datasources/build.gradle.kts
dependencies {
implementation(project(":modules:typesystem"))
implementation(libs.hikaricp)
implementation(libs.spring.boot.starter.jdbc)
runtimeOnly(libs.postgresql)
runtimeOnly(libs.mssql.jdbc)
runtimeOnly(libs.h2)
runtimeOnly(libs.duckdb.jdbc)
runtimeOnly(libs.sqlite.jdbc)
// ./gradlew -Poracle build → OTN-licensed driver bundled (operator accepts the licence)
if (project.hasProperty("oracle")) {
runtimeOnly(libs.ojdbc11)
}
// ./gradlew -Pmysql build → GPL+FOSS-exception driver bundled
if (project.hasProperty("mysql")) {
runtimeOnly(libs.mysql.connector.j)
}
}
Two consequences worth stating, because both are easy to get wrong:
- The flag must be passed to every task that builds the artifact.
-Poracleonbuildand not onbootJarproduces a JAR without the driver. CI publishes the optional variants as separate jobs (./gradlew -Poracle bootJar), never as a post-hoc patch of the default JAR. - Property presence, not value, is the switch.
-Poracle=falsestill enables it (hasPropertyis true). Documented here so nobody "disables" it that way.
lib/ drop-in (no rebuild). The licence-clean alternative is deploy-time: the operator drops ojdbc11.jar into a lib/ directory beside the application JAR. This is not automatic — the default JarLauncher only reads BOOT-INF/lib/. Extra classpath entries require PropertiesLauncher, selected by the JAR manifest:
// modules/app/build.gradle.kts
tasks.named<BootJar>("bootJar") {
// PropertiesLauncher is the only launcher that honours loader.path.
manifest {
attributes("Main-Class" to "org.springframework.boot.loader.launch.PropertiesLauncher")
}
}
tasks.named<BootRun>("bootRun") {
classpath += files("lib") // dev parity: same drop-in directory, no packaging
}
At runtime the operator points the launcher at the directory with loader.path — a comma-separated list of directories, archives, or directories within archives, resolved relative to loader.home (default: the process working directory). It can be supplied as a system property, the LOADER_PATH environment variable, or a loader.properties file:
java -Dloader.path=lib -jar datapipelines-app.jar
# or, in the container image (absolute: the image sets no WORKDIR, so a relative entry would resolve against /;
# the second entry is the JavaScript isolate's directory, §5.15):
ENV LOADER_PATH=/app/lib,/opt/graaljs
The deployment image therefore ships an empty, read-only /app/lib (root:root, mode 0555, created before the image drops to its app user) and sets LOADER_PATH=/app/lib,/opt/graaljs — the drop-in directory, then the directory holding the image's JavaScript isolate jar (§5.15) — so a drop-in works with no rebuild and no re-configuration (Deployment §6 owns the image contents; the operator mounts the driver jar read-only there, Deployment §3.5). The path is absolute because loader.home defaults to the working directory and the image sets no WORKDIR: LOADER_PATH=lib would put /lib, the OS library directory, on the classpath. The two directories are one list in one ENV, never a -Dloader.path beside it: PropertiesLauncher reads the system property before the environment, so a -Dloader.path naming only the isolate would silently drop the driver drop-in (measured in #8 lane 8a). ImageLoaderPathSpecDriftTest pins the Dockerfile and this section to the same value.
The wiring lives in app (it owns the runnable artifact) even though the drivers are a datasources concern. A driver that is neither bundled nor dropped in fails datasource save with datasource.driver_not_loaded — see Datasources §10 for the licensing rationale and the full driver matrix.
Tests: unit tests for adapter URL validation; integration tests via Testcontainers (real DB containers); a build-level check that -Poracle bootJar contains ojdbc11 and the default bootJar does not.
5.5 staging
Dependencies (internal): typesystem.
Dependencies (external):
com.h2database:h2org.jetbrains.kotlinx:kotlinx-coroutines-core— pool admission is a coroutineSemaphoreand every staging operation issuspend(Staging §9.2).
Public API:
StaginginterfaceStagingFactoryinterface —create(executionId, engine: StagingEngine = H2)H2Staging,H2StagingFactoryimplementationsStageResult(columns: List<ColumnSchema>),StagingStatsdata classes
No repository: tempdb lives and dies with one execution and is never persisted (§3.1).
Tests: unit tests for type mapping; integration tests for staging round-trip; streaming tests for memory-bounded behavior.
5.6 dag
Dependencies (internal): pipeline-contract, templates, datasources, staging, typesystem, scripting, graph, persistence (#321 — FailureShape for the two scheduled jobs' failure lines; #326 — ScheduledJob, their failed-tick counter's name and closed tag set; nothing else; §4.2 note); calculators allowed and not declared.
Dependencies (external):
org.jetbrains.kotlinx:kotlinx-coroutines-coreorg.springframework.boot:spring-boot-starter-data-redis(Lettuce, the starter's default client) — result store, idempotency keys, cancellation flagsorg.springframework.boot:spring-boot-starter-jdbc— for the execution repositoriesio.micrometer:micrometer-core— executor metrics (DAG Executor §15.3)
Public API:
PipelineExecutor— built onDag<T>, which lives ingraphsince #194 (§5.17; same package,co.datapipelines.dag)ExecutableNode,NodeSource,NodeTypeNodeResult— the executor's internal in-flight per-node value (§7.1); carriescallerResultRef, a Redis key, never a liveResultSetNodeStats,NodeStatus— the wire-facing projection ofNodeResultCancellationRegistry,CancellationHandle— per-nodeStatementregistration and cancel (§8.3.1)ExecutionAbortedException,AbortReason(CLIENT_DISCONNECT,CANCELLED,SHUTDOWN) —ExecutionAbortedExceptionextendsCancellationException, maps to no error codeResultStore— Redis-backed caller-result materialization + paging cursor (D9)ExecutionSlots— per-user + global concurrency permitsExecutorDispatcher— the module's own bounded IO dispatcher (executor code never touchesDispatchers.IO)EventEmitterinterfaceExecutionEventsealed classExecutionRepository—pipeline_executions;ExecutionEventRepository—execution_events(durable 7-day record; the 1h Redis event log isweb's, §5.9)StaleExecutionSweeper— the crash sweep's idempotentUPDATE(safe for every replica to run; no leader election by design). Its scheduling lives inweb(§5.9):web'sSweepSchedulingConfigurationis the project's one@EnableScheduling/@Scheduledsurface, introduced 2026-09-01 (036), and — since #316 — declares the scheduled jobs' own scheduler,taskScheduler: ONE daemon thread,dp-scheduled, that every@Scheduledjob ticks on and nothing else uses. (Until #316 there was none, and Spring ran every job on the context's uniqueScheduledExecutorService— the SSE log streamer'sdp-sse-logthread, which serves replays.) Before 036, "no@Scheduled/Quartz/cron anywhere" was a verified property of the codebase — any NEW scheduled job is a lifecycle-surface change and belongs on this record.BoundedJdbc— (#326) the one construction of a JDBC template whose every statement carries aqueryTimeout(#311's lifecycle writes, the stale sweep's UPDATE, the retention's statements;webhands the auth jobs a template it built here, becausepersistenceholds no JDBC, §5.19)ExecutionEventRetention— theexecution_eventsretention job (050/T60): one idempotentDELETEpast the retention window, safe for every replica, never touchingpipeline_executions. Scheduled byweb'sRetentionSchedulingConfiguration(@Scheduled, fixed-delay 1h, on the jobs' single-thread scheduler,dp-scheduled— still exactly ONE@EnableScheduling).
Tests: unit tests for executor (mocked dependencies) — the Dag<T> algorithm tests moved to graph with the class; cancellation tests covering all three AbortReason paths incl. the cross-instance Redis flag; integration tests with real H2 + Testcontainers sources + a Redis container.
5.7 auth
Dependencies (internal): typesystem (shared exception base only), pipeline-contract (#504b — the shared SearchNeedle rule, §4.2; boundary-tested to that one use), persistence (#266 — the audit log's batching writer, §5.19).
Dependencies (external):
de.mkammerer:argon2-jvmio.jsonwebtoken:jjwt-api,jjwt-impl,jjwt-jacksonorg.springframework.boot:spring-boot-starter-oauth2-client(Spring Security web/config + OAuth2 client + Jose, per Auth §12.2 — which correctly lists no BouncyCastle; see the §5.4 removal note)org.springframework.boot:spring-boot-starter-jdbc— for the user / key / audit repositories
Public API:
User,ApiKey,AuthenticatedPrincipal,Workspace,WorkspaceMembership,WorkspaceMemberRow,WorkspaceContextdata classes;WorkspaceRole,WorkspaceProvisioningModeenums;WorkspacesProperties(configuration §3.17)ScopeenumJwtService,ApiKeyService,UserService,WorkspaceServiceOidcSuccessHandler,JwtAuthenticationFilter,ApiKeyFilter,SecurityConfig@RequiredScopeannotation +ScopeInterceptorAuditLogger— writesaudit_log, through its batching writer outside a transaction and directly inside one (Auth §10);PersistenceProperties—datapipelines.persistence.*(Configuration §3.32), read by every writer the application buildsAuditLogRetention— deletesaudit_logrows pastdatapipelines.audit.retention-days(#310, metadata-db §8.2): one database-clock cutoff per tick, bounded batches; scheduled as the retention sweep's last step byweb'sRetentionSchedulingConfiguration(no new@Scheduled).AuditPropertiesbinds the key (configuration §3.12; 30–3650, refused at bind)UserRepository—users;ApiKeyRepository—api_keys(§8.1)
The per-request is_active / revocation re-check (D13) reads through the same 60s cache as the key-hash lookup; the cache is owned by this module and is in-process per instance, not Redis — it is a read-through cache of Postgres truth, not shared state (Auth §11.4).
Tests: unit tests for each component; integration tests for full auth flow; repository integration tests against a Postgres Testcontainer.
5.8 mcp-server
Dependencies (internal): typesystem, pipeline-contract, templates, datasources, dag, auth.
dag is a real dependency, not an accident of layering: pipelines_execute and the executions_* tools drive PipelineExecutor and ResultStore directly. mcp-server is a thin adapter over the same service layer the REST controllers use — it never loops back through HTTP, and it must not (that would make web a dependency and create a cycle).
Dependencies (external):
io.modelcontextprotocol.sdk:mcp-core2.0.0 andio.modelcontextprotocol.sdk:mcp-json-jackson22.0.0.
Gate G1 — RESOLVED 2026-08-07 (verified by downloading and inspecting the published jars, not docs). The earlier draft's
io.modelcontextprotocol:mcp-coregroup id was indeed wrong — the real group isio.modelcontextprotocol.sdk. Facts that bind themcp-serverimplementation:
mcp-core-2.0.0ships plain Jakarta-servlet Streamable HTTP transports (HttpServletStreamableServerTransportProvider,HttpServletStatelessServerTransport) — drop onto Spring MVC directly. The stateless variant matches MCP Server §3.3.- Use
mcp-json-jackson2, never themcpaggregator — the aggregator pullsmcp-json-jackson3(Jackson 3.x) onto a Jackson 2.x classpath.mcp-spring-webmvcstopped at 0.18.3 (two majors stale) — do not use it. The Ktor-basedio.modelcontextprotocol:kotlin-sdkwas rejected (second HTTP stack).- Protocol versions compiled into 2.0.0:
2024-11-05,2025-03-26,2025-06-18,2025-11-25. Which to advertise remains the MCP Server §3.1 gate.
Public API:
McpServer— Spring Boot autoconfigurationMcpTool,McpResource,McpPromptannotation markers- Tool/resource/prompt implementations (one per top-level entity operation)
McpAuthFilter— translates API key into MCP session
Tests: unit tests for tool dispatch; integration tests using an in-process MCP client.
5.9 web
Dependencies (internal): typesystem, pipeline-contract, templates, datasources, staging, dag, auth, mcp-server — the aggregation layer, declared explicitly per §4.2.
Dependencies (external):
org.springframework.boot:spring-boot-starter-weborg.springframework.boot:spring-boot-starter-thymeleaforg.springframework.boot:spring-boot-starter-validationorg.springframework.boot:spring-boot-starter-data-redis— post-completion SSE event log (1h), per-user rate-limit counters, and thedp:datasource-invalidatedpub/sub channel (050 §5.7). The durable 7-day event record isdag'sExecutionEventRepository; these are two different stores for two different retention windows (D9).io.micrometer:micrometer-corecom.fasterxml.jackson.module:jackson-module-kotlin
Vendored front-end libraries: htmx 2.0.10, Alpine 3.14.1, Cytoscape 3.34.0 (+ dagre
and node-html-label), dagre 0.8.5 — all static files under static/vendor/, none of them
a build dependency. htmx was org.webjars.npm:htmx.org until 096 §B; a webjar puts a
/webjars/ glob on the public allowlist over a namespace the dependency graph fills, so it
was vendored like the rest (VendoredHtmxAuditTest pins the bytes and the licence).
Styling foundation: @acme/design-tokens (v0.2.0+) — vendored CSS-only design system. NOT an npm dependency; vendored as static files under static/vendor/design-system/. Provides semantic tokens (--surface-*, --text-*, --accent-*), 9 swappable themes (default: saas), and ~80 .ds-* primitive component classes. All UI colors, spacing, typography, shadows, and radii reference design system tokens. No hardcoded hex values anywhere. See Pipeline Editor spec §3.4 for integration details.
Public API:
RestControllers — REST endpoints (see REST API spec)Controllers — Thymeleaf UI controllers- SSE endpoints (via Spring WebFlux's
FluxorSseEmitter) - Global exception handler (
@RestControllerAdvice) - CORS filter
- Design system CSS vendoring + theme configuration (
DATAPIPELINES_UI_THEMEenv var, default:saas)
Tests: controller unit tests (@WebMvcTest); SSE flow tests; full integration tests in tests/integration-tests.
5.10 app
Dependencies (internal): web (transitively pulls everything).
Dependencies (external):
org.springframework.boot:spring-boot-starterorg.springframework.boot:spring-boot-starter-web— serves the root-level/healthand/readyprobes (rest-api.md §11), which lived here from P0 (added 2026-08-07; if the probe controller moves toweb, this dependency reverts with it — the §4.2 internal table is unaffected either way).org.springframework.boot:spring-boot-starter-actuatororg.flywaydb:flyway-core+org.flywaydb:flyway-database-postgresql— schema migration. This module is the only one that depends on Flyway (§3.1 rule 2). The Postgres module is a separate artifact since Flyway 10 and is required for a Postgres target.org.postgresql:postgresql— the metadata-DB driver at runtime (also bundled bydatasourcesfor user datasources; the version catalog pins it once).
Public API:
DatapipelinesApplication.kt—@SpringBootApplication main()application.yml— top-level configlogback-spring.xml— logging configsrc/main/resources/db/migration/V*.sql— the Flyway migrations, generated from Metadata DB §4 per §7.1bootJarmanifest wiring for thelib/driver drop-in (§5.4.1)
Tests: smoke tests via @SpringBootTest (full context load); a migration test that runs Flyway against a clean Postgres Testcontainer and asserts the resulting schema matches Metadata DB §4.
5.11 tests/integration-tests
Dependencies (internal): app (full app context for end-to-end tests).
Dependencies (external):
org.springframework.boot:spring-boot-starter-testorg.testcontainers:postgresql,mysql,mssql,oracle-xe— one container module per supported dialect that needs a real server. DuckDB, SQLite, H2 and LAKE are embedded (no container; LAKE's suite uses a MinIO object-storage container instead). There is no DB2 container — DB2 is not a supported dialect (Type System §5 / Datasources §4.1 list the eight).- A Redis container (
org.testcontainers:testcontainersgeneric container, or the Redis module) — required for result delivery, idempotency, and cancellation tests (D9/D7). io.rest-assured:rest-assuredorspring-boot-starter-webflux(for reactive test client)
Purpose: end-to-end tests that spin up real database containers, register datasources, create templates, build pipelines, execute them, verify results. The "cold executable" check — if the integration tests pass, the app works.
5.12 tests/browser-tests
Dependencies (internal): :modules:app (full app context, same allowance as §5.11) and :modules:web (testImplementation, #721: SiteShotsRailOrderTest reads AppNav.ITEMS; app carries web as implementation, so the edge is declared, not leaked).
Dependencies (external):
com.microsoft.playwright:playwright— pinned in the catalog; the pin covers the driver AND the bundled browser build (a Playwright release pins its browser set).org.springframework.boot:spring-boot-starter-test, Testcontainers (PostgreSQL + Redis) — same as §5.11.de.mkammerer:argon2-jvm— same declared-exception rationale as §5.11's seeding note: the seeded admin's stored hash must be a real Argon2id of the known password.
Purpose: the mechanical, LLM-free browser suite of the release-critical UI golden
paths (TEST-GAP-2026-09.md): login → forced password change → dashboard, datasource
CRUD, template/pipeline editors, execute+SSE, history, API keys, workspace switching.
Codifies the ad-hoc .playwright-mcp dev loop as a re-runnable gate.
Design rules (ratified 2026-09-03):
- Self-contained boot:
@SpringBootTest(RANDOM_PORT)+ Testcontainers Postgres + Redis + local auth with a seeded admin — one command, zero manual setup, no dependency on an operator's running stack. - Runs in
build/check(#308):buildandcheckrun:tests:browser-tests:testlike every module'stest(CI's build step lists it; every gate log shows it). The rootbrowserTestlifecycle task is the name for running that suite ALONE. The only browser surface outsidebuild/checkissiteShots— it needs a running demo deployment (070 §C), not a browser binary. - Fails loud, never skips: a deliberate invocation without browser binaries FAILS
with the install instructions — a silent skip is not a verdict. (The banner-skip
precedent of
editorJsTestis for INVOLUNTARY toolchain absence; it does not apply.) - Chromium only, headless, auto-wait: no timing sleeps; selectors by stable
idfirst (the templates carry them), role/text otherwise. Traces + screenshots saved undertests/browser-tests/build/reports/on failure. - Out of scope for v1: multi-browser matrix, visual regression pixel-diffing, accessibility scans.
5.13 application
Added by round 056 (ARCH-AUDIT-2026-08 S1–S5, ruling R6). The number is append-order, not
layer-order: web is §5.9 and app is §5.10, and renumbering them would break every inbound
anchor in the spec set and the code. Layer position is §4.1's job; this heading is an address.
Dependencies (internal): typesystem, pipeline-contract, templates, datasources, dag,
auth. Declared in the build file only as they are actually used at compile time (§4.2) — slice A
uses typesystem, pipeline-contract, dag and auth.
Dependencies (external): jackson-module-kotlin, slf4j-api.
Purpose: the use cases that need MORE THAN ONE aggregate, so they belong to no single domain
module — and, before this module existed, ended up in web because nowhere else could hold them.
That was S4's finding: mcp-server cannot depend on web (the arrow runs the other way), so any
use case parked in web is invisible to the MCP surface and gets reimplemented there. Eight of
them had been (S2's D1–D8), and one of the copies had a real behavioural difference.
The placement rule, in one sentence:
Cross-aggregate use cases live in
application; single-aggregate ones live with the aggregate that owns them.
So PipelineService lives in pipeline-contract (it needs only the pipeline aggregate) and
ExecutionLauncher lives here (it needs the pipeline aggregate AND dag's reservation store).
Public API (slice A):
ExecutionLauncher— parameter binding + the idempotency reservation, shared byPOST /pipelines/{id}/executeand thepipelines_executeMCP tool. Sharing it is what gave MCP execute idempotency support, which it had never had (S2's D6).ExecutionLaunch,LaunchDecision,IdempotencyMetrics— its request, its answer, and the counter portwebadapts ontoWebMetrics.
Constraints:
- No web or MCP types, ever. No
HttpStatus, noResponseEntity, noApiResponse, no MCP wire type. The mapping to a status code stays inApiErrorCatalog; the mapping to an MCP error stays in the tool.ArchitectureGuardTestfails the build on an offending import, so the rule cannot decay into a convention. DatapipelinesExceptionwith catalogued codes is the error contract, as everywhere else.- Every workspace-scoped operation takes
workspaceIdexplicitly —TemplateRepository's "no default anywhere: a missed caller is a compile error" rule applies to services too. - It ships no Spring configuration of its own:
webis the aggregation layer (§5.9) and declares the beans, exactly as it already does forpipeline-contract's repository.
Tests: unit tests over the use cases with the domain modules' own fixtures. The surfaces' own tests shrink to "the controller/tool calls it and maps the result".
Coming in slices B and C: the import services, 055's promotion orchestrator, and the
ExecutionService/TemplateService/DatasourceService split of the remaining D3–D8 rows.
5.14 calculators
Dependencies (internal): typesystem — and nothing else, ever (see the §4.2 note).
Dependencies (external): none.
Public API:
CalculatorKindinterface — one pureinputs → outputfunction, with its declared input types, output type and worked exampleCalculatorInput/CalculatorExample— the declaration shape the catalog doc, the validator andcalculators_listall readCalculatorRegistry— every shipped kind, bykind; additive forever (D4)CalculatorEvaluationException— a refusal naming the offending input; the executor maps it topipeline.node.calculator_failed
Why it is its own module. The kinds are the one part of the system that must be provably free of I/O: the executor evaluates one at an arbitrary DAG position, the validator type-checks its inputs at save time, and a future editor preview would evaluate one with no execution at all. Purity as a build rule (this row) plus a source rule (CalculatorPurityTest) is what makes those three uses safe. It deliberately does not depend on pipeline-contract: JSON literals, $ references, context_key collisions and error codes are contract concerns, and a catalog that knew about them would be a catalog nobody could reuse.
Tests: unit tests per kind (boundary values; the fiscal kinds against both a calendar and a non-calendar fiscal start; add_business_days across a weekend and a holiday; tz_shift across a DST boundary; date_parse with a bad pattern), a registry-invariant test, CalculatorPurityTest, and CalculatorRegistrySpecDriftTest against docs/calculators.md.
5.15 scripting
Dependencies (internal): typesystem — and nothing else, ever (§4.2 row; the root
build's allowedInternalDependencies map carries the same closed set).
Dependencies (external):
com.dashjoin:jsonata(pinned, 0.9.10 at introduction) — the round-one script engine; Apache-2.0. Bus-factor note for the dependency review: a small upstream project (99 stars, 17 open issues, last push 2026-07-08 at dispatch) — recorded on purpose; not a blocker while the seam keeps it replaceable.com.fasterxml.jackson.core:jackson-databind(BOM-managed) — the oneObjectMapperbehindCanonicalJson, private to the module.org.slf4j:slf4j-api(BOM-managed) — the evaluation pool's abandonment log; the registry lives inapp.org.graalvm.polyglot:polyglot,org.graalvm.sdk:nativebridge,org.graalvm.truffle:truffle-api(pinned, 25.4.4.1.1 at introduction; UPL 1.0) — the JavaScript engine's polyglot API and the isolate's two declared bridge dependencies (#8 lane 8a). They bringorg.graalvm.sdk:collections,nativeimage,wordandjniutils(UPL 1.0); all seven ride in the boot jar.org.graalvm.polyglot:js-isolate-community(same pin; MIT + UPL 1.0) — the GraalJS isolate, test runtime only: the aggregator pulls the four platform binaries into the Gradle cache. No isolate binary enters the jar; the image fetches its platform's into/opt/graaljs, SHA-256-pinned, and puts it on the loader path (§5.4.1). A process without it reports JavaScript unavailable.
Public API:
ScriptEngine— compile once, evaluate many; JSON-shaped values in and out;JsonataEngineandJavaScriptEngineare the two implementations, wired through one availability-aware map (#8 lane 8b)ScriptLanguage,EvaluationLimits,EngineCapabilities— the seam's vocabulary; capabilities state which limits an engine can actually enforce, measured by the breach suite, never intendedJavaScriptRuntime,JavaScriptRuntimeOptions,Availability,EngineScope— the one factory of every polyglot Engine and Context (SandboxPolicy.UNTRUSTED,spawnIsolate(true), no host, IO, polyglot, native, process or environment access), one Engine per JVM with a fresh Context per evaluation, and the isolate's sizingConsoleSink,ConsoleLine,ConsoleLevel— where a JavaScript body's console lines goJavaScriptResourcesMain— the image build's pre-install of the isolate's native resourcesCompiledScript— opaque engine-produced handleScriptEvaluationPool— the bounded pool every production evaluate call runs on;ScriptClockis its injected time sourceTypeGate(+GateRefusal/GateResult) — the §5.3/R1 output contract of the script design; DECIMAL rounds half-even, BIGDECIMAL is exactCanonicalJson— the §5.5 canonical form: keys sorted, shortest doubles, plain decimalsScriptingExceptionand its five typed refusals (ScriptSyntaxException,ScriptEvaluationException,ScriptTimeoutException,ScriptResourceLimitException,ScriptPoolExhaustedException) — the codes they carry are the script design's §7 mapping
Why it is its own module. The engine evaluates UNTRUSTED script bodies as pure functions of their JSON input (script design D-T4). That is safe because the module cannot reach anything else: one internal edge, an I/O-refusing source guard (ScriptingPurityTest), and resource bounds that are measured, not believed (JsonataBreachTest and JavaScriptBreachTest write build/reports/jsonata-breach.md and javascript-breach.md, and dag-executor.md's honest-bounds table is pasted from them). The JavaScript engine adds a second wall: its bodies run in a separate GraalJS isolate whose Engines and Contexts only JavaScriptRuntime builds (JavaScriptSandboxGuardTest). It deliberately does not depend on pipeline-contract: modes, contracts, error codes and the Context are callers' concerns (7b/7c), and an engine that knew them would be an engine nobody could sandbox.
Tests: the conformance suite parameterised over every engine (ScriptEngineConformanceTest), the breach suites with their three-way outcome records (JsonataBreachTest, JavaScriptBreachTest) and the JavaScript engine's measurements (JavaScriptMeasurementsTest), the 32-thread determinism proof, the pool contract, the §5.3 type-gate table, the JavaScript engine's own suites (JavaScript*Test, DockerfileGraalJsPinTest), CanonicalJsonTest, and ScriptingPurityTest.
5.16 scheduler
Dependencies (internal): typesystem, pipeline-contract (the name grammar only — §4.2 note).
Dependencies (external):
com.github.kagkarlsson:db-scheduler-spring-boot-starter(pinned, 16.12.0 at introduction; bringsdb-schedulercore anddb-scheduler-spring-commonat the same version) — the durable queue, cluster-wide picking and dead-task detection; Apache-2.0. Its Java-serialization default is replaced by the JSON serializer (scheduler design revision §2.2, A8), and its health indicator is disabled (the record §7.2).spring-boot-starter-jdbc,io.micrometer:micrometer-core, Jackson (all BOM-managed).org.thymeleaf:thymeleaf-spring6(BOM-managed, #442b) — the notice templates underscheduler-mail-templates/, rendered by an engine of the module's own exactly asauthrenders its notices (no MVC, no view layer; the catalog note says why not bare Thymeleaf).
Public API:
ScheduleService— the management use cases REST calls (create, edit, pause/resume/unblock, delete, Run now, reads, preview). The only scheduler type a transport may name.JobExecutor(+JobExecutors) — the executor port: validate, prepare, start, inspect, lens; standardized outcomes, no error-code parsingCapacityGate/CapacityLease— the acquire-before-claim port (R4);webimplements it overdag'sExecutionSlotsOccurrenceFunction— the ONE occurrence computation (the record §3.2) the dispatcher and the preview shareSchedulerProperties—datapipelines.scheduler.*(Configuration §3.29)- the jobs
ScheduleDispatcher,ScheduledRunWorker,RunReconciler,ScheduleNotifier(#442b) — declared as db-scheduler tasks by the module's ownSchedulerAutoConfiguration; no transport may name them (ArchitectureGuardTest, B5) NotificationMailer(#442b) — the mail port: one rendered notice to one recipient, answered with whether the relay could have accepted it;webimplements it overauth's one shared SMTP transport (the scheduler cannot seeauth)
Why it is its own module. The scheduler must stay pipeline-agnostic (the owner's boundary, record §5): timing, occurrence identity, admission and the run trail are generic, and a report executor is expected to plug into the same port. A module with no dag/application/auth edge makes that a build fact; the fake executor in its own suite proves it at runtime.
Tests: the occurrence function (DST gap/fold against the measured A9 case, month/year boundaries, the min-interval guard); the repositories and the dispatcher/worker/reconciler against a real Postgres with a controlled clock (ManualScheduler, SettableClock) — two instances, one occurrence, one launch; the crash windows; capacity retries; the two spike proofs; SchedulerBoundaryTest.
5.17 graph
Dependencies (internal): none. Dependencies (external): none — Kotlin stdlib only (DAG Executor §2 principle 1: no graph library for ~150 lines of code).
Public API:
Dag<T>— the immutable, well-formed-by-construction DAG (DAG Executor §3): node ids,dependenciesOf/dependentsOf,topologicalOrder(),independentBatches(),detectCycle()DagBuilder<T>— rejects duplicate ids, dangling dependencies and cycles atbuild()
Why it is its own module. Parameter-engine record P16/P17 (#194): the parameter engine builds its dependency graph with the house Dag<T>, and dag — the executor — carries pipeline-contract, templates, datasources, staging, Redis and JDBC, so nothing below application could depend on it without inheriting that set. The primitive moved byte-identical (git diff -M shows two 100% renames) and in the same package, co.datapipelines.dag, so the executor's two importers did not change — the owner's ruling was that the mature executor is not touched. DagPackageTest pins the package; renaming it is an executor change, not a tidy-up.
Tests: DagTest (moved unchanged: topological order, batches, cycle detection and its path, builder refusals, the deep-chain iterative search); DagPackageTest.
5.18 parameters
Dependencies (internal): typesystem, graph, pipeline-contract, templates, datasources — all declared (the last two since lane C's selector runtime, §4.2 note). Dependencies (external): spring-boot-starter-jdbc (the repository, §8.1), Jackson (BOM-managed), kotlinx-coroutines-core (the evaluator, BOM-managed). No Redis (§3.1 rule 3).
Public API (frozen by #194 lane B — the model, the codes, the probe port and the repository's API; lanes C and D build on them):
ParameterSetBody/ParameterDefinition/SelectorSource/ConstantOption/Presentation— the definition model (the record's §3), bound byParameterSetReader(unknown keys and wrong JSON types refused at every level asparameter.validation.body_invalid) andParameterSetJson's strict mapper. The value-level declaration inside a definition IStypesystem'sParameterDeclaration— never a copy.ParameterErrorCodes— theparameter.*family (Pipeline Contract §13.20);PipelineErrorCodes.Parametersmirrors it, pinned equal by reflection.SelectorProbe— the port the save-time validator's steps 5–6 call (the record's §4): render a pinned selector template, run it withmaxRows = 2. REQUIRED byParameterSetValidatorsince lane C, which retired theselector_probe_unavailablestand-in.SelectorRunner(lane C) — the selector runtime and the productionSelectorProbe(the record's §6.3): renders a pin through the workspace'sTemplateEngine, resolves the datasource by the workspace-visible live read (P31), translates:namebinds withMULTIlist expansion (the empty list asIN (NULL), P29's__<k>slices, every expanded placeholder counted), runs throughdatasources'ReadOnlyStatementLease(the §7D gate before any lease, the clamped timeout,maxRows) and reads canonical rows with the schema from metadata. Its evaluate-time face isSelectorTasks/SelectorTask/SelectorRequest/SelectorRun/DatasourceResolver— the unit the bulkhead runs and abandons.SelectorPool(lane C) — the selector bulkhead (the record's P31, §5.2 step 3), theScriptEvaluationPoolshape copied:sizeRUNNING statements +waitingmore admitted,Saturatedat once when full, a suspending cancellable wait for a slot (the evaluate's deadline bounds it), one slot-owning thread per statement (abandoned ones keep their slot), the abandonment —cancel()thenConnectionPool.discard— off the caller's thread, theabandonedLongAdderfor theparameters.selectors.abandonedgauge (the web wiring's bean). One per process.ParameterEvaluator(lane C) — the runtime (the record's §5):evaluate(workspaceId, set: ParameterSetVersion, selections)/evaluateBlocking→EvaluateResponse(EvaluatedParameter,ParameterState,EvaluatedOption,ParameterError,ValueOrigin,OrgEcho) andEvaluateResponseJson, the one writer the byte cap measures. Step 1 through the shared validator, the set'sDag, one coroutine per parameter awaiting its parents on the bulkhead underwithTimeout(evaluate-timeout-seconds), the P26 selection priority, the three whole-request refusals asDatapipelinesExceptions (unknown_parameter,timeout,response_too_large). The route, the tool, the permission and the version resolution are lane D's.ParametersProperties/ParametersConfig—datapipelines.parameters.*(Configuration §3.30). The runner applies the statement-level keys from its own config and the evaluator the evaluate-level ones: the wiring builds both, and the one pool, from the same config (the record's §17, C27).ParameterSetTemplatePins(#194 lane D) — the parameters side of the templates reverse arrow (the record's §8.4): the working-version, any-version, live-exact-pin and per-version-count scans over the set's pinned templates. ADDITIVE to the frozen repository (a separate class over the same tables). The COMPOSITION with the pipeline scan isapplication'sTemplateUsage; every consumer —templates_used_by,templates_purge_draft, the web delete guards, the template screen's counts — reads the composition.ParameterSetValidator— the record's §4 in order (structure, the graph ongraph'sDag<T>, the expression AST's static rules, the template pins by namespace, the dry run throughSelectorProbe);revalidateSourcesre-runs steps 4–6 at release and import.Expr/ExpressionParser/ExpressionPrinter/ExpressionEvaluator— the §7 AST (lane C evaluateshidden/disabledwith it).ParameterSetGraph— the set's DAG (lane C's evaluation order).ParameterSetRepository(jdbc;parameter_sets,parameter_set_versions— V39) andParameterSetService— the versioning §3.5 verb table (draft create/write with the hash precondition, release with the 142 cascade, purge, discard, restore, switch, import), the working-version read rule and the promoter lens. Every method takes the workspace; every statement filters by it.
Why it is its own module. The owner's intent (the record's P1): an engine for parameters "independent of anything", a decoupled offering. A single-aggregate module — the definition, its validation and its lifecycle — whose only cross-aggregate facts arrive through pipeline-contract's ports; the templates reverse arrow and promotion are application's (the record's §8.4/§8.5).
Tests: the model's strict binding and round trip; the reflection pin between the two code objects; the expression AST (every operator, both cardinalities, the null/empty rules, every cap one past); the validator (every §13.20 validation code reached by a fixture, the probe proven CALLED by a recording fake); the repository and every lifecycle verb against the module's own Postgres container (ParametersTestDb, the shipped migrations through plain JDBC) — workspace scoping both ways, the one-draft index, and both races FORCED with a second connection; ParametersConfigKeysSpecDriftTest. Lane C: SelectorBindsTest (the pinned spring-jdbc's list expansion, the empty list, the slices, the placeholder count); SelectorRunnerIntegrationTest and ParameterEvaluatorIntegrationTest — the REAL runner over the same container as the CUSTOMER database (CustomerDb, a real-pool CustomerRegistry with revocable grants and counted reads) — save end to end, the gate, canonical types, a revoked grant, the row invariants, a statement abandoned at the deadline, the query count per evaluate; SelectorPoolTest over a stub driver that ignores cancel(); ParameterEvaluatorTest, SelectorRowsTest and EvaluatorConcurrencyTest over a scripted, recording selector runtime (every P26 branch, the owner's three scenarios, the caps one off, concurrency on latches).
5.19 persistence
Dependencies (internal): none. Dependencies (external): kotlinx-coroutines-core (the bounded suspending record the event emitter awaits from the executor's coroutines) and slf4j-api (the WARNs a failed batch, a saturated queue and an incomplete drain owe the operator), both BOM-managed; tests only, logback-classic (the BOM's — the suite records the writer's WARN lines, #266b). No Spring, no Micrometer, no JDBC — java.sql.SQLException is read for its SQLState alone (FailureShape).
Public API:
BatchingWriter<T>—record(blocking: returns once the item is durable or definitively not),recordSuspending(bounded: at most twicerecord-max-wait-ms, thenIndeterminate),submit(non-awaited, refuses when full — no production caller yet, D27's dashboard event),queueDepth/queuedBytes,stop(bounded drain →DrainReport)BatchSink<T>— the store's side:write(items)(one unit, throws on failure),writeOne(the direct path),partitionKey,sizeOf,describe(ids for log lines, never payloads),classify(the failure-kind tag),propagates(a failure that belongs to the CALLER — rethrown on its thread byrecord/recordSuspending; default none, #266b)FailureShape— a failure as a log line may carry it:cause(the simple class name) andsqlState(the first SQLState in the chain) — never the message (#266b)ScheduledJob— (#326) the four metadata@Scheduledjobs' closedjobtag set and the name of their failed-tick counter, as plain strings: each job (dag,auth) registers the counter with its own registry, so this module still carries no MicrometerBatchingConfig(thedatapipelines.persistence.*values),Outcome,FallbackReason,FailureKinds,BatchingHooks(what the web layer binds to Micrometer),DrainReport
Why it is its own module. #266: the audit log (auth) and the execution-event record and replay log (web) need the SAME group commit, and the only module below both is one that knows neither. A copy in each would be two implementations of the ordering and durability guarantees, and those are exactly the guarantees that must not drift. It holds no store and no Spring type; each module that owns a store supplies its BatchSink and its wiring.
Tests: BatchingWriterTest — one suite over a recording sink, because the primitive is generic: per-key order under 8 writers × 1,000 items, the idle fast path (a sequential producer never lingers), group commit, the batch bounds, linger under load, saturation by count and by bytes (record falls back, submit refuses and counts), the singles retry with the poison row skipped, a hung sink (the bounded wait claims the item back; an in-flight item is never written twice), the bounded suspending record (indeterminate, never unbounded; an abandoned direct write that had not started never runs), the drain (nothing unflushed; a hung sink gives up at shutdown-drain-ms and reports what it lost). BatchingWriterFailureTest (#266b): a failure is logged by class and SQLState and never by its message (a recording appender over a sink whose message carries a marker), and a failure the sink claims is rethrown on the caller's thread, blocking and suspending, from a batch and from the direct write.
5.20 visualization
Dependencies (internal): typesystem, pipeline-contract, parameters — declared; templates allowed and not declared (§4.2 note). Dependencies (external): spring-boot-starter-jdbc (the two repositories, §8.1), Jackson (BOM-managed). No Redis (§3.1 rule 3), no coroutines (nothing here waits on anything).
Public API (#10 L1a — the documents, the codes, the ports and the repositories' API; L1b–L5 build on them):
VisualizationBody/DashboardBodyand their parts — the two documents (dashboards.md §2, the implementation spec §3), bound byVisualizationReader/DashboardReader(an explicit key table per level; unknown keys, wrong JSON types and missing keys refused as*.validation.body_invalidnaming the path; the collection bounds checked before their members are walked) andArtifactJson's strict mapper.VisualizationErrorCodes/DashboardErrorCodes— thevisualization.*/dashboard.*families (Pipeline Contract §13.22/§13.23);PipelineErrorCodes.Visualization/.Dashboardmirror them, pinned equal by reflection.VisualizationValidator/DashboardValidator— the save-time rules (the spec's §3.1/§3.2), exhaustive, one code per rule. The ports they judge through, declared here and implemented by the surfaces:TemplateContractFacts(a pinned template version's type and script contract —TemplateContractFacts.over(TemplateDryRenderer, TemplateVersionStatuses)is the production adapter),RendererConfigValidator(this module'stable/kpischemas and the Plotly shape check; L4 adds the plot-schema depth behind the same port),PipelineReleaseFacts(a pinned pipeline release: status, read-only, parameters, caller output columns —applicationimplements it overReadOnlyPipelineRule, L1b),ParameterSetFacts(a pinned set version's parameters and their dependents —ParameterSetFacts.over(ParameterSetRepository)is the production adapter).VisualizationRepository/DashboardRepository(jdbc; V42) over ONE generic core,ArtifactRepository— theParameterSetRepositoryAPI for each family: every statement takes the workspace, the hash is the database's (HASH_EXPRover the bound body), the one-draft index and the draft race mapped to*.version.conflict.VisualizationService/DashboardService— the versioning §3.5 verb table over one generic core (ArtifactLifecycle): create, the draft write, release, purge, discard, restore, switch, import, and the lensed reads (every read takes aReadLens, no default). A visualization's release cascades its DRAFT transform pins with consent (TemplateReleaser) and passes theReleaseEvidencegate — whose default REFUSES (visualization.release.tests_missing) until L4 installs the evidence tables' gate. A dashboard's release checks its sources, set and visualizations and cascades DRAFT visualization pins with consent throughVisualizationService.release, inside ONE transaction. Since L1b the lifecycle core'srepository,transactionsandflipDraft, and both services'repository, are module-INTERNAL (the L1a security pass's O3): a surface reads only through the lensed reads — which gainedfindVersionByName(a pin's address) andDashboardService.pinnedBy(D30'sused_by, lensed) for the MCP tools.ArtifactTransferService— the export envelopes (rest-api §21.4's shape:templatesat a visualization envelope's root,visualizationsat a dashboard's) and the import (the lifecycle keys stripped by name, the strict bind, templates → visualizations → the artifact) behind theTemplateBundleport; the routes are L1c's.VisualizationProperties/VisualizationConfig—datapipelines.visualization.*(Configuration §3.33).- The closed vocabularies
RendererKind,AssertionKind,TestRunStatus,DashboardObjectType,ActionScope,StateSetting,LayoutPosition(Enumerations §31–§37);ReleaseEvidence(the D56 gate,NOT_INSTALLEDby default — refusing) andTemplateBundle(the transfer's template half) are ports L4 and L1c implement.
Why it is its own module. The design record's D29: a separate module whose responsibilities and edges are specified before implementation. Dashboards and visualizations are two aggregates that share one lifecycle shape; the runtime that executes a dashboard (DashboardRuntime, L2) is cross-aggregate and lives in application.
Tests: the readers' key tables pinned against the models' properties by reflection, every reason and bound; the validators — one case per code, the count of codes covered asserted equal to the family's catalogued validation set, the spec's two worked documents accepted unchanged; the two repositories and every lifecycle verb against the module's own Postgres container (VisualizationTestDb, the shipped migrations through plain JDBC), one suite over both families — workspace scoping, the DB-projection hash, the one-draft index and the draft race FORCED; the release cascades rolled back by a stale hash; the transfer strip and order; the spec-drift suites (§13.22/§13.23 both directions, configuration §3.33, enums §31–§37).
6. Version Catalog
gradle/libs.versions.toml — single source of truth for third-party versions.
[versions]
kotlin = "1.9.24"
spring-boot = "3.3.2"
kotlinx-coroutines = "1.8.1"
jackson = "2.17.2"
flyway = "10.17.0"
slf4j = "2.0.13"
logback = "1.5.6"
freemarker = "2.3.33"
hikari = "5.1.0"
postgresql = "42.7.3"
mssql-jdbc = "12.6.1.jre11"
mysql-connector-j = "8.4.0"
h2 = "2.2.224"
duckdb-jdbc = "1.0.0"
sqlite-jdbc = "3.46.1.0"
ojdbc11 = "23.4.0.24.114"
argon2-jvm = "2.11"
jjwt = "0.12.6"
micrometer = "1.13.2"
spring-boot-starter-test = "3.3.2"
testcontainers = "1.20.1"
mcp-sdk = "0.10.0"
htmx = "2.0.0"
junit-jupiter = "5.10.2"
mockk = "1.13.12"
kotest = "5.9.1"
[libraries]
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
# ... (every dependency declared here, used by ref from build.gradle.kts files)
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
spring-boot-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "spring-boot" }
spring-boot-starter-data-redis = { module = "org.springframework.boot:spring-boot-starter-data-redis", version.ref = "spring-boot" }
spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client", version.ref = "spring-boot" }
# Flyway — `app` module only (§3.1 rule 2). Since Flyway 10 the Postgres support is a
# separate artifact; flyway-core alone cannot migrate a Postgres target.
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
# etc.
Redis client: no explicit entry — spring-boot-starter-data-redis brings Lettuce as its default client, version-managed by the Spring Boot BOM. Adding a second, separately-pinned Lettuce entry would let it drift from the BOM; if Jedis is ever wanted instead, that is an explicit exclusion + dependency, and a spec change here.
Gate G2 — RESOLVED 2026-08-07 (lockfile sub-item closed 2026-08-15, §7.6). Every entry was resolved against
repo1.maven.orgmaven-metadata.xml, pre-releases rejected, and BOM-managed artifacts (verified by parsingspring-boot-dependencies:3.5.16+ its 43 imported BOMs) declared version-less.gradle/libs.versions.tomlis now the ratified source of truth for versions; the table above is historical. Anchor decisions: Spring Boot 3.5.16 (4.x exists but §11.1 freezes 3.x for v1), Kotlin 2.4.10 (with an empirically-verified conflict-resolution win over the BOM's kotlin-bom 1.9.25 — see the toml comment).gradle.lockfile(transitive pinning) landed 2026-08-15 per §7.6. The original G2 procedure is retained below for future catalog changes:
- Resolve the current stable release:
curl -s 'https://search.maven.org/solrsearch/select?q=g:"org.flywaydb"+AND+a:"flyway-core"&core=gav&rows=5&wt=json' | jq -r '.response.docs[].v'(repeat per artifact; or run./gradlew dependencyUpdatesonce the build exists).- Reject anything that is not a released stable version — no ranges, no
+, nolatest.release, no SNAPSHOT, no RC/M/beta (§6.1, and the project-wide version-pinning rule).- Check BOM-managed artifacts are not pinned here at all: anything the
spring-bootBOM manages (Jackson, Micrometer, Lettuce, HikariCP, the Spring modules) takes its version from the BOM. A local pin that disagrees with the BOM is a silent runtime-incompatibility source.- Verify JDK 21 compatibility for each pinned artifact, then commit
gradle.lockfile(§6.1) in the same commit as the catalog.- Record the date the catalog was verified in the change log below.
This gate is the reason the versions above are safe to leave stale: they are explicitly labelled unverified, so nobody builds on them believing otherwise.
6.1 Versioning rules
- Pin every dependency to an exact version. No ranges, no
+, no SNAPSHOT in production. - Upgrade deliberately. Dependabot or Renovate can open PRs, but no auto-merge.
- Track security advisories. GitHub Dependabot alerts + Snyk (or equivalent) on the repo.
- Lockfile committed. Gradle generates
gradle.lockfilefor repeatable builds (§7.6).
7. Build Conventions
7.1 CommonConventionsPlugin
Applied to every module. Configures:
class CommonConventionsPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.plugins.apply("io.spring.dependency-management")
project.extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
project.extensions.configure<KotlinJvmProjectExtension> {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
// NOTE (2026-08-07): -Xcontext-receivers deliberately NOT set — the flag
// was removed from the Kotlin compiler in 2.2 and is a hard error on 2.4.x.
// Its successor (-Xcontext-parameters) is unused by v1.
allWarningsAsErrors = true // strict
}
}
project.dependencies {
testImplementation(libs.junit.jupiter)
testImplementation(libs.mockk)
// Kotest ASSERTIONS only — deliberately NOT kotest-runner-junit5 (2026-08-08).
// The runner registers a second JUnit Platform TestEngine in every test JVM;
// with zero Kotest spec classes it discovers nothing but participates in
// result reporting, and it has open Gradle 9 incompatibilities that surface
// as truncated test-result stores (intermittent EOFException in
// SerializableTestResultStore). §7.4's convention is JUnit 5 as the platform,
// Kotest as the assertion library. A module that wants Kotest SPECS adds the
// runner to its own build file and owns that engine's Gradle compatibility.
testImplementation(libs.kotest.assertions)
}
project.tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
showExceptions = true
showCauses = true
}
}
}
}
7.2 JVM target
- JDK 21 (current LTS as of 2025). Use virtual threads (
Project Loom) for blocking-IO work in v1.1+. - Toolchain configured in Gradle; the build fails if the wrong JDK is used (rather than silently producing Java 17 bytecode).
7.3 Linting / formatting
- ktlint via plugin, enforced on build. No code merges with ktlint violations.
- detekt for static analysis. Configured in
detekt.yml. Some rules relaxed where they conflict with Kotlin idioms. - Pre-commit hook runs
./gradlew ktlintCheck detekt(optional but recommended).
7.4 Test conventions
- JUnit 5 as the platform.
- MockK for mocking (idiomatic for Kotlin, vs. Mockito which requires extra setup for final classes).
- Kotest for assertion library (more expressive than JUnit's built-in).
- Testcontainers for integration tests requiring real databases.
- Test file naming:
*Test.ktfor unit tests,*IntegrationTest.ktfor integration tests (different Gradle task). - Test source root convention: unit tests in each module's
src/test/kotlin. Cross-module integration tests live intests/integration-tests/src/test/kotlin— there is no per-modulesrc/integrationTestsource set in v1; the rootintegrationTesttask simply depends on:tests:integration-tests:test. A module needing module-local integration tests (e.g. against a Testcontainer) puts them in its ownsrc/test/kotlinwith a*IntegrationTestname; theverifyTestsExecutedguard scanssrc/test/kotlinonly, so tests placed anywhere else are unguarded — don't.
7.5 Build commands
./gradlew build # everything: compile, test, lint
./gradlew test # unit tests only
./gradlew integrationTest # integration tests
./gradlew :modules:dag:test # one module's tests
./gradlew bootJar # produce executable app JAR
./gradlew -Poracle build # build with Oracle driver bundled
./gradlew -Pmysql build # build with MySQL driver bundled
./gradlew clean build # full rebuild
7.6 Dependency locking
Every module — and buildSrc itself — resolves dependencies against a committed
gradle.lockfile. Locking covers all configurations
(lockAllConfigurations()) in STRICT mode, applied by
CommonConventionsPlugin to every module and declared directly in
buildSrc/build.gradle.kts (an included build is not reached by the main
build's locking). A resolution that drifts from the lock — a version bump, a
new or removed dependency, a shifting transitive — fails the build. This is
the mechanical twin of §6: the catalog records what we ASK for, the lockfile
records what was actually RESOLVED, and STRICT mode additionally fails on any
locked configuration with no recorded lock state, so a newly added
configuration cannot escape silently.
- Maintenance procedure (the only way locks may change): DEVELOPMENT.md §6.2 —
./gradlew resolveAndLockAll --write-locks, then review and commit the diff with the dependency change that caused it. resolveAndLockAllis the documented "lock all configurations in a single build execution" pattern (docs.gradle.org dependency_locking); the built-independenciestask resolves only one project's configurations, so each project registers its own copy.- Exactly two artifacts are excluded from lock validation, both declared with
their reason in
modules/datasources/build.gradle.kts: the §5.4.1 flag-gated driversojdbc11andmysql-connector-j. One committed lockfile cannot validate both the default build and-Poracle/-Pmysqlbuilds. - The root project locks too (STRICT, same
resolveAndLockAllflow, declared directly in the rootbuild.gradle.kts): it resolves thekoveraggregation configuration for the cross-module coverage report (§7.7).
7.7 Coverage (Kover)
Every module gets the Kover plugin (org.jetbrains.kotlinx.kover, pinned in
the catalog) from CommonConventionsPlugin. The root project applies it too
and merges the modules into an aggregated report via kover(project(...))
dependencies — wired reactively through
pluginManager.withPlugin("org.jetbrains.kotlinx.kover") (012/F3), so a new
module joins the aggregate automatically and a subproject without the Kover
plugin cannot break root resolution. tests/integration-tests is
deliberately excluded from the ROOT aggregate: pulling its Testcontainers
tests into the report's task graph made koverHtmlReport need a Docker
daemon, and its integration coverage would break comparability with the
unit-only 2026-08-15 baseline the coverage floors derive from. Its own
module-level report still exists; only the root aggregate excludes it.
./gradlew koverHtmlReport produces per-module reports plus the aggregate.
check depends on koverVerify. Each module carries a minimum line
coverage floor in COVERAGE_FLOORS (CommonConventionsPlugin): the module's
measured baseline from the first Kover run (2026-08-15) minus 2 points,
rounded down. Floors are a regression tripwire, not a coverage target — raise
one only when coverage genuinely improved; never lower one to force a build
green. A module absent from COVERAGE_FLOORS fails configuration unless
it is in NO_COVERAGE_FLOOR_ALLOWLIST with its reason — currently only
tests/integration-tests (no main sources).
Escape hatch: -Pkover.off runs tests without the coverage agent attached
(for timing-sensitive diagnosis). The flag also skips the floor rules and the
check→koverVerify wiring — with instrumentation off there is no coverage
data, so a registered floor would fail on absent data. It is for targeted
diagnosis, not for making a red build green: the floors still enforce on
every normal build.
7.8 Architecture-as-tests (Konsist)
House layering rules that previously existed only as prose are encoded as Konsist tests (pinned in the catalog, TEST dependency only):
modules/web—RequiredScopeKonsistTest: every HTTP handler on a@RestControllerdeclares@RequiredScope. Deliberately redundant with the reflection-basedRequiredScopeCoverageTest: one proves it on the live classpath, the other statically from sources.tests/integration-tests—ArchitectureGuardTest, scanning every module's production sources from the cross-module suite:- no field injection (
@Autowiredon properties/fields) — constructor injection only; - no DI stereotypes — no class, interface, or
objectcarries@Service,@Component, or@Repository(zero allowlist; every bean is an explicit@Beanmethod, §8.4); - every
@Transactionalnames the metadata transaction manager (056, §8.5) — a bare one fails the build. This replaced the pre-056 outright ban, which was correct while there was no transaction manager at all. The check is a source text scan rather than an annotation walk, so it can name file and line and cannot be satisfied by a constant that reads as bare on the page; it asserts non-vacuity first, because a scan that finds nothing would otherwise pass forever; - nothing below the surfaces imports a web or MCP type (056, §5.13):
applicationand every domain module are refusedco.datapipelines.web.*/co.datapipelines.mcp.*imports. Gradle'sverifyModuleDependenciesenforces the declared EDGES; this enforces the source-level rule, names the offending import, and would still fire on a type reached through some future transitive edge; - the production scope itself is non-empty, and the layering scan actually
covers
modules/application(a guard scanning nothing proves nothing).
- no field injection (
Konsist lives in existing test source sets only — no dedicated Gradle module.
8. Spring Boot Conventions
8.1 Persistence layer: NamedParameterJdbcTemplate
Decision: NamedParameterJdbcTemplate exclusively. No JPA, no Hibernate, no Exposed.
Reasons:
- Predictable SQL. Every query is explicit SQL — no generated SQL, no N+1 surprises, no lazy-loading traps. What you write is what runs.
- Named parameters (
:start_date,:pipeline_id) are far more readable than positional?for queries with 5+ parameters. - Full control over type mapping, JSONB handling, array columns. No fighting the ORM.
- Lighter — no Hibernate dependency, no second-level cache, no entity manager.
- Spring-native —
NamedParameterJdbcTemplateis part of Spring JDBC, auto-configured by Spring Boot.
Where repositories live: in the module that owns the entity, never in web and never in a shared persistence module — see the §3.1 persistence-ownership rule for the full mapping and the Flyway/Redis boundaries. Each owning module takes spring-boot-starter-jdbc; the DataSource bean itself is app-level.
Pattern per module (pipeline-contract; declared as a @Bean in the consuming
configuration — no @Repository stereotype, §8.4):
class PipelineRepository(private val jdbc: NamedParameterJdbcTemplate) {
fun findById(id: UUID): Pipeline? =
jdbc.query(
"SELECT * FROM pipelines WHERE id = :id AND is_deleted = FALSE",
mapOf("id" to id),
PipelineRowMapper
).singleOrNull()
fun create(pipeline: Pipeline): Pipeline =
jdbc.queryForObject(
"""INSERT INTO pipelines (id, name, display_name, description, owner_id)
VALUES (:id, :name, :displayName, :description, :ownerId)
RETURNING *""",
mapOf(
"id" to pipeline.id,
"name" to pipeline.name,
"displayName" to pipeline.displayName,
"description" to pipeline.description,
"ownerId" to pipeline.ownerId
),
PipelineRowMapper
)!!
}
See Metadata DB spec §6 for the full pattern.
8.2 Module auto-configuration
Modules that provide Spring beans expose them via a @AutoConfiguration class in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. This lets app pull in module configuration without explicit @Import per module.
Example (templates module):
@AutoConfiguration
@ConditionalOnClass(TemplateEngine::class)
@EnableConfigurationProperties(TemplatesProperties::class)
class TemplatesAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun templateEngine(registry: TemplateRegistry, props: TemplatesProperties): TemplateEngine =
TemplateEngine(registry, props)
}
8.3 Configuration properties per module
Each module exposes typed configuration:
@ConfigurationProperties(prefix = "datapipelines.templates")
data class TemplatesProperties(
val cacheSize: Int = 1000,
val renderTimeoutMs: Long = 10_000
)
Key names, defaults, and env-var bindings are defined once in configuration.md (D8); the snippets here are shape illustrations, not a second definition. Composed into application.yml in app:
datapipelines:
templates:
cache-size: 2000
render-timeout-ms: 5000
staging:
h2:
max-memory-mb: 2048
executor:
max-parallel-nodes: 8
auth:
jwt:
ttl-hours: 12
8.4 Beans and DI
- Constructor injection only. No
@Autowiredon fields (per the user's global rules). - Zero DI stereotypes. No production class carries
@Service,@Component, or@Repository— every bean is declared explicitly as a@Beanmethod in the module's@Configurationclass (auth:AuthConfiguration; engine/domain repositories:EngineConfiguration/DomainConfigurationwhere their consumers are already wired; templates:TemplatesConfiguration). Component scanning stays on, but the only annotated production classes are configurations (@Configuration/@AutoConfiguration),@ConfigurationProperties, and the web edge (@Controller,@RestController,@ControllerAdvice). Enforced byArchitectureGuardTest(§7.8). - Transactions: see §8.5 —
@Transactionalis allowed since 056 and must always NAME its manager. - Open classes: Spring's
kotlin-springplugin opens@Configurationclasses and@Transactionalones that need CGLIB proxying. No manualopenkeyword required — and §8.5 explains why that is not a detail.
8.5 Transactions (056, ARCH-AUDIT S3 / ruling R6)
One transaction, one database. There is exactly ONE Spring transaction
manager, metadataTransactionManager (declared in app's
TransactionConfiguration, over the metadata DataSource), and N Hikari pools
for CUSTOMER databases which are not Spring transaction resources and must
never become one. No XA, no JTA, no two-phase commit. The full consistency
model, including what a partially-failed pipeline leaves behind, is
dag-executor §16.
Four rules, each mechanical:
- The manager is always named:
@Transactional("metadataTransactionManager"), never bare. A bare one binds to whichever manager Spring finds — correct by accident with one manager, a trap with two, and silent about which database it means either way.ArchitectureGuardTest(§7.8) fails the build on a bare one. - Transactions live on the service layer, never on a repository or a
controller. First-choice atomicity is still a single data-modifying CTE (the
PipelineRepositorystance, unchanged); the annotation is for a unit of work that genuinely spans statements. Two writes that are ALTERNATIVES selected by a caught constraint violation are not such a unit — seePipelineService.discardandPipelineService.update, both deliberately un-annotated with the reasoning on their KDoc. - No customer-datasource I/O inside a metadata transaction.
ConnectionLeaserefuses to lease while a transaction is active on the thread, with the catalogueddatasource.lease_in_transaction. - Rollback is proven, not assumed.
@Transactionalneeds a CGLIB proxy; a Kotlin class is final unlesskotlin("plugin.spring")opens it, and an advice that cannot be applied produces no error and no transaction.TransactionRollbackE2eTestasserts two writes plus a throw persist nothing, with a commit control beside it so the assertion cannot pass vacuously.
PromotionReceiveService's explicit TransactionTemplate (055) remains valid:
it is the same manager, demarcated in code rather than by annotation.
9. Test Categories
9.1 Unit tests
- Live in
src/test/kotlinof each module. - Run on every
./gradlew build. - Fast (target: full unit test suite < 60 seconds).
- No Spring context (use plain Kotlin test setup, manual DI).
- Mock external dependencies.
9.2 Integration tests
- Live in
tests/integration-tests/src/test/kotlin(cross-module), or in the owning module'ssrc/test/kotlinnamed*IntegrationTest(module-local, e.g. against a Testcontainer) — see §7.4; no per-modulesrc/integrationTestsource set exists in v1. - Cross-module suite runs on
./gradlew integrationTest(delegates to:tests:integration-tests:test); module-local ones run with the module's normaltesttask. - Slower; use real databases (Testcontainers), real H2, real HTTP layer.
- Cover: end-to-end pipeline execution, MCP tool calls, REST endpoints, SSE streams.
- One container per module per test JVM, not one per suite (round 060): each module's suites share a
SharedPostgres/SharedRedissingleton started and reset on first touch;appand the E2E module migrate through the first context's Flyway, the domain modules apply the shipped migrations once over JDBC (§3.1 rule 2 keeps the Flyway dependency inapp). Sharing is safe by rule, and the rule is load-bearing: each spec cleans the tables it touches (TRUNCATE ... CASCADE+ re-seed), seeds carry suite-unique identities, suites with global or exact-set assertions reset via the module's clean helper before seeding, and partial-migration suites take a scratch database on the shared container. Suites that legitimately need isolated deployments keep their own containers (the promotion E2E pair). Verified by shuffled-method-order runs (two seeds,DEVELOPMENT.md§9.3); adding a suite that relies on a fresh container instead of cleaning will fail those runs.
9.3 Smoke tests
- Live in
appmodule. - Run on every
./gradlew build(full Spring context loads, smoke-tests critical endpoints). - Run before deployment.
9.4 Performance / load tests
- Live in
tests/performance/(separate directory). - Run on demand, not in CI fast path.
- Use JMeter or k6 scripts.
10. Documentation Conventions
10.1 Specs
- Live in
docs/(this directory). - One spec per file.
- Versioned (status: v1 frozen, additive-only).
- Cross-referenced via Markdown links.
10.2 KDoc
- Every public class, function, and property has KDoc.
- KDoc explains what the API does and why, not how (the code is the how).
- Examples for non-trivial APIs.
10.3 README per module
Each modules/{name}/README.md is one page:
- What the module does.
- Its public API.
- Its dependencies.
- How to test it locally.
11. Stability Promise
11.1 Frozen in v1
- The module list (the 10 modules + integration tests).
- The dependency direction — specifically the allowed-dependency table in §4.2, which is the normative form. Adding an edge is a spec change, not a build-file change.
- Persistence ownership (§3.1): repositories in their owning module, Flyway only in
app, Redis only indagandweb. - The version catalog as the single source of dependency versions.
- Kotlin + JDK 21 as the language/runtime baseline.
- Spring Boot 3.x as the application framework.
11.2 Not frozen
- Specific third-party versions (updated deliberately per §6.1).
- Internal class names (only public API is the contract).
- Test framework choice (could evolve — Kotest + MockK is v1 default).
- Build commands (could evolve with Gradle versions).
12. Open Questions / Future Additions
Out of scope for v1:
- Module extraction: publishing
typesystemto Maven Central for clients to consume (typed client SDKs). - Gradle version catalogs merging: when the project grows, split catalogs per concern (database drivers, web libs, etc.).
- Build performance: Gradle configuration-cache, build-cache sharing across CI runs.
- Multi-platform (KMP): extract
typesystemto a KMP module so the same types can be consumed by JS / .NET (via tooling) for client SDKs. Probably never needed. - Polyglot modules: if we add Python SDK or CLI, separate
python/directory at root, not inmodules/.
13. Verification Checklist
13.1 Implementation gates (must be closed before the first build lands)
These are the two items this spec deliberately does not resolve on paper. Each has an exact check; neither may be closed by recall.
- [x] G1 — MCP SDK coordinates (§5.8): resolved 2026-08-07 —
io.modelcontextprotocol.sdk:mcp-core:2.0.0+mcp-json-jackson2:2.0.0, Streamable HTTP transports confirmed by jar inspection; see the resolved gate note in §5.8. - [x] G2 — version catalog vs Maven Central (§6): resolved 2026-08-07 — all entries verified, BOM-managed artifacts version-less,
gradle/libs.versions.tomlis the ratified source. Lockfile sub-item closed 2026-08-15 (§7.6): committedgradle.lockfileper module + buildSrc, STRICT mode.
13.2 Build checklist
Before considering the module structure "ready":
- [ ]
./gradlew buildsucceeds from clean state. - [ ] The §4.2 allowed-dependency verification task passes (no module declares an edge outside its row).
- [ ]
./gradlew -Poracle bootJarproduces a JAR containingojdbc11; the defaultbootJardoes not. - [ ] A driver mounted read-only into
/app/lib/is picked up at runtime with the image'sLOADER_PATH=/app/lib,/opt/graaljs(§5.4.1). - [ ] Flyway migrations live only in
app; no other module declares a Flyway dependency. - [ ] Only
dagandwebdeclare a Redis dependency. - [ ] All unit tests pass.
- [ ] All integration tests pass.
- [ ] ktlint and detekt clean.
- [ ]
./gradlew bootJarproduces an executable JAR. - [ ]
java -jar app/build/libs/datapipelines-app-*.jarstarts the app and serves/health. - [ ] Every module has a README.
- [ ] Every module's public API has KDoc.
- [ ] Version catalog has exact versions for every dependency (no
+, no SNAPSHOT). - [x] Gradle lockfile committed (§7.6 — per-module
gradle.lockfile, STRICT mode, 2026-08-15). - [ ] No internal module depends on
app(one-way dependency). - [ ]
tests/integration-testsruns end-to-end pipeline against a real PG container.
Appendix A: Change Log
| Date | Version | Author | Change |
|---|---|---|---|
| 2026-10-26 | v1.8 | 721 (#721) | §4.1/§4.2/§5.12: tests/browser-tests gains the internal edge web (testImplementation, a direct edge per the §4.2 rule — not api leakage through app) so SiteShotsRailOrderTest can pin the screenshot driver's rail table against AppNav.ITEMS. The root allowedInternalDependencies map and the §4.2 row move together (verifyModuleDependencies). Test-scope only: no production edge, public API or runtime change. |
| 2026-10-24 | v1.7 | 442b (#442) | §5.16: scheduler gains thymeleaf-spring6 (BOM-managed, already on auth's and web's class paths — the scheduler's lock gains its four lines) for the notice templates; its public API gains the NotificationMailer port and the ScheduleNotifier job (B5). web implements the port over auth's MailSender. No internal dependency edge changes. |
| 2026-10-22 | JavaScript product wiring | 8b (#8) | One availability-aware engine map supplies templates, the pool runner and node support; §3.1/§5.15 name the two engines and their responsibilities. |
| 2026-10-21 | #8 the JavaScript engine | 8a (#8) | §5.15: the module gains the round-two engine — JavaScriptEngine on the same seam, JavaScriptRuntime (the one Engine/Context factory: UNTRUSTED policy, spawned isolate, one Engine per JVM), its options, Availability, EngineScope, the console sink types and JavaScriptResourcesMain. External dependencies gain GraalVM polyglot, nativebridge and truffle-api 25.4.4.1.1 (UPL 1.0; with collections, nativeimage, word and jniutils) and the test-runtime-only js-isolate-community (MIT + UPL 1.0); no isolate binary in the jar. The internal edge set is unchanged (scripting → typesystem only). No product surface: a javascript template is still refused until 8b. |
| 2026-10-21 | #8 lane 8a the image's loader path (owner ruling 2026-10-21) | 8a (#8) | §5.4.1: the image's value is now LOADER_PATH=/app/lib,/opt/graaljs in the fence, the prose and the readiness checklist — the drop-in directory, then the JavaScript isolate's (§5.15). One list in one ENV, never a -Dloader.path beside it: PropertiesLauncher reads the system property before the environment, so a -Dloader.path for the isolate would silently drop the drop-in (measured in lane 8a). ImageLoaderPathSpecDriftTest pins the new value and refuses a -Dloader.path in the Dockerfile. |
| 2026-10-21 | #657 the image's driver drop-in | 657 | §5.4.1: the container value is LOADER_PATH=/app/lib (was lib), absolute because loader.home defaults to the working directory and the image sets no WORKDIR — lib would have meant /lib. The image now ships the empty, read-only /app/lib the section promised (the Dockerfile created none and set no LOADER_PATH); the readiness checklist names the same path. ImageLoaderPathSpecDriftTest pins the Dockerfile and this section together. No module, dependency edge or public API changes. |
| 2026-10-21 | #640 the script-node rename | script-node-rename (#640, V50, owner ruling 2026-10-20) | The script engine seam, script bodies and the SCRIPT node in the module notes and their design references (was transform); no module, dependency edge or public API changes. A rename only: no behaviour, route, permission or key change beyond the names, and no alias (the ruling). The visualization block's transform key, transform.template / transform.inputs and the CSS/SVG sense keep their word. |
| 2026-10-20 | #326 the scheduled jobs' statement bounds | 326 | Additive: §5.6's public API gains BoundedJdbc (#311's bounded-template construction, lifted and shared) and its persistence note names ScheduledJob; §5.19's public API gains ScheduledJob (strings only — no Micrometer, no JDBC). No dependency edge changes. |
| 2026-09-29 | #321 the scheduled jobs' failure lines | 321 | §4.2's allowed map gains dag → persistence (the root map and modules/dag/build.gradle.kts follow it; owner's ruling on the lane's question, 2026-09-29): StaleExecutionSweeper and ExecutionEventRetention log a store failure by FailureShape's class and SQLState, never by its message (observability §3.4G). The edge is for FailureShape only — dag still does not sit on the batching writer (§4.2 note). §4.1's layer-4 row and §5.6's dependency line follow; §5.6's line also names the declared scripting and graph edges it had missed. |
| 2026-09-29 | #10 L1b — the visualization surfaces | L1b | §4.2's visualization note: the edges of application (PipelineReleaseFactsReader), mcp-server (the eleven tools) and web (the routes and VisualizationConfiguration) are DECLARED — they were allowed ahead. §5.20: the lifecycle core's and the services' repository, transactions and flipDraft are module-internal (O3); the lensed reads gain findVersionByName and pinnedBy. No module, layer or allowed edge added. |
| 2026-09-29 | #316 the scheduled jobs' own scheduler | 316 | §5.6: SweepSchedulingConfiguration declares taskScheduler, one daemon thread dp-scheduled, for every @Scheduled job (the stale sweep, the pool reaper, the retention sweep) — they had been running on the SSE log streamer's dp-sse-log thread, Spring's fallback while no TaskScheduler bean existed; ExecutionEventRetention's line names the thread. No module or dependency change. |
| 2026-09-29 | #10 L1a — the visualization module | L1a | New §5.20 visualization — layer 5, above parameters and below application (§4.1 renumbers the layers above it): the visualization and dashboard documents, readers, validators, repositories, lifecycle and transfer. §3/§3.1 rows, §4.1 layer, §4.2's table gains the row (typesystem, pipeline-contract, templates, parameters) and application, mcp-server and web gain visualization (allowed ahead, undeclared — L1b/L1c/L2 declare it). The root build's allowed-dependency map, COVERAGE_FLOORS (90 until measured) and ArchitectureGuardTest's below-the-surfaces list carry the module; its gradle.lockfile ships in the same commit. |
| 2026-09-29 | #308 the browser suite's invocation truth | 307 | The tests/browser-tests tree comment (§3), the §3.1 matrix row and §5.12's design rule said the suite was "NOT part of/wired into build/check" — false since #292/#297: build/check run :tests:browser-tests:test like every module's test; ./gradlew browserTest is the name for running the suite ALONE, and only siteShots (a running demo deployment needed) is outside. TEST-GAP-2026-09.md's decision 4 carries the correction beside its original proposal. |
| 2026-09-29 | #266 persistence batching, with its correction round 266b | 266 | New §5.19 persistence — layer 0 beside typesystem and graph, no internal dependency: BatchingWriter<T> and its BatchSink seam, the one group commit the audit log (auth) and the execution-event record and replay log (web) share. §3/§3.1 rows, §4.1 layer, §4.2's allowed map gains auth → persistence and web → persistence (dag needs none: appendAll is a plain repository method); §5.7 names the edge. 266b: BatchSink.propagates (a failure that belongs to the caller) and FailureShape (a failure's class and SQLState for a log line, never its message) join the public API; logback-classic as a test-only dependency. (Round 1 changed §3–§5 without this row.) |
| 2026-09-28 | #194 lane D — the surfaces | 194d | web, mcp-server and application DECLARE their parameters edges (§4.2/§5.18): the REST routes and the wiring in web (ParametersConfiguration builds the runner, the evaluator and the ONE pool from one config, plus the parameters.selectors.abandoned gauge), the six parameter_sets_* tools in mcp-server (a parameters DocArea with its guide), and the reverse-arrow composition TemplateUsage + ParameterSetTemplatePins in application. AuthoringStartupCheck covers parameter-set drafts (versioning §5.5, C14). |
| 2026-09-27 | #194 lane C — the selector runtime | 194c | §4.2's note: parameters DECLARES templates and datasources (the selector runtime renders and runs through them; the table's row is unchanged — it always allowed both). §5.18 gains the runtime's public API — SelectorRunner (the production SelectorProbe, now REQUIRED; selector_probe_unavailable retired) with its evaluate-time face, SelectorPool, ParameterEvaluator with the §5.3 model and writer — and kotlinx-coroutines-core (BOM-managed). datasources (§5.4's module, datasources.md §5.3) gains ConnectionPool.discard and ReadOnlyStatementLease. |
| 2026-09-25 | #9 scheduler lane 1 | scheduler-1 | New module scheduler (§5.16): the pipeline-agnostic scheduler core over db-scheduler 16.12.0 (the catalog's one new version, two library aliases). §3, §3.1, §4.1 and §4.2 gain the row (scheduler → typesystem, pipeline-contract for the name grammar alone); web gains scheduler. The root build's allowed-dependency map and COVERAGE_FLOORS carry the module; its gradle.lockfile and the db-scheduler verification entries ship in the same commit. The scheduler's beans come from the module's own @AutoConfiguration (§8.2 — the second module to use it, after mcp-server), so no db-scheduler type leaves the module. |
| 2026-09-23 | 7b (#7) | lane 7b — the template model | §4.2: templates and application gain scripting (transform bodies parse and evaluate through its seam — never through Freemarker), and web gains it for the EngineConfiguration pool bean (the fence's "templates and application, nothing else" was written before the bean's home was known; the diagram and the map moved with the table, and the #214 check proves they cannot drift). §5.3's dependency line and the §4.1 ← lists updated in the same commits. |
| 2026-09-23 | #214 | lane 7b (#7) | §4.1 redrawn from §4.2 — the old diagram predated calculators and scripting entirely, omitted the two test modules, and its mcp-server annotation dropped the calculators, dag and auth edges; the redraw renders every module and every edge as a per-layer ← list. The drift is now mechanical: verifyModuleDependencies also parses the §4.2 table and fails when it and the root build's allowedInternalDependencies disagree in either direction, and when a table module is absent from the §4.1 diagram; a sibling verifyVerificationMetadataDocs check keeps hand-written comments out of gradle/verification-metadata.xml (their home is DEVELOPMENT.md §6.3's hand-verified table, which the same check cross-references). Both are wired into check. |
| 2026-09-23 | 7a (#7) | #7 lane 7a — the script engine | New layer-0 module scripting (§5.15): the ScriptEngine seam with the JSONata engine (com.dashjoin:jsonata 0.9.10 — the catalog's one new version + library), the evaluation pool, the type gate, and canonical JSON; the transform design's §4.1/§4.5/§5.3/§5.5 as a library with no product surface. §3, §3.1 and §4.2 gain the row (scripting → typesystem, the calculators shape); the root build's allowed-dependency map and COVERAGE_FLOORS (measured 91.4 − 2) carry the module. Its gradle.lockfile and the jsonata verification entries ship in the same commit. |
| 2026-08-05 | v1.0 | initial draft | Initial module structure spec: 10 modules + integration tests, dependency graph, version catalog, build conventions, Spring Boot conventions |
| 2026-08-05 | v1.1 | design system integration | Added @acme/design-tokens as the styling foundation for the web module. Documented vendoring approach (CSS files, not npm). Referenced Pipeline Editor spec for integration details. |
| 2026-08-07 | v1.2 | consistency campaign | Persistence ownership (§3.1): repositories live in their owning domain module (PipelineRepository, TemplateRepository, DatasourceRepository, UserRepository/ApiKeyRepository, ExecutionRepository/ExecutionEventRepository), each taking spring-boot-starter-jdbc; Flyway dep + migrations confined to app; Redis (spring-boot-starter-data-redis, Lettuce) confined to dag and web; catalog gains flyway-core + flyway-database-postgresql. §4.1 graph regenerated to match the §5.x lists; §4.2 ambiguous layering rules replaced by one machine-checkable allowed-dependency table (+ Gradle verification task). §5.1 H2TypeMapper → H2IngressMapper / H2EgressMapper (staging §5.3). §5.2 TerminalDetector → CallerNodeResolver [D1], PipelineRepository added, Jackson named as the ser/deser stack. §5.3 params-schema types dropped [D3]. §5.4.1 new: -Poracle/-Pmysql conditional runtimeOnly sketch + lib/ drop-in via PropertiesLauncher/loader.path. §5.6 dag API gains NodeResult, CancellationRegistry, CancellationHandle, ResultStore, ExecutionSlots, ExecutionAbortedException, AbortReason, ExecutorDispatcher. §5.11 db2 Testcontainer removed (not a supported dialect); Redis container added. Both "Verification needed" markers converted to implementation gates G1/G2 with exact commands (§13.1). Duplicate ### 8.2 renumbered (→ 8.3/8.4). See SPEC-REVIEW-2026-08 |
| 2026-08-15 | v1.3 | dependency locking | §7.6 new: STRICT lockAllConfigurations() dependency locking applied by CommonConventionsPlugin to every module and declared in buildSrc/build.gradle.kts; committed gradle.lockfile per module + buildSrc + settings; resolveAndLockAll --write-locks is the documented regeneration path (DEVELOPMENT.md §6.2). §5.4.1's flag-gated drivers (ojdbc11, mysql-connector-j) are the only lock-validation exclusions, declared in modules/datasources/build.gradle.kts. Closes the §13.1 G2 sub-item (lockfile was deferred to P9) and the §13.2 "Gradle lockfile committed" checklist row. Zero dependency version changes — the locks record current resolution. |
| 2026-08-17 | v1.4 | explicit bean wiring | §7.8 rewritten to the three-guard reality: ArchitectureGuardTest now bans field injection, DI stereotypes (@Service/@Component/@Repository, zero allowlist), and declarative transactions (@Transactional anywhere) across every module's production sources, plus the scope-non-emptiness guard; the stale @Transactional-on-@Service rule is gone. §8.4 rewritten: zero DI stereotypes — every bean is an explicit @Bean method in a @Configuration class; the only annotated production classes are configurations, @ConfigurationProperties, and the web edge; transaction policy codified (TransactionTemplate sanctioned, @Transactional banned); the "open classes" bullet now names configurations as the CGLIB target. §8.1 pattern snippet drops the @Repository annotation to match. |
| 2026-09-01 | v1.5 | multi-instance readiness (036) | §5.6 gains StaleExecutionSweeper and records the project's FIRST scheduled surface: web's SweepSchedulingConfiguration is the only @EnableScheduling, running the crash sweep (@Scheduled, fixed-delay 60s, default single-thread scheduler — decisions in its KDoc). "No @Scheduled/Quartz/cron anywhere" stops being a codebase property with this change; new scheduled jobs must be recorded here. (Header version catch-up: the header had been left at v1.3 when v1.4 landed.) |
| 2026-09-02 | v1.6 | multi-instance round 2 (050) | §5.6 gains ExecutionEventRetention — the SECOND scheduled job (retention, fixed-delay 1h), riding the same single @EnableScheduling; the sweep record above now reads as the pattern, not the exception. §5.9's web list gains DatasourceInvalidationConfiguration (the §5.7 pool-invalidation channel — the codebase's first Redis pub/sub message, dp:datasource-invalidated). dag's ExecutionSlots/ExecutorConfig rename maxConcurrentExecutionsGlobal → maxConcurrentExecutionsPerInstance (050/R2: the limit was always per JVM). |
| 2026-08-28 | §3.1 amendment (promised at 019's merge) | The workspace domain's placement recorded in the responsibility matrix and the auth module spec: WorkspaceRepository → workspaces/workspace_members, WorkspaceService/types in the public API. No module moves; this documents what 019 built where it built it. |
|
| 2026-09-08 | 089 dialect-count fix | The integration-tests dependency note said the dialect catalogs "list the seven" — they have listed eight since 087's LAKE (§4.1). One word, plus the fact that LAKE is embedded too (its suite's container is MinIO, object storage, not a database server). | |
| 2026-09-26 | #194 lane A — graph |
New layer-0 module graph (§3 tree, §3.1 matrix, §4.1 diagram, §4.2 table, new §5.17): the generic Dag<T>/DagBuilder<T> moved out of dag byte-identical and in the same package (co.datapipelines.dag), so the parameter engine can build a graph without depending on the executor (parameter-engine record P16/P17). dag's row gains graph; §5.6's API and test lists follow. The root build's allowed-dependency map moved with the table. |
|
| 2026-09-26 | #194 lane B — parameters |
194b | New module parameters (§3 tree, §3.1 matrix, §4.1 layer 4 beside dag, §4.2 row, new §5.18): the parameter engine's frozen model, codes, probe port and config. Its §4.2 row is the record's §2.4 (typesystem, graph, pipeline-contract, templates, datasources); it DECLARES typesystem, pipeline-contract and — from the graph piece — graph, and pipeline-contract is compiled against (the record's §14 item 3, answered: TemplateRef, the name grammars and the template/datasource ports live there). application, mcp-server and web gain the allowed-ahead parameters edge (lanes C–D; undeclared). The root map, COVERAGE_FLOORS (measured 91.18 − 2 on the first run) and the module's gradle.lockfile (no new artifact) ship in the same commit. The record's "layer 2 beside templates" predates the #214 redraw: parameters depends on templates, which is layer 3 — so layer 4. |
| 2026-09-29 | #310 audit-log retention | 310 | §5.7 auth gains AuditLogRetention (the audit-log retention job, a step of the existing hourly sweep — no new @Scheduled surface) and AuditProperties (configuration §3.12) |