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.
Button vanishes and shows "Something went wrong" (⟦block.fallback.message⟧) after a click — even though its own onFailure runs correctly
Cause: a real, confirmed platform behavior, not a bug in your page: every action's onError defaults to "stop" when you don't set it, and any"stop" disposition marks the whole chain run as failed — regardless of whether that specific action's own onFailure already ran and set a perfectly good inline error message. The host (TenantPageHost.tsx) throws on a failed chain, and that throw faults the clicked button's own block instance, replacing it with the generic crash fallback (block.fallback.message, with a Retry button) — while sibling blocks (e.g. a Cancel button next to it) render fine, since only the faulted block's own output is replaced. This is not specific to REST API failures — a plain client-side validate action rejecting bad input hits the exact same path. Fix: never rely on an action's default "stop" behavior for anything a user can trigger by normal use (a validation failure, an expected 4xx). Two changes, together:
- Set
"onError": "ignore"on every action whose failure is a normal, expected outcome — avalidatestep, a duplicate-checkcallApi, the real create/updatecallApiitself.onFailurestill runs exactly as authored (toast, inline error label, etc.); the chain just no longer marks itselffailedfor it. - Replace "does an earlier
validatestep stop the chain" flow control with a real"condition"expression on the action that should only run once validation passes, e.g."condition": "isEmpty(page.usernameError) && isEmpty(page.emailError)"on the actual createcallApi. Acondition:falseskip is a normalcontinue, never afailed— seeerp expression testbelow for checking one before you ship it.onError: "stop"is still exactly right for a genuinely unexpected/fatal failure you want to surface loudly (that's what it's for) — the fix here is narrower: don't let it be the DEFAULT for actions whose failure is routine.
validate/condition throws Unresolved variable: api.x even though ${api.x} works fine elsewhere on the same page
Cause: two different resolution mechanisms. ${api.x} inside an action's config (e.g. a callApi's params, or a setValue's value) is action-engine's own template interpolation, which does read the api bucket a prior action's output wrote. A bare expression — validate's config.expression, an action's condition, a core.label's own "source": "expression" — goes straight to @erp/expression-engine's evaluateExpression, whose scope chain is exactly ["local", "form", "block", "page", "session", "tenant", "global"]. api is not in that list, ever — a bare expression can never see it, no matter how the scope is wired. Fix: stage the value into page first — a setValue with "value": "${api.x}" (template, works) — then reference page.x from the bare expression. erp expression test reproduces this offline in one line: erp expression test "api.x" --scope '{"api":{"x":1}}' fails the same way a real page would.
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 plugin 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 plugin loader 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 help-docs/examples/test-examples.mjs— run both before every publish.erp expression test "<expr>" [--scope '{"page":{...}}']— parse (and, with--scope, actually evaluate) a Phase 1 expression offline before it goes into avalidate/condition/labelexpressionfield. Catches both syntax mistakes and scope mistakes — e.g. it's how the"api"scope gotcha below was found:api.xresolves fine inside a${...}template, but a barevalidate/conditionexpression can only seepage/session/tenant/global/local/form/block— stage an API result intopagefirst ("setValue","value": "${api.x}") if a validate/condition needs it.
