Commit 5bffbc402d0c7401a19e47e5fda6b7fcfcc59f12

Authored by zichun
1 parent 4e67ac97

feat(metadata): P3.4 — custom field application (Tier 1 customization)

The keystone of the framework's "key user adds fields without code"
promise. A YAML declaration is now enough to add a typed custom field
to an existing entity, validate it on every save, and surface it to
the SPA / OpenAPI / AI agent. This is the bit that makes vibe_erp a
*framework* instead of a fork-per-customer app.

What landed
-----------
* Extended `MetadataYamlFile` with a top-level `customFields:` section
  and `CustomFieldYaml` + `CustomFieldTypeYaml` wire-format DTOs. The
  YAML uses a flat `kind: decimal / scale: 2` discriminator instead of
  Jackson polymorphic deserialization so plug-in authors don't have to
  nest type configs and api.v1's `FieldType` stays free of Jackson
  imports.
* `MetadataLoader.doLoad` now upserts `metadata__custom_field` rows
  alongside entities/permissions/menus, with the same delete-by-source
  idempotency. `LoadResult` carries the count for the boot log.
* `CustomFieldRegistry` reads every `metadata__custom_field` row from
  the database and builds an in-memory `Map<entityName, List<CustomField>>`
  for the validator's hot path. `refresh()` is called by
  `VibeErpPluginManager` after the initial core load AND after every
  plug-in load, so a freshly-installed plug-in's custom fields are
  immediately enforceable. ConcurrentHashMap under the hood; reads
  are lock-free.
* `ExtJsonValidator` is the on-save half: takes (entityName, ext map),
  walks the declared fields, coerces each value to its native type,
  and returns the canonicalised map (or throws IllegalArgumentException
  with ALL violations joined for a single 400). Per-FieldType rules:
  - String: maxLength enforced.
  - Integer: accepts Number and numeric String, rejects garbage.
  - Decimal: precision/scale enforced; preserved as plain string in
    canonical form so JSON encoding doesn't lose trailing zeros.
  - Boolean: accepts true/false (case-insensitive).
  - Date / DateTime: ISO-8601 parse via java.time.
  - Uuid: java.util.UUID parse.
  - Enum: must be in declared `allowedValues`.
  - Money / Quantity / Json / Reference: pass-through (Reference target
    existence check pending the cross-PBC EntityRegistry seam).
  Unknown ext keys are rejected with the entity's name and the keys
  themselves listed. ALL violations are returned in one response, not
  failing on the first, so a form submitter fixes everything in one
  round-trip.
* `Partner` is the first PBC entity to wire ext through the validator:
  `CreatePartnerRequest` and `UpdatePartnerRequest` accept an
  `ext: Map<String, Any?>?`; `PartnerService.create/update` calls
  `extValidator.validate("Partner", ext)` and persists the canonical
  JSON to the existing `partners__partner.ext` JSONB column;
  `PartnerResponse` parses it back so callers see what they wrote.
* `partners.yml` now declares two custom fields on Partner —
  `partners_credit_limit` (Decimal precision=14, scale=2) and
  `partners_industry` (Enum of printing/publishing/packaging/other) —
  with English and Chinese labels. Tagged `source=core` so the
  framework has something to demo from a fresh boot.
* New public `GET /api/v1/_meta/metadata/custom-fields/{entityName}`
  endpoint serves the api.v1 runtime view of declarations from the
  in-memory registry (so it reflects every refresh) for the SPA's form
  builder, the OpenAPI generator, and the AI agent function catalog.
  The existing `GET /api/v1/_meta/metadata` endpoint also gained a
  `customFields` list.

End-to-end smoke test
---------------------
Reset Postgres, booted the app, verified:
* Boot log: `CustomFieldRegistry: refreshed 2 custom fields across 1
  entities (0 malformed rows skipped)` — twice (after core load and
  after plug-in load).
* `GET /api/v1/_meta/metadata/custom-fields/Partner` → both declarations
  with their labels.
* `POST /api/v1/partners/partners` with `ext = {credit_limit: "50000.00",
  industry: "printing"}` → 201; the response echoes the canonical map.
* `POST` with `ext = {credit_limit: "1.234", industry: "unicycles",
  rogue: "x"}` → 400 with all THREE violations in one body:
  "ext contains undeclared key(s) for 'Partner': [rogue]; ext.partners_credit_limit:
  decimal scale 3 exceeds declared scale 2; ext.partners_industry:
  value 'unicycles' is not in allowed set [printing, publishing,
  packaging, other]".
* `SELECT ext FROM partners__partner WHERE code = 'CUST-EXT-OK'` →
  `{"partners_industry": "printing", "partners_credit_limit": "50000.00"}`
  — the canonical JSON is in the JSONB column verbatim.
* Regression: catalog uoms, identity users, partners list, and the
  printing-shop plug-in's POST /plates (with i18n via Accept-Language:
  zh-CN) all still HTTP 2xx.

Build
-----
* `./gradlew build`: 13 subprojects, 129 unit tests (was 118), all
  green. The 11 new tests cover each FieldType variant, multi-violation
  reporting, required missing rejection, unknown key rejection, and the
  null-value-dropped case.

What was deferred
-----------------
* JPA listener auto-validation. Right now PartnerService explicitly
  calls extValidator.validate(...) before save; Item, Uom, User and
  the plug-in tables don't yet. Promoting the validator to a JPA
  PrePersist/PreUpdate listener attached to a marker interface
  HasExt is the right next step but is its own focused chunk.
* Reference target existence check. FieldType.Reference passes
  through unchanged because the cross-PBC EntityRegistry that would
  let the validator look up "does an instance with this id exist?"
  is a separate seam landing later.
* The customization UI (Tier 1 self-service add a field via the SPA)
  is P3.3 — the runtime enforcement is done, the editor is not.
* Custom-field-aware permissions. The `pii: true` flag is in the
  metadata but not yet read by anything; the DSAR/erasure pipeline
  that will consume it is post-v1.0.
CLAUDE.md
... ... @@ -96,8 +96,8 @@ plugins (incl. ref) depend on: api/api-v1 only
96 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97  
98 98 - **13 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
99   -- **118 unit tests across 13 modules**, all green. `./gradlew build` is the canonical full build.
100   -- **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader (P1.5), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
  99 +- **129 unit tests across 13 modules**, all green. `./gradlew build` is the canonical full build.
  100 +- **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101 101 - **3 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`) — they validate the recipe every future PBC clones across three different aggregate shapes (single entity, two-entity catalog, parent-with-children partners+addresses+contacts).
102 102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
103 103 - **Package root** is `org.vibeerp`.
... ...
PROGRESS.md
... ... @@ -10,27 +10,27 @@
10 10  
11 11 | | |
12 12 |---|---|
13   -| **Latest version** | v0.8 (post-P1.6) |
14   -| **Latest commit** | `01c71a6 feat(i18n): P1.6 — ICU4J translator + per-plug-in locale chain` |
  13 +| **Latest version** | v0.9 (post-P3.4) |
  14 +| **Latest commit** | `feat(metadata): P3.4 — custom field application (Tier 1 customization)` |
15 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 16 | **Modules** | 13 |
17   -| **Unit tests** | 118, all green |
18   -| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales |
  17 +| **Unit tests** | 129, all green |
  18 +| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner accepts custom-field `ext` declared in metadata |
19 19 | **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) |
20 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
21 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22  
23 23 ## Current stage
24 24  
25   -**Foundation complete; business surface area growing; i18n online.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader, ICU4J translator). Three real PBCs (identity, catalog, partners) validate the modular-monolith template against three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain through the api.v1 typed-SQL surface, registers its own HTTP endpoints, ships its own message bundles, returns localised strings in three locales, and would be rejected at install time if it tried to import any internal framework class.
  25 +**Foundation complete; Tier 1 customization live; business surface area growing.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader, ICU4J translator). The metadata layer now drives **custom-field validation on JSONB `ext` columns** (P3.4), the cornerstone of the "key user adds fields without code" promise: a YAML declaration is enough to start enforcing types, scales, enums, and required-checks on every save. Three real PBCs (identity, catalog, partners) validate the modular-monolith template; Partner is the first to use custom fields. The reference printing-shop plug-in remains the executable acceptance test.
26 26  
27   -The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), the metadata-driven custom fields and forms layer (Tier 1 customization), and eventually the React SPA.
  27 +The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), permission enforcement against `metadata__role_permission`, and eventually the React SPA.
28 28  
29 29 ## Total scope (the v1.0 cut line)
30 30  
31 31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar.
32 32  
33   -That target breaks down into roughly 30 work units across 8 phases. About **17 are done** as of today. Below is the full list with status.
  33 +That target breaks down into roughly 30 work units across 8 phases. About **18 are done** as of today. Below is the full list with status.
34 34  
35 35 ### Phase 1 — Platform completion (foundation)
36 36  
... ... @@ -62,7 +62,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **17 a
62 62 | P3.1 | JSON Schema form renderer (server) | 🔜 Pending |
63 63 | P3.2 | Form renderer (web) | 🔜 Pending — depends on R1 |
64 64 | P3.3 | Form designer (web) | 🔜 Pending — depends on R1 |
65   -| P3.4 | Custom field application (JSONB `ext` validation) | ⏳ Next priority candidate |
  65 +| P3.4 | Custom field application (JSONB `ext` validation) | ✅ DONE — `<this commit>` |
66 66 | P3.5 | Rules engine (event-driven) | 🔜 Pending |
67 67 | P3.6 | List view designer (web) | 🔜 Pending — depends on R1 |
68 68  
... ... @@ -127,6 +127,7 @@ These are the cross-cutting platform services already wired into the running fra
127 127 | **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. |
128 128 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. |
129 129 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. |
  130 +| **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. |
130 131 | **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners` | Three real PBCs prove the recipe across three aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. |
131 132  
132 133 ## What the reference plug-in proves end-to-end
... ... @@ -163,7 +164,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework,
163 164 ## What's not yet live (the deferred list)
164 165  
165 166 - **Workflow engine.** No Flowable yet. The api.v1 `TaskHandler` interface exists; the runtime that calls it doesn't.
166   -- **Custom fields.** The `ext jsonb` columns exist on every entity; the metadata describing them exists; the validator that enforces them on save does not (P3.4).
167 167 - **Forms.** No JSON Schema form renderer (server or client). No form designer.
168 168 - **Reports.** No JasperReports.
169 169 - **File store.** No abstraction; no S3 backend.
... ...
README.md
... ... @@ -77,7 +77,7 @@ vibe-erp/
77 77 ## Building
78 78  
79 79 ```bash
80   -# Build everything (compiles 13 modules, runs 118 unit tests)
  80 +# Build everything (compiles 13 modules, runs 129 unit tests)
81 81 ./gradlew build
82 82  
83 83 # Bring up Postgres + the reference plug-in JAR
... ... @@ -92,12 +92,12 @@ The bootstrap admin password is printed to the application logs on first boot. A
92 92  
93 93 ## Status
94 94  
95   -**Current stage: foundation complete; business surface area growing; i18n online.** All eight cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, the metadata seeder, and the ICU4J translator with per-plug-in locale chain. Three real PBCs (identity + catalog + partners) validate the modular-monolith template across three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain via REST, ships its own message bundles, returns localised strings in three locales, and would be rejected at install time if it tried to import any internal framework class.
  95 +**Current stage: foundation complete; Tier 1 customization live; business surface area growing.** All eight cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, the metadata seeder + custom-field validator, and the ICU4J translator with per-plug-in locale chain. The Tier 1 customization story works end-to-end: a YAML declaration adds a typed custom field to an existing entity, the framework's `ExtJsonValidator` enforces it on every save, and the SPA-facing `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the runtime view to the form builder.
96 96  
97 97 | | |
98 98 |---|---|
99 99 | Modules | 13 |
100   -| Unit tests | 118, all green |
  100 +| Unit tests | 129, all green |
101 101 | Real PBCs | 3 of 10 |
102 102 | Cross-cutting services live | 8 |
103 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
... ...
pbc/pbc-partners/build.gradle.kts
... ... @@ -35,6 +35,7 @@ dependencies {
35 35 api(project(":api:api-v1"))
36 36 implementation(project(":platform:platform-persistence"))
37 37 implementation(project(":platform:platform-security"))
  38 + implementation(project(":platform:platform-metadata")) // for ExtJsonValidator (P3.4)
38 39  
39 40 implementation(libs.kotlin.stdlib)
40 41 implementation(libs.kotlin.reflect)
... ...
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt
1 1 package org.vibeerp.pbc.partners.application
2 2  
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
3 5 import org.springframework.stereotype.Service
4 6 import org.springframework.transaction.annotation.Transactional
5 7 import org.vibeerp.pbc.partners.domain.Partner
... ... @@ -7,6 +9,7 @@ import org.vibeerp.pbc.partners.domain.PartnerType
7 9 import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository
8 10 import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository
9 11 import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  12 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
10 13 import java.util.UUID
11 14  
12 15 /**
... ... @@ -44,8 +47,11 @@ class PartnerService(
44 47 private val partners: PartnerJpaRepository,
45 48 private val addresses: AddressJpaRepository,
46 49 private val contacts: ContactJpaRepository,
  50 + private val extValidator: ExtJsonValidator,
47 51 ) {
48 52  
  53 + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
  54 +
49 55 @Transactional(readOnly = true)
50 56 fun list(): List<Partner> = partners.findAll()
51 57  
... ... @@ -59,6 +65,10 @@ class PartnerService(
59 65 require(!partners.existsByCode(command.code)) {
60 66 "partner code '${command.code}' is already taken"
61 67 }
  68 + // Validate the ext JSONB before save (P3.4 — Tier 1 customization).
  69 + // Throws IllegalArgumentException with all violations on failure;
  70 + // GlobalExceptionHandler maps it to 400 Bad Request.
  71 + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
62 72 return partners.save(
63 73 Partner(
64 74 code = command.code,
... ... @@ -69,7 +79,9 @@ class PartnerService(
69 79 email = command.email,
70 80 phone = command.phone,
71 81 active = command.active,
72   - ),
  82 + ).also {
  83 + it.ext = jsonMapper.writeValueAsString(canonicalExt)
  84 + },
73 85 )
74 86 }
75 87  
... ... @@ -86,10 +98,43 @@ class PartnerService(
86 98 command.email?.let { partner.email = it }
87 99 command.phone?.let { partner.phone = it }
88 100 command.active?.let { partner.active = it }
  101 + // Ext is updated as a whole, not field-by-field — partial ext
  102 + // updates would force callers to merge with the existing JSON
  103 + // before sending, which is the kind of foot-gun the framework
  104 + // should not normalise. PATCH is for top-level columns only.
  105 + if (command.ext != null) {
  106 + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
  107 + partner.ext = jsonMapper.writeValueAsString(canonicalExt)
  108 + }
89 109 return partner
90 110 }
91 111  
92 112 /**
  113 + * Parse the entity's `ext` JSON string back into a Map for the
  114 + * REST response. Returns an empty map when the column is empty
  115 + * or unparseable — the latter shouldn't happen because every
  116 + * write goes through [extValidator], but cleaning up bad rows
  117 + * from old data isn't this method's responsibility.
  118 + */
  119 + @Suppress("UNCHECKED_CAST")
  120 + fun parseExt(partner: Partner): Map<String, Any?> = try {
  121 + if (partner.ext.isBlank()) emptyMap()
  122 + else jsonMapper.readValue(partner.ext, Map::class.java) as Map<String, Any?>
  123 + } catch (ex: Throwable) {
  124 + emptyMap()
  125 + }
  126 +
  127 + companion object {
  128 + /**
  129 + * The entity name the partner aggregate is registered under in
  130 + * `metadata__entity` (and therefore the key the [ExtJsonValidator]
  131 + * looks up). Kept as a constant so the controller and tests
  132 + * can reference the same string without hard-coding it twice.
  133 + */
  134 + const val ENTITY_NAME: String = "Partner"
  135 + }
  136 +
  137 + /**
93 138 * Deactivate the partner and all its contacts. Addresses are left
94 139 * alone (they have no `active` flag — see [Address]).
95 140 */
... ... @@ -111,6 +156,7 @@ data class CreatePartnerCommand(
111 156 val email: String? = null,
112 157 val phone: String? = null,
113 158 val active: Boolean = true,
  159 + val ext: Map<String, Any?>? = null,
114 160 )
115 161  
116 162 data class UpdatePartnerCommand(
... ... @@ -121,4 +167,5 @@ data class UpdatePartnerCommand(
121 167 val email: String? = null,
122 168 val phone: String? = null,
123 169 val active: Boolean? = null,
  170 + val ext: Map<String, Any?>? = null,
124 171 )
... ...
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt
... ... @@ -40,18 +40,18 @@ class PartnerController(
40 40  
41 41 @GetMapping
42 42 fun list(): List<PartnerResponse> =
43   - partnerService.list().map { it.toResponse() }
  43 + partnerService.list().map { it.toResponse(partnerService) }
44 44  
45 45 @GetMapping("/{id}")
46 46 fun get(@PathVariable id: UUID): ResponseEntity<PartnerResponse> {
47 47 val partner = partnerService.findById(id) ?: return ResponseEntity.notFound().build()
48   - return ResponseEntity.ok(partner.toResponse())
  48 + return ResponseEntity.ok(partner.toResponse(partnerService))
49 49 }
50 50  
51 51 @GetMapping("/by-code/{code}")
52 52 fun getByCode(@PathVariable code: String): ResponseEntity<PartnerResponse> {
53 53 val partner = partnerService.findByCode(code) ?: return ResponseEntity.notFound().build()
54   - return ResponseEntity.ok(partner.toResponse())
  54 + return ResponseEntity.ok(partner.toResponse(partnerService))
55 55 }
56 56  
57 57 @PostMapping
... ... @@ -67,8 +67,9 @@ class PartnerController(
67 67 email = request.email,
68 68 phone = request.phone,
69 69 active = request.active ?: true,
  70 + ext = request.ext,
70 71 ),
71   - ).toResponse()
  72 + ).toResponse(partnerService)
72 73  
73 74 @PatchMapping("/{id}")
74 75 fun update(
... ... @@ -85,8 +86,9 @@ class PartnerController(
85 86 email = request.email,
86 87 phone = request.phone,
87 88 active = request.active,
  89 + ext = request.ext,
88 90 ),
89   - ).toResponse()
  91 + ).toResponse(partnerService)
90 92  
91 93 @DeleteMapping("/{id}")
92 94 @ResponseStatus(HttpStatus.NO_CONTENT)
... ... @@ -106,6 +108,14 @@ data class CreatePartnerRequest(
106 108 @field:Size(max = 256) val email: String? = null,
107 109 @field:Size(max = 64) val phone: String? = null,
108 110 val active: Boolean? = true,
  111 + /**
  112 + * Custom-field values, validated against [PartnerService.ENTITY_NAME]
  113 + * declarations from `metadata__custom_field` (P3.4). Unknown keys
  114 + * are rejected with 400; required missing fields are rejected with
  115 + * 400; type mismatches are rejected with 400. Omit or send `null`
  116 + * to leave the column unchanged.
  117 + */
  118 + val ext: Map<String, Any?>? = null,
109 119 )
110 120  
111 121 data class UpdatePartnerRequest(
... ... @@ -116,6 +126,13 @@ data class UpdatePartnerRequest(
116 126 @field:Size(max = 256) val email: String? = null,
117 127 @field:Size(max = 64) val phone: String? = null,
118 128 val active: Boolean? = null,
  129 + /**
  130 + * When present, REPLACES the entire ext map (PATCH semantics for
  131 + * the top-level columns, PUT semantics for ext as a whole). Omit
  132 + * to leave ext unchanged. Send an empty map to clear it (subject
  133 + * to required-field declarations).
  134 + */
  135 + val ext: Map<String, Any?>? = null,
119 136 )
120 137  
121 138 data class PartnerResponse(
... ... @@ -128,9 +145,10 @@ data class PartnerResponse(
128 145 val email: String?,
129 146 val phone: String?,
130 147 val active: Boolean,
  148 + val ext: Map<String, Any?>,
131 149 )
132 150  
133   -private fun Partner.toResponse() = PartnerResponse(
  151 +private fun Partner.toResponse(service: PartnerService) = PartnerResponse(
134 152 id = this.id,
135 153 code = this.code,
136 154 name = this.name,
... ... @@ -140,4 +158,5 @@ private fun Partner.toResponse() = PartnerResponse(
140 158 email = this.email,
141 159 phone = this.phone,
142 160 active = this.active,
  161 + ext = service.parseExt(this),
143 162 )
... ...
pbc/pbc-partners/src/main/resources/META-INF/vibe-erp/metadata/partners.yml
... ... @@ -55,3 +55,40 @@ menus:
55 55 icon: users
56 56 section: Partners
57 57 order: 300
  58 +
  59 +# Custom fields declared on the Partner entity (P3.4 — Tier 1
  60 +# customization). The framework's ExtJsonValidator enforces these on
  61 +# every save through the JSONB `ext` column on partners__partner.
  62 +#
  63 +# These two fields are seeded as `source=core` here so the framework
  64 +# has something to demo from a fresh boot. In production, key users
  65 +# would add custom fields via the SPA's customization UI (which writes
  66 +# `source=user` rows). Plug-ins can also declare custom fields via
  67 +# their own YAML.
  68 +customFields:
  69 + - key: partners_credit_limit
  70 + targetEntity: Partner
  71 + type:
  72 + kind: decimal
  73 + precision: 14
  74 + scale: 2
  75 + required: false
  76 + pii: false
  77 + labelTranslations:
  78 + en: Credit limit
  79 + zh-CN: 信用额度
  80 +
  81 + - key: partners_industry
  82 + targetEntity: Partner
  83 + type:
  84 + kind: enum
  85 + allowedValues:
  86 + - printing
  87 + - publishing
  88 + - packaging
  89 + - other
  90 + required: false
  91 + pii: false
  92 + labelTranslations:
  93 + en: Industry
  94 + zh-CN: 行业
... ...
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt
... ... @@ -18,6 +18,7 @@ import org.vibeerp.pbc.partners.domain.PartnerType
18 18 import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository
19 19 import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository
20 20 import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  21 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
21 22 import java.util.Optional
22 23 import java.util.UUID
23 24  
... ... @@ -26,6 +27,7 @@ class PartnerServiceTest {
26 27 private lateinit var partners: PartnerJpaRepository
27 28 private lateinit var addresses: AddressJpaRepository
28 29 private lateinit var contacts: ContactJpaRepository
  30 + private lateinit var extValidator: ExtJsonValidator
29 31 private lateinit var service: PartnerService
30 32  
31 33 @BeforeEach
... ... @@ -33,7 +35,11 @@ class PartnerServiceTest {
33 35 partners = mockk()
34 36 addresses = mockk()
35 37 contacts = mockk()
36   - service = PartnerService(partners, addresses, contacts)
  38 + extValidator = mockk()
  39 + // Default: validator returns the input map unchanged. Tests
  40 + // that exercise ext directly override this expectation.
  41 + every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
  42 + service = PartnerService(partners, addresses, contacts, extValidator)
37 43 }
38 44  
39 45 @Test
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt
... ... @@ -151,16 +151,19 @@ class MetadataLoader(
151 151 entities = files.flatMap { it.parsed.entities },
152 152 permissions = files.flatMap { it.parsed.permissions },
153 153 menus = files.flatMap { it.parsed.menus },
  154 + customFields = files.flatMap { it.parsed.customFields },
154 155 )
155 156  
156 157 wipeBySource(source)
157 158 insertEntities(source, merged)
158 159 insertPermissions(source, merged)
159 160 insertMenus(source, merged)
  161 + insertCustomFields(source, merged)
160 162  
161 163 log.info(
162   - "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus from {} file(s)",
163   - source, merged.entities.size, merged.permissions.size, merged.menus.size, files.size,
  164 + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields from {} file(s)",
  165 + source, merged.entities.size, merged.permissions.size, merged.menus.size,
  166 + merged.customFields.size, files.size,
164 167 )
165 168  
166 169 return LoadResult(
... ... @@ -168,6 +171,7 @@ class MetadataLoader(
168 171 entityCount = merged.entities.size,
169 172 permissionCount = merged.permissions.size,
170 173 menuCount = merged.menus.size,
  174 + customFieldCount = merged.customFields.size,
171 175 files = files.map { it.url },
172 176 )
173 177 }
... ... @@ -181,6 +185,7 @@ class MetadataLoader(
181 185 val entityCount: Int,
182 186 val permissionCount: Int,
183 187 val menuCount: Int,
  188 + val customFieldCount: Int = 0,
184 189 val files: List<String>,
185 190 )
186 191  
... ... @@ -191,6 +196,7 @@ class MetadataLoader(
191 196 jdbc.update("DELETE FROM metadata__entity WHERE source = :source", params)
192 197 jdbc.update("DELETE FROM metadata__permission WHERE source = :source", params)
193 198 jdbc.update("DELETE FROM metadata__menu WHERE source = :source", params)
  199 + jdbc.update("DELETE FROM metadata__custom_field WHERE source = :source", params)
194 200 }
195 201  
196 202 private fun insertEntities(source: String, file: MetadataYamlFile) {
... ... @@ -244,6 +250,23 @@ class MetadataLoader(
244 250 }
245 251 }
246 252  
  253 + private fun insertCustomFields(source: String, file: MetadataYamlFile) {
  254 + val now = Timestamp.from(Instant.now())
  255 + for (customField in file.customFields) {
  256 + jdbc.update(
  257 + """
  258 + INSERT INTO metadata__custom_field (id, source, payload, created_at, updated_at)
  259 + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)
  260 + """.trimIndent(),
  261 + MapSqlParameterSource()
  262 + .addValue("id", UUID.randomUUID())
  263 + .addValue("source", source)
  264 + .addValue("payload", jsonMapper.writeValueAsString(customField))
  265 + .addValue("now", now),
  266 + )
  267 + }
  268 + }
  269 +
247 270 private data class ParsedYaml(
248 271 val url: String,
249 272 val parsed: MetadataYamlFile,
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/CustomFieldRegistry.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.customfield
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  5 +import org.slf4j.LoggerFactory
  6 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  7 +import org.springframework.stereotype.Component
  8 +import org.vibeerp.api.v1.entity.CustomField
  9 +import org.vibeerp.api.v1.entity.FieldType
  10 +import org.vibeerp.platform.metadata.yaml.CustomFieldTypeYaml
  11 +import org.vibeerp.platform.metadata.yaml.CustomFieldYaml
  12 +import java.util.concurrent.ConcurrentHashMap
  13 +
  14 +/**
  15 + * In-memory index of every [CustomField] declaration loaded from
  16 + * `metadata__custom_field` rows, grouped by [CustomField.targetEntity].
  17 + *
  18 + * **Why an in-memory cache.** Custom-field declarations are read on
  19 + * EVERY entity save (the [ExtJsonValidator] calls [forEntity]) and on
  20 + * every form render. Hitting Postgres for each call would dominate
  21 + * the request latency budget for what is otherwise a sub-millisecond
  22 + * operation, and the data is updated rarely (boot, plug-in load, key
  23 + * user clicks "save" in the form designer). The cache is rebuilt by
  24 + * [refresh], which the metadata loader calls after every successful
  25 + * load + the customization UI will call after each user-initiated
  26 + * change.
  27 + *
  28 + * **What this is NOT.** This is not a generic metadata cache. It only
  29 + * indexes custom fields, because they have a uniquely high call rate
  30 + * and a unique consumption pattern (look up by entity name, return a
  31 + * list). Other metadata kinds (entities, permissions, menus) are
  32 + * served directly by the [org.vibeerp.platform.metadata.web.MetadataController]
  33 + * read-through to the database — request rates for those are low and
  34 + * an in-memory cache adds complexity for no payoff.
  35 + *
  36 + * **Concurrency.** [ConcurrentHashMap] under the hood; reads are
  37 + * lock-free, writes during [refresh] are atomic per-entity. The race
  38 + * window is intentionally tolerated: a save in flight when a refresh
  39 + * happens may see either the old field set or the new one. Both are
  40 + * valid framework states and the user-visible outcome is "your save
  41 + * went through, the new field is now also enforced".
  42 + */
  43 +@Component
  44 +class CustomFieldRegistry(
  45 + private val jdbc: NamedParameterJdbcTemplate,
  46 +) {
  47 +
  48 + private val log = LoggerFactory.getLogger(CustomFieldRegistry::class.java)
  49 +
  50 + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
  51 +
  52 + private val byEntity: ConcurrentHashMap<String, List<CustomField>> = ConcurrentHashMap()
  53 +
  54 + /**
  55 + * Return the declared custom fields for [entityName], in the order
  56 + * they appear in the database. Returns an empty list (NOT null)
  57 + * when no fields are declared, so callers can iterate without a
  58 + * null check.
  59 + */
  60 + fun forEntity(entityName: String): List<CustomField> =
  61 + byEntity[entityName] ?: emptyList()
  62 +
  63 + /**
  64 + * Snapshot of every entity name that currently has at least one
  65 + * declared custom field. Used by the public metadata REST endpoint
  66 + * and by the unit tests; not on the hot path.
  67 + */
  68 + fun entitiesWithCustomFields(): Set<String> = byEntity.keys.toSet()
  69 +
  70 + /**
  71 + * Read every `metadata__custom_field` row from the database and
  72 + * rebuild the in-memory index. Called at startup (after the
  73 + * MetadataLoader has loaded core YAML) and after every plug-in
  74 + * load. Tolerates rows with malformed payloads — they're logged
  75 + * and skipped, the rest of the load continues.
  76 + */
  77 + fun refresh() {
  78 + val rows = jdbc.query(
  79 + "SELECT payload FROM metadata__custom_field",
  80 + emptyMap<String, Any?>(),
  81 + ) { rs, _ -> rs.getString("payload") }
  82 +
  83 + val parsed = mutableListOf<CustomField>()
  84 + var malformed = 0
  85 + for (payload in rows) {
  86 + val cf = try {
  87 + val yaml = jsonMapper.readValue(payload, CustomFieldYaml::class.java)
  88 + yaml.toApiV1()
  89 + } catch (ex: Throwable) {
  90 + malformed++
  91 + log.warn("CustomFieldRegistry: skipping malformed metadata__custom_field row: {}", ex.message)
  92 + null
  93 + }
  94 + if (cf != null) parsed += cf
  95 + }
  96 +
  97 + val grouped = parsed.groupBy { it.targetEntity }
  98 + // Atomic-ish swap: clear-then-put under the same operation. The
  99 + // window where a reader might see "the old + the new" simultaneously
  100 + // is acceptable — see the class doc on concurrency.
  101 + byEntity.clear()
  102 + byEntity.putAll(grouped)
  103 +
  104 + log.info(
  105 + "CustomFieldRegistry: refreshed {} custom fields across {} entities ({} malformed rows skipped)",
  106 + parsed.size, grouped.size, malformed,
  107 + )
  108 + }
  109 +}
  110 +
  111 +/**
  112 + * Convert the YAML wire format to the api.v1 runtime type.
  113 + *
  114 + * The conversion is non-trivial because [FieldType] is a sealed
  115 + * interface with per-variant config — STRING has maxLength, DECIMAL
  116 + * has precision/scale, ENUM has allowedValues, REFERENCE has
  117 + * targetEntity. The YAML has all of those as optional fields and
  118 + * picks the right ones based on `kind`.
  119 + *
  120 + * Throws [IllegalArgumentException] when the YAML is internally
  121 + * inconsistent (e.g. `kind: enum` with no `allowedValues`). Callers
  122 + * (the registry refresh) catch this and skip the malformed row.
  123 + */
  124 +internal fun CustomFieldYaml.toApiV1(): CustomField =
  125 + CustomField(
  126 + key = key,
  127 + targetEntity = targetEntity,
  128 + type = type.toFieldType(),
  129 + required = required,
  130 + pii = pii,
  131 + labelTranslations = labelTranslations,
  132 + )
  133 +
  134 +internal fun CustomFieldTypeYaml.toFieldType(): FieldType = when (kind.lowercase()) {
  135 + "string" -> FieldType.String(maxLength = maxLength ?: 255)
  136 + "integer" -> FieldType.Integer
  137 + "decimal" -> FieldType.Decimal(
  138 + precision = precision ?: 19,
  139 + scale = scale ?: 4,
  140 + )
  141 + "boolean" -> FieldType.Boolean
  142 + "date" -> FieldType.Date
  143 + "date_time", "datetime" -> FieldType.DateTime
  144 + "uuid" -> FieldType.Uuid
  145 + "money" -> FieldType.Money
  146 + "quantity" -> FieldType.Quantity
  147 + "reference" -> FieldType.Reference(
  148 + targetEntity = requireNotNull(targetEntity) {
  149 + "REFERENCE custom field requires 'targetEntity'"
  150 + },
  151 + )
  152 + "enum" -> FieldType.Enum(
  153 + allowedValues = requireNotNull(allowedValues) {
  154 + "ENUM custom field requires 'allowedValues'"
  155 + },
  156 + )
  157 + "json" -> FieldType.Json
  158 + else -> throw IllegalArgumentException("Unknown custom field kind: '$kind'")
  159 +}
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidator.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.customfield
  2 +
  3 +import org.springframework.stereotype.Component
  4 +import org.vibeerp.api.v1.entity.CustomField
  5 +import org.vibeerp.api.v1.entity.FieldType
  6 +import java.math.BigDecimal
  7 +import java.time.LocalDate
  8 +import java.time.OffsetDateTime
  9 +import java.time.format.DateTimeParseException
  10 +import java.util.UUID
  11 +
  12 +/**
  13 + * Validates the contents of a JSONB `ext` map against the declared
  14 + * [CustomField]s for an entity. This is the **on-save** half of the
  15 + * Tier 1 customization story (P3.4) — the metadata declarations are
  16 + * loaded by [CustomFieldRegistry], and every PBC service that writes
  17 + * the `ext` column calls [validate] before persisting.
  18 + *
  19 + * **What this enforces**:
  20 + *
  21 + * 1. **Unknown keys are rejected.** No schemaless writes — if a
  22 + * caller sends `ext.foo` and `foo` is not declared as a custom
  23 + * field on this entity, the request is rejected. This is the rule
  24 + * that makes the form designer, the OpenAPI generator, and the AI
  25 + * agent function catalog trustworthy: every value in `ext` MUST
  26 + * match a declared field.
  27 + * 2. **Required fields must be present.** A field declared
  28 + * `required: true` whose value is missing or `null` is a violation.
  29 + * 3. **Type coercion.** Each [FieldType] variant has its own coercion
  30 + * rule. Numbers can come in as `Int`, `Long`, `Double`, or string
  31 + * (since some JSON parsers preserve scale by quoting). Booleans
  32 + * accept `true`/`false`. Dates accept ISO-8601 strings. Enums
  33 + * accept any of the declared `allowedValues` exactly.
  34 + *
  35 + * **What this does NOT enforce** (deliberately):
  36 + *
  37 + * - Permission checks (P4.3 — "can THIS user write to THIS field").
  38 + * - PII tagging (the field's `pii` flag is read by DSAR jobs, not
  39 + * by save-time validation).
  40 + * - Reference target existence (P3.4 doesn't load every referenced
  41 + * entity at save time; that requires the EntityRegistry to be
  42 + * cross-PBC, which is a separate seam landing later).
  43 + *
  44 + * **Failure mode.** [validate] either returns the canonicalised map
  45 + * (every value coerced to its native Kotlin type for clean JSON
  46 + * encoding) or throws [IllegalArgumentException] with ALL violations
  47 + * concatenated. The exception is caught by the framework's
  48 + * `GlobalExceptionHandler` and returned to the caller as a 400 Bad
  49 + * Request. Returning all violations at once (instead of failing on
  50 + * the first one) lets a form submitter fix every error in one
  51 + * round-trip.
  52 + */
  53 +@Component
  54 +class ExtJsonValidator(
  55 + private val registry: CustomFieldRegistry,
  56 +) {
  57 +
  58 + /**
  59 + * Validate (and canonicalise) the [ext] map for [entityName].
  60 + *
  61 + * @return a fresh map containing only the declared fields, with
  62 + * values coerced to their native Kotlin types — safe to encode
  63 + * directly as JSON.
  64 + * @throws IllegalArgumentException with a single line per
  65 + * violation, joined by `; `, when validation fails.
  66 + */
  67 + fun validate(entityName: String, ext: Map<String, Any?>?): Map<String, Any?> {
  68 + val declared: List<CustomField> = registry.forEntity(entityName)
  69 + val incoming: Map<String, Any?> = ext ?: emptyMap()
  70 +
  71 + // Fast path: no declared fields → must be empty.
  72 + if (declared.isEmpty()) {
  73 + if (incoming.isEmpty()) return emptyMap()
  74 + throw IllegalArgumentException(
  75 + "ext must be empty for entity '$entityName' (no custom fields declared); got keys: ${incoming.keys}",
  76 + )
  77 + }
  78 +
  79 + val declaredByKey: Map<String, CustomField> = declared.associateBy { it.key }
  80 + val violations = mutableListOf<String>()
  81 + val canonical = LinkedHashMap<String, Any?>()
  82 +
  83 + // Reject unknown keys.
  84 + val unknown = incoming.keys - declaredByKey.keys
  85 + if (unknown.isNotEmpty()) {
  86 + violations += "ext contains undeclared key(s) for '$entityName': $unknown"
  87 + }
  88 +
  89 + // Validate each declared field.
  90 + for (field in declared) {
  91 + val raw = incoming[field.key]
  92 + if (raw == null) {
  93 + if (field.required) {
  94 + violations += "ext.${field.key} is required"
  95 + }
  96 + // null is a legitimate "field not provided" — drop from canonical.
  97 + continue
  98 + }
  99 + val coerced = try {
  100 + coerceValue(field, raw)
  101 + } catch (ex: ExtFieldViolation) {
  102 + violations += "ext.${field.key}: ${ex.message}"
  103 + null
  104 + }
  105 + if (coerced != null) {
  106 + canonical[field.key] = coerced
  107 + }
  108 + }
  109 +
  110 + if (violations.isNotEmpty()) {
  111 + throw IllegalArgumentException(violations.joinToString("; "))
  112 + }
  113 + return canonical
  114 + }
  115 +
  116 + /**
  117 + * Coerce one [raw] value for a single [field] to its native type.
  118 + *
  119 + * Throws [ExtFieldViolation] (an internal type) on a coercion
  120 + * error so [validate] can collect the message into the
  121 + * per-call violations list.
  122 + */
  123 + private fun coerceValue(field: CustomField, raw: Any): Any = when (val type = field.type) {
  124 + is FieldType.String -> {
  125 + val s = raw.toString()
  126 + if (s.length > type.maxLength) {
  127 + throw ExtFieldViolation("string exceeds maxLength ${type.maxLength} (got ${s.length})")
  128 + }
  129 + s
  130 + }
  131 +
  132 + FieldType.Integer -> when (raw) {
  133 + is Number -> raw.toLong()
  134 + is String -> raw.toLongOrNull()
  135 + ?: throw ExtFieldViolation("not a valid integer: '$raw'")
  136 + else -> throw ExtFieldViolation("not a valid integer: $raw")
  137 + }
  138 +
  139 + is FieldType.Decimal -> {
  140 + val bd = when (raw) {
  141 + is BigDecimal -> raw
  142 + is Number -> BigDecimal(raw.toString())
  143 + is String -> try {
  144 + BigDecimal(raw)
  145 + } catch (ex: NumberFormatException) {
  146 + throw ExtFieldViolation("not a valid decimal: '$raw'")
  147 + }
  148 + else -> throw ExtFieldViolation("not a valid decimal: $raw")
  149 + }
  150 + // precision = total significant digits; scale = digits after the point.
  151 + // bd.precision() reports the unscaled significand digit count.
  152 + if (bd.scale() > type.scale) {
  153 + throw ExtFieldViolation("decimal scale ${bd.scale()} exceeds declared scale ${type.scale}")
  154 + }
  155 + // Effective precision after normalising trailing zeros.
  156 + val effectivePrecision = bd.stripTrailingZeros().let {
  157 + // BigDecimal("0").precision() reports 1 — fine for our check.
  158 + if (it.scale() < 0) it.precision() - it.scale() else it.precision()
  159 + }
  160 + if (effectivePrecision > type.precision) {
  161 + throw ExtFieldViolation(
  162 + "decimal precision $effectivePrecision exceeds declared precision ${type.precision}",
  163 + )
  164 + }
  165 + // Return as a plain string so JSON encoding preserves scale.
  166 + bd.toPlainString()
  167 + }
  168 +
  169 + FieldType.Boolean -> when (raw) {
  170 + is Boolean -> raw
  171 + is String -> when (raw.lowercase()) {
  172 + "true" -> true
  173 + "false" -> false
  174 + else -> throw ExtFieldViolation("not a valid boolean: '$raw'")
  175 + }
  176 + else -> throw ExtFieldViolation("not a valid boolean: $raw")
  177 + }
  178 +
  179 + FieldType.Date -> try {
  180 + LocalDate.parse(raw.toString()).toString()
  181 + } catch (ex: DateTimeParseException) {
  182 + throw ExtFieldViolation("not a valid ISO-8601 date: '$raw'")
  183 + }
  184 +
  185 + FieldType.DateTime -> try {
  186 + // Accept any ISO-8601 offset date-time and canonicalise to UTC.
  187 + OffsetDateTime.parse(raw.toString()).toString()
  188 + } catch (ex: DateTimeParseException) {
  189 + throw ExtFieldViolation("not a valid ISO-8601 date-time: '$raw'")
  190 + }
  191 +
  192 + FieldType.Uuid -> try {
  193 + UUID.fromString(raw.toString()).toString()
  194 + } catch (ex: IllegalArgumentException) {
  195 + throw ExtFieldViolation("not a valid UUID: '$raw'")
  196 + }
  197 +
  198 + is FieldType.Enum -> {
  199 + val s = raw.toString()
  200 + if (s !in type.allowedValues) {
  201 + throw ExtFieldViolation("value '$s' is not in allowed set ${type.allowedValues}")
  202 + }
  203 + s
  204 + }
  205 +
  206 + FieldType.Money,
  207 + FieldType.Quantity,
  208 + FieldType.Json,
  209 + is FieldType.Reference,
  210 + -> {
  211 + // These types pass through as-is in v1 — they're either
  212 + // structured values the API surface decides on (Money,
  213 + // Quantity), opaque blobs (Json), or pointers whose
  214 + // existence the validator doesn't yet check (Reference,
  215 + // pending the cross-PBC EntityRegistry seam). The framework
  216 + // still ensures the value is non-null because the
  217 + // null-check happens above this method.
  218 + raw
  219 + }
  220 + }
  221 +
  222 + /**
  223 + * Internal sentinel that lets [coerceValue] surface a per-field
  224 + * message that [validate] turns into one entry in the violations
  225 + * list. Not exported because callers see the violations as
  226 + * [IllegalArgumentException] message strings, not typed
  227 + * exceptions per field.
  228 + */
  229 + private class ExtFieldViolation(message: String) : RuntimeException(message)
  230 +}
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt
... ... @@ -3,8 +3,12 @@ package org.vibeerp.platform.metadata.web
3 3 import com.fasterxml.jackson.databind.ObjectMapper
4 4 import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
5 5 import org.springframework.web.bind.annotation.GetMapping
  6 +import org.springframework.web.bind.annotation.PathVariable
6 7 import org.springframework.web.bind.annotation.RequestMapping
7 8 import org.springframework.web.bind.annotation.RestController
  9 +import org.vibeerp.api.v1.entity.CustomField
  10 +import org.vibeerp.api.v1.entity.FieldType
  11 +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
8 12  
9 13 /**
10 14 * Read-only REST endpoint that returns the seeded metadata.
... ... @@ -27,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController
27 31 class MetadataController(
28 32 private val jdbc: NamedParameterJdbcTemplate,
29 33 private val objectMapper: ObjectMapper,
  34 + private val customFieldRegistry: CustomFieldRegistry,
30 35 ) {
31 36  
32 37 @GetMapping
... ... @@ -34,6 +39,7 @@ class MetadataController(
34 39 "entities" to readPayloads("metadata__entity"),
35 40 "permissions" to readPayloads("metadata__permission"),
36 41 "menus" to readPayloads("metadata__menu"),
  42 + "customFields" to readPayloads("metadata__custom_field"),
37 43 )
38 44  
39 45 @GetMapping("/entities")
... ... @@ -45,6 +51,52 @@ class MetadataController(
45 51 @GetMapping("/menus")
46 52 fun menus(): List<Map<String, Any?>> = readPayloads("metadata__menu")
47 53  
  54 + /**
  55 + * Read every custom-field declaration as raw `metadata__custom_field`
  56 + * payload rows. Returns the YAML wire format unchanged so the SPA
  57 + * (or any introspection tool) gets the same shape it would get
  58 + * from reading the source YAML directly.
  59 + */
  60 + @GetMapping("/custom-fields")
  61 + fun customFieldsAll(): List<Map<String, Any?>> = readPayloads("metadata__custom_field")
  62 +
  63 + /**
  64 + * Per-entity custom-field view, served from the in-memory
  65 + * [CustomFieldRegistry] (so it reflects every refresh, including
  66 + * after plug-in load) rather than re-querying the database. The
  67 + * shape is the api.v1 [CustomField] runtime view, not the YAML
  68 + * wire format — this endpoint is consumed by the SPA's form
  69 + * builder which wants resolved [FieldType] descriptors, not the
  70 + * raw `kind: decimal` discriminator.
  71 + */
  72 + @GetMapping("/custom-fields/{entityName}")
  73 + fun customFieldsForEntity(@PathVariable entityName: String): List<Map<String, Any?>> =
  74 + customFieldRegistry.forEntity(entityName).map { it.toApiResponse() }
  75 +
  76 + private fun CustomField.toApiResponse(): Map<String, Any?> = mapOf(
  77 + "key" to key,
  78 + "targetEntity" to targetEntity,
  79 + "type" to type.toApiResponse(),
  80 + "required" to required,
  81 + "pii" to pii,
  82 + "labelTranslations" to labelTranslations,
  83 + )
  84 +
  85 + private fun FieldType.toApiResponse(): Map<String, Any?> = when (this) {
  86 + is FieldType.String -> mapOf("kind" to "string", "maxLength" to maxLength)
  87 + FieldType.Integer -> mapOf("kind" to "integer")
  88 + is FieldType.Decimal -> mapOf("kind" to "decimal", "precision" to precision, "scale" to scale)
  89 + FieldType.Boolean -> mapOf("kind" to "boolean")
  90 + FieldType.Date -> mapOf("kind" to "date")
  91 + FieldType.DateTime -> mapOf("kind" to "dateTime")
  92 + FieldType.Uuid -> mapOf("kind" to "uuid")
  93 + FieldType.Money -> mapOf("kind" to "money")
  94 + FieldType.Quantity -> mapOf("kind" to "quantity")
  95 + is FieldType.Reference -> mapOf("kind" to "reference", "targetEntity" to targetEntity)
  96 + is FieldType.Enum -> mapOf("kind" to "enum", "allowedValues" to allowedValues)
  97 + FieldType.Json -> mapOf("kind" to "json")
  98 + }
  99 +
48 100 private fun readPayloads(table: String): List<Map<String, Any?>> {
49 101 return jdbc.query(
50 102 "SELECT source, payload FROM $table ORDER BY source",
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt
... ... @@ -31,6 +31,7 @@ data class MetadataYamlFile(
31 31 val entities: List<EntityYaml> = emptyList(),
32 32 val permissions: List<PermissionYaml> = emptyList(),
33 33 val menus: List<MenuYaml> = emptyList(),
  34 + val customFields: List<CustomFieldYaml> = emptyList(),
34 35 )
35 36  
36 37 /**
... ... @@ -117,3 +118,89 @@ data class MenuYaml(
117 118 val section: String = "Other",
118 119 val order: Int = 1000,
119 120 )
  121 +
  122 +/**
  123 + * A custom field bolted onto an existing entity.
  124 + *
  125 + * Custom fields are how the framework satisfies guardrail #1 ("core
  126 + * stays domain-agnostic") and the Tier 1 customization story: a
  127 + * printing shop that needs a `credit_limit` column on Partner does
  128 + * not patch `pbc-partners`. They declare a custom field — through the
  129 + * SPA editor (Tier 1) or through a plug-in YAML (Tier 2) — and the
  130 + * value lands in the JSONB `ext` column on the host entity's table the
  131 + * same instant. The framework's [ExtJsonValidator] enforces the
  132 + * declared types on every save; the SPA's form builder, the OpenAPI
  133 + * generator, the AI agent function catalog, and the future PII/DSAR
  134 + * pipeline all read from these declarations.
  135 + *
  136 + * Why YAML mirrors `org.vibeerp.api.v1.entity.CustomField` instead of
  137 + * being that type directly:
  138 + * - The api.v1 type is the **runtime** view returned by
  139 + * [org.vibeerp.api.v1.entity.EntityRegistry] — it ships with no
  140 + * Jackson annotations because api.v1 must stay free of host
  141 + * dependencies.
  142 + * - This [CustomFieldYaml] is the **wire format** parsed from the
  143 + * file. The loader builds the runtime [CustomField] from it. The
  144 + * duplication is real but each side stays minimal.
  145 + *
  146 + * @property key Stable identifier within [targetEntity]. Convention:
  147 + * `<owning_pbc_or_plugin>_<short_name>` in snake_case (e.g.
  148 + * `partners_credit_limit`, `printingshop_plate_thickness_mm`).
  149 + * The prefix prevents two unrelated declarations from colliding on
  150 + * a generic key like `notes`.
  151 + * @property targetEntity The entity name (matches `EntityYaml.name`)
  152 + * this field attaches to. The validator looks the field up by this
  153 + * name when an entity row is saved.
  154 + * @property type The field type. See [CustomFieldTypeYaml] — the YAML
  155 + * shape closely mirrors `org.vibeerp.api.v1.entity.FieldType` but
  156 + * in a Jackson-friendly form.
  157 + * @property required Whether the value must be present on insert/update.
  158 + * @property pii Whether this field contains personally identifiable
  159 + * information. Drives DSAR exports and erasure jobs (architecture
  160 + * spec section 8 — "Data sovereignty"). When in doubt, mark `true`.
  161 + * @property labelTranslations Locale code → human-readable label.
  162 + * Optional; the SPA's form builder shows the key when no label is
  163 + * provided. Plug-ins SHOULD ship at least the platform default
  164 + * locale here.
  165 + */
  166 +@JsonIgnoreProperties(ignoreUnknown = true)
  167 +data class CustomFieldYaml(
  168 + val key: String,
  169 + val targetEntity: String,
  170 + val type: CustomFieldTypeYaml,
  171 + val required: Boolean = false,
  172 + val pii: Boolean = false,
  173 + val labelTranslations: Map<String, String> = emptyMap(),
  174 +)
  175 +
  176 +/**
  177 + * Wire format for a custom field's type. Each property carries the
  178 + * config a single FieldType variant needs; only the field that
  179 + * matches `kind` is consulted, the rest are ignored.
  180 + *
  181 + * Why a flat shape with a discriminator instead of a Jackson polymorphic
  182 + * type hierarchy:
  183 + * - YAML files are written by hand by plug-in authors and key users.
  184 + * A flat `kind: enum` + `allowedValues: [...]` block is far more
  185 + * readable than `type: { type: enum, allowedValues: [...] }`.
  186 + * - Jackson's polymorphic deserialization needs `@JsonTypeInfo`
  187 + * annotations on every variant, which would force api.v1's
  188 + * [org.vibeerp.api.v1.entity.FieldType] to import Jackson —
  189 + * forbidden by guardrail #10 ("api.v1 is the only stable
  190 + * contract").
  191 + *
  192 + * Supported `kind` values match the closed set in
  193 + * [org.vibeerp.api.v1.entity.FieldType]: `string`, `integer`,
  194 + * `decimal`, `boolean`, `date`, `date_time`, `uuid`, `money`,
  195 + * `quantity`, `reference`, `enum`, `json`. Unknown kinds are rejected
  196 + * at load time.
  197 + */
  198 +@JsonIgnoreProperties(ignoreUnknown = true)
  199 +data class CustomFieldTypeYaml(
  200 + val kind: String,
  201 + val maxLength: Int? = null, // string
  202 + val precision: Int? = null, // decimal
  203 + val scale: Int? = null, // decimal
  204 + val targetEntity: String? = null, // reference
  205 + val allowedValues: List<String>? = null, // enum
  206 +)
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidatorTest.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.customfield
  2 +
  3 +import assertk.all
  4 +import assertk.assertFailure
  5 +import assertk.assertThat
  6 +import assertk.assertions.contains
  7 +import assertk.assertions.hasSize
  8 +import assertk.assertions.isEmpty
  9 +import assertk.assertions.isEqualTo
  10 +import assertk.assertions.isInstanceOf
  11 +import assertk.assertions.messageContains
  12 +import io.mockk.every
  13 +import io.mockk.mockk
  14 +import org.junit.jupiter.api.BeforeEach
  15 +import org.junit.jupiter.api.Test
  16 +import org.vibeerp.api.v1.entity.CustomField
  17 +import org.vibeerp.api.v1.entity.FieldType
  18 +
  19 +class ExtJsonValidatorTest {
  20 +
  21 + private lateinit var registry: CustomFieldRegistry
  22 + private lateinit var validator: ExtJsonValidator
  23 +
  24 + @BeforeEach
  25 + fun setUp() {
  26 + registry = mockk()
  27 + validator = ExtJsonValidator(registry)
  28 + }
  29 +
  30 + private fun stub(vararg fields: CustomField) {
  31 + every { registry.forEntity(ENTITY) } returns fields.toList()
  32 + }
  33 +
  34 + @Test
  35 + fun `entity with no declared custom fields rejects any non-empty ext`() {
  36 + stub() // empty
  37 + assertThat(validator.validate(ENTITY, null)).isEmpty()
  38 + assertThat(validator.validate(ENTITY, emptyMap())).isEmpty()
  39 +
  40 + val failure = assertFailure { validator.validate(ENTITY, mapOf("anything" to 1)) }
  41 + failure.isInstanceOf(IllegalArgumentException::class)
  42 + failure.messageContains("ext must be empty")
  43 + }
  44 +
  45 + @Test
  46 + fun `unknown ext key is rejected`() {
  47 + stub(CustomField("known", ENTITY, FieldType.Integer))
  48 +
  49 + val failure = assertFailure {
  50 + validator.validate(ENTITY, mapOf("known" to 1, "rogue" to "x"))
  51 + }
  52 + failure.isInstanceOf(IllegalArgumentException::class)
  53 + failure.messageContains("undeclared key")
  54 + }
  55 +
  56 + @Test
  57 + fun `required missing field is rejected`() {
  58 + stub(CustomField("must_have", ENTITY, FieldType.Integer, required = true))
  59 +
  60 + val failure = assertFailure { validator.validate(ENTITY, emptyMap()) }
  61 + failure.isInstanceOf(IllegalArgumentException::class)
  62 + failure.messageContains("must_have is required")
  63 + }
  64 +
  65 + @Test
  66 + fun `string respects maxLength`() {
  67 + stub(CustomField("nm", ENTITY, FieldType.String(maxLength = 5)))
  68 +
  69 + assertThat(validator.validate(ENTITY, mapOf("nm" to "okay"))).isEqualTo(mapOf("nm" to "okay"))
  70 +
  71 + val failure = assertFailure { validator.validate(ENTITY, mapOf("nm" to "way too long")) }
  72 + failure.isInstanceOf(IllegalArgumentException::class)
  73 + failure.messageContains("maxLength 5")
  74 + }
  75 +
  76 + @Test
  77 + fun `integer accepts Number and numeric String, rejects garbage`() {
  78 + stub(CustomField("n", ENTITY, FieldType.Integer))
  79 +
  80 + assertThat(validator.validate(ENTITY, mapOf("n" to 42))).isEqualTo(mapOf("n" to 42L))
  81 + assertThat(validator.validate(ENTITY, mapOf("n" to "42"))).isEqualTo(mapOf("n" to 42L))
  82 +
  83 + val failure = assertFailure { validator.validate(ENTITY, mapOf("n" to "not a number")) }
  84 + failure.isInstanceOf(IllegalArgumentException::class)
  85 + failure.messageContains("not a valid integer")
  86 + }
  87 +
  88 + @Test
  89 + fun `decimal enforces declared scale`() {
  90 + stub(CustomField("limit", ENTITY, FieldType.Decimal(precision = 10, scale = 2)))
  91 +
  92 + // Two-place decimal accepted, value canonicalised to plain string.
  93 + assertThat(validator.validate(ENTITY, mapOf("limit" to "1234.56")))
  94 + .isEqualTo(mapOf("limit" to "1234.56"))
  95 +
  96 + // Three-place decimal exceeds scale.
  97 + val failure = assertFailure { validator.validate(ENTITY, mapOf("limit" to "1234.567")) }
  98 + failure.isInstanceOf(IllegalArgumentException::class)
  99 + failure.messageContains("scale 3 exceeds")
  100 + }
  101 +
  102 + @Test
  103 + fun `boolean accepts Boolean and 'true'-or-'false' string`() {
  104 + stub(CustomField("flag", ENTITY, FieldType.Boolean))
  105 +
  106 + assertThat(validator.validate(ENTITY, mapOf("flag" to true))).isEqualTo(mapOf("flag" to true))
  107 + assertThat(validator.validate(ENTITY, mapOf("flag" to "false"))).isEqualTo(mapOf("flag" to false))
  108 +
  109 + val failure = assertFailure { validator.validate(ENTITY, mapOf("flag" to "maybe")) }
  110 + failure.isInstanceOf(IllegalArgumentException::class)
  111 + failure.messageContains("not a valid boolean")
  112 + }
  113 +
  114 + @Test
  115 + fun `date accepts ISO-8601 string`() {
  116 + stub(CustomField("d", ENTITY, FieldType.Date))
  117 +
  118 + assertThat(validator.validate(ENTITY, mapOf("d" to "2026-04-08")))
  119 + .isEqualTo(mapOf("d" to "2026-04-08"))
  120 +
  121 + val failure = assertFailure { validator.validate(ENTITY, mapOf("d" to "08/04/2026")) }
  122 + failure.isInstanceOf(IllegalArgumentException::class)
  123 + failure.messageContains("ISO-8601 date")
  124 + }
  125 +
  126 + @Test
  127 + fun `enum accepts allowed value, rejects others`() {
  128 + stub(
  129 + CustomField(
  130 + "kind",
  131 + ENTITY,
  132 + FieldType.Enum(allowedValues = listOf("a", "b", "c")),
  133 + ),
  134 + )
  135 +
  136 + assertThat(validator.validate(ENTITY, mapOf("kind" to "a"))).isEqualTo(mapOf("kind" to "a"))
  137 +
  138 + val failure = assertFailure { validator.validate(ENTITY, mapOf("kind" to "z")) }
  139 + failure.isInstanceOf(IllegalArgumentException::class)
  140 + failure.messageContains("not in allowed set")
  141 + }
  142 +
  143 + @Test
  144 + fun `multiple violations are returned together`() {
  145 + stub(
  146 + CustomField("required_int", ENTITY, FieldType.Integer, required = true),
  147 + CustomField("limited_str", ENTITY, FieldType.String(maxLength = 3)),
  148 + )
  149 +
  150 + val failure = assertFailure {
  151 + validator.validate(
  152 + ENTITY,
  153 + mapOf("limited_str" to "too long", "rogue" to 1),
  154 + )
  155 + }
  156 + failure.isInstanceOf(IllegalArgumentException::class)
  157 + failure.all {
  158 + messageContains("undeclared key")
  159 + messageContains("required_int is required")
  160 + messageContains("maxLength 3")
  161 + }
  162 + }
  163 +
  164 + @Test
  165 + fun `null value for non-required field is dropped from canonical map`() {
  166 + stub(CustomField("opt", ENTITY, FieldType.Integer))
  167 +
  168 + // Sending {opt: null} is identical to omitting opt entirely.
  169 + val canonical = validator.validate(ENTITY, mapOf("opt" to null))
  170 + assertThat(canonical.keys).hasSize(0)
  171 + }
  172 +
  173 + companion object {
  174 + const val ENTITY: String = "TestEntity"
  175 + }
  176 +}
... ...
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
... ... @@ -12,6 +12,7 @@ import org.vibeerp.api.v1.i18n.LocaleProvider
12 12 import org.vibeerp.api.v1.plugin.PluginJdbc
13 13 import org.vibeerp.platform.i18n.IcuTranslator
14 14 import org.vibeerp.platform.metadata.MetadataLoader
  15 +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
15 16 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
16 17 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar
17 18 import org.vibeerp.platform.plugins.lint.PluginLinter
... ... @@ -60,6 +61,7 @@ class VibeErpPluginManager(
60 61 private val linter: PluginLinter,
61 62 private val liquibaseRunner: PluginLiquibaseRunner,
62 63 private val metadataLoader: MetadataLoader,
  64 + private val customFieldRegistry: CustomFieldRegistry,
63 65 private val localeProvider: LocaleProvider,
64 66 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
65 67  
... ... @@ -85,6 +87,18 @@ class VibeErpPluginManager(
85 87 )
86 88 }
87 89  
  90 + // Refresh the in-memory custom-field index now so PBC services
  91 + // can validate `ext` columns even if no plug-ins are loaded.
  92 + // The same refresh happens again after each plug-in load below.
  93 + try {
  94 + customFieldRegistry.refresh()
  95 + } catch (ex: Throwable) {
  96 + log.error(
  97 + "VibeErpPluginManager: CustomFieldRegistry initial refresh failed; ext validation will allow nothing",
  98 + ex,
  99 + )
  100 + }
  101 +
88 102 if (!properties.autoLoad) {
89 103 log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan")
90 104 return
... ... @@ -170,6 +184,18 @@ class VibeErpPluginManager(
170 184 }
171 185 }
172 186  
  187 + // Refresh the custom-field index now that every plug-in has
  188 + // contributed its declarations. The single refresh covers all
  189 + // plug-ins at once instead of one refresh per plug-in.
  190 + try {
  191 + customFieldRegistry.refresh()
  192 + } catch (ex: Throwable) {
  193 + log.warn(
  194 + "VibeErpPluginManager: CustomFieldRegistry post-plug-in refresh failed; the in-memory index may be stale",
  195 + ex,
  196 + )
  197 + }
  198 +
173 199 startPlugins()
174 200  
175 201 // PF4J's `startPlugins()` calls each plug-in's PF4J `start()`
... ...