-
Closes the P1.10 row of the implementation plan. New platform-jobs subproject shipping a Quartz-backed background job engine adapted to the api.v1 JobHandler contract, so PBCs and plug-ins can register scheduled work without ever importing Quartz types. ## The shape (matches the P2.1 workflow engine) platform-jobs is to scheduled work what platform-workflow is to BPMN service tasks. Same pattern, same discipline: - A single `@Component` bridge (`QuartzJobBridge`) is the ONLY org.quartz.Job implementation in the framework. Every persistent trigger points at it. - A single `JobHandlerRegistry` (owner-tagged, duplicate-key-rejecting, ConcurrentHashMap-backed) holds every registered JobHandler by key. Mirrors `TaskHandlerRegistry`. - The bridge reads the handler key from the trigger's JobDataMap, looks it up in the registry, and executes the matching JobHandler inside a `PrincipalContext.runAs("system:jobs:<key>")` block so audit rows written during the job get a structured, greppable `created_by` value ("system:jobs:core.audit.prune") instead of the default `__system__`. - Handler-thrown exceptions are re-wrapped as `JobExecutionException` so Quartz's MISFIRE machinery handles them properly. - `@DisallowConcurrentExecution` on the bridge stops a long-running handler from being started again before it finishes. ## api.v1 additions (package `org.vibeerp.api.v1.jobs`) - `JobHandler` — interface with `key()` + `execute(context)`. Analogous to the workflow TaskHandler. Plug-ins implement this to contribute scheduled work without any Quartz dependency. - `JobContext` — read-only execution context passed to the handler: principal, locale, correlation id, started-at instant, data map. Unlike TaskContext it has no `set()` writeback — scheduled jobs don't produce continuation state for a downstream step; a job that wants to talk to the rest of the system writes to its own domain table or publishes an event. - `JobScheduler` — injectable facade exposing: * `scheduleCron(scheduleKey, handlerKey, cronExpression, data)` * `scheduleOnce(scheduleKey, handlerKey, runAt, data)` * `unschedule(scheduleKey): Boolean` * `triggerNow(handlerKey, data): JobExecutionSummary` — synchronous in-thread execution, bypasses Quartz; used by the HTTP trigger endpoint and by tests. * `listScheduled(): List<ScheduledJobInfo>` — introspection Both `scheduleCron` and `scheduleOnce` are idempotent on `scheduleKey` (replace if exists). - `ScheduledJobInfo` + `JobExecutionSummary` + `ScheduleKind` — read-only DTOs returned by the scheduler. ## platform-jobs runtime - `QuartzJobBridge` — the shared Job impl. Routes by the `__vibeerp_handler_key` JobDataMap entry. Uses `@Autowired` field injection because Quartz instantiates Job classes through its own JobFactory (Spring Boot's `SpringBeanJobFactory` autowires fields after construction, which is the documented pattern). - `QuartzJobScheduler` — the concrete api.v1 `JobScheduler` implementation. Builds JobDetail + Trigger pairs under fixed group names (`vibeerp-jobs`), uses `addJob(replace=true)` + explicit `checkExists` + `rescheduleJob` for idempotent scheduling, strips the reserved `__vibeerp_handler_key` from the data visible to the handler. - `SimpleJobContext` — internal immutable `JobContext` impl. Defensive-copies the data map at construction. - `JobHandlerRegistry` — owner-tagged registry (OWNER_CORE by default, any other string for plug-in ownership). Same `register` / `unregister` / `unregisterAllByOwner` / `find` / `keys` / `size` surface as `TaskHandlerRegistry`. The plug-in loader integration seam is defined; the loader hook that calls `register(handler, pluginId)` lands when a plug-in actually ships a job handler (YAGNI). - `JobController` at `/api/v1/jobs/**`: * `GET /handlers` (perm `jobs.handler.read`) * `POST /handlers/{key}/trigger` (perm `jobs.job.trigger`) * `GET /scheduled` (perm `jobs.schedule.read`) * `POST /scheduled` (perm `jobs.schedule.write`) * `DELETE /scheduled/{key}` (perm `jobs.schedule.write`) - `VibeErpPingJobHandler` — built-in diagnostic. Key `vibeerp.jobs.ping`. Logs the invocation and exits. Safe to trigger from any environment; mirrors the core `vibeerp.workflow.ping` workflow handler from P2.1. - `META-INF/vibe-erp/metadata/jobs.yml` — 4 permissions + 2 menus. ## Spring Boot config (application.yaml) ``` spring.quartz: job-store-type: jdbc jdbc: initialize-schema: always # creates QRTZ_* tables on first boot properties: org.quartz.scheduler.instanceName: vibeerp-scheduler org.quartz.scheduler.instanceId: AUTO org.quartz.threadPool.threadCount: "4" org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate org.quartz.jobStore.isClustered: "false" ``` ## The config trap caught during smoke-test (documented in-file) First boot crashed with `SchedulerConfigException: DataSource name not set.` because I'd initially added `org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX` to the raw Quartz properties. That is correct for a standalone Quartz deployment but WRONG for the Spring Boot starter: the starter configures a `LocalDataSourceJobStore` that wraps the Spring-managed DataSource automatically when `job-store-type=jdbc`, and setting `jobStore.class` explicitly overrides that wrapper back to Quartz's standalone JobStoreTX — which then fails at init because Quartz-standalone expects a separately-named `dataSource` property the Spring Boot starter doesn't supply. Fix: drop the `jobStore.class` property entirely. The `driverDelegateClass` is still fine to set explicitly because it's read by both the standalone and Spring-wrapped JobStore implementations. Rationale is documented in the config comment so the next maintainer doesn't add it back. ## Smoke test (fresh DB, as admin) ``` GET /api/v1/jobs/handlers → {"count": 1, "keys": ["vibeerp.jobs.ping"]} POST /api/v1/jobs/handlers/vibeerp.jobs.ping/trigger {"data": {"source": "smoke-test"}} → 200 {"handlerKey": "vibeerp.jobs.ping", "correlationId": "e142...", "startedAt": "...", "finishedAt": "...", "ok": true} log: VibeErpPingJobHandler invoked at=... principal='system:jobs:manual-trigger' data={source=smoke-test} GET /api/v1/jobs/scheduled → [] POST /api/v1/jobs/scheduled {"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping", "cronExpression": "0/1 * * * * ?", "data": {"trigger": "cron"}} → 201 {"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping"} # after 3 seconds GET /api/v1/jobs/scheduled → [{"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping", "kind": "CRON", "cronExpression": "0/1 * * * * ?", "nextFireTime": "...", "previousFireTime": "...", "data": {"trigger": "cron"}}] DELETE /api/v1/jobs/scheduled/ping-every-sec → 200 {"removed": true} # handler log count after ~3 seconds of cron ticks grep -c "VibeErpPingJobHandler invoked" /tmp/boot.log → 5 # 1 manual trigger + 4 cron ticks before unschedule — matches the # 0/1 * * * * ? expression # negatives POST /api/v1/jobs/handlers/nope/trigger → 400 "no JobHandler registered for key 'nope'" POST /api/v1/jobs/scheduled {cronExpression: "not a cron"} → 400 "invalid Quartz cron expression: 'not a cron'" ``` ## Three schemas coexist in one Postgres database ``` SELECT count(*) FILTER (WHERE table_name LIKE 'qrtz_%') AS quartz_tables, count(*) FILTER (WHERE table_name LIKE 'act_%') AS flowable_tables, count(*) FILTER (WHERE table_name NOT LIKE 'qrtz_%' AND table_name NOT LIKE 'act_%' AND table_schema = 'public') AS vibeerp_tables FROM information_schema.tables WHERE table_schema = 'public'; quartz_tables | flowable_tables | vibeerp_tables ---------------+-----------------+---------------- 11 | 39 | 48 ``` Three independent schema owners (Quartz / Flowable / Liquibase) in one public schema, no collisions. Spring Boot's `QuartzDataSourceScriptDatabaseInitializer` runs the QRTZ_* DDL once and skips on subsequent boots; Flowable's internal MyBatis schema manager does the same for ACT_* tables; our Liquibase owns the rest. ## Tests - 6 new tests in `JobHandlerRegistryTest`: * initial handlers registered with OWNER_CORE * duplicate key fails fast with both owners in the error * unregisterAllByOwner only removes handlers owned by that id * unregister by key returns false for unknown * find on missing key returns null * blank key is rejected - 9 new tests in `QuartzJobSchedulerTest` (Quartz Scheduler mocked): * scheduleCron rejects an unknown handler key * scheduleCron rejects an invalid cron expression * scheduleCron adds job + schedules trigger when nothing exists yet * scheduleCron reschedules when the trigger already exists * scheduleOnce uses a simple trigger at the requested instant * unschedule returns true/false correctly * triggerNow calls the handler synchronously and returns ok=true * triggerNow propagates the handler's exception * triggerNow rejects an unknown handler key - Total framework unit tests: 315 (was 300), all green. ## What this unblocks - **pbc-finance audit prune** — a core recurring job that deletes posted journal entries older than N days, driven by a cron from a Tier 1 metadata row. - **Plug-in scheduled work** — once the loader integration hook is wired (trivial follow-up), any plug-in's `start(context)` can register a JobHandler via `context.jobs.register(handler)` and the host strips it on plug-in stop via `unregisterAllByOwner`. - **Delayed workflow continuations** — a BPMN handler can call `jobScheduler.scheduleOnce(...)` to "re-evaluate this workflow in 24 hours if no one has approved it", bridging the workflow engine and the scheduler without introducing Thread.sleep. - **Outbox draining strategy** — the existing 5-second OutboxPoller can move from a Spring @Scheduled to a Quartz cron so it inherits the scheduler's persistence, misfire handling, and the future clustering story. ## Non-goals (parking lot) - **Clustered scheduling.** `isClustered=false` for now. Making this true requires every instance to share a unique `instanceId` and agree on the JDBC lock policy — doable but out of v1.0 scope since vibe_erp is single-tenant single-instance by design. - **Async execution of triggerNow.** The current `triggerNow` runs synchronously on the caller thread so HTTP requests see the real result. A future "fire and forget" endpoint would delegate to `Scheduler.triggerJob(...)` against the JobDetail instead. - **Per-job permissions.** Today the four `jobs.*` permissions gate the whole controller. A future enhancement could attach per-handler permissions (so "trigger audit prune" requires a different permission than "trigger pricing refresh"). - **Plug-in loader integration.** The seam is defined on `JobHandlerRegistry` (owner tagging + unregisterAllByOwner) but `VibeErpPluginManager` doesn't call it yet. Lands in the same chunk as the first plug-in that ships a JobHandler. -
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.
-
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