diff --git a/.releaserc.json b/.releaserc.json
index 62fa782..fabc687 100644
--- a/.releaserc.json
+++ b/.releaserc.json
@@ -7,14 +7,14 @@
[
"@semantic-release/exec",
{
- "prepareCmd": "./mvnw -B versions:set -DnewVersion=${nextRelease.version} -DgenerateBackupPoms=false"
+ "prepareCmd": "./mvnw -B versions:set -DnewVersion=${nextRelease.version} -DgenerateBackupPoms=false -DprocessAllModules=true"
}
],
"@semantic-release/github",
[
"@semantic-release/git",
{
- "assets": ["CHANGELOG.md", "pom.xml"],
+ "assets": ["CHANGELOG.md", "pom.xml", "*/pom.xml"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 48137ce..53c18e2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -53,4 +53,4 @@ the properties in `pom.xml`:
```
Set `jacoco.line.min` from the module's actually-measured coverage (see the report under
-`target/site/jacoco/`), and ratchet it up over time rather than guessing a threshold now.
+`filter-core/target/site/jacoco/`), and ratchet it up over time rather than guessing a threshold now.
diff --git a/README.md b/README.md
index f6eaec6..a560eb7 100644
--- a/README.md
+++ b/README.md
@@ -23,11 +23,22 @@ Requires JDK 21.
```xml
org.sequeless
- sequeless-filter
+ filter-core0.0.0-SNAPSHOT
```
+## Modules
+
+| Module | Coordinates | Contents |
+| --- | --- | --- |
+| [`filter-core`](filter-core) | `org.sequeless:filter-core` | The DSL: AST model, ANTLR 4 grammar and parser, serializer, operator and field registries, completion utilities. |
+| [`filter-sql-adapter`](filter-sql-adapter) | `org.sequeless:filter-sql-adapter` | Translates a parsed `FilterNode` AST into raw, parameterized SQL via a hexagonal `QueryBuilder` vendor port. See [docs/sql-adapter.md](docs/sql-adapter.md). |
+
+The repository root is a `pom`-packaged aggregator/parent (`org.sequeless:sequeless-filter`) that
+ships no code — it holds the single shared version, dependency management, and build plugins. All
+modules are released together from that one version.
+
## Build
```bash
@@ -42,6 +53,7 @@ install is required.
## Documentation
- [Filter DSL reference](docs/filter-dsl.md) — grammar, operators, serialization, completion.
+- [SQL adapter](docs/sql-adapter.md) — translating a `FilterNode` AST into parameterized SQL.
- Architecture decisions live under [`docs/adr/`](docs/adr/).
## Contributing
diff --git a/docs/adr/0001-split-the-build-into-a-maven-reactor.md b/docs/adr/0001-split-the-build-into-a-maven-reactor.md
new file mode 100644
index 0000000..3af01aa
--- /dev/null
+++ b/docs/adr/0001-split-the-build-into-a-maven-reactor.md
@@ -0,0 +1,61 @@
+# 0001. Split the build into a Maven reactor
+
+- Status: accepted
+- Date: 2026-07-30
+
+## Context
+
+`sequeless-filter` was a single `jar` module holding the entire filter DSL:
+AST model, ANTLR 4 grammar/parser, serializer, operator and field registries, and completion
+utilities.
+
+A SQL adapter (`FilterNode` AST → SQL) is arriving as a second module. It must not inherit the
+ANTLR runtime dependency it never uses — a single-jar layout has no way to express "this
+dependency belongs to one consumer of the DSL, not the DSL itself."
+
+Publishing doesn't exist for this project today: there's no `` and no
+`mvn deploy` step anywhere in CI, so there's no real external consumer whose published coordinates
+this restructuring could break.
+
+## Decision
+
+Convert the root `pom.xml` into a `pom`-packaged aggregator/parent that keeps the existing
+coordinates `org.sequeless:sequeless-filter`, and move all existing source under a new
+`filter-core` module (`org.sequeless:filter-core`).
+
+- **One shared, parent-inherited version.** Children declare no `` of their own.
+- **`jackson-databind` and `antlr4-runtime` move to ``-only.** Each module
+ must opt in by redeclaring the dependency it actually needs — this is what lets the upcoming
+ `filter-sql-adapter` module avoid pulling in ANTLR.
+- **`lombok`, `junit-jupiter`, `assertj-core`, `mockito-core`, `archunit-junit5` stay as plain,
+ parent-inherited ``.** Every module wants all five, so redeclaring them per module
+ would buy nothing.
+- **All build plugins stay in the parent's inherited ``, with one exception:**
+ `flatten-maven-plugin` moves to `` with no `` element, and the aggregator
+ never declares/binds it in its own ``. Each child declares it bare
+ (groupId + artifactId only) and inherits the full managed configuration.
+
+## Consequences
+
+- **Published coordinates change** from `sequeless-filter` to `filter-core` for the actual DSL jar.
+ This is accepted and intentionally left unmarked as a breaking change in commit messages, since
+ nothing is published today and there's no real external consumer to break.
+- **`.releaserc.json` must commit `*/pom.xml`** (alongside the root `pom.xml`) as a release asset.
+ `versions:set -DprocessAllModules=true` rewrites both the aggregator's `` and each
+ child's `` — omitting the child glob would leave the child POM's parent
+ reference stale after a release commit.
+- **JaCoCo coverage reports are now produced per module** (e.g. `filter-core/target/site/jacoco/`),
+ with no aggregate report across the reactor. Deliberate, not an oversight — a coverage aggregator
+ module isn't needed until there's a concrete reason to view coverage across modules in one report.
+- **`sonar-project.properties` was deliberately left untouched.** CI's SonarCloud job has never
+ actually executed an analysis — every run to date logs "SONAR_TOKEN not set; skipping SonarCloud
+ scan." and exits 0. Whether `sonar-project.properties` (a SonarScanner-CLI-style file) is even
+ read by the Maven-integrated `sonar:sonar` goal CI invokes remains unconfirmed: there's no
+ "ANALYSIS SUCCESSFUL ... dashboard?id=..." log line to point at a working configuration to
+ preserve or fix. Speculatively migrating Sonar properties into the parent POM without that
+ confirmation risks guessing wrong about the project key or paths it depends on.
+
+ **Follow-up for whoever enables `SONAR_TOKEN` later:** check the resulting analysis log's
+ `dashboard?id=` line, then either fix `sonar.coverage.jacoco.xmlReportPaths` to point at
+ `filter-core/target/site/jacoco/jacoco.xml`, or migrate the relevant Sonar properties into the
+ parent POM's `` and retire `sonar-project.properties`.
diff --git a/docs/filter-dsl.md b/docs/filter-dsl.md
index acb20a0..12099d7 100644
--- a/docs/filter-dsl.md
+++ b/docs/filter-dsl.md
@@ -93,3 +93,8 @@ function-argument index the cursor sits in.
A field's `CompletionProvider` is optional and embedder-supplied (e.g. backing a `status` field's value
completion with the live set of distinct values) — fields without one simply offer no value candidates.
+
+## See also
+
+- [SQL adapter](sql-adapter.md) — translating a parsed `FilterNode` AST into parameterized SQL
+ against a relational database, via `filter-sql-adapter`.
diff --git a/docs/specs/multimodule-archunit-sql-adapter/brief.md b/docs/specs/multimodule-archunit-sql-adapter/brief.md
new file mode 100644
index 0000000..1c010e2
--- /dev/null
+++ b/docs/specs/multimodule-archunit-sql-adapter/brief.md
@@ -0,0 +1,126 @@
+# Brief
+
+## Project
+
+Restructure the existing single-module `sequeless-filter` Maven project (currently one `jar`-packaged root pom with `org.sequeless.filter.api`, `org.sequeless.filter.internal`, and `org.sequeless.filter.spi` packages under one `src/main`/`src/test`) into a multi-module Maven build with ArchUnit rules guarding module/package structure.
+
+1. **filter-core module** — retains the current `api`, `internal`, and `spi` packages and their existing main-scope responsibilities (AST model, ANTLR grammar/parser, completion engine, operator/field registry contracts), with one scope change:
+ - `DefaultFieldRegistry` and `PermissiveFieldRegistry` (currently `org.sequeless.filter.internal`) move out of `src/main` entirely and become an in-memory `FieldRegistry` adapter fixture living only in filter-core's test scope, for use by filter-core's own tests.
+ - The `FieldRegistry` interface's static factory methods (`of(List)` and `permissive()`) are removed from the api, since their backing implementations no longer exist in main scope. Beyond just removing the two factory methods, the `FieldRegistry` interface's remaining contract (including `isPermissive()`) should be reconsidered for necessity now that no production implementation ships a permissive mode — do not assume the rest of the interface is untouched.
+ - `DefaultOperatorRegistry`, `BuiltinOperatorContributor`, `CursorAnalyzer`, `FilterBuildingVisitor`, and the generated ANTLR `internal.parser` package all remain in filter-core main scope, unchanged. They are load-bearing for `FilterParser.parse()`/`parsePartial()` and for `OperatorRegistry.defaults()`/`of()` to keep working in production — only the field-registry defaults move to test. (`OperatorRegistry`'s built-in operator vocabulary is module-shipped and stays a real production capability; `FieldRegistry`'s defaults are domain-specific sample data and become test-only.)
+ - ArchUnit rules must guard this: `internal` stays inaccessible from outside filter-core, and the test-only field-registry fixture must not leak into any module's main/compile scope.
+
+2. **filter-sql-adapter module (new)** — a SQL rendering layer that translates parsed `FilterNode` ASTs (produced by filter-core) into SQL, exposing its own API/SPI so downstream vendor-specific SQL adapters (e.g. Postgres, MySQL, Oracle) can plug in dialect-specific behavior. Depends on filter-core's main-scope api (and spi as needed) — never on filter-core's test-only fixtures.
+ - An H2-backed implementation of the new SQL adapter SPI is provided as a **test fixture** of filter-sql-adapter (i.e. in its test scope, not shipped as a production artifact), used to validate SQL generation against a real embedded H2 database in tests.
+
+3. Both modules are ArchUnit-guarded: rules enforce the intended package/module layering (e.g. `internal` packages not depended on from outside their own module, api packages depending only on other api/spi packages, test-only fixtures never leaking into any main/compile scope) and fail the build on violation.
+
+## Phase
+
+phase: research
+
+## Register
+
+### B1: ArchUnit cross-module rule verification
+- Status: Resolved
+- Kind: Fact
+- Description: Confirm ArchUnit's (project currently pins `archunit-junit5` 1.3.0) current version and API surface for verifying module/package dependency rules across a multi-module Maven reactor, not just packages within a single module — e.g. importing classes from multiple modules' classpaths and asserting that one module's `internal` package or a test-scope-only fixture is not depended on by another module.
+- Answer: Confirmed — `pom.xml` pins `archunit-junit5 1.3.0`; `BoundaryRulesTest.java` uses the existing `@AnalyzeClasses(packages=..., importOptions={...})` API with `classes()`/`noClasses()` rules for package-level verification; these patterns generalize to multi-module scenarios (ArchUnit can import classes spanning multiple modules' classpaths in a reactor build to assert cross-module dependency rules).
+
+### B2: Sharing test-scope fixtures across Maven modules
+- Status: Needs Answer
+- Kind: Decision
+- Description: Given fixture sharing between filter-core and filter-sql-adapter's tests turns out to be desired (per B12), what Maven mechanism should filter-core use to expose its test-scope `FieldRegistry` fixture to filter-sql-adapter's tests: the `maven-jar-plugin` `test-jar` goal, a dedicated test-fixtures submodule, or simple duplication of the fixture per module?
+- Suggestion: Use the `maven-jar-plugin` `test-jar` goal on filter-core (produces a classifier-`tests` jar that filter-sql-adapter depends on via `teststest`) — Justification: it's the standard Maven idiom for sharing test-only code across reactor modules, keeps the `FieldRegistry` test double in one place instead of two copies that can drift, and doesn't require a new submodule for a single small fixture class. It's low-risk here because no Maven Central publish/deploy step currently exists in this repo (confirmed: no `distributionManagement` or deploy plugin in `pom.xml` or `.github/workflows/release.yml`), so there's no near-term risk of the `-tests.jar` classifier being accidentally published. Limitations: if/when a Central publish step is added later, the release config will need an explicit step to exclude the `tests` classifier from deployment (or the fixture should move to a dedicated non-published test-fixtures module at that point); the `test-jar` approach also creates a build-order dependency where filter-sql-adapter's test compile waits on filter-core's test-jar packaging, a minor reactor-ordering cost but standard for this pattern.
+- Answer:
+
+### B3: H2 database current version
+- Status: Resolved
+- Kind: Fact
+- Description: Confirm the current H2 database version/API surface suitable as a Maven test-scope JDBC dependency for the new filter-sql-adapter test fixture.
+- Answer: Cannot be confirmed against the current codebase — filter-sql-adapter (and its H2 test fixture) only exists as documentation in this brief; no database dependency currently exists in the project (only junit/jackson/antlr/archunit/mockito/assertj). Confirming an H2 version/pin requires the filter-sql-adapter module to exist first, or a fresh out-of-repo check of H2's current release.
+
+### B4: antlr4-maven-plugin behavior in a multi-module reactor
+- Status: Resolved
+- Kind: Fact
+- Description: Confirm the antlr4-maven-plugin (currently pinned 4.13.2) generates code correctly (same output package/paths, no reactor build-order surprises) once the ANTLR grammar (`Filter.g4`) and its generated `internal.parser` sources move from the current single-module root to the filter-core submodule.
+- Answer: The claim mixes hypothetical restructure with current state — codebase shows single-module root pom (artifactId=sequeless-filter) with no filter-core/filter-sql-adapter modules; Filter.g4 and antlr4-maven-plugin 4.13.2 exist, but multi-module reactor behavior cannot be verified since the restructure only exists as documentation proposals in docs/specs/multimodule-archunit-sql-adapter/brief.md.
+
+### B5: Existing CI/release tooling in a multi-module reactor
+- Status: Resolved
+- Kind: Fact
+- Description: The current root pom.xml configures semantic-release, flatten-maven-plugin, jacoco, spotless, maven-enforcer-plugin, and SonarCloud analysis for a single-module build. Confirm how each behaves once the root pom becomes a multi-module aggregator (e.g. jacoco aggregate reporting across modules, flatten applied per-module, spotless scope, enforcer rules like `banDuplicatePomDependencyVersions`/`dependencyConvergence` across modules, semantic-release versioning multiple artifacts) and whether current configs/versions need adjustment.
+- Answer: Confirmed the repo is currently single-module (`jar`, no `` section) — none of these plugins are configured for multi-module behavior yet because modules don't exist. Per-plugin implications once modules are added: `jacoco-maven-plugin` needs a `report-aggregate` execution (typically in the parent or a dedicated aggregator module) to merge per-module coverage instead of the current single `report` goal. `flatten-maven-plugin`'s existing config applies per-module fine as-is (each child pom gets flattened independently). `spotless-maven-plugin`'s `check` execution at `verify` would need to run per-module (default Maven multi-module behavior) unless centralized in the parent. `maven-enforcer-plugin`'s `dependencyConvergence`/`banDuplicatePomDependencyVersions` rules already operate correctly across a reactor. `semantic-release` (`.releaserc.json`) currently targets one artifact/version; multi-artifact versioning would need either a shared parent-inherited version (simplest, no semantic-release changes) or per-module release config — no Maven Central publish/deploy step is currently wired into CI (the release pipeline only sets the version, generates a changelog/GitHub release, and commits the bump back via `@semantic-release/git`; there is no `mvn deploy`, `distributionManagement`, or Central-publishing plugin anywhere in `pom.xml` or `.github/workflows/release.yml`), so that question is moot until publishing is added. SonarCloud does **not** need `sonar-project.properties` source-path edits once children exist: `sonar-project.properties` already states "Source/test layout is discovered from the Maven reactor" (comment directly above `sonar.exclusions`), and CI invokes the Sonar Maven plugin directly (`./mvnw -B verify sonar:sonar`, in `.github/workflows/ci.yml`) rather than a standalone `sonar-scanner` — the Maven-integrated scanner auto-discovers reactor modules and their source/test directories from the POM structure, so no `sonar.modules`/`sonar.sources` changes are needed. The one thing that may need revisiting is `sonar.coverage.jacoco.xmlReportPaths`, which currently points at a single `target/site/jacoco/jacoco.xml` and would need to point at the aggregated report (per the `jacoco-maven-plugin` `report-aggregate` change above) or list each module's report path.
+
+### B6: Multi-module reactor / parent pom shape
+- Status: Needs Answer
+- Kind: Decision
+- Description: What should the root pom become — a `pom`-packaged parent/aggregator at the repo root with `filter-core` and `filter-sql-adapter` as `` children (each with its own artifactId), replacing the current single `jar`-packaged root pom? Confirm groupId/artifactId conventions for the new child modules and whether shared build config (dependency versions, plugin config) lives in the parent's ``/`` or is duplicated per module.
+- Suggestion: Convert root pom to parent with `filter-core` as child module for general DSL, `filter-sql-adapter` created separately — Current single-jar root pom contains all API/internal/SPI types; no existing multi-module structure — refactor to split core DSL from backend adapter and use parent's dependencyManagement/pluginManagement for shared versions. Limitations: Requires a refactoring move of code from monolith to two modules, which entails migration risk and updating build/tool scripts (Makefile/mvnw) plus potentially adjusting tests.
+- Suggestion: Convert root pom to parent with filter-core and filter-sql-adapter as child modules, sharing build config in parent's dependencyManagement/pluginManagement — Root becomes `pom` with ``; `filter-core` retains `org.sequeless.filter:filter-core`, `filter-sql-adapter` gets `org.sequeless.filter:filter-sql-adapter`; parent's dependencyManagement/pluginManagement centralizes common versions (JDK 21, lombok 1.18.36, jackson-databind 2.18.3) and plugin configs (compiler 10+, spotless check), children declare only their explicit dependencies. Limitations: Requires existing single-module code to be reorganized into filter-core module boundary; filter-sql-adapter must be created from scratch as per B14; no current Maven Central publish step minimizes classifier-risk but future OSSRH config would need explicit `excludeClassifiers` for any shared `-tests.jar`; SonarCloud's sonar.coverage.jacoco.xmlReportPaths must aggregate paths across modules; version 1.0.0 will apply uniformly to both artifacts until B7 decides on independent versioning
+- Suggestion: Keep single jar root for now; split only later if architecture evolves to add backends/adapters — All functionality is one cohesive filter/query language package per README, and splitting would create unnecessary complexity without current need (no existing backends or adapters that require independence). Limitations: If team adds new concerns (e.g., SQL adapter, multiple DSL variants), they'll face a refactoring decision later; also prevents parallel development on different sub-concerns in this monolithic phase
+- Answer:
+
+### B7: Module versioning strategy
+- Status: Needs Answer
+- Kind: Decision
+- Description: Given semantic-release currently manages a single artifact's version, should filter-core and filter-sql-adapter share one version (released together, e.g. inherited from the parent pom) or be versioned/released independently?
+- Suggestion: Share one parent-inherited version and release together until filter-sql-adapter is independently published to Maven Central (or another CI publishes separately) — Per docs/specs/multimodule-archunit-sql-adapter/brief.md B7 context—semantic-release configures single artifact only—.releaserc.json uses @semantic-release/exec + @semantic-release/git touching root pom.xml; current release.yml runs npx semantic-release for one artifact. The root will be a parent pom with filter-core/filter-sql-adapter as children (per B6), and without Maven Central deployment wired in, shared version minimizes complexity while allowing future independent releases if publishing is added. Limitations: This choice assumes no immediate need to publish independently; if filter-sql-adapter must ship at different cadence than filter-core before publishing, this option requires refactoring .releaserc.json or adding multiple release workers (higher operational cost). If filter-sql-adapter introduces breaking API changes that filter-core users don't depend on yet, they'll be delayed until compatible backport; this option also inherits any single-repo GitHub Actions rate limit and branch policy constraints to both artifacts together
+- Suggestion: Share one parent-inherited version for both modules until independently, filter-sql-adapter should publish its own release artifacts to Maven Central or have separate CI publishing configured — Current `semantic-release` setup targets one artifact via single-root `pom.xml`; once multi-module exists (per B6), keep them in sync by inheriting the parent Pom's version unless/until both modules can independently build and deploy with their own `.releaserc.json` files, then use a coordinated bump approach. Limitations: Requires filter-sql-adapter to eventually support independent CI deployment; if semantic-release is removed or replaced for multi-module publishing, new config needed per child module; until filter-sql-adapter publishes standalone, version lock remains in parent; no current deploy step means this limitation only applies at future publish time
+- Suggestion: Defer versioning strategy until filter-sql-adapter module actually exists — maintain single artifact at this time as described in README's "cohesive filter/query language package" stance; when splitting occurs, use shared parent version then evolve based on actual adoption patterns rather than abstract speculation about Maven Central publishes that don't exist yet. — Single-module project (no `` section in root pom), semantic-release manages one version via `.releaserc.json` and release.yml (no Maven Central publishing step present), multi-module structure doesn't exist per B14's answer confirming filter-sql-adapter module is not implemented; all two accepted suggestions assume the modules will eventually be published to Maven Central, but since no CI publish/deploy is configured yet, deferring avoids premature architectural decisions around versioning mechanics the project doesn't currently need.. Limitations: Assumes project can operate as monolith longer than expected (no immediate SQL adapter integration); if multi-module split must happen urgently for other reasons unrelated to version concerns, this deferral may cause additional rework; team policy on when/how modules actually separate is not codified in docs; de facto "keep single jar" effectively conflicts with any strategy suggesting independent versions or immediate splitting.
+- Answer:
+
+### B8: filter-sql-adapter package naming convention
+- Status: Needs Answer
+- Kind: Decision
+- Description: What package names does filter-sql-adapter use for its own api/spi (e.g. `org.sequeless.filter.sql.api`, `org.sequeless.filter.sql.spi`), consistent with filter-core's existing `org.sequeless.filter.{api,internal,spi}` convention?
+- Suggestion: Use `org.sequeless.filter.sql.api` / `org.sequeless.filter.sql.spi` (matching filter-core's `{api,internal,spi}` subdomain layering) — aligns with Maven multi-module adapter patterns where SQL-specific concerns extend the same namespace by adding a subdomain qualifier. — filter-core uses explicit separation of public contracts via `.api` and service provider interfaces via `.spi`; filter-sql-adapter introducing new SPI types (SQLRenderingHook, ConditionDescriptor, etc.) should mirror this layered structure under its own module boundary, analogous to how Spring Boot's JDBC has `com.zaxxer.hikari.config` and H2 uses dedicated `javax.sql.rowset.*` extensions; also B6/B7 in docs/specs/multimodule-archunit-sql-adapter/brief.md propose filter-core (api/internal/spi) and filter-sql-adapter as distinct modules, implying SQL gets its own namespace prefix (`sql`) under shared `org.sequeless.filter` base.. Limitations: The project currently has no multi-module structure or published modules (no Maven Central publishing; .github/workflows/release.yml only sets local version/artifact via semantic-release and commits CHANGES), so this recommendation is aspirational for future implementation — actual package choices can't be validated against real filter-sql-adapter code that doesn't yet exist beyond documentation; if the team chooses B6's deferral strategy (keep single jar until adapting backend demands arise), SQL adapter won't exist yet to demonstrate/package conventions
+- Answer:
+
+### B9: filter-sql-adapter SPI output shape
+- Status: Needs Answer
+- Kind: Decision
+- Description: Should the new SQL adapter SPI produce raw SQL string fragments + a bind-parameter list (JDBC-style `PreparedStatement` usage), a JPA Criteria/`Predicate`-style object graph, or does it need to support both? This shapes the entire SPI contract vendor adapters (and the H2 test fixture) implement against.
+- Suggestion: Implement raw SQL fragments + bind-parameter list (JDBC PreparedStatement), matching how filters already carry fully-resolved values rather than parameter placeholders — The FilterNode AST uses JsonNode value nodes (not placeholder markers), the project is explicitly JPA-agnostic per README/pom, so binding-style output fits best without introducing Framework dependencies; field-path-to-column mapping would naturally occur in an adapter subclass that knows domain entities/columns. Limitations: Assumes no immediate need for Criteria-builder style queries which could be added later as a separate SPI variant if/when JPA integration is pursued; JDBC PreparedStatement usage requires callers to manually execute statements with prepared/prepared parameters, which may require documentation/example code for teams new to this pattern
+- Suggestion: Return SQL string fragments + JDBC `PreparedStatement` value placeholders matching how `FilterNode` AST carries fully-resolved `JsonNode` values directly — The `FilterNode` AST (FieldFilter/AnyFilter) carries explicit `JsonNode value` fields per code read, making PreparedStatement the natural fit; no JPA dependencies exist per pom.xml showing only junit/jackson/antlr/archunit/mockito/assertj, and a non-JPA project fundamentally cannot support Criteria/Predicate-style graphs which are JPA-specific.. Limitations: This assumes target databases all support named/bind parameters (nearly all JDBC drivers do) but omits scenarios where raw dynamic SQL interpolation is required for derived expressions; vendor adapters must implement their own column/path-to-SQL resolution since the SPI receives AST nodes with field paths but no mapping contract per B10 question.
+- Suggestion: Return fully-resolved Condition descriptors (path string, canonical op name, type-safe boxed value) as an immutable AST-like interface optimized for SQL emission to a `visit*` pattern — The FilterNode tree carries path strings from FieldDefinition's schema metadata plus OperatorDefinition.canonicalName and fully resolved JsonNode values - this allows vendors to walk it with their own visitor that produces either raw fragments, placeholder lists, or typed object graphs without repeating SPI contract negotiation. Limitations: Vendors must implement a custom FilterVisitor implementation for SQL translation which adds implementation complexity; the API only exposes string paths and boxed Java types so adapters cannot infer specific DB column type precision beyond what is defined in FieldDefinition.jsonSchemaType
+- Answer:
+
+### B10: filter-sql-adapter field-path-to-column mapping
+- Status: Needs Answer
+- Kind: Decision
+- Description: Does the SQL adapter SPI itself define a contract for mapping a `FilterNode`'s field paths to actual SQL column names/expressions (e.g. a `ColumnMapping` SPI type), or is that mapping assumed to happen entirely outside the adapter, with the adapter only ever receiving already-resolved column references?
+- Suggestion: The filter-sql-adapter SPI leaves field-path-to-column mapping entirely outside its contract—assumed to happen externally—so the adapter receives FilterNode ASTs already resolved by whatever caller knows entity/table schemas — FieldFilter/AnyFilter stores `path` as plain String in their api records without binding metadata; B10's own context from B9 suggests vendor adapters implement their own column/path resolution since SPI only exposes path strings and no ColumnMapping contract exists or is being defined. Limitations: Assumes callers can resolve paths-to-columns (e.g., JPA metamodels, table schemas) if/when SQL backend integration is added; if JPA/Criteria-style query APIs are later introduced, mapping semantics would need revisiting around that new variant
+- Suggestion: SQL adapter module defines a `ColumnMapping` SPI contract where adapters declare path-to-column mappings during construction, and adapters receive FilterNodes with fully-resolved column references — The current single-module project has no multi-module structure (confirmed by pom.xml packaging=jar with no modules), FieldFilter carries string paths not SQL column refs (code read confirms path is String in record), mapping must happen at adapter initialization time when domain entities are available, allowing adapters to register path->column mappings from their persistence models. Limitations: Requires filter-sql-adapter module to exist for SPI contract acceptance; vendors would need to implement custom visitor pattern for SQL generation as the core only exposes string paths and boxed values without schema inference beyond FieldDefinition.jsonSchemaType
+- Answer:
+
+### B11: filter-sql-adapter dependency on filter-core's spi package
+- Status: Needs Answer
+- Kind: Decision
+- Description: Does filter-sql-adapter need to depend on filter-core's `spi` package (e.g. `FieldContributor`/`OperatorContributor`) in addition to `api`, or does it only ever consume already-built `FilterNode`/`OperatorDefinition` instances from `api`?
+- Suggestion: filter-sql-adapter depends *only* on filter-core's `api` package, never on spi — FilterNode/OperatorDefinition instances in api are concrete value objects populated directly by parser/builders and stored as Definition references (not contributor instances) when exposed; FieldRegistry only exposes already-built FieldDefinition lists via its static factory methods; default registries (DefaultOperatorRegistry, DefaultFieldRegistry) assemble definitions from contributors' resolved output before making available via their public APIs. Limitations: This answer assumes filter-sql-adapter follows the design outlined in docs/specs/multimodule-archunit-sql-adapter/brief.md (FilterNode AST translation only) and does not require contributing new operators/fields at runtime; verification requires implementing a minimal sql adapter stub and confirming no imports of org.sequeless.filter.spi.* leak into filter-sql-adapter's main scope
+- Suggestion: filter-sql-adapter depends only on filter-core's api package, never on spi — The api package declares only api and internal (not spi) imports; OperatorDefinition/FieldDefinition are value types consumed to build ASTs — spi is solely for registry building via extension points at framework composition time. Limitations: Risk if future API changes introduce new SPI-only features; needs version pinning during filter-core updates; assumes contributor-based configuration pattern remains stable
+- Suggestion: filter-sql-adapter depends only on filter-core's api package, never on spi — The brief explicitly states filter-sql-adapter "defines its own API and SPI for translating parsed filters into SQL" that takes a FilterNode from parser/filter-core and produces SQL — the api package already exports built FilterNode/OperatorDefinition instances via OperatorRegistry/FieldRegistry; spi interfaces (OperatorContributor/FieldContributor) are registry extension points, not runtime data objects consumed by adapters.. Limitations: This reasoning assumes the brief's multi-module refactoring plan is implemented as described (filter-core as parent with api/internal/spi packages). The answer also holds even before restructure because FilterNode and OperatorDefinition classes have never imported from org.sequeless.filter.spi in their Javadoc or code comments; spi interfaces are design/registration hooks, not runtime dependencies for end-to-end adapter translation pipelines.
+- Answer:
+
+### B12: Reuse vs duplicate in-memory field-registry fixtures for filter-sql-adapter tests
+- Status: Needs Answer
+- Kind: Decision
+- Description: Should filter-sql-adapter's own tests (including the H2 fixture's tests) reuse filter-core's test-only `FieldRegistry` fixture (per B2's findings on how that could be shared), or should filter-sql-adapter define its own separate in-memory field registry for test purposes?
+- Suggestion: filter-sql-adapter should define its own in-memory FieldRegistry fixture (no reusing from filter-core suggested) — sequeless-filter tests locally instantiate FieldRegistry instances per test scenario; no filter-sql-adapter module exists in this workspace. Limitations: this workspace lacks a 'filter-sql-adapter' module - only pure java Filter library + Spring starter which depends ON it not vice versa
+- Answer:
+
+### B13: FieldRegistry interface contract reconsideration
+- Status: Needs Answer
+- Kind: Decision
+- Description: With `permissive()`/`PermissiveFieldRegistry` moving to test-only and no production `FieldRegistry` implementation shipping a permissive mode, should `FieldRegistry.isPermissive()` be removed from the interface, kept as-is for implementors outside this codebase (e.g. a vendor-supplied registry) to still express, or changed in some other way? Identify any other `FieldRegistry` contract members that only make sense in light of the removed default implementations.
+- Suggestion: Change `isPermissive()` to a package-private default in the public interface or convert all internal implementations and remove it from API entirely — The method only serves as an implementation hint for tests and external vendors don't need this contract since DefaultFieldRegistry fully implements find()/all()/compatibleWith() per their actual behavior; moving `isPermissive` out of its default position would expose API consumers to breaking changes if they previously inherited from internal implementations, but keeping it reveals no real purpose for public consumers who create registry via static factories. Limitations: External vendors implementing FieldRegistry won't break by omitting isPermissive() since the interface uses a default method; however, any existing production code relying on default-interface semantics to differentiate registry types will lose that ability without explicit overrides
+- Suggestion: Make `isPermissive()` package-private (remove from public API) while keeping its implementation behavior in internal classes, then remove all callers or replace with path-based validation check — The current codebase uses `isPermissive()` defensively in two internal places (`FilterBuildingVisitor.resolvePath`, `FilterValidator.validateField`) to skip field-path validation when the registry is permissive; both callers are internal and can be simplified to always-validate since only `DefaultFieldRegistry` ships for production. With PermissiveFieldRegistry/test-only removed from the shipped product and no vendor-supplied FieldRegistry implementations documented or expected, exposing a boolean marker that only ever differs in test code serves no purpose—converting it to package-private (`private` in internal packages) or removing it entirely prevents future implementors outside this module from being tripped up by an unrelated flag. Callers should validate against `find(path).isEmpty()` unconditionally or use a path wildcard mechanism if desired, rather than relying on the method's existence. Limitations: This suggestion changes interface stability for external implementors who may depend on isPermissive() as part of a contract; removing it (or making it non-public) breaks binary compatibility but improves conceptual clarity. Replacing callers with unconditional validation increases risk of spurious "Unknown field" errors if vendors later add permissive behavior, unless they adapt by using the wildcard mechanism or wrapping validation logic themselves
+- Suggestion: Remove `isPermissive()` as a public interface method and rely solely on path-based validation logic — Only two internal implementations exist (both in org.sequeless.filter.internal package); the permissive() static factory returns PermissiveFieldRegistry which always allows any path, so validation should be gate-checked against using either FieldRegistry.permissive() or the of() factory rather than calling isPermissive(). Limitations: Requires updating FilterBuildingVisitor and FilterValidator to explicitly pass a boolean parameter (permissive flag) instead of querying the registry; external implementations would need to accept this permissive flag via constructor/injectable property; slightly more verbose API for callers who previously relied on default method
+- Answer:
+
+### B14: Implement filter-sql-adapter module
+- Status: Resolved
+- Kind: Fact
+- Description: Implement filter-sql-adapter module (raised while researching B3 — H2 test-fixture dependency confirmation requires the filter-sql-adapter module to actually exist first).
+- Answer: The filter-sql-adapter module does not exist yet — no `filter-sql-adapter` directory, package, or module anywhere in the repo, and the root `pom.xml` has `jar` with no `` section — so B3's H2 test-fixture work remains blocked pending this module's creation. (This brief's Register uses only "Resolved" and "Needs Answer" statuses; there is no "Needs Research" status value in this document to confirm against.)
diff --git a/docs/specs/multimodule-archunit-sql-adapter/plan.md b/docs/specs/multimodule-archunit-sql-adapter/plan.md
new file mode 100644
index 0000000..840f050
--- /dev/null
+++ b/docs/specs/multimodule-archunit-sql-adapter/plan.md
@@ -0,0 +1,766 @@
+# Plan — Multi-module restructure, ArchUnit guards, filter-sql-adapter
+
+## Goal
+
+Turn the single-module `org.sequeless:sequeless-filter` jar into a Maven reactor with two
+modules — `filter-core` (the existing DSL: AST, ANTLR grammar/parser, serializer, registries,
+completion) and a new `filter-sql-adapter` (FilterNode AST → SQL), with ArchUnit guarding the
+package/module boundaries that remain enforceable. (Neither module is actually published today —
+no `` or deploy step exists anywhere in this repo's tooling; that's a
+pre-existing gap this plan doesn't attempt to close.) **Note on scope of "ArchUnit-guarded":** two
+of the rules originally envisioned (an api→internal facade rule in filter-core, and a
+"testfixtures never leak to main" rule) were dropped during review because Maven's own compilation
+model already makes both invariants un-violatable — see D8/D15. What ArchUnit actually enforces
+after this plan: filter-core's existing `BoundaryRulesTest` rules (`filter_is_free_of_spring`,
+`api_and_spi_types_are_public`) survive untouched; filter-sql-adapter's new ArchUnit test adds a
+module-local public-types rule, a cross-module rule that `filter-sql-adapter`'s main scope may not
+reach filter-core's `internal`/`spi`, and — kept for documentation purposes despite being
+unfalsifiable given Maven already rejects the cycle — a rule that filter-core may not depend on
+`sql..` (D26). That is a smaller guarantee than "layering is fully policed," and is accurate to
+state as such.
+
+Along the way, filter-core's field-registry *defaults* stop being production code: they become
+test-scope fixtures, and `FieldRegistry`'s contract is trimmed to what production actually needs —
+except `of(...)`, which Stage 3 decided to keep (see D5). Separately, `OperatorDefinition` gains a
+value-shape signal and `FilterValidator` gains a value-shape check (D17) — a small, independently
+motivated improvement to filter-core surfaced by reviewing the SQL adapter's requirements, not
+something filter-sql-adapter depends on filter-core changing for it (the adapter still degrades
+gracefully via its own `SqlRenderException` if a shape mismatch somehow reaches it).
+
+## Settled decisions
+
+These were `Needs Answer` in the brief's Register; each was verified against the codebase and is
+now folded into the plan body below. Deviations from the brief's first-ranked suggestion are
+marked and justified. **Several of these were revised during Stage 3 (user interview) and Stage 4
+(two rounds of automated review) — see `## Decisions` below for the current, authoritative
+version; rows here are kept as the historical Stage 2 record.**
+
+| Ref | Decision |
+| --- | --- |
+| B6 | Root `pom.xml` becomes `pom` aggregator/parent (`org.sequeless:sequeless-filter`), with `filter-core` and `filter-sql-adapter` as `` children. groupId stays `org.sequeless`; artifactIds are `filter-core` / `filter-sql-adapter` matching their directory names. Shared versions/plugin config live in the parent's `` / `` / ``, never duplicated per module — **refined by D9**: most plugins stay in the parent's inherited ``, only `flatten` moves to ``. **Deviation:** the suggestion proposed groupId `org.sequeless.filter`; keeping `org.sequeless` avoids inventing a second group for a project that already ships under `org.sequeless`. |
+| B7 | One shared, parent-inherited version. Children declare no `` of their own and reference each other with `${project.version}`. Verified: `.releaserc.json` drives a single `versions:set` and no `mvn deploy`/`distributionManagement` exists anywhere, so per-module release cadence buys nothing today. |
+| B8 | filter-sql-adapter uses `org.sequeless.filter.sql.api`, `org.sequeless.filter.sql.spi`, `org.sequeless.filter.sql.internal` — the same `{api,internal,spi}` layering filter-core already uses, one subdomain down. **Refined by D12**: the shipped default `QueryBuilder` implementation lives in `sql.spi` (public, extensible), not `sql.internal`; `sql.internal` currently has no classes reserved for it. |
+| B9 | The SPI produces **raw SQL fragments plus an ordered bind-parameter list** (JDBC `PreparedStatement` style). Verified: `FieldFilter`/`AnyFilter` carry fully-resolved `JsonNode` values (not placeholders), and the enforcer `ban-spring` rule plus the "no storage/query engine dependency" stance in README rule out a JPA Criteria object graph. Reaffirmed directly by **D2**. **Superseded mechanism** — see D3/D6: the original plan had a single `SqlRenderer` walk the tree with a `SqlDialect` override hook; this is now a hexagonal `QueryBuilder` port (`binary`/`unary`/`and`/`or`, each receiving `OperatorDefinition`) that owns full construction. |
+| B10 | The adapter **does** own a path-to-column contract: a `ColumnMapping` functional interface in `org.sequeless.filter.sql.api`, supplied by the caller at render time, with a `ColumnMapping.identity()` for callers whose paths already equal column names. **Refined by D13**: `identity()` is documented as trusted-input-only (no runtime validation added) — callers exposing untrusted paths must supply an explicit `of(Map)` allowlist instead. **Scope limit surfaced by D20**: only flat, single-table column mappings are supported — there is no way to contribute a join, correlated subquery, or JSON path expression. |
+| B11 | filter-sql-adapter main scope depends on filter-core's `api` package only — never `spi`, never `internal`. Unaffected by Stage 3/4; still holds under D4 (AnyFilter expansion uses `AnyFilterExpander`, itself in `api`). |
+| B12 | **Superseded by D5.** The original finding (filter-sql-adapter defines its own test-only `FieldRegistry` fixture rather than sharing filter-core's) is moot: D5 reinstates `FieldRegistry.of(...)` as production API, so there is no test-only fixture to share or duplicate in the first place. |
+| B2 | **Superseded by D5**, for the same reason as B12 — no test-scope fixture-sharing question remains once `FieldRegistry.of(...)` is production API again. |
+| B13 | **Partially reversed by D5.** The behavior-preservation finding for removing `isPermissive()` still holds (its only two callers collapse to `fields.find(path).isEmpty()` unconditionally), and `permissive()`/`PermissiveFieldRegistry` still move to test-only. What changed: `of(List)` does **not** go away — it's kept, retyped to take `FieldRegistrySpec` (D5). |
+
+## Decisions
+
+Answers reached by interviewing the user in Stage 3 and via two rounds of Stage 4 automated
+review, in the order resolved. These are the authoritative, current design — the
+`## Settled decisions` table above is historical.
+
+### Stage 3 (user interview)
+
+- **D1 — Commit message convention: no breaking-change markers.** Removing
+ `FieldRegistry.isPermissive()` is technically a breaking API change, but **no commit produced
+ while implementing this plan should be marked as breaking** (no `!` after the conventional-commit
+ type, no `BREAKING CHANGE:` footer) — use a plain `fix:` (or otherwise appropriate type) instead,
+ for this and every other commit in the plan. Reconsidered and reaffirmed at D10 after the review
+ flagged the semver implications.
+- **D2 — SQL SPI output stays raw SQL, confirmed.** Re-affirms B9's conclusion (`SqlFragment`, not
+ JPA Criteria) after being asked directly; only the internal mechanism changed (D3/D6).
+- **D3 — Hexagonal query-builder architecture replaces the renderer+dialect-override design.**
+ `sql.spi.QueryBuilder` is now the **port**: vendor adapters implement it and own construction of
+ each `SqlFragment` — identifier quoting, operator-to-SQL translation, value expansion, AND/OR
+ combination — rather than a shared renderer that only exposes override hooks. `sql.api`'s
+ translator is the vendor-agnostic core: it walks the `FilterNode` tree and delegates every node to
+ the injected `QueryBuilder`. The port stays **fixed to producing `SqlFragment`** (not a generic
+ `QueryBuilder`) — considered and rejected, since full genericity would reopen the
+ already-settled JPA-Criteria question (B9/D2) and would mean filter-sql-adapter's own H2 fixture
+ and ArchUnit guards could no longer assume one concrete output shape. This supersedes the Stage 2
+ plan's `SqlRenderer`/`SqlDialect` design entirely; the exact port signature is D6.
+- **D4 — The SQL adapter expands `AnyFilter` itself.** Reverses the Stage 2 plan's stance (which
+ rejected unexpanded `AnyFilter` and required callers to pre-expand). The translator now depends on
+ filter-core's `AnyFilterExpander` (api) and expands any `AnyFilter` node before delegating to the
+ `QueryBuilder`, using `AnyFilterExpander`'s **default** expansion strategy. This means the
+ translator's entry point needs a `FieldRegistry` and `OperatorRegistry` in addition to
+ `ColumnMapping` and `QueryBuilder` — which motivates D5. **Caveat:** callers who need a *custom*
+ `AnyExpansionStrategy` (a real, documented feature of `AnyFilterExpander`) have no way to supply
+ one through the adapter's entry point — they must still pre-expand with their own strategy before
+ calling `translate(...)` if they need anything other than the default. **Refined by D18**: an
+ operand of the expansion that has no `ColumnMapping` entry is dropped rather than failing the
+ whole filter — see D18.
+- **D5 — `FieldRegistry.of(...)` reinstated in filter-core main scope, parameterized by a new
+ builder-constructed spec type, not a raw `List`.** Partially reverses B13/T1 (which removed
+ `FieldRegistry.of(List)` entirely and moved `DefaultFieldRegistry` to test-only).
+ `DefaultFieldRegistry` stays in filter-core's main scope (not moved to test), backing a reinstated
+ `FieldRegistry.of(FieldRegistrySpec)` static factory. The parameter type changes from a raw
+ `List` to a new `FieldRegistrySpec` value type, built via Lombok `@Builder`
+ (holding `List fields` today), so the registry can be configured by more than a
+ flat field list later without another signature break. `DefaultFieldRegistry` itself does **not**
+ need its own builder — only its constructor's parameter type changes, from `List`
+ to `FieldRegistrySpec`. `PermissiveFieldRegistry`/`permissive()`/`isPermissive()` still move to
+ test-only / get removed exactly as B13 already settled ("keep the permissive one out unless we
+ discover later that we'll need it," per the user — main scope, that is). This eliminates the
+ Stage 2 plan's `testfixtures.InMemoryFieldRegistry` fixture entirely: tests construct a
+ `FieldRegistry` the same way any production caller now does, via
+ `FieldRegistry.of(FieldRegistrySpec.builder().fields(...).build())`. Only
+ `testfixtures.PermissiveFieldRegistry` remains as a genuine test-only fixture. **Supersedes B2 and
+ B12**: with the default registry back in production, there is no test-only fixture left to share
+ or duplicate across modules.
+
+### Stage 4, first review pass
+
+- **D6 — `QueryBuilder`'s methods take `OperatorDefinition`, not a bare canonical-name string.**
+ `SqlFragment binary(String column, OperatorDefinition operator, Object value)`,
+ `SqlFragment unary(String column, OperatorDefinition operator)`,
+ `SqlFragment and(List operands)`, `SqlFragment or(List operands)`. Lets
+ a vendor branch on `operator.getSyntax()`/`isUnary()`/`getParameters()` without re-deriving them
+ from a string, and — as a side effect, not a scope expansion — structurally accommodates a future
+ `Syntax.FUNCTION` operator (same `(column, operator, value)` shape; `value` would be the
+ argument list) without a fifth port method. `BuiltinOperatorContributor` ships no `FUNCTION`
+ operator today, so the translator/builder tasks implement only the 13 shipped `INFIX` operators;
+ this is forward compatibility, not a commitment to build FUNCTION support now. `value` here is the
+ **already-converted** Java value — see D7. **Refined by D16**: `binary()`/`unary()` are `final`
+ dispatchers on the shipped `AnsiQueryBuilder`, which needs its own fallback path for operators
+ outside the 13 built-ins — see D16.
+- **D7 — The translator (not the vendor `QueryBuilder`) converts `JsonNode` values to plain Java
+ objects before calling the port.** One documented mapping, applied uniformly so every vendor gets
+ it for free: `TextNode`→`String`; numeric (`IntNode`/`LongNode`/`DoubleNode`, matching
+ `FilterBuildingVisitor.parseNumber`'s int/long/double selection)→the corresponding boxed
+ `Number`; `BooleanNode`→`Boolean`; `NullNode`→Java `null`; `ArrayNode`→`List