You need to sign in before continuing.

Commit 0090e3af7bf4980055d21d1ddd1475d006fbacfe

Authored by vibe_erp
1 parent 95b26a88

docs: align deeper how-to docs with current code state

The previous docs commit fixed README + CLAUDE + the three "scope note"
guides but explicitly left the deeper how-to docs as-is, claiming the
architecture hadn't changed. That was wrong on closer reading: those
docs still described the pre-single-tenant world (tenant_id columns,
two-wall isolation, per-tenant routing) and the pre-implementation
plug-in API (class-based @PluginEndpoint controllers, ResponseBuilder,
context.log instead of context.logger, plugin.yml entryClass field).
A reader following any of those guides would write code that doesn't
compile.

This commit aligns them with what the framework actually does today.

What changed:

* docs/architecture/overview.md
  - Guardrail #5 rewritten from "Multi-tenant in spirit from day one"
    to "Single-tenant per instance, isolated database" matching
    CLAUDE.md.
  - The api.v1 package layout updated to match the actual current
    surface: dropped `Tenant`, added `PrincipalId`,
    `PersistenceExceptions`, the full plug-in package
    (PluginContext, PluginEndpointRegistrar, PluginRequest/Response,
    PluginJdbc, PluginRow), and the two live cross-PBC facades
    (IdentityApi, CatalogApi).
  - The whole "Multi-tenancy" section replaced with
    "Single-tenant per instance" — drops the two-wall framing,
    drops per-region routing, drops tenant_id columns and RLS.
    The single-tenant rationale is explained in line with the
    rest of the framework.
  - Custom fields and metadata store sections drop "per tenant"
    and tenant_id from their column lists.
  - Audit row in the cross-cutting table drops tenant_id from
    the column list and adds the PrincipalContext bridge note
    that's actually live as of P4.1.
  - "Tier 1 — Key user, no-code" intro drops "scoped to their tenant".

* docs/plugin-api/overview.md
  - api.v1 package layout updated to match the real current surface
    (same set of additions and deletions as the architecture
    overview).
  - Per-package orientation table rewritten to describe what is
    actually in each package today, including which parts are
    live (event/, plugin/, http/) and which are stubbed (i18n/,
    workflow/, form/) with the relevant implementation-plan unit
    referenced.

* docs/plugin-author/getting-started.md — full rewrite. The previous
  version walked an author through APIs that don't exist:
    • `Plugin.start(context: PluginContext)` and
      `Plugin.stop(context: PluginContext)` — actual API is
      `start(context)` and `stop()` (no arg), with the Pf4jPlugin +
      VibeErpPlugin dual-supertype trick using import aliases.
    • `context.log` — actual is `context.logger` (PluginLogger
      interface).
    • `plugin.yml` with `entryClass:` field and a structured
      `metadata.i18n:` subkey — actual schema has no entryClass
      (PF4J reads `Plugin-Class` from MANIFEST.MF) and `metadata:`
      is a flat list of paths.
    • Class-based `@PluginEndpoint` controllers with `ResponseBuilder`
      — actual is lambda-based registration via
      `context.endpoints.register(method, path, handler)` returning
      `PluginResponse`.
    • `request.translator.format(...)` — translator currently
      throws UnsupportedOperationException; the example would crash.

  The new version walks through:
    1. The compileOnly api.v1 + pf4j dependency setup.
    2. The dual-supertype Pf4jPlugin + VibeErpPlugin trick with
       import aliases (matches the actual reference plug-in).
    3. The real JAR layout (META-INF/MANIFEST.MF for PF4J,
       plugin.yml for human metadata, META-INF/vibe-erp/db/changelog.xml
       for Liquibase, META-INF/vibe-erp/metadata/<id>.yml for the
       metadata loader).
    4. Liquibase changelog at the unique META-INF path (not the
       host's `db/changelog/master.xml` path which would collide
       on parent-first classloader lookup — the bug we hit and
       fixed in commit 1ead32d7).
    5. Lambda-based endpoint registration with real working
       examples (path templates, queryForObject, update, JSON
       body parsing).
    6. Metadata YAML shape matching what MetadataLoader actually
       parses.
    7. The boot-log sequence the operator will see, taken from
       a real smoke run.
    8. An honest "what this guide does not yet cover" list with
       implementation-plan unit references.

* docs/i18n/guide.md
  - Added a "Status" callout at the top: the api.v1 contract is
    locked, the ICU4J implementation is P1.6 and not yet wired,
    `context.translator` currently throws.
  - Bundle filename convention corrected from `<locale>.properties`
    to `messages_<locale>.properties` (matches what the reference
    plug-in actually ships).
  - plugin.yml example updated to show the flat `metadata:` array
    that PluginManifest actually parses (the previous structured
    `metadata.i18n:` subkey doesn't exist in the schema).
  - Merge precedence renamed "tenant overrides" → "operator
    overrides" since vibe_erp is single-tenant per instance.
  - LocaleProvider resolution chain updated to drop "tenant default"
    and reference `vibeerp.i18n.default-locale` configuration.
  - "Time zones are per-tenant" → "per-user with an instance default".
  - Locale-add reference to "i18n-overrides volume" annotated as
    "once P1.6 lands".

* docs/customer-onboarding/guide.md
  - Section 3 "Create a tenant" deleted; replaced with
    "Configure the instance" pointing at vibeerp.instance.* config.
  - First-boot bullet list rewritten to drop the bogus
    "create default tenant row in identity__tenant" step (no such
    table) and add the real metadata-loader and per-plug-in lifecycle
    steps that happen.
  - Mounted-volume table updated: `i18n-overrides/` is "operator
    translation overrides", not "tenant-level".
  - "Bind users to roles, bind roles to tenants" simplified;
    OIDC group claims annotated as P4.2 (not yet wired).
  - Go-live checklist drops "non-production tenant" wording and
    "tenant locale defaults" (now instance-level config).
  - "Hosted multi-tenant operations" deferred-list item rewritten
    to "hosted operations: provisioning many independent vibe_erp
    instances", matching the new single-tenant deployment model.

* docs/form-authoring/guide.md
  - Tier 1 section drops "scoped to the tenant",
    "tweaking for a specific tenant", "tenant-specific custom field".

* docs/workflow-authoring/guide.md
  - "Approval chains that vary per tenant" → "Approval chains the
    customer wants to author themselves".

* docs/index.md
  - Architecture overview row description updated from
    "multi-tenancy" to "single-tenant deployment model".
  - i18n guide row description updated from "tenant overrides"
    to "operator overrides".

Build: ./gradlew build still green (no source touched).

The remaining `tenant` references in docs are now ALL intentional —
they appear in sentences like "vibe_erp is single-tenant per instance",
"there is no create-a-tenant step", "no per-tenant bundles". The
historical specs under docs/superpowers/specs/ were intentionally
NOT touched: those are dated design records, and the implementation
plan already had the single-tenant refactor noted at the top.
docs/architecture/overview.md
... ... @@ -16,7 +16,7 @@ vibe_erp adopts SAP S/4HANA&#39;s **Clean Core** vocabulary: **extensions never modi
16 16 2. **Workflows are data, not code.** State machines, approval chains, and form definitions are declarative, so a new customer is onboarded by editing definitions, not source.
17 17 3. **Extensibility seams come first.** Before any feature is added, the extension point it hangs off of must exist. If no seam exists, the seam is designed first.
18 18 4. **The reference customer is a test, not a requirement.** Anything implemented for the reference plug-in must be expressible by a different printing shop with different rules, without code changes.
19   -5. **Multi-tenant in spirit from day one.** Even single-tenant deployments use the same multi-tenant code path.
  19 +5. **Single-tenant per instance, isolated database.** vibe_erp deliberately does NOT support multiple companies in one process. Each running instance serves exactly one company against an isolated Postgres database. Hosting many customers means provisioning many independent instances, not multiplexing them. There are no `tenant_id` columns, no row-level filters, no Postgres Row-Level Security policies anywhere in the framework. Customer isolation is a deployment concern, not a code concern.
20 20 6. **Global / i18n from day one.** No hard-coded user-facing strings, currencies, date formats, time zones, address shapes, or tax models.
21 21  
22 22 ## Two-tier extensibility
... ... @@ -25,7 +25,7 @@ vibe_erp offers **two extension paths**, both first-class. The same outcome can
25 25  
26 26 ### Tier 1 — Key user, no-code
27 27  
28   -Business analysts customize the system through the web UI. Everything they create is stored as **rows in the metadata tables**, scoped to their tenant, tagged `source = 'user'`, and preserved across plug-in install/uninstall and core upgrades.
  28 +Business analysts customize the system through the web UI. Everything they create is stored as **rows in the metadata tables**, tagged `source = 'user'`, and preserved across plug-in install/uninstall and core upgrades.
29 29  
30 30 | Capability | Stored in |
31 31 |---|---|
... ... @@ -120,18 +120,20 @@ Package layout:
120 120  
121 121 ```
122 122 org.vibeerp.api.v1
123   -├── core/ Tenant, Locale, Money, Quantity, Id<T>, Result<T,E>
124   -├── entity/ Entity, Field, FieldType, EntityRegistry
125   -├── persistence/ Repository<T>, Query, Page, Transaction
126   -├── workflow/ WorkflowTask, WorkflowEvent, TaskHandler
127   -├── form/ FormSchema, UiSchema
128   -├── http/ @PluginEndpoint, RequestContext, ResponseBuilder
129   -├── event/ DomainEvent, EventListener, EventBus
130   -├── security/ Principal, Permission, PermissionCheck
  123 +├── core/ Id<T>, Money, Currency, Quantity, UnitOfMeasure, Result<T,E>
  124 +├── entity/ Entity, AuditedEntity, FieldType, CustomField, EntityRegistry
  125 +├── persistence/ Repository<T>, Query, Page, Transaction, PersistenceExceptions
  126 +├── workflow/ WorkflowTask, TaskHandler, TaskContext
  127 +├── form/ FormSchema
  128 +├── http/ @PluginEndpoint, RequestContext
  129 +├── event/ DomainEvent, EventListener, EventBus (with Subscription)
  130 +├── security/ Principal, PrincipalId, Permission, PermissionCheck
131 131 ├── i18n/ MessageKey, Translator, LocaleProvider
132   -├── reporting/ ReportTemplate, ReportContext
133   -├── plugin/ Plugin, PluginManifest, ExtensionPoint
134   -└── ext/ Typed extension interfaces a plug-in implements
  132 +├── plugin/ Plugin, PluginContext, PluginManifest, ExtensionPoint,
  133 +│ PluginEndpointRegistrar, HttpMethod, PluginRequest,
  134 +│ PluginResponse, PluginEndpointHandler, PluginJdbc, PluginRow
  135 +└── ext/ Typed cross-PBC facades (ext.identity.IdentityApi,
  136 + ext.catalog.CatalogApi)
135 137 ```
136 138  
137 139 `api.v1` is published as `api-v1.jar` to **Maven Central**, so plug-in authors can build against it without pulling the entire vibe_erp source tree.
... ... @@ -185,41 +187,35 @@ One Spring Boot process per instance, one Docker image per release, one mounted
185 187  
186 188 The only mandatory external dependency is **PostgreSQL**. Optional sidecars for larger deployments — Keycloak, Redis, OpenSearch, SMTP relay — are off by default.
187 189  
188   -## Multi-tenancy
  190 +## Single-tenant per instance
189 191  
190   -vibe_erp is **multi-tenant in spirit from day one**, even when deployed for a single customer. The same code path serves self-hosted single-tenant and hosted multi-tenant deployments.
  192 +vibe_erp is **deliberately single-tenant per instance**. One running process serves exactly one company against an isolated Postgres database. Hosting many customers means provisioning many independent instances, each with its own DB. There are no `tenant_id` columns, no row-level filters, no Postgres Row-Level Security policies anywhere in the framework. Customer isolation is a deployment concern, not a code concern.
  193 +
  194 +This is the choice in CLAUDE.md guardrail #5. The rationale: most ERP/EBC customers will not accept a SaaS where their data shares a database with other companies, and per-instance isolation is dramatically simpler than per-row tenant filtering. The cost is that hosting many customers requires real provisioning infrastructure (one container + one DB per customer); the benefit is that the framework itself stays simple, the audit story is trivial, and GDPR data residency is automatic — the customer chose where Postgres lives.
191 195  
192 196 ### Schema namespacing
193 197  
194 198 PBCs and plug-ins use **table name prefixes**, not Postgres schemas:
195 199  
196 200 ```
197   -identity__user, identity__role
198   -catalog__item, catalog__item_attribute
199   -inventory__stock_item, inventory__movement
200   -orders_sales__order, orders_sales__order_line
201   -production__work_order, production__operation
202   -plugin_printingshop__plate_spec (reference plug-in)
203   -metadata__custom_field, metadata__form, metadata__workflow
204   -flowable_* (Flowable's own tables, untouched)
  201 +identity__user, identity__role, identity__user_credential
  202 +catalog__item, catalog__uom
  203 +inventory__stock_item, inventory__movement (future PBC)
  204 +orders_sales__order, orders_sales__order_line (future PBC)
  205 +plugin_printingshop__plate, plugin_printingshop__ink_recipe (reference plug-in)
  206 +metadata__entity, metadata__permission, metadata__menu, metadata__custom_field, ...
  207 +platform__event_outbox
  208 +flowable_* (Flowable's own tables, untouched, when wired)
205 209 ```
206 210  
207   -This keeps Hibernate, RLS policies, and migrations all in one logical schema (`public`), avoids `search_path` traps, and gives clean uninstall semantics.
208   -
209   -### Tenant isolation — two independent walls
210   -
211   -- Every business table has `tenant_id`, NOT NULL.
212   -- Hibernate `@TenantId` filters every query at the application layer.
213   -- Postgres Row-Level Security policies filter every query at the database layer.
214   -
215   -Two independent walls. A bug in one is not a data leak. Self-hosted single-customer deployments use one tenant row called `default`. Hosted multi-tenant deployments use many tenant rows. **Same code path.**
  211 +This keeps Hibernate and migrations all in one logical schema (`public`), avoids `search_path` traps, and gives clean plug-in uninstall semantics: drop everything matching `plugin_<id>__*`.
216 212  
217 213 ### Data sovereignty
218 214  
219   -- Self-hosted is automatically compliant — the customer chose where Postgres lives.
220   -- Hosted supports per-region tenant routing: each tenant row carries a region; connections are routed to the right regional Postgres cluster.
221   -- PII tagging on field metadata drives auto-generated DSAR exports and erasure jobs (GDPR Articles 15/17).
222   -- Append-only audit log records access to PII fields when audit-strict mode is on.
  215 +- **Self-hosted is automatically compliant** — the customer chose where Postgres lives.
  216 +- **Hosted** is a separate "operator console" concern (post-v1.0): provision a fresh vibe_erp instance + dedicated Postgres per customer. The framework code is unchanged.
  217 +- **PII tagging** on field metadata drives auto-generated DSAR exports and erasure jobs (GDPR Articles 15/17). The metadata seeder ships the tag; the eraser is future work (P3.x).
  218 +- **Append-only audit log** records the principal and timestamp of every save via the JPA listener.
223 219  
224 220 ## Custom fields via JSONB
225 221  
... ... @@ -230,7 +226,7 @@ ext jsonb not null default &#39;{}&#39;,
230 226 ext_meta text generated
231 227 ```
232 228  
233   -Custom fields are JSON keys inside `ext`. A GIN index on `ext` makes them queryable. The `metadata__custom_field` table describes the JSON shape per entity per tenant. The form designer, list views, OpenAPI generator, and AI-agent function catalog all read from this table.
  229 +Custom fields are JSON keys inside `ext`. A GIN index on `ext` makes them queryable. The `metadata__custom_field` table describes the JSON shape per entity. The form designer, list views, OpenAPI generator, and AI-agent function catalog all read from this table.
234 230  
235 231 Why JSONB and not EAV: one row, one read, indexable, no migrations needed for additions, no joins. EAV is the wrong tool.
236 232  
... ... @@ -245,7 +241,7 @@ metadata__workflow metadata__rule metadata__menu
245 241 metadata__report metadata__translation metadata__plugin_config
246 242 ```
247 243  
248   -Every row carries `tenant_id`, `source` (`core` / `plugin:<id>` / `user`), `version`, `is_active`. The `source` column makes uninstall and upgrade safe: removing a plug-in cleans up its metadata, and user-created metadata is sacred and never touched by an upgrade.
  244 +Every row carries `source` (`core` / `plugin:<id>` / `user`) plus timestamps. The `source` column makes uninstall and upgrade safe: removing a plug-in cleans up its metadata via a single `DELETE WHERE source = 'plugin:<id>'`, and user-created metadata is sacred and never touched by core or plug-in upgrades. The current metadata loader (P1.5) implements `loadCore()` and `loadFromPluginJar(pluginId, jarPath)` and exposes the seeded data at the public `GET /api/v1/_meta/metadata` endpoint.
249 245  
250 246 Every consumer reads from this store: the form renderer, list views, OpenAPI generator, AI-agent function catalog, role editor. There are no parallel sources of truth.
251 247  
... ... @@ -269,7 +265,7 @@ The temptation to invent a vibe_erp-only workflow language must be rejected. BPM
269 265 |---|---|
270 266 | Security | `PermissionCheck` declared in `api.v1.security`; plug-ins register their own permissions, auto-listed in the role editor |
271 267 | Transactions | Spring `@Transactional` at the application-service layer; plug-ins use `api.v1.persistence.Transaction`, never Spring directly |
272   -| Audit | `created_at`, `created_by`, `updated_at`, `updated_by`, `tenant_id` on every entity, applied by a JPA listener |
  268 +| Audit | `created_at`, `created_by`, `updated_at`, `updated_by`, `version` on every entity, applied by a JPA listener that reads `created_by` from `PrincipalContext` (bound by the JWT auth filter) |
273 269 | Events | Typed `DomainEvent`s on every state change; in-process bus by default; outbox table in Postgres for cross-crash reliability and as the seam where Kafka/NATS plugs in later |
274 270 | AI-agent surface | Same business operations exposed through REST are exposable through an MCP server; v1.1 ships the MCP endpoint, v1.0 architects the seam |
275 271 | Reporting | JasperReports; templates shipped by core or by plug-ins, customer-skinnable |
... ...
docs/customer-onboarding/guide.md
... ... @@ -27,10 +27,11 @@ docker run -d --name vibe-erp \
27 27 What happens on first boot:
28 28  
29 29 1. The host connects to Postgres.
30   -2. Liquibase runs every core PBC's migrations and creates the `flowable_*` tables.
31   -3. A `default` tenant row is created in `identity__tenant`.
32   -4. A bootstrap admin user is created and its one-time password is printed to the boot log.
33   -5. The host is ready in under 30 seconds.
  30 +2. Liquibase runs every core PBC's migrations (plus `flowable_*` once that lands).
  31 +3. A bootstrap admin user is created and its one-time password is printed to the boot log.
  32 +4. The metadata loader walks the host classpath and seeds `metadata__entity` / `metadata__permission` / `metadata__menu` from each PBC's YAML file.
  33 +5. Each plug-in JAR is linted, migrated, metadata-loaded, and started in turn.
  34 +6. The host is ready in under 30 seconds.
34 35  
35 36 The mounted volume `/srv/vibeerp` (mapped to `/opt/vibe-erp` inside the container) holds:
36 37  
... ... @@ -38,32 +39,24 @@ The mounted volume `/srv/vibeerp` (mapped to `/opt/vibe-erp` inside the containe
38 39 /opt/vibe-erp/
39 40 ├── config/vibe-erp.yaml single config file (closed key set)
40 41 ├── plugins/ drop *.jar to install
41   -├── i18n-overrides/ tenant-level translation overrides
  42 +├── i18n-overrides/ operator translation overrides
42 43 ├── files/ file store (if not using S3)
43 44 └── logs/
44 45 ```
45 46  
46   -Customer extensions live entirely outside the image. Upgrading the host is `docker rm` plus `docker run` with the new image tag — extensions and config stay put.
  47 +Customer extensions live entirely outside the image. Upgrading the host is `docker rm` plus `docker run` with the new image tag — extensions and config stay put. **vibe_erp is single-tenant per instance: this container serves exactly one company. Hosting a second customer means provisioning a second container with its own Postgres.**
47 48  
48 49 ## 2. Log in as the bootstrap admin
49 50  
50   -Open `http://<host>:8080/`, log in with `admin` and the one-time password from the boot log, and change the password immediately. Configure OIDC (Keycloak-compatible) at this point if the customer has an existing identity provider — built-in JWT auth and OIDC SSO are both shipped from day one.
  51 +Open `http://<host>:8080/`, log in with `admin` and the one-time password from the boot log, and change the password immediately. Configure OIDC (Keycloak-compatible) at this point if the customer has an existing identity provider — built-in JWT auth is live today; OIDC is P4.2 in the implementation plan and not yet wired.
51 52  
52   -## 3. Create a tenant (hosted only)
  53 +## 3. Configure the instance
53 54  
54   -For a self-hosted single-customer deployment, the `default` tenant is everything you need. Skip this step.
55   -
56   -For a hosted multi-tenant deployment, create one tenant row per customer. Each tenant carries:
57   -
58   -- A unique tenant id.
59   -- A region (used by the per-region routing layer in hosted mode).
60   -- A default locale, currency, and time zone.
61   -
62   -Tenant onboarding is an `INSERT` plus seed metadata, not a migration — sub-second per tenant.
  55 +Set the company name, default locale, default currency, and default time zone in `vibe-erp.yaml` under `vibeerp.instance.*`. There is no "create a tenant" step — this entire instance belongs to one company.
63 56  
64 57 ## 4. Use the Customize UI to model the customer's reality
65 58  
66   -This is where most of the integrator's time is spent. Everything here is **Tier 1**: rows in `metadata__*` tables, tagged `source = 'user'`, scoped to the tenant. No build, no restart, no deploy. **(current: API only — POST to the metadata endpoints; the UIs ship in v1.0.)**
  59 +This is where most of the integrator's time is spent. Everything here is **Tier 1**: rows in `metadata__*` tables, tagged `source = 'user'`. No build, no restart, no deploy. **(current: API only — POST to the metadata endpoints; the UIs ship in v1.0.)**
67 60  
68 61 ### Custom fields
69 62  
... ... @@ -101,7 +94,7 @@ Define the customer&#39;s roles (e.g. *sales clerk*, *production planner*, *warehous
101 94 - Plug-ins (each plug-in registers its own permissions through `api.v1.security.PermissionCheck`).
102 95 - Custom entities (each generates a standard CRUD permission set).
103 96  
104   -Bind users to roles, bind roles to tenants. Hosted deployments can also bind roles via OIDC group claims.
  97 +Bind users to roles. Once OIDC (P4.2) lands, roles can also be bound via the identity provider's group claims.
105 98  
106 99 ## 7. Add Tier 2 plug-ins as needed
107 100  
... ... @@ -122,14 +115,14 @@ A guiding principle: **anything a Tier 2 plug-in does should also become possibl
122 115  
123 116 - [ ] Backups configured against the Postgres instance. Customer data lives there exclusively (plus the file store).
124 117 - [ ] Audit log enabled for PII fields if the customer is subject to GDPR or equivalent.
125   -- [ ] DSAR export and erasure jobs tested against a non-production tenant.
126   -- [ ] Locale, currency, time zone defaults set for the tenant.
127   -- [ ] OIDC integration tested with the customer's identity provider.
  118 +- [ ] DSAR export and erasure jobs tested against a non-production copy.
  119 +- [ ] Locale, currency, time zone defaults set in `vibe-erp.yaml`.
  120 +- [ ] OIDC integration tested with the customer's identity provider (once P4.2 lands).
128 121 - [ ] Health check (`/actuator/health`) wired into the customer's monitoring.
129 122 - [ ] Documented upgrade procedure for the operator: `docker rm` plus `docker run` with the new image tag, plug-ins and config stay put.
130 123  
131 124 ## What this guide deliberately does not cover
132 125  
133 126 - **Plug-in development.** That is the [plug-in author getting-started guide](../plugin-author/getting-started.md).
134   -- **Hosted multi-tenant operations** (per-region routing, billing, tenant provisioning UI). Hosted mode is a v2 deliverable; the data model and routing layer are architected for it from day one but the operations UI is not in v1.0.
135   -- **MCP / AI-agent endpoint.** v1.1 deliverable. The seam exists in v1.0; the endpoint does not.
  127 +- **Hosted operations** (provisioning many independent vibe_erp instances, one per customer; billing; an operator console). vibe_erp is single-tenant per instance — hosting many customers means running many independent containers with their own databases. The provisioning console is a post-v1.0 deliverable.
  128 +- **MCP / AI-agent endpoint.** v1.1 deliverable. The seam exists today; the endpoint does not.
... ...
docs/form-authoring/guide.md
... ... @@ -55,12 +55,12 @@ Both paths are first-class. The renderer does not know which one produced a give
55 55  
56 56 ### Tier 1 — Form designer in the web UI (v1.0)
57 57  
58   -Business analysts build forms in the form designer that ships in the web SPA. The result is stored as a row in `metadata__form`, scoped to the tenant, tagged `source = 'user'` so it survives plug-in install/uninstall and core upgrades. No build, no restart.
  58 +Business analysts build forms in the form designer that ships in the web SPA. The result is stored as a row in `metadata__form`, tagged `source = 'user'` so it survives plug-in install/uninstall and core upgrades. No build, no restart.
59 59  
60 60 This path is the right one for:
61 61  
62   -- Tweaking the layout of an existing entity for a specific tenant.
63   -- Adding a tenant-specific custom field to an existing form.
  62 +- Tweaking the layout of an existing entity for the customer's specific needs.
  63 +- Adding a custom field to an existing form.
64 64 - Building a form for a Tier 1 custom entity.
65 65  
66 66 The form designer is a **v1.0 deliverable**.
... ...
docs/i18n/guide.md
... ... @@ -2,6 +2,14 @@
2 2  
3 3 vibe_erp is built to be sold worldwide. There are no hard-coded user-facing strings, currencies, date formats, time zones, number formats, address shapes, or tax models in the framework — everything user-facing flows through the i18n and formatting layers from day one. This is guardrail #6 in `CLAUDE.md`, and it applies to the core, every PBC, and every plug-in.
4 4  
  5 +> **Status (current build).** The api.v1 contract for i18n (`Translator`,
  6 +> `LocaleProvider`, `MessageKey`) is locked in and present. The host
  7 +> implementation backed by ICU4J is **P1.6** in the implementation plan
  8 +> and is **not yet wired** — `context.translator` currently throws
  9 +> `UnsupportedOperationException`. This guide describes the design that
  10 +> P1.6 will deliver. See [`../../PROGRESS.md`](../../PROGRESS.md) for
  11 +> the live status of every feature.
  12 +
5 13 For the architectural placement of i18n, see [`../architecture/overview.md`](../architecture/overview.md) and the full spec at [`../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md`](../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md).
6 14  
7 15 ## ICU MessageFormat is the backbone
... ... @@ -23,25 +31,24 @@ The same key produces correct output in English, German, Japanese, Chinese, and
23 31  
24 32 ## Plug-ins ship message bundles
25 33  
26   -Each plug-in (and each PBC) ships its message bundles inside its JAR under `i18n/<locale>.properties`. The locale tag follows BCP 47:
  34 +Each plug-in (and each PBC) ships its message bundles inside its JAR under `i18n/messages_<locale>.properties`. The locale tag follows BCP 47:
27 35  
28 36 ```
29 37 src/main/resources/
30 38 └── i18n/
31   - ├── en-US.properties
32   - ├── zh-CN.properties
33   - ├── de-DE.properties
34   - ├── ja-JP.properties
35   - └── es-ES.properties
  39 + ├── messages_en-US.properties
  40 + ├── messages_zh-CN.properties
  41 + ├── messages_de-DE.properties
  42 + ├── messages_ja-JP.properties
  43 + └── messages_es-ES.properties
36 44 ```
37 45  
38   -The `plugin.yml` manifest lists the bundles so the loader knows to pick them up:
  46 +The `plugin.yml` manifest lists the bundle paths under its flat `metadata:` array so the loader knows to pick them up:
39 47  
40 48 ```yaml
41 49 metadata:
42   - i18n:
43   - - i18n/en-US.properties
44   - - i18n/de-DE.properties
  50 + - i18n/messages_en-US.properties
  51 + - i18n/messages_zh-CN.properties
45 52 ```
46 53  
47 54 ## How the host merges bundles
... ... @@ -49,16 +56,16 @@ metadata:
49 56 On boot, and on every plug-in install, the host builds a merged `MessageSource` per locale. The merge order, from lowest precedence to highest:
50 57  
51 58 1. **Core bundles** shipped inside the vibe_erp image.
52   -2. **Plug-in bundles** picked up from `./plugins/*.jar` in plug-in load order. A plug-in may override a core key with the same key in its own bundle, but is not encouraged to.
53   -3. **Tenant overrides** stored in `metadata__translation` rows tagged with the tenant id. These are entered through the Tier 1 customization UI and let an integrator rewrite any string for any tenant without rebuilding anything.
  59 +2. **Plug-in bundles** picked up from each plug-in's JAR in plug-in load order. A plug-in may override a core key with the same key in its own bundle, but is not encouraged to.
  60 +3. **Operator overrides** stored as `metadata__translation` rows tagged `source = 'user'`. These will be entered through the Tier 1 customization UI (P3.x) and let an integrator rewrite any string for the instance without rebuilding anything.
54 61  
55 62 ```
56   -tenant overrides ← highest precedence
  63 +operator overrides ← highest precedence
57 64 plug-in bundles
58   - core bundles ← lowest precedence
  65 + core bundles ← lowest precedence
59 66 ```
60 67  
61   -The merged `MessageSource` is per-locale and per-tenant. Switching the user's locale or switching tenants picks the right merged bundle without restarting anything.
  68 +The merged `MessageSource` is per-locale. Switching the user's locale picks the right merged bundle without restarting anything. (vibe_erp is single-tenant per instance, so there are no per-tenant bundles — every user of the same instance shares the same merge result for a given locale.)
62 69  
63 70 ## Shipping locales for v1.0
64 71  
... ... @@ -72,7 +79,7 @@ vibe_erp v1.0 ships with five locales:
72 79 | `ja-JP` | Japanese |
73 80 | `es-ES` | Spanish (Spain) |
74 81  
75   -Adding a sixth locale is a Tier 1 operation: drop a `<locale>.properties` file into `i18n-overrides/` on the mounted volume, or add `metadata__translation` rows through the customization UI. No rebuild.
  82 +Adding a sixth locale (once P1.6 lands) is a Tier 1 operation: drop a `<locale>.properties` file into `i18n-overrides/` on the mounted volume, or add `metadata__translation` rows through the customization UI. No rebuild.
76 83  
77 84 The reference business documentation under `raw/业务流程设计文档/` is in Chinese. **That does not make Chinese the default.** Chinese is one supported locale among the five.
78 85  
... ... @@ -116,7 +123,7 @@ What this means in practice:
116 123 - A reviewer who sees a `String.format` against a hard-coded format string rejects the PR.
117 124 - A reviewer who sees `if (locale == "en") "..." else "..."` rejects the PR with prejudice.
118 125  
119   -The `Translator` is locale-aware: it picks up the request's resolved locale from the `LocaleProvider`, which in turn resolves from (in order) the user's profile preference, the request's `Accept-Language`, the tenant default, and the system default.
  126 +The `Translator` is locale-aware: it picks up the request's resolved locale from the `LocaleProvider`, which in turn resolves from (in order) the user's profile preference, the request's `Accept-Language`, and the instance default configured in `vibeerp.i18n.default-locale`.
120 127  
121 128 ## PII, dates, numbers, currency
122 129  
... ... @@ -125,7 +132,7 @@ Locale handling does not stop at strings:
125 132 - **Dates and times** are formatted through the locale, not through hard-coded patterns. The host exposes locale-aware formatters; plug-ins use them.
126 133 - **Numbers** use the locale's grouping and decimal separators.
127 134 - **Currency** values are `Money` (from `api.v1.core`) — an amount plus an ISO 4217 code — and the formatter renders them in the user's locale (e.g. `$1,234.56` vs. `1.234,56 €` vs. `¥1,235`).
128   -- **Time zones** are per-tenant and per-user, not per-server.
  135 +- **Time zones** are per-user (with an instance default configured in `vibeerp.instance.*`), not per-server.
129 136 - **PII fields** are tagged in the field metadata. Tagged fields drive auto-generated DSAR exports and erasure jobs (GDPR Articles 15/17), and the audit log records access to them when audit-strict mode is on.
130 137 - **Address shapes** are not assumed. There is no "state/province" or "ZIP code" hard-coded in the core; address fields are configurable via metadata so a Japanese address can have prefecture/city/ward and a French address can have arrondissement.
131 138  
... ...
docs/index.md
... ... @@ -6,10 +6,10 @@ vibe_erp is an ERP/EBC framework for the printing industry, designed so customer
6 6  
7 7 | Guide | What it covers |
8 8 |---|---|
9   -| [Architecture overview](architecture/overview.md) | Clean Core philosophy, two-tier extensibility, PBCs, the dependency rule, multi-tenancy, custom fields, the workflow engine. |
  9 +| [Architecture overview](architecture/overview.md) | Clean Core philosophy, two-tier extensibility, PBCs, the dependency rule, single-tenant deployment model, custom fields, the workflow engine. |
10 10 | [Plug-in API overview](plugin-api/overview.md) | What `api.v1` is, the package layout, the stability contract, the A/B/C/D extension grading. |
11 11 | [Plug-in author getting started](plugin-author/getting-started.md) | A concrete walkthrough for building, packaging, and loading your first vibe_erp plug-in. |
12 12 | [Workflow authoring guide](workflow-authoring/guide.md) | BPMN 2.0 workflows on embedded Flowable: visual designer (Tier 1) and `.bpmn` files in plug-ins (Tier 2). |
13 13 | [Form authoring guide](form-authoring/guide.md) | JSON Schema + UI Schema forms: visual designer (Tier 1) and JSON files in plug-ins (Tier 2). |
14   -| [i18n guide](i18n/guide.md) | ICU MessageFormat, message bundles, locale resolution, tenant overrides, shipping locales. |
  14 +| [i18n guide](i18n/guide.md) | ICU MessageFormat, message bundles, locale resolution, operator overrides, shipping locales. |
15 15 | [Customer onboarding guide](customer-onboarding/guide.md) | Integrator's checklist for standing up a new vibe_erp customer end-to-end. |
... ...
docs/plugin-api/overview.md
... ... @@ -15,34 +15,37 @@ For the architectural reasoning behind `api.v1`, see [`../architecture/overview.
15 15  
16 16 ```
17 17 org.vibeerp.api.v1
18   -├── core/ Tenant, Locale, Money, Quantity, Id<T>, Result<T,E>
19   -├── entity/ Entity, Field, FieldType, EntityRegistry
20   -├── persistence/ Repository<T>, Query, Page, Transaction
21   -├── event/ DomainEvent, EventListener, EventBus
22   -├── security/ Principal, Permission, PermissionCheck
  18 +├── core/ Id<T>, Money, Currency, Quantity, UnitOfMeasure, Result<T,E>
  19 +├── entity/ Entity, AuditedEntity, FieldType, CustomField, EntityRegistry
  20 +├── persistence/ Repository<T>, Query, Page, Transaction, PersistenceExceptions
  21 +├── event/ DomainEvent, EventListener, EventBus (with Subscription)
  22 +├── security/ Principal, PrincipalId, Permission, PermissionCheck
23 23 ├── i18n/ MessageKey, Translator, LocaleProvider
24   -├── http/ @PluginEndpoint, RequestContext, ResponseBuilder
25   -├── plugin/ Plugin, PluginManifest, ExtensionPoint
26   -├── ext/ Typed extension interfaces a plug-in implements
27   -├── workflow/ WorkflowTask, WorkflowEvent, TaskHandler
28   -└── form/ FormSchema, UiSchema
  24 +├── http/ @PluginEndpoint, RequestContext
  25 +├── plugin/ Plugin, PluginContext, PluginManifest, ExtensionPoint,
  26 +│ PluginEndpointRegistrar, HttpMethod, PluginRequest,
  27 +│ PluginResponse, PluginEndpointHandler, PluginJdbc, PluginRow
  28 +├── ext/ Typed cross-PBC facades: ext.identity.IdentityApi,
  29 +│ ext.catalog.CatalogApi
  30 +├── workflow/ WorkflowTask, TaskHandler, TaskContext
  31 +└── form/ FormSchema
29 32 ```
30 33  
31 34 A short orientation:
32 35  
33 36 | Package | What it gives you |
34 37 |---|---|
35   -| `core/` | The primitive value types every plug-in needs: `Tenant`, `Locale`, `Money`, `Quantity`, typed `Id<T>`, `Result<T,E>`. No printing concepts. |
36   -| `entity/` | Declarative entity model. Plug-ins describe entities, fields, and field types through `EntityRegistry`; the platform handles persistence and OpenAPI. |
37   -| `persistence/` | `Repository<T>`, `Query`, `Page`, `Transaction`. Plug-ins never see Hibernate or Spring `@Transactional` directly. |
38   -| `event/` | `DomainEvent`, `EventListener`, `EventBus`. The primary cross-PBC and cross-plug-in communication channel. |
39   -| `security/` | `Principal`, `Permission`, `PermissionCheck`. Plug-ins register their own permissions; the role editor auto-discovers them. |
40   -| `i18n/` | `MessageKey`, `Translator`, `LocaleProvider`. The only sanctioned way for a plug-in to produce user-facing text. |
41   -| `http/` | `@PluginEndpoint` and the request/response abstractions for adding REST endpoints from a plug-in. |
42   -| `plugin/` | `Plugin`, `PluginManifest`, `ExtensionPoint`, and the `@Extension` annotation. The plug-in lifecycle entry points. |
43   -| `ext/` | Typed extension interfaces that PBCs declare and plug-ins implement (e.g. `api.v1.ext.inventory.StockReservationStrategy`). The cross-PBC interaction surface. |
44   -| `workflow/` | `WorkflowTask`, `WorkflowEvent`, `TaskHandler`. The hooks BPMN service tasks call into. |
45   -| `form/` | `FormSchema`, `UiSchema`. JSON Schema and UI Schema as Kotlin types, for plug-ins shipping form definitions. |
  38 +| `core/` | The primitive value types every plug-in needs: typed `Id<T>`, `Money` + `Currency`, `Quantity` + `UnitOfMeasure`, `Result<T,E>`. No printing concepts. |
  39 +| `entity/` | Declarative entity model. `Entity` and `AuditedEntity` markers, `FieldType` sealed hierarchy, `CustomField` and `EntityRegistry` for the metadata-driven custom fields story. |
  40 +| `persistence/` | `Repository<T>`, `Query`, `Page`, `Transaction`, plus the closed `PersistenceException` hierarchy (`OptimisticLockConflictException`, `UniqueConstraintViolationException`, `EntityValidationException`, `EntityNotFoundException`). Plug-ins never see Hibernate, JPA, or Spring `@Transactional` directly. |
  41 +| `event/` | `DomainEvent`, `EventListener`, `EventBus` with `Subscription` (closeable) and topic-string + class-keyed subscribe overloads. The primary cross-PBC communication channel. **Live as of P1.7.** |
  42 +| `security/` | `Principal` (sealed: `User`, `System`, `PluginPrincipal`), `PrincipalId`, `Permission`, `PermissionCheck`. Plug-ins register their own permissions; the role editor auto-discovers them. |
  43 +| `i18n/` | `MessageKey`, `Translator`, `LocaleProvider`. The only sanctioned way for a plug-in to produce user-facing text. (Real ICU4J `Translator` impl is P1.6 — the api.v1 contract is locked, the host implementation throws `UnsupportedOperationException` until then.) |
  44 +| `http/` | `@PluginEndpoint` annotation and `RequestContext` for plug-in HTTP handlers. |
  45 +| `plugin/` | The plug-in lifecycle: `Plugin` interface, `PluginContext` (with live accessors for `logger`, `endpoints`, `eventBus`, `jdbc`), `PluginManifest`, `ExtensionPoint`/`@Extension`, and the endpoint types: `PluginEndpointRegistrar`, `HttpMethod`, `PluginRequest`, `PluginResponse`, `PluginEndpointHandler`, plus the typed-SQL surface `PluginJdbc`/`PluginRow`. |
  46 +| `ext/` | Typed cross-PBC facades. Two are live today: `ext.identity.IdentityApi` (with `UserRef`) and `ext.catalog.CatalogApi` (with `ItemRef`/`UomRef`). Future PBCs will add their own under the same pattern. |
  47 +| `workflow/` | `WorkflowTask`, `TaskHandler`, `TaskContext` (with `tenantId` removed — single-tenant — and `principal()`, `locale()`, `correlationId()` accessors). The hooks BPMN service tasks call into when Flowable lands (P2.1). |
  48 +| `form/` | `FormSchema` — JSON Schema string + UI Schema string + version. Form parsing and rendering happens in the host (server) and the SPA (client). |
46 49  
47 50 ## The stability contract
48 51  
... ...
docs/plugin-author/getting-started.md
1 1 # Plug-in author: getting started
2 2  
3   -This walkthrough is for a developer building their first vibe_erp plug-in. By the end you will have a JAR that drops into `./plugins/`, registers an extension, exposes a REST endpoint, and shows up in the plug-in loader log on the next restart.
  3 +This walkthrough is for a developer building their first vibe_erp plug-in. By the end you will have a JAR that drops into `./plugins/`, owns its own database tables, registers HTTP endpoints, and shows up in the host's boot log.
4 4  
5   -For the conceptual overview of the plug-in API, read [`../plugin-api/overview.md`](../plugin-api/overview.md) first. For the architectural reasoning behind the constraints, see [`../architecture/overview.md`](../architecture/overview.md) and the full spec at [`../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md`](../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md).
  5 +For the conceptual overview of the plug-in API, read [`../plugin-api/overview.md`](../plugin-api/overview.md) first. For the architectural reasoning behind the constraints, see [`../architecture/overview.md`](../architecture/overview.md). The most up-to-date worked example is the **reference printing-shop plug-in** at `reference-customer/plugin-printing-shop/` in this repo — it exercises everything described below.
6 6  
7 7 ## 1. The only dependency you need
8 8  
9   -Add `org.vibeerp:api-v1` from Maven Central. **This is the only vibe_erp dependency a plug-in is allowed to declare.** Importing anything from `org.vibeerp.platform.*` or any PBC's internal package will fail the plug-in linter at install time.
  9 +Add `org.vibeerp:api-v1` and PF4J's `Plugin` base class. **`api-v1` is the only vibe_erp dependency a plug-in is allowed to declare** — the host's `PluginLinter` will scan your JAR's bytecode at install time and reject it if it references anything in `org.vibeerp.platform.*` or `org.vibeerp.pbc.*`.
10 10  
11 11 In Gradle (`build.gradle.kts`):
12 12  
13 13 ```kotlin
14 14 plugins {
15   - kotlin("jvm") version "2.0.0"
  15 + kotlin("jvm") version "1.9.24"
16 16 }
17 17  
18 18 repositories {
... ... @@ -20,185 +20,323 @@ repositories {
20 20 }
21 21  
22 22 dependencies {
  23 + // The only vibe_erp dependency. Use `compileOnly` so it isn't bundled
  24 + // into your JAR — the host provides api-v1 at runtime via parent-first
  25 + // classloading.
23 26 compileOnly("org.vibeerp:api-v1:1.0.0")
24   - // Test against the same artifact at runtime
25   - testImplementation("org.vibeerp:api-v1:1.0.0")
  27 +
  28 + // PF4J's Plugin base class — also `compileOnly`. The host provides it.
  29 + compileOnly("org.pf4j:pf4j:3.12.0")
  30 +}
  31 +
  32 +// PF4J expects manifest entries to identify the plug-in.
  33 +tasks.jar {
  34 + duplicatesStrategy = DuplicatesStrategy.EXCLUDE
  35 + manifest {
  36 + attributes(
  37 + "Plugin-Id" to "hello-print",
  38 + "Plugin-Version" to project.version,
  39 + "Plugin-Provider" to "Example Print Co.",
  40 + "Plugin-Class" to "com.example.helloprint.HelloPrintPlugin",
  41 + "Plugin-Description" to "Hello-print example plug-in",
  42 + "Plugin-Requires" to "1.x",
  43 + )
  44 + }
26 45 }
27 46 ```
28 47  
29   -`compileOnly` is correct: at runtime the plug-in classloader is given `api.v1` from the host. Bundling it inside your plug-in JAR causes classloader confusion.
  48 +`compileOnly` is correct for both deps: at runtime the host's parent-first classloader serves them. Bundling either one inside your JAR causes ClassCastException at the host/plug-in boundary.
  49 +
  50 +## 2. Implement the plug-in entry class
30 51  
31   -## 2. Implement `org.vibeerp.api.v1.plugin.Plugin`
  52 +Your plug-in's main class needs to do two things at once:
32 53  
33   -The plug-in's entry class is the single point where the host hands you a context and asks you to wire yourself up.
  54 +- **Extend `org.pf4j.Plugin`** so PF4J's loader can instantiate it from the `Plugin-Class` manifest entry.
  55 +- **Implement `org.vibeerp.api.v1.plugin.Plugin`** so vibe_erp's lifecycle hook can call `start(context)` after PF4J's no-arg `start()` runs.
  56 +
  57 +The two have the same simple name `Plugin` but live in different packages, so use Kotlin import aliases:
34 58  
35 59 ```kotlin
36 60 package com.example.helloprint
37 61  
38   -import org.vibeerp.api.v1.plugin.Plugin
  62 +import org.pf4j.PluginWrapper
  63 +import org.vibeerp.api.v1.plugin.HttpMethod
39 64 import org.vibeerp.api.v1.plugin.PluginContext
  65 +import org.vibeerp.api.v1.plugin.PluginRequest
  66 +import org.vibeerp.api.v1.plugin.PluginResponse
  67 +import org.pf4j.Plugin as Pf4jPlugin
  68 +import org.vibeerp.api.v1.plugin.Plugin as VibeErpPlugin
  69 +
  70 +class HelloPrintPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpPlugin {
  71 +
  72 + private var context: PluginContext? = null
40 73  
41   -class HelloPrintPlugin : Plugin {
  74 + override fun id(): String = "hello-print"
  75 + override fun version(): String = "0.1.0"
42 76  
43 77 override fun start(context: PluginContext) {
44   - context.log.info("hello-print plug-in starting")
45   - // Register listeners, seed metadata, etc.
  78 + this.context = context
  79 + context.logger.info("hello-print plug-in starting")
  80 +
  81 + // Register HTTP handlers, subscribe to events, etc., here.
46 82 }
47 83  
48   - override fun stop(context: PluginContext) {
49   - context.log.info("hello-print plug-in stopping")
  84 + override fun stop() {
  85 + context?.logger?.info("hello-print plug-in stopping")
  86 + context = null
50 87 }
51 88 }
52 89 ```
53 90  
54   -The host calls `start` after the plug-in's classloader, Spring child context, and Liquibase migrations are ready. Anything the plug-in needs from the host (event bus, translator, repositories, configuration) comes through `PluginContext`.
  91 +The host's lifecycle order is **load → lint → migrate → metadata → start**. By the time your `start(context)` runs:
55 92  
56   -## 3. Write a `plugin.yml` manifest
  93 +- PF4J has loaded your JAR.
  94 +- The plug-in linter has scanned your bytecode for forbidden imports.
  95 +- The host has applied your `META-INF/vibe-erp/db/changelog.xml` against the shared Postgres datasource.
  96 +- The host has loaded your `META-INF/vibe-erp/metadata/*.yml` files.
  97 +- A real `PluginContext` is wired with `logger`, `endpoints`, `eventBus`, and `jdbc` accessors.
57 98  
58   -The manifest sits at the root of the JAR (`src/main/resources/plugin.yml`). It tells the host who you are, what API version you target, and what permissions you need.
  99 +## 3. The plug-in JAR layout
59 100  
60   -```yaml
61   -id: com.example.helloprint
62   -version: 0.1.0
63   -requiresApi: "1.x"
64   -name: Hello Print
65   -entryClass: com.example.helloprint.HelloPrintPlugin
  101 +```
  102 +src/main/
  103 +├── kotlin/
  104 +│ └── com/example/helloprint/
  105 +│ └── HelloPrintPlugin.kt
  106 +└── resources/
  107 + ├── plugin.yml ← human-readable manifest
  108 + └── META-INF/
  109 + └── vibe-erp/
  110 + ├── db/
  111 + │ └── changelog.xml ← Liquibase, applied at start
  112 + └── metadata/
  113 + └── hello-print.yml ← entities, perms, menus
  114 +```
66 115  
67   -description: >
68   - A minimal worked example for the vibe_erp plug-in author guide.
69   - Registers one extension and exposes one REST endpoint.
  116 +Two manifest files:
70 117  
71   -vendor:
72   - name: Example Print Co.
73   - url: https://example.com
  118 +- **`META-INF/MANIFEST.MF`** is what PF4J reads to discover the plug-in (set via Gradle's `tasks.jar.manifest { attributes(...) }`).
  119 +- **`plugin.yml`** is human-readable metadata used by the SPA and the operator console. It's parsed by `org.vibeerp.api.v1.plugin.PluginManifest`.
74 120  
  121 +A minimal `plugin.yml`:
  122 +
  123 +```yaml
  124 +id: hello-print
  125 +version: 0.1.0
  126 +requiresApi: "1.x"
  127 +name:
  128 + en-US: Hello Print
  129 + zh-CN: 你好印刷
75 130 permissions:
76   - - hello_print.greet.read
77   -
78   -metadata:
79   - forms:
80   - - metadata/forms/greeting.json
81   - i18n:
82   - - i18n/en-US.properties
83   - - i18n/de-DE.properties
  131 + - hello.print.greet
  132 +dependencies: []
84 133 ```
85 134  
86   -Field notes:
  135 +`requiresApi: "1.x"` means "any 1.y.z release of api.v1". A mismatch fails the plug-in at install time, never at runtime.
  136 +
  137 +## 4. Own your own database tables
  138 +
  139 +Plug-ins use the same Liquibase that the host uses, applied via `PluginLiquibaseRunner` against the shared host datasource. The host looks for the changelog at exactly `META-INF/vibe-erp/db/changelog.xml`. **The unique `META-INF/vibe-erp/db/` path matters**: if you put your changelog at `db/changelog/master.xml` (the host's path), the parent-first classloader will find both files and Liquibase will throw `ChangeLogParseException` at install time.
  140 +
  141 +Convention: every table you create is prefixed `plugin_<your-id>__`. The host doesn't enforce this in v0.6, but a future linter will, and the prefix is the only thing that lets a future "uninstall plug-in" cleanly drop your tables.
  142 +
  143 +```xml
  144 +<?xml version="1.0" encoding="UTF-8"?>
  145 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  146 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  147 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  148 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  149 +
  150 + <changeSet id="hello-print-001" author="hello-print">
  151 + <sql>
  152 + CREATE TABLE plugin_helloprint__greeting (
  153 + id uuid PRIMARY KEY,
  154 + name varchar(128) NOT NULL,
  155 + greeting text NOT NULL,
  156 + created_at timestamptz NOT NULL DEFAULT now()
  157 + );
  158 + CREATE UNIQUE INDEX plugin_helloprint__greeting_name_uk
  159 + ON plugin_helloprint__greeting (name);
  160 + </sql>
  161 + <rollback>
  162 + DROP TABLE plugin_helloprint__greeting;
  163 + </rollback>
  164 + </changeSet>
  165 +
  166 +</databaseChangeLog>
  167 +```
87 168  
88   -- `id` is globally unique. Use a reverse-DNS style. The host uses it as the plug-in's schema namespace (`plugin_helloprint__*`) and as the `source` tag (`plugin:com.example.helloprint`) on every metadata row the plug-in seeds.
89   -- `version` is your plug-in's version, not the host's.
90   -- `requiresApi: "1.x"` means "any 1.x release of `api.v1`". A mismatch fails at install time, not at runtime. Across a major version, the host loads `api.v1` and `api.v2` side by side for at least one major release window.
91   -- `permissions` are auto-registered with the role editor. Plug-ins should not invent permissions outside their own namespace.
92   -- `metadata` lists files inside the JAR that the host should pick up on plug-in start: form definitions, BPMN files, message bundles, seed rules.
  169 +## 5. Register HTTP endpoints
93 170  
94   -## 4. Register an extension via `@Extension`
  171 +Plug-ins do NOT use Spring annotations. The host exposes a **lambda-based registrar** through `context.endpoints` that mounts your handlers under `/api/v1/plugins/<plugin-id>/<path>`. The handler receives a `PluginRequest` (path parameters, query parameters, raw body string) and returns a `PluginResponse` (status + body, serialized as JSON by the host).
95 172  
96   -Extensions are how a plug-in plugs into a typed seam declared by the core or another PBC. Every seam lives in `org.vibeerp.api.v1.ext.<area>`.
  173 +Inside your `start(context)`:
97 174  
98 175 ```kotlin
99   -package com.example.helloprint
100   -
101   -import org.vibeerp.api.v1.plugin.Extension
102   -import org.vibeerp.api.v1.workflow.TaskHandler
103   -import org.vibeerp.api.v1.workflow.WorkflowTask
  176 +override fun start(context: PluginContext) {
  177 + this.context = context
  178 + context.logger.info("hello-print plug-in starting")
  179 +
  180 + // GET /api/v1/plugins/hello-print/ping
  181 + context.endpoints.register(HttpMethod.GET, "/ping") { _: PluginRequest ->
  182 + PluginResponse(
  183 + status = 200,
  184 + body = mapOf("plugin" to "hello-print", "ok" to true),
  185 + )
  186 + }
104 187  
105   -@Extension(point = TaskHandler::class)
106   -class GreetCustomerHandler : TaskHandler {
  188 + // GET /api/v1/plugins/hello-print/greetings
  189 + context.endpoints.register(HttpMethod.GET, "/greetings") { _ ->
  190 + val rows = context.jdbc.query(
  191 + "SELECT id, name, greeting FROM plugin_helloprint__greeting ORDER BY name",
  192 + ) { row ->
  193 + mapOf(
  194 + "id" to row.uuid("id").toString(),
  195 + "name" to row.string("name"),
  196 + "greeting" to row.string("greeting"),
  197 + )
  198 + }
  199 + PluginResponse(body = rows)
  200 + }
107 201  
108   - override val id: String = "hello_print.greet_customer"
  202 + // POST /api/v1/plugins/hello-print/greetings
  203 + context.endpoints.register(HttpMethod.POST, "/greetings") { request ->
  204 + // Plug-ins parse JSON themselves. The reference plug-in uses the
  205 + // host's Jackson via reflection; a real plug-in author would
  206 + // ship their own JSON library or use kotlinx.serialization.
  207 + val body = parseJsonObject(request.body.orEmpty())
  208 + val name = body["name"] as? String
  209 + ?: return@register PluginResponse(400, mapOf("detail" to "name is required"))
  210 +
  211 + val id = java.util.UUID.randomUUID()
  212 + context.jdbc.update(
  213 + """
  214 + INSERT INTO plugin_helloprint__greeting (id, name, greeting)
  215 + VALUES (:id, :name, :greeting)
  216 + """.trimIndent(),
  217 + mapOf(
  218 + "id" to id,
  219 + "name" to name,
  220 + "greeting" to "hello, $name",
  221 + ),
  222 + )
  223 + PluginResponse(
  224 + status = 201,
  225 + body = mapOf("id" to id.toString(), "name" to name),
  226 + )
  227 + }
109 228  
110   - override fun handle(task: WorkflowTask) {
111   - val name = task.variable<String>("customerName") ?: "world"
112   - task.setVariable("greeting", "hello, $name")
113   - task.complete()
  229 + // GET /api/v1/plugins/hello-print/greetings/{name} — path variable
  230 + context.endpoints.register(HttpMethod.GET, "/greetings/{name}") { request ->
  231 + val name = request.pathParameters["name"]
  232 + ?: return@register PluginResponse(400, mapOf("detail" to "name is required"))
  233 + val row = context.jdbc.queryForObject(
  234 + "SELECT name, greeting FROM plugin_helloprint__greeting WHERE name = :name",
  235 + mapOf("name" to name),
  236 + ) { r -> mapOf("name" to r.string("name"), "greeting" to r.string("greeting")) }
  237 + if (row == null) {
  238 + PluginResponse(404, mapOf("detail" to "no greeting for '$name'"))
  239 + } else {
  240 + PluginResponse(body = row)
  241 + }
114 242 }
115 243 }
116 244 ```
117 245  
118   -The host scans `@Extension`-annotated classes inside the plug-in JAR and registers them against the declared extension point. A BPMN service task referencing `hello_print.greet_customer` will now be routed to this handler.
  246 +A few things this is doing on purpose:
119 247  
120   -## 5. Add a `@PluginEndpoint` controller
  248 +- **Path templates** with `{var}` placeholders. Spring's `AntPathMatcher` extracts the values into `PluginRequest.pathParameters`.
  249 +- **`context.jdbc`** is the api.v1 typed-SQL surface. It wraps Spring's `NamedParameterJdbcTemplate` behind a small interface that hides every JDBC and Spring type. Plug-ins use named parameters only — positional `?` is not supported.
  250 +- **`PluginResponse`** is serialized as JSON by the host. A `Map<String, Any?>` round-trips through Jackson without you doing anything.
  251 +- **Spring Security still applies.** Every plug-in endpoint inherits the `anyRequest().authenticated()` rule, so callers need a valid Bearer token. The public allowlist (actuator health, `/api/v1/_meta/**`, `/api/v1/auth/login`, `/api/v1/auth/refresh`) does NOT include plug-in endpoints.
121 252  
122   -Plug-ins add REST endpoints through `@PluginEndpoint`. The endpoint runs inside the plug-in's Spring child context and is auto-listed in the OpenAPI document under the plug-in's namespace.
  253 +## 6. Ship metadata about your plug-in
123 254  
124   -```kotlin
125   -package com.example.helloprint
  255 +Drop a YAML file at `META-INF/vibe-erp/metadata/hello-print.yml`:
126 256  
127   -import org.vibeerp.api.v1.http.PluginEndpoint
128   -import org.vibeerp.api.v1.http.RequestContext
129   -import org.vibeerp.api.v1.http.ResponseBuilder
130   -import org.vibeerp.api.v1.i18n.MessageKey
131   -import org.vibeerp.api.v1.security.Permission
132   -
133   -@PluginEndpoint(
134   - path = "/api/plugin/hello-print/greet",
135   - method = "GET",
136   - permission = "hello_print.greet.read",
137   -)
138   -class GreetEndpoint {
139   -
140   - fun handle(request: RequestContext): ResponseBuilder {
141   - val name = request.query("name") ?: "world"
142   - val greeting = request.translator.format(
143   - MessageKey("hello_print.greet.message"),
144   - mapOf("name" to name),
145   - )
146   - return ResponseBuilder.ok(mapOf("greeting" to greeting))
147   - }
148   -}
149   -```
  257 +```yaml
  258 +entities:
  259 + - name: Greeting
  260 + pbc: hello-print
  261 + table: plugin_helloprint__greeting
  262 + description: A personalized greeting record
150 263  
151   -A few things this snippet is doing on purpose:
  264 +permissions:
  265 + - key: hello.print.greet.read
  266 + description: Read greetings
  267 + - key: hello.print.greet.create
  268 + description: Create greetings
  269 +
  270 +menus:
  271 + - path: /plugins/hello-print/greetings
  272 + label: Greetings
  273 + icon: message-circle
  274 + section: Hello Print
  275 + order: 100
  276 +```
152 277  
153   -- The endpoint declares a `permission`. The plug-in loader registers `hello_print.greet.read` with the role editor; an admin grants it before the endpoint becomes callable.
154   -- The greeting goes through the `Translator`. There is **no** string concatenation in user-facing code. See the [i18n guide](../i18n/guide.md).
155   -- The handler returns through `ResponseBuilder`, not through Spring's `ResponseEntity`. Plug-ins do not see Spring directly.
  278 +The host's `MetadataLoader` will pick up this file at plug-in start, tag every row with `source = 'plugin:hello-print'`, and expose it at `GET /api/v1/_meta/metadata`. The future SPA reads that endpoint to discover what entities and menus exist.
156 279  
157   -## 6. Loading the plug-in
  280 +## 7. Building, deploying, loading
158 281  
159 282 Build the JAR and drop it into the host's plug-in directory:
160 283  
161 284 ```bash
162   -./gradlew :jar
  285 +./gradlew jar
163 286 cp build/libs/hello-print-0.1.0.jar /opt/vibe-erp/plugins/
164   -# or, for a Docker deployment
165   -docker cp build/libs/hello-print-0.1.0.jar vibe-erp:/opt/vibe-erp/plugins/
  287 +docker restart vibe-erp
166 288 ```
167 289  
168   -Restart the host:
  290 +For local development against this repo, the reference plug-in uses an `installToDev` Gradle task that stages the JAR into `./plugins-dev/` so `:distribution:bootRun` finds it on boot. The same pattern works for any plug-in.
169 291  
170   -```bash
171   -docker restart vibe-erp
172   -```
  292 +Hot reload of plug-ins without a restart is not in v1.0 — it is on the v1.2+ roadmap.
  293 +
  294 +## 8. What you'll see in the boot log
  295 +
  296 +A successfully-loading plug-in produces a sequence like this:
173 297  
174   -Hot reload of plug-ins without a restart is not in v1.0 — it is on the v1.2+ roadmap. For v1.0 and v1.1, install and uninstall require a restart.
  298 +```
  299 +PF4J: Plugin 'hello-print@0.1.0' resolved
  300 +PF4J: Start plugin 'hello-print@0.1.0'
  301 +PluginLiquibaseRunner: plug-in 'hello-print' has META-INF/vibe-erp/db/changelog.xml — running plug-in Liquibase migrations
  302 +liquibase.changelog: ChangeSet META-INF/vibe-erp/db/changelog.xml::hello-print-001::hello-print ran successfully
  303 +PluginLiquibaseRunner: plug-in 'hello-print' Liquibase migrations applied successfully
  304 +MetadataLoader: source='plugin:hello-print' loaded 1 entities, 2 permissions, 1 menus from 1 file(s)
  305 +VibeErpPluginManager: vibe_erp plug-in loaded: id=hello-print version=0.1.0 state=STARTED
  306 +plugin.hello-print: hello-print plug-in starting
  307 +VibeErpPluginManager: vibe_erp plug-in 'hello-print' started successfully
  308 +```
175 309  
176   -On boot, the host scans `./plugins/`, validates each manifest, runs the plug-in linter, creates a classloader and Spring child context per plug-in, runs the plug-in's Liquibase changesets in `plugin_<id>__*`, seeds metadata, and finally calls `Plugin.start`. The lifecycle in full is described in section 7 of [`../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md`](../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md).
  310 +If anything goes wrong, the host logs the per-plug-in failure loudly and continues with the other plug-ins. A failed lint, a failed migration, or a failed `start()` does NOT bring the framework down.
177 311  
178   -## 7. Where to find logs and errors
  312 +## 9. Where to find logs and errors
179 313  
180   -- **Boot log:** `/opt/vibe-erp/logs/vibe-erp.log`. Filter on the `platform-plugins` logger to see exactly which plug-ins were scanned, accepted, rejected, and why.
181   -- **Linter rejections:** if your JAR imports a class outside `org.vibeerp.api.v1.*`, the linter rejects it at install time and logs the offending import. Fix the import; never work around the linter.
182   -- **Migration failures:** Liquibase output is logged under the `platform-persistence` logger, scoped to your plug-in id.
183   -- **Runtime errors inside your plug-in:** logged under the plug-in's id (`com.example.helloprint`). Use the `PluginContext.log` handed to you in `start`; do not pull in your own logging framework.
184   -- **Health endpoint:** `/actuator/health` reports per-plug-in status. A plug-in stuck in a half-loaded state shows up here before it shows up in user-visible failures.
  314 +- **Boot log:** `/opt/vibe-erp/logs/vibe-erp.log` (or stdout for `bootRun`). Filter on `o.v.p.plugins` for the plug-in lifecycle, `o.v.p.p.lint` for the linter, `o.v.p.p.migration` for Liquibase, `o.v.platform.metadata` for the metadata loader, and `plugin.<your-id>` for your own messages routed through `context.logger`.
  315 +- **Linter rejections** print every offending class + the forbidden type referenced. Fix the import; never work around the linter — it will catch you again.
  316 +- **Migration failures** abort the plug-in's start step but don't stop the framework. Read the Liquibase output carefully.
  317 +- **Runtime errors inside your handler lambdas** are caught by the dispatcher, logged with a full stack trace, and returned to the caller as a 500 with a generic body. Use `context.logger` (not your own SLF4J binding) so your messages get the per-plug-in tag.
185 318  
186   -## 8. The reference printing-shop plug-in is your worked example
  319 +## 10. The reference printing-shop plug-in is your worked example
187 320  
188   -`reference-customer/plugin-printing-shop/` is a real, production-shaped plug-in built and CI-tested on every PR. It expresses the workflows in `raw/业务流程设计文档/` using **only `api.v1`**. When this guide leaves a question unanswered, the answer is almost always "look at how `plugin-printing-shop` does it":
  321 +`reference-customer/plugin-printing-shop/` is a real, CI-tested plug-in. When this guide leaves a question unanswered, the answer is almost always "look at how `plugin-printing-shop` does it":
189 322  
190   -- The shape of `plugin.yml` for a non-trivial plug-in.
191   -- How a plug-in declares a brand-new entity (printing plates, presses, color proofs) without touching any PBC.
192   -- How a multi-step BPMN workflow is shipped inside a JAR and wired up to typed `TaskHandler` implementations.
193   -- How form definitions reference custom fields.
194   -- How i18n bundles are organized for a plug-in that ships in five locales.
  323 +- The shape of `plugin.yml` and the `META-INF/MANIFEST.MF` attributes.
  324 +- A real Liquibase changelog with multiple changesets at `META-INF/vibe-erp/db/changelog.xml`.
  325 +- A real metadata YAML at `META-INF/vibe-erp/metadata/printing-shop.yml`.
  326 +- Seven HTTP endpoints, including path-variable extraction and POST handlers that parse JSON bodies.
  327 +- The dual-supertype trick (`Pf4jPlugin` + `VibeErpPlugin`) with import aliases.
  328 +- A working `installToDev` Gradle task for local development.
195 329  
196   -The plug-in is built by the main Gradle build but **not loaded by default**. Drop its JAR into `./plugins/` to load it locally.
  330 +The plug-in is built by the main Gradle build but **not loaded by default**. Drop its JAR into `./plugins/` (or use `installToDev` for development against this repo) to load it.
197 331  
198 332 ## What this guide does not yet cover
199 333  
200   -- Publishing a plug-in to a marketplace — the marketplace and signed plug-ins are a v2 deliverable.
201   -- Hot reload without restart — v1.2+.
202   -- Calling the MCP endpoint as an AI agent — v1.1.
  334 +- **Subscribing to events.** `context.eventBus.subscribe(...)` is wired and live, but no worked example yet — see `EventBusImpl` and `EventBus` in api.v1 for the contract.
  335 +- **Workflow handlers.** `TaskHandler` is in api.v1 but Flowable isn't wired yet (P2.1, deferred). Plug-ins that try to register handlers today have nothing to call them.
  336 +- **Translator.** `context.translator` currently throws `UnsupportedOperationException` until P1.6 lands.
  337 +- **Custom permission enforcement.** Your declared permissions are stored in `metadata__permission` but `@RequirePermission`-style checks aren't enforced yet (P4.3).
  338 +- **Publishing a plug-in to a marketplace** — v2 deliverable.
  339 +- **Hot reload without restart** — v1.2+.
  340 +- **Calling the MCP endpoint as an AI agent** — v1.1.
203 341  
204   -For everything else, the source of truth is [`../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md`](../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md).
  342 +For everything else, the source of truth is [`../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md`](../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md), and the live status of every feature is in [`../../PROGRESS.md`](../../PROGRESS.md).
... ...
docs/workflow-authoring/guide.md
... ... @@ -20,7 +20,7 @@ Business analysts draw workflows in the BPMN designer that ships in the web SPA.
20 20  
21 21 This path is the right one for:
22 22  
23   -- Approval chains that vary per tenant.
  23 +- Approval chains the customer wants to author themselves.
24 24 - Document routing rules that the customer wants to tune themselves.
25 25 - Workflows that compose existing typed task handlers in a new order.
26 26  
... ...