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.