Server Plugin Step Registration
Schema id: plugin-step@1
Machine-readable schema: plugin-step.schema.json
The canonical registration wrapper for a Server Plugin Step (Section 6.6) — server-side logic that runs when a record on an entity is created, updated, or deleted. The wrapper says when and how the logic runs; a BS Lang implementation additionally carries the script inline, which is validated by the BS Lang grammar in addition to this schema. A registration is validated identically whether it comes from a designer, an API call, an AI assistant, or a Solution import.
Shape
| Field | Type | Required | Notes |
|---|---|---|---|
logicalName | string | yes | Publisher-prefixed, lower snake-case. |
displayName | string | yes | Human-readable step name. |
publisherPrefix | string | yes | The owning publisher's prefix, which logicalName must be namespaced by — not the watched table's. A standard table such as account is deliberately unprefixed, and validating against it made a step on one impossible to name. |
entity | string | yes | The entity whose writes trigger the step. |
message | string | yes | Create, Update, or Delete. |
stage | string | yes | PreValidation, PreOperation, or PostOperation. |
executionMode | string | yes | Synchronous (inline: whoever saved waits for it — inside the write's transaction at PreValidation/PreOperation, after the commit at PostOperation) or Asynchronous (queued through the outbox and run after the write commits). An asynchronous step must be PostOperation: it runs after the record is already saved, so it has nothing left to refuse. |
rank | integer | yes | Execution order among steps on the same entity + message + stage; lower runs first. |
isEnabled | boolean | yes | A disabled step is stored but never executed. |
implementation | object | yes | Exactly one implementation (see below). |
Implementation
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes | BsLangScript or DotNetAssembly. |
script | string | when BsLangScript | The BS Lang source. |
assemblyReference | string | when DotNetAssembly | The uploaded assembly's storage key. |
typeName | string | when DotNetAssembly | The plugin type to instantiate. |
Stages
- PreValidation — runs before the operation; coarse shape/security checks.
- PreOperation — runs just before the write; may change field values on the record or reject the whole operation.
- PostOperation — runs just after the write; for cascading or dependent changes.
A step rejects an operation by calling throwValidationError("reason"), which stops the
write and returns the reason to the caller.
Example
{
"logicalName": "par_invoice_reject_negative",
"displayName": "Reject negative invoice total",
"entity": "par_invoice",
"message": "Create",
"stage": "PreOperation",
"executionMode": "Synchronous",
"rank": 1,
"isEnabled": true,
"implementation": {
"type": "BsLangScript",
"script": "if (getValue(\"par_total\") < 0) { throwValidationError(\"Total cannot be negative.\"); }"
}
}
BS Lang script host functions
A BsLangScript step reads and changes the record through these host functions:
| Function | Purpose |
|---|---|
getValue("field") | The current value of a field on the record being written. |
setValue("field", value) | Change a field's value (honoured only in PreOperation). |
getPreValue("field") | The field's prior value (updates and deletes). |
throwValidationError("message") | Reject the operation and roll it back. |
message() / stage() / entityName() | The current operation, stage, and entity. |
userId() / depth() | The triggering user and the recursion depth. |
Reaching other tables
The functions above see only the record being written. These reach the rest of the environment, and every one of them runs as the user who triggered the step — a script is not a way around a permission, and a step whose caller cannot read a table gets nothing back from it.
| Function | Purpose |
|---|---|
getRecord("table", id) | One record as an object, or empty if it is not there. .id is on it. |
queryRecords("table", "field", value) | The rows where a field equals a value, as a list of objects. Capped at 200. |
runQuery({ … }) | Runs a full parsware-query@1 document — nested and/or filters with ten operators, joins, sorts, paging, or an aggregate that answers groups. The same query language Advanced Find emits and a saved query stores. Rows capped at 200; an aggregate is not. A joined column reads under its alias (row.acct.par_industry), and a link's 'from' must be quoted because from is a BS Lang keyword. |
runSavedQuery("name") | Runs a saved query by logical name. The complexity lives in a designer and travels in the solution, so the script only names the question. |
countRecords("table", "field", value) | How many there are, without fetching them — a real COUNT, so it is right past the 200-row cap that bounds queryRecords. |
createRecord("table", { field: value, … }) | Creates a record and answers its id. |
updateRecord("table", id, { field: value, … }) | Changes the fields named, and only those. |
deleteRecord("table", id) | Deletes a record. |
A write made this way runs the ordinary rules, so a step registered on the table being written
will run in turn. That nesting is bounded at a depth of eight and depth() tells a script where
it is — but the limit is a backstop, not a design. A step that writes the table it watches will
trigger itself, and the fix is a condition or a different table.
Importing shared logic
A step's script may import a solution script by name, exactly as a Client Business Rule does,
so a rule enforced by several steps is written once:
import 'par_approval_rules';
if (getValue('par_total') > par_approval_rules.limit()) { … }
Both quote styles work. The module must be a solution script in the same environment; anything else fails when the step is saved rather than when it runs.