Commit 4879fd7d866642ef208b56ca220f988c68badb89

Authored by vibe_erp
1 parent 28e1aa38

refactor: rip out multi-tenancy, vibe_erp is now single-tenant per instance

Design change: vibe_erp deliberately does NOT support multiple companies in
one process. Each running instance serves exactly one company against an
isolated Postgres database. Hosting many customers means provisioning many
independent instances, not multiplexing them.

Why: most ERP/EBC customers will not accept a SaaS where their data shares
a database with other companies. The single-tenant-per-instance model is
what the user actually wants the product to look like, and it dramatically
simplifies the framework.

What changed:
- CLAUDE.md guardrail #5 rewritten from "multi-tenant from day one" to
  "single-tenant per instance, isolated database"
- api.v1: removed TenantId value class entirely; removed tenantId from
  Entity, AuditedEntity, Principal, DomainEvent, RequestContext,
  TaskContext, IdentityApi.UserRef, Repository
- platform-persistence: deleted TenantContext, HibernateTenantResolver,
  TenantAwareJpaTransactionManager, TenancyJpaConfiguration; removed
  @TenantId and tenant_id column from AuditedJpaEntity
- platform-bootstrap: deleted TenantResolutionFilter; dropped
  vibeerp.instance.mode and default-tenant from properties; added
  vibeerp.instance.company-name; added VibeErpApplication @EnableJpaRepositories
  and @EntityScan so PBC repositories outside the main package are wired;
  added GlobalExceptionHandler that maps IllegalArgumentException → 400
  and NoSuchElementException → 404 (RFC 7807 ProblemDetail)
- pbc-identity: removed tenant_id from User, repository, controller, DTOs,
  IdentityApiAdapter; updated UserService duplicate-username message and
  the matching test
- distribution: dropped multiTenancy=DISCRIMINATOR and
  tenant_identifier_resolver from application.yaml; configured Spring Boot
  mainClass on the springBoot extension (not just bootJar) so bootRun works
- Liquibase: rewrote platform-init changelog to drop platform__tenant and
  the tenant_id columns on every metadata__* table; rewrote
  pbc-identity init to drop tenant_id columns, the (tenant_id, *)
  composite indexes, and the per-table RLS policies
- IdentifiersTest replaced with Id<T> tests since the TenantId tests
  no longer apply

Verified end-to-end against a real Postgres via docker-compose:
  POST /api/v1/identity/users   → 201 Created
  GET  /api/v1/identity/users   → list works
  GET  /api/v1/identity/users/X → fetch by id works
  POST duplicate username       → 400 Bad Request (was 500)
  PATCH bogus id                → 404 Not Found (was 500)
  PATCH alice                   → 200 OK
  DELETE alice                  → 204, alice now disabled

All 18 unit tests pass.
Showing 29 changed files with 353 additions and 717 deletions
CLAUDE.md
... ... @@ -19,7 +19,7 @@ These rules exist because the whole point of the project is reusability across c
19 19 2. **Workflows are data, not code.** State machines, approval chains, document routing, and form definitions must be declarative (config / DB / DSL) so a new customer can be onboarded by editing definitions, not by editing source.
20 20 3. **Extensibility seams come first.** Before adding any feature, identify the extension point (hook, plug-in interface, event, custom field, scripting hook) it should hang off of. If no seam exists yet, design the seam first, then implement the reference customer's behavior on top of it.
21 21 4. **The reference customer is a test, not a requirement.** When implementing something inspired by `raw/业务流程设计文档/`, ask: "Could a different printing shop with different rules also be expressed here without code changes?" If no, redesign.
22   -5. **Multi-tenant from day one in spirit.** Even if deployment is single-tenant, data models and APIs should not assume one company, one workflow, or one form layout.
  22 +5. **Single-tenant per instance, isolated database.** vibe_erp is deliberately NOT multi-tenant. One running instance serves exactly one company against an isolated Postgres database. Hosting many customers means provisioning many independent instances (each with its own DB), not multiplexing them in one process. There are no `tenant_id` columns, no row-level tenant filtering, no Row-Level Security, no `TenantContext`, and no per-request tenant resolution anywhere in the framework. Customer isolation is a deployment concern, not a code concern. Data models and APIs may freely assume "one company, one process, one DB" — but they must still allow per-customer customization through configuration, plug-ins, and metadata so the SAME image runs for any customer.
23 23 6. **Global / i18n from day one.** This codebase is the foundation of an ERP/EBC product intended to be sold worldwide. No hard-coded user-facing strings, no hard-coded currency, date format, time zone, number format, address shape, tax model, or language. All such concerns must go through localization, formatting, and configuration layers — even in the very first prototype. The reference docs being in Chinese does **not** mean Chinese is the primary or default locale; it is just one supported locale among many.
24 24 7. **Clean Core.** Borrowed from SAP S/4HANA's vocabulary: extensions **never modify the core**. The core is stable, generic, and upgrade-safe; everything customer-specific lives in plug-ins or in metadata rows. Extensions are graded A/B/C/D by upgrade safety:
25 25 - **A** = Tier-1 metadata only (custom fields, forms, workflows, rules) — always upgrade-safe
... ... @@ -55,7 +55,7 @@ The architecture has been brainstormed, validated against 2026 ERP/EBC SOTA, and
55 55 - **Backend:** Kotlin on the JVM, Spring Boot, single fat-JAR / single Docker image
56 56 - **Workflow engine:** Embedded Flowable (BPMN 2.0)
57 57 - **Persistence:** PostgreSQL (the only mandatory external dependency)
58   -- **Multi-tenancy:** row-level `tenant_id` + Postgres Row-Level Security (defense in depth, same code path for self-host and hosted)
  58 +- **Multi-tenancy:** intentionally none. Single-tenant per instance, one isolated Postgres database per customer (see guardrail #5)
59 59 - **Custom fields:** JSONB `ext` column on every business table, described by `metadata__custom_field` rows; GIN-indexed
60 60 - **Plug-in framework:** PF4J + Spring Boot child contexts (classloader isolation per plug-in)
61 61 - **i18n:** ICU MessageFormat (ICU4J) + Spring `MessageSource`
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Identifiers.kt
... ... @@ -6,10 +6,12 @@ import java.util.UUID
6 6 * Strongly-typed identifier wrapping a UUID.
7 7 *
8 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.
  9 + * mistake of passing one entity's id where another was expected — the
  10 + * compiler catches it. The wrapper is a value class so it has zero
  11 + * runtime overhead.
11 12 *
12   - * @param T phantom type parameter that names what kind of entity this id refers to.
  13 + * @param T phantom type parameter that names what kind of entity this id
  14 + * refers to.
13 15 */
14 16 @JvmInline
15 17 value class Id<T>(val value: UUID) {
... ... @@ -20,27 +22,3 @@ value class Id&lt;T&gt;(val value: UUID) {
20 22 fun <T> random(): Id<T> = Id(UUID.randomUUID())
21 23 }
22 24 }
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/entity/Entity.kt
1 1 package org.vibeerp.api.v1.entity
2 2  
3 3 import org.vibeerp.api.v1.core.Id
4   -import org.vibeerp.api.v1.core.TenantId
5 4  
6 5 /**
7 6 * Marker interface for every domain entity that lives in the vibe_erp data model.
8 7 *
9 8 * 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.
  9 + * - It gives the runtime a single hook for JSONB `ext` handling, audit
  10 + * interception, and metadata-driven custom fields without each PBC
  11 + * reinventing it.
17 12 * - It is intentionally minimal: no equality, no hashCode, no lifecycle
18 13 * callbacks. Those are concerns of `platform.persistence`, not the public
19 14 * contract. Adding them later is a breaking change; leaving them out is not.
20 15 *
  16 + * **Single-tenant per instance.** vibe_erp is deliberately single-tenant per
  17 + * deployment: one running instance serves exactly one company against an
  18 + * isolated database. There is no `tenantId` field on this interface and no
  19 + * tenant filtering anywhere in the framework. Hosting many customers means
  20 + * provisioning many independent instances, not multiplexing them in one
  21 + * process. See CLAUDE.md guardrail #5 for the rationale.
  22 + *
21 23 * Plug-ins implement this on their own data classes. They never see the
22 24 * underlying JPA `@Entity` annotation — that lives in the platform's internal
23 25 * mapping layer.
... ... @@ -25,7 +27,6 @@ import org.vibeerp.api.v1.core.TenantId
25 27 * ```
26 28 * data class Plate(
27 29 * override val id: Id<Plate>,
28   - * override val tenantId: TenantId,
29 30 * val gauge: Quantity,
30 31 * ) : Entity
31 32 * ```
... ... @@ -39,12 +40,4 @@ interface Entity {
39 40 * parameter to themselves.
40 41 */
41 42 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 43 }
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/DomainEvent.kt
1 1 package org.vibeerp.api.v1.event
2 2  
3 3 import org.vibeerp.api.v1.core.Id
4   -import org.vibeerp.api.v1.core.TenantId
5 4 import java.time.Instant
6 5  
7 6 /**
... ... @@ -12,21 +11,23 @@ import java.time.Instant
12 11 * other only through events or through `api.v1.ext.<pbc>` interfaces. If
13 12 * events were untyped, every consumer would have to know every producer's
14 13 * 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.
  14 + * - The standard envelope (`eventId`, `occurredAt`, `aggregateType`,
  15 + * `aggregateId`) is what the platform's outbox table, audit log, and
  16 + * future Kafka/NATS bridge all rely on. Plug-ins that forget any of these
  17 + * fields cannot misroute or replay events.
  18 + *
  19 + * **No tenant id.** vibe_erp is single-tenant per instance — the entire
  20 + * process belongs to one company — so events do not need to carry a tenant
  21 + * discriminator. The hosting environment (Postgres database, Kafka cluster,
  22 + * future event archive) is itself per-instance.
19 23 *
20 24 * Note that this is `interface`, not `sealed interface`: plug-ins must be
21 25 * 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.
  26 + * A truly closed hierarchy would make plug-ins impossible.
25 27 *
26 28 * ```
27 29 * data class PlateApproved(
28 30 * override val eventId: Id<DomainEvent> = Id.random(),
29   - * override val tenantId: TenantId,
30 31 * override val occurredAt: Instant = Instant.now(),
31 32 * val plateId: Id<*>,
32 33 * ) : DomainEvent {
... ... @@ -40,9 +41,6 @@ interface DomainEvent {
40 41 /** Unique id for this event instance. Used for idempotent consumer logic and outbox dedup. */
41 42 val eventId: Id<DomainEvent>
42 43  
43   - /** Tenant the event belongs to. Carried explicitly because background consumers run outside any HTTP request. */
44   - val tenantId: TenantId
45   -
46 44 /** When the event occurred (UTC). Set by the producer, not by the bus, so replays preserve original timing. */
47 45 val occurredAt: Instant
48 46  
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt
1 1 package org.vibeerp.api.v1.ext.identity
2 2  
3 3 import org.vibeerp.api.v1.core.Id
4   -import org.vibeerp.api.v1.core.TenantId
5 4 import org.vibeerp.api.v1.security.PrincipalId
6 5  
7 6 /**
... ... @@ -26,9 +25,9 @@ import org.vibeerp.api.v1.security.PrincipalId
26 25 interface IdentityApi {
27 26  
28 27 /**
29   - * Look up a user by their unique username within the current tenant.
30   - * Returns `null` when no such user exists or when the calling principal
31   - * lacks permission to read user records.
  28 + * Look up a user by their unique username. Returns `null` when no such
  29 + * user exists or when the calling principal lacks permission to read
  30 + * user records.
32 31 */
33 32 fun findUserByUsername(username: String): UserRef?
34 33  
... ... @@ -50,8 +49,7 @@ interface IdentityApi {
50 49 * a deliberate api.v1 change and triggers a security review.
51 50 *
52 51 * @property id Stable user id.
53   - * @property username Login handle. Unique within [tenantId].
54   - * @property tenantId Owning tenant.
  52 + * @property username Login handle. Unique across the instance.
55 53 * @property displayName Human-readable name for UI rendering. Falls back to
56 54 * [username] when the user has not set one.
57 55 * @property enabled Whether the account is active. A disabled user can still
... ... @@ -61,7 +59,6 @@ interface IdentityApi {
61 59 data class UserRef(
62 60 val id: Id<UserRef>,
63 61 val username: String,
64   - val tenantId: TenantId,
65 62 val displayName: String,
66 63 val enabled: Boolean,
67 64 )
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/RequestContext.kt
1 1 package org.vibeerp.api.v1.http
2 2  
3   -import org.vibeerp.api.v1.core.TenantId
4 3 import org.vibeerp.api.v1.security.Principal
5 4 import java.util.Locale
6 5  
... ... @@ -13,10 +12,15 @@ import java.util.Locale
13 12 * inside api.v1 would have to bridge classloaders to find the host's
14 13 * state. An injected context is cleaner, testable, and forces the
15 14 * 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,
  15 + * - It is the single answer to "who, when, which language, which request"
  16 + * that every other api.v1 surface (permission check, translator,
18 17 * repository, event bus) consults under the covers.
19 18 *
  19 + * **No tenant id.** vibe_erp is single-tenant per instance — the entire
  20 + * process belongs to one company — so the request context does not need to
  21 + * carry a tenant. The instance's company identity comes from configuration
  22 + * (`vibeerp.instance.company-name`) and is global to the process.
  23 + *
20 24 * The host provides one [RequestContext] per HTTP request and per workflow
21 25 * task execution. Background jobs receive a context whose [principal] is a
22 26 * [org.vibeerp.api.v1.security.Principal.System] and whose [requestId] is
... ... @@ -27,14 +31,6 @@ interface RequestContext {
27 31 /** The authenticated caller. Never `null`; anonymous endpoints get a system principal. */
28 32 fun principal(): Principal
29 33  
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 34 /** The resolved request locale. Same value [org.vibeerp.api.v1.i18n.LocaleProvider.current] would return. */
39 35 fun locale(): Locale
40 36  
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt
... ... @@ -4,20 +4,21 @@ import org.vibeerp.api.v1.core.Id
4 4 import org.vibeerp.api.v1.entity.Entity
5 5  
6 6 /**
7   - * Generic, tenant-scoped repository contract for any [Entity].
  7 + * Generic repository contract for any [Entity].
8 8 *
9 9 * Why this lives in api.v1:
10 10 * - Plug-ins must be able to persist their own entities without depending on
11 11 * Spring Data, JPA, or any platform internal class. The platform binds this
12 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 13 * - It is generic on `T : Entity` so the type system rejects "wrong-entity"
19 14 * mistakes at compile time.
20 15 *
  16 + * **Single-tenant per instance.** vibe_erp does no tenant filtering anywhere
  17 + * in the framework — the entire process serves one company against one
  18 + * isolated database. Customer isolation happens at the *deployment* level
  19 + * (one running instance per customer), not at the row level. See CLAUDE.md
  20 + * guardrail #5.
  21 + *
21 22 * Methods are deliberately few. Anything more sophisticated (joins,
22 23 * projections, native SQL) is a smell at this layer — model the use case as a
23 24 * domain service or a query object instead. Adding methods later (with default
... ... @@ -26,9 +27,7 @@ import org.vibeerp.api.v1.entity.Entity
26 27 interface Repository<T : Entity> {
27 28  
28 29 /**
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.
  30 + * Find by primary key. Returns `null` when no row exists.
32 31 */
33 32 fun findById(id: Id<T>): T?
34 33  
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Principal.kt
1 1 package org.vibeerp.api.v1.security
2 2  
3 3 import org.vibeerp.api.v1.core.Id
4   -import org.vibeerp.api.v1.core.TenantId
5 4  
6 5 /**
7 6 * Type alias for a principal identifier.
8 7 *
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>`.
  8 + * Distinct phantom-typed alias of [Id] so signatures like
  9 + * `createdBy: PrincipalId` are self-documenting in IDE hovers and so the
  10 + * compiler refuses to mix a `PrincipalId` with, say, an `Id<Order>`.
12 11 */
13 12 typealias PrincipalId = Id<Principal>
14 13  
... ... @@ -28,6 +27,12 @@ typealias PrincipalId = Id&lt;Principal&gt;
28 27 * plug-in-to-plug-in calls and for bookkeeping rows the plug-in writes
29 28 * without a user request (e.g. a periodic sync from an external system).
30 29 *
  30 + * **Single-tenant per instance.** Principals do NOT carry a tenant id —
  31 + * the entire vibe_erp instance is owned by exactly one company, so there
  32 + * is no tenant for the principal to belong to. The company identity comes
  33 + * from configuration (`vibeerp.instance.company-name`) and is the same for
  34 + * every principal in the process.
  35 + *
31 36 * Sealed because the framework needs to switch on the full set in audit and
32 37 * permission code; new principal kinds are deliberate api.v1 changes.
33 38 */
... ... @@ -37,20 +42,11 @@ sealed interface Principal {
37 42 val id: PrincipalId
38 43  
39 44 /**
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 45 * A real human user (or an AI agent acting on a human's behalf, which the
49 46 * framework intentionally does not distinguish at the principal level).
50 47 */
51 48 data class User(
52 49 override val id: PrincipalId,
53   - override val tenantId: TenantId,
54 50 val username: String,
55 51 ) : Principal {
56 52 init {
... ... @@ -64,7 +60,6 @@ sealed interface Principal {
64 60 */
65 61 data class System(
66 62 override val id: PrincipalId,
67   - override val tenantId: TenantId,
68 63 val name: String,
69 64 ) : Principal {
70 65 init {
... ... @@ -75,7 +70,6 @@ sealed interface Principal {
75 70 /** A plug-in acting under its own identity. */
76 71 data class PluginPrincipal(
77 72 override val id: PrincipalId,
78   - override val tenantId: TenantId,
79 73 val pluginId: String,
80 74 ) : Principal {
81 75 init {
... ...
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 3 import org.vibeerp.api.v1.security.Principal
5 4 import java.util.Locale
6 5  
... ... @@ -71,17 +70,6 @@ interface TaskContext {
71 70 fun set(name: String, value: Any?)
72 71  
73 72 /**
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 73 * The principal whose action triggered this task instance. For
86 74 * automatically scheduled tasks (timer-driven, signal-driven), this
87 75 * is a system principal — never null.
... ...
api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/IdentifiersTest.kt
... ... @@ -3,40 +3,46 @@ package org.vibeerp.api.v1.core
3 3 import assertk.assertFailure
4 4 import assertk.assertThat
5 5 import assertk.assertions.isEqualTo
  6 +import assertk.assertions.isNotEqualTo
6 7 import org.junit.jupiter.api.Test
  8 +import java.util.UUID
7 9  
  10 +/** Phantom-typed [Id] sanity checks. */
8 11 class IdentifiersTest {
9 12  
10   - @Test
11   - fun `TenantId rejects blank`() {
12   - assertFailure { TenantId("") }
13   - assertFailure { TenantId(" ") }
14   - }
  13 + private class Foo
  14 + private class Bar
15 15  
16 16 @Test
17   - fun `TenantId rejects too-long values`() {
18   - val tooLong = "a".repeat(65)
19   - assertFailure { TenantId(tooLong) }
  17 + fun `Id of valid uuid string round-trips`() {
  18 + val raw = "11111111-2222-3333-4444-555555555555"
  19 + val id: Id<Foo> = Id.of(raw)
  20 + assertThat(id.value).isEqualTo(UUID.fromString(raw))
  21 + assertThat(id.toString()).isEqualTo(raw)
20 22 }
21 23  
22 24 @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
  25 + fun `Id of invalid uuid string fails`() {
  26 + assertFailure { Id.of<Foo>("not-a-uuid") }
28 27 }
29 28  
30 29 @Test
31   - fun `TenantId accepts the default tenant id`() {
32   - assertThat(TenantId("default").value).isEqualTo("default")
33   - assertThat(TenantId.DEFAULT.value).isEqualTo("default")
  30 + fun `Id random produces distinct ids`() {
  31 + val a = Id.random<Foo>()
  32 + val b = Id.random<Foo>()
  33 + assertThat(a).isNotEqualTo(b)
34 34 }
35 35  
36 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")
  37 + fun `Id of two different phantom types are still equal when their UUIDs match`() {
  38 + // The phantom parameter is erased at runtime; equality is by UUID.
  39 + // This test exists to document that the type-safety of `Id<T>` is a
  40 + // *compile-time* guarantee, not a runtime one. The compiler refuses
  41 + // to mix `Id<Foo>` with `Id<Bar>`, but reflection or unchecked casts
  42 + // can still smuggle them — value classes do not box at runtime.
  43 + val uuid = UUID.randomUUID()
  44 + val a = Id<Foo>(uuid)
  45 + val b = Id<Bar>(uuid)
  46 + assertThat(a.value).isEqualTo(b.value)
41 47 }
42 48 }
... ...
distribution/build.gradle.kts
... ... @@ -37,10 +37,17 @@ dependencies {
37 37 testImplementation(libs.spring.boot.starter.test)
38 38 }
39 39  
  40 +// Configure Spring Boot's main class once, on the `springBoot` extension.
  41 +// This is the only place that drives BOTH bootJar and bootRun — setting
  42 +// mainClass on `tasks.bootJar` alone leaves `bootRun` unable to resolve
  43 +// the entry point because :distribution has no Kotlin sources of its own.
  44 +springBoot {
  45 + mainClass.set("org.vibeerp.platform.bootstrap.VibeErpApplicationKt")
  46 +}
  47 +
40 48 // The fat-jar produced here is what the Dockerfile copies into the
41 49 // runtime image as /app/vibe-erp.jar.
42 50 tasks.bootJar {
43   - mainClass.set("org.vibeerp.platform.bootstrap.VibeErpApplicationKt")
44 51 archiveFileName.set("vibe-erp.jar")
45 52 }
46 53  
... ...
distribution/src/main/resources/application.yaml
... ... @@ -8,6 +8,10 @@
8 8 # Customer overrides live in /opt/vibe-erp/config/vibe-erp.yaml on the
9 9 # mounted volume. Plug-in configuration lives in metadata__plugin_config,
10 10 # never here.
  11 +#
  12 +# vibe_erp is single-tenant per instance: one running process serves
  13 +# exactly one company against an isolated database. There is no
  14 +# multi-tenancy configuration here, by design.
11 15  
12 16 spring:
13 17 application:
... ... @@ -22,18 +26,6 @@ spring:
22 26 hibernate:
23 27 ddl-auto: validate
24 28 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
37 29 liquibase:
38 30 change-log: classpath:db/changelog/master.xml
39 31  
... ... @@ -49,8 +41,11 @@ management:
49 41  
50 42 vibeerp:
51 43 instance:
52   - mode: ${VIBEERP_INSTANCE_MODE:self-hosted}
53   - default-tenant: ${VIBEERP_DEFAULT_TENANT:default}
  44 + # Display name of the company that owns this instance. Shown in branding,
  45 + # report headers, and audit logs. There is exactly ONE company per
  46 + # running instance — provisioning a second customer means deploying a
  47 + # second instance with its own database.
  48 + company-name: ${VIBEERP_COMPANY_NAME:vibe_erp instance}
54 49 plugins:
55 50 directory: ${VIBEERP_PLUGINS_DIR:/opt/vibe-erp/plugins}
56 51 auto-load: true
... ...
distribution/src/main/resources/db/changelog/pbc-identity/001-identity-init.xml
... ... @@ -9,23 +9,17 @@
9 9  
10 10 Owns: identity__user, identity__role, identity__user_role.
11 11  
  12 + vibe_erp is single-tenant per instance: one running process serves
  13 + exactly one company against an isolated database. There are no
  14 + tenant_id columns and no Row-Level Security policies on these
  15 + tables — customer isolation happens at the deployment level.
  16 +
12 17 Conventions enforced for every business table in vibe_erp:
13 18 • UUID primary key
14   - • tenant_id varchar(64) NOT NULL
15 19 • Audit columns: created_at, created_by, updated_at, updated_by
16 20 • Optimistic-locking version column
17 21 • ext jsonb NOT NULL DEFAULT '{}' for key-user custom fields
18 22 • GIN index on ext for fast custom-field queries
19   - • Composite index on (tenant_id, ...) for tenant-scoped lookups
20   - • Postgres Row-Level Security enabled, with a simple per-tenant
21   - policy bound to the GUC `vibeerp.current_tenant` set by the
22   - platform on every transaction (see TODO below)
23   -
24   - TODO(v0.2): the platform's `RlsTransactionHook` is not yet
25   - implemented, so the RLS policy in this file uses
26   - `current_setting('vibeerp.current_tenant', true)` and is enabled
27   - but not yet enforced (NO FORCE). When the hook lands, change to
28   - FORCE ROW LEVEL SECURITY in a follow-up changeset.
29 23 -->
30 24  
31 25 <changeSet id="identity-init-001" author="vibe_erp">
... ... @@ -33,7 +27,6 @@
33 27 <sql>
34 28 CREATE TABLE identity__user (
35 29 id uuid PRIMARY KEY,
36   - tenant_id varchar(64) NOT NULL,
37 30 username varchar(128) NOT NULL,
38 31 display_name varchar(256) NOT NULL,
39 32 email varchar(320),
... ... @@ -45,8 +38,8 @@
45 38 updated_by varchar(128) NOT NULL,
46 39 version bigint NOT NULL DEFAULT 0
47 40 );
48   - CREATE UNIQUE INDEX identity__user_tenant_username_uk
49   - ON identity__user (tenant_id, username);
  41 + CREATE UNIQUE INDEX identity__user_username_uk
  42 + ON identity__user (username);
50 43 CREATE INDEX identity__user_ext_gin
51 44 ON identity__user USING GIN (ext jsonb_path_ops);
52 45 </sql>
... ... @@ -56,24 +49,10 @@
56 49 </changeSet>
57 50  
58 51 <changeSet id="identity-init-002" author="vibe_erp">
59   - <comment>Enable Row-Level Security on identity__user (advisory until RlsTransactionHook lands)</comment>
60   - <sql>
61   - ALTER TABLE identity__user ENABLE ROW LEVEL SECURITY;
62   - CREATE POLICY identity__user_tenant_isolation ON identity__user
63   - USING (tenant_id = current_setting('vibeerp.current_tenant', true));
64   - </sql>
65   - <rollback>
66   - DROP POLICY IF EXISTS identity__user_tenant_isolation ON identity__user;
67   - ALTER TABLE identity__user DISABLE ROW LEVEL SECURITY;
68   - </rollback>
69   - </changeSet>
70   -
71   - <changeSet id="identity-init-003" author="vibe_erp">
72 52 <comment>Create identity__role table</comment>
73 53 <sql>
74 54 CREATE TABLE identity__role (
75 55 id uuid PRIMARY KEY,
76   - tenant_id varchar(64) NOT NULL,
77 56 code varchar(64) NOT NULL,
78 57 name varchar(256) NOT NULL,
79 58 description text,
... ... @@ -84,25 +63,21 @@
84 63 updated_by varchar(128) NOT NULL,
85 64 version bigint NOT NULL DEFAULT 0
86 65 );
87   - CREATE UNIQUE INDEX identity__role_tenant_code_uk
88   - ON identity__role (tenant_id, code);
  66 + CREATE UNIQUE INDEX identity__role_code_uk
  67 + ON identity__role (code);
89 68 CREATE INDEX identity__role_ext_gin
90 69 ON identity__role USING GIN (ext jsonb_path_ops);
91   - ALTER TABLE identity__role ENABLE ROW LEVEL SECURITY;
92   - CREATE POLICY identity__role_tenant_isolation ON identity__role
93   - USING (tenant_id = current_setting('vibeerp.current_tenant', true));
94 70 </sql>
95 71 <rollback>
96 72 DROP TABLE identity__role;
97 73 </rollback>
98 74 </changeSet>
99 75  
100   - <changeSet id="identity-init-004" author="vibe_erp">
  76 + <changeSet id="identity-init-003" author="vibe_erp">
101 77 <comment>Create identity__user_role join table</comment>
102 78 <sql>
103 79 CREATE TABLE identity__user_role (
104 80 id uuid PRIMARY KEY,
105   - tenant_id varchar(64) NOT NULL,
106 81 user_id uuid NOT NULL REFERENCES identity__user(id) ON DELETE CASCADE,
107 82 role_id uuid NOT NULL REFERENCES identity__role(id) ON DELETE CASCADE,
108 83 created_at timestamptz NOT NULL,
... ... @@ -112,10 +87,7 @@
112 87 version bigint NOT NULL DEFAULT 0
113 88 );
114 89 CREATE UNIQUE INDEX identity__user_role_uk
115   - ON identity__user_role (tenant_id, user_id, role_id);
116   - ALTER TABLE identity__user_role ENABLE ROW LEVEL SECURITY;
117   - CREATE POLICY identity__user_role_tenant_isolation ON identity__user_role
118   - USING (tenant_id = current_setting('vibeerp.current_tenant', true));
  90 + ON identity__user_role (user_id, role_id);
119 91 </sql>
120 92 <rollback>
121 93 DROP TABLE identity__user_role;
... ...
distribution/src/main/resources/db/changelog/platform/000-platform-init.xml
... ... @@ -2,410 +2,219 @@
2 2 <!--
3 3 vibe_erp — platform init changelog.
4 4  
5   - Creates the cross-cutting platform tables: a tenant table, an audit
6   - table, and the metadata__* registry tables described in architecture
7   - spec section 8 ("The metadata store").
  5 + Creates the cross-cutting platform tables: an audit log and the
  6 + metadata__* registry tables described in architecture spec section 8
  7 + ("The metadata store").
8 8  
9   - These are intentionally minimal v0.1 stubs: id + tenant_id + source +
10   - timestamps + (where useful) a payload jsonb. Real columns are added by
11   - later changesets in the PBCs that own them. pbc-identity owns the User
  9 + These are intentionally minimal v0.1 stubs: id + source + timestamps
  10 + + (where useful) a payload jsonb. Real columns are added by later
  11 + changesets in the PBCs that own them. pbc-identity owns the User
12 12 schema and ships its own changelog.
13 13  
14   - NOTE: Postgres Row-Level Security policies are NOT enabled here. RLS
15   - policies are added in a dedicated changeset in the v1.0 cut, so that
16   - they can be authored alongside the application-level @TenantId filter
17   - (architecture spec section 8 — "Tenant isolation"). The build pattern
18   - for that changeset will mirror the changesets below.
  14 + vibe_erp is single-tenant per instance: one running process serves
  15 + exactly one company against an isolated database. There are no
  16 + tenant_id columns and no Row-Level Security policies anywhere in
  17 + this changelog — customer isolation happens at the deployment level
  18 + (one running instance per customer).
19 19 -->
20 20 <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
21 21 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
22 22 xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
23 23 https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
24 24  
25   - <!-- ─── platform__tenant ─────────────────────────────────────────── -->
26   - <changeSet id="platform-init-001" author="vibe_erp">
27   - <createTable tableName="platform__tenant">
28   - <column name="id" type="uuid">
29   - <constraints primaryKey="true" nullable="false"/>
30   - </column>
31   - <column name="tenant_id" type="varchar(64)">
32   - <constraints nullable="false"/>
33   - </column>
34   - <column name="source" type="varchar(32)" defaultValue="core">
35   - <constraints nullable="false"/>
36   - </column>
37   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
38   - <constraints nullable="false"/>
39   - </column>
40   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
41   - <constraints nullable="false"/>
42   - </column>
43   - </createTable>
44   - <rollback>
45   - <dropTable tableName="platform__tenant"/>
46   - </rollback>
47   - </changeSet>
48   -
49 25 <!-- ─── platform__audit ──────────────────────────────────────────── -->
50   - <changeSet id="platform-init-002" author="vibe_erp">
  26 + <changeSet id="platform-init-001" author="vibe_erp">
51 27 <createTable tableName="platform__audit">
52   - <column name="id" type="uuid">
53   - <constraints primaryKey="true" nullable="false"/>
54   - </column>
55   - <column name="tenant_id" type="varchar(64)">
56   - <constraints nullable="false"/>
57   - </column>
58   - <column name="source" type="varchar(32)" defaultValue="core">
59   - <constraints nullable="false"/>
60   - </column>
61   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
62   - <constraints nullable="false"/>
63   - </column>
64   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
65   - <constraints nullable="false"/>
66   - </column>
67   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
68   - <constraints nullable="false"/>
69   - </column>
  28 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  29 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  30 + <column name="payload" type="jsonb"/>
  31 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  32 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
70 33 </createTable>
71   - <rollback>
72   - <dropTable tableName="platform__audit"/>
73   - </rollback>
  34 + <createIndex tableName="platform__audit" indexName="platform__audit_source_idx">
  35 + <column name="source"/>
  36 + </createIndex>
  37 + <rollback><dropTable tableName="platform__audit"/></rollback>
74 38 </changeSet>
75 39  
76 40 <!-- ─── metadata__entity ─────────────────────────────────────────── -->
77   - <changeSet id="platform-init-003" author="vibe_erp">
  41 + <changeSet id="platform-init-002" author="vibe_erp">
78 42 <createTable tableName="metadata__entity">
79   - <column name="id" type="uuid">
80   - <constraints primaryKey="true" nullable="false"/>
81   - </column>
82   - <column name="tenant_id" type="varchar(64)">
83   - <constraints nullable="false"/>
84   - </column>
85   - <column name="source" type="varchar(32)" defaultValue="core">
86   - <constraints nullable="false"/>
87   - </column>
88   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
89   - <constraints nullable="false"/>
90   - </column>
91   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
92   - <constraints nullable="false"/>
93   - </column>
94   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
95   - <constraints nullable="false"/>
96   - </column>
  43 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  44 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  45 + <column name="payload" type="jsonb"/>
  46 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  47 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
97 48 </createTable>
98   - <createIndex tableName="metadata__entity" indexName="idx_metadata__entity__tenant_source">
99   - <column name="tenant_id"/>
  49 + <createIndex tableName="metadata__entity" indexName="metadata__entity_source_idx">
100 50 <column name="source"/>
101 51 </createIndex>
102   - <rollback>
103   - <dropTable tableName="metadata__entity"/>
104   - </rollback>
  52 + <rollback><dropTable tableName="metadata__entity"/></rollback>
105 53 </changeSet>
106 54  
107 55 <!-- ─── metadata__custom_field ───────────────────────────────────── -->
108   - <changeSet id="platform-init-004" author="vibe_erp">
  56 + <changeSet id="platform-init-003" author="vibe_erp">
109 57 <createTable tableName="metadata__custom_field">
110   - <column name="id" type="uuid">
111   - <constraints primaryKey="true" nullable="false"/>
112   - </column>
113   - <column name="tenant_id" type="varchar(64)">
114   - <constraints nullable="false"/>
115   - </column>
116   - <column name="source" type="varchar(32)" defaultValue="core">
117   - <constraints nullable="false"/>
118   - </column>
119   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
120   - <constraints nullable="false"/>
121   - </column>
122   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
123   - <constraints nullable="false"/>
124   - </column>
125   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
126   - <constraints nullable="false"/>
127   - </column>
  58 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  59 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  60 + <column name="payload" type="jsonb"/>
  61 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  62 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
128 63 </createTable>
129   - <createIndex tableName="metadata__custom_field" indexName="idx_metadata__custom_field__tenant_source">
130   - <column name="tenant_id"/>
  64 + <createIndex tableName="metadata__custom_field" indexName="metadata__custom_field_source_idx">
131 65 <column name="source"/>
132 66 </createIndex>
133   - <rollback>
134   - <dropTable tableName="metadata__custom_field"/>
135   - </rollback>
  67 + <rollback><dropTable tableName="metadata__custom_field"/></rollback>
136 68 </changeSet>
137 69  
138 70 <!-- ─── metadata__form ───────────────────────────────────────────── -->
139   - <changeSet id="platform-init-005" author="vibe_erp">
  71 + <changeSet id="platform-init-004" author="vibe_erp">
140 72 <createTable tableName="metadata__form">
141   - <column name="id" type="uuid">
142   - <constraints primaryKey="true" nullable="false"/>
143   - </column>
144   - <column name="tenant_id" type="varchar(64)">
145   - <constraints nullable="false"/>
146   - </column>
147   - <column name="source" type="varchar(32)" defaultValue="core">
148   - <constraints nullable="false"/>
149   - </column>
150   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
151   - <constraints nullable="false"/>
152   - </column>
153   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
154   - <constraints nullable="false"/>
155   - </column>
156   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
157   - <constraints nullable="false"/>
158   - </column>
  73 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  74 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  75 + <column name="payload" type="jsonb"/>
  76 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  77 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  78 + </createTable>
  79 + <createIndex tableName="metadata__form" indexName="metadata__form_source_idx">
  80 + <column name="source"/>
  81 + </createIndex>
  82 + <rollback><dropTable tableName="metadata__form"/></rollback>
  83 + </changeSet>
  84 +
  85 + <!-- ─── metadata__list_view ──────────────────────────────────────── -->
  86 + <changeSet id="platform-init-005" author="vibe_erp">
  87 + <createTable tableName="metadata__list_view">
  88 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  89 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  90 + <column name="payload" type="jsonb"/>
  91 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  92 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
159 93 </createTable>
160   - <createIndex tableName="metadata__form" indexName="idx_metadata__form__tenant_source">
161   - <column name="tenant_id"/>
  94 + <createIndex tableName="metadata__list_view" indexName="metadata__list_view_source_idx">
162 95 <column name="source"/>
163 96 </createIndex>
164   - <rollback>
165   - <dropTable tableName="metadata__form"/>
166   - </rollback>
  97 + <rollback><dropTable tableName="metadata__list_view"/></rollback>
167 98 </changeSet>
168 99  
169 100 <!-- ─── metadata__workflow ───────────────────────────────────────── -->
170 101 <changeSet id="platform-init-006" author="vibe_erp">
171 102 <createTable tableName="metadata__workflow">
172   - <column name="id" type="uuid">
173   - <constraints primaryKey="true" nullable="false"/>
174   - </column>
175   - <column name="tenant_id" type="varchar(64)">
176   - <constraints nullable="false"/>
177   - </column>
178   - <column name="source" type="varchar(32)" defaultValue="core">
179   - <constraints nullable="false"/>
180   - </column>
181   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
182   - <constraints nullable="false"/>
183   - </column>
184   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
185   - <constraints nullable="false"/>
186   - </column>
187   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
188   - <constraints nullable="false"/>
189   - </column>
  103 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  104 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  105 + <column name="payload" type="jsonb"/>
  106 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  107 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
190 108 </createTable>
191   - <createIndex tableName="metadata__workflow" indexName="idx_metadata__workflow__tenant_source">
192   - <column name="tenant_id"/>
  109 + <createIndex tableName="metadata__workflow" indexName="metadata__workflow_source_idx">
193 110 <column name="source"/>
194 111 </createIndex>
195   - <rollback>
196   - <dropTable tableName="metadata__workflow"/>
197   - </rollback>
  112 + <rollback><dropTable tableName="metadata__workflow"/></rollback>
198 113 </changeSet>
199 114  
200 115 <!-- ─── metadata__rule ───────────────────────────────────────────── -->
201 116 <changeSet id="platform-init-007" author="vibe_erp">
202 117 <createTable tableName="metadata__rule">
203   - <column name="id" type="uuid">
204   - <constraints primaryKey="true" nullable="false"/>
205   - </column>
206   - <column name="tenant_id" type="varchar(64)">
207   - <constraints nullable="false"/>
208   - </column>
209   - <column name="source" type="varchar(32)" defaultValue="core">
210   - <constraints nullable="false"/>
211   - </column>
212   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
213   - <constraints nullable="false"/>
214   - </column>
215   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
216   - <constraints nullable="false"/>
217   - </column>
218   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
219   - <constraints nullable="false"/>
220   - </column>
  118 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  119 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  120 + <column name="payload" type="jsonb"/>
  121 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  122 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
221 123 </createTable>
222   - <createIndex tableName="metadata__rule" indexName="idx_metadata__rule__tenant_source">
223   - <column name="tenant_id"/>
  124 + <createIndex tableName="metadata__rule" indexName="metadata__rule_source_idx">
224 125 <column name="source"/>
225 126 </createIndex>
226   - <rollback>
227   - <dropTable tableName="metadata__rule"/>
228   - </rollback>
  127 + <rollback><dropTable tableName="metadata__rule"/></rollback>
229 128 </changeSet>
230 129  
231 130 <!-- ─── metadata__permission ─────────────────────────────────────── -->
232 131 <changeSet id="platform-init-008" author="vibe_erp">
233 132 <createTable tableName="metadata__permission">
234   - <column name="id" type="uuid">
235   - <constraints primaryKey="true" nullable="false"/>
236   - </column>
237   - <column name="tenant_id" type="varchar(64)">
238   - <constraints nullable="false"/>
239   - </column>
240   - <column name="source" type="varchar(32)" defaultValue="core">
241   - <constraints nullable="false"/>
242   - </column>
243   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
244   - <constraints nullable="false"/>
245   - </column>
246   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
247   - <constraints nullable="false"/>
248   - </column>
  133 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  134 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  135 + <column name="payload" type="jsonb"/>
  136 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  137 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
249 138 </createTable>
250   - <createIndex tableName="metadata__permission" indexName="idx_metadata__permission__tenant_source">
251   - <column name="tenant_id"/>
  139 + <createIndex tableName="metadata__permission" indexName="metadata__permission_source_idx">
252 140 <column name="source"/>
253 141 </createIndex>
254   - <rollback>
255   - <dropTable tableName="metadata__permission"/>
256   - </rollback>
  142 + <rollback><dropTable tableName="metadata__permission"/></rollback>
257 143 </changeSet>
258 144  
259 145 <!-- ─── metadata__role_permission ────────────────────────────────── -->
260 146 <changeSet id="platform-init-009" author="vibe_erp">
261 147 <createTable tableName="metadata__role_permission">
262   - <column name="id" type="uuid">
263   - <constraints primaryKey="true" nullable="false"/>
264   - </column>
265   - <column name="tenant_id" type="varchar(64)">
266   - <constraints nullable="false"/>
267   - </column>
268   - <column name="source" type="varchar(32)" defaultValue="core">
269   - <constraints nullable="false"/>
270   - </column>
271   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
272   - <constraints nullable="false"/>
273   - </column>
274   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
275   - <constraints nullable="false"/>
276   - </column>
  148 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  149 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  150 + <column name="payload" type="jsonb"/>
  151 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  152 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
277 153 </createTable>
278   - <createIndex tableName="metadata__role_permission" indexName="idx_metadata__role_permission__tenant_source">
279   - <column name="tenant_id"/>
  154 + <createIndex tableName="metadata__role_permission" indexName="metadata__role_permission_source_idx">
280 155 <column name="source"/>
281 156 </createIndex>
282   - <rollback>
283   - <dropTable tableName="metadata__role_permission"/>
284   - </rollback>
  157 + <rollback><dropTable tableName="metadata__role_permission"/></rollback>
285 158 </changeSet>
286 159  
287 160 <!-- ─── metadata__menu ───────────────────────────────────────────── -->
288 161 <changeSet id="platform-init-010" author="vibe_erp">
289 162 <createTable tableName="metadata__menu">
290   - <column name="id" type="uuid">
291   - <constraints primaryKey="true" nullable="false"/>
292   - </column>
293   - <column name="tenant_id" type="varchar(64)">
294   - <constraints nullable="false"/>
295   - </column>
296   - <column name="source" type="varchar(32)" defaultValue="core">
297   - <constraints nullable="false"/>
298   - </column>
299   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
300   - <constraints nullable="false"/>
301   - </column>
302   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
303   - <constraints nullable="false"/>
304   - </column>
305   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
306   - <constraints nullable="false"/>
307   - </column>
  163 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  164 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  165 + <column name="payload" type="jsonb"/>
  166 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  167 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
308 168 </createTable>
309   - <createIndex tableName="metadata__menu" indexName="idx_metadata__menu__tenant_source">
310   - <column name="tenant_id"/>
  169 + <createIndex tableName="metadata__menu" indexName="metadata__menu_source_idx">
311 170 <column name="source"/>
312 171 </createIndex>
313   - <rollback>
314   - <dropTable tableName="metadata__menu"/>
315   - </rollback>
  172 + <rollback><dropTable tableName="metadata__menu"/></rollback>
316 173 </changeSet>
317 174  
318 175 <!-- ─── metadata__report ─────────────────────────────────────────── -->
319 176 <changeSet id="platform-init-011" author="vibe_erp">
320 177 <createTable tableName="metadata__report">
321   - <column name="id" type="uuid">
322   - <constraints primaryKey="true" nullable="false"/>
323   - </column>
324   - <column name="tenant_id" type="varchar(64)">
325   - <constraints nullable="false"/>
326   - </column>
327   - <column name="source" type="varchar(32)" defaultValue="core">
328   - <constraints nullable="false"/>
329   - </column>
330   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
331   - <constraints nullable="false"/>
332   - </column>
333   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
334   - <constraints nullable="false"/>
335   - </column>
336   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
337   - <constraints nullable="false"/>
338   - </column>
  178 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  179 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  180 + <column name="payload" type="jsonb"/>
  181 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  182 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
339 183 </createTable>
340   - <createIndex tableName="metadata__report" indexName="idx_metadata__report__tenant_source">
341   - <column name="tenant_id"/>
  184 + <createIndex tableName="metadata__report" indexName="metadata__report_source_idx">
342 185 <column name="source"/>
343 186 </createIndex>
344   - <rollback>
345   - <dropTable tableName="metadata__report"/>
346   - </rollback>
  187 + <rollback><dropTable tableName="metadata__report"/></rollback>
347 188 </changeSet>
348 189  
349 190 <!-- ─── metadata__translation ────────────────────────────────────── -->
350 191 <changeSet id="platform-init-012" author="vibe_erp">
351 192 <createTable tableName="metadata__translation">
352   - <column name="id" type="uuid">
353   - <constraints primaryKey="true" nullable="false"/>
354   - </column>
355   - <column name="tenant_id" type="varchar(64)">
356   - <constraints nullable="false"/>
357   - </column>
358   - <column name="source" type="varchar(32)" defaultValue="core">
359   - <constraints nullable="false"/>
360   - </column>
361   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
362   - <constraints nullable="false"/>
363   - </column>
364   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
365   - <constraints nullable="false"/>
366   - </column>
367   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
368   - <constraints nullable="false"/>
369   - </column>
  193 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  194 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  195 + <column name="payload" type="jsonb"/>
  196 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  197 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
370 198 </createTable>
371   - <createIndex tableName="metadata__translation" indexName="idx_metadata__translation__tenant_source">
372   - <column name="tenant_id"/>
  199 + <createIndex tableName="metadata__translation" indexName="metadata__translation_source_idx">
373 200 <column name="source"/>
374 201 </createIndex>
375   - <rollback>
376   - <dropTable tableName="metadata__translation"/>
377   - </rollback>
  202 + <rollback><dropTable tableName="metadata__translation"/></rollback>
378 203 </changeSet>
379 204  
380 205 <!-- ─── metadata__plugin_config ──────────────────────────────────── -->
381 206 <changeSet id="platform-init-013" author="vibe_erp">
382 207 <createTable tableName="metadata__plugin_config">
383   - <column name="id" type="uuid">
384   - <constraints primaryKey="true" nullable="false"/>
385   - </column>
386   - <column name="tenant_id" type="varchar(64)">
387   - <constraints nullable="false"/>
388   - </column>
389   - <column name="source" type="varchar(32)" defaultValue="core">
390   - <constraints nullable="false"/>
391   - </column>
392   - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb">
393   - <constraints nullable="false"/>
394   - </column>
395   - <column name="created_at" type="timestamptz" defaultValueComputed="now()">
396   - <constraints nullable="false"/>
397   - </column>
398   - <column name="updated_at" type="timestamptz" defaultValueComputed="now()">
399   - <constraints nullable="false"/>
400   - </column>
  208 + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column>
  209 + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column>
  210 + <column name="payload" type="jsonb"/>
  211 + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
  212 + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column>
401 213 </createTable>
402   - <createIndex tableName="metadata__plugin_config" indexName="idx_metadata__plugin_config__tenant_source">
403   - <column name="tenant_id"/>
  214 + <createIndex tableName="metadata__plugin_config" indexName="metadata__plugin_config_source_idx">
404 215 <column name="source"/>
405 216 </createIndex>
406   - <rollback>
407   - <dropTable tableName="metadata__plugin_config"/>
408   - </rollback>
  217 + <rollback><dropTable tableName="metadata__plugin_config"/></rollback>
409 218 </changeSet>
410 219  
411 220 </databaseChangeLog>
... ...
local-vibeerp/config/vibe-erp.yaml
... ... @@ -6,8 +6,11 @@
6 6 # operators can copy it as a starting point for a real deployment.
7 7  
8 8 instance:
9   - mode: self-hosted
10   - default-tenant: default
  9 + # Display name of the company that owns this instance.
  10 + # vibe_erp is single-tenant per instance: one running process serves
  11 + # exactly one company against an isolated database. Provisioning a
  12 + # second customer means deploying a second instance.
  13 + company-name: Acme Printing Co.
11 14  
12 15 database:
13 16 url: jdbc:postgresql://db:5432/vibeerp
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/UserService.kt
... ... @@ -32,7 +32,7 @@ class UserService(
32 32  
33 33 fun create(command: CreateUserCommand): User {
34 34 require(!users.existsByUsername(command.username)) {
35   - "username '${command.username}' is already taken in this tenant"
  35 + "username '${command.username}' is already taken"
36 36 }
37 37 val user = User(
38 38 username = command.username,
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/User.kt
... ... @@ -2,7 +2,6 @@ package org.vibeerp.pbc.identity.domain
2 2  
3 3 import jakarta.persistence.Column
4 4 import jakarta.persistence.Entity
5   -import jakarta.persistence.Id
6 5 import jakarta.persistence.Table
7 6 import org.hibernate.annotations.JdbcTypeCode
8 7 import org.hibernate.type.SqlTypes
... ... @@ -17,9 +16,12 @@ import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
17 16 * This is the entire point of the modular monolith — PBCs get power, plug-ins
18 17 * get safety.
19 18 *
20   - * Multi-tenancy: `tenant_id` and audit columns come from [AuditedJpaEntity]
21   - * and are filled in by the platform's JPA listener. PBC code never writes
22   - * to those columns directly.
  19 + * Audit columns come from [AuditedJpaEntity] and are filled in by the
  20 + * platform's JPA listener. PBC code never writes to them directly.
  21 + *
  22 + * vibe_erp is single-tenant per instance — the entire database belongs to one
  23 + * company — so there is no `tenant_id` column on this entity and no tenant
  24 + * filtering anywhere in this PBC.
23 25 *
24 26 * Custom fields: the `ext` JSONB column is the metadata-driven custom-field
25 27 * store (architecture spec section 8). A key user can add fields to this
... ... @@ -58,5 +60,5 @@ class User(
58 60 var ext: String = "{}"
59 61  
60 62 override fun toString(): String =
61   - "User(id=$id, tenantId=$tenantId, username='$username')"
  63 + "User(id=$id, username='$username')"
62 64 }
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/ext/IdentityApiAdapter.kt
... ... @@ -3,7 +3,6 @@ package org.vibeerp.pbc.identity.ext
3 3 import org.springframework.stereotype.Component
4 4 import org.springframework.transaction.annotation.Transactional
5 5 import org.vibeerp.api.v1.core.Id
6   -import org.vibeerp.api.v1.core.TenantId
7 6 import org.vibeerp.api.v1.ext.identity.IdentityApi
8 7 import org.vibeerp.api.v1.ext.identity.UserRef
9 8 import org.vibeerp.api.v1.security.PrincipalId
... ... @@ -21,7 +20,6 @@ import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
21 20 *
22 21 * **What this adapter MUST NOT do:**
23 22 * • leak `org.vibeerp.pbc.identity.domain.User` to callers
24   - * • bypass tenant filtering (caller's tenant context is honored automatically)
25 23 * • return PII fields that aren't already on `UserRef`
26 24 *
27 25 * If a caller needs more fields, the answer is to grow `UserRef` deliberately
... ... @@ -42,7 +40,6 @@ class IdentityApiAdapter(
42 40 private fun User.toRef(): UserRef = UserRef(
43 41 id = Id<UserRef>(this.id),
44 42 username = this.username,
45   - tenantId = TenantId(this.tenantId),
46 43 displayName = this.displayName,
47 44 enabled = this.enabled,
48 45 )
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/UserController.kt
... ... @@ -29,12 +29,13 @@ import java.util.UUID
29 29 * PBC, and so plug-in endpoints (which mount under `/api/v1/plugins/...`)
30 30 * cannot collide with core endpoints.
31 31 *
32   - * Tenant id is bound automatically by `TenantResolutionFilter` upstream of
33   - * this controller — there is no `tenantId` parameter on any endpoint and
34   - * there never will be. Cross-tenant access is impossible by construction.
  32 + * vibe_erp is single-tenant per instance — the entire process serves one
  33 + * company against an isolated database — so this controller does no tenant
  34 + * filtering and exposes no `tenantId` parameter. Customer isolation happens
  35 + * at the deployment level (one running instance per customer).
35 36 *
36 37 * Authentication is deferred to v0.2 (pbc-identity's auth flow). For v0.1
37   - * the controller answers any request that gets past the tenant filter.
  38 + * the controller answers any request.
38 39 */
39 40 @RestController
40 41 @RequestMapping("/api/v1/identity/users")
... ... @@ -102,7 +103,6 @@ data class UpdateUserRequest(
102 103  
103 104 data class UserResponse(
104 105 val id: UUID,
105   - val tenantId: String,
106 106 val username: String,
107 107 val displayName: String,
108 108 val email: String?,
... ... @@ -111,7 +111,6 @@ data class UserResponse(
111 111  
112 112 private fun User.toResponse() = UserResponse(
113 113 id = this.id,
114   - tenantId = this.tenantId,
115 114 username = this.username,
116 115 displayName = this.displayName,
117 116 email = this.email,
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserJpaRepository.kt
... ... @@ -8,14 +8,11 @@ import java.util.UUID
8 8 /**
9 9 * Spring Data JPA repository for [User].
10 10 *
11   - * Tenant filtering happens automatically: Hibernate's
12   - * [org.vibeerp.platform.persistence.tenancy.HibernateTenantResolver] sees the
13   - * tenant from `TenantContext` and adds the discriminator predicate to every
14   - * generated query. Code in this file never writes `WHERE tenant_id = ?`.
15   - *
16   - * The Postgres Row-Level Security policy on `identity__user` is the second
17   - * wall — even if Hibernate's filter were misconfigured, the database would
18   - * still refuse to return cross-tenant rows.
  11 + * vibe_erp is single-tenant per instance — the entire database belongs to
  12 + * one company — so this repository does no tenant filtering. Querying for a
  13 + * user gives back the row if it exists, regardless of who is asking.
  14 + * Customer isolation happens at the *deployment* level: each customer gets
  15 + * their own running instance and their own Postgres database.
19 16 */
20 17 @Repository
21 18 interface UserJpaRepository : JpaRepository<User, UUID> {
... ...
pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/UserServiceTest.kt
... ... @@ -53,7 +53,7 @@ class UserServiceTest {
53 53 )
54 54 }
55 55 .isInstanceOf(IllegalArgumentException::class)
56   - .hasMessage("username 'alice' is already taken in this tenant")
  56 + .hasMessage("username 'alice' is already taken")
57 57 }
58 58  
59 59 @Test
... ...
platform/platform-bootstrap/build.gradle.kts
... ... @@ -21,13 +21,13 @@ kotlin {
21 21  
22 22 dependencies {
23 23 api(project(":api:api-v1"))
24   - api(project(":platform:platform-persistence")) // for TenantContext used by the HTTP filter
25 24 implementation(libs.kotlin.stdlib)
26 25 implementation(libs.kotlin.reflect)
27 26 implementation(libs.jackson.module.kotlin)
28 27  
29 28 implementation(libs.spring.boot.starter)
30 29 implementation(libs.spring.boot.starter.web)
  30 + implementation(libs.spring.boot.starter.data.jpa) // for @EnableJpaRepositories on VibeErpApplication
31 31 implementation(libs.spring.boot.starter.validation)
32 32 implementation(libs.spring.boot.starter.actuator)
33 33  
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt
1 1 package org.vibeerp.platform.bootstrap
2 2  
3 3 import org.springframework.boot.autoconfigure.SpringBootApplication
  4 +import org.springframework.boot.autoconfigure.domain.EntityScan
4 5 import org.springframework.boot.context.properties.ConfigurationPropertiesScan
5 6 import org.springframework.boot.runApplication
6 7 import org.springframework.context.annotation.ComponentScan
  8 +import org.springframework.data.jpa.repository.config.EnableJpaRepositories
7 9  
8 10 /**
9 11 * Entry point for the vibe_erp framework.
... ... @@ -13,8 +15,13 @@ import org.springframework.context.annotation.ComponentScan
13 15 * • all core PBCs that have been registered as Gradle dependencies of :distribution
14 16 * • the plug-in loader, which scans an external `./plugins/` directory
15 17 *
16   - * The package scan is anchored at `org.vibeerp` so every PBC and platform module
17   - * is picked up by Spring as long as its Kotlin packages live under that root.
  18 + * **The four `@*Scan` annotations are critical for modular monolith setups.**
  19 + * Spring Boot's defaults only scan the main application's own package
  20 + * (`org.vibeerp.platform.bootstrap`), but every PBC lives in its own package
  21 + * (`org.vibeerp.pbc.identity`, `org.vibeerp.pbc.catalog`, …). Without the
  22 + * explicit `org.vibeerp` root, Spring would refuse to wire PBC repositories
  23 + * and entities at startup. PBCs are added by listing them in
  24 + * `:distribution`'s build.gradle.kts, not by editing this file.
18 25 *
19 26 * **What does NOT live in this process:**
20 27 * • the React web client (separate build, served by reverse proxy)
... ... @@ -24,6 +31,8 @@ import org.springframework.context.annotation.ComponentScan
24 31 @SpringBootApplication
25 32 @ComponentScan(basePackages = ["org.vibeerp"])
26 33 @ConfigurationPropertiesScan(basePackages = ["org.vibeerp"])
  34 +@EnableJpaRepositories(basePackages = ["org.vibeerp"])
  35 +@EntityScan(basePackages = ["org.vibeerp"])
27 36 class VibeErpApplication
28 37  
29 38 fun main(args: Array<String>) {
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/config/VibeErpProperties.kt
... ... @@ -9,7 +9,7 @@ import java.util.Locale
9 9 *
10 10 * The set of keys here is **closed and documented**: plug-ins do NOT extend
11 11 * this tree. Per-plug-in configuration lives in the `metadata__plugin_config`
12   - * table, where it is tenant-scoped and editable in the UI.
  12 + * table, editable through the web UI.
13 13 *
14 14 * Reference: architecture spec section 10 ("The single config file").
15 15 */
... ... @@ -22,10 +22,15 @@ data class VibeErpProperties(
22 22 )
23 23  
24 24 data class InstanceProperties(
25   - /** "self-hosted" or "hosted". Self-hosted single-customer is the v0.1 default. */
26   - val mode: String = "self-hosted",
27   - /** Tenant id used when [mode] is "self-hosted". Always "default" unless overridden. */
28   - val defaultTenant: String = "default",
  25 + /**
  26 + * Display name of the company that owns this instance.
  27 + *
  28 + * vibe_erp is single-tenant per instance: one running process serves
  29 + * exactly one company against an isolated database. The company name
  30 + * appears in branding, report headers, and audit logs. Provisioning a
  31 + * second customer means deploying a second instance with its own DB.
  32 + */
  33 + val companyName: String = "vibe_erp instance",
29 34 /** External base URL of this instance, used for absolute links and OIDC. Optional. */
30 35 val baseUrl: String? = null,
31 36 )
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt 0 → 100644
  1 +package org.vibeerp.platform.bootstrap.web
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.http.HttpStatus
  5 +import org.springframework.http.ProblemDetail
  6 +import org.springframework.web.bind.annotation.ExceptionHandler
  7 +import org.springframework.web.bind.annotation.RestControllerAdvice
  8 +import java.time.Instant
  9 +
  10 +/**
  11 + * Process-wide exception → HTTP status translator.
  12 + *
  13 + * The framework has two principles for failure mapping:
  14 + *
  15 + * 1. **Expected failures get a typed status code.** A duplicate username,
  16 + * a missing entity, an invalid argument — these are part of normal
  17 + * business flow and should never surface as a 500. Mapping them here
  18 + * keeps every PBC controller free of try/catch noise.
  19 + *
  20 + * 2. **Unexpected failures stay 500.** Anything not handled below is a
  21 + * real bug or an infrastructure failure (DB down, NPE, …) — those
  22 + * need to be logged with a stack trace and surfaced as a generic
  23 + * "internal error" so an operator can pick them up from the logs.
  24 + *
  25 + * The response shape uses Spring's `ProblemDetail` (RFC 7807), which is
  26 + * what every modern API client and OpenAPI tool understands out of the box.
  27 + */
  28 +@RestControllerAdvice
  29 +class GlobalExceptionHandler {
  30 +
  31 + private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)
  32 +
  33 + /**
  34 + * `require(...)` failures and explicit `IllegalArgumentException` from
  35 + * domain code map to 400 Bad Request. Domain code uses these for
  36 + * "the caller asked for something that does not make sense" — invalid
  37 + * combinations, duplicate keys, malformed inputs the JSR-380 validation
  38 + * layer didn't catch.
  39 + */
  40 + @ExceptionHandler(IllegalArgumentException::class)
  41 + fun handleIllegalArgument(ex: IllegalArgumentException): ProblemDetail =
  42 + problem(HttpStatus.BAD_REQUEST, ex.message ?: "invalid argument")
  43 +
  44 + /**
  45 + * Domain-layer "I asked for X but it isn't here" → 404 Not Found.
  46 + * Application services use `NoSuchElementException` consistently for
  47 + * this; mapping it here keeps controllers from manually wrapping every
  48 + * `findById().orElseThrow { ... }` in a try/catch.
  49 + */
  50 + @ExceptionHandler(NoSuchElementException::class)
  51 + fun handleNotFound(ex: NoSuchElementException): ProblemDetail =
  52 + problem(HttpStatus.NOT_FOUND, ex.message ?: "not found")
  53 +
  54 + /**
  55 + * Last-resort fallback. Anything not handled above is logged with a
  56 + * full stack trace and surfaced as a generic 500 to the caller. The
  57 + * detail message is intentionally vague so we don't leak internals.
  58 + */
  59 + @ExceptionHandler(Throwable::class)
  60 + fun handleAny(ex: Throwable): ProblemDetail {
  61 + log.error("Unhandled exception bubbled to GlobalExceptionHandler", ex)
  62 + return problem(HttpStatus.INTERNAL_SERVER_ERROR, "internal error")
  63 + }
  64 +
  65 + private fun problem(status: HttpStatus, message: String): ProblemDetail =
  66 + ProblemDetail.forStatusAndDetail(status, message).apply {
  67 + setProperty("timestamp", Instant.now().toString())
  68 + }
  69 +}
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/TenantResolutionFilter.kt deleted
1   -package org.vibeerp.platform.bootstrap.web
2   -
3   -import jakarta.servlet.FilterChain
4   -import jakarta.servlet.http.HttpServletRequest
5   -import jakarta.servlet.http.HttpServletResponse
6   -import org.springframework.boot.context.properties.ConfigurationProperties
7   -import org.springframework.core.Ordered
8   -import org.springframework.core.annotation.Order
9   -import org.springframework.stereotype.Component
10   -import org.springframework.web.filter.OncePerRequestFilter
11   -import org.vibeerp.api.v1.core.TenantId
12   -import org.vibeerp.platform.persistence.tenancy.TenantContext
13   -
14   -/**
15   - * HTTP filter that binds the request's tenant id to [TenantContext] for the
16   - * duration of the request and clears it on the way out.
17   - *
18   - * Lives in `platform-bootstrap` (not `platform-persistence`) because the
19   - * persistence layer must not depend on the servlet API or Spring Web — that
20   - * would couple JPA code to HTTP and forbid running PBCs from background
21   - * jobs or message consumers.
22   - *
23   - * Resolution order:
24   - * 1. `X-Tenant-Id` header (used by trusted internal callers and tests)
25   - * 2. `tenant` claim on the JWT (production path, once auth is wired up)
26   - * 3. The configured fallback tenant when running in self-hosted single-tenant mode
27   - *
28   - * The third path is what makes self-hosted single-customer use the SAME
29   - * code path as hosted multi-tenant: the request always sees a tenant in
30   - * `TenantContext`, the only difference is whether it came from the JWT
31   - * or from configuration. PBC code never branches on `mode`.
32   - */
33   -@Component
34   -@Order(Ordered.HIGHEST_PRECEDENCE + 10)
35   -class TenantResolutionFilter(
36   - private val tenancyProperties: TenancyProperties,
37   -) : OncePerRequestFilter() {
38   -
39   - override fun doFilterInternal(
40   - request: HttpServletRequest,
41   - response: HttpServletResponse,
42   - filterChain: FilterChain,
43   - ) {
44   - val tenantId = resolveTenant(request)
45   - TenantContext.runAs(tenantId) {
46   - filterChain.doFilter(request, response)
47   - }
48   - }
49   -
50   - private fun resolveTenant(request: HttpServletRequest): TenantId {
51   - val header = request.getHeader("X-Tenant-Id")
52   - if (header != null && header.isNotBlank()) {
53   - return TenantId(header)
54   - }
55   - // TODO(v0.2): read JWT 'tenant' claim once pbc-identity ships auth.
56   - return TenantId(tenancyProperties.fallbackTenant)
57   - }
58   -}
59   -
60   -@Component
61   -@ConfigurationProperties(prefix = "vibeerp.tenancy")
62   -data class TenancyProperties(
63   - /**
64   - * Tenant id to use when no header or JWT claim is present.
65   - * Defaults to "default" — the canonical id for self-hosted single-tenant.
66   - */
67   - var fallbackTenant: String = "default",
68   -)
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt
... ... @@ -2,12 +2,11 @@ package org.vibeerp.platform.persistence.audit
2 2  
3 3 import jakarta.persistence.Column
4 4 import jakarta.persistence.EntityListeners
  5 +import jakarta.persistence.Id
5 6 import jakarta.persistence.MappedSuperclass
6 7 import jakarta.persistence.PrePersist
7 8 import jakarta.persistence.PreUpdate
8 9 import jakarta.persistence.Version
9   -import org.hibernate.annotations.TenantId
10   -import org.vibeerp.platform.persistence.tenancy.TenantContext
11 10 import java.time.Instant
12 11 import java.util.UUID
13 12  
... ... @@ -16,10 +15,16 @@ import java.util.UUID
16 15 *
17 16 * Provides, for free, on every business table:
18 17 * • `id` (UUID primary key)
19   - * • `tenant_id` (multi-tenant discriminator, NOT NULL, never written manually)
20 18 * • `created_at`, `created_by`, `updated_at`, `updated_by` (audit columns)
21 19 * • `version` (optimistic-locking column for safe concurrent edits)
22 20 *
  21 + * **Single-tenant per instance.** vibe_erp is a single-tenant framework: one
  22 + * running process serves exactly one company against an isolated database.
  23 + * There is no `tenant_id` column on this superclass and no tenant filtering
  24 + * anywhere in the platform. Hosting multiple customers means provisioning
  25 + * multiple instances, not multiplexing them in one process — see CLAUDE.md
  26 + * guardrail #5 for the rationale.
  27 + *
23 28 * **What this is NOT:**
24 29 * • This is INTERNAL to the platform layer. PBC code that lives in
25 30 * `pbc-*` extends this directly, but plug-ins do NOT — plug-ins extend
... ... @@ -34,21 +39,10 @@ import java.util.UUID
34 39 @EntityListeners(AuditedJpaEntityListener::class)
35 40 abstract class AuditedJpaEntity {
36 41  
  42 + @Id
37 43 @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid")
38 44 open var id: UUID = UUID.randomUUID()
39 45  
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
49   - @Column(name = "tenant_id", updatable = false, nullable = false, length = 64)
50   - open var tenantId: String = ""
51   -
52 46 @Column(name = "created_at", updatable = false, nullable = false)
53 47 open var createdAt: Instant = Instant.EPOCH
54 48  
... ... @@ -67,11 +61,11 @@ abstract class AuditedJpaEntity {
67 61 }
68 62  
69 63 /**
70   - * JPA entity listener that fills in tenant id and audit columns at save time.
  64 + * JPA entity listener that fills in audit columns at save time.
71 65 *
72   - * Reads tenant from `TenantContext` (set by an HTTP filter) and the principal
73   - * from a yet-to-exist `PrincipalContext` — for v0.1 the principal is hard-coded
74   - * to `__system__` because pbc-identity's auth flow lands in v0.2.
  66 + * Reads the principal from a yet-to-exist `PrincipalContext` — for v0.1 the
  67 + * principal is hard-coded to `__system__` because pbc-identity's auth flow
  68 + * lands in v0.2.
75 69 */
76 70 class AuditedJpaEntityListener {
77 71  
... ... @@ -79,21 +73,7 @@ class AuditedJpaEntityListener {
79 73 fun onCreate(entity: Any) {
80 74 if (entity is AuditedJpaEntity) {
81 75 val now = Instant.now()
82   - val tenant = TenantContext.currentOrNull()
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   -
95 76 val principal = currentPrincipal()
96   - entity.tenantId = tenant.value
97 77 entity.createdAt = now
98 78 entity.createdBy = principal
99 79 entity.updatedAt = now
... ...
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/HibernateTenantResolver.kt deleted
1   -package org.vibeerp.platform.persistence.tenancy
2   -
3   -import org.hibernate.context.spi.CurrentTenantIdentifierResolver
4   -import org.springframework.stereotype.Component
5   -
6   -/**
7   - * Hibernate hook that returns the tenant id for the current Hibernate session.
8   - *
9   - * Wired into Hibernate via `spring.jpa.properties.hibernate.multiTenancy=DISCRIMINATOR`
10   - * (set in platform-persistence's auto-config). Hibernate then automatically
11   - * filters every query and every save by tenant — the application code never
12   - * writes `WHERE tenant_id = ?` manually.
13   - *
14   - * This is the FIRST of two walls against cross-tenant data leaks. The
15   - * SECOND wall is Postgres Row-Level Security policies on every business
16   - * table, applied by `RlsTransactionHook` on every transaction. A bug in
17   - * either wall is contained by the other; both walls must be wrong at
18   - * the same time for a leak to occur.
19   - *
20   - * Reference: architecture spec section 8 ("Tenant isolation").
21   - */
22   -@Component
23   -class HibernateTenantResolver : CurrentTenantIdentifierResolver<String> {
24   -
25   - override fun resolveCurrentTenantIdentifier(): String =
26   - TenantContext.currentOrNull()?.value
27   - // We return a sentinel rather than throwing here because
28   - // Hibernate calls this resolver during schema validation at
29   - // startup, before any HTTP request has set a tenant.
30   - ?: "__no_tenant__"
31   -
32   - override fun validateExistingCurrentSessions(): Boolean = true
33   -}
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/TenantContext.kt deleted
1   -package org.vibeerp.platform.persistence.tenancy
2   -
3   -import org.vibeerp.api.v1.core.TenantId
4   -
5   -/**
6   - * Per-thread holder of the current request's tenant.
7   - *
8   - * Set by an HTTP filter on the way in (from JWT claim, header, or
9   - * subdomain), read by Hibernate's tenant resolver and by Postgres RLS
10   - * via a `SET LOCAL` statement at the start of every transaction.
11   - *
12   - * **Why a ThreadLocal and not a request-scoped Spring bean?**
13   - * Hibernate's `CurrentTenantIdentifierResolver` is called from inside
14   - * the Hibernate session, which is not always inside a Spring request
15   - * scope (e.g., during background jobs and event handlers). A ThreadLocal
16   - * is the lowest common denominator that works across all those code paths.
17   - *
18   - * **For background jobs:** the job scheduler explicitly calls
19   - * [runAs] to bind a tenant for the duration of the job, then clears it.
20   - * Never let a job thread inherit a stale tenant from a previous run.
21   - */
22   -object TenantContext {
23   -
24   - private val current = ThreadLocal<TenantId?>()
25   -
26   - fun set(tenantId: TenantId) {
27   - current.set(tenantId)
28   - }
29   -
30   - fun clear() {
31   - current.remove()
32   - }
33   -
34   - fun currentOrNull(): TenantId? = current.get()
35   -
36   - fun current(): TenantId =
37   - currentOrNull() ?: error(
38   - "TenantContext.current() called outside a tenant-bound scope. " +
39   - "Either an HTTP filter forgot to set the tenant, or a background job " +
40   - "is running without explicitly calling TenantContext.runAs(...)."
41   - )
42   -
43   - /**
44   - * Run [block] with [tenantId] bound to the current thread, restoring
45   - * the previous value (which is usually null) afterwards.
46   - */
47   - fun <T> runAs(tenantId: TenantId, block: () -> T): T {
48   - val previous = current.get()
49   - current.set(tenantId)
50   - try {
51   - return block()
52   - } finally {
53   - if (previous == null) current.remove() else current.set(previous)
54   - }
55   - }
56   -}