Commit f51644142c38dbc26949a8cfae54899b83011a85

Authored by vibe_erp
1 parent 728e3793

feat(api-v1): public plug-in contract — entities, persistence, events, security,…

… i18n, http, plugin lifecycle
Showing 37 changed files with 1974 additions and 0 deletions
api/api-v1/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + `java-library`
  4 + `maven-publish`
  5 +}
  6 +
  7 +description = "vibe_erp Public Plug-in API v1 — the only stable contract a plug-in is allowed to depend on."
  8 +
  9 +java {
  10 + toolchain {
  11 + languageVersion.set(JavaLanguageVersion.of(21))
  12 + }
  13 + withSourcesJar()
  14 + withJavadocJar()
  15 +}
  16 +
  17 +kotlin {
  18 + jvmToolchain(21)
  19 +}
  20 +
  21 +// api-v1 has the strictest dependency hygiene in the entire project.
  22 +// Adding ANY dependency here is effectively adding it to every plug-in
  23 +// in the ecosystem, forever, until the next major version bump. Be paranoid.
  24 +dependencies {
  25 + api(libs.kotlin.stdlib)
  26 + api(libs.jakarta.validation.api)
  27 +
  28 + testImplementation(libs.junit.jupiter)
  29 + testImplementation(libs.assertk)
  30 +}
  31 +
  32 +tasks.test {
  33 + useJUnitPlatform()
  34 +}
  35 +
  36 +// We override the inherited project version (0.1.0-SNAPSHOT) with the
  37 +// independently-versioned API line, because plug-in authors track this.
  38 +version = providers.gradleProperty("vibeerp.api.version").get()
  39 +
  40 +publishing {
  41 + publications {
  42 + create<MavenPublication>("maven") {
  43 + from(components["java"])
  44 + groupId = "org.vibeerp"
  45 + artifactId = "api-v1"
  46 + version = project.version.toString()
  47 + pom {
  48 + name.set("vibe_erp Public Plug-in API v1")
  49 + description.set("Stable, semver-governed plug-in API for the vibe_erp framework.")
  50 + url.set("https://github.com/vibeerp/vibe-erp")
  51 + licenses {
  52 + license {
  53 + name.set("Apache License 2.0")
  54 + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
  55 + }
  56 + }
  57 + }
  58 + }
  59 + }
  60 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Identifiers.kt 0 → 100644
  1 +package org.vibeerp.api.v1.core
  2 +
  3 +import java.util.UUID
  4 +
  5 +/**
  6 + * Strongly-typed identifier wrapping a UUID.
  7 + *
  8 + * Using `Id<User>` instead of raw `UUID` everywhere prevents the classic
  9 + * mistake of passing a tenant id where a user id was expected — the compiler
  10 + * catches it. The wrapper is a value class so it has zero runtime overhead.
  11 + *
  12 + * @param T phantom type parameter that names what kind of entity this id refers to.
  13 + */
  14 +@JvmInline
  15 +value class Id<T>(val value: UUID) {
  16 + override fun toString(): String = value.toString()
  17 +
  18 + companion object {
  19 + fun <T> of(uuid: String): Id<T> = Id(UUID.fromString(uuid))
  20 + fun <T> random(): Id<T> = Id(UUID.randomUUID())
  21 + }
  22 +}
  23 +
  24 +/**
  25 + * A tenant identifier.
  26 + *
  27 + * Self-hosted single-customer deployments use a single tenant whose id is the
  28 + * literal string "default". Hosted multi-tenant deployments have one [TenantId]
  29 + * per customer organization.
  30 + */
  31 +@JvmInline
  32 +value class TenantId(val value: String) {
  33 + init {
  34 + require(value.isNotBlank()) { "TenantId must not be blank" }
  35 + require(value.length <= 64) { "TenantId must be at most 64 characters" }
  36 + require(value.all { it.isLetterOrDigit() || it == '-' || it == '_' }) {
  37 + "TenantId may only contain letters, digits, '-', or '_' (got '$value')"
  38 + }
  39 + }
  40 +
  41 + override fun toString(): String = value
  42 +
  43 + companion object {
  44 + val DEFAULT = TenantId("default")
  45 + }
  46 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Money.kt 0 → 100644
  1 +package org.vibeerp.api.v1.core
  2 +
  3 +import java.math.BigDecimal
  4 +import java.math.RoundingMode
  5 +
  6 +/**
  7 + * A monetary amount in a specific ISO-4217 currency.
  8 + *
  9 + * vibe_erp NEVER uses raw `Double` or `BigDecimal` for money in api.v1, because
  10 + * that strips currency context and invites silent unit conversion bugs across
  11 + * tenants and locales. Every monetary field on every entity uses [Money].
  12 + *
  13 + * The framework is sold worldwide, so multi-currency is non-negotiable from
  14 + * day one (CLAUDE.md guardrail #6 / i18n).
  15 + */
  16 +data class Money(
  17 + val amount: BigDecimal,
  18 + val currency: Currency,
  19 +) : Comparable<Money> {
  20 +
  21 + init {
  22 + require(amount.scale() <= currency.defaultFractionDigits) {
  23 + "Money amount scale ${amount.scale()} exceeds currency ${currency.code} fraction digits ${currency.defaultFractionDigits}"
  24 + }
  25 + }
  26 +
  27 + operator fun plus(other: Money): Money {
  28 + require(currency == other.currency) {
  29 + "Cannot add Money in different currencies: ${currency.code} vs ${other.currency.code}"
  30 + }
  31 + return Money(amount + other.amount, currency)
  32 + }
  33 +
  34 + operator fun minus(other: Money): Money {
  35 + require(currency == other.currency) {
  36 + "Cannot subtract Money in different currencies: ${currency.code} vs ${other.currency.code}"
  37 + }
  38 + return Money(amount - other.amount, currency)
  39 + }
  40 +
  41 + operator fun times(multiplier: BigDecimal): Money =
  42 + Money(amount.multiply(multiplier).setScale(currency.defaultFractionDigits, RoundingMode.HALF_EVEN), currency)
  43 +
  44 + override fun compareTo(other: Money): Int {
  45 + require(currency == other.currency) {
  46 + "Cannot compare Money in different currencies: ${currency.code} vs ${other.currency.code}"
  47 + }
  48 + return amount.compareTo(other.amount)
  49 + }
  50 +
  51 + companion object {
  52 + fun zero(currency: Currency): Money =
  53 + Money(BigDecimal.ZERO.setScale(currency.defaultFractionDigits), currency)
  54 + }
  55 +}
  56 +
  57 +/**
  58 + * Minimal ISO-4217 currency descriptor.
  59 + *
  60 + * Kept intentionally tiny in api.v1: code + default fraction digits. The
  61 + * full currency catalog (display names, symbols, regional formatting) lives
  62 + * in the i18n layer and is not part of the stable plug-in contract.
  63 + */
  64 +data class Currency(val code: String, val defaultFractionDigits: Int) {
  65 + init {
  66 + require(code.length == 3 && code.all { it.isUpperCase() }) {
  67 + "Currency code must be 3 uppercase letters (got '$code')"
  68 + }
  69 + require(defaultFractionDigits in 0..6) {
  70 + "defaultFractionDigits out of range: $defaultFractionDigits"
  71 + }
  72 + }
  73 +
  74 + companion object {
  75 + val USD = Currency("USD", 2)
  76 + val EUR = Currency("EUR", 2)
  77 + val CNY = Currency("CNY", 2)
  78 + val JPY = Currency("JPY", 0)
  79 + val GBP = Currency("GBP", 2)
  80 + }
  81 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Quantity.kt 0 → 100644
  1 +package org.vibeerp.api.v1.core
  2 +
  3 +import java.math.BigDecimal
  4 +
  5 +/**
  6 + * A quantity in a specific unit of measure.
  7 + *
  8 + * Like [Money], quantities carry their unit so the compiler and the runtime
  9 + * can refuse to add 5 kg to 5 m. The full unit catalog (with conversion
  10 + * factors and locale-aware display) lives in pbc-catalog and is not part of
  11 + * the stable api.v1 surface — only the [UnitOfMeasure] descriptor is.
  12 + */
  13 +data class Quantity(
  14 + val amount: BigDecimal,
  15 + val unit: UnitOfMeasure,
  16 +) : Comparable<Quantity> {
  17 +
  18 + operator fun plus(other: Quantity): Quantity {
  19 + require(unit == other.unit) {
  20 + "Cannot add Quantity in different units: ${unit.code} vs ${other.unit.code}. " +
  21 + "Convert through pbc-catalog before arithmetic."
  22 + }
  23 + return Quantity(amount + other.amount, unit)
  24 + }
  25 +
  26 + operator fun minus(other: Quantity): Quantity {
  27 + require(unit == other.unit) {
  28 + "Cannot subtract Quantity in different units: ${unit.code} vs ${other.unit.code}"
  29 + }
  30 + return Quantity(amount - other.amount, unit)
  31 + }
  32 +
  33 + override fun compareTo(other: Quantity): Int {
  34 + require(unit == other.unit) {
  35 + "Cannot compare Quantity in different units: ${unit.code} vs ${other.unit.code}"
  36 + }
  37 + return amount.compareTo(other.amount)
  38 + }
  39 +}
  40 +
  41 +/**
  42 + * Unit of measure descriptor.
  43 + *
  44 + * `code` is a stable identifier (e.g. "kg", "m", "ea", "sheet"). Display
  45 + * names and conversions are NOT part of api.v1 — they live in pbc-catalog.
  46 + */
  47 +data class UnitOfMeasure(val code: String) {
  48 + init {
  49 + require(code.isNotBlank() && code.length <= 16) {
  50 + "UnitOfMeasure code must be 1-16 characters (got '$code')"
  51 + }
  52 + }
  53 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Result.kt 0 → 100644
  1 +package org.vibeerp.api.v1.core
  2 +
  3 +/**
  4 + * A typed success-or-failure value used across the api.v1 surface.
  5 + *
  6 + * vibe_erp prefers explicit Result types in plug-in-facing APIs over thrown
  7 + * exceptions for *expected* failures (validation errors, permission denials,
  8 + * not-found, optimistic-locking conflicts). Exceptions remain appropriate for
  9 + * unexpected, infrastructural failures (the database is down, etc.).
  10 + *
  11 + * Why: plug-ins are loaded into a host process and a thrown exception that
  12 + * crosses a classloader boundary can be very hard to handle correctly. A typed
  13 + * Result keeps the contract honest.
  14 + */
  15 +sealed interface Result<out T, out E> {
  16 + data class Ok<T>(val value: T) : Result<T, Nothing>
  17 + data class Err<E>(val error: E) : Result<Nothing, E>
  18 +
  19 + fun isOk(): Boolean = this is Ok
  20 + fun isErr(): Boolean = this is Err
  21 +
  22 + fun valueOrNull(): T? = (this as? Ok)?.value
  23 + fun errorOrNull(): E? = (this as? Err)?.error
  24 +
  25 + companion object {
  26 + fun <T> ok(value: T): Result<T, Nothing> = Ok(value)
  27 + fun <E> err(error: E): Result<Nothing, E> = Err(error)
  28 + }
  29 +}
  30 +
  31 +inline fun <T, E, R> Result<T, E>.map(transform: (T) -> R): Result<R, E> = when (this) {
  32 + is Result.Ok -> Result.Ok(transform(value))
  33 + is Result.Err -> this
  34 +}
  35 +
  36 +inline fun <T, E, R> Result<T, E>.flatMap(transform: (T) -> Result<R, E>): Result<R, E> = when (this) {
  37 + is Result.Ok -> transform(value)
  38 + is Result.Err -> this
  39 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/AuditedEntity.kt 0 → 100644
  1 +package org.vibeerp.api.v1.entity
  2 +
  3 +import org.vibeerp.api.v1.security.PrincipalId
  4 +import java.time.Instant
  5 +
  6 +/**
  7 + * An [Entity] that carries who/when audit columns automatically maintained by
  8 + * the platform's JPA listener.
  9 + *
  10 + * Why this exists as a *separate* interface rather than fields on [Entity]:
  11 + * - Some entities (config rows, lookup tables, immutable event records) do
  12 + * not need an updated-at column and forcing one on them invites lying
  13 + * timestamps.
  14 + * - It gives the platform a typed hook to know "this entity wants the audit
  15 + * listener" without reflection-scanning every class.
  16 + * - It documents in the type system that the four columns are populated by
  17 + * the framework, not by the plug-in. Plug-ins should treat the setters
  18 + * as read-only at the application level — the values are filled in at
  19 + * flush time from the current [org.vibeerp.api.v1.security.Principal].
  20 + *
  21 + * The four columns mirror what every serious ERP records on every business
  22 + * row and what GDPR/SOX-style audits expect to find.
  23 + */
  24 +interface AuditedEntity : Entity {
  25 +
  26 + /** When this row was first inserted. UTC. Set once, never updated. */
  27 + val createdAt: Instant
  28 +
  29 + /** Principal that performed the insert. */
  30 + val createdBy: PrincipalId
  31 +
  32 + /** When this row was last updated. UTC. Equal to [createdAt] until first update. */
  33 + val updatedAt: Instant
  34 +
  35 + /** Principal that performed the most recent update. */
  36 + val updatedBy: PrincipalId
  37 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/CustomField.kt 0 → 100644
  1 +package org.vibeerp.api.v1.entity
  2 +
  3 +/**
  4 + * Declarative description of a custom field added to an existing entity.
  5 + *
  6 + * Custom fields are how the framework satisfies guardrail #1 ("core stays
  7 + * domain-agnostic") in practice: a printing shop that needs a `plate_gauge`
  8 + * column on the catalog item entity does not patch `pbc-catalog`. They add
  9 + * a [CustomField] row through the no-code UI (Tier 1) or through a plug-in
  10 + * manifest (Tier 2), and the JSONB `ext` column on the catalog item table
  11 + * starts carrying the value the same instant.
  12 + *
  13 + * The form builder, list view designer, OpenAPI generator, AI agent function
  14 + * catalog, and DSAR/erasure jobs all consume this descriptor. There is
  15 + * intentionally no parallel source of truth.
  16 + *
  17 + * @property key Stable identifier for this field within [targetEntity]. Must be
  18 + * stable across upgrades because it is the JSON key inside the `ext` column.
  19 + * Convention: `snake_case`, prefixed by the owning plug-in or PBC, e.g.
  20 + * `printingshop_plate_gauge`.
  21 + * @property targetEntity Name of the entity this field is attached to. Must
  22 + * be discoverable through [EntityRegistry] at registration time.
  23 + * @property type The field's data type — see [FieldType].
  24 + * @property required Whether the value must be present on insert/update.
  25 + * @property pii Whether this field contains personally identifiable information.
  26 + * Drives automatic DSAR exports and erasure jobs (architecture spec section 8,
  27 + * "Data sovereignty"). Setting this wrong is a compliance bug, not a styling
  28 + * choice — when in doubt, mark it `true`.
  29 + * @property labelTranslations Locale code → human-readable label. The framework
  30 + * refuses to render a custom field that has no label for the current locale
  31 + * *and* no entry for the configured fallback locale; this prevents the
  32 + * classic "key string leaked to the UI" failure mode.
  33 + */
  34 +data class CustomField(
  35 + val key: String,
  36 + val targetEntity: String,
  37 + val type: FieldType,
  38 + val required: Boolean = false,
  39 + val pii: Boolean = false,
  40 + val labelTranslations: Map<String, String> = emptyMap(),
  41 +) {
  42 + init {
  43 + require(key.isNotBlank()) { "CustomField key must not be blank" }
  44 + require(key.length <= 64) { "CustomField key must be at most 64 characters (got '$key')" }
  45 + require(key.all { it.isLetterOrDigit() || it == '_' }) {
  46 + "CustomField key may only contain letters, digits, and '_' (got '$key')"
  47 + }
  48 + require(targetEntity.isNotBlank()) { "CustomField targetEntity must not be blank" }
  49 + }
  50 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/Entity.kt 0 → 100644
  1 +package org.vibeerp.api.v1.entity
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.core.TenantId
  5 +
  6 +/**
  7 + * Marker interface for every domain entity that lives in the vibe_erp data model.
  8 + *
  9 + * Why this exists at all:
  10 + * - It enforces, at the type level, that every entity carries a tenant id.
  11 + * Multi-tenancy is an architectural guardrail (CLAUDE.md #5) and the cheapest
  12 + * place to enforce it is the type system. A repository signature like
  13 + * `Repository<T : Entity>` cannot be instantiated for a type that "forgot"
  14 + * its tenant id.
  15 + * - It gives the runtime a single hook for tenant filtering, JSONB `ext`
  16 + * handling, and audit interception without each PBC reinventing it.
  17 + * - It is intentionally minimal: no equality, no hashCode, no lifecycle
  18 + * callbacks. Those are concerns of `platform.persistence`, not the public
  19 + * contract. Adding them later is a breaking change; leaving them out is not.
  20 + *
  21 + * Plug-ins implement this on their own data classes. They never see the
  22 + * underlying JPA `@Entity` annotation — that lives in the platform's internal
  23 + * mapping layer.
  24 + *
  25 + * ```
  26 + * data class Plate(
  27 + * override val id: Id<Plate>,
  28 + * override val tenantId: TenantId,
  29 + * val gauge: Quantity,
  30 + * ) : Entity
  31 + * ```
  32 + */
  33 +interface Entity {
  34 + /**
  35 + * The strongly-typed identifier for this entity. Using `Id<*>` here (rather
  36 + * than `Id<Self>`) keeps the marker contravariant-friendly so polymorphic
  37 + * collections of `Entity` can be passed across api.v1 boundaries without
  38 + * generic gymnastics. Concrete implementations should narrow the type
  39 + * parameter to themselves.
  40 + */
  41 + val id: Id<*>
  42 +
  43 + /**
  44 + * The tenant that owns this row. Required on every business entity, even in
  45 + * single-tenant self-hosted deployments (where the value is
  46 + * [TenantId.DEFAULT]) — there is intentionally one code path for both
  47 + * deployment shapes.
  48 + */
  49 + val tenantId: TenantId
  50 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/EntityRegistry.kt 0 → 100644
  1 +package org.vibeerp.api.v1.entity
  2 +
  3 +/**
  4 + * Runtime catalog of every entity the framework knows about — core PBC
  5 + * entities, plug-in entities, and Tier 1 user-defined entities.
  6 + *
  7 + * Why this is part of api.v1:
  8 + * - Plug-ins need to introspect entities at runtime to render generic UIs,
  9 + * drive the OpenAPI generator, and resolve [FieldType.Reference] targets,
  10 + * *without* depending on the concrete PBC that owns each entity (which
  11 + * would violate guardrail #9, "PBC boundaries are sacred").
  12 + * - It is the seam through which Tier 1 user-defined entities and Tier 2
  13 + * plug-in entities look identical to consumers. Neither caller cares
  14 + * where an entity descriptor came from.
  15 + *
  16 + * The registry is read-only from the plug-in's perspective. Registration
  17 + * happens during plug-in startup via [org.vibeerp.api.v1.plugin.PluginContext]
  18 + * helpers that the platform implements internally.
  19 + */
  20 +interface EntityRegistry {
  21 +
  22 + /**
  23 + * Look up an entity descriptor by its registered name.
  24 + *
  25 + * @return the descriptor, or `null` if no entity with that name exists.
  26 + * Plug-ins should not throw on a miss — entities can be uninstalled
  27 + * while a workflow that referenced them is mid-flight.
  28 + */
  29 + fun describe(entityName: String): EntityDescriptor?
  30 +
  31 + /**
  32 + * Snapshot of every currently-registered entity name. Default
  33 + * implementation returns an empty set so adding new lookup methods later
  34 + * (e.g. filtered by source) is non-breaking for existing host
  35 + * implementations.
  36 + */
  37 + fun entityNames(): Set<String> = emptySet()
  38 +}
  39 +
  40 +/**
  41 + * Lightweight description of an entity, returned by [EntityRegistry.describe].
  42 + *
  43 + * Kept intentionally small. The full mapping (table name, column types,
  44 + * Hibernate metadata) lives in the platform internals; api.v1 exposes only
  45 + * what plug-ins legitimately need to render generic UIs and validate input.
  46 + *
  47 + * @property name Stable, globally unique entity name, e.g. `catalog.item` or
  48 + * `plugin_printingshop.plate_spec`.
  49 + * @property pluginId The plug-in id that owns this entity, or `null` if it
  50 + * belongs to a core PBC.
  51 + * @property customFields Custom fields currently declared against this entity
  52 + * for the calling tenant. Note: this list is *tenant-scoped* — two tenants
  53 + * on the same host can see different custom fields on the same entity.
  54 + * @property auditable Whether the entity implements [AuditedEntity] and
  55 + * therefore carries the four audit columns.
  56 + */
  57 +data class EntityDescriptor(
  58 + val name: String,
  59 + val pluginId: String? = null,
  60 + val customFields: List<CustomField> = emptyList(),
  61 + val auditable: Boolean = false,
  62 +) {
  63 + init {
  64 + require(name.isNotBlank()) { "EntityDescriptor name must not be blank" }
  65 + }
  66 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/FieldType.kt 0 → 100644
  1 +package org.vibeerp.api.v1.entity
  2 +
  3 +/**
  4 + * The closed set of field types that vibe_erp's metadata layer can describe.
  5 + *
  6 + * Why this is a sealed interface and not an enum:
  7 + * - Several types carry their own configuration (max length on STRING,
  8 + * precision on DECIMAL, target entity on REFERENCE, allowed values on
  9 + * ENUM). An enum cannot do that without a parallel `Map<Type, Config>`
  10 + * table that the type system cannot keep in sync.
  11 + * - It is closed deliberately. Adding a new field type is an api.v1 change
  12 + * requiring a release; allowing arbitrary new field types in user metadata
  13 + * would defeat the form designer, the OpenAPI generator, the AI function
  14 + * catalog, and the PII/DSAR pipeline, all of which switch on this set.
  15 + *
  16 + * Field types are used in two places:
  17 + * 1. [CustomField] declarations, where a key user adds a column to an
  18 + * existing entity through the no-code UI.
  19 + * 2. Plug-in entity descriptors registered through [EntityRegistry], so the
  20 + * same UI machinery (form builder, list view, OpenAPI) works on plug-in
  21 + * entities without the plug-in shipping its own React code.
  22 + *
  23 + * Storage shape:
  24 + * - Custom-field values live in the JSONB `ext` column on the host entity's
  25 + * table (architecture spec section 8). The field type drives JSON encoding,
  26 + * GIN-index strategy, validation, and OpenAPI schema generation.
  27 + */
  28 +sealed interface FieldType {
  29 +
  30 + /** Free text. [maxLength] caps storage and form input length. */
  31 + data class String(val maxLength: Int = 255) : FieldType {
  32 + init {
  33 + require(maxLength in 1..1_048_576) { "STRING maxLength out of range: $maxLength" }
  34 + }
  35 + }
  36 +
  37 + /** 64-bit signed integer. */
  38 + data object Integer : FieldType
  39 +
  40 + /**
  41 + * Fixed-point decimal stored as a JSON string to preserve scale.
  42 + * [precision] is total digits, [scale] is digits after the decimal point.
  43 + */
  44 + data class Decimal(val precision: Int = 19, val scale: Int = 4) : FieldType {
  45 + init {
  46 + require(precision in 1..38) { "DECIMAL precision out of range: $precision" }
  47 + require(scale in 0..precision) { "DECIMAL scale out of range: $scale" }
  48 + }
  49 + }
  50 +
  51 + /** Boolean. */
  52 + data object Boolean : FieldType
  53 +
  54 + /** Calendar date with no time-of-day and no zone. */
  55 + data object Date : FieldType
  56 +
  57 + /** Instant on the timeline (stored UTC, rendered in the user's locale). */
  58 + data object DateTime : FieldType
  59 +
  60 + /** RFC-4122 UUID. Distinct from [Reference] — a free-floating identifier. */
  61 + data object Uuid : FieldType
  62 +
  63 + /**
  64 + * A monetary value. The currency is part of the *value*, not the field
  65 + * descriptor — see [org.vibeerp.api.v1.core.Money]. This means a single
  66 + * `total_amount` field can legitimately hold mixed-currency rows.
  67 + */
  68 + data object Money : FieldType
  69 +
  70 + /** A quantity with a unit of measure attached to the value. */
  71 + data object Quantity : FieldType
  72 +
  73 + /**
  74 + * A foreign-key-style pointer to another entity by name.
  75 + *
  76 + * The reference is resolved by [EntityRegistry] at runtime, which is what
  77 + * lets a Tier 1 user create a custom field that points at a plug-in
  78 + * entity without anyone writing JPA mappings.
  79 + */
  80 + data class Reference(val targetEntity: kotlin.String) : FieldType {
  81 + init {
  82 + require(targetEntity.isNotBlank()) { "REFERENCE targetEntity must not be blank" }
  83 + }
  84 + }
  85 +
  86 + /**
  87 + * A closed set of allowed string values. The values themselves are stored
  88 + * as plain strings; their human-readable labels live in the i18n bundles
  89 + * keyed by `<entity>.<field>.<value>`.
  90 + */
  91 + data class Enum(val allowedValues: List<kotlin.String>) : FieldType {
  92 + init {
  93 + require(allowedValues.isNotEmpty()) { "ENUM must declare at least one allowed value" }
  94 + require(allowedValues.toSet().size == allowedValues.size) { "ENUM allowedValues must be unique" }
  95 + }
  96 + }
  97 +
  98 + /**
  99 + * Arbitrary structured JSON. Use sparingly — JSON fields are opaque to
  100 + * the form builder, the list view, and the OpenAPI schema generator.
  101 + * Prefer a structured type when one fits.
  102 + */
  103 + data object Json : FieldType
  104 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/DomainEvent.kt 0 → 100644
  1 +package org.vibeerp.api.v1.event
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.core.TenantId
  5 +import java.time.Instant
  6 +
  7 +/**
  8 + * Base contract for every domain event published on the vibe_erp event bus.
  9 + *
  10 + * Why a sealed-style typed contract instead of "publish any object":
  11 + * - PBC boundaries are sacred (CLAUDE.md guardrail #9). PBCs talk to each
  12 + * other only through events or through `api.v1.ext.<pbc>` interfaces. If
  13 + * events were untyped, every consumer would have to know every producer's
  14 + * class, which is exactly the coupling we are trying to avoid.
  15 + * - The standard envelope (`eventId`, `tenantId`, `occurredAt`,
  16 + * `aggregateType`, `aggregateId`) is what the platform's outbox table,
  17 + * audit log, and future Kafka/NATS bridge all rely on. Plug-ins that
  18 + * forget any of these fields cannot misroute or replay events.
  19 + *
  20 + * Note that this is `interface`, not `sealed interface`: plug-ins must be
  21 + * free to *extend* the hierarchy with their own concrete event classes.
  22 + * "Sealed" here means "every plug-in event must extend this sealed envelope",
  23 + * not "the set of envelope shapes is closed". A truly closed hierarchy would
  24 + * make plug-ins impossible.
  25 + *
  26 + * ```
  27 + * data class PlateApproved(
  28 + * override val eventId: Id<DomainEvent> = Id.random(),
  29 + * override val tenantId: TenantId,
  30 + * override val occurredAt: Instant = Instant.now(),
  31 + * val plateId: Id<*>,
  32 + * ) : DomainEvent {
  33 + * override val aggregateType = "printingshop.plate"
  34 + * override val aggregateId = plateId.toString()
  35 + * }
  36 + * ```
  37 + */
  38 +interface DomainEvent {
  39 +
  40 + /** Unique id for this event instance. Used for idempotent consumer logic and outbox dedup. */
  41 + val eventId: Id<DomainEvent>
  42 +
  43 + /** Tenant the event belongs to. Carried explicitly because background consumers run outside any HTTP request. */
  44 + val tenantId: TenantId
  45 +
  46 + /** When the event occurred (UTC). Set by the producer, not by the bus, so replays preserve original timing. */
  47 + val occurredAt: Instant
  48 +
  49 + /**
  50 + * Logical name of the aggregate this event is about, e.g.
  51 + * `orders_sales.order` or `plugin_printingshop.plate`. Used for routing,
  52 + * audit views, and the future event-streaming bridge.
  53 + */
  54 + val aggregateType: String
  55 +
  56 + /**
  57 + * String form of the aggregate's id. String (not [Id]) because some
  58 + * aggregates are keyed by composite or natural keys and the bus must
  59 + * route them all.
  60 + */
  61 + val aggregateId: String
  62 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventBus.kt 0 → 100644
  1 +package org.vibeerp.api.v1.event
  2 +
  3 +/**
  4 + * Publish/subscribe contract for domain events.
  5 + *
  6 + * The host's implementation is backed by an in-process bus plus a Postgres
  7 + * **outbox** table (architecture spec section 9, "Cross-cutting concerns").
  8 + * The outbox is what makes "modular monolith now, splittable later" real:
  9 + * the day the framework grows a Kafka or NATS bridge, the existing
  10 + * publish/subscribe code in every PBC and plug-in keeps working unchanged.
  11 + *
  12 + * Why this is the only way plug-ins emit events:
  13 + * - The publish path goes through the platform so it can attach the current
  14 + * tenant, principal, and request id automatically; a plug-in that bypasses
  15 + * [EventBus] would lose all of that and break the audit trail.
  16 + * - The subscribe path is the platform's only place to track listener
  17 + * registrations per plug-in classloader, so unloading a plug-in cleanly
  18 + * removes its listeners.
  19 + *
  20 + * Both methods are deliberately non-suspending and Java-friendly (`Class<E>`
  21 + * over `KClass<E>`) so the contract is callable from any JVM language a
  22 + * plug-in author chooses.
  23 + */
  24 +interface EventBus {
  25 +
  26 + /**
  27 + * Publish an event. The bus is responsible for delivering it to every
  28 + * matching listener and for persisting an outbox row inside the current
  29 + * transaction (so a publish followed by a rollback never escapes).
  30 + */
  31 + fun publish(event: DomainEvent)
  32 +
  33 + /**
  34 + * Register a listener for events of [eventType] (and its subtypes).
  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.
  39 + */
  40 + fun <E : DomainEvent> subscribe(eventType: Class<E>, listener: EventListener<E>)
  41 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventListener.kt 0 → 100644
  1 +package org.vibeerp.api.v1.event
  2 +
  3 +/**
  4 + * Single-method listener for domain events.
  5 + *
  6 + * Declared as `fun interface` so plug-ins can register listeners with a Kotlin
  7 + * lambda or a method reference, without writing an anonymous-class boilerplate
  8 + * shell:
  9 + *
  10 + * ```
  11 + * eventBus.subscribe(PlateApproved::class.java) { event ->
  12 + * logger.info("Plate ${event.plateId} approved")
  13 + * }
  14 + * ```
  15 + *
  16 + * Listeners run on the thread the bus chooses, which may be the publisher's
  17 + * thread (in-process synchronous), a worker thread (in-process async), or a
  18 + * dedicated outbox-drain thread when reliability is required. Implementations
  19 + * MUST be thread-safe and MUST NOT assume the publisher's transaction is
  20 + * still open — if a listener needs its own transaction, it must request one
  21 + * through [org.vibeerp.api.v1.persistence.Transaction].
  22 + */
  23 +fun interface EventListener<E : DomainEvent> {
  24 + fun handle(event: E)
  25 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt 0 → 100644
  1 +package org.vibeerp.api.v1.ext.identity
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.core.TenantId
  5 +
  6 +/**
  7 + * Cross-PBC facade for the identity bounded context.
  8 + *
  9 + * **This file is the proof that PBCs talk to each other through `api.v1`,
  10 + * not directly.** Guardrail #9 ("PBC boundaries are sacred") is not just a
  11 + * convention; it is enforced by the Gradle build, which forbids
  12 + * `pbc-orders-sales` from declaring `pbc-identity` as a dependency. The only
  13 + * way `pbc-orders-sales` (or any plug-in) can ask "who is the user with this
  14 + * username?" is by injecting [IdentityApi] — an interface declared here, in
  15 + * api.v1, and implemented by `pbc-identity` at runtime.
  16 + *
  17 + * If [IdentityApi] grows a method that leaks identity-internal types, the
  18 + * abstraction has failed and the method must be reshaped. The whole point of
  19 + * this package is to expose intent (`UserRef`) without exposing the storage
  20 + * model (`IdentityUserEntity`, `IdentityUserRow`, etc.).
  21 + *
  22 + * Future PBCs will get sibling packages (`api.v1.ext.catalog`,
  23 + * `api.v1.ext.inventory`, …) following the same pattern.
  24 + */
  25 +interface IdentityApi {
  26 +
  27 + /**
  28 + * Look up a user by their unique username within the current tenant.
  29 + * Returns `null` when no such user exists or when the calling principal
  30 + * lacks permission to read user records.
  31 + */
  32 + fun findUserByUsername(username: String): UserRef?
  33 +
  34 + /**
  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.
  38 + */
  39 + fun findUserById(id: Id<*>): UserRef?
  40 +}
  41 +
  42 +/**
  43 + * Minimal, safe-to-publish view of a user.
  44 + *
  45 + * Notably absent: password hash, MFA secrets, last login IP, anything from
  46 + * the `identity__user_security` table. Anything that does not appear here
  47 + * cannot be observed cross-PBC, and that is intentional — adding a field is
  48 + * a deliberate api.v1 change and triggers a security review.
  49 + *
  50 + * @property id Stable user id.
  51 + * @property username Login handle. Unique within [tenantId].
  52 + * @property tenantId Owning tenant.
  53 + * @property displayName Human-readable name for UI rendering. Falls back to
  54 + * [username] when the user has not set one.
  55 + * @property enabled Whether the account is active. A disabled user can still
  56 + * appear as an audit-column foreign key, which is why this field exists at
  57 + * all — callers must check it before treating the user as a current actor.
  58 + */
  59 +data class UserRef(
  60 + val id: Id<UserRef>,
  61 + val username: String,
  62 + val tenantId: TenantId,
  63 + val displayName: String,
  64 + val enabled: Boolean,
  65 +)
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/form/FormSchema.kt 0 → 100644
  1 +package org.vibeerp.api.v1.form
  2 +
  3 +/**
  4 + * A form definition shipped by a plug-in or stored in the metadata table.
  5 + *
  6 + * Why two strings instead of a structured Kotlin model:
  7 + * - The form designer in the web UI works in JSON Schema + UI Schema (the
  8 + * same shape used by Frappe Doctype, ERPNext form layouts, and the
  9 + * `@rjsf/core` React renderer). Re-modeling that as Kotlin classes would
  10 + * create a parallel source of truth that drifts the moment a renderer
  11 + * feature is added.
  12 + * - api.v1 must not depend on a specific JSON parser (Jackson is forbidden).
  13 + * Holding the JSON as opaque strings means the platform parses it once,
  14 + * inside the platform layer, and plug-ins never need a JSON dependency
  15 + * just to ship a form.
  16 + * - The richer typed wrapper API lands in v0.2 of the api.v1 line as a
  17 + * purely additive layer. Until then, this is the smallest contract that
  18 + * lets a plug-in ship a working form.
  19 + *
  20 + * @property jsonSchema A JSON Schema (draft 2020-12) document describing
  21 + * the data shape of the form. The host validates user input against this
  22 + * schema before passing it to the plug-in.
  23 + * @property uiSchema A UI Schema document (the `@rjsf/core` convention)
  24 + * describing widget choices, ordering, and layout hints. May be empty
  25 + * string when the default rendering is acceptable.
  26 + * @property version Monotonically increasing schema version. The host
  27 + * stores submitted form values tagged with the version they were captured
  28 + * under so a plug-in upgrade does not retroactively invalidate old
  29 + * records. Bump this when the [jsonSchema] changes shape, not when the
  30 + * [uiSchema] is merely restyled.
  31 + */
  32 +data class FormSchema(
  33 + val jsonSchema: String,
  34 + val uiSchema: String,
  35 + val version: Int,
  36 +) {
  37 + init {
  38 + require(jsonSchema.isNotBlank()) { "FormSchema jsonSchema must not be blank" }
  39 + require(version >= 1) { "FormSchema version must be >= 1 (got $version)" }
  40 + }
  41 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/PluginEndpoint.kt 0 → 100644
  1 +package org.vibeerp.api.v1.http
  2 +
  3 +/**
  4 + * Marks a class as a plug-in HTTP controller.
  5 + *
  6 + * Why this is a vibe_erp-owned annotation rather than `@RestController`:
  7 + * - api.v1 must not leak Spring types to plug-ins (CLAUDE.md guardrail #10).
  8 + * A plug-in that imports `org.springframework.web.bind.annotation.RestController`
  9 + * is grade-D and rejected at install time.
  10 + * - The host scans for `@PluginEndpoint` inside each plug-in's child Spring
  11 + * context and mounts the controller automatically under the prefix
  12 + * `/api/v1/plugins/<plugin-id>` + [basePath]. The plug-in author never
  13 + * chooses the absolute URL — that prevents two plug-ins from colliding on
  14 + * the same path and gives the OpenAPI generator one source of truth.
  15 + * - The host applies authentication, tenant resolution, locale resolution,
  16 + * request id propagation, structured logging, and the `RequestContext`
  17 + * binding to every method on a `@PluginEndpoint` class, so plug-ins
  18 + * inherit all of that without any per-controller setup.
  19 + *
  20 + * The actual HTTP method bindings (GET/POST/etc.), request body parsing, and
  21 + * response serialization are still expressed using a small set of api.v1
  22 + * annotations to be added in subsequent releases. Until then, the host
  23 + * delegates to standard JAX-RS / Spring Web binding inside the platform
  24 + * layer; the annotation surface visible to plug-ins is intentionally minimal
  25 + * in v1.0 so it can grow deliberately.
  26 + *
  27 + * ```
  28 + * @PluginEndpoint(basePath = "/plates")
  29 + * class PlateController(private val ctx: RequestContext) {
  30 + * // mounted at /api/v1/plugins/printingshop/plates
  31 + * }
  32 + * ```
  33 + */
  34 +@Target(AnnotationTarget.CLASS)
  35 +@Retention(AnnotationRetention.RUNTIME)
  36 +@MustBeDocumented
  37 +annotation class PluginEndpoint(
  38 + /**
  39 + * Path under the plug-in's namespace, beginning with `/`. The full URL
  40 + * is `/api/v1/plugins/<plugin-id><basePath>`.
  41 + */
  42 + val basePath: String,
  43 +)
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/RequestContext.kt 0 → 100644
  1 +package org.vibeerp.api.v1.http
  2 +
  3 +import org.vibeerp.api.v1.core.TenantId
  4 +import org.vibeerp.api.v1.security.Principal
  5 +import java.util.Locale
  6 +
  7 +/**
  8 + * Per-request context, available by injection inside any class annotated with
  9 + * [PluginEndpoint].
  10 + *
  11 + * Why an explicit context object instead of "scattered ThreadLocals":
  12 + * - Plug-ins live in their own classloader, so a static `ThreadLocal` lookup
  13 + * inside api.v1 would have to bridge classloaders to find the host's
  14 + * state. An injected context is cleaner, testable, and forces the
  15 + * plug-in author to declare a dependency on the values they use.
  16 + * - It is the single answer to "who, where, when, which language, which
  17 + * request" that every other api.v1 surface (permission check, translator,
  18 + * repository, event bus) consults under the covers.
  19 + *
  20 + * The host provides one [RequestContext] per HTTP request and per workflow
  21 + * task execution. Background jobs receive a context whose [principal] is a
  22 + * [org.vibeerp.api.v1.security.Principal.System] and whose [requestId] is
  23 + * the job execution id.
  24 + */
  25 +interface RequestContext {
  26 +
  27 + /** The authenticated caller. Never `null`; anonymous endpoints get a system principal. */
  28 + fun principal(): Principal
  29 +
  30 + /**
  31 + * The tenant the request is operating in. Always equal to
  32 + * `principal().tenantId` in normal flows, but separated for clarity in
  33 + * "act as" / impersonation scenarios where the two could legitimately
  34 + * differ.
  35 + */
  36 + fun tenantId(): TenantId
  37 +
  38 + /** The resolved request locale. Same value [org.vibeerp.api.v1.i18n.LocaleProvider.current] would return. */
  39 + fun locale(): Locale
  40 +
  41 + /**
  42 + * Correlation id for this request, propagated to logs, downstream calls,
  43 + * and emitted events. Stable across the whole request lifecycle.
  44 + */
  45 + fun requestId(): String
  46 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/LocaleProvider.kt 0 → 100644
  1 +package org.vibeerp.api.v1.i18n
  2 +
  3 +import java.util.Locale
  4 +
  5 +/**
  6 + * Resolves the locale of the current operation.
  7 + *
  8 + * Why a separate interface from [Translator]:
  9 + * - The decision "what locale am I in right now?" is the host's
  10 + * responsibility, not the plug-in's. The host walks a precedence chain
  11 + * (HTTP `Accept-Language` → user preference → tenant default → system
  12 + * default) on every request and on every background job, and a plug-in
  13 + * must NEVER reach into HTTP headers itself — it would break for
  14 + * non-HTTP callers (workflow tasks, scheduled jobs, MCP agents).
  15 + * - Splitting it from [Translator] keeps the translator pure and testable
  16 + * (you can pass any [Locale] to `translate`), while [LocaleProvider] is
  17 + * the impure side that knows about the ambient request.
  18 + *
  19 + * Outside of an HTTP request (background jobs, workflow tasks, system
  20 + * principals), the host returns the tenant's default locale, then the
  21 + * platform default. There is always *some* locale; this method never throws.
  22 + */
  23 +interface LocaleProvider {
  24 +
  25 + /** The locale to use for the current operation. Never `null`. */
  26 + fun current(): Locale
  27 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/MessageKey.kt 0 → 100644
  1 +package org.vibeerp.api.v1.i18n
  2 +
  3 +/**
  4 + * Type-safe wrapper around an i18n message key.
  5 + *
  6 + * Why a value class instead of "just a String":
  7 + * - Translator method signatures take `MessageKey`, not `String`, so the
  8 + * compiler refuses to pass a raw user-facing English sentence into the
  9 + * translator. That single rule eliminates the most common i18n bug:
  10 + * "we forgot to translate this one place".
  11 + * - Zero runtime overhead — `@JvmInline` erases to a `String` at runtime.
  12 + *
  13 + * Naming convention is `<plugin-or-pbc>.<area>.<message>`. Examples:
  14 + * - `identity.user.created`
  15 + * - `catalog.item.duplicate_sku`
  16 + * - `printingshop.plate.approval_required`
  17 + *
  18 + * The convention is enforced by [init] because once a key is shipped to a
  19 + * tenant's translation overrides table, renaming it costs everyone money.
  20 + */
  21 +@JvmInline
  22 +value class MessageKey(val key: String) {
  23 + init {
  24 + require(key.isNotBlank()) { "MessageKey must not be blank" }
  25 + require(KEY_REGEX.matches(key)) {
  26 + "MessageKey must look like '<area>.<resource>.<message>' (got '$key')"
  27 + }
  28 + }
  29 +
  30 + override fun toString(): String = key
  31 +
  32 + companion object {
  33 + private val KEY_REGEX = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*){2,}$")
  34 + }
  35 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/i18n/Translator.kt 0 → 100644
  1 +package org.vibeerp.api.v1.i18n
  2 +
  3 +import java.util.Locale
  4 +
  5 +/**
  6 + * Plug-in-facing translation lookup.
  7 + *
  8 + * Why this is a single small interface and not a fluent builder:
  9 + * - i18n is a guardrail (CLAUDE.md #6). The interface a plug-in author sees
  10 + * must be tiny and impossible to use wrong; otherwise plug-ins will hard-
  11 + * code English strings "just for now" and never come back.
  12 + * - The host implements this with ICU4J `MessageFormat` so plurals, gender,
  13 + * number/date/currency formatting all work without the plug-in knowing.
  14 + * - The lookup goes through the metadata translation overrides table first,
  15 + * then the plug-in's own bundles, then the platform's bundles, then the
  16 + * fallback locale, then the key itself. None of that is the plug-in's
  17 + * problem.
  18 + *
  19 + * ```
  20 + * translator.translate(
  21 + * MessageKey("printingshop.plate.approved"),
  22 + * localeProvider.current(),
  23 + * mapOf("plateId" to plate.id, "user" to user.username),
  24 + * )
  25 + * ```
  26 + */
  27 +interface Translator {
  28 +
  29 + /**
  30 + * Resolve a message in the given locale, substituting the named arguments.
  31 + *
  32 + * @param key the message key to look up.
  33 + * @param locale the locale to render in. Use [LocaleProvider.current] to
  34 + * pick up the request locale automatically.
  35 + * @param args named arguments referenced from the message text using ICU
  36 + * MessageFormat placeholders, e.g. `{user}`, `{count, plural, ...}`.
  37 + * @return the translated string. NEVER `null` — when nothing matches, the
  38 + * host returns the key itself so the failure is visible in the UI rather
  39 + * than silently swallowed.
  40 + */
  41 + fun translate(key: MessageKey, locale: Locale, args: Map<String, Any> = emptyMap()): String
  42 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/package-info.kt 0 → 100644
  1 +/**
  2 + * vibe_erp Public Plug-in API v1.
  3 + *
  4 + * THE STABILITY CONTRACT
  5 + * ----------------------
  6 + * Everything declared under [org.vibeerp.api.v1] is the public, semver-governed
  7 + * surface of the vibe_erp framework. Plug-in authors depend on this package and
  8 + * only this package.
  9 + *
  10 + * Within the 1.x release line:
  11 + * • Adding new types is allowed.
  12 + * • Adding new methods to existing interfaces is allowed ONLY when a default
  13 + * implementation is provided so existing plug-ins continue to compile and load.
  14 + * • Renaming, removing, or changing the observable behavior of any symbol is
  15 + * a breaking change and requires a major-version bump (api.v2).
  16 + *
  17 + * Across a major version, the previous major (api.v1) ships side-by-side with
  18 + * the new major (api.v2) for at least one major release window so plug-in
  19 + * authors have a real migration path.
  20 + *
  21 + * WHAT IS NOT IN HERE
  22 + * -------------------
  23 + * Anything not in [org.vibeerp.api.v1] — including everything in
  24 + * `org.vibeerp.platform.*` and `org.vibeerp.pbc.*` — is INTERNAL. Plug-ins
  25 + * that import internal classes are graded "D" (unsupported) and are rejected
  26 + * by the plug-in linter at install time.
  27 + *
  28 + * See `docs/superpowers/specs/2026-04-07-vibe-erp-architecture-design.md`
  29 + * sections 4 and 6 for the full extensibility model and api.v1 surface plan.
  30 + */
  31 +package org.vibeerp.api.v1
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Page.kt 0 → 100644
  1 +package org.vibeerp.api.v1.persistence
  2 +
  3 +/**
  4 + * One page of results returned from [Repository.findAll].
  5 + *
  6 + * Why a dedicated type rather than `Pair<List<T>, Long>`:
  7 + * - Named fields make the wire format and the AI-agent function catalog
  8 + * self-describing. "What does the second element of the pair mean?" is
  9 + * the kind of question that makes plug-in authors miserable.
  10 + * - Adding fields later (e.g. a continuation token for keyset pagination)
  11 + * is non-breaking. Adding a third tuple element is.
  12 + *
  13 + * `totalElements` is intentionally `Long` because some PBCs (audit log,
  14 + * inventory movements) routinely cross the 32-bit row count.
  15 + */
  16 +data class Page<T>(
  17 + val content: List<T>,
  18 + val pageNumber: Int,
  19 + val pageSize: Int,
  20 + val totalElements: Long,
  21 +) {
  22 + init {
  23 + require(pageNumber >= 0) { "Page pageNumber must be >= 0 (got $pageNumber)" }
  24 + require(pageSize >= 1) { "Page pageSize must be >= 1 (got $pageSize)" }
  25 + require(totalElements >= 0) { "Page totalElements must be >= 0 (got $totalElements)" }
  26 + }
  27 +
  28 + /** Number of pages, given [pageSize] and [totalElements]. */
  29 + val totalPages: Int
  30 + get() = if (totalElements == 0L) 0 else (((totalElements - 1) / pageSize) + 1).toInt()
  31 +
  32 + /** Whether at least one more page exists after this one. */
  33 + val hasNext: Boolean
  34 + get() = (pageNumber + 1) < totalPages
  35 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Query.kt 0 → 100644
  1 +package org.vibeerp.api.v1.persistence
  2 +
  3 +/**
  4 + * Declarative query model used by [Repository.findAll].
  5 + *
  6 + * Why a structured query object instead of "Spring Data Specification" or a
  7 + * raw string DSL:
  8 + * - The whole api.v1 surface is forbidden from leaking Spring types
  9 + * (CLAUDE.md guardrail #10). Spring Data's `Specification` is out.
  10 + * - A typed model is what makes the OpenAPI generator and the AI agent
  11 + * function catalog possible: every supported filter shape is enumerable.
  12 + * - It can be serialized and shipped over the wire, which is how the
  13 + * cross-process query path will work when the framework eventually grows
  14 + * a remote-PBC mode.
  15 + *
  16 + * The model is intentionally small — equality, ordering, set membership, a
  17 + * SQL `LIKE`, null checks, and boolean composition. Anything more exotic
  18 + * (full-text search, geo, vector) belongs in a dedicated capability behind a
  19 + * separate api.v1 interface, not bolted onto the generic repository.
  20 + */
  21 +data class Query(
  22 + val filters: List<Filter> = emptyList(),
  23 + val sort: List<SortClause> = emptyList(),
  24 + val pageRequest: PageRequest = PageRequest.FIRST,
  25 +)
  26 +
  27 +/**
  28 + * The closed set of supported filter shapes.
  29 + *
  30 + * Sealed so the platform can pattern-match exhaustively when translating to
  31 + * SQL, and so adding a new shape is a deliberate api.v1 change.
  32 + */
  33 +sealed interface Filter {
  34 +
  35 + /** Field equals value. `value` may be `null` to match SQL `IS NULL`. */
  36 + data class Eq(val field: String, val value: Any?) : Filter
  37 +
  38 + /** Field does not equal value. */
  39 + data class Ne(val field: String, val value: Any?) : Filter
  40 +
  41 + /** Field strictly less than value. */
  42 + data class Lt(val field: String, val value: Any) : Filter
  43 +
  44 + /** Field strictly greater than value. */
  45 + data class Gt(val field: String, val value: Any) : Filter
  46 +
  47 + /** Field value is one of [values]. Empty list matches nothing. */
  48 + data class In(val field: String, val values: List<Any>) : Filter
  49 +
  50 + /**
  51 + * SQL `LIKE` against [pattern]. The pattern uses SQL wildcards (`%`, `_`)
  52 + * unescaped — callers are responsible for escaping user input.
  53 + */
  54 + data class Like(val field: String, val pattern: String) : Filter
  55 +
  56 + /** Field is `NULL`. */
  57 + data class IsNull(val field: String) : Filter
  58 +
  59 + /** Logical conjunction. Empty list is treated as "always true". */
  60 + data class And(val operands: List<Filter>) : Filter
  61 +
  62 + /** Logical disjunction. Empty list is treated as "always false". */
  63 + data class Or(val operands: List<Filter>) : Filter
  64 +
  65 + /** Logical negation. */
  66 + data class Not(val operand: Filter) : Filter
  67 +}
  68 +
  69 +/**
  70 + * Sort directive. Stable across pages — the platform will append a tie-break
  71 + * on the primary key so pagination is deterministic.
  72 + */
  73 +data class SortClause(val field: String, val direction: SortDirection = SortDirection.ASC)
  74 +
  75 +/** Sort direction. Two options, no `RANDOM` — randomness is a separate concern. */
  76 +enum class SortDirection { ASC, DESC }
  77 +
  78 +/**
  79 + * Page coordinates. Zero-indexed for consistency with the rest of the JVM
  80 + * ecosystem (Spring Data, Hibernate). Maximum page size is enforced by the
  81 + * platform, not here, because the limit may differ per deployment.
  82 + */
  83 +data class PageRequest(val page: Int, val size: Int) {
  84 + init {
  85 + require(page >= 0) { "PageRequest page must be >= 0 (got $page)" }
  86 + require(size in 1..10_000) { "PageRequest size must be 1..10000 (got $size)" }
  87 + }
  88 +
  89 + companion object {
  90 + val FIRST = PageRequest(page = 0, size = 50)
  91 + }
  92 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt 0 → 100644
  1 +package org.vibeerp.api.v1.persistence
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.entity.Entity
  5 +
  6 +/**
  7 + * Generic, tenant-scoped repository contract for any [Entity].
  8 + *
  9 + * Why this lives in api.v1:
  10 + * - Plug-ins must be able to persist their own entities without depending on
  11 + * Spring Data, JPA, or any platform internal class. The platform binds this
  12 + * interface to a Hibernate-backed implementation per entity at startup.
  13 + * - It is the cleanest place to enforce the tenant guarantee: **none of these
  14 + * methods accept a `tenantId`**. The tenant is resolved from the ambient
  15 + * [org.vibeerp.api.v1.http.RequestContext] (or, in background jobs, from the
  16 + * job's tenant binding) by the platform. A plug-in cannot read or write
  17 + * cross-tenant data through this contract — that is the whole point.
  18 + * - It is generic on `T : Entity` so the type system rejects "wrong-entity"
  19 + * mistakes at compile time.
  20 + *
  21 + * Methods are deliberately few. Anything more sophisticated (joins,
  22 + * projections, native SQL) is a smell at this layer — model the use case as a
  23 + * domain service or a query object instead. Adding methods later (with default
  24 + * implementations) is non-breaking; removing them is not.
  25 + */
  26 +interface Repository<T : Entity> {
  27 +
  28 + /**
  29 + * Find by primary key. Returns `null` when no row exists for the current
  30 + * tenant — even if a row with that id exists for *another* tenant. Cross-
  31 + * tenant reads are unobservable through this API.
  32 + */
  33 + fun findById(id: Id<T>): T?
  34 +
  35 + /**
  36 + * Run a query and return one page of results. The page descriptor is part
  37 + * of [query], not a separate parameter, so it travels with the rest of the
  38 + * query intact (important for paginated AI-agent function calls).
  39 + */
  40 + fun findAll(query: Query): Page<T>
  41 +
  42 + /**
  43 + * Insert or update. Returning the saved entity (rather than `Unit`) gives
  44 + * the platform a place to populate generated columns (audit timestamps,
  45 + * generated ids) before the plug-in sees them again.
  46 + */
  47 + fun save(entity: T): T
  48 +
  49 + /**
  50 + * Delete by id. Returns `true` if a row was deleted, `false` if no
  51 + * matching row existed for the current tenant.
  52 + */
  53 + fun delete(id: Id<T>): Boolean
  54 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Transaction.kt 0 → 100644
  1 +package org.vibeerp.api.v1.persistence
  2 +
  3 +/**
  4 + * Plug-in-facing transaction boundary.
  5 + *
  6 + * Why this exists instead of "use Spring `@Transactional`":
  7 + * - api.v1 is forbidden from exposing Spring types (CLAUDE.md guardrail #10).
  8 + * A plug-in that imports `org.springframework.transaction.annotation.Transactional`
  9 + * is grade-D and rejected at install time.
  10 + * - The host needs a single, controlled place to enforce nested-transaction
  11 + * rules, tenant binding, and outbox flushing across plug-in boundaries.
  12 + * A method-level annotation cannot do those things from a child Spring
  13 + * context cleanly.
  14 + * - It is small enough to be implemented over JTA, Spring TX, or a future
  15 + * Kotlin coroutines transactional context without an api.v1 change.
  16 + *
  17 + * Semantics:
  18 + * - The block runs in a single platform transaction. If [block] throws, the
  19 + * transaction rolls back and the exception propagates unchanged.
  20 + * - Nested calls join the surrounding transaction; they do not start a new
  21 + * one. Plug-ins that need savepoints must request them through a future
  22 + * api.v1 extension, not by stacking calls.
  23 + * - The current tenant and principal are propagated automatically.
  24 + *
  25 + * ```
  26 + * pluginContext.transaction.inTransaction {
  27 + * val saved = repo.save(plate)
  28 + * eventBus.publish(PlateApproved(saved.id))
  29 + * saved
  30 + * }
  31 + * ```
  32 + */
  33 +interface Transaction {
  34 +
  35 + /**
  36 + * Run [block] inside a transaction and return its result.
  37 + *
  38 + * @param T the type returned by the block. Use `Unit` for side-effect-only
  39 + * blocks; the host treats `Unit` no differently from any other return
  40 + * type.
  41 + */
  42 + fun <T> inTransaction(block: () -> T): T
  43 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/ExtensionPoint.kt 0 → 100644
  1 +package org.vibeerp.api.v1.plugin
  2 +
  3 +import kotlin.reflect.KClass
  4 +
  5 +/**
  6 + * Marks an interface or abstract class as an *extension point*: a seam the
  7 + * core declares so that plug-ins (and other PBCs) can supply implementations.
  8 + *
  9 + * Why annotated rather than enforced by base-class inheritance:
  10 + * - Several extension points are plain interfaces (e.g. a custom field type
  11 + * handler, a workflow task handler, a report data provider) and forcing
  12 + * them to extend a marker class would be noise. An annotation lets the
  13 + * plug-in linter and the OpenAPI generator find them by metadata alone.
  14 + * - It documents intent at the declaration site: a reader sees
  15 + * `@ExtensionPoint` and knows "plug-ins are expected to implement this".
  16 + * - The platform's plug-in loader scans for `@Extension` (below) and
  17 + * cross-references it to `@ExtensionPoint`-annotated types so a typo
  18 + * fails at install time, not at first use.
  19 + */
  20 +@Target(AnnotationTarget.CLASS)
  21 +@Retention(AnnotationRetention.RUNTIME)
  22 +@MustBeDocumented
  23 +annotation class ExtensionPoint
  24 +
  25 +/**
  26 + * Marks a class as a plug-in's implementation of an [ExtensionPoint].
  27 + *
  28 + * The plug-in loader instantiates every `@Extension`-annotated class in the
  29 + * plug-in's child Spring context, verifies that [point] is itself
  30 + * `@ExtensionPoint`-annotated, and registers the instance with the matching
  31 + * platform registry.
  32 + *
  33 + * ```
  34 + * @Extension(point = TaskHandler::class)
  35 + * class ApprovePlateHandler : TaskHandler { ... }
  36 + * ```
  37 + */
  38 +@Target(AnnotationTarget.CLASS)
  39 +@Retention(AnnotationRetention.RUNTIME)
  40 +@MustBeDocumented
  41 +annotation class Extension(
  42 + /** The extension-point type this class implements. Must be `@ExtensionPoint`-annotated. */
  43 + val point: KClass<*>,
  44 +)
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/Plugin.kt 0 → 100644
  1 +package org.vibeerp.api.v1.plugin
  2 +
  3 +/**
  4 + * The single entry-class contract every PF4J plug-in implements.
  5 + *
  6 + * Why this is in api.v1:
  7 + * - PF4J's own `Plugin` base class is an internal implementation choice of
  8 + * the platform-plugins module. Exposing it would tie every plug-in to a
  9 + * specific PF4J version forever — the textbook reason api.v1 exists.
  10 + * - The four methods are the bare minimum to manage a plug-in's lifecycle:
  11 + * identify itself, declare its version, start, and stop. Anything richer
  12 + * (hot reload, partial reload, health check) is added as default methods
  13 + * in later 1.x releases without breaking existing plug-ins.
  14 + *
  15 + * Lifecycle (architecture spec section 7):
  16 + * 1. The platform reads the plug-in's `plugin.yml` ([PluginManifest]).
  17 + * 2. The classloader is created.
  18 + * 3. The plug-in's entry class (which implements this interface) is
  19 + * instantiated.
  20 + * 4. [start] is called once with the [PluginContext]. The plug-in registers
  21 + * its endpoints, listeners, workflow handlers, etc. on the context.
  22 + * 5. When the plug-in is disabled or the host shuts down, [stop] is called
  23 + * once. The plug-in releases any resources it holds. The platform tears
  24 + * down the child Spring context, the listeners, and the classloader.
  25 + *
  26 + * Implementations must be safe to call [stop] without [start] having
  27 + * succeeded — the platform may invoke it after a partial start failure.
  28 + */
  29 +interface Plugin {
  30 +
  31 + /**
  32 + * Stable plug-in identifier matching `id` in `plugin.yml`. Used as the
  33 + * key for tenant scoping, table prefixes (`plugin_<id>__*`), permission
  34 + * scoping, and the URL prefix `/api/v1/plugins/<id>`. Must match the id
  35 + * in [PluginManifest] — the platform verifies this at start time.
  36 + */
  37 + fun id(): String
  38 +
  39 + /** Semver version of this plug-in build. Reported in admin UIs and audit logs. */
  40 + fun version(): String
  41 +
  42 + /**
  43 + * Called once when the plug-in is being started. The [context] gives
  44 + * access to every host service the plug-in is allowed to use.
  45 + *
  46 + * Throwing from [start] aborts the plug-in load. The platform calls
  47 + * [stop] afterwards regardless.
  48 + */
  49 + fun start(context: PluginContext)
  50 +
  51 + /**
  52 + * Called once when the plug-in is being stopped. Implementations should
  53 + * release resources owned by the plug-in itself; listeners, endpoints,
  54 + * and Spring beans registered through [PluginContext] are torn down by
  55 + * the platform automatically.
  56 + */
  57 + fun stop()
  58 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt 0 → 100644
  1 +package org.vibeerp.api.v1.plugin
  2 +
  3 +import org.vibeerp.api.v1.entity.EntityRegistry
  4 +import org.vibeerp.api.v1.event.EventBus
  5 +import org.vibeerp.api.v1.i18n.LocaleProvider
  6 +import org.vibeerp.api.v1.i18n.Translator
  7 +import org.vibeerp.api.v1.persistence.Transaction
  8 +import org.vibeerp.api.v1.security.PermissionCheck
  9 +
  10 +/**
  11 + * The bag of host services a plug-in receives at [Plugin.start].
  12 + *
  13 + * Why a single context object instead of constructor injection of each
  14 + * service:
  15 + * - Plug-ins are instantiated by PF4J before any Spring wiring exists, so
  16 + * constructor injection in the Spring sense is not yet available at
  17 + * [Plugin.start] time. Handing a context object solves the bootstrapping
  18 + * problem in one line.
  19 + * - It is forward-compatible: adding a new host service is a non-breaking
  20 + * addition to this interface (with a default implementation that throws
  21 + * `UnsupportedOperationException` — see the per-getter doc).
  22 + *
  23 + * The plug-in is free to keep references to whichever services it needs and
  24 + * pass them into its own Spring beans inside the child context. Holding onto
  25 + * the entire context is also fine.
  26 + */
  27 +interface PluginContext {
  28 +
  29 + /** Publish/subscribe to domain events. Backed by the platform bus + outbox. */
  30 + val eventBus: EventBus
  31 +
  32 + /** Run blocks in a platform transaction without ever importing Spring. */
  33 + val transaction: Transaction
  34 +
  35 + /** Resolve translations using the host's ICU MessageFormat machinery. */
  36 + val translator: Translator
  37 +
  38 + /** Read the current request/job locale. */
  39 + val localeProvider: LocaleProvider
  40 +
  41 + /** Soft and hard permission checks. */
  42 + val permissionCheck: PermissionCheck
  43 +
  44 + /** Look up entity descriptors (core PBC, plug-in, or user-defined). */
  45 + val entityRegistry: EntityRegistry
  46 +
  47 + /**
  48 + * Lifecycle-bound logger. Always prefer this over `LoggerFactory.getLogger`
  49 + * because the platform tags every log line with the plug-in id, the
  50 + * tenant, the request id, and the principal automatically. A plug-in that
  51 + * brings its own SLF4J binding bypasses all of that and is hard to debug
  52 + * in a multi-plug-in deployment.
  53 + */
  54 + val logger: PluginLogger
  55 +}
  56 +
  57 +/**
  58 + * Minimal logger interface exposed to plug-ins.
  59 + *
  60 + * Why not just expose SLF4J:
  61 + * - api.v1 forbids leaking external library types (CLAUDE.md guardrail #10).
  62 + * SLF4J version skew across plug-in classloaders is a real PF4J pain that
  63 + * a thin abstraction sidesteps entirely.
  64 + * - The host implementation forwards to whichever logging backend is
  65 + * configured at the platform layer (Logback today; structured JSON in the
  66 + * cloud build), without the plug-in choosing.
  67 + *
  68 + * Three levels are intentional. `debug` and `trace` are deliberately omitted
  69 + * from the contract because they encourage chatty plug-ins; if a plug-in
  70 + * needs verbose diagnostics it should ship its own logger inside the plug-in
  71 + * jar (which is fine — it just won't get the platform's structured tags).
  72 + */
  73 +interface PluginLogger {
  74 + /** Informational message about normal operation. */
  75 + fun info(message: String)
  76 +
  77 + /** Something looked wrong but the operation continued. */
  78 + fun warn(message: String, error: Throwable? = null)
  79 +
  80 + /** An operation failed. */
  81 + fun error(message: String, error: Throwable? = null)
  82 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginManifest.kt 0 → 100644
  1 +package org.vibeerp.api.v1.plugin
  2 +
  3 +/**
  4 + * Strongly-typed view of a plug-in's `plugin.yml` manifest.
  5 + *
  6 + * Why this is in api.v1 even though plug-ins do not parse it themselves:
  7 + * - The host parses `plugin.yml` and offers the result to the plug-in via
  8 + * [PluginContext] (and to admin UIs, the linter, and the marketplace).
  9 + * Putting the type in api.v1 means every consumer agrees on the schema.
  10 + * - The schema is part of the public contract: changing a field name in
  11 + * `plugin.yml` would break every existing plug-in's manifest. Documenting
  12 + * it as a Kotlin data class is the cheapest way to keep that promise.
  13 + *
  14 + * The corresponding YAML shape:
  15 + *
  16 + * ```
  17 + * id: printingshop
  18 + * version: 1.4.2
  19 + * requiresApi: "1.x"
  20 + * name:
  21 + * en-US: Printing Shop
  22 + * zh-CN: 印刷工坊
  23 + * dependencies:
  24 + * - identity
  25 + * - catalog
  26 + * permissions:
  27 + * - printingshop.plate.create
  28 + * - printingshop.plate.approve
  29 + * metadata:
  30 + * - metadata/forms/plate.json
  31 + * - metadata/workflows/approval.bpmn
  32 + * ```
  33 + *
  34 + * @property id Stable plug-in id, used everywhere ([Plugin.id], URL prefix,
  35 + * table prefix, metadata `source` tag).
  36 + * @property version Plug-in build version. Semver, but the framework does
  37 + * not enforce it parses — some customers ship date-stamped builds.
  38 + * @property requiresApi Required api.v1 major version, expressed as a range.
  39 + * The platform refuses to load a plug-in whose required range does not
  40 + * include the running host's api version. Today the only supported value
  41 + * is `"1.x"`; this field exists so the same parser handles `"2.x"` etc.
  42 + * without an api break.
  43 + * @property name Locale → display name. Locale codes are BCP-47.
  44 + * @property dependencies Other plug-in ids that must be present and started
  45 + * before this plug-in starts. The platform topologically orders startup;
  46 + * cycles are rejected at install.
  47 + * @property permissions Keys of the permissions this plug-in declares (see
  48 + * [org.vibeerp.api.v1.security.Permission]). The platform refuses to start
  49 + * a plug-in that uses a permission not in this list.
  50 + * @property metadata JAR-relative paths the platform should load at startup
  51 + * to seed forms, workflows, rules, list views, and translations.
  52 + */
  53 +data class PluginManifest(
  54 + val id: String,
  55 + val version: String,
  56 + val requiresApi: String,
  57 + val name: Map<String, String> = emptyMap(),
  58 + val dependencies: List<String> = emptyList(),
  59 + val permissions: List<String> = emptyList(),
  60 + val metadata: List<String> = emptyList(),
  61 +) {
  62 + init {
  63 + require(id.isNotBlank()) { "PluginManifest id must not be blank" }
  64 + require(id.all { it.isLetterOrDigit() || it == '-' || it == '_' }) {
  65 + "PluginManifest id may only contain letters, digits, '-', or '_' (got '$id')"
  66 + }
  67 + require(version.isNotBlank()) { "PluginManifest version must not be blank" }
  68 + require(requiresApi.isNotBlank()) { "PluginManifest requiresApi must not be blank" }
  69 + }
  70 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Permission.kt 0 → 100644
  1 +package org.vibeerp.api.v1.security
  2 +
  3 +/**
  4 + * A named permission a plug-in or PBC declares.
  5 + *
  6 + * Why permissions are first-class data, not magic strings scattered through
  7 + * code:
  8 + * - The role editor in the web UI lists every registered permission so an
  9 + * administrator can grant it. There is no way to do that if permissions
  10 + * are unenumerable.
  11 + * - The OpenAPI generator and the AI-agent function catalog annotate every
  12 + * operation with the permissions it requires; an LLM that calls the API
  13 + * can therefore refuse an action up-front rather than discovering the
  14 + * failure mid-call.
  15 + * - Plug-ins register their permissions at startup, so uninstalling a
  16 + * plug-in cleanly removes its permission rows from `metadata__permission`.
  17 + *
  18 + * Naming convention is `<plugin-or-pbc>.<resource>.<action>`. Examples:
  19 + * - `identity.user.create`
  20 + * - `catalog.item.update`
  21 + * - `printingshop.plate.approve` (declared by the reference plug-in)
  22 + *
  23 + * The convention is enforced by [init], not by mere documentation, because
  24 + * "we'll fix the names later" never works in a permission catalog.
  25 + *
  26 + * @property key The dotted permission identifier. Lower-case, three or more
  27 + * segments separated by `.`, each segment `[a-z][a-z0-9_]*`.
  28 + * @property description Human-readable explanation shown in the role editor.
  29 + * This is a fallback English string; tenants override it through the i18n
  30 + * layer.
  31 + * @property pluginId The plug-in that owns this permission, or `null` if it
  32 + * belongs to a core PBC. The platform uses this to scope cleanup on
  33 + * plug-in uninstall.
  34 + */
  35 +data class Permission(
  36 + val key: String,
  37 + val description: String,
  38 + val pluginId: String? = null,
  39 +) {
  40 + init {
  41 + require(description.isNotBlank()) { "Permission description must not be blank" }
  42 + require(KEY_REGEX.matches(key)) {
  43 + "Permission key must look like '<area>.<resource>.<action>' (got '$key')"
  44 + }
  45 + }
  46 +
  47 + companion object {
  48 + private val KEY_REGEX = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*){2,}$")
  49 + }
  50 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/PermissionCheck.kt 0 → 100644
  1 +package org.vibeerp.api.v1.security
  2 +
  3 +/**
  4 + * Plug-in-facing permission check.
  5 + *
  6 + * Why this is its own interface instead of "throw whenever you feel like it":
  7 + * - Permission checks must be enforced at the application-service layer
  8 + * (not just the HTTP layer) so background jobs, workflow tasks, AI-agent
  9 + * calls, and direct service-to-service calls all funnel through the same
  10 + * code path. A plug-in that consults this interface is automatically
  11 + * correct against every transport.
  12 + * - The two-method shape ([has] for soft checks, [require] for hard checks)
  13 + * is deliberate. Plug-ins frequently want to *render different UI* based
  14 + * on whether the user can do a thing, and that case must not throw.
  15 + *
  16 + * The platform binds this to the active [Principal] from
  17 + * [org.vibeerp.api.v1.http.RequestContext] so plug-ins almost never need to
  18 + * pass a principal explicitly — but the explicit form exists because some
  19 + * legitimate flows (impersonation, "act as", scheduled jobs queued by user A
  20 + * but executed by the system) need to ask the question for a *different*
  21 + * principal than the ambient one.
  22 + */
  23 +interface PermissionCheck {
  24 +
  25 + /**
  26 + * Soft check: returns whether [principal] currently holds [permission].
  27 + * Never throws on a denial.
  28 + */
  29 + fun has(principal: Principal, permission: Permission): Boolean
  30 +
  31 + /**
  32 + * Hard check: throws [PermissionDeniedException] if [principal] does not
  33 + * hold [permission]. Returns normally otherwise. Use this at the entry
  34 + * point of any state-changing operation a plug-in exposes.
  35 + */
  36 + fun require(principal: Principal, permission: Permission) {
  37 + if (!has(principal, permission)) {
  38 + throw PermissionDeniedException(principal, permission)
  39 + }
  40 + }
  41 +}
  42 +
  43 +/**
  44 + * Thrown by [PermissionCheck.require] when a principal lacks a permission.
  45 + *
  46 + * The two fields ([principal], [permission]) are exposed because the platform's
  47 + * HTTP error mapper turns this exception into a structured 403 response that
  48 + * includes the missing permission key — important for debuggability and for
  49 + * AI agents that need to ask the user to grant access.
  50 + */
  51 +class PermissionDeniedException(
  52 + val principal: Principal,
  53 + val permission: Permission,
  54 +) : RuntimeException("Principal ${principal.id} lacks permission '${permission.key}'")
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Principal.kt 0 → 100644
  1 +package org.vibeerp.api.v1.security
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.core.TenantId
  5 +
  6 +/**
  7 + * Type alias for a principal identifier.
  8 + *
  9 + * Distinct phantom-typed alias of [Id] so signatures like `createdBy: PrincipalId`
  10 + * are self-documenting in IDE hovers and so the compiler refuses to mix a
  11 + * `PrincipalId` with, say, an `Id<Order>`.
  12 + */
  13 +typealias PrincipalId = Id<Principal>
  14 +
  15 +/**
  16 + * Who is making a request.
  17 + *
  18 + * vibe_erp recognizes three kinds of caller, and they all have legitimate
  19 + * audit, permission, and event-attribution needs:
  20 + *
  21 + * - [User]: a human (or an AI agent acting *on behalf of* a human, which is
  22 + * still a [User] in this model — the agent does not get its own principal
  23 + * type, which would let it circumvent per-user permissions).
  24 + * - [System]: the framework itself, e.g. background jobs, migrations, the
  25 + * metadata seeder. Recorded in audit logs as `system:<name>` so an auditor
  26 + * can tell automation from a human.
  27 + * - [PluginPrincipal]: a plug-in acting under its own identity, used for
  28 + * plug-in-to-plug-in calls and for bookkeeping rows the plug-in writes
  29 + * without a user request (e.g. a periodic sync from an external system).
  30 + *
  31 + * Sealed because the framework needs to switch on the full set in audit and
  32 + * permission code; new principal kinds are deliberate api.v1 changes.
  33 + */
  34 +sealed interface Principal {
  35 +
  36 + /** This principal's stable id, used as a foreign key in audit columns. */
  37 + val id: PrincipalId
  38 +
  39 + /**
  40 + * The tenant the principal is acting in. A [User] always has one. A
  41 + * [System] principal uses [TenantId.DEFAULT] for global jobs and a
  42 + * specific tenant for per-tenant jobs. A [PluginPrincipal] uses the
  43 + * tenant of the operation it is performing.
  44 + */
  45 + val tenantId: TenantId
  46 +
  47 + /**
  48 + * A real human user (or an AI agent acting on a human's behalf, which the
  49 + * framework intentionally does not distinguish at the principal level).
  50 + */
  51 + data class User(
  52 + override val id: PrincipalId,
  53 + override val tenantId: TenantId,
  54 + val username: String,
  55 + ) : Principal {
  56 + init {
  57 + require(username.isNotBlank()) { "User username must not be blank" }
  58 + }
  59 + }
  60 +
  61 + /**
  62 + * The framework itself. [name] is a short identifier of which subsystem
  63 + * is acting (e.g. `metadata-seeder`, `outbox-drain`).
  64 + */
  65 + data class System(
  66 + override val id: PrincipalId,
  67 + override val tenantId: TenantId,
  68 + val name: String,
  69 + ) : Principal {
  70 + init {
  71 + require(name.isNotBlank()) { "System name must not be blank" }
  72 + }
  73 + }
  74 +
  75 + /** A plug-in acting under its own identity. */
  76 + data class PluginPrincipal(
  77 + override val id: PrincipalId,
  78 + override val tenantId: TenantId,
  79 + val pluginId: String,
  80 + ) : Principal {
  81 + init {
  82 + require(pluginId.isNotBlank()) { "PluginPrincipal pluginId must not be blank" }
  83 + }
  84 + }
  85 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt 0 → 100644
  1 +package org.vibeerp.api.v1.workflow
  2 +
  3 +/**
  4 + * Implementation contract for a single workflow task type.
  5 + *
  6 + * Why this is the only workflow seam in v1.0:
  7 + * - Workflows are data, not code (CLAUDE.md guardrail #2). The shape of a
  8 + * workflow lives in BPMN; the only thing a plug-in legitimately needs to
  9 + * write in code is the body of a service task. That is exactly what
  10 + * [TaskHandler] models.
  11 + * - It deliberately does not expose the Flowable `DelegateExecution` type,
  12 + * because doing so would couple every plug-in to a specific Flowable
  13 + * version forever. The platform adapts Flowable to this api.v1 shape
  14 + * inside its workflow host module.
  15 + *
  16 + * Plug-ins register handlers via the `@org.vibeerp.api.v1.plugin.Extension`
  17 + * annotation pointing at this interface, and the platform routes incoming
  18 + * Flowable service-task executions to the handler whose [key] matches.
  19 + *
  20 + * ```
  21 + * @Extension(point = TaskHandler::class)
  22 + * class ApprovePlate : TaskHandler {
  23 + * override fun key() = "printingshop.plate.approve"
  24 + * override fun execute(task: WorkflowTask, ctx: TaskContext) {
  25 + * val plateId = task.variables["plateId"] as String
  26 + * // domain logic …
  27 + * ctx.set("approved", true)
  28 + * }
  29 + * }
  30 + * ```
  31 + */
  32 +interface TaskHandler {
  33 +
  34 + /**
  35 + * The BPMN service-task key this handler claims. Convention is
  36 + * `<plugin-id>.<aggregate>.<verb>`. Two handlers in the same running
  37 + * deployment must not return the same key — the platform fails plug-in
  38 + * load on conflict.
  39 + */
  40 + fun key(): String
  41 +
  42 + /**
  43 + * Execute the task. The implementation may write output variables via
  44 + * [ctx]. Throwing aborts the workflow with a Flowable BPMN error,
  45 + * which the surrounding process model can catch.
  46 + */
  47 + fun execute(task: WorkflowTask, ctx: TaskContext)
  48 +}
  49 +
  50 +/**
  51 + * Mutable side of a task execution: the only way a [TaskHandler] writes
  52 + * variables back into the workflow.
  53 + *
  54 + * Why a small interface instead of "return a Map":
  55 + * - The host wants to know which variables a handler set (for audit) and
  56 + * in what order (for diagnostic logging). A returned map loses both.
  57 + * - It leaves room to grow methods like `signal(eventName)` and
  58 + * `complete()` without changing the handler's signature.
  59 + */
  60 +interface TaskContext {
  61 +
  62 + /**
  63 + * Set a process variable. The value is persisted by the workflow engine
  64 + * under the same transaction as the surrounding task execution.
  65 + * Passing `null` removes the variable.
  66 + */
  67 + fun set(name: String, value: Any?)
  68 +}
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/WorkflowTask.kt 0 → 100644
  1 +package org.vibeerp.api.v1.workflow
  2 +
  3 +/**
  4 + * Description of a workflow task instance the host hands to a [TaskHandler].
  5 + *
  6 + * Why this is in v1.0 even though the full workflow API is v0.2:
  7 + * - The reference printing-shop plug-in is the executable acceptance test
  8 + * (CLAUDE.md, "The reference plug-in is the executable acceptance test"),
  9 + * and that plug-in needs to register at least one task handler against
  10 + * the embedded Flowable engine. v1.0 ships the smallest possible
  11 + * contract that lets the plug-in do that.
  12 + * - The richer surface (signal/timer events, sub-processes, candidate
  13 + * groups, business calendars) lands in v0.2 of the api.v1 line as
  14 + * purely additive interfaces. None of it changes the shape below.
  15 + *
  16 + * @property taskKey The BPMN element id (`<serviceTask id="...">`) that
  17 + * triggered this handler. A single handler may be wired to several keys
  18 + * inside one process; matching is performed by the host based on
  19 + * [TaskHandler.key] and the BPMN definition.
  20 + * @property processInstanceId Flowable's process-instance id, opaque to the
  21 + * plug-in. Useful for log correlation and for callbacks back into the
  22 + * workflow engine.
  23 + * @property variables The current process variables visible to this task.
  24 + * The map is read-only — to write back, the handler uses
  25 + * [TaskContext.set] which routes through Flowable so the variables are
  26 + * persisted under the right transaction.
  27 + */
  28 +data class WorkflowTask(
  29 + val taskKey: String,
  30 + val processInstanceId: String,
  31 + val variables: Map<String, Any?> = emptyMap(),
  32 +) {
  33 + init {
  34 + require(taskKey.isNotBlank()) { "WorkflowTask taskKey must not be blank" }
  35 + require(processInstanceId.isNotBlank()) { "WorkflowTask processInstanceId must not be blank" }
  36 + }
  37 +}
api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/IdentifiersTest.kt 0 → 100644
  1 +package org.vibeerp.api.v1.core
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.isEqualTo
  6 +import org.junit.jupiter.api.Test
  7 +
  8 +class IdentifiersTest {
  9 +
  10 + @Test
  11 + fun `TenantId rejects blank`() {
  12 + assertFailure { TenantId("") }
  13 + assertFailure { TenantId(" ") }
  14 + }
  15 +
  16 + @Test
  17 + fun `TenantId rejects too-long values`() {
  18 + val tooLong = "a".repeat(65)
  19 + assertFailure { TenantId(tooLong) }
  20 + }
  21 +
  22 + @Test
  23 + fun `TenantId rejects invalid characters`() {
  24 + assertFailure { TenantId("acme corp") } // space
  25 + assertFailure { TenantId("acme.corp") } // dot
  26 + assertFailure { TenantId("acme/corp") } // slash
  27 + assertFailure { TenantId("acme!") } // punctuation
  28 + }
  29 +
  30 + @Test
  31 + fun `TenantId accepts the default tenant id`() {
  32 + assertThat(TenantId("default").value).isEqualTo("default")
  33 + assertThat(TenantId.DEFAULT.value).isEqualTo("default")
  34 + }
  35 +
  36 + @Test
  37 + fun `TenantId accepts hyphens digits and underscores`() {
  38 + assertThat(TenantId("tenant-1").value).isEqualTo("tenant-1")
  39 + assertThat(TenantId("acme_2").value).isEqualTo("acme_2")
  40 + assertThat(TenantId("ACME").value).isEqualTo("ACME")
  41 + }
  42 +}
api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/MoneyTest.kt 0 → 100644
  1 +package org.vibeerp.api.v1.core
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.isEqualTo
  6 +import assertk.assertions.messageContains
  7 +import org.junit.jupiter.api.Test
  8 +import java.math.BigDecimal
  9 +
  10 +class MoneyTest {
  11 +
  12 + @Test
  13 + fun `adding two USD amounts produces a USD amount`() {
  14 + val a = Money(BigDecimal("10.00"), Currency.USD)
  15 + val b = Money(BigDecimal("2.50"), Currency.USD)
  16 +
  17 + val sum = a + b
  18 +
  19 + assertThat(sum).isEqualTo(Money(BigDecimal("12.50"), Currency.USD))
  20 + }
  21 +
  22 + @Test
  23 + fun `adding USD to EUR throws`() {
  24 + val usd = Money(BigDecimal("10.00"), Currency.USD)
  25 + val eur = Money(BigDecimal("10.00"), Currency.EUR)
  26 +
  27 + assertFailure { usd + eur }.messageContains("different currencies")
  28 + }
  29 +
  30 + @Test
  31 + fun `JPY rejects fractional amounts because its default fraction digits is zero`() {
  32 + // JPY has 0 fraction digits — anything with a non-zero scale is wrong.
  33 + assertFailure { Money(BigDecimal("100.50"), Currency.JPY) }
  34 + .messageContains("scale")
  35 +
  36 + // A whole-yen amount is fine.
  37 + val whole = Money(BigDecimal("100"), Currency.JPY)
  38 + assertThat(whole.amount).isEqualTo(BigDecimal("100"))
  39 + }
  40 +}
api/api-v1/src/test/kotlin/org/vibeerp/api/v1/persistence/QueryTest.kt 0 → 100644
  1 +package org.vibeerp.api.v1.persistence
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import assertk.assertions.isInstanceOf
  6 +import org.junit.jupiter.api.Test
  7 +
  8 +class QueryTest {
  9 +
  10 + @Test
  11 + fun `every Filter shape can be constructed`() {
  12 + val filters: List<Filter> = listOf(
  13 + Filter.Eq("name", "Acme"),
  14 + Filter.Ne("status", "DELETED"),
  15 + Filter.Lt("price", 100),
  16 + Filter.Gt("price", 1),
  17 + Filter.In("category", listOf("a", "b", "c")),
  18 + Filter.Like("name", "Ac%"),
  19 + Filter.IsNull("deleted_at"),
  20 + Filter.And(listOf(Filter.Eq("a", 1), Filter.Eq("b", 2))),
  21 + Filter.Or(listOf(Filter.Eq("a", 1), Filter.Eq("b", 2))),
  22 + Filter.Not(Filter.Eq("flag", true)),
  23 + )
  24 +
  25 + assertThat(filters.size).isEqualTo(10)
  26 + }
  27 +
  28 + @Test
  29 + fun `Filter hierarchy can be exhaustively pattern-matched`() {
  30 + // The point of sealing Filter is that this `when` is exhaustive without
  31 + // an `else` branch. If a new variant is added without updating this
  32 + // test, the compiler fails — which is exactly the safety net we want.
  33 + fun describe(f: Filter): String = when (f) {
  34 + is Filter.Eq -> "eq"
  35 + is Filter.Ne -> "ne"
  36 + is Filter.Lt -> "lt"
  37 + is Filter.Gt -> "gt"
  38 + is Filter.In -> "in"
  39 + is Filter.Like -> "like"
  40 + is Filter.IsNull -> "isnull"
  41 + is Filter.And -> "and"
  42 + is Filter.Or -> "or"
  43 + is Filter.Not -> "not"
  44 + }
  45 +
  46 + assertThat(describe(Filter.Eq("a", 1))).isEqualTo("eq")
  47 + assertThat(describe(Filter.IsNull("x"))).isEqualTo("isnull")
  48 + assertThat(describe(Filter.Not(Filter.Eq("a", 1)))).isEqualTo("not")
  49 + }
  50 +
  51 + @Test
  52 + fun `Query default values are usable as-is`() {
  53 + val q = Query()
  54 + assertThat(q.filters).isEqualTo(emptyList<Filter>())
  55 + assertThat(q.sort).isEqualTo(emptyList<SortClause>())
  56 + assertThat(q.pageRequest).isEqualTo(PageRequest.FIRST)
  57 + }
  58 +
  59 + @Test
  60 + fun `SortClause carries field and direction`() {
  61 + val s = SortClause("created_at", SortDirection.DESC)
  62 + assertThat(s.field).isEqualTo("created_at")
  63 + assertThat(s.direction).isEqualTo(SortDirection.DESC)
  64 + }
  65 +
  66 + @Test
  67 + fun `nested boolean filters compose`() {
  68 + val nested: Filter = Filter.And(
  69 + listOf(
  70 + Filter.Or(listOf(Filter.Eq("status", "OPEN"), Filter.Eq("status", "PENDING"))),
  71 + Filter.Not(Filter.IsNull("assignee")),
  72 + )
  73 + )
  74 + assertThat(nested).isInstanceOf(Filter.And::class)
  75 + }
  76 +}