Actions — business-process operations
An action is a named operation you invoke on a business object — the mechanism for driving a document or record through its business process. Completing an order, cancelling it, and spawning the resulting invoice are all actions. It is the third behavior in the framework’s model, alongside determinations (which derive data) and validators (which block invalid saves), and the only one that is invoked rather than run on every save.
The names come from OData: an Action is a side-effecting operation bound to an entity,
a @Function is the read, and the two together are an
operation.
Anatomy of an action
Section titled “Anatomy of an action”An action is an ordinary NestJS provider extending
Action.on(Schema, { name, label, description }), with two methods:
isAvailable(record)decides whether the action applies to this record. It is optional — the base returnstrue— and it is ordinary TypeScript, so the class may inject services and read other records, returningbooleanorPromise<boolean>. It runs per record on every read that lists actions, so keep it cheap and prefer the record’s own state.run(record, params)does the work, in the caller’s unit of work. If it throws, everything rolls back.
Three declarations shape those signatures. params may be omitted — the base pins
it to {} — and the schema is strict, so run never sees a parameter the action did
not declare; parameters are sparse, so an optional one the caller omitted reads as
undefined and the action applies its own default. A hasMany inside params is a
list of plain rows — the row’s fields and nothing else, not even an id, because
an argument is not a record; createInvoice’s lines is one. A struct
is its member object, or null.
returns is how an action that
spawns a record hands it back, declared and typed exactly like a function’s. And the
factory names the binding: Action.on answers on the record with a permission scoped to
that business object, while Action.root(config) declares a root operation at
POST /api/actions/:namespace/:name, with a two-segment name and a zero-argument
isAvailable.
Register the class in the plugin’s providers — the only registration, and the only
step nothing checks for you. See Registration for the full
class and the Params / Returns annotations.
The code stays on the server, the declaration does not
Section titled “The code stays on the server, the declaration does not”An action is a NestJS provider, and its body never leaves the server: isAvailable is code, and code the
client cannot run is code the client cannot get wrong.
Its declaration does cross. Name, label, description, subject, params and returns
are part of meta, served from GET /api/meta and loaded
into the client on construction. That declaration is load-bearing, not
informational: the client refuses to run an action absent from it, and revives the
Decimal and Temporal values in a result out of the declared returns.
What a static declaration cannot answer is whether the action applies right now, so the frontend asks rather than derives, on its own endpoint:
GET /api/business-object/sales/SalesOrder/{id}/actions→ { "data": [{ "name": "complete", "label": "Complete" }] }Separate from the detail read on purpose: isAvailable may inject services and read
other records, and a read that never renders an action menu should not pay for it. The
client asks when the menu opens — <ActionMenu> lives inside the dropdown content,
which unmounts while closed — so nothing is evaluated until someone looks, and the list
is never stale. It is not filtered by permission: an action the caller may not run
is listed and refused on invocation (action:{action}).
Two form-level gates sit in front of all of that. A draft or unpersisted record shows no action menu at all — the whole menu belongs to the operational world. And every item is disabled while the form is saving or holds unsaved edits, because an action runs against the persisted record and would not see them.
Reacting to what an action returned
Section titled “Reacting to what an action returned”An action that spawns a document hands back its id, and the surface showing the record
decides what to do with it. useActions and onAction (from @onerp/react/actions,
with <ActionMenu>) bind a handler to one action, typed against the action’s own class:
useActions([ onAction<CancelInvoice>("cancel", { after: ({ cancellationInvoice }) => { if (cancellationInvoice) { navigate(recordPath("sales/SalesInvoice", cancellationInvoice)); } }, }),]);onAction<A> checks the name against the action’s declared name, the result against
its returns and the params against its params, through a type-only import — the
class is server code. The after handler receives what the action returned and what it
was sent, and returns nothing: navigating, refetching and opening a dialog are the
surface’s business, and useActions only says when. The click pipeline runs confirm,
dispatch, then the success toast before after fires.
The confirm dialog’s body is the action’s own description when it declares one, so
write the description as what the user is about to agree to; without one, a generic “are
you sure” stands in. An action that is safe on a single click skips the confirm with
confirm: false, which cannot be combined with a dialog — the dialog is the
confirmation.
A binding belongs to the surface that owns the record, not to the action menu, so a list view can register its own handlers for the same action. A surface that registers nothing gets nothing; running the action still works.
Paths come from @onerp/react/resource (recordPath, draftPath, listPath,
createPath). Do not spell one by hand: a document saved as a draft has its own
address, and /{resource}/{id} will not resolve one. The order’s createInvoice
binding reads the activate param it sent to pick between the two.
Custom action dialogs
Section titled “Custom action dialogs”Some actions need to collect something from the user before they run — a cancellation
reason, a target warehouse. onAction’s dialog replaces the generic confirm with a
component receiving ActionDialogProps<TheAction>, whose run(params) is the same
policy-wrapped dispatch the generic confirm uses underneath (dispatch, success toast,
failures to the error modal, then after), typed against the action’s declared params.
It resolves to whether the action landed, so if (await run(params)) close() is the
pattern, and a failed attempt leaves the dialog open for another. The modal shell and its
title are framework-owned, so the component renders content only and must not render its
own DialogTitle; it receives the action’s label for its own confirm button, and a
binding whose content needs the room — a table — says wide: true.
The reference dialog is the line picker the sales order binds to createInvoice and
createFulfillmentOrder (apps/web/src/resources/sales-documents/line-picker-dialog.tsx):
one generic table — checkbox, line, open quantity, quantity to carry — fed by a thin
adapter per action that reads the lines off the form context and says what each line
has open, in which unit. The picker shows what the document’s lines carry themselves,
nothing looked up from master data. Every open line starts selected at its open quantity, so
one click carries everything, and a partial forward is a matter of unchecking or typing
less. The submitted rows are the action’s lines parameter. The picker only defaults:
what a line may carry is checked, if at all, on the document it lands in — not here, and
not on the request.
Moving a status
Section titled “Moving a status”An action writes through the ordinary manager methods; there is no privileged write
path, because it does not need one. A business object’s
lock seals its input fields — the ones a user may type
— while a status like execution is access: "system", written by determinations and
by the actions that drive the process. So bom.update with a system-field delta lands
on a sealed document, and the same call with an input field does not.
Follow-up documents
Section titled “Follow-up documents”Spawning a follow-up document (an order spawns an invoice, an invoice spawns its own
cancellation — see Cancellations and corrections) is an action whose run
creates a new record.
Declare what you spawned. Without a declared returns the new id exists for one
instant inside run and is then unreachable to the caller. CreateInvoiceFromOrder
declares returns: { invoice: field.referenceOne("sales/SalesInvoice", …) } and returns
{ invoice: invoice.id } (the full class is in
Registration).
The run response carries it as data — { "data": { "invoice": "01K…" } }. The record
itself does not ride along: a caller that needs the state the action left behind re-reads
it, which the browser client does on its own after every action.
A spawned document is born active. Everything a cancellation, a correction or a
fulfillment order needs was decided before the action ran — in its params, or on the
document it came from — so run creates through the operational BusinessObjectManager
and the caller gets a numbered, sealed record in one call. An agent or an integration
never sees a draft, and a validation failure rolls the whole action back with the field
named, instead of leaving a draft behind that no list can find.
The exception is a document a person still finishes. An invoice from an order may want a
service date or a reworded line before it goes out, so createInvoice takes an
activate param, true when omitted: the order form’s picker offers Create Invoice
and Save as draft, and only the second passes activate: false. The action then
creates through DraftWorkspace instead of BusinessObjectManager — one payload,
one branch — and the id it returns is a draft’s — reachable only by the draft route the surface builds (see
Lifecycle). The flag is an ordinary param: whether a
spawning action offers it is that action’s decision, and only where a person has
something to add.
There are two ways for run to fill the new record, and the choice is a domain decision:
- Copy some fields, derive the rest — right when the new document is genuinely new.
CreateInvoiceFromOrdercopies each line’s product, quantity, price and discount, wires the new line’sorderLineback to the order row it came from (a row reference), and lets the invoice’s own determinations derive the rest against today’s prices and tax rules. - Copy the record whole — right when the new document must reflect what the old one
said.
copyRecord(record, schema, { omit })(@onerp/meta/records) mirrors every field,systemfields and other domains’ extension fields included, leaving theomitted paths absent for the new record’s determinations to fill. See Documents capture their data.
The HTTP endpoint
Section titled “The HTTP endpoint”Every registered action is callable over HTTP:
POST /api/business-object/{namespace}/{name}/{id}/actions/{action}The body carries the action’s params, always validated against its declaration. The
response is { "data": … } holding whatever the action declared in returns — the one
envelope every operation route answers with. The saved record is not in it. Which
actions the record offers now is a separate read (GET …/{id}/actions).
| Outcome | Status |
|---|---|
| Action succeeded | 200 OK |
| Record not found | 409 Conflict (ActionNotAvailableError) |
isAvailable rejected the record’s state |
409 Conflict (ActionNotAvailableError) |
| Unknown action | 403 Forbidden (deny-by-default) |
| Invalid params | 400 Bad Request (core.validation_failed; availability guards first, so an unavailable record answers 409) |
Domain rule violated inside run |
422 Unprocessable Entity (AppException subclass, e.g. core.*/sales.*) |
Deny-by-default for unknown actions is intentional: the permission check is
action:{action}, so an action name not registered on the business object is treated as
an unauthorized request, not a 404.
Execution order
Section titled “Execution order”ActionService’s dispatch seam is the same lookup → load → guard → parse → run every
caller crosses, HTTP or in-process: resolve the action from MetaService.current() (404 if
it is not registered), load the record, call isAvailable (409 if the record is absent
or its state rejects), parse the body against the declared params rejecting anything
undeclared, then run and answer with whatever it returned. An HTTP caller never sees
that 404 — the permission guard runs before the handler and turns an unregistered name
into the 403 above. The availability check runs here even though the caller was handed a
list: that list was a snapshot from an earlier request. Everything shares one unit of
work, so if run throws, the whole thing rolls back.
Actions and the determination engine
Section titled “Actions and the determination engine”An action’s writes flow through the same engine pipeline as a field edit, because
they are field edits. The engine re-evaluates every determination whose inputs
include a field the action wrote, within the same save.
Actions and the AI agent
Section titled “Actions and the AI agent”describe_business_object returns the action catalog — name, label, description,
params, and an allowed flag for whether the caller’s role holds action:<name>
(actions the user may not invoke are still listed, so the agent can explain the
process). Which actions apply to a particular record is a separate question the agent
has no read for, so it learns by invoking and reading the refusal. It runs one via
invoke_action, approval-gated like every write; a rejected isAvailable maps to
core.action_not_available.
Actions and the audit timeline
Section titled “Actions and the audit timeline”An action is a mechanism, not an event. Whatever it writes surfaces as an ordinary
updated row carrying the field diff, including any determination cascade it
triggered. An action that writes nothing to its own record emits nothing for it;
business objects it spawns emit their own created events, linked by the shared per-UoW
correlation_id.
See Audit for the row shape and the timeline UI, and Events for how the bus dispatches and attributes them.