⛓️ Chain of Command
Updated
How several event handlers of one object, and several implementations of one
IFoo, call each other through next(...). Short recipe:
Wrap with Chain of Command.
The same text opens from the ⓘ button on the Chain of Command strip in the
studio (guide key script.chain).
Why this exists#
One document or dictionary can have several handlers from different models:
stock (Inventory), cost (Costing), the tenant. This is
neither class inheritance nor a "left-to-right subscription". It is an onion:
the top layer starts first, next(...) goes down and returns the result of
the lower link.
Code after next() sees what the lower ones wrote (stock, GL) — owner
work, then each extension, in layer order.
Transaction scripts on one subtype are still a horizontal list (stock +
cost), not a single onion. The CoC vertical is for when you need to adjust an
already existing tx / command / driver of another model:
next(...), not a second button. New links come only from the "Add yours on
top" strip.
Services sharing one contract name IFoo are the same onion:
GetService<IFoo>() starts from the top layer, next<T>(...) /
nextAsync<T>(...) goes down. A single implementation is not wrapped.
How to write it#
[ExtensionOf("GoodsIssue")] // a handler of somebody else's object declares itself
public partial class GoodsIssueCostingEventHandler : TypedDocumentEventHandler<GoodsIssue>
{
public override async Task<EventResult> OnAfterPostAsync(GoodsIssue header, EventContext context)
{
var prior = await next(header, context);
if (!prior.Success) return prior;
// here header and the database are already as the lower layer left them
// header.Lines (and the other table parts) are already on the object — the same graph that went into Save
return EventResult.Ok();
}
}
The owner (the handler in the object's own model) is written with no attribute
and no next:
public partial class GoodsIssueEventHandler : TypedDocumentEventHandler<GoodsIssue>
{
public override Task<EventResult> OnAfterPostAsync(GoodsIssue header, EventContext context)
=> Task.FromResult(EventResult.Ok());
}
The rules:
- Pass the same arguments into
nextthat the signature received (header, context,record, isNew, context, …). After the lower link, the typednext(record, …)copies the bag back onto the same instance — work afternext()sees their fields. In the editor the parameter hint (IntelliSense) takes<param>from the XML docs of the hook and ofnext(). - A missing
nexton a link above the owner is compile errorZOCOC001. The lower handlers of that hook will not run. The owner does not callnext— it is link 0, there is nobody below. - A handler on an object owned by ANOTHER model must declare itself with the
class attribute
[ExtensionOf("ObjectName")], otherwiseZOCOC004. The name is checked against the script envelope: rename the object and you see an error, not a handler that quietly detached. The attribute on a handler of YOUR OWN model isZOCOC004as well ("it IS the owner"). - To stop the chain on purpose —
[Replace]on the method, with nonext. - A second
nextin the same override throws. - An empty class with no override is normal: the hook does not take part in the
chain.
A field on a dictionary card, a field in a document header and a cell of a
table part line call live evaluate (
POST /api/events/dictionary/field/document/field/tablepart/field) — the sameOnValidateField/OnFieldChanged, the samenextonion. - Class names are unique across the whole workspace: the owner is
GoodsIssueEventHandler, Costing's isGoodsIssueCostingEventHandler, the tenant's has a suffix of its own.
override goes against the platform's virtual
(TypedDocumentEventHandler<T>), not against another handler. The
classes are neighbours, not Costing : Inventory.
Where it is in the interface#
One and the same strip: the owner on the left, the higher layer on the right.
The right one starts first. The arrows are the direction of next(),
leftwards toward the owner.
Inventory ← Costing ← Tenant
bottom top (starts first)
For events the owner is fixed structurally: it is the handler in the object's
own model, and it is always the leftmost one, even if the extension is declared
on the same layer. Order by layer and name and a foreign layer-2 extension
SalesGLEventHandler ends up to the left of the owner
SalesInvoiceEventHandler (Sales, layer 2) simply because
"SalesG…" < "SalesI…".
The strip is drawn straight from GET /api/metadata/scripts/event-chain, which
orders through the same EventChainOrder the runtime uses, so the numbering on
it is the execution order. Nothing in the front end re-sorts the answer.
| What | Where to go | To add your own | To check |
|---|---|---|---|
| Dictionary events | Dictionaries → a row / the designer → Events (or "Open event handler") | "Add yours on top" on the strip; saving creates a handler in the working model with [ExtensionOf("…")] |
Check in the editor — ZOCOC001 / ZOCOC004 |
| Document events | Documents → Events | the same | the same |
| Table part events | Table part types → Events | the same | the same |
| Register events | Registers → Events (accumulation ones) | the same | the same |
The IFoo service |
Services → a row (the "Chain" column, if the name exists on several layers) → the card or the script | "Add yours on top": the same name, next<T> stubs in the working model (a strictly higher layer) |
Check on the script — ZOCOC002; a red triangle on the link if the registry rejected it |
| Totals driver | Registers / drivers → the script slot | "Add yours on top" on this driver's strip. A second driver cannot be put beside it | Check — ZOCOC003 |
| Command | The command card / the script slot | "Add yours on top" on this command's strip. Do not start a second command "with the same meaning" | Check — ZOCOC003 |
| One owner's tx | The slot of a specific tx, not the subtype's list | "Add yours on top" on this script's strip ≠ "a new tx on the subtype" | Check — ZOCOC003 |
| The extensions registry | /extensions |
the "Chain" column — the same links as on the target object; a click opens the script | your own link has landed |
The working model must be a layer above the owner. Two links on one layer are not allowed — a tie throws.
The horizontal list of tx on a subtype (executionOrder) is not this strip.
Stock and cost do not glue into one onion.
Services — the same IFoo on different layers#
The same service name (the metadata name → the contract I{Name}) in two
models makes neighbours, not heirs. The lower layer owns the interface; the
upper one implements the same IFoo in full.
public partial class Adder
{
public long Add(long a, long b) => next<long>(a, b) + 1;
}
The rules:
- Pass the method's arguments into
next<T>/nextAsync<T>. The typeTis the return type (forTask<T>— the innerT). - The owner on the lower layer does not call
next— there is nobody below. - A missing
nexton an extension isZOCOC002. The registry does not register such an extension, andGetService<IFoo>()stays on the inner one. - To stop the chain on purpose —
[Replace]on the method. - A second
nextin the same call throws.nexton the very lowest one is "no inner service". - Two implementations of one
IFooon the same layer are a registry error; there is no winner. - A single implementation — no proxy.
- A platform contract (
IQuantityConverter): the chain runs by layers; a tie on one layer still throws. - The class name still equals the service name. Inside the workspace's single
IDE project, put the extension in a
namespaceof your own, otherwise twoclass Adderwill collide. - The chain's proxy is a collectible
IFoo_Chain(notDispatchProxy). A script contract out of the collectible ALC and a platformIFootravel the same path: two implementations across layers andGetService<IFoo>()hands back the chain.
GetService<IFoo>() and Run in the designer on an extension link both see the
chain with that link on the outside.
A driver, a command, a tx — the same next#
The owner does not have to to call next — it writes the posting entries /
ExecuteAsync / the engine's base.. An extension is obliged to:
public override async Task<CommandResult> ExecuteAsync(CommandContext context)
{
var prior = await nextAsync<CommandResult>(context);
if (!prior.Success) return prior;
// the document is already past the owner
return prior;
}
A missing next on an extension is ZOCOC003. [Replace] stops the chain on
purpose. A tie of two links on one layer is an error.
Renaming an object detaches the whole chain#
Handlers find their object by name (objectName in the script envelope),
not by objectMetaId. Transaction scripts are the other way round — by their
binding to the subtype, that is, by metaId.
Rename a document and the metaId wiring updates, while the envelopes keep the old name. The document continues being posted — the stock and revenue movements happen — and at the same time silently stops doing everything the handlers did.
SalesInvoice → SalesRealization is the shape: leave the old name in the
envelopes and not one of the invoice's handlers is called any more.
| What does not fire | What is lost |
|---|---|
OnBeforePost |
the legal entity is not stamped, TaxRateApplied stays 0 |
OnAfterPost |
no TaxCalculation is born → no VAT, no GL, no loyalty points |
OnBeforeSave |
the customer's PaymentTerm and Contact are not copied |
How to tell "the handler was not called" from "the handler crashed". Put
return EventResult.Cancel("PROBE") as the first line. It depends on no service
at all, so it cannot break by itself: if the posting went straight past it, the
handler is not hooked up. Probing with a logger is not allowed — GetService is
able to throw, and a probe that crashes is indistinguishable from one that was
never called.
The gate .github/scripts/check_script_bindings.py in the workspace checks
every envelope's objectName against the object's real name, looked up by its
objectMetaId.
What is not CoC#
| Mechanism | How to extend it |
|---|---|
| Events | a neighbouring class + [ExtensionOf("…")] + next(...) |
A model service IFoo (Core's included) |
the same name in your own model + next<T>(the same arguments) |
| A driver / a command / one tx | the CoC vertical: next / nextAsync, the "Add yours on top" strip |
A Core kernel driver (StockBaseTotalDriver, …) |
a wrap with next(), not a partial class named after the engine |
| Several tx on one subtype | a list by executionOrder (a horizontal; not this strip) |
The platform's IDataService / IDocumentManager / ISqlService |
not wrappable from the workspace |
Checking#
- Compiling the model:
POST /api/metadata/models/compile— there must be noZOCOC001/ZOCOC003/ZOCOC004. Compile-all writes the missing[ExtensionOf("…")]onto a handler of another model's object by itself and saves the script; Check in the editor still showsZOCOC004, so that the rule stays visible to the author. - Services:
POST /api/services/rebuildand Check on the extension's script — there must be noZOCOC002. - Platform tests:
EventChainOfCommandTests,ServiceChainOfCommandTests,ServiceCoCTests,ExecutableCoCTests,CommandChainOfCommandTests,TotalDriverChainOfCommandTests,TransactionChainOfCommandTests,ChainOfCommandAnalyzerTests. - Skills:
zuloone-extend§2 / §2b / §2c–§2e,zuloone-new-service,zuloone-new-document§3,zuloone-new-command. - The workspace contract:
docs/WORKSPACE.md§8.1 / §8.7.