• Completes the HasExt rollout across every core entity with an ext
    column. Item was the last one that carried an ext JSONB column
    without any validation wired — a plug-in could declare custom fields
    for Item but nothing would enforce them on save. This fixes that and
    restores two printing-shop-specific Item fields to the reference
    plug-in that were temporarily dropped from the previous Tier 1
    customization chunk (commit 16c59310) precisely because Item wasn't
    wired.
    
    Code changes:
      - Item implements HasExt; `ext` becomes `override var ext`, a
        companion constant holds the entity name "Item".
      - ItemService injects ExtJsonValidator, calls applyTo() in both
        create() and update() (create + update symmetry like partners
        and locations). parseExt passthrough added for response mappers.
      - CreateItemCommand, UpdateItemCommand, CreateItemRequest,
        UpdateItemRequest gain a nullable ext field.
      - ItemResponse now carries the parsed ext map, same shape as
        PartnerResponse / LocationResponse / SalesOrderResponse.
      - pbc-catalog build.gradle adds
        `implementation(project(":platform:platform-metadata"))`.
      - ItemServiceTest constructor updated to pass the new validator
        dependency with no-op stubs.
    
    Plug-in YAML (printing-shop.yml):
      - Re-added `printing_shop_color_count` (integer) and
        `printing_shop_paper_gsm` (integer) custom fields targeting Item.
        These were originally in the commit 16c59310 draft but removed
        because Item wasn't wired. Now that Item is wired, they're back
        and actually enforced.
    
    Smoke verified end-to-end against real Postgres with the plug-in
    staged:
      - GET /_meta/metadata/custom-fields/Item returns 2 plug-in fields.
      - POST /catalog/items with `{printing_shop_color_count: 4,
        printing_shop_paper_gsm: 170}` → 201, canonical form persisted.
      - GET roundtrip preserves both integer values.
      - POST with `printing_shop_color_count: "not-a-number"` → 400
        "ext.printing_shop_color_count: not a valid integer: 'not-a-number'".
      - POST with `rogue_key` → 400 "ext contains undeclared key(s)
        for 'Item': [rogue_key]".
    
    Six of eight PBCs now participate in HasExt:
      Partner, Location, SalesOrder, PurchaseOrder, WorkOrder, Item.
    The remaining two are pbc-identity (User has no ext column by
    design — identity is a security concern, not a customization one)
    and pbc-finance (JournalEntry is derived state from events, no
    customization surface).
    
    Five core entities carry Tier 1 custom fields as of this commit:
      Partner     (2 core + 1 plug-in)
      Item        (0 core + 2 plug-in)
      SalesOrder  (0 core + 1 plug-in)
      WorkOrder   (2 core + 1 plug-in)
      Location    (0 core + 0 plug-in — wired but no declarations yet)
    
    246 unit tests, all green. 18 Gradle subprojects.
    zichun authored
     
    Browse Code »
  • Closes the P4.3 permission-rollout gap for the two oldest PBCs that
    were never updated when the @RequirePermission aspect landed. The
    catalog and partners metadata YAMLs already declared all the needed
    permission keys — the controllers just weren't consuming them.
    
    Catalog
      - ItemController: list/get/getByCode → catalog.item.read;
        create → catalog.item.create; update → catalog.item.update;
        deactivate → catalog.item.deactivate.
      - UomController: list/get/getByCode → catalog.uom.read;
        create → catalog.uom.create; update → catalog.uom.update.
    
    Partners (including the PII boundary)
      - PartnerController: list/get/getByCode → partners.partner.read;
        create → partners.partner.create; update → partners.partner.update.
        (deactivate was already annotated in the P4.3 demo chunk.)
      - AddressController: all five verbs annotated with
        partners.address.{read,create,update,delete}.
      - ContactController: all five verbs annotated with
        partners.contact.{read,create,update,deactivate}. The
        "TODO once P4.3 lands" note in the class KDoc was removed; P4.3
        is live and the annotations are now in place. This is the PII
        boundary that CLAUDE.md flagged as incomplete after the original
        P4.3 rollout.
    
    No new permission keys were added — all 14 keys this touches were
    already declared in pbc-catalog/catalog.yml and
    pbc-partners/partners.yml when those PBCs were first built. The
    metadata loader has been serving them to the SPA/OpenAPI/MCP
    introspection endpoint since day one; this change just starts
    enforcing them at the controller.
    
    Smoke-tested end-to-end against real Postgres
      - Fresh DB + fresh boot.
      - Admin happy path (bootstrap admin has wildcard `admin` role):
          GET  /api/v1/catalog/items           → 200
          POST /api/v1/catalog/items           → 201 (SMOKE-1 created)
          GET  /api/v1/catalog/uoms            → 200
          POST /api/v1/partners/partners       → 201 (SMOKE-P created)
          POST /api/v1/partners/.../contacts   → 201 (contact created)
          GET  /api/v1/partners/.../contacts   → 200 (PII read)
      - Anonymous negative path (no Bearer token):
          GET  /api/v1/catalog/items           → 401
          GET  /api/v1/partners/.../contacts   → 401
      - 230 unit tests still green (annotations are purely additive,
        no existing test hit the @RequirePermission path since the
        service-level tests bypass the controller entirely).
    
    Why this is a genuine security improvement
      - Before: any authenticated user (including the eventual "Alice
        from reception", the contractor's read-only service account,
        the AI-agent MCP client) could read PII, create partners, and
        create catalog items.
      - After: those operations require explicit role-permission grants
        through metadata__role_permission. The bootstrap admin still
        has unconditional access via the wildcard admin role, so
        nothing in a fresh deployment is broken; but a real operator
        granting minimum-privilege roles now has the columns they need
        in the database to do it.
      - The contact PII boundary in particular is GDPR-relevant: before
        this change, any logged-in user could enumerate every contact's
        name + email + phone. After, only users with partners.contact.read
        can see them.
    
    What's still NOT annotated
      - pbc-inventory's Location create/update/deactivate endpoints
        (only stock.adjust and movement.create are annotated).
      - pbc-orders-sales and pbc-orders-purchase list/get/create/update
        endpoints (only the state-transition verbs are annotated).
      - pbc-identity's user admin endpoints.
      These are the next cleanup chunk. This one stays focused on
      catalog + partners because those were the two PBCs that predated
      P4.3 entirely and hadn't been touched since.
    zichun authored
     
    Browse Code »

  • 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 »
  • 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 »