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,7 +19,7 @@ These rules exist because the whole point of the project is reusability across c
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. 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 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. 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 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. 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 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. 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 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: 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 - **A** = Tier-1 metadata only (custom fields, forms, workflows, rules) — always upgrade-safe 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,7 +55,7 @@ The architecture has been brainstormed, validated against 2026 ERP/EBC SOTA, and
55 - **Backend:** Kotlin on the JVM, Spring Boot, single fat-JAR / single Docker image 55 - **Backend:** Kotlin on the JVM, Spring Boot, single fat-JAR / single Docker image
56 - **Workflow engine:** Embedded Flowable (BPMN 2.0) 56 - **Workflow engine:** Embedded Flowable (BPMN 2.0)
57 - **Persistence:** PostgreSQL (the only mandatory external dependency) 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 - **Custom fields:** JSONB `ext` column on every business table, described by `metadata__custom_field` rows; GIN-indexed 59 - **Custom fields:** JSONB `ext` column on every business table, described by `metadata__custom_field` rows; GIN-indexed
60 - **Plug-in framework:** PF4J + Spring Boot child contexts (classloader isolation per plug-in) 60 - **Plug-in framework:** PF4J + Spring Boot child contexts (classloader isolation per plug-in)
61 - **i18n:** ICU MessageFormat (ICU4J) + Spring `MessageSource` 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,10 +6,12 @@ import java.util.UUID
6 * Strongly-typed identifier wrapping a UUID. 6 * Strongly-typed identifier wrapping a UUID.
7 * 7 *
8 * Using `Id<User>` instead of raw `UUID` everywhere prevents the classic 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 @JvmInline 16 @JvmInline
15 value class Id<T>(val value: UUID) { 17 value class Id<T>(val value: UUID) {
@@ -20,27 +22,3 @@ value class Id&lt;T&gt;(val value: UUID) { @@ -20,27 +22,3 @@ value class Id&lt;T&gt;(val value: UUID) {
20 fun <T> random(): Id<T> = Id(UUID.randomUUID()) 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 package org.vibeerp.api.v1.entity 1 package org.vibeerp.api.v1.entity
2 2
3 import org.vibeerp.api.v1.core.Id 3 import org.vibeerp.api.v1.core.Id
4 -import org.vibeerp.api.v1.core.TenantId  
5 4
6 /** 5 /**
7 * Marker interface for every domain entity that lives in the vibe_erp data model. 6 * Marker interface for every domain entity that lives in the vibe_erp data model.
8 * 7 *
9 * Why this exists at all: 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 * - It is intentionally minimal: no equality, no hashCode, no lifecycle 12 * - It is intentionally minimal: no equality, no hashCode, no lifecycle
18 * callbacks. Those are concerns of `platform.persistence`, not the public 13 * callbacks. Those are concerns of `platform.persistence`, not the public
19 * contract. Adding them later is a breaking change; leaving them out is not. 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 * Plug-ins implement this on their own data classes. They never see the 23 * 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 24 * underlying JPA `@Entity` annotation — that lives in the platform's internal
23 * mapping layer. 25 * mapping layer.
@@ -25,7 +27,6 @@ import org.vibeerp.api.v1.core.TenantId @@ -25,7 +27,6 @@ import org.vibeerp.api.v1.core.TenantId
25 * ``` 27 * ```
26 * data class Plate( 28 * data class Plate(
27 * override val id: Id<Plate>, 29 * override val id: Id<Plate>,
28 - * override val tenantId: TenantId,  
29 * val gauge: Quantity, 30 * val gauge: Quantity,
30 * ) : Entity 31 * ) : Entity
31 * ``` 32 * ```
@@ -39,12 +40,4 @@ interface Entity { @@ -39,12 +40,4 @@ interface Entity {
39 * parameter to themselves. 40 * parameter to themselves.
40 */ 41 */
41 val id: Id<*> 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 package org.vibeerp.api.v1.event 1 package org.vibeerp.api.v1.event
2 2
3 import org.vibeerp.api.v1.core.Id 3 import org.vibeerp.api.v1.core.Id
4 -import org.vibeerp.api.v1.core.TenantId  
5 import java.time.Instant 4 import java.time.Instant
6 5
7 /** 6 /**
@@ -12,21 +11,23 @@ import java.time.Instant @@ -12,21 +11,23 @@ import java.time.Instant
12 * other only through events or through `api.v1.ext.<pbc>` interfaces. If 11 * 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 12 * events were untyped, every consumer would have to know every producer's
14 * class, which is exactly the coupling we are trying to avoid. 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 * Note that this is `interface`, not `sealed interface`: plug-ins must be 24 * Note that this is `interface`, not `sealed interface`: plug-ins must be
21 * free to *extend* the hierarchy with their own concrete event classes. 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 * data class PlateApproved( 29 * data class PlateApproved(
28 * override val eventId: Id<DomainEvent> = Id.random(), 30 * override val eventId: Id<DomainEvent> = Id.random(),
29 - * override val tenantId: TenantId,  
30 * override val occurredAt: Instant = Instant.now(), 31 * override val occurredAt: Instant = Instant.now(),
31 * val plateId: Id<*>, 32 * val plateId: Id<*>,
32 * ) : DomainEvent { 33 * ) : DomainEvent {
@@ -40,9 +41,6 @@ interface DomainEvent { @@ -40,9 +41,6 @@ interface DomainEvent {
40 /** Unique id for this event instance. Used for idempotent consumer logic and outbox dedup. */ 41 /** Unique id for this event instance. Used for idempotent consumer logic and outbox dedup. */
41 val eventId: Id<DomainEvent> 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 /** When the event occurred (UTC). Set by the producer, not by the bus, so replays preserve original timing. */ 44 /** When the event occurred (UTC). Set by the producer, not by the bus, so replays preserve original timing. */
47 val occurredAt: Instant 45 val occurredAt: Instant
48 46
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt
1 package org.vibeerp.api.v1.ext.identity 1 package org.vibeerp.api.v1.ext.identity
2 2
3 import org.vibeerp.api.v1.core.Id 3 import org.vibeerp.api.v1.core.Id
4 -import org.vibeerp.api.v1.core.TenantId  
5 import org.vibeerp.api.v1.security.PrincipalId 4 import org.vibeerp.api.v1.security.PrincipalId
6 5
7 /** 6 /**
@@ -26,9 +25,9 @@ import org.vibeerp.api.v1.security.PrincipalId @@ -26,9 +25,9 @@ import org.vibeerp.api.v1.security.PrincipalId
26 interface IdentityApi { 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 fun findUserByUsername(username: String): UserRef? 32 fun findUserByUsername(username: String): UserRef?
34 33
@@ -50,8 +49,7 @@ interface IdentityApi { @@ -50,8 +49,7 @@ interface IdentityApi {
50 * a deliberate api.v1 change and triggers a security review. 49 * a deliberate api.v1 change and triggers a security review.
51 * 50 *
52 * @property id Stable user id. 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 * @property displayName Human-readable name for UI rendering. Falls back to 53 * @property displayName Human-readable name for UI rendering. Falls back to
56 * [username] when the user has not set one. 54 * [username] when the user has not set one.
57 * @property enabled Whether the account is active. A disabled user can still 55 * @property enabled Whether the account is active. A disabled user can still
@@ -61,7 +59,6 @@ interface IdentityApi { @@ -61,7 +59,6 @@ interface IdentityApi {
61 data class UserRef( 59 data class UserRef(
62 val id: Id<UserRef>, 60 val id: Id<UserRef>,
63 val username: String, 61 val username: String,
64 - val tenantId: TenantId,  
65 val displayName: String, 62 val displayName: String,
66 val enabled: Boolean, 63 val enabled: Boolean,
67 ) 64 )
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/RequestContext.kt
1 package org.vibeerp.api.v1.http 1 package org.vibeerp.api.v1.http
2 2
3 -import org.vibeerp.api.v1.core.TenantId  
4 import org.vibeerp.api.v1.security.Principal 3 import org.vibeerp.api.v1.security.Principal
5 import java.util.Locale 4 import java.util.Locale
6 5
@@ -13,10 +12,15 @@ import java.util.Locale @@ -13,10 +12,15 @@ import java.util.Locale
13 * inside api.v1 would have to bridge classloaders to find the host's 12 * inside api.v1 would have to bridge classloaders to find the host's
14 * state. An injected context is cleaner, testable, and forces the 13 * state. An injected context is cleaner, testable, and forces the
15 * plug-in author to declare a dependency on the values they use. 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 * repository, event bus) consults under the covers. 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 * The host provides one [RequestContext] per HTTP request and per workflow 24 * The host provides one [RequestContext] per HTTP request and per workflow
21 * task execution. Background jobs receive a context whose [principal] is a 25 * task execution. Background jobs receive a context whose [principal] is a
22 * [org.vibeerp.api.v1.security.Principal.System] and whose [requestId] is 26 * [org.vibeerp.api.v1.security.Principal.System] and whose [requestId] is
@@ -27,14 +31,6 @@ interface RequestContext { @@ -27,14 +31,6 @@ interface RequestContext {
27 /** The authenticated caller. Never `null`; anonymous endpoints get a system principal. */ 31 /** The authenticated caller. Never `null`; anonymous endpoints get a system principal. */
28 fun principal(): Principal 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 /** The resolved request locale. Same value [org.vibeerp.api.v1.i18n.LocaleProvider.current] would return. */ 34 /** The resolved request locale. Same value [org.vibeerp.api.v1.i18n.LocaleProvider.current] would return. */
39 fun locale(): Locale 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,20 +4,21 @@ import org.vibeerp.api.v1.core.Id
4 import org.vibeerp.api.v1.entity.Entity 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 * Why this lives in api.v1: 9 * Why this lives in api.v1:
10 * - Plug-ins must be able to persist their own entities without depending on 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 11 * Spring Data, JPA, or any platform internal class. The platform binds this
12 * interface to a Hibernate-backed implementation per entity at startup. 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" 13 * - It is generic on `T : Entity` so the type system rejects "wrong-entity"
19 * mistakes at compile time. 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 * Methods are deliberately few. Anything more sophisticated (joins, 22 * 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 * 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 * domain service or a query object instead. Adding methods later (with default
@@ -26,9 +27,7 @@ import org.vibeerp.api.v1.entity.Entity @@ -26,9 +27,7 @@ import org.vibeerp.api.v1.entity.Entity
26 interface Repository<T : Entity> { 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 fun findById(id: Id<T>): T? 32 fun findById(id: Id<T>): T?
34 33
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Principal.kt
1 package org.vibeerp.api.v1.security 1 package org.vibeerp.api.v1.security
2 2
3 import org.vibeerp.api.v1.core.Id 3 import org.vibeerp.api.v1.core.Id
4 -import org.vibeerp.api.v1.core.TenantId  
5 4
6 /** 5 /**
7 * Type alias for a principal identifier. 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 typealias PrincipalId = Id<Principal> 12 typealias PrincipalId = Id<Principal>
14 13
@@ -28,6 +27,12 @@ typealias PrincipalId = Id&lt;Principal&gt; @@ -28,6 +27,12 @@ typealias PrincipalId = Id&lt;Principal&gt;
28 * plug-in-to-plug-in calls and for bookkeeping rows the plug-in writes 27 * 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). 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 * Sealed because the framework needs to switch on the full set in audit and 36 * 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. 37 * permission code; new principal kinds are deliberate api.v1 changes.
33 */ 38 */
@@ -37,20 +42,11 @@ sealed interface Principal { @@ -37,20 +42,11 @@ sealed interface Principal {
37 val id: PrincipalId 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 * A real human user (or an AI agent acting on a human's behalf, which the 45 * 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). 46 * framework intentionally does not distinguish at the principal level).
50 */ 47 */
51 data class User( 48 data class User(
52 override val id: PrincipalId, 49 override val id: PrincipalId,
53 - override val tenantId: TenantId,  
54 val username: String, 50 val username: String,
55 ) : Principal { 51 ) : Principal {
56 init { 52 init {
@@ -64,7 +60,6 @@ sealed interface Principal { @@ -64,7 +60,6 @@ sealed interface Principal {
64 */ 60 */
65 data class System( 61 data class System(
66 override val id: PrincipalId, 62 override val id: PrincipalId,
67 - override val tenantId: TenantId,  
68 val name: String, 63 val name: String,
69 ) : Principal { 64 ) : Principal {
70 init { 65 init {
@@ -75,7 +70,6 @@ sealed interface Principal { @@ -75,7 +70,6 @@ sealed interface Principal {
75 /** A plug-in acting under its own identity. */ 70 /** A plug-in acting under its own identity. */
76 data class PluginPrincipal( 71 data class PluginPrincipal(
77 override val id: PrincipalId, 72 override val id: PrincipalId,
78 - override val tenantId: TenantId,  
79 val pluginId: String, 73 val pluginId: String,
80 ) : Principal { 74 ) : Principal {
81 init { 75 init {
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt
1 package org.vibeerp.api.v1.workflow 1 package org.vibeerp.api.v1.workflow
2 2
3 -import org.vibeerp.api.v1.core.TenantId  
4 import org.vibeerp.api.v1.security.Principal 3 import org.vibeerp.api.v1.security.Principal
5 import java.util.Locale 4 import java.util.Locale
6 5
@@ -71,17 +70,6 @@ interface TaskContext { @@ -71,17 +70,6 @@ interface TaskContext {
71 fun set(name: String, value: Any?) 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 * The principal whose action triggered this task instance. For 73 * The principal whose action triggered this task instance. For
86 * automatically scheduled tasks (timer-driven, signal-driven), this 74 * automatically scheduled tasks (timer-driven, signal-driven), this
87 * is a system principal — never null. 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,40 +3,46 @@ package org.vibeerp.api.v1.core
3 import assertk.assertFailure 3 import assertk.assertFailure
4 import assertk.assertThat 4 import assertk.assertThat
5 import assertk.assertions.isEqualTo 5 import assertk.assertions.isEqualTo
  6 +import assertk.assertions.isNotEqualTo
6 import org.junit.jupiter.api.Test 7 import org.junit.jupiter.api.Test
  8 +import java.util.UUID
7 9
  10 +/** Phantom-typed [Id] sanity checks. */
8 class IdentifiersTest { 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 @Test 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 @Test 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 @Test 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 @Test 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,10 +37,17 @@ dependencies {
37 testImplementation(libs.spring.boot.starter.test) 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 // The fat-jar produced here is what the Dockerfile copies into the 48 // The fat-jar produced here is what the Dockerfile copies into the
41 // runtime image as /app/vibe-erp.jar. 49 // runtime image as /app/vibe-erp.jar.
42 tasks.bootJar { 50 tasks.bootJar {
43 - mainClass.set("org.vibeerp.platform.bootstrap.VibeErpApplicationKt")  
44 archiveFileName.set("vibe-erp.jar") 51 archiveFileName.set("vibe-erp.jar")
45 } 52 }
46 53
distribution/src/main/resources/application.yaml
@@ -8,6 +8,10 @@ @@ -8,6 +8,10 @@
8 # Customer overrides live in /opt/vibe-erp/config/vibe-erp.yaml on the 8 # Customer overrides live in /opt/vibe-erp/config/vibe-erp.yaml on the
9 # mounted volume. Plug-in configuration lives in metadata__plugin_config, 9 # mounted volume. Plug-in configuration lives in metadata__plugin_config,
10 # never here. 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 spring: 16 spring:
13 application: 17 application:
@@ -22,18 +26,6 @@ spring: @@ -22,18 +26,6 @@ spring:
22 hibernate: 26 hibernate:
23 ddl-auto: validate 27 ddl-auto: validate
24 open-in-view: false 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 liquibase: 29 liquibase:
38 change-log: classpath:db/changelog/master.xml 30 change-log: classpath:db/changelog/master.xml
39 31
@@ -49,8 +41,11 @@ management: @@ -49,8 +41,11 @@ management:
49 41
50 vibeerp: 42 vibeerp:
51 instance: 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 plugins: 49 plugins:
55 directory: ${VIBEERP_PLUGINS_DIR:/opt/vibe-erp/plugins} 50 directory: ${VIBEERP_PLUGINS_DIR:/opt/vibe-erp/plugins}
56 auto-load: true 51 auto-load: true
distribution/src/main/resources/db/changelog/pbc-identity/001-identity-init.xml
@@ -9,23 +9,17 @@ @@ -9,23 +9,17 @@
9 9
10 Owns: identity__user, identity__role, identity__user_role. 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 Conventions enforced for every business table in vibe_erp: 17 Conventions enforced for every business table in vibe_erp:
13 • UUID primary key 18 • UUID primary key
14 - • tenant_id varchar(64) NOT NULL  
15 • Audit columns: created_at, created_by, updated_at, updated_by 19 • Audit columns: created_at, created_by, updated_at, updated_by
16 • Optimistic-locking version column 20 • Optimistic-locking version column
17 • ext jsonb NOT NULL DEFAULT '{}' for key-user custom fields 21 • ext jsonb NOT NULL DEFAULT '{}' for key-user custom fields
18 • GIN index on ext for fast custom-field queries 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 <changeSet id="identity-init-001" author="vibe_erp"> 25 <changeSet id="identity-init-001" author="vibe_erp">
@@ -33,7 +27,6 @@ @@ -33,7 +27,6 @@
33 <sql> 27 <sql>
34 CREATE TABLE identity__user ( 28 CREATE TABLE identity__user (
35 id uuid PRIMARY KEY, 29 id uuid PRIMARY KEY,
36 - tenant_id varchar(64) NOT NULL,  
37 username varchar(128) NOT NULL, 30 username varchar(128) NOT NULL,
38 display_name varchar(256) NOT NULL, 31 display_name varchar(256) NOT NULL,
39 email varchar(320), 32 email varchar(320),
@@ -45,8 +38,8 @@ @@ -45,8 +38,8 @@
45 updated_by varchar(128) NOT NULL, 38 updated_by varchar(128) NOT NULL,
46 version bigint NOT NULL DEFAULT 0 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 CREATE INDEX identity__user_ext_gin 43 CREATE INDEX identity__user_ext_gin
51 ON identity__user USING GIN (ext jsonb_path_ops); 44 ON identity__user USING GIN (ext jsonb_path_ops);
52 </sql> 45 </sql>
@@ -56,24 +49,10 @@ @@ -56,24 +49,10 @@
56 </changeSet> 49 </changeSet>
57 50
58 <changeSet id="identity-init-002" author="vibe_erp"> 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 <comment>Create identity__role table</comment> 52 <comment>Create identity__role table</comment>
73 <sql> 53 <sql>
74 CREATE TABLE identity__role ( 54 CREATE TABLE identity__role (
75 id uuid PRIMARY KEY, 55 id uuid PRIMARY KEY,
76 - tenant_id varchar(64) NOT NULL,  
77 code varchar(64) NOT NULL, 56 code varchar(64) NOT NULL,
78 name varchar(256) NOT NULL, 57 name varchar(256) NOT NULL,
79 description text, 58 description text,
@@ -84,25 +63,21 @@ @@ -84,25 +63,21 @@
84 updated_by varchar(128) NOT NULL, 63 updated_by varchar(128) NOT NULL,
85 version bigint NOT NULL DEFAULT 0 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 CREATE INDEX identity__role_ext_gin 68 CREATE INDEX identity__role_ext_gin
90 ON identity__role USING GIN (ext jsonb_path_ops); 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 </sql> 70 </sql>
95 <rollback> 71 <rollback>
96 DROP TABLE identity__role; 72 DROP TABLE identity__role;
97 </rollback> 73 </rollback>
98 </changeSet> 74 </changeSet>
99 75
100 - <changeSet id="identity-init-004" author="vibe_erp"> 76 + <changeSet id="identity-init-003" author="vibe_erp">
101 <comment>Create identity__user_role join table</comment> 77 <comment>Create identity__user_role join table</comment>
102 <sql> 78 <sql>
103 CREATE TABLE identity__user_role ( 79 CREATE TABLE identity__user_role (
104 id uuid PRIMARY KEY, 80 id uuid PRIMARY KEY,
105 - tenant_id varchar(64) NOT NULL,  
106 user_id uuid NOT NULL REFERENCES identity__user(id) ON DELETE CASCADE, 81 user_id uuid NOT NULL REFERENCES identity__user(id) ON DELETE CASCADE,
107 role_id uuid NOT NULL REFERENCES identity__role(id) ON DELETE CASCADE, 82 role_id uuid NOT NULL REFERENCES identity__role(id) ON DELETE CASCADE,
108 created_at timestamptz NOT NULL, 83 created_at timestamptz NOT NULL,
@@ -112,10 +87,7 @@ @@ -112,10 +87,7 @@
112 version bigint NOT NULL DEFAULT 0 87 version bigint NOT NULL DEFAULT 0
113 ); 88 );
114 CREATE UNIQUE INDEX identity__user_role_uk 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 </sql> 91 </sql>
120 <rollback> 92 <rollback>
121 DROP TABLE identity__user_role; 93 DROP TABLE identity__user_role;
distribution/src/main/resources/db/changelog/platform/000-platform-init.xml
@@ -2,410 +2,219 @@ @@ -2,410 +2,219 @@
2 <!-- 2 <!--
3 vibe_erp — platform init changelog. 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 schema and ships its own changelog. 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 <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" 20 <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
21 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 21 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
22 xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog 22 xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
23 https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> 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 <!-- ─── platform__audit ──────────────────────────────────────────── --> 25 <!-- ─── platform__audit ──────────────────────────────────────────── -->
50 - <changeSet id="platform-init-002" author="vibe_erp"> 26 + <changeSet id="platform-init-001" author="vibe_erp">
51 <createTable tableName="platform__audit"> 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 </createTable> 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 </changeSet> 38 </changeSet>
75 39
76 <!-- ─── metadata__entity ─────────────────────────────────────────── --> 40 <!-- ─── metadata__entity ─────────────────────────────────────────── -->
77 - <changeSet id="platform-init-003" author="vibe_erp"> 41 + <changeSet id="platform-init-002" author="vibe_erp">
78 <createTable tableName="metadata__entity"> 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 </createTable> 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 <column name="source"/> 50 <column name="source"/>
101 </createIndex> 51 </createIndex>
102 - <rollback>  
103 - <dropTable tableName="metadata__entity"/>  
104 - </rollback> 52 + <rollback><dropTable tableName="metadata__entity"/></rollback>
105 </changeSet> 53 </changeSet>
106 54
107 <!-- ─── metadata__custom_field ───────────────────────────────────── --> 55 <!-- ─── metadata__custom_field ───────────────────────────────────── -->
108 - <changeSet id="platform-init-004" author="vibe_erp"> 56 + <changeSet id="platform-init-003" author="vibe_erp">
109 <createTable tableName="metadata__custom_field"> 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 </createTable> 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 <column name="source"/> 65 <column name="source"/>
132 </createIndex> 66 </createIndex>
133 - <rollback>  
134 - <dropTable tableName="metadata__custom_field"/>  
135 - </rollback> 67 + <rollback><dropTable tableName="metadata__custom_field"/></rollback>
136 </changeSet> 68 </changeSet>
137 69
138 <!-- ─── metadata__form ───────────────────────────────────────────── --> 70 <!-- ─── metadata__form ───────────────────────────────────────────── -->
139 - <changeSet id="platform-init-005" author="vibe_erp"> 71 + <changeSet id="platform-init-004" author="vibe_erp">
140 <createTable tableName="metadata__form"> 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 </createTable> 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 <column name="source"/> 95 <column name="source"/>
163 </createIndex> 96 </createIndex>
164 - <rollback>  
165 - <dropTable tableName="metadata__form"/>  
166 - </rollback> 97 + <rollback><dropTable tableName="metadata__list_view"/></rollback>
167 </changeSet> 98 </changeSet>
168 99
169 <!-- ─── metadata__workflow ───────────────────────────────────────── --> 100 <!-- ─── metadata__workflow ───────────────────────────────────────── -->
170 <changeSet id="platform-init-006" author="vibe_erp"> 101 <changeSet id="platform-init-006" author="vibe_erp">
171 <createTable tableName="metadata__workflow"> 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 </createTable> 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 <column name="source"/> 110 <column name="source"/>
194 </createIndex> 111 </createIndex>
195 - <rollback>  
196 - <dropTable tableName="metadata__workflow"/>  
197 - </rollback> 112 + <rollback><dropTable tableName="metadata__workflow"/></rollback>
198 </changeSet> 113 </changeSet>
199 114
200 <!-- ─── metadata__rule ───────────────────────────────────────────── --> 115 <!-- ─── metadata__rule ───────────────────────────────────────────── -->
201 <changeSet id="platform-init-007" author="vibe_erp"> 116 <changeSet id="platform-init-007" author="vibe_erp">
202 <createTable tableName="metadata__rule"> 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 </createTable> 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 <column name="source"/> 125 <column name="source"/>
225 </createIndex> 126 </createIndex>
226 - <rollback>  
227 - <dropTable tableName="metadata__rule"/>  
228 - </rollback> 127 + <rollback><dropTable tableName="metadata__rule"/></rollback>
229 </changeSet> 128 </changeSet>
230 129
231 <!-- ─── metadata__permission ─────────────────────────────────────── --> 130 <!-- ─── metadata__permission ─────────────────────────────────────── -->
232 <changeSet id="platform-init-008" author="vibe_erp"> 131 <changeSet id="platform-init-008" author="vibe_erp">
233 <createTable tableName="metadata__permission"> 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 </createTable> 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 <column name="source"/> 140 <column name="source"/>
253 </createIndex> 141 </createIndex>
254 - <rollback>  
255 - <dropTable tableName="metadata__permission"/>  
256 - </rollback> 142 + <rollback><dropTable tableName="metadata__permission"/></rollback>
257 </changeSet> 143 </changeSet>
258 144
259 <!-- ─── metadata__role_permission ────────────────────────────────── --> 145 <!-- ─── metadata__role_permission ────────────────────────────────── -->
260 <changeSet id="platform-init-009" author="vibe_erp"> 146 <changeSet id="platform-init-009" author="vibe_erp">
261 <createTable tableName="metadata__role_permission"> 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 </createTable> 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 <column name="source"/> 155 <column name="source"/>
281 </createIndex> 156 </createIndex>
282 - <rollback>  
283 - <dropTable tableName="metadata__role_permission"/>  
284 - </rollback> 157 + <rollback><dropTable tableName="metadata__role_permission"/></rollback>
285 </changeSet> 158 </changeSet>
286 159
287 <!-- ─── metadata__menu ───────────────────────────────────────────── --> 160 <!-- ─── metadata__menu ───────────────────────────────────────────── -->
288 <changeSet id="platform-init-010" author="vibe_erp"> 161 <changeSet id="platform-init-010" author="vibe_erp">
289 <createTable tableName="metadata__menu"> 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 </createTable> 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 <column name="source"/> 170 <column name="source"/>
312 </createIndex> 171 </createIndex>
313 - <rollback>  
314 - <dropTable tableName="metadata__menu"/>  
315 - </rollback> 172 + <rollback><dropTable tableName="metadata__menu"/></rollback>
316 </changeSet> 173 </changeSet>
317 174
318 <!-- ─── metadata__report ─────────────────────────────────────────── --> 175 <!-- ─── metadata__report ─────────────────────────────────────────── -->
319 <changeSet id="platform-init-011" author="vibe_erp"> 176 <changeSet id="platform-init-011" author="vibe_erp">
320 <createTable tableName="metadata__report"> 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 </createTable> 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 <column name="source"/> 185 <column name="source"/>
343 </createIndex> 186 </createIndex>
344 - <rollback>  
345 - <dropTable tableName="metadata__report"/>  
346 - </rollback> 187 + <rollback><dropTable tableName="metadata__report"/></rollback>
347 </changeSet> 188 </changeSet>
348 189
349 <!-- ─── metadata__translation ────────────────────────────────────── --> 190 <!-- ─── metadata__translation ────────────────────────────────────── -->
350 <changeSet id="platform-init-012" author="vibe_erp"> 191 <changeSet id="platform-init-012" author="vibe_erp">
351 <createTable tableName="metadata__translation"> 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 </createTable> 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 <column name="source"/> 200 <column name="source"/>
374 </createIndex> 201 </createIndex>
375 - <rollback>  
376 - <dropTable tableName="metadata__translation"/>  
377 - </rollback> 202 + <rollback><dropTable tableName="metadata__translation"/></rollback>
378 </changeSet> 203 </changeSet>
379 204
380 <!-- ─── metadata__plugin_config ──────────────────────────────────── --> 205 <!-- ─── metadata__plugin_config ──────────────────────────────────── -->
381 <changeSet id="platform-init-013" author="vibe_erp"> 206 <changeSet id="platform-init-013" author="vibe_erp">
382 <createTable tableName="metadata__plugin_config"> 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 </createTable> 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 <column name="source"/> 215 <column name="source"/>
405 </createIndex> 216 </createIndex>
406 - <rollback>  
407 - <dropTable tableName="metadata__plugin_config"/>  
408 - </rollback> 217 + <rollback><dropTable tableName="metadata__plugin_config"/></rollback>
409 </changeSet> 218 </changeSet>
410 219
411 </databaseChangeLog> 220 </databaseChangeLog>
local-vibeerp/config/vibe-erp.yaml
@@ -6,8 +6,11 @@ @@ -6,8 +6,11 @@
6 # operators can copy it as a starting point for a real deployment. 6 # operators can copy it as a starting point for a real deployment.
7 7
8 instance: 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 database: 15 database:
13 url: jdbc:postgresql://db:5432/vibeerp 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,7 +32,7 @@ class UserService(
32 32
33 fun create(command: CreateUserCommand): User { 33 fun create(command: CreateUserCommand): User {
34 require(!users.existsByUsername(command.username)) { 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 val user = User( 37 val user = User(
38 username = command.username, 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,7 +2,6 @@ package org.vibeerp.pbc.identity.domain
2 2
3 import jakarta.persistence.Column 3 import jakarta.persistence.Column
4 import jakarta.persistence.Entity 4 import jakarta.persistence.Entity
5 -import jakarta.persistence.Id  
6 import jakarta.persistence.Table 5 import jakarta.persistence.Table
7 import org.hibernate.annotations.JdbcTypeCode 6 import org.hibernate.annotations.JdbcTypeCode
8 import org.hibernate.type.SqlTypes 7 import org.hibernate.type.SqlTypes
@@ -17,9 +16,12 @@ import org.vibeerp.platform.persistence.audit.AuditedJpaEntity @@ -17,9 +16,12 @@ import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
17 * This is the entire point of the modular monolith — PBCs get power, plug-ins 16 * This is the entire point of the modular monolith — PBCs get power, plug-ins
18 * get safety. 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 * Custom fields: the `ext` JSONB column is the metadata-driven custom-field 26 * Custom fields: the `ext` JSONB column is the metadata-driven custom-field
25 * store (architecture spec section 8). A key user can add fields to this 27 * store (architecture spec section 8). A key user can add fields to this
@@ -58,5 +60,5 @@ class User( @@ -58,5 +60,5 @@ class User(
58 var ext: String = "{}" 60 var ext: String = "{}"
59 61
60 override fun toString(): String = 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,7 +3,6 @@ package org.vibeerp.pbc.identity.ext
3 import org.springframework.stereotype.Component 3 import org.springframework.stereotype.Component
4 import org.springframework.transaction.annotation.Transactional 4 import org.springframework.transaction.annotation.Transactional
5 import org.vibeerp.api.v1.core.Id 5 import org.vibeerp.api.v1.core.Id
6 -import org.vibeerp.api.v1.core.TenantId  
7 import org.vibeerp.api.v1.ext.identity.IdentityApi 6 import org.vibeerp.api.v1.ext.identity.IdentityApi
8 import org.vibeerp.api.v1.ext.identity.UserRef 7 import org.vibeerp.api.v1.ext.identity.UserRef
9 import org.vibeerp.api.v1.security.PrincipalId 8 import org.vibeerp.api.v1.security.PrincipalId
@@ -21,7 +20,6 @@ import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository @@ -21,7 +20,6 @@ import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
21 * 20 *
22 * **What this adapter MUST NOT do:** 21 * **What this adapter MUST NOT do:**
23 * • leak `org.vibeerp.pbc.identity.domain.User` to callers 22 * • leak `org.vibeerp.pbc.identity.domain.User` to callers
24 - * • bypass tenant filtering (caller's tenant context is honored automatically)  
25 * • return PII fields that aren't already on `UserRef` 23 * • return PII fields that aren't already on `UserRef`
26 * 24 *
27 * If a caller needs more fields, the answer is to grow `UserRef` deliberately 25 * If a caller needs more fields, the answer is to grow `UserRef` deliberately
@@ -42,7 +40,6 @@ class IdentityApiAdapter( @@ -42,7 +40,6 @@ class IdentityApiAdapter(
42 private fun User.toRef(): UserRef = UserRef( 40 private fun User.toRef(): UserRef = UserRef(
43 id = Id<UserRef>(this.id), 41 id = Id<UserRef>(this.id),
44 username = this.username, 42 username = this.username,
45 - tenantId = TenantId(this.tenantId),  
46 displayName = this.displayName, 43 displayName = this.displayName,
47 enabled = this.enabled, 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,12 +29,13 @@ import java.util.UUID
29 * PBC, and so plug-in endpoints (which mount under `/api/v1/plugins/...`) 29 * PBC, and so plug-in endpoints (which mount under `/api/v1/plugins/...`)
30 * cannot collide with core endpoints. 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 * Authentication is deferred to v0.2 (pbc-identity's auth flow). For v0.1 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 @RestController 40 @RestController
40 @RequestMapping("/api/v1/identity/users") 41 @RequestMapping("/api/v1/identity/users")
@@ -102,7 +103,6 @@ data class UpdateUserRequest( @@ -102,7 +103,6 @@ data class UpdateUserRequest(
102 103
103 data class UserResponse( 104 data class UserResponse(
104 val id: UUID, 105 val id: UUID,
105 - val tenantId: String,  
106 val username: String, 106 val username: String,
107 val displayName: String, 107 val displayName: String,
108 val email: String?, 108 val email: String?,
@@ -111,7 +111,6 @@ data class UserResponse( @@ -111,7 +111,6 @@ data class UserResponse(
111 111
112 private fun User.toResponse() = UserResponse( 112 private fun User.toResponse() = UserResponse(
113 id = this.id, 113 id = this.id,
114 - tenantId = this.tenantId,  
115 username = this.username, 114 username = this.username,
116 displayName = this.displayName, 115 displayName = this.displayName,
117 email = this.email, 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,14 +8,11 @@ import java.util.UUID
8 /** 8 /**
9 * Spring Data JPA repository for [User]. 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 @Repository 17 @Repository
21 interface UserJpaRepository : JpaRepository<User, UUID> { 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,7 +53,7 @@ class UserServiceTest {
53 ) 53 )
54 } 54 }
55 .isInstanceOf(IllegalArgumentException::class) 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 @Test 59 @Test
platform/platform-bootstrap/build.gradle.kts
@@ -21,13 +21,13 @@ kotlin { @@ -21,13 +21,13 @@ kotlin {
21 21
22 dependencies { 22 dependencies {
23 api(project(":api:api-v1")) 23 api(project(":api:api-v1"))
24 - api(project(":platform:platform-persistence")) // for TenantContext used by the HTTP filter  
25 implementation(libs.kotlin.stdlib) 24 implementation(libs.kotlin.stdlib)
26 implementation(libs.kotlin.reflect) 25 implementation(libs.kotlin.reflect)
27 implementation(libs.jackson.module.kotlin) 26 implementation(libs.jackson.module.kotlin)
28 27
29 implementation(libs.spring.boot.starter) 28 implementation(libs.spring.boot.starter)
30 implementation(libs.spring.boot.starter.web) 29 implementation(libs.spring.boot.starter.web)
  30 + implementation(libs.spring.boot.starter.data.jpa) // for @EnableJpaRepositories on VibeErpApplication
31 implementation(libs.spring.boot.starter.validation) 31 implementation(libs.spring.boot.starter.validation)
32 implementation(libs.spring.boot.starter.actuator) 32 implementation(libs.spring.boot.starter.actuator)
33 33
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt
1 package org.vibeerp.platform.bootstrap 1 package org.vibeerp.platform.bootstrap
2 2
3 import org.springframework.boot.autoconfigure.SpringBootApplication 3 import org.springframework.boot.autoconfigure.SpringBootApplication
  4 +import org.springframework.boot.autoconfigure.domain.EntityScan
4 import org.springframework.boot.context.properties.ConfigurationPropertiesScan 5 import org.springframework.boot.context.properties.ConfigurationPropertiesScan
5 import org.springframework.boot.runApplication 6 import org.springframework.boot.runApplication
6 import org.springframework.context.annotation.ComponentScan 7 import org.springframework.context.annotation.ComponentScan
  8 +import org.springframework.data.jpa.repository.config.EnableJpaRepositories
7 9
8 /** 10 /**
9 * Entry point for the vibe_erp framework. 11 * Entry point for the vibe_erp framework.
@@ -13,8 +15,13 @@ import org.springframework.context.annotation.ComponentScan @@ -13,8 +15,13 @@ import org.springframework.context.annotation.ComponentScan
13 * • all core PBCs that have been registered as Gradle dependencies of :distribution 15 * • all core PBCs that have been registered as Gradle dependencies of :distribution
14 * • the plug-in loader, which scans an external `./plugins/` directory 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 * **What does NOT live in this process:** 26 * **What does NOT live in this process:**
20 * • the React web client (separate build, served by reverse proxy) 27 * • the React web client (separate build, served by reverse proxy)
@@ -24,6 +31,8 @@ import org.springframework.context.annotation.ComponentScan @@ -24,6 +31,8 @@ import org.springframework.context.annotation.ComponentScan
24 @SpringBootApplication 31 @SpringBootApplication
25 @ComponentScan(basePackages = ["org.vibeerp"]) 32 @ComponentScan(basePackages = ["org.vibeerp"])
26 @ConfigurationPropertiesScan(basePackages = ["org.vibeerp"]) 33 @ConfigurationPropertiesScan(basePackages = ["org.vibeerp"])
  34 +@EnableJpaRepositories(basePackages = ["org.vibeerp"])
  35 +@EntityScan(basePackages = ["org.vibeerp"])
27 class VibeErpApplication 36 class VibeErpApplication
28 37
29 fun main(args: Array<String>) { 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,7 +9,7 @@ import java.util.Locale
9 * 9 *
10 * The set of keys here is **closed and documented**: plug-ins do NOT extend 10 * The set of keys here is **closed and documented**: plug-ins do NOT extend
11 * this tree. Per-plug-in configuration lives in the `metadata__plugin_config` 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 * Reference: architecture spec section 10 ("The single config file"). 14 * Reference: architecture spec section 10 ("The single config file").
15 */ 15 */
@@ -22,10 +22,15 @@ data class VibeErpProperties( @@ -22,10 +22,15 @@ data class VibeErpProperties(
22 ) 22 )
23 23
24 data class InstanceProperties( 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 /** External base URL of this instance, used for absolute links and OIDC. Optional. */ 34 /** External base URL of this instance, used for absolute links and OIDC. Optional. */
30 val baseUrl: String? = null, 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,12 +2,11 @@ package org.vibeerp.platform.persistence.audit
2 2
3 import jakarta.persistence.Column 3 import jakarta.persistence.Column
4 import jakarta.persistence.EntityListeners 4 import jakarta.persistence.EntityListeners
  5 +import jakarta.persistence.Id
5 import jakarta.persistence.MappedSuperclass 6 import jakarta.persistence.MappedSuperclass
6 import jakarta.persistence.PrePersist 7 import jakarta.persistence.PrePersist
7 import jakarta.persistence.PreUpdate 8 import jakarta.persistence.PreUpdate
8 import jakarta.persistence.Version 9 import jakarta.persistence.Version
9 -import org.hibernate.annotations.TenantId  
10 -import org.vibeerp.platform.persistence.tenancy.TenantContext  
11 import java.time.Instant 10 import java.time.Instant
12 import java.util.UUID 11 import java.util.UUID
13 12
@@ -16,10 +15,16 @@ import java.util.UUID @@ -16,10 +15,16 @@ import java.util.UUID
16 * 15 *
17 * Provides, for free, on every business table: 16 * Provides, for free, on every business table:
18 * • `id` (UUID primary key) 17 * • `id` (UUID primary key)
19 - * • `tenant_id` (multi-tenant discriminator, NOT NULL, never written manually)  
20 * • `created_at`, `created_by`, `updated_at`, `updated_by` (audit columns) 18 * • `created_at`, `created_by`, `updated_at`, `updated_by` (audit columns)
21 * • `version` (optimistic-locking column for safe concurrent edits) 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 * **What this is NOT:** 28 * **What this is NOT:**
24 * • This is INTERNAL to the platform layer. PBC code that lives in 29 * • This is INTERNAL to the platform layer. PBC code that lives in
25 * `pbc-*` extends this directly, but plug-ins do NOT — plug-ins extend 30 * `pbc-*` extends this directly, but plug-ins do NOT — plug-ins extend
@@ -34,21 +39,10 @@ import java.util.UUID @@ -34,21 +39,10 @@ import java.util.UUID
34 @EntityListeners(AuditedJpaEntityListener::class) 39 @EntityListeners(AuditedJpaEntityListener::class)
35 abstract class AuditedJpaEntity { 40 abstract class AuditedJpaEntity {
36 41
  42 + @Id
37 @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid") 43 @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid")
38 open var id: UUID = UUID.randomUUID() 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 @Column(name = "created_at", updatable = false, nullable = false) 46 @Column(name = "created_at", updatable = false, nullable = false)
53 open var createdAt: Instant = Instant.EPOCH 47 open var createdAt: Instant = Instant.EPOCH
54 48
@@ -67,11 +61,11 @@ abstract class AuditedJpaEntity { @@ -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 class AuditedJpaEntityListener { 70 class AuditedJpaEntityListener {
77 71
@@ -79,21 +73,7 @@ class AuditedJpaEntityListener { @@ -79,21 +73,7 @@ class AuditedJpaEntityListener {
79 fun onCreate(entity: Any) { 73 fun onCreate(entity: Any) {
80 if (entity is AuditedJpaEntity) { 74 if (entity is AuditedJpaEntity) {
81 val now = Instant.now() 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 val principal = currentPrincipal() 76 val principal = currentPrincipal()
96 - entity.tenantId = tenant.value  
97 entity.createdAt = now 77 entity.createdAt = now
98 entity.createdBy = principal 78 entity.createdBy = principal
99 entity.updatedAt = now 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 -}