-
Adds the foundation for the entire Tier 1 customization story. Core PBCs and plug-ins now ship YAML files declaring their entities, permissions, and menus; a `MetadataLoader` walks the host classpath and each plug-in JAR at boot, upserts the rows tagged with their source, and exposes them at a public REST endpoint so the future SPA, AI-agent function catalog, OpenAPI generator, and external introspection tooling can all see what the framework offers without scraping code. What landed: * New `platform/platform-metadata/` Gradle subproject. Depends on api-v1 + platform-persistence + jackson-yaml + spring-jdbc. * `MetadataYamlFile` DTOs (entities, permissions, menus). Forward- compatible: unknown top-level keys are ignored, so a future plug-in built against a newer schema (forms, workflows, rules, translations) loads cleanly on an older host that doesn't know those sections yet. * `MetadataLoader` with two entry points: loadCore() — uses Spring's PathMatchingResourcePatternResolver against the host classloader. Finds every classpath*:META-INF/ vibe-erp/metadata/*.yml across all jars contributing to the application. Tagged source='core'. loadFromPluginJar(pluginId, jarPath) — opens ONE specific plug-in JAR via java.util.jar.JarFile and walks its entries directly. This is critical: a plug-in's PluginClassLoader is parent-first, so a classpath*: scan against it would ALSO pick up the host's metadata files via parent classpath. We saw this in the first smoke run — the plug-in source ended up with 6 entities (the plug-in's 2 + the host's 4) before the fix. Walking the JAR file directly guarantees only the plug-in's own files load. Tagged source='plugin:<id>'. Both entry points use the same delete-then-insert idempotent core (doLoad). Loading the same source twice produces the same final state. User-edited metadata (source='user') is NEVER touched by either path — it survives boot, plug-in install, and plug-in upgrade. This is what lets a future SPA "Customize" UI add custom fields without fearing they'll be wiped on the next deploy. * `VibeErpPluginManager.afterPropertiesSet()` now calls metadataLoader.loadCore() at the very start, then walks plug-ins and calls loadFromPluginJar(...) for each one between Liquibase migration and start(context). Order is guaranteed: core → linter → migrate → metadata → start. The CommandLineRunner I originally put `loadCore()` in turned out to be wrong because Spring runs CommandLineRunners AFTER InitializingBean.afterPropertiesSet(), so the plug-in metadata was loading BEFORE core — the wrong way around. Calling loadCore() inline in the plug-in manager fixes the ordering without any @Order(...) gymnastics. * `MetadataController` exposes: GET /api/v1/_meta/metadata — all three sections GET /api/v1/_meta/metadata/entities — entities only GET /api/v1/_meta/metadata/permissions GET /api/v1/_meta/metadata/menus Public allowlist (covered by the existing /api/v1/_meta/** rule in SecurityConfiguration). The metadata is intentionally non- sensitive — entity names, permission keys, menu paths. Nothing in here is PII or secret; the SPA needs to read it before the user has logged in. * YAML files shipped: - pbc-identity/META-INF/vibe-erp/metadata/identity.yml (User + Role entities, 6 permissions, Users + Roles menus) - pbc-catalog/META-INF/vibe-erp/metadata/catalog.yml (Item + Uom entities, 7 permissions, Items + UoMs menus) - reference plug-in/META-INF/vibe-erp/metadata/printing-shop.yml (Plate + InkRecipe entities, 5 permissions, Plates + Inks menus in a "Printing shop" section) Tests: 4 MetadataLoaderTest cases (loadFromPluginJar happy paths, mixed sections, blank pluginId rejection, missing-file no-op wipe) + 7 MetadataYamlParseTest cases (DTO mapping, optional fields, section defaults, forward-compat unknown keys). Total now **92 unit tests** across 11 modules, all green. End-to-end smoke test against fresh Postgres + plug-in loaded: Boot logs: MetadataLoader: source='core' loaded 4 entities, 13 permissions, 4 menus from 2 file(s) MetadataLoader: source='plugin:printing-shop' loaded 2 entities, 5 permissions, 2 menus from 1 file(s) HTTP smoke (everything green): GET /api/v1/_meta/metadata (no auth) → 200 6 entities, 18 permissions, 6 menus entity names: User, Role, Item, Uom, Plate, InkRecipe menu sections: Catalog, Printing shop, System GET /api/v1/_meta/metadata/entities → 200 GET /api/v1/_meta/metadata/menus → 200 Direct DB verification: metadata__entity: core=4, plugin:printing-shop=2 metadata__permission: core=13, plugin:printing-shop=5 metadata__menu: core=4, plugin:printing-shop=2 Idempotency: restart the app, identical row counts. Existing endpoints regression: GET /api/v1/identity/users (Bearer) → 1 user GET /api/v1/catalog/uoms (Bearer) → 15 UoMs GET /api/v1/plugins/printing-shop/ping (Bearer) → 200 Bugs caught and fixed during the smoke test: • The first attempt loaded core metadata via a CommandLineRunner annotated @Order(HIGHEST_PRECEDENCE) and per-plug-in metadata inline in VibeErpPluginManager.afterPropertiesSet(). Spring runs all InitializingBeans BEFORE any CommandLineRunner, so the plug-in metadata loaded first and the core load came second — wrong order. Fix: drop CoreMetadataInitializer entirely; have the plug-in manager call metadataLoader.loadCore() directly at the start of afterPropertiesSet(). • The first attempt's plug-in load used metadataLoader.load(pluginClassLoader, ...) which used Spring's PathMatchingResourcePatternResolver against the plug-in's classloader. PluginClassLoader is parent-first, so the resolver enumerated BOTH the plug-in's own JAR AND the host classpath's metadata files, tagging core entities as source='plugin:<id>' and corrupting the seed counts. Fix: refactor MetadataLoader to expose loadFromPluginJar(pluginId, jarPath) which opens the plug-in JAR directly via java.util.jar.JarFile and walks its entries — never asking the classloader at all. The api-v1 surface didn't change. • Two KDoc comments contained the literal string `*.yml` after a `/` character (`/metadata/*.yml`), forming the `/*` pattern that Kotlin's lexer treats as a nested-comment opener. The file failed to compile with "Unclosed comment". This is the third time I've hit this trap; rewriting both KDocs to avoid the literal `/*` sequence. • The MetadataLoaderTest's hand-rolled JAR builder didn't include explicit directory entries for parent paths. Real Gradle JARs do include them, and Spring's PathMatchingResourcePatternResolver needs them to enumerate via classpath*:. Fixed the test helper to write directory entries for every parent of each file. Implementation plan refreshed: P1.5 marked DONE. Next priority candidates: P5.2 (pbc-partners — third PBC clone) and P3.4 (custom field application via the ext jsonb column, which would unlock the full Tier 1 customization story). Framework state: 17→18 commits, 10→11 modules, 81→92 unit tests, metadata seeded for 6 entities + 18 permissions + 6 menus. -
Adds the framework's event bus, the second cross-cutting service (after auth) that PBCs and plug-ins both consume. Implements the transactional outbox pattern from the architecture spec section 9 — events are written to the database in the same transaction as the publisher's domain change, so a publish followed by a rollback never escapes. This is the seam where a future Kafka/NATS bridge plugs in WITHOUT touching any PBC code. What landed: * New `platform/platform-events/` module: - `EventOutboxEntry` JPA entity backed by `platform__event_outbox` (id, event_id, topic, aggregate_type, aggregate_id, payload jsonb, status, attempts, last_error, occurred_at, dispatched_at, version). Status enum: PENDING / DISPATCHED / FAILED. - `EventOutboxRepository` Spring Data JPA repo with a pessimistic SELECT FOR UPDATE query for poller dispatch. - `ListenerRegistry` — in-memory subscription holder, indexed both by event class (Class.isInstance) and by topic string. Supports a `**` wildcard for the platform's audit subscriber. Backed by CopyOnWriteArrayList so dispatch is lock-free. - `EventBusImpl` — implements the api.v1 EventBus. publish() writes the outbox row AND synchronously delivers to in-process listeners in the SAME transaction. Marked Propagation.MANDATORY so the bus refuses to publish outside an existing transaction (preventing publish-and-rollback leaks). Listener exceptions are caught and logged; the outbox row still commits. - `OutboxPoller` — Spring @Scheduled component that runs every 5s, drains PENDING / FAILED rows under a pessimistic lock, marks them DISPATCHED. v0.5 has no real external dispatcher — the poller is the seam where Kafka/NATS plugs in later. - `EventBusConfiguration` — @EnableScheduling so the poller actually runs. Lives in this module so the seam activates automatically when platform-events is on the classpath. - `EventAuditLogSubscriber` — wildcard subscriber that logs every event at INFO. Demo proof that the bus works end-to-end. Future versions replace it with a real audit log writer. * `platform__event_outbox` Liquibase changeset (platform-events-001): table + unique index on event_id + index on (status, created_at) + index on topic. * DefaultPluginContext.eventBus is no longer a stub that throws — it's now the real EventBus injected by VibeErpPluginManager. Plug-ins can publish and subscribe via the api.v1 surface. Note: subscriptions are NOT auto-scoped to the plug-in lifecycle in v0.5; a plug-in that wants its subscriptions removed on stop() must call subscription.close() explicitly. Auto-scoping lands when per-plug-in Spring child contexts ship. * pbc-identity now publishes `UserCreatedEvent` after a successful UserService.create(). The event class is internal to pbc-identity (not in api.v1) — other PBCs subscribe by topic string (`identity.user.created`), not by class. This is the right tradeoff: string topics are stable across plug-in classloaders, class equality is not, and adding every event class to api.v1 would be perpetual surface-area bloat. Tests: 13 new unit tests (9 EventBusImplTest + 4 OutboxPollerTest) plus 2 new UserServiceTest cases that verify the publish happens on the happy path and does NOT happen when create() rejects a duplicate. Total now 76 unit tests across the framework, all green. End-to-end smoke test against fresh Postgres with the plug-in loaded (everything green): EventAuditLogSubscriber subscribed to ** at boot Outbox empty before any user create ✓ POST /api/v1/auth/login → 200 POST /api/v1/identity/users (create alice) → 201 Outbox row appears with topic=identity.user.created, status=PENDING immediately after create ✓ EventAuditLogSubscriber log line fires synchronously inside the create transaction ✓ POST /api/v1/identity/users (create bob) → 201 Wait 8s (one OutboxPoller cycle) Both outbox rows now DISPATCHED, dispatched_at set ✓ Existing PBCs still work: GET /api/v1/identity/users → 3 users ✓ GET /api/v1/catalog/uoms → 15 UoMs ✓ Plug-in still works: GET /api/v1/plugins/printing-shop/ping → 200 ✓ The most important assertion is the synchronous audit log line appearing on the same thread as the user creation request. That proves the entire chain — UserService.create() → eventBus.publish() → EventBusImpl writes outbox row → ListenerRegistry.deliver() finds wildcard subscriber → EventAuditLogSubscriber.handle() logs — runs end-to-end inside the publisher's transaction. The poller flipping PENDING → DISPATCHED 5s later proves the outbox + poller seam works without any external dispatcher. Bug encountered and fixed during the smoke test: • EventBusImplTest used `ObjectMapper().registerKotlinModule()` which doesn't pick up jackson-datatype-jsr310. Production code uses Spring Boot's auto-configured ObjectMapper which already has jsr310 because spring-boot-starter-web is on the classpath of distribution. The test setup was the only place using a bare mapper. Fixed by switching to `findAndRegisterModules()` AND by adding jackson-datatype-jsr310 as an explicit implementation dependency of platform-events (so future modules that depend on the bus without bringing web in still get Instant serialization). What is explicitly NOT in this chunk: • External dispatcher (Kafka/NATS bridge) — the poller is a no-op that just marks rows DISPATCHED. The seam exists; the dispatcher is a future P1.7.b unit. • Exponential backoff on FAILED rows — every cycle re-attempts. Real backoff lands when there's a real dispatcher to fail. • Dead-letter queue — same. • Per-plug-in subscription auto-scoping — plug-ins must close() explicitly today. • Async / fire-and-forget publish — synchronous in-process only. -
The reference printing-shop plug-in now actually does something: its main class registers two HTTP endpoints during start(context), and a real curl to /api/v1/plugins/printing-shop/ping returns the JSON the plug-in's lambda produced. End-to-end smoke test 10/10 green. This is the chunk that turns vibe_erp from "an ERP app that has a plug-in folder" into "an ERP framework whose plug-ins can serve traffic". What landed: * api.v1 — additive (binary-compatible per the api.v1 stability rule): - org.vibeerp.api.v1.plugin.HttpMethod (enum) - org.vibeerp.api.v1.plugin.PluginRequest (path params, query, body) - org.vibeerp.api.v1.plugin.PluginResponse (status + body) - org.vibeerp.api.v1.plugin.PluginEndpointHandler (fun interface) - org.vibeerp.api.v1.plugin.PluginEndpointRegistrar (per-plugin scoped, register(method, path, handler)) - PluginContext.endpoints getter with default impl that throws UnsupportedOperationException so the addition is binary-compatible with plug-ins compiled against earlier api.v1 builds. * platform-plugins — three new files: - PluginEndpointRegistry: process-wide registration storage. Uses Spring's AntPathMatcher so {var} extracts path variables. Synchronized mutation. Exact-match fast path before pattern loop. Rejects duplicate (method, path) per plug-in. unregisterAll(plugin) on shutdown. - ScopedPluginEndpointRegistrar: per-plugin wrapper that tags every register() call with the right plugin id. Plug-ins cannot register under another plug-in's namespace. - PluginEndpointDispatcher: single Spring @RestController at /api/v1/plugins/{pluginId}/** that catches GET/POST/PUT/PATCH/DELETE, asks the registry for a match, builds a PluginRequest, calls the handler, serializes the response. 404 on no match, 500 on handler throw (logged with stack trace). - DefaultPluginContext: implements PluginContext with a real SLF4J-backed logger (every line tagged with the plug-in id) and the scoped endpoint registrar. The other six services (eventBus, transaction, translator, localeProvider, permissionCheck, entityRegistry) throw UnsupportedOperationException with messages pointing at the implementation plan unit that will land each one. Loud failure beats silent no-op. * VibeErpPluginManager — after PF4J's startPlugins() now walks every loaded plug-in, casts the wrapper instance to api.v1.plugin.Plugin, and calls start(context) with a freshly-built DefaultPluginContext. Tracks the started set so destroy() can call stop() and unregisterAll() in reverse order. Catches plug-in start failures loudly without bringing the framework down. * Reference plug-in (PrintingShopPlugin): - Now extends BOTH org.pf4j.Plugin (so PF4J's loader can instantiate it via the Plugin-Class manifest entry) AND org.vibeerp.api.v1.plugin.Plugin (so the host's vibe_erp lifecycle hook can call start(context)). Uses Kotlin import aliases to disambiguate the two `Plugin` simple names. - In start(context), registers two endpoints: GET /ping — returns {plugin, version, ok, message} GET /echo/{name} — extracts path variable, echoes it back - The /echo handler proves path-variable extraction works end-to-end. * Build infrastructure: - reference-customer/plugin-printing-shop now has an `installToDev` Gradle task that builds the JAR and stages it into <repo>/plugins-dev/. The task wipes any previous staged copies first so renaming the JAR on a version bump doesn't leave PF4J trying to load two versions. - distribution's `bootRun` task now (a) depends on `installToDev` so the staging happens automatically and (b) sets workingDir to the repo root so application-dev.yaml's relative `vibeerp.plugins.directory: ./plugins-dev` resolves to the right place. Without (b) bootRun's CWD was distribution/ and PF4J found "No plugins" — which is exactly the bug that surfaced in the first smoke run. - .gitignore now excludes /plugins-dev/ and /files-dev/. Tests: 12 new unit tests for PluginEndpointRegistry covering literal paths, single/multi path variables, duplicate registration rejection, literal-vs-pattern precedence, cross-plug-in isolation, method matching, and unregisterAll. Total now 61 unit tests across the framework, all green. End-to-end smoke test against fresh Postgres + the plug-in JAR loaded by PF4J at boot (10/10 passing): GET /api/v1/plugins/printing-shop/ping (no auth) → 401 POST /api/v1/auth/login → access token GET /api/v1/plugins/printing-shop/ping (Bearer) → 200 {plugin, version, ok, message} GET /api/v1/plugins/printing-shop/echo/hello → 200, echoed=hello GET /api/v1/plugins/printing-shop/echo/world → 200, echoed=world GET /api/v1/plugins/printing-shop/nonexistent → 404 (no handler) GET /api/v1/plugins/missing-plugin/ping → 404 (no plugin) POST /api/v1/plugins/printing-shop/ping → 404 (wrong method) GET /api/v1/catalog/uoms (Bearer) → 200, 15 UoMs GET /api/v1/identity/users (Bearer) → 200, 1 user PF4J resolved the JAR, started the plug-in, the host called vibe_erp's start(context), the plug-in registered two endpoints, and the dispatcher routed real HTTP traffic to the plug-in's lambdas. The boot log shows the full chain. What is explicitly NOT in this chunk and remains for later: • plug-in linter (P1.2) — bytecode scan for forbidden imports • plug-in Liquibase application (P1.4) — plug-in-owned schemas • per-plug-in Spring child context — currently we just instantiate the plug-in via PF4J's classloader; there is no Spring context for the plug-in's own beans • PluginContext.eventBus / transaction / translator / etc. — they still throw UnsupportedOperationException with TODO messages • Path-template precedence between multiple competing patterns (only literal-beats-pattern is implemented, not most-specific-pattern) • Permission checks at the dispatcher (Spring Security still catches plug-in endpoints with the global "anyRequest authenticated" rule, which is the right v0.5 behavior) • Hot reload of plug-ins (cold restart only) Bug encountered and fixed during the smoke test: • application-dev.yaml has `vibeerp.plugins.directory: ./plugins-dev`, a relative path. Gradle's `bootRun` task by default uses the subproject's directory as the working directory, so the relative path resolved to <repo>/distribution/plugins-dev/ instead of <repo>/plugins-dev/. PF4J reported "No plugins" because that directory was empty. Fixed by setting bootRun.workingDir = rootProject.layout.projectDirectory.asFile. • One KDoc comment in PluginEndpointDispatcher contained the literal string `/api/v1/plugins/{pluginId}/**` inside backticks. The Kotlin lexer doesn't treat backticks as comment-suppressing, so `/**` opened a nested KDoc comment that was never closed and the file failed to compile. Same root cause as the AuthController bug earlier in the session. Rewrote the line to avoid the literal `/**` sequence. -
Adds the second core PBC, validating that the pbc-identity template is actually clonable and that the Gradle dependency rule fires correctly for a real second PBC. What landed: * New `pbc/pbc-catalog/` Gradle subproject. Same shape as pbc-identity: api-v1 + platform-persistence + platform-security only (no platform-bootstrap, no other pbc). The architecture rule in the root build.gradle.kts now has two real PBCs to enforce against. * `Uom` entity (catalog__uom) — code, name, dimension, ext jsonb. Code is the natural key (stable, human-readable). UomService rejects duplicate codes and refuses to update the code itself (would invalidate every Item FK referencing it). UomController at /api/v1/catalog/uoms exposes list, get-by-id, get-by-code, create, update. * `Item` entity (catalog__item) — code, name, description, item_type (GOOD/SERVICE/DIGITAL enum), base_uom_code FK, active flag, ext jsonb. ItemService validates the referenced UoM exists at the application layer (better error message than the DB FK alone), refuses to update code or baseUomCode (data-migration operations, not edits), supports soft delete via deactivate. ItemController at /api/v1/catalog/items with full CRUD. * `org.vibeerp.api.v1.ext.catalog.CatalogApi` — second cross-PBC facade in api.v1 (after IdentityApi). Exposes findItemByCode(code) and findUomByCode(code) returning safe ItemRef/UomRef DTOs. Inactive items are filtered to null at the boundary so callers cannot accidentally reference deactivated catalog rows. * `CatalogApiAdapter` in pbc-catalog — concrete @Component implementing CatalogApi. Maps internal entities to api.v1 DTOs without leaking storage types. * Liquibase changeset (catalog-init-001..003) creates both tables with unique indexes on code, GIN indexes on ext, and seeds 15 canonical units of measure: kg/g/t (mass), m/cm/mm/km (length), m2 (area), l/ml (volume), ea/sheet/pack (count), h/min (time). Tagged created_by='__seed__' so a future metadata uninstall sweep can identify them. Tests: 11 new unit tests (UomServiceTest x5, ItemServiceTest x6), total now 49 unit tests across the framework, all green. End-to-end smoke test against fresh Postgres via docker-compose (14/14 passing): GET /api/v1/catalog/items (no auth) → 401 POST /api/v1/auth/login → access token GET /api/v1/catalog/uoms (Bearer) → 15 seeded UoMs GET /api/v1/catalog/uoms/by-code/kg → 200 POST custom UoM 'roll' → 201 POST duplicate UoM 'kg' → 400 + clear message GET items → [] POST item with unknown UoM → 400 + clear message POST item with valid UoM → 201 catalog__item.created_by → admin user UUID (NOT __system__) GET /by-code/INK-CMYK-CYAN → 200 PATCH item name + description → 200 DELETE item → 204 GET item → active=false The principal-context bridge from P4.1 keeps working without any additional wiring in pbc-catalog: every PBC inherits the audit behavior for free by extending AuditedJpaEntity. That is exactly the "PBCs follow a recipe, the framework provides the cross-cutting machinery" promise from the architecture spec. Architectural rule enforcement still active: confirmed by reading the build.gradle.kts and observing that pbc-catalog declares no :platform:platform-bootstrap and no :pbc:pbc-identity dependency. The build refuses to load on either violation.
-
Implements the auth unit from the implementation plan. Until now, the framework let any caller hit any endpoint; with the single-tenant refactor there is no second wall, so auth was the most pressing gap. What landed: * New `platform-security` module owns the framework's security primitives (JWT issuer/verifier, password encoder, Spring Security filter chain config, AuthenticationFailedException). Lives between platform-persistence and platform-bootstrap. * `JwtIssuer` mints HS256-signed access (15min) and refresh (7d) tokens via NimbusJwtEncoder. `JwtVerifier` decodes them back to a typed `DecodedToken` so PBCs never need to import OAuth2 types. JWT secret is read from VIBEERP_JWT_SECRET; the framework refuses to start if the secret is shorter than 32 bytes. * `SecurityConfiguration` wires Spring Security with JWT resource server, stateless sessions, CSRF disabled, and a public allowlist for /actuator/health, /actuator/info, /api/v1/_meta/**, /api/v1/auth/login, /api/v1/auth/refresh. * `PrincipalContext` (in platform-persistence/security) is the bridge between Spring Security's SecurityContextHolder and the audit listener. Bound by `PrincipalContextFilter` which runs AFTER BearerTokenAuthenticationFilter so SecurityContextHolder is fully populated. The audit listener (AuditedJpaEntityListener) now reads from PrincipalContext, so created_by/updated_by are real user ids instead of __system__. * `pbc-identity` gains `UserCredential` (separate table from User — password hashes never share a query plan with user records), `AuthService` (login + refresh, generic AuthenticationFailedException on every failure to thwart account enumeration), and `AuthController` exposing /api/v1/auth/login and /api/v1/auth/refresh. * `BootstrapAdminInitializer` runs on first boot of an empty identity__user table, creates an `admin` user with a random 16-char password printed to the application logs. Subsequent boots see the user exists and skip silently. * GlobalExceptionHandler maps AuthenticationFailedException → 401 with a generic "invalid credentials" body (RFC 7807 ProblemDetail). * New module also brings BouncyCastle as a runtime-only dep (Argon2PasswordEncoder needs it). Tests: 38 unit tests pass, including JwtRoundTripTest (issue/decode round trip + tamper detection + secret-length validation), PrincipalContextTest (ThreadLocal lifecycle), AuthServiceTest (9 cases covering login + refresh happy paths and every failure mode). End-to-end smoke test against a fresh Postgres via docker-compose: GET /api/v1/identity/users (no auth) → 401 POST /api/v1/auth/login (admin + bootstrap) → 200 + access/refresh POST /api/v1/auth/login (wrong password) → 401 GET /api/v1/identity/users (Bearer) → 200, lists admin POST /api/v1/identity/users (Bearer) → 201, creates alice alice.created_by → admin's user UUID POST /api/v1/auth/refresh (refresh token) → 200 + new pair POST /api/v1/auth/refresh (access token) → 401 (type mismatch) GET /api/v1/identity/users (garbage token) → 401 GET /api/v1/_meta/info (no auth, public) → 200 Plan: docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md refreshed to drop the now-dead P1.1 (RLS hook) and H1 (per-region tenant routing), reorder priorities so P4.1 is first, and reflect the single-tenant change throughout. Bug fixes encountered along the way (caught by the smoke test, not by unit tests — the value of running real workflows): • JwtIssuer was producing IssuedToken.expiresAt with nanosecond precision but JWT exp is integer seconds; the round-trip test failed equality. Fixed by truncating to ChronoUnit.SECONDS at issue time. • PrincipalContextFilter was registered with addFilterAfter UsernamePasswordAuthenticationFilter, which runs BEFORE the OAuth2 BearerTokenAuthenticationFilter, so SecurityContextHolder was empty when the bridge filter read it. Result: every authenticated request still wrote __system__ in audit columns. Fixed by addFilterAfter BearerTokenAuthenticationFilter::class. • RefreshRequest is a single-String data class. jackson-module-kotlin interprets single-arg data classes as delegate-based creators, so Jackson tried to deserialize the entire JSON object as a String and threw HttpMessageNotReadableException. Fixed by adding @JsonCreator(mode = PROPERTIES) + @param:JsonProperty. -
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.
-
BLOCKER: wire Hibernate multi-tenancy - application.yaml: set hibernate.tenant_identifier_resolver and hibernate.multiTenancy=DISCRIMINATOR so HibernateTenantResolver is actually installed into the SessionFactory - AuditedJpaEntity.tenantId: add @org.hibernate.annotations.TenantId so every PBC entity inherits the discriminator - AuditedJpaEntityListener.onCreate: throw if a caller pre-set tenantId to a different value than the current TenantContext, instead of silently overwriting (defense against cross-tenant write bugs) IMPORTANT: dependency hygiene - pbc-identity no longer depends on platform-bootstrap (wrong direction; bootstrap assembles PBCs at the top of the stack) - root build.gradle.kts: tighten the architectural-rule enforcement to also reject :pbc:* -> platform-bootstrap; switch plug-in detection from a fragile pathname heuristic to an explicit extra["vibeerp.module-kind"] = "plugin" marker; reference plug-in declares the marker IMPORTANT: api.v1 surface additions (all non-breaking) - Repository: documented closed exception set; new PersistenceExceptions.kt declares OptimisticLockConflictException, UniqueConstraintViolationException, EntityValidationException, and EntityNotFoundException so plug-ins never see Hibernate types - TaskContext: now exposes tenantId(), principal(), locale(), correlationId() so workflow handlers (which run outside an HTTP request) can pass tenant-aware calls back into api.v1 - EventBus: subscribe() now returns a Subscription with close() so long-lived subscribers can deregister explicitly; added a subscribe(topic: String, ...) overload for cross-classloader event routing where Class<E> equality is unreliable - IdentityApi.findUserById: tightened from Id<*> to PrincipalId so the type system rejects "wrong-id-kind" mistakes at the cross-PBC boundary NITs: - HealthController.kt -> MetaController.kt (file name now matches the class name); added TODO(v0.2) for reading implementationVersion from the Spring Boot BuildProperties bean