Skip to content

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:

  1. Set "onError": "ignore" on every action whose failure is a normal, expected outcome — a validate step, a duplicate-check callApi, the real create/update callApi itself. onFailure still runs exactly as authored (toast, inline error label, etc.); the chain just no longer marks itself failed for it.
  2. Replace "does an earlier validate step 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 create callApi. A condition:false skip is a normal continue, never a failed — see erp expression test below 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's X-Correlation-Id header 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> and node 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 a validate/condition/label expression field. Catches both syntax mistakes and scope mistakes — e.g. it's how the "api" scope gotcha below was found: api.x resolves fine inside a ${...} template, but a bare validate/condition expression can only see page/session/tenant/ global/local/form/block — stage an API result into page first ("setValue", "value": "${api.x}") if a validate/condition needs it.

See also ​