Skip to content
Air-Cloud docs

The acting node and elevation

Every invocation of a function acts at exactly one node of your product's organization tree. The records it reads and writes, the mail it sends, the events it publishes, what it is metered against and what its log says are all at that node. This page says which node that is, how a function moves below it, how it manages the tree, and how it may count records it cannot read.

Which node an invocation acts at

the invocation acts at
a route inside [authenticate] (a session) the home of the end user's selected membership
a public [[route]] in config/routes.toml the route's org_path
an event trigger ([[triggers.events]] in function.toml) the trigger's org_path
a cron trigger ([[triggers.cron]]) the trigger's org_path
aircloud functions invoke your own node (the membership you act through), or with --at <node> a node at or below it

A public route, an event trigger and a cron trigger have no end user, so they must declare their node:

[[route]]
prefix = "/api/hooks"
function = "accounts"
org_path = "/crm"
[[triggers.cron]]
schedule = "0 3 * * *"
org_path = "/crm/companies"

[[triggers.events]]
topic = "accounts.opened"
org_path = "/"

aircloud check refuses an entry without one: route.org_path_required, trigger.org_path_required. /, the root, is a valid choice only when you write it — nothing defaults to it, so acting over every customer of the product is always visible in your repository. The generators write org_path = "/" on every route and trigger they add; edit it to act lower. A route to a container runs no function and declares none (route.org_path_not_invocation).

Every node you name is declared

A node exists only when something creates it, so every node your repository names — a public route's or a trigger's org_path, an [mcp] facet's org_path, an [[authenticate.route]]'s at — must be one each production activation creates, or one created at run time below a declared parent:

you may name because
/ the tenant root always exists
admin_org_path, default_org_path, the [authenticate.workspaces] parent, every [authenticate] nodes entry, and each of their ancestors each production activation creates them (Members and roles)
any node strictly below the [authenticate.workspaces] parent workspace signup creates those at run time (Workspaces)

aircloud check refuses any other node (org.path_undeclared), so a product never fails at run time on a node nothing created. To act at a fixed node of your own, declare it:

[authenticate]
nodes = ["/crm", { path = "/crm/companies", display_name = "Empresas" }]

An entry is the node's path, or { path, display_name } to name it for people: each production release sets the declared name on the node. A node below a fixed node is not declared by it: declare that node too. A node created by a function through nodes.create outside the workspaces parent exists only once the function has run, so declare it in nodes if your repository names it. Removing a node from nodes never deletes it: archiving and erasing a node are your explicit acts (aircloud org nodes archive, aircloud org nodes delete).

A function with no end user is not owner-scoped: it reads every customer's owned records at or below its node, and what it writes is stored at its node (Owner and shared entities). An event trigger receives only the events published at or below its node.

Narrowing — only downward

A function may move to a node at or below the one it entered at, through the guest interface acting-node: current() answers the node the next call acts at, and narrow(path) moves there. A node above or beside the entry node is refused (capability.forbidden). Narrowing grants nothing; it only shrinks what the function already reaches.

import { current, narrow } from "aircloud:functions/acting-node@1.0.0";

// A nightly cron declared at /crm/companies, one client at a time.
for (const company of companies) {
  narrow(company.org_path); // every read and write that follows acts at that client's node
  // …
}

Moving between nodes inside the entry node's subtree is not widening, so a sweep declared once at a parent narrows to each child in turn.

Managing the tree from a function

A function manages nodes, members, profiles, groups, denies and workspaces through the platform's tree capabilities, with the authority of its acting node, and only through the operations it declares:

[org]
operations = ["nodes.create", "members.invite"]

nodes.create takes {org_path, display_name?, seed?}: a display_name names the new node for people (1 to 100 characters, no control character; org_node_name_invalid otherwise), and nodes.list answers each node's display_name, and the acting node's as at_display_name, when it has one.

Each name is an area and one of its operations — nodes.create, nodes.archive, members.invite, members.update, workspaces.create and so on, one per operation the tree capabilities offer; aircloud check refuses a name that is not one of them (org.operation_unknown), and an undeclared call is refused at run time (capability.forbidden). A cron declared at / manages the whole tree; one declared at /acme manages only below /acme. A session function manages members with the end user's own authority, never more.

Archived nodes

When a node is archived (aircloud org nodes archive), everything acting at it or below stops: sessions, public routes, events, crons and direct invokes are refused with org_node_archived until you unarchive it.

Elevated aggregates

An owned entity keeps each customer's records to that customer. Some answers need a number across all of them — "1 seat left", "you are 2nd on the waitlist" — without showing anyone else's record. A session function declares exactly the aggregate it needs in function.toml:

[[elevate]]
entity = "booking"
ops = ["count", "sum", "max"]
filter = ["slot"]
group_by = ["status"]
fields = ["seats"]
scope = "subtree"
key what it says
entity the owned entity whose owner scope the aggregate lifts
ops the aggregate functions it may run: count, sum, avg, min, max
filter the fields it may filter on
group_by the fields it may group by
fields the fields sum, avg, min and max may aggregate
scope node: the records stored at the acting node; subtree: the acting node and below

The function calls aggregate-elevated of the flex-query capability. That one aggregate runs over every owner's records; every other read of the same invocation stays owner-scoped, and the tree, denies and field policy still apply. No record and no owner can ever be named: the record id, the entity's owner field and its unique fields are refused in every role (elevate.field_identifier), so the answer is a number over records nobody can single out. A call outside the declaration is refused (capability.forbidden), each use is recorded, and it is metered. aircloud check also refuses an unknown entity (elevate.entity_missing), operation (elevate.op_invalid), field (elevate.field_unknown) or scope (elevate.scope_invalid).

A function with no end user is not owner-scoped to begin with, so an elevation declared for it lifts nothing.