Interaction and actuation
Action, Entitlement, Behaviour, Animation. How people change a twin, and how a twin commands a device, in one direction only.
A twin people can only look at is a dashboard. Interaction is how they change it, and for a Digital Twin, how a change reaches the physical thing. Klorad models it as a chain of four parts that runs in one direction.
- ActionWhat can be done to an object, and by whom it is asked.
- EntitlementAccess decides: this actor, this World, this object.
- BehaviourThe predefined change, applied atomically.
- AnimationShows the change. Changes nothing.
For a Digital Twin: Command → Actuator → device → reading → the twin's state changes.
| Part | What it is | Who defines it |
|---|---|---|
| Action | Something that can be done to an object: "Dim", "Open", "Mark emptied". It says which objects offer it (appliesTo), how to check its parameters (validate), which Behaviour follows, and for a twin, which command to send. | You, as an ActionDefinition |
| Entitlement | The decision whether this actor may take this Action on this object in this World. World only asks; Access answers. | @klorad/access (createAccessPolicy), or allowEveryone for a single user tool |
| Behaviour | The predefined change that follows an allowed Action: it patches the object and may ask for an Animation. | You, as a BehaviourDefinition |
| Animation | A request to renderers to show the change (pulse or highlight). It changes nothing. | The Behaviour, through animate() |
One way only
A Behaviour receives update and animate, and nothing that could request an Action. That is
deliberate: chains of Actions triggering Actions are how automation loops and privilege
escalation happen. If something should follow an Action, a person or a rule outside the chain
requests it, and the Entitlement checks it again.
A Behaviour's changes are collected and applied together after it returns. If it throws, or
produces an invalid change, nothing changes and the outcome is failed.
Outcomes
interaction.perform(request) resolves to an ActionResult. Every outcome, refusals included,
is also one action event on the scene, so an audit log sees what was denied as well as what
was done.
| Outcome | Meaning |
|---|---|
done | The Behaviour ran. |
sent | A twin's command was accepted by its device, and the Behaviour ran. The twin's physical state follows when the device reports it. |
denied | The Entitlement refused. reason says why. |
invalid | Unknown Action or object, the Action does not apply to the object, or its parameters failed validate. |
failed | The actuator refused or failed, or the Behaviour threw. World state is unchanged. |
Commanding a Digital Twin
An Action with a command applies to twins. When it is allowed, Klorad builds a Command
(with the twin's entity, the actor and the instant) and sends it through the Actuator
registered for the twin's binding.source, before the Behaviour runs. If the device refuses,
the outcome is failed and nothing changes.
The twin's physical state is never written by the Action. It changes when the device reports
its new state as a reading, the same path every reading takes. Typically the Behaviour records
the request (requestedAt) and the reading confirms it.
import { allowEveryone, createAccessPolicy } from "@klorad/access";
import { connectSource, fixtureDevice } from "@klorad/api/integration";
import { createInteraction, createScene } from "@klorad/api/world";
const scene = createScene({ coordinateSystem: { origin: { lat: 40.62637, lon: 22.94838 } } });
scene.add({ id: "lamp", name: "Street lamp", position: { east: -8, north: 4 }, properties: { level: 100 } });
scene.add({
id: "barrier",
name: "Entrance barrier",
position: { east: 6, north: -2 },
correspondence: "twin",
binding: { source: "plc", entity: "barrier-1" },
});
// The physical side: a simulated PLC that reports the barrier's state and accepts commands.
const plc = fixtureDevice({
id: "plc",
entities: { "barrier-1": { state: "closed" } },
respond: (command, now) => (now.state === command.name ? `already ${now.state}` : { state: command.name }),
});
connectSource(scene, plc);
const policy = createAccessPolicy({
scene,
roles: { operator: "*", visitor: [] },
worlds: [{ id: "site", name: "Site", scope: { all: true }, capabilities: { actions: "*" }, principals: [{ kind: "role", id: "operator" }] }],
});
const interaction = createInteraction(scene, {
entitlement: policy,
actuators: [plc],
behaviours: [
{ id: "dim", run: ({ update, animate }) => (update({ properties: { level: 30 } }), animate({ kind: "pulse" })) },
{ id: "requested", run: ({ update, now }) => update({ properties: { requestedAt: now } }) },
],
actions: [
{ id: "dim", label: "Dim", appliesTo: (o) => o.id === "lamp", behaviour: "dim" },
{ id: "open", label: "Open", appliesTo: (o) => o.correspondence === "twin", behaviour: "requested", command: () => ({ name: "open" }) },
],
});
const operator = { id: "eleni", roles: ["operator"] };
const visitor = { id: "guest", roles: ["visitor"] };
async function demo() {
await interaction.perform({ action: "dim", objectId: "lamp", actor: visitor, world: "site" }); // { outcome: "denied", reason: ... }
await interaction.perform({ action: "dim", objectId: "lamp", actor: operator, world: "site" }); // { outcome: "done" }
await interaction.perform({ action: "open", objectId: "barrier", actor: operator, world: "site" }); // { outcome: "sent", command }
// About 600 ms later the PLC reports state "open": scene.latest("barrier", "state")?.value === "open"
return interaction.available("lamp", operator, "site").map((a) => a.id); // ["dim"]
}
// For a demo or a single-user tool, allowEveryone says so in its name.
const playground = createInteraction(createScene({ coordinateSystem: { origin: { lat: 0, lon: 0 } } }), { entitlement: allowEveryone });
export { demo, playground };In React, wrap your tree in <InteractionProvider interaction={interaction}>; useActions lists
what an actor may do on an object and useAction performs one and tracks its state. See
@klorad/react.
Something wrong or unclear? Tell your contact at Prieston Technologies.