Appearance
Known gotchas
The mistakes everyone makes once. Each entry: symptom → cause → fix. All real, found in a live build (many while building the tutorial module).
Entities
label fails the install with invalid input syntax for type json
Cause: an entity's label or a field's label was a bare string. Fix: the column is JSON, so the string must be valid JSON. Entity: "label": "{\"en\":\"Equipment\"}". Field: "label": "\"Asset Tag\"". Same for typeParams ("{\"enumValues\":[...]}") and a workflow's stagesJson/tasksJson/transitionsJson.
A field is silently missing after install
Cause: required: false without nullable: true (or vice-versa) on a table that already has rows — the ALTER TABLE ADD COLUMN ... NOT NULL fails but the metadata row was inserted. Fix: always pair them. required:true ⇒ nullable:false; required:false ⇒ nullable:true. Then bump the version and re-publish.
A big blob of text is rejected / truncated
Cause: dataType: "long" — that's the 64-bit integer type. Fix: use dataType: "text".
The generic status-date-sweep job reports "failed": N on your rows
Fixed 2026-09-10: a sweep config whose set_field (or status_field) is a boolean, integer or numeric column now works — set_status_to: "true" / "42" is coerced to the column's real type before the write (in EntityRecordService, so every generic sweep job — status-date, compliance, cadence, aggregation, cross-plugin — and entity-rule SET_VALUE benefits). An unparseable value (e.g. "maybe" into a boolean) still counts that one row as failed without throwing. If you still see failed: N, the value genuinely doesn't parse for the column type, or the row hit an entity-rule rejection — check erp logs tail.
Pages and action chains
committed payload key is event.new
Cause: a handler read event.value / event.record. Fix: the value the user entered or picked is ${event.new}. For a core.lookup it's the picked record's id.
metadata.dataSource is page-level, not grid-level
Cause: put dataSource / entity / recordType on the core.grid block — it has none of those properties. Fix: the page artifact's top-level metadata.dataSource must equal a metadata/provider/*.json file's name, exactly. Miss or mistype it and the grid renders 0 rows with no error. erp plugin test catches this as PAGE-DS-001.
KPI card shows undefined
Cause: bound ${out.value} against a composite Data Service. Fix: count → ${out.value}; composite → ${out.results.<step>.value}. .total is the unrelated list-pagination envelope.
A parameterized Data Service /execute returns 400
Cause: arguments passed as { "params": { "search": "x" } }. Fix: nest one level deeper: { "params": { "parameters": { "search": "x" } } }. A core.lookup does this for you.
!= null in an action-engine condition never matches
Cause: action-engine quirk. Fix: use the isNotNull / isNull operators (rule engine: is_not_null / is_null).
UI shows a raw i18n key like office-equipment.x.y
Cause: the key has no entry in metadata/i18n/en.json. Fix: add it. erp plugin test lists missing keys as I18N-001.
Rules and workflows
A workflow-trigger rule fires on every save, not just the transition
Cause: the condition checks only status == PENDING_APPROVAL. Fix: add { "field": "status__previous", "op": "neq", "value": "PENDING_APPROVAL" } inside an all clause.
Nobody can approve a workflow task
Cause: approvalPermissions references a role display name. Fix: use roleCode — the stable code, never the mutable name.
Install rejects a workflow file
Cause: stagesJson / tasksJson / transitionsJson authored as nested objects. Fix: each is a JSON-encoded string.
Publishing
A same-version re-publish does nothing
Cause: the plugin is already installed at that version — the re-publish is a silent no-op. Fix: bump plugin.json version before every build + publish, even if you're sure the last publish shipped the fix.
Refusing to overwrite immutable artifact
Cause: re-publishing a version whose .spk was already stored, with different bytes (e.g. after a publish that failed later in the pipeline). Fix: go to the next version number.
There is an already loaded plugin ... with the same id
Cause: you uninstalled the plugin and the background unload didn't complete (it can stall on a transient DB error), so the old PF4J code is still loaded and the next publish (install-new path) is refused. Fix: to iterate, bump the version and re-publish — don't uninstall. Recovery from a stuck state is a backend restart or publishing under a new plugin id.
plugin.json is missing mainClass (fixed 2026-09-10 — no longer occurs)
A pure-JSON plugin (only declarative metadata artifacts — entities, pages, providers, menus, seed-data, workflows, rules, sweep configs — and zero Java classes) now publishes with "mainClass": null exactly as erp plugin create scaffolds it. The platform loads it through a built-in generic no-op PF4J entry point (JsonOnlyPlugin); full lifecycle (start/stop/enable/disable/upgrade), artifact installation, migrations and data seeding all run normally. You do not compile or ship any jar. Only declare a mainClass (with a jar in spk-assembly/lib/) if your plugin genuinely ships Java extensions.
A role named "X" already exists / Role "X" ... already exists owned by "Y"
Cause: a plugin.json role whose name or code collides with another installed plugin's role. Fix: prefix your role names and codes.
Runtime / navigation
The browser URL is not route.pattern
Cause: treating the menu/page route.pattern string as the reachable URL. Fix: the URL is /app/<app-slug>/<module-slug>/<pluginId>/<page-name>. The route.pattern is an internal routing key.
A menu item 404s
Cause: navigation.route doesn't match the page's route.pattern. Fix: copy it exactly.
A page isn't in the sidenav
Cause: the page's modules array doesn't name the module it should appear under. Fix: set modules to your module's name; align the menu file's modules too.
Environment / tooling (Windows)
erp command turns /api/v1/... into C:/Program Files/Git/api/v1/...
Cause: Git Bash rewrites a leading / in an argument to a Windows path. Fix: run the command from PowerShell, or set MSYS_NO_PATHCONV=1.
--tenant 5 errors even though you're logged in
Cause: --tenant only overrides the X-Tenant-Id header; it doesn't re-authenticate you. Fix: your token must already be valid for that tenant.
Getting unstuck
erp logs tail --grep <X-Correlation-Id>— a failed API response'sX-Correlation-Idheader joins to the exact server-side stack trace. (SDK connected mode, offline: use the ERP admin log view.)erp platform catalog --query "<term>"— before writing any "there's no X" assumption, check whether the platform already has it.erp plugin test <dir>andnode developer-docs/examples/test-examples.mjs— run both before every publish.