• 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.
    vibe_erp authored
     
    Browse Code »
  • 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).
    vibe_erp authored
     
    Browse Code »
  • 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.
    vibe_erp authored
     
    Browse Code »
  • 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.
    vibe_erp authored
     
    Browse Code »
  • 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.
    vibe_erp authored
     
    Browse Code »

  • 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.
    vibe_erp authored
     
    Browse Code »
  • 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.
    vibe_erp authored
     
    Browse Code »
  • 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
    vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »
  • …tation plan, updated CLAUDE.md
    vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »
  • …er, IdentityApi adapter, unit tests)
    vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »
  • … i18n, http, plugin lifecycle
    vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »
  • vibe_erp authored
     
    Browse Code »