# 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 ` 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: ```toml [[route]] prefix = "/api/hooks" function = "accounts" org_path = "/crm" ``` ```toml [[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](members.md)) | | any node strictly below the `[authenticate.workspaces]` parent | workspace signup creates those at run time ([Workspaces](workspaces.md)) | `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: ```toml [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](entities.md#requests-with-no-end-user)). 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. ```js 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: ```toml [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`: ```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. --- Rendered from aircloud 0.1.12 at commit d84a0ce6dae53629277f656d96d81549df36807e. --- # The AI-Skill The CLI carries an AI-Skill: an Agent Skills bundle (`SKILL.md` and its references) that teaches a coding agent the CLI, the product grammar, the generators, and the rules and error codes. It is rendered from the same catalogs as this site's reference, by the very binary you run, so it always describes the CLI you have. ## Installing it ```sh aircloud skill install --agent claude aircloud skill install --detect --prompt aircloud skill install --dir ./.claude/skills ``` `--agent` installs for one agent; `--detect` installs for every supported agent found on the machine, and with `--prompt` asks before each. The skill is written to the agent's user skills directory, from any directory, with no session and no network: | agent | skill directory | MCP registration | |---|---|---| | `claude` | `~/.claude/skills/aircloud/` | `~/.claude.json` | | `codex` | `~/.agents/skills/aircloud/` | `~/.codex/config.toml` | | `gemini` | `~/.gemini/skills/aircloud/` | `~/.gemini/settings.json` | | `copilot` | `~/.copilot/skills/aircloud/` | `~/.copilot/mcp-config.json` | | `cursor` | `~/.cursor/skills/aircloud/` | `~/.cursor/mcp.json` | `--dir` writes the skill under another root instead, for an unlisted agent or a project. Installing also registers the [dev MCP server](mcp.md) with the agent as a user-scope stdio entry (command `aircloud`, arguments `mcp`); it pre-approves no tool. `--with-mcp=false` skips the registration. The CLI never overwrites a directory it did not write. A copy it wrote follows the `aircloud` installation that wrote it: when that installation's build changes — after `aircloud upgrade`, or after a downgrade — it re-renders the copy on its next run. Any other `aircloud` (a second installation on your PATH, a build you run from a checkout) leaves the copy as it is and tells you so; `aircloud skill install --agent ` makes the copy follow the `aircloud` you run it with. ## Reading and checking it ```sh aircloud skill show aircloud skill doctor ``` `skill show` prints the skill's `SKILL.md`. `skill doctor` checks that your region speaks the contracts this CLI was built for and that every installed copy of the skill is this CLI's; it exits 0 only when both hold, and names each domain or copy that differs. A difference means the CLI, the skill or the region is a different build: upgrade the CLI and reinstall the skill. --- Rendered from aircloud 0.1.12 at commit d84a0ce6dae53629277f656d96d81549df36807e. --- # How authorization works Everything that decides who may do what in your product, on one page: the organization tree, memberships, profiles, grants, groups, denies, field policy, the node each request acts at, and elevation. Each section links to the guide that puts it to work. The short version: **a request acts at one node of your product's organization tree, with the authority its membership holds there, and the platform reads that authority on every request.** Your end user's token says only who they are; what they may do comes from the tree, never from the token and never from your code. A change to a member's authority takes effect within seconds, on the requests that follow, not at the next sign-in. ## The picture ```text / the root: your product's base profiles live here (config/profiles.toml) └── /org Ada's membership is homed here: roles admin, staff, customer ├── /org/customers Bea signed up here as a customer, and owns her own orders └── /org/acme a client node; deny: entity.delete on orders, binding Acme and below ├── /org/acme/sales Carl: membership at /org/acme, grant "editor" here until 31 Jan └── /org/acme/ops a group "night-shift" holds a grant of "viewer" here a request ─► acts at ONE node ─► denies there and above ─► roles there ─► owner / shared ─► field policy (its membership's (refuse first, (home roles (which records) (which fields) home, or the entry's whatever grants + grants at declared org_path) confer) or above it) ``` ## The pieces - **Nodes.** Your product's end users and their records live in a tree of nodes such as `/org`, `/org/customers` and `/org/acme`. A record is stored at the node its writer acts at. A session reaches the records at its node and below; an entity can narrow that to each user's own records or open an ancestor's records for reading ([Owner and shared entities](entities.md)). You create client nodes with `aircloud org nodes create` ([Members, roles and registration](members.md#nodes)). - **Memberships.** A person holds one or more memberships in your product. Each membership is homed at one node and holds roles there. One person's memberships never sit one inside another. Every request acts through one membership: a session switches it at `/_air/memberships`; the CLI takes `--membership` when you hold several ([Members, roles and registration](members.md#memberships-and-their-ids)). - **Roles** are the keys of your `[authenticate] roles` vocabulary. A route admits a session holding any of its roles ([Routes](routes.md)); an entity's `write_roles` decide who may write it ([Owner and shared entities](entities.md#roles-each-write-requires)). - **Profiles** are named sets of roles. The base profiles are declared in `config/profiles.toml`; runtime profiles are created at a node below the root ([Profiles and grants](profiles-and-grants.md)). - **Grants** confer a profile, by reference, at a node at or below a membership's home, optionally until a time. Changing a profile changes every grant that references it. - **Groups** are memberships that share grants: a group's grant reaches each member at or below both the grant's node and the member's own home. - **Denies** remove roles or refuse operations at and below a node, whatever any grant confers, and they bind every actor there: members, functions acting there, and you. - **Field policy** decides, per record, which fields are visible, writable and required ([Field policy](field-policy.md)). - **The acting node.** A function with no end user acts at the `org_path` its route or trigger declares, and may narrow to a node below it, never above ([The acting node and elevation](acting-node.md)). - **Elevation.** A declared aggregate may count or sum an owned entity across its owners without ever naming a record or an owner ([The acting node and elevation](acting-node.md#elevated-aggregates)). - **Workspaces.** A product may let any signed-in end user create a company of their own: a node and a new membership homed there ([Workspaces](workspaces.md)). ## Who decides, in what order The platform evaluates a request in this order. The first step that refuses ends it; `aircloud authz explain` prints which step decided and the `rule` it names. | step | what it reads | refuses with | the `rule` explain names | |---|---|---|---| | the membership | the selected membership, against the projection | `session_membership_required` (a session), `membership_required` (a direct caller) | — | | the node is active | the acting node and every ancestor | `org_node_archived` | — | | denies | every deny at the acting node and above | an op-deny refuses the operation; a role-deny removes the role before the next steps | `deny.op`, `deny.role` | | roles | the membership's home roles, its grants and its groups' grants at or above the record's node | `session_role_required` (a route's roles), `flex.role_required` (`write_roles`) | `home`, `grant.membership`, `grant.group`, `write_roles` | | records | `owner`, `shared` and the tree | a record outside the scope is not found | `owner` | | fields | the `[[policy]]` rules at the record's node | a hidden field is omitted; a non-writable change is `flex.forbidden` | `field_policy` | When the authority cannot be read in time, the request is refused (`session_authz_unavailable` on a session), never treated as "no restriction". ## Asking why ```sh aircloud authz explain --sub 2891… --plane end_user --node /org/acme --action entity.delete --entity orders aircloud authz who --node /org/acme aircloud members review --window 720h ``` `authz check` answers the decision and exits 1 unless it is an allow; `authz explain` prints the whole answer: the membership, its roles and scopes at the node, the step that decided and the record layer's detail. `authz who` lists every membership whose authority reaches a node, with its roles there. Each is answered by the same evaluator enforcement uses, over the nodes at or below yours. Your coding agent asks the same question through the dev MCP server's `authz_check` ([The dev MCP server](mcp.md)). --- Rendered from aircloud 0.1.12 at commit d84a0ce6dae53629277f656d96d81549df36807e. --- # How the platform works Air-Cloud is the backend you build a product on without leaving it. Data, end-user sign-in, functions, releases, mail and the public front door come already wired together, and one CLI, `aircloud`, drives them from a product directory in a conventional shape. The point is the integration: the pieces know about each other, so you write your product, not the glue between services. This page explains the ideas the other guides assume. Each section links to the guide that puts it to work. ## Your account, your products, and their tenants - **Your account** is you, the Builder. `aircloud signup` creates it and `aircloud login` signs you in to it ([Getting started](getting-started.md), [Logging in](login.md)). - **A product** is one repository and everything it runs: its data, its functions, its site, its end users. `aircloud products` lists your account's products. - **Each product is its own tenant** of the platform: a hard boundary. One product's data, end users, secrets and keys are never reachable from another product, yours or anyone else's. - **Environments.** A product runs one release per environment. Every product has `production`; other environments are previews you activate releases in ([Releases and environments](releases.md)). ## The organization tree Inside a product, your end users live in an **organization tree** that you shape: nodes with paths such as `/org`, `/org/staff` and `/org/customers`. Each end user holds a **membership** homed at a node, with role keys from your vocabulary; one person may hold several, each at its own node. The tree decides what a request reaches. By default a session reads and writes the records of its node and of every node below it, and manages members at or below its node, conferring only roles it holds itself. An entity can narrow that to the records each user owns, open records kept higher up for reading, or hide and freeze fields record by record. Profiles, grants, groups and denies add roles at other nodes or take them away. The platform reads all of it on every request, never from the token ([How authorization works](authorization.md), [Owner and shared entities](entities.md), [Members, roles and registration](members.md)). ## Every request acts at one node Whatever starts it, a request acts at exactly one node, and what it reads, writes, sends and is billed for is at that node: - a **session** acts at the home of the end user's selected membership; - a **public route**, an **event trigger** and a **cron trigger** act at the `org_path` they declare; - `aircloud functions invoke` acts at your own node, or with `--at ` at a node at or below it. A route or trigger with no end user **must declare** its `org_path`: `aircloud check` refuses one without it (`route.org_path_required`, `trigger.org_path_required`). Writing `org_path = "/"` is the explicit choice to act at the root, over every customer; nothing defaults to it. A function may then narrow to a node below the one it was declared at — a nightly sweep declared at a parent visits each client in turn — and never widen ([The acting node and elevation](acting-node.md)). ## Staff and customers Your product's end users are not you. Your Builder identity runs the product; your end users use it. Even your own first admin account in the product is a separate end-user identity, invited at your declared admin node. Among end users, a common split is **staff** — people who run the product with you — and **customers** — people who use it. `aircloud generate authentication` writes that convention: roles `admin`, `staff` and `customer`, a base profile for each, admins at `/org`, staff invited, and, with open registration, customers signing themselves up at `/org/customers` with the `customer` role. Staff are not owner-scoped by default, so they see every customer's orders while each customer sees their own ([Members, roles and registration](members.md)). ## B2B2B: your clients get the same structure If your product serves other businesses, each of your clients is a node of the tree, such as `/org/acme`, with an admin of its own who invites and manages Acme's people below that node. Acme's people see Acme's data and never another client's, because the tree, not your code, draws the line. You pass the platform's structure on to your clients instead of rebuilding it: that is what B2B2B means here. A client node comes into being in one of three ways: - **you create it** with `aircloud org nodes create /org/acme`, then invite its admin with `aircloud members invite ada@acme.example --path /org/acme --scopes tenant:org --profile admin --profile-node /` ([Members, roles and registration](members.md#nodes)); - **your code creates it**: a function that declares the tree operation `nodes.create` creates nodes below the node it acts at ([The acting node and elevation](acting-node.md#managing-the-tree-from-a-function)); - **the client creates it**: with workspaces declared, any signed-in end user creates a company of their own and becomes its admin ([Workspaces](workspaces.md)). The client's admin can in turn manage Acme's own sub-nodes, profiles, groups and denies, the same way you manage yours, and never above Acme's node. Archiving a client's node suspends everything acting there until you unarchive it. ## The motors you meet The platform is built from a set of engines, called motors. These are the ones a product uses today: - **Air-Deploy — the product and its releases.** The CLI, the conventional product layout, the generators, `aircloud check`, and releases that build, test and activate your product per environment. Also the AI-Skill and the dev MCP server that teach your coding agent all of this ([Releases and environments](releases.md), [The AI-Skill](ai-skill.md), [The dev MCP server](mcp.md)). - **Air-Flex — your data.** You declare entities in `db/schema/.toml`; Air-Flex stores and validates the records, enforces required fields, enum values, defaults, owners and the tree's boundaries at one chokepoint that your code cannot skip ([Owner and shared entities](entities.md)). - **Air-Auth — sign-in and members.** End users sign in on the platform's hosted login, never on a password table of yours; roles and registration are declared in `config/routes.toml` ([Members, roles and registration](members.md), [Routes](routes.md)). - **Air-Functions — your code.** Functions run sandboxed, behind a route, on a schedule or on an event, and reach data, secrets and mail only through the platform's capabilities ([Logs and invoking functions](functions.md)). - **Air-Reflect — screens from your schema.** The site's resource pages render lists, details and forms from your entity declarations, so a field you add appears without copying it into a page. - **Air-Edge — the public front door.** TLS, the web application firewall and rate limits sized by your plan, in front of every product host. - **Air-Comms — mail.** The platform sends your end users' sign-in and verification mail, and a function can hand mail to it through its mail capability. - **Air-Billing — plans and metering.** Every metered action (a query, a record write, an invocation, a mail send) is counted against your product's plan (see below). Other motors are planned and not available yet: object storage (Air-Blob), analytics (Air-Insights), outbound connectors (Air-Connect), declarative automation flows (the flow generator is reserved and refuses), and billing your own customers through your own payment provider. ## Plans and allowances Every product runs on a plan. The plan declares, per metered action, how much is included and what happens past it: the action is refused with a typed error, or a warning to upgrade is raised, or the overage is counted, as the plan declares for that action. Your account's first products draw a free allowance. Mail is one such action. A free monthly allowance of **100 platform mail recipients** per product is **planned**: today the default plan includes no mail sends of its own. The sign-in and verification mail the platform sends to your end users is not one of your sends and will not count against that allowance. ## Sovereign infrastructure Air-Cloud runs on infrastructure the platform operates itself, not resold from a cloud provider: its own servers, its own keys, its own mail egress. For you it means the platform is not reselling another provider's services at a margin, which is what lets it bundle everything into one product, and that the keys signing what you install are held by the platform alone ([Installing and verifying the CLI](install.md)). You do not need to do anything about it; it is what makes the all-in-one affordable. --- Rendered from aircloud 0.1.12 at commit d84a0ce6dae53629277f656d96d81549df36807e. --- # Currency The currency a product charges is a fact of the product, declared once in `aircloud.toml` as an ISO 4217 code: ```toml [i18n] default_locale = "pt-BR" available_locales = ["pt-BR", "en"] currency = "BRL" ``` Formatting an amount with `numberToCurrency` renders it in the product's currency under every locale: the currency gives the code and the default number of fraction digits (its ISO 4217 minor unit — two for BRL, none for JPY), and the viewer's locale gives the symbol and the pattern. A product charging BRL shows reais to every visitor, as `R$ 1.234,50` in `pt-BR` and `R$1,234.50` in `en`. A call's `precision` overrides the fraction digits, and a call's `unit` formats an amount that is in another currency. The currency is never assumed. Without `[i18n] currency`, a release whose code formats a currency the build can see is refused, and a formatting call that only runs at run time renders the generic currency sign `¤`. A value that carries its own currency — an amount Air-Billing billed — is formatted in that currency, never in the product's default. --- Rendered from aircloud 0.1.12 at commit d84a0ce6dae53629277f656d96d81549df36807e. --- # Owner and shared entities An entity is declared by its file `db/schema/.toml`. What an end user's session may read and write of it follows from where the user sits in the organization tree, and from two declarations of the entity: `owner` and `shared`. They exclude each other. ## The default: at or below the user's node With neither key, every request of an end user reaches the records of their node and of every node below it. A record is stored at the node the request that created it acts at. ## Owned entities ```toml display_name = "Order" owner = "customer_sub" owner_bypass_roles = ["admin", "staff"] [fields.customer_sub] type = "string" [fields.total] type = "decimal" ``` `owner` names a `string` field of the entity that holds the owning end user's identity, one line. An end user then reaches only the records they own, and a record they create is owned by them whatever the request says. `owner_bypass_roles` lists roles of `[authenticate] roles` whose holders are not owner-scoped on this entity: that is how staff read every order. `aircloud check` refuses an `owner` field that does not exist or is not `string`, and a bypass role the vocabulary lacks. `aircloud generate scaffold` declares an owned entity with `--owner`; its bypass roles default to the vocabulary's roles other than `customer`: ```sh aircloud generate scaffold order total:decimal --owner customer_sub ``` ## Shared entities ```toml display_name = "Product" shared = true ``` Records of a `shared` entity stored at a node above the user's are **readable** by them — a catalogue kept at the top of the tree is read by every customer below it. Writes stay at or below the user's node. An owned entity can never be shared. ## Roles each write requires ```toml display_name = "Invoice" write_roles = { create = ["staff"], update = ["staff", "clerk"], delete = ["admin"] } ``` `write_roles` names, per operation, the roles an end user must hold to write a record: each list is any-of, each role a key of `[authenticate] roles` (`aircloud check` refuses another, `schema.write_role_undeclared`), and an operation it does not list needs no role. Air-Flex enforces it on every path a write takes: an end user holding none of the operation's roles at the record's node is refused `flex.role_required` (403, naming the entity, the operation and the roles that would admit it). It binds acting end users only, and never restricts reads — `owner` and `shared` decide those. ## Labels An entity's and a field's names are ASCII identifiers. Their label in the product's default locale is written verbatim with `--display-name` (the entity) and a field's `:display_name=