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.