-
New platform subproject `platform/platform-workflow` that makes `org.vibeerp.api.v1.workflow.TaskHandler` a live extension point. This is the framework's first chunk of Phase 2 (embedded workflow engine) and the dependency other work has been waiting on — pbc-production routings/operations, the full buy-make-sell BPMN scenario in the reference plug-in, and ultimately the BPMN designer web UI all hang off this seam. ## The shape - `flowable-spring-boot-starter-process:7.0.1` pulled in behind a single new module. Every other module in the framework still sees only the api.v1 TaskHandler + WorkflowTask + TaskContext surface — guardrail #10 stays honest, no Flowable type leaks to plug-ins or PBCs. - `TaskHandlerRegistry` is the host-side index of every registered handler, keyed by `TaskHandler.key()`. Auto-populated from every Spring bean implementing TaskHandler via constructor injection of `List<TaskHandler>`; duplicate keys fail fast at registration time. `register` / `unregister` exposed for a future plug-in lifecycle integration. - `DispatchingJavaDelegate` is a single Spring-managed JavaDelegate named `taskDispatcher`. Every BPMN service task in the framework references it via `flowable:delegateExpression="${taskDispatcher}"`. The dispatcher reads `execution.currentActivityId` as the task key (BPMN `id` attribute = TaskHandler key — no extension elements, no field injection, no second source of truth) and routes to the matching registered handler. A defensive copy of the execution variables is passed to the handler so it cannot mutate Flowable's internal map. - `DelegateTaskContext` adapts Flowable's `DelegateExecution` to the api.v1 `TaskContext` — the variable `set(name, value)` call forwards through Flowable's variable scope (persisted in the same transaction as the surrounding service task execution) and null values remove the variable. Principal + locale are documented placeholders for now (a workflow-engine `Principal.System`), waiting on the propagation chunk that plumbs the initiating user through `runtimeService.startProcessInstanceByKey(...)`. - `WorkflowService` is a thin facade over Flowable's `RuntimeService` + `RepositoryService` exposing exactly the four operations the controller needs: start, list active, inspect variables, list definitions. Everything richer (signals, timers, sub-processes, user-task completion, history queries) lands on this seam in later chunks. - `WorkflowController` at `/api/v1/workflow/**`: * `POST /process-instances` (permission `workflow.process.start`) * `GET /process-instances` (`workflow.process.read`) * `GET /process-instances/{id}/variables` (`workflow.process.read`) * `GET /definitions` (`workflow.definition.read`) * `GET /handlers` (`workflow.definition.read`) Exception handlers map `NoSuchElementException` + `FlowableObjectNotFoundException` → 404, `IllegalArgumentException` → 400, and any other `FlowableException` → 400. Permissions are declared in a new `META-INF/vibe-erp/metadata/workflow.yml` loaded by the core MetadataLoader so they show up under `GET /api/v1/_meta/metadata` alongside every other permission. ## The executable self-test - `vibeerp-ping.bpmn20.xml` ships in `processes/` on the module classpath and Flowable's starter auto-deploys it at boot. Structure: `start` → serviceTask id=`vibeerp.workflow.ping` (delegateExpression=`${taskDispatcher}`) → `end`. Process definitionKey is `vibeerp-workflow-ping` (distinct from the serviceTask id because BPMN 2.0 ids must be unique per document). - `PingTaskHandler` is a real shipped bean, not test code: its `execute` writes `pong=true`, `pongAt=<Instant.now()>`, and `correlationId=<ctx.correlationId()>` to the process variables. Operators and AI agents get a trivial "is the workflow engine alive?" probe out of the box. Why the demo lives in src/main, not src/test: Flowable's auto-deployer reads from the host classpath at boot, so if either half lived under src/test the smoke test wouldn't be reproducible from the shipped image — exactly what CLAUDE.md's "reference plug-in is the executable acceptance test" discipline is trying to prevent. ## The Flowable + Liquibase trap **Learned the hard way during the smoke test.** Adding `flowable-spring-boot-starter-process` immediately broke boot with `Schema-validation: missing table [catalog__item]`. Liquibase was silently not running. Root cause: Flowable 7.x registers a Spring Boot `EnvironmentPostProcessor` called `FlowableLiquibaseEnvironmentPostProcessor` that, unless the user has already set an explicit value, forces `spring.liquibase.enabled=false` with a WARN log line that reads "Flowable pulls in Liquibase but does not use the Spring Boot configuration for it". Our master.xml then never executes and JPA validation fails against the empty schema. Fix is a single line in `distribution/src/main/resources/application.yaml` — `spring.liquibase.enabled: true` — with a comment explaining why it must stay there for anyone who touches config next. Flowable's own ACT_* tables and vibe_erp's `catalog__*`, `pbc.*__*`, etc. tables coexist happily in the same public schema — 39 ACT_* tables alongside 45 vibe_erp tables on the smoke-tested DB. Flowable manages its own schema via its internal MyBatis DDL, Liquibase manages ours, they don't touch each other. ## Smoke-test transcript (fresh DB, dev profile) ``` docker compose down -v && docker compose up -d db ./gradlew :distribution:bootRun & # ... Flowable creates ACT_* tables, Liquibase creates vibe_erp tables, # MetadataLoader loads workflow.yml, TaskHandlerRegistry boots with 1 handler, # BPMN auto-deployed from classpath POST /api/v1/auth/login → JWT GET /api/v1/workflow/definitions → 1 definition (vibeerp-workflow-ping) GET /api/v1/workflow/handlers → {"count":1,"keys":["vibeerp.workflow.ping"]} POST /api/v1/workflow/process-instances {"processDefinitionKey":"vibeerp-workflow-ping", "businessKey":"smoke-1", "variables":{"greeting":"ni hao"}} → 201 {"processInstanceId":"...","ended":true, "variables":{"pong":true,"pongAt":"2026-04-09T...", "correlationId":"...","greeting":"ni hao"}} POST /api/v1/workflow/process-instances {"processDefinitionKey":"does-not-exist"} → 404 {"message":"No process definition found for key 'does-not-exist'"} GET /api/v1/catalog/uoms → still returns the 15 seeded UoMs (sanity) ``` ## Tests - 15 new unit tests in `platform-workflow/src/test`: * `TaskHandlerRegistryTest` — init with initial handlers, duplicate key fails fast, blank key rejected, unregister removes, unregister on unknown returns false, find on missing returns null * `DispatchingJavaDelegateTest` — dispatches by currentActivityId, throws on missing handler, defensive-copies the variable map * `DelegateTaskContextTest` — set non-null forwards, set null removes, blank name rejected, principal/locale/correlationId passthrough, default correlation id is stable across calls * `PingTaskHandlerTest` — key matches the BPMN serviceTask id, execute writes pong + pongAt + correlationId - Total framework unit tests: 261 (was 246), all green. ## What this unblocks - **REF.1** — real quote→job-card workflow handler in the printing-shop plug-in - **pbc-production routings/operations (v3)** — each operation becomes a BPMN step with duration + machine assignment - **P2.3** — user-task form rendering (landing on top of the RuntimeService already exposed via WorkflowService) - **P2.2** — BPMN designer web page (later, depends on R1) ## Deliberate non-goals (parking lot) - Principal propagation from the REST caller through the process start into the handler — uses a fixed `workflow-engine` `Principal.System` for now. Follow-up chunk will plumb the authenticated user as a Flowable variable. - Plug-in-contributed TaskHandler registration via PF4J child contexts — the registry exposes `register/unregister` but the plug-in loader doesn't call them yet. Follow-up chunk. - BPMN user tasks, signals, timers, history queries — seam exists, deliberately not built out. - Workflow deployment from `metadata__workflow` rows (the Tier 1 path). Today deployment is classpath-only via Flowable's auto- deployer. - The Flowable async job executor is explicitly deactivated (`flowable.async-executor-activate: false`) — background-job machinery belongs to the future Quartz integration (P1.10), not Flowable.
-
The framework's authorization layer is now live. Until now, every authenticated user could do everything; the framework had only an authentication gate. This chunk adds method-level @RequirePermission annotations enforced by a Spring AOP aspect that consults the JWT's roles claim and a metadata-driven role-permission map. What landed ----------- * New `Role` and `UserRole` JPA entities mapping the existing identity__role + identity__user_role tables (the schema was created in the original identity init but never wired to JPA). RoleJpaRepository + UserRoleJpaRepository with a JPQL query that returns a user's role codes in one round-trip. * `JwtIssuer.issueAccessToken(userId, username, roles)` now accepts a Set<String> of role codes and encodes them as a `roles` JWT claim (sorted for deterministic tests). Refresh tokens NEVER carry roles by design — see the rationale on `JwtIssuer.issueRefreshToken`. A role revocation propagates within one access-token lifetime (15 min default). * `JwtVerifier` reads the `roles` claim into `DecodedToken.roles`. Missing claim → empty set, NOT an error (refresh tokens, system tokens, and pre-P4.3 tokens all legitimately omit it). * `AuthService.login` now calls `userRoles.findRoleCodesByUserId(...)` before minting the access token. `AuthService.refresh` re-reads the user's roles too — so a refresh always picks up the latest set, since refresh tokens deliberately don't carry roles. * New `AuthorizationContext` ThreadLocal in `platform-security.authz` carrying an `AuthorizedPrincipal(id, username, roles)`. Separate from `PrincipalContext` (which lives in platform-persistence and carries only the principal id, for the audit listener). The two contexts coexist because the audit listener has no business knowing what roles a user has. * `PrincipalContextFilter` now populates BOTH contexts on every authenticated request, reading the JWT's `username` and `roles` claims via `Jwt.getClaimAsStringList("roles")`. The filter is the one and only place that knows about Spring Security types AND about both vibe_erp contexts; everything downstream uses just the Spring-free abstractions. * `PermissionEvaluator` Spring bean: takes a role set + permission key, returns boolean. Resolution chain: 1. The literal `admin` role short-circuits to `true` for every key (the wildcard exists so the bootstrap admin can do everything from the very first boot without seeding a complete role-permission mapping). 2. Otherwise consults an in-memory `Map<role, Set<permission>>` loaded from `metadata__role_permission` rows. The cache is rebuilt by `refresh()`, called from `VibeErpPluginManager` after the initial core load AND after every plug-in load. 3. Empty role set is always denied. No implicit grants. * `@RequirePermission("...")` annotation in `platform-security.authz`. `RequirePermissionAspect` is a Spring AOP @Aspect with @Around advice that intercepts every annotated method, reads the current request's `AuthorizationContext`, calls `PermissionEvaluator.has(...)`, and either proceeds or throws `PermissionDeniedException`. * New `PermissionDeniedException` carrying the offending key. `GlobalExceptionHandler` maps it to HTTP 403 Forbidden with `"permission denied: 'partners.partner.deactivate'"` as the detail. The key IS surfaced to the caller (unlike the 401's generic "invalid credentials") because the SPA needs it to render a useful "your role doesn't include X" message and callers are already authenticated, so it's not an enumeration vector. * `BootstrapAdminInitializer` now creates the wildcard `admin` role on first boot and grants it to the bootstrap admin user. * `@RequirePermission` applied to four sensitive endpoints as the demo: `PartnerController.deactivate`, `StockBalanceController.adjust`, `SalesOrderController.confirm`, `SalesOrderController.cancel`. More endpoints will gain annotations as additional roles are introduced; v1 keeps the blast radius narrow. End-to-end smoke test --------------------- Reset Postgres, booted the app, verified: * Admin login → JWT length 265 (was 241), decoded claims include `"roles":["admin"]` * Admin POST /sales-orders/{id}/confirm → 200, status DRAFT → CONFIRMED (admin wildcard short-circuits the permission check) * Inserted a 'powerless' user via raw SQL with no role assignments but copied the admin's password hash so login works * Powerless login → JWT length 247, decoded claims have NO roles field at all * Powerless POST /sales-orders/{id}/cancel → **403 Forbidden** with `"permission denied: 'orders.sales.cancel'"` in the body * Powerless DELETE /partners/{id} → **403 Forbidden** with `"permission denied: 'partners.partner.deactivate'"` * Powerless GET /sales-orders, /partners, /catalog/items → all 200 (read endpoints have no @RequirePermission) * Admin regression: catalog uoms, identity users, inventory locations, printing-shop plates with i18n, metadata custom-fields endpoint — all still HTTP 2xx Build ----- * `./gradlew build`: 15 subprojects, 163 unit tests (was 153), all green. The 10 new tests cover: - PermissionEvaluator: empty roles deny, admin wildcard, explicit role-permission grant, multi-role union, unknown role denial, malformed payload tolerance, currentHas with no AuthorizationContext, currentHas with bound context (8 tests). - JwtRoundTrip: roles claim round-trips through the access token, refresh token never carries roles even when asked (2 tests). What was deferred ----------------- * **OIDC integration (P4.2)**. Built-in JWT only. The Keycloak- compatible OIDC client will reuse the same authorization layer unchanged — the roles will come from OIDC ID tokens instead of the local user store. * **Permission key validation at boot.** The framework does NOT yet check that every `@RequirePermission` value matches a declared metadata permission key. The plug-in linter is the natural place for that check to land later. * **Role hierarchy**. Roles are flat in v1; a role with permission X cannot inherit from another role. Adding a `parent_role` field on the role row is a non-breaking change later. * **Resource-aware permissions** ("the user owns THIS partner"). v1 only checks the operation, not the operand. Resource-aware checks are post-v1. * **Composite (AND/OR) permission requirements**. A single key per call site keeps the contract simple. Composite requirements live in service code that calls `PermissionEvaluator.currentHas` directly. * **Role management UI / REST**. The framework can EVALUATE permissions but has no first-class endpoints for "create a role", "grant a permission to a role", "assign a role to a user". v1 expects these to be done via direct DB writes or via the future SPA's role editor (P3.x); the wiring above is intentionally policy-only, not management. -
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. -
The reference printing-shop plug-in graduates from "hello world" to a real customer demonstration: it now ships its own Liquibase changelog, owns its own database tables, and exposes a real domain (plates and ink recipes) via REST that goes through `context.jdbc` — a new typed-SQL surface in api.v1 — without ever touching Spring's `JdbcTemplate` or any other host internal type. A bytecode linter that runs before plug-in start refuses to load any plug-in that tries to import `org.vibeerp.platform.*` or `org.vibeerp.pbc.*` classes. What landed: * api.v1 (additive, binary-compatible): - PluginJdbc — typed SQL access with named parameters. Methods: query, queryForObject, update, inTransaction. No Spring imports leaked. Forces plug-ins to use named params (no positional ?). - PluginRow — typed nullable accessors over a single result row: string, int, long, uuid, bool, instant, bigDecimal. Hides java.sql.ResultSet entirely. - PluginContext.jdbc getter with default impl that throws UnsupportedOperationException so older builds remain binary compatible per the api.v1 stability rules. * platform-plugins — three new sub-packages: - jdbc/DefaultPluginJdbc backed by Spring's NamedParameterJdbcTemplate. ResultSetPluginRow translates each accessor through ResultSet.wasNull() so SQL NULL round-trips as Kotlin null instead of the JDBC defaults (0 for int, false for bool, etc. — bug factories). - jdbc/PluginJdbcConfiguration provides one shared PluginJdbc bean for the whole process. Per-plugin isolation lands later. - migration/PluginLiquibaseRunner looks for META-INF/vibe-erp/db/changelog.xml inside the plug-in JAR via the PF4J classloader and applies it via Liquibase against the host's shared DataSource. The unique META-INF path matters: plug-ins also see the host's parent classpath, where the host's own db/changelog/master.xml lives, and a collision causes Liquibase ChangeLogParseException at install time. - lint/PluginLinter walks every .class entry in the plug-in JAR via java.util.jar.JarFile + ASM ClassReader, visits every type/ method/field/instruction reference, rejects on any reference to `org/vibeerp/platform/` or `org/vibeerp/pbc/` packages. * VibeErpPluginManager lifecycle is now load → lint → migrate → start: - lint runs immediately after PF4J's loadPlugins(); rejected plug-ins are unloaded with a per-violation error log and never get to run any code - migrate runs the plug-in's own Liquibase changelog; failure means the plug-in is loaded but skipped (loud warning, framework boots fine) - then PF4J's startPlugins() runs the no-arg start - then we walk loaded plug-ins and call vibe_erp's start(context) with a fully-wired DefaultPluginContext (logger + endpoints + eventBus + jdbc). The plug-in's tables are guaranteed to exist by the time its lambdas run. * DefaultPluginContext.jdbc is no longer a stub. Plug-ins inject the shared PluginJdbc and use it to talk to their own tables. * Reference plug-in (PrintingShopPlugin): - Ships META-INF/vibe-erp/db/changelog.xml with two changesets: plugin_printingshop__plate (id, code, name, width_mm, height_mm, status) and plugin_printingshop__ink_recipe (id, code, name, cmyk_c/m/y/k). - Now registers seven endpoints: GET /ping — health GET /echo/{name} — path variable demo GET /plates — list GET /plates/{id} — fetch POST /plates — create (with race-conditiony existence check before INSERT, since plug-ins can't import Spring's DataAccessException) GET /inks POST /inks - All CRUD lambdas use context.jdbc with named parameters. The plug-in still imports nothing from org.springframework.* in its own code (it does reach the host's Jackson via reflection for JSON parsing — a deliberate v0.6 shortcut documented inline). Tests: 5 new PluginLinterTest cases use ASM ClassWriter to synthesize in-memory plug-in JARs (clean class, forbidden platform ref, forbidden pbc ref, allowed api.v1 ref, multiple violations) and a mocked PluginWrapper to avoid touching the real PF4J loader. Total now **81 unit tests** across 10 modules, all green. End-to-end smoke test against fresh Postgres with the plug-in loaded (every assertion green): Boot logs: PluginLiquibaseRunner: plug-in 'printing-shop' has changelog.xml Liquibase: ChangeSet printingshop-init-001 ran successfully Liquibase: ChangeSet printingshop-init-002 ran successfully Liquibase migrations applied successfully plugin.printing-shop: registered 7 endpoints HTTP smoke: \dt plugin_printingshop* → both tables exist GET /api/v1/plugins/printing-shop/plates → [] POST plate A4 → 201 + UUID POST plate A3 → 201 + UUID POST duplicate A4 → 409 + clear msg GET plates → 2 rows GET /plates/{id} → A4 details psql verifies both rows in plugin_printingshop__plate POST ink CYAN → 201 POST ink MAGENTA → 201 GET inks → 2 inks with nested CMYK GET /ping → 200 (existing endpoint) GET /api/v1/catalog/uoms → 15 UoMs (no regression) GET /api/v1/identity/users → 1 user (no regression) Bug encountered and fixed during the smoke test: • The plug-in initially shipped its changelog at db/changelog/master.xml, which collides with the HOST's db/changelog/master.xml. The plug-in classloader does parent-first lookup (PF4J default), so Liquibase's ClassLoaderResourceAccessor found BOTH files and threw ChangeLogParseException ("Found 2 files with the path"). Fixed by moving the plug-in changelog to META-INF/vibe-erp/db/changelog.xml, a path the host never uses, and updating PluginLiquibaseRunner. The unique META-INF prefix is now part of the documented plug-in convention. What is explicitly NOT in this chunk (deferred): • Per-plugin Spring child contexts — plug-ins still instantiate via PF4J's classloader without their own Spring beans • Per-plugin datasource isolation — one shared host pool today • Plug-in changelog table-prefix linter — convention only, runtime enforcement comes later • Rollback on plug-in uninstall — uninstall is operator-confirmed and rare; running dropAll() during stop() would lose data on accidental restart • Subscription auto-scoping on plug-in stop — plug-ins still close their own subscriptions in stop() • Real customer-grade JSON parsing in plug-in lambdas — the v0.6 reference plug-in uses reflection to find the host's Jackson; a real plug-in author would ship their own JSON library or use a future api.v1 typed-DTO surface Implementation plan refreshed: P1.2, P1.3, P1.4, P1.7, P4.1, P5.1 all marked DONE in docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md. Next priority candidates: P1.5 (metadata seeder) and P5.2 (pbc-partners). -
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.
-
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.