Commit 28e1aa38008bf342577c6ca8ae767cd1cbc8b1f0

Authored by vibe_erp
1 parent d8aa39eb

fix: address v0.1 acceptance review (1 BLOCKER + 6 IMPORTANT/NIT findings)

BLOCKER: wire Hibernate multi-tenancy
- application.yaml: set hibernate.tenant_identifier_resolver and
  hibernate.multiTenancy=DISCRIMINATOR so HibernateTenantResolver is
  actually installed into the SessionFactory
- AuditedJpaEntity.tenantId: add @org.hibernate.annotations.TenantId so
  every PBC entity inherits the discriminator
- AuditedJpaEntityListener.onCreate: throw if a caller pre-set tenantId
  to a different value than the current TenantContext, instead of
  silently overwriting (defense against cross-tenant write bugs)

IMPORTANT: dependency hygiene
- pbc-identity no longer depends on platform-bootstrap (wrong direction;
  bootstrap assembles PBCs at the top of the stack)
- root build.gradle.kts: tighten the architectural-rule enforcement to
  also reject :pbc:* -> platform-bootstrap; switch plug-in detection
  from a fragile pathname heuristic to an explicit
  extra["vibeerp.module-kind"] = "plugin" marker; reference plug-in
  declares the marker

IMPORTANT: api.v1 surface additions (all non-breaking)
- Repository: documented closed exception set; new
  PersistenceExceptions.kt declares OptimisticLockConflictException,
  UniqueConstraintViolationException, EntityValidationException, and
  EntityNotFoundException so plug-ins never see Hibernate types
- TaskContext: now exposes tenantId(), principal(), locale(),
  correlationId() so workflow handlers (which run outside an HTTP
  request) can pass tenant-aware calls back into api.v1
- EventBus: subscribe() now returns a Subscription with close() so
  long-lived subscribers can deregister explicitly; added a
  subscribe(topic: String, ...) overload for cross-classloader event
  routing where Class<E> equality is unreliable
- IdentityApi.findUserById: tightened from Id<*> to PrincipalId so the
  type system rejects "wrong-id-kind" mistakes at the cross-PBC boundary

NITs:
- HealthController.kt -> MetaController.kt (file name now matches the
  class name); added TODO(v0.2) for reading implementationVersion from
  the Spring Boot BuildProperties bean
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventBus.kt
... ... @@ -31,11 +31,46 @@ interface EventBus {
31 31 fun publish(event: DomainEvent)
32 32  
33 33 /**
34   - * Register a listener for events of [eventType] (and its subtypes).
  34 + * Register a typed listener for events of [eventType] (and its subtypes).
35 35 *
36   - * The listener stays registered until the plug-in is stopped, at which
37   - * point the platform tears it down automatically — plug-ins do not
38   - * need (and should not have) a manual `unsubscribe` method.
  36 + * Plug-ins can ignore the returned [Subscription] — when the plug-in is
  37 + * stopped, the platform tears down all of its listeners automatically.
  38 + * Core PBCs and tooling that legitimately need to drop a subscription
  39 + * mid-life (feature toggles, completed sagas, per-tenant subscriptions)
  40 + * call `subscription.close()` to deregister explicitly.
39 41 */
40   - fun <E : DomainEvent> subscribe(eventType: Class<E>, listener: EventListener<E>)
  42 + fun <E : DomainEvent> subscribe(
  43 + eventType: Class<E>,
  44 + listener: EventListener<E>,
  45 + ): Subscription
  46 +
  47 + /**
  48 + * Register a listener keyed on the event's [DomainEvent.aggregateType]
  49 + * string instead of its concrete Java class.
  50 + *
  51 + * Why this overload exists: cross-classloader event listening cannot rely
  52 + * on `Class<E>` equality, because two plug-ins compiling against the
  53 + * same `OrderCreated` event class will see two different `Class` objects
  54 + * loaded by two different classloaders. The string topic is the only
  55 + * stable identifier across the boundary.
  56 + *
  57 + * @param topic the [DomainEvent.aggregateType] (or topic-style) string
  58 + * to match. Convention: `<plugin-or-pbc>.<aggregate>` (e.g.
  59 + * `orders_sales.Order`, `printingshop.Plate`).
  60 + */
  61 + fun subscribe(
  62 + topic: String,
  63 + listener: EventListener<DomainEvent>,
  64 + ): Subscription
  65 +
  66 + /**
  67 + * Handle returned by `subscribe`, used to deregister explicitly.
  68 + *
  69 + * Implements [AutoCloseable] so `use { ... }` patterns work in tests
  70 + * and short-lived listeners.
  71 + */
  72 + interface Subscription : AutoCloseable {
  73 + /** Deregister this listener. Idempotent: calling twice is a no-op. */
  74 + override fun close()
  75 + }
41 76 }
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt
... ... @@ -2,6 +2,7 @@ package org.vibeerp.api.v1.ext.identity
2 2  
3 3 import org.vibeerp.api.v1.core.Id
4 4 import org.vibeerp.api.v1.core.TenantId
  5 +import org.vibeerp.api.v1.security.PrincipalId
5 6  
6 7 /**
7 8 * Cross-PBC facade for the identity bounded context.
... ... @@ -32,11 +33,12 @@ interface IdentityApi {
32 33 fun findUserByUsername(username: String): UserRef?
33 34  
34 35 /**
35   - * Look up a user by id. The id is `Id<*>` rather than `Id<UserRef>` so
36   - * callers do not have to know the exact entity type — they pass the id
37   - * they have, and the implementation does the matching internally.
  36 + * Look up a user by their principal id. The id is typed as
  37 + * [PrincipalId] (which is `Id<Principal>`) so the compiler refuses to
  38 + * accept, e.g., an `Id<Order>` here. Callers that have a generic id
  39 + * from an audit row construct a `PrincipalId(rawUuid)` deliberately.
38 40 */
39   - fun findUserById(id: Id<*>): UserRef?
  41 + fun findUserById(id: PrincipalId): UserRef?
40 42 }
41 43  
42 44 /**
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/PersistenceExceptions.kt 0 → 100644
  1 +package org.vibeerp.api.v1.persistence
  2 +
  3 +/**
  4 + * The closed set of exceptions that [Repository] is allowed to raise.
  5 + *
  6 + * Why a small custom hierarchy in api.v1 instead of letting JPA exceptions
  7 + * leak through:
  8 + *
  9 + * • Plug-ins live in their own classloader and cannot import JPA or
  10 + * Hibernate types — those packages are not on the parent classloader's
  11 + * exported list. A `jakarta.persistence.OptimisticLockException` thrown
  12 + * across the plug-in/host boundary would surface as a wrapped runtime
  13 + * error the plug-in cannot meaningfully `catch`.
  14 + *
  15 + * • Plug-in authors need a stable, semver-governed contract for "what
  16 + * can go wrong when I save this thing?". JPA exception names and
  17 + * hierarchies have shifted across Hibernate majors more than once.
  18 + *
  19 + * • Optimistic-locking conflicts and unique-constraint violations are
  20 + * **expected** failure modes (the kind of thing the [Result] doctrine
  21 + * applies to). They aren't bugs; they're business outcomes that callers
  22 + * legitimately want to handle.
  23 + *
  24 + * The platform translates underlying JPA / Hibernate exceptions to these
  25 + * types at the persistence boundary. Anything else (the database is down,
  26 + * the connection pool is exhausted, …) bubbles up as the generic
  27 + * [PersistenceException] and is treated as infrastructure failure.
  28 + *
  29 + * **Adding a new subtype** is a non-breaking change within api.v1.x as long
  30 + * as the new type extends [PersistenceException] (so existing `catch`
  31 + * blocks still match). **Removing or renaming** a subtype is a major bump.
  32 + */
  33 +sealed class PersistenceException(
  34 + message: String,
  35 + cause: Throwable? = null,
  36 +) : RuntimeException(message, cause)
  37 +
  38 +/**
  39 + * The save was rejected because another transaction concurrently updated
  40 + * the same row. The caller's in-memory copy is stale; reload, re-apply the
  41 + * intent, and try again — or surface a "someone else changed this" UX.
  42 + *
  43 + * Backed by Hibernate's `OptimisticLockException` / `StaleObjectStateException`.
  44 + */
  45 +class OptimisticLockConflictException(
  46 + val entityName: String,
  47 + val entityId: String,
  48 + cause: Throwable? = null,
  49 +) : PersistenceException(
  50 + "Optimistic lock conflict on $entityName id=$entityId — the row was modified by another transaction",
  51 + cause,
  52 +)
  53 +
  54 +/**
  55 + * A unique constraint was violated by the save. [constraint] is the index
  56 + * or constraint name as reported by the database (e.g.
  57 + * `identity__user_tenant_username_uk`). The platform surfaces the raw name
  58 + * because plug-ins legitimately want to map specific constraints to
  59 + * specific user-facing messages.
  60 + */
  61 +class UniqueConstraintViolationException(
  62 + val entityName: String,
  63 + val constraint: String,
  64 + cause: Throwable? = null,
  65 +) : PersistenceException(
  66 + "Unique constraint '$constraint' violated on $entityName",
  67 + cause,
  68 +)
  69 +
  70 +/**
  71 + * The save failed validation against entity invariants enforced by the
  72 + * platform (custom-field constraints, jakarta.validation annotations, etc.)
  73 + * [errors] is a per-field map of message keys (NOT translated strings) so
  74 + * the caller can render them in any locale.
  75 + */
  76 +class EntityValidationException(
  77 + val entityName: String,
  78 + val errors: Map<String, String>,
  79 +) : PersistenceException(
  80 + "Validation failed on $entityName: ${errors.entries.joinToString(", ") { "${it.key}=${it.value}" }}",
  81 +)
  82 +
  83 +/**
  84 + * The targeted entity does not exist for the current tenant. Distinct from
  85 + * `findById` returning `null`: this is for operations that semantically
  86 + * require the row to exist (update, delete, increment, …).
  87 + */
  88 +class EntityNotFoundException(
  89 + val entityName: String,
  90 + val entityId: String,
  91 +) : PersistenceException(
  92 + "$entityName with id=$entityId not found in current tenant",
  93 +)
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt
... ... @@ -43,12 +43,26 @@ interface Repository&lt;T : Entity&gt; {
43 43 * Insert or update. Returning the saved entity (rather than `Unit`) gives
44 44 * the platform a place to populate generated columns (audit timestamps,
45 45 * generated ids) before the plug-in sees them again.
  46 + *
  47 + * The closed set of expected failure modes is declared in
  48 + * [PersistenceException]. The platform translates underlying JPA /
  49 + * Hibernate exceptions to those types at the persistence boundary so
  50 + * plug-ins never see Hibernate types crossing their classloader.
  51 + *
  52 + * @throws OptimisticLockConflictException when another transaction
  53 + * concurrently updated the same row.
  54 + * @throws UniqueConstraintViolationException when a unique index would
  55 + * be violated.
  56 + * @throws EntityValidationException when entity invariants fail.
46 57 */
47 58 fun save(entity: T): T
48 59  
49 60 /**
50 61 * Delete by id. Returns `true` if a row was deleted, `false` if no
51 62 * matching row existed for the current tenant.
  63 + *
  64 + * Does NOT throw [EntityNotFoundException] — use `findById` first if
  65 + * you need "must exist" semantics.
52 66 */
53 67 fun delete(id: Id<T>): Boolean
54 68 }
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt
1 1 package org.vibeerp.api.v1.workflow
2 2  
  3 +import org.vibeerp.api.v1.core.TenantId
  4 +import org.vibeerp.api.v1.security.Principal
  5 +import java.util.Locale
  6 +
3 7 /**
4 8 * Implementation contract for a single workflow task type.
5 9 *
... ... @@ -65,4 +69,37 @@ interface TaskContext {
65 69 * Passing `null` removes the variable.
66 70 */
67 71 fun set(name: String, value: Any?)
  72 +
  73 + /**
  74 + * Owning tenant of the workflow instance this task belongs to.
  75 + *
  76 + * Workflow tasks do NOT run inside an HTTP request, so the usual
  77 + * `RequestContext` injection is unavailable. The platform binds the
  78 + * task's tenant context for the duration of [TaskHandler.execute] and
  79 + * exposes it here so handlers can pass tenant-aware calls back into
  80 + * api.v1 services.
  81 + */
  82 + fun tenantId(): TenantId
  83 +
  84 + /**
  85 + * The principal whose action triggered this task instance. For
  86 + * automatically scheduled tasks (timer-driven, signal-driven), this
  87 + * is a system principal — never null.
  88 + */
  89 + fun principal(): Principal
  90 +
  91 + /**
  92 + * The locale to use for any user-facing strings produced by this task
  93 + * (notification titles, audit messages, etc.). Resolved from the
  94 + * triggering user, the workflow definition, or the tenant default,
  95 + * in that order.
  96 + */
  97 + fun locale(): Locale
  98 +
  99 + /**
  100 + * Correlation id for this task execution. The same id appears in the
  101 + * structured logs and the audit log so an operator can trace a task
  102 + * end-to-end.
  103 + */
  104 + fun correlationId(): String
68 105 }
... ...
build.gradle.kts
... ... @@ -11,19 +11,33 @@ allprojects {
11 11 version = providers.gradleProperty("vibeerp.version").get()
12 12 }
13 13  
14   -// Enforce the architecture-level dependency rule (CLAUDE.md guardrail #9):
  14 +// Enforce the architecture-level dependency rules (CLAUDE.md guardrail #9):
15 15 //
16   -// • PBCs may NEVER depend on other PBCs.
17   -// Cross-PBC interaction goes through api.v1 service interfaces or the
18   -// event bus.
  16 +// 1. PBCs may NEVER depend on other PBCs.
  17 +// Cross-PBC interaction goes through api.v1.ext.<pbc> service
  18 +// interfaces or the event bus.
19 19 //
20   -// • Plug-ins (and the reference plug-in) may ONLY depend on :api:api-v1.
21   -// They never see :platform:* or :pbc:* internals.
  20 +// 2. PBCs may NEVER depend on `:platform:platform-bootstrap`.
  21 +// Bootstrap is the application entry point that ASSEMBLES PBCs;
  22 +// depending on it inverts the layering. PBCs depend on platform
  23 +// runtime modules (persistence, plugins, i18n, etc.) only.
22 24 //
23   -// The build fails at configuration time if either rule is violated.
  25 +// 3. Plug-ins (modules carrying the `vibeerp.module-kind=plugin` ext
  26 +// property) may ONLY depend on `:api:api-v1`. They never see
  27 +// `:platform:*` or `:pbc:*` internals.
  28 +//
  29 +// All three rules fail the build at configuration time.
  30 +//
  31 +// We use an explicit `extra["vibeerp.module-kind"]` marker rather than a
  32 +// pathname heuristic so that adding a plug-in under a different directory
  33 +// (e.g. `customer-plugins/foo/`) does not silently bypass the rule, and
  34 +// so that `:platform:platform-plugins` (which contains the substring
  35 +// "plugin-" in its path) is correctly classified as a runtime module, not
  36 +// a plug-in.
24 37 gradle.projectsEvaluated {
25 38 rootProject.allprojects.forEach { p ->
26 39 val ownPath: String = p.path
  40 + val moduleKind: String? = p.extra.properties["vibeerp.module-kind"] as String?
27 41  
28 42 val checkedConfigs = listOf("api", "implementation", "compileOnly", "compileClasspath")
29 43 val projectDeps = p.configurations
... ... @@ -32,10 +46,12 @@ gradle.projectsEvaluated {
32 46 cfg.dependencies.filterIsInstance<org.gradle.api.artifacts.ProjectDependency>()
33 47 }
34 48  
  49 + // Rule 1 & 2 — PBC dependency hygiene
35 50 if (ownPath.startsWith(":pbc:")) {
36 51 projectDeps.forEach { dep ->
37 52 @Suppress("DEPRECATION")
38 53 val depPath = dep.dependencyProject.path
  54 +
39 55 if (depPath.startsWith(":pbc:") && depPath != ownPath) {
40 56 throw GradleException(
41 57 "Architectural violation in $ownPath: depends on $depPath. " +
... ... @@ -43,12 +59,18 @@ gradle.projectsEvaluated {
43 59 "interfaces (org.vibeerp.api.v1.ext.<pbc>) or the event bus."
44 60 )
45 61 }
  62 + if (depPath == ":platform:platform-bootstrap") {
  63 + throw GradleException(
  64 + "Architectural violation in $ownPath: depends on $depPath. " +
  65 + "PBCs MUST NOT depend on platform-bootstrap (the application " +
  66 + "entry point). Depend on platform runtime modules only."
  67 + )
  68 + }
46 69 }
47 70 }
48 71  
49   - val isPlugin = ownPath.startsWith(":reference-customer:") ||
50   - ownPath.contains("plugin-")
51   - if (isPlugin) {
  72 + // Rule 3 — Plug-in dependency hygiene (driven by an explicit marker)
  73 + if (moduleKind == "plugin") {
52 74 projectDeps.forEach { dep ->
53 75 @Suppress("DEPRECATION")
54 76 val depPath = dep.dependencyProject.path
... ...
distribution/src/main/resources/application.yaml
... ... @@ -22,6 +22,18 @@ spring:
22 22 hibernate:
23 23 ddl-auto: validate
24 24 open-in-view: false
  25 + # Multi-tenant wall #1 (the application-layer wall):
  26 + # Hibernate's DISCRIMINATOR strategy means every query and every save
  27 + # is automatically filtered/written with the tenant id from
  28 + # `HibernateTenantResolver`, which reads `TenantContext` (set per-request
  29 + # by `TenantResolutionFilter`). Wall #2 — Postgres Row-Level Security —
  30 + # is enforced by the `RlsTransactionHook` (planned: implementation-plan
  31 + # P1.1) once it lands. Both walls are required by CLAUDE.md guardrail #5;
  32 + # disabling either is a release-blocker.
  33 + properties:
  34 + hibernate:
  35 + tenant_identifier_resolver: org.vibeerp.platform.persistence.tenancy.HibernateTenantResolver
  36 + multiTenancy: DISCRIMINATOR
25 37 liquibase:
26 38 change-log: classpath:db/changelog/master.xml
27 39  
... ...
pbc/pbc-identity/build.gradle.kts
... ... @@ -26,13 +26,19 @@ allOpen {
26 26 annotation("jakarta.persistence.Embeddable")
27 27 }
28 28  
29   -// CRITICAL: pbc-identity may depend on api-v1 and platform-* but NEVER on
30   -// another pbc-*. The root build.gradle.kts enforces this; if you add a
31   -// `:pbc:pbc-foo` dependency below, the build will refuse to load.
  29 +// CRITICAL: pbc-identity may depend on api-v1 and platform-* runtime modules
  30 +// (persistence, plugins, i18n, etc.) but NEVER on platform-bootstrap, and
  31 +// NEVER on another pbc-*.
  32 +//
  33 +// • No cross-PBC: enforced by the root build.gradle.kts.
  34 +// • No dependency on platform-bootstrap: bootstrap is the application
  35 +// entry point that ASSEMBLES PBCs at the top of the stack. Depending
  36 +// on it inverts the layering — every change to the HTTP filter or the
  37 +// properties bean would force a recompile of every PBC, and a future
  38 +// PBC graph would tangle into an unfixable cycle.
32 39 dependencies {
33 40 api(project(":api:api-v1"))
34 41 implementation(project(":platform:platform-persistence"))
35   - implementation(project(":platform:platform-bootstrap"))
36 42  
37 43 implementation(libs.kotlin.stdlib)
38 44 implementation(libs.kotlin.reflect)
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/ext/IdentityApiAdapter.kt
... ... @@ -6,9 +6,9 @@ import org.vibeerp.api.v1.core.Id
6 6 import org.vibeerp.api.v1.core.TenantId
7 7 import org.vibeerp.api.v1.ext.identity.IdentityApi
8 8 import org.vibeerp.api.v1.ext.identity.UserRef
  9 +import org.vibeerp.api.v1.security.PrincipalId
9 10 import org.vibeerp.pbc.identity.domain.User
10 11 import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
11   -import java.util.UUID
12 12  
13 13 /**
14 14 * Concrete [IdentityApi] implementation. The ONLY thing other PBCs and
... ... @@ -36,7 +36,7 @@ class IdentityApiAdapter(
36 36 override fun findUserByUsername(username: String): UserRef? =
37 37 users.findByUsername(username)?.toRef()
38 38  
39   - override fun findUserById(id: Id<*>): UserRef? =
  39 + override fun findUserById(id: PrincipalId): UserRef? =
40 40 users.findById(id.value).orElse(null)?.toRef()
41 41  
42 42 private fun User.toRef(): UserRef = UserRef(
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/HealthController.kt renamed to platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/MetaController.kt
... ... @@ -20,6 +20,10 @@ class MetaController {
20 20 fun info(): Map<String, Any> = mapOf(
21 21 "name" to "vibe-erp",
22 22 "apiVersion" to "v1",
  23 + // TODO(v0.2): read implementationVersion from the Spring Boot
  24 + // BuildProperties bean once `springBoot { buildInfo() }` is wired
  25 + // up in the distribution module. Hard-coding a fallback here is
  26 + // fine for v0.1 but will silently drift from `gradle.properties`.
23 27 "implementationVersion" to (javaClass.`package`.implementationVersion ?: "0.1.0-SNAPSHOT"),
24 28 )
25 29 }
... ...
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt
... ... @@ -6,6 +6,7 @@ import jakarta.persistence.MappedSuperclass
6 6 import jakarta.persistence.PrePersist
7 7 import jakarta.persistence.PreUpdate
8 8 import jakarta.persistence.Version
  9 +import org.hibernate.annotations.TenantId
9 10 import org.vibeerp.platform.persistence.tenancy.TenantContext
10 11 import java.time.Instant
11 12 import java.util.UUID
... ... @@ -36,6 +37,15 @@ abstract class AuditedJpaEntity {
36 37 @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid")
37 38 open var id: UUID = UUID.randomUUID()
38 39  
  40 + /**
  41 + * Tenant discriminator. Hibernate's `@TenantId` makes this column the
  42 + * automatic filter for every query and save: the value is sourced from
  43 + * `HibernateTenantResolver`, which in turn reads `TenantContext`. PBC
  44 + * code MUST NOT write to this property directly — the JPA listener
  45 + * fills it in on `@PrePersist`. Attempting to set it to a value other
  46 + * than the current tenant before save throws (see [AuditedJpaEntityListener]).
  47 + */
  48 + @TenantId
39 49 @Column(name = "tenant_id", updatable = false, nullable = false, length = 64)
40 50 open var tenantId: String = ""
41 51  
... ... @@ -71,6 +81,17 @@ class AuditedJpaEntityListener {
71 81 val now = Instant.now()
72 82 val tenant = TenantContext.currentOrNull()
73 83 ?: error("Cannot persist ${entity::class.simpleName}: no tenant in context")
  84 +
  85 + // Defense against the "buggy caller pre-set tenantId to the wrong
  86 + // value" failure mode flagged in the v0.1 acceptance review. Silent
  87 + // overwrite would mask a real bug; throwing here surfaces it.
  88 + if (entity.tenantId.isNotBlank() && entity.tenantId != tenant.value) {
  89 + error(
  90 + "Cannot persist ${entity::class.simpleName}: entity tenantId='${entity.tenantId}' " +
  91 + "but current TenantContext is '${tenant.value}'. Cross-tenant write attempt?"
  92 + )
  93 + }
  94 +
74 95 val principal = currentPrincipal()
75 96 entity.tenantId = tenant.value
76 97 entity.createdAt = now
... ...
reference-customer/plugin-printing-shop/build.gradle.kts
... ... @@ -4,6 +4,10 @@ plugins {
4 4  
5 5 description = "vibe_erp reference plug-in: a hello-world printing-shop customer expressed entirely through api.v1."
6 6  
  7 +// Marker that the root build.gradle.kts uses to apply the plug-in dependency
  8 +// rule (no `:platform:*`, no `:pbc:*`, only `:api:api-v1`).
  9 +extra["vibeerp.module-kind"] = "plugin"
  10 +
7 11 java {
8 12 toolchain {
9 13 languageVersion.set(JavaLanguageVersion.of(21))
... ...