Platform Entities & UI
The Stone Age Console provides a unified interface for managing the logical and physical structures of your IoT environment or Event-Driven Architecture. This document explains the primary entities used to organize your data and how they interact with the user interface.
These entities live in the Control Plane (PocketBase) — they're the source of truth for identity, inventory, and relationships. At provisioning time and at runtime, they shape what flows through the Data Plane (NATS subjects, KV buckets, Nebula certificates). See Architecture for the Control/Data Plane split in full.
1. Organizations & Memberships
Organizations are the top-level container for all data and infrastructure. Every resource in the platform belongs to an Organization and platform Users can belong to multiple Organizations.
Organizations
- Isolation: Each Organization receives its own private NATS Account and Nebula Certificate Authority.
- Organization Code: A short slug (
acme,northwind) that is the one globally unique identifier in the ecosystem — every other code on the platform is unique only within an Organization. It is derived from the name when you don't supply one, and it roots the public namespace: the managed-org subject rewrite carries it, sibling apps name a tenant by it, and it is the handle that lets a consumer join their data to the platform's without a mapping table. Optional, but immutable once set — creation refuses a colliding code rather than inventingacme-2, because a wrong code would be baked into signed account JWTs and printed on labels long before anyone noticed. A leading digit is fine (816techis a valid code). See ADR 0002. - Ownership: An organization has an Owner, who holds full tenant authority and cannot leave it. Creating, editing and deleting the organization record are all Platform Operator actions: the record carries the tenancy flags and drives NATS Account and Nebula CA provisioning, and deleting it blanks rather than cascades — orphaning the whole inventory — so no tenant role has an update or delete path to it. See Authorization §3.
- Suspension: A Platform Operator can clear an organization's Active flag, which withdraws its NATS account: every device, agent and browser in the tenant disconnects at once. It is reversible — no credential is revoked, so everything reconnects when the flag is set again — and deliberately narrow: Nebula is untouched, and the tenant can still sign in to the console and read what it owns. The operator and system organizations refuse it. See Authorization §3.1.
- Invites: Owners and Admins can invite users to join their organization via email. Invites generate a secure token used for onboarding. Invitations can offer any role except
owner.
Memberships
A Membership binds a PocketBase User to an Organization.
- Roles (per-organization):
Owner: Full tenant authority. Identical toAdminin every API rule — the only difference is that an Owner cannot leave their own organization.Admin: Full tenant authority — members and invitations, NATS and Nebula infrastructure, Thing/Location types and contracts, and the identity links on a Thing.Member: Creates and edits Things and Locations, and reads the contract collections (Thing Types, Operations). Cannot delete a Thing or Location, cannot attach identities to one, and cannot read the infrastructure collections at all.Viewer: Read-only staff. Browses the inventory screens and uses dashboards, and writes nothing anywhere. Adding it needed no rule change at all — a role that names itself in no write branch is denied by construction.Dashboard: An appliance login for an unattended screen — the Visualizer and its own settings page, nothing else. It holds no write capability, which is exactly why the authorization suite uses it as the probe that proves an allowlist works.- Both, like every role, can still read the one NATS identity linked to their own membership, which is what the browser connects with. Neither restriction is a NATS restriction: what a login can do on the bus is whatever its linked
nats_usersrole permits, set independently.
- Identity Linking: A critical feature of the Membership is the Linked NATS Identity. This allows a human user to browse the NATS bus using specific credentials assigned to their membership for that specific Organization. Since users can be members of multiple Organizations, this NATS user relation is stored on the membership record itself. Access to it is row-scoped, not field-hidden: a member, viewer or dashboard holder sees exactly that one
nats_usersrow and no other — see Authorization §4. Because the read follows the link, choosing which identity a membership links to is an Owner/Admin action (on the member's detail page); every role may clear its own link from Settings, but not point it somewhere else.
Cross-Organization Roles
Two roles exist outside the per-organization Membership model and apply to the user account itself:
- Platform Operator (
users.is_operator = true): Can create, edit, suspend and delete Organizations, and invite users into any Org. Editing the organization record is exclusively a Platform Operator action — no tenant role, not even Owner, has an update path to it. A Platform Operator is also the only identity that can read the audit log (audit_logs); no tenant role can. Platform Operators are the day-to-day platform administrators and the recommended identity for managing the system from the UI. The first one is created by thebootstrapcommand, which — along with the embedded admin panel — is the only way to grant Platform Operator status. The API cannot, and that includes a Platform Operator creating a user: the flag is refused there too. - SuperUser (
_superuserscollection): A backend service account with full database access regardless of API rules. Created via./stone-age superuser upsertand intended for infrastructure-level management — schema imports, NATS Operator/System Account seeding, and other platform-level concerns. SuperUsers are not members of any organization; they sign in at the embedded admin UI (/_/).
Permissions
Permissions are enforced solely by PocketBase API rules on each collection. (One server-side hook enforces an invariant rather than a permission: no relation may point into another organization's records. See Authorization.) The UI's capability map decides which menu items and buttons render — it is navigation convenience, not the security boundary, and a hidden button is still a reachable endpoint for anyone holding a token.
The authoritative capability matrix lives on one page: Authorization & Roles. Rather than duplicate it here, the highlights that most often surprise people:
OwnerandAdminare the same allowlist in every rule. Grantingadmingrants full tenant authority.Memberdoes create and edit Things and Locations. It cannot delete them, deactivate them, or attach a NATS user or Nebula host to a Thing — a member who could re-point those relations at a privileged identity and then authenticate as the Thing would have a credential-theft path, and one who could clearactivecould take any device in the org off the network. Members create and edit inventory; decommissioning it is a management action.Member,ViewerandDashboardcannot read the infrastructure collections at all (nats_users,nats_roles,nats_account_exports,nats_account_imports,nebula_networks,nebula_hosts). They receive an empty list, not a filtered one — with the single exception of their own linked NATS identity.- Editing the Organization record, and reading the audit log, are Platform-Operator-only.
- Every role, including
Dashboard, can rotate its own NATS credential (POST /api/me/nats-creds/rotate) — unless that identity is suspended.
Self-Service Credential Rotation
Any authenticated identity with a linked NATS user — a users membership or a things record — can rotate its own credential:
POST /api/me/nats-creds/rotate
It takes no id parameter: it only ever targets the caller's own linked identity, so there is no other identity it could be aimed at. Available to every role, including dashboard. Afterwards, re-read your own record to pick up the new .creds.
The reason this is a route rather than a permissive update rule is that a PocketBase rule cannot express a single-field allowlist, and the field that must stay closed is consequential — nats_users.publish_permissions is copied verbatim into the JWT the platform signs. A suspended identity (active = false) gets 403: a re-mint would be issued after the revocation cutoff and quietly lift the suspension. Suspending, reactivating and revoking stay Owner/Admin actions — and note that on the NATS user's detail view, Revoke is for leaked credentials: it moves the identity to a new key pair and hands back a working replacement, leaving it active. To take an identity out of service, deactivate the Thing that holds it. See Authorization §4.
2. Locations
Locations define the physical or logical hierarchy of your environment. They answer the question: "Where is this thing?"
Concepts
- Hierarchy: Locations support parent/child relationships (e.g.,
Global > North America > Chicago > Warehouse A > Row 4). - Location Code: A unique identifier (e.g.,
CHI-W-A), unique within the Organization (ignoring case) and matching^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$— no dots, no NATS wildcards, no spaces (why). Prefer the name already on the door or drawing (RM-204); a Location saved without a code gets a generated one under its type's prefix, so none is left without. It namespaces the Digital Twin in the NATS Key-Value store, it is the join key a sibling app resolves a ticket or work order against, and it is the payload of the site's QR label. Immutable once set: changing it orphans every twin key, label and external history pointing at it. The Location's type is frozen once set too. - Path: Computed by the server on every save: the Location's code and every ancestor's, from the root down, with
/between and at both ends (/KC/BD-3/RM-204/). The end slashes mean/BD-3/never matches inside/BD-30/. It makes "everything under BD-3" one filter,location.path ~ '/BD-3/'on Things andpath ~ '/BD-3/'on Locations, and it is what long-term dashboards filter by site with. Moving a Location under a different parent rewrites its path and every path beneath it in the same save. A move under the Location itself or one of its descendants is refused. Deleting a parent makes each child a root, with its subtree. A path sent by a client is ignored. The Location page shows it. (ADR 0004)~is SQLLIKE, so an_in a code matches any one character; with both slashes required, that rarely matters. - Metadata: A flexible JSON field for storing site-specific data like time zones, contact info, or local gateway IPs.
Mapping & Visualization
The UI provides two distinct ways to see your locations:
- Geospatial Map: A global view using Leaflet, plotting locations by Latitude and Longitude.
- Floor Plans: An image-overlay system. You can upload a JPG/PNG of a floor plan and "drag and drop" Things onto the map to represent their physical position in a room.
The map draws one pin per site, not one per location. A location gets a pin only when nothing above it in the hierarchy has coordinates; everything below folds into that pin and is reached through its drawer. Without this, a campus, its buildings, their floors and their rooms all carry coordinates within a few metres of each other and every address becomes a pile — and "Room 302" is not a fact at map scale anyway. Interior geography is what the floor plan is for.
It promotes the outermost mapped ancestor rather than the root, because whether a tenant puts coordinates on the campus or only on the buildings is a modelling choice the platform does not control. Unmapped intermediate ancestors are walked through, so a room under an unmapped floor still folds onto its building. Pins that remain stacked by pure geography — two adjacent sites — are then clustered, which is the other half of the same problem and is not solved by folding.
Searching flattens the map back out, matching the list view beside it: one search box on one screen should not produce two disagreeing counts.
3. Things
A Thing is any entity that produces or consumes data — or just an asset you want a record of. In Stone-Age.io, a Thing is a first-class Auth Record, and that one record doubles as the device's identity on the messaging fabric and the mesh. See Architecture §3.1 — Inventory-as-Identity for why the platform collapses those two registries into one.
Pure inventory is a supported use. The identity relations below are optional; a Thing with neither is an asset-tracking row and nothing more. Nothing on this page obliges you to put a device on the bus.
Concepts
- Identity: Because Things are an authentication collection, they can log in to the PocketBase API directly to fetch their own configuration. An Owner or Admin can set a new password from the Authentication card on the Thing's edit form if it is lost — type it in and save; nothing is generated or displayed. (The only time the platform shows a Thing password is the one it generates at creation.)
- Thing Code: Same character rules as the Location code, used for NATS namespacing (e.g.,
camera.CA-9KD-4PX), and likewise the join key for sibling apps and the payload of the device's QR label. Immutable once set, for the same reasons. Leave it blank on the create form and the server generates one under the Thing Type's prefix, likeCA-9KD-4PX, when you save. To put codes on a batch of devices, create the records first and print their labels from the list. A code stencilled on the hardware (DOOR-1) is still the right one to type in. See Generated codes. - Type: Frozen once set. The code prefix and the default subject are both derived from it, so a wrong type is fixed by deleting and recreating the Thing, ideally before it is provisioned. See A type is frozen once set.
- Metadata: Used to store device-specific state that doesn't change often, such as hardware revision, install date, or calibration offsets.
- Active: An Owner/Admin switch for taking the device out of service without deleting its record and history. Deactivating is a real decommission — the device is signed out immediately, cannot sign in again, its NATS identity is suspended, and its Nebula certificate is blocklisted by every peer as their configs are redeployed. The detail view banners the state, and the list greys the row. Reactivating issues a new
.credsfile; the old one stays revoked. Deactivate rather than delete: deleting a Thing touches neither identity, so its credential keeps working and its certificate stays trusted with nothing left pointing at them. See Authorization §4.2.
Infrastructure Binding
Binding is what turns an inventory row into a participant on the fabric. It is optional and reversible — the console's create form offers three modes per identity (auto to mint a new one, link to attach an existing one, none to leave it unbound), and POST /api/org/things performs the Thing and both identities in a single transaction so you never end up with a half-provisioned device.
A Thing is typically linked to:
- A Thing Type: The contract that declares what subjects the Thing uses and what message shapes it exchanges. See Thing Types.
- A NATS User: To allow the device to publish telemetry. What it may publish or subscribe to comes from the
nats_rolesrecord assigned to that NATS user (plus any per-user overrides) — authored directly by an Owner or Admin, not derived from the Thing Type. See Thing Types §5. - A Nebula Host: To allow secure, encrypted access to the device for maintenance or SSH.
Both relations are Owner/Admin only. A member may create and edit a Thing but cannot set or change its nats_user or nebula_host — otherwise a member could re-point a Thing at a privileged identity, authenticate as the Thing, and read credentials that were never theirs. In practice this means a member-created Thing sits un-provisioned until an Owner or Admin links its identities. See Authorization §2.
The subjects a Thing publishes to become the inputs to your Layer 1 rules — picking a clean Thing Code and subject namespace pattern is the first step in building automation that's easy to reason about later. The Thing Type makes that pattern declarative rather than implicit: rather than hoping every camera publishes on a sensible subject, the camera Thing Type declares the contract once and every camera of that type follows it.
4. Types
Types provide a way to categorize your inventory and locations. They act as blueprints for classification and filtering. Location Types are purely for organization; Thing Types have grown into the platform's primary contract layer for describing what a participant does on the fabric.
- Location Types: Categorize your sites (e.g.,
Campus,Building,Room,Cabinet). - Thing Types: The contract for a kind of participant on the fabric. A Thing Type declares a subject prefix (template like
camera.{thing}, or blank for the default{thing_type_code}.{thing}), and a set of operations (shareable verbs — publish, subscribe, request, reply — each with a subject suffix). See Thing Types for the full model.
Both kinds of type carry an optional code prefix, 1–4 capital letters (CA, BLD), copied into every code generated for a record of that type. Thing prefixes and Location prefixes are separate sets within an organization, so a generated Thing code never looks like a Location code. Changing a prefix affects future codes only.
Thing Types compose from one other collection that the UI also manages directly:
- Thing Operations: Shareable records describing individual verbs on the fabric. A single
heartbeatoperation record is typically linked from every Thing Type that emits heartbeats.
Both (Thing Types, Thing Operations) live under the Types menu group in the sidebar alongside Location Types. Creating, editing, and deleting them is Owner/Admin only, and so is the Types menu itself: the console has no read-only view of a type, so the group and its screens are shown to owners and admins only. The API read is open to every role in the organization — a member's Thing form and the Publisher widget resolve subjects against the contract — so a hidden menu is navigation, not a denied read.
5. The User Interface Features
The UI is designed to be reactive and low-latency, connecting the Control Plane and Data Plane into a Single Pane of Glass.
The Dashboard
The Dashboard is a flexible grid system where you can build custom views:
- Widgets: Add Gauges, Charts, Switches, and Maps.
- NATS-Native: Most widgets subscribe directly to NATS subjects. Data never touches the database; it flows from the device to NATS to your browser.
- Variables: Define dashboard variables (e.g.,
{{building_id}}) to create a single dashboard that can be "switched" to show data for different sites/things/etc. - Thing Type-aware binding: The Publisher widget can bind to a
Thing + Operationpair. When bound, the subject auto-resolves from the Thing's context against the Thing Type's templates and renders read-only. The payload stays free text — themessage_schemascollection that once drove a typed form was dropped, because nothing validated against it. See Thing Types for the contract model that powers this.
The Digital Twin
Every Location and Thing with a valid Code gets a Live State panel on its detail view, showing the keys under thing.<code> or location.<code> in the organization's twin buckets. It is the same KV browser used for every other bucket, with a second bucket attached — so tree and flat views, filtering, revision history and the detail drawer all behave identically.
It has two tabs, because there are two buckets:
- Reported (
twin) is what the device says. It is read-only — the edge overwrites it, so an edit button here would be a lie: the value returns on the next sync. - Desired (
twin_desired) is what you want. This is the writable half, and it is a console user's actual control. Setpoints and configuration belong here; commands likerebootdo not (send those as a message oncmd.>— a durable "reboot now" is a bug), and neither do thresholds or alarm ranges (those are rules over reported state).
Where the two disagree, the row shows the values themselves — "auto" → "manual" — rather than a status word, and the detail pane pairs them in adjacent columns. It says differs, never "pending": nothing in the platform pushes a desired value into a device, so a word implying a control loop in progress would be describing something that does not exist. twin_desired delivers the value to the edge's local KV; what acts on it is your firmware or your rules.
Only the keys present in a desired value are compared, so extra fields a device reports are ignored. That is deliberate — full equality would flip every assertion you ever set to "differs" the day a device starts reporting one new field.
The same KV buckets are what Layer 1 rules read and write for stateful operations like alarm stacking. See Architecture §4 for the full model, and Automation for the KV-state patterns.
The Control Plane does not create these buckets on its own. The Control Plane holds the NATS Operator key but has no reach into an organization's own account, so it cannot provision them. Creation is the console's Initialize button, or the Agent at the edge — whichever gets there first defines the bucket, which is why the two retention configurations are kept in step deliberately.
JetStream Streams and KV Buckets
Owners and Admins can manage the org's JetStream resources directly from the UI without dropping to the nats CLI. These views connect over the same NATS WebSocket session the rest of the UI uses, so changes take effect immediately.
- Streams (
/nats/streams): create, edit, inspect, and delete JetStream streams. The form covers the common operational knobs — captured subjects, retention policy (limits/interest/workqueue), storage backend (file/memory), max-messages / max-bytes / max-age limits, replicas, discard policy, and duplicate window. - KV Buckets (
/nats/kv): create, configure, and inspect Key-Value buckets. The form covers history depth, max bucket size, max value size, TTL, and replicas. The detail view embeds a KV Dashboard that lets you browse keys, view current values, and watch live updates as keys change.
Both views appear in the sidebar only when the browser is connected to NATS — the operations execute against the live cluster, not against PocketBase. What you can create is bounded by your NATS role's permissions and, in total, by the account's JetStream storage limits, which are set when the organization's account is provisioned. Layer 1 rules and stream processors consume the same streams and buckets you create here; the UI is a convenience surface, not a separate runtime.
Codes and QR Labels
Any Location or Thing with a Code gets a Label button on its detail view, producing a QR label to print and stick on the equipment. A record with no code gets no button — the payload is the code.
The Things and Locations lists have a Label button too, and it prints the whole result set of the current filter, not the current page — the search box is the selection mechanism, and the count rides in the button so the scope is visible before you click. Records in that set without a code are skipped and named above the preview: a silent drop is only discovered at the site.
- The payload is the bare code. Not a web address, not
org/kind/code— justDOOR-1. A sticker on a wall in a public corridor is something a stranger can replace, and a payload containing a URL would let a forged label send a person to arbitrary content. A bare in-system identifier means the worst a forged label achieves is opening the wrong record inside an app you were already signed in to. It also buys error correction: a short code at the highest correction level is a 21×21 symbol where the URL form of the same identifier needs 41×41 — four times the modules on an identically sized sticker, all of it spent on surviving scratches and grease rather than on repeating a hostname. - Scanning happens inside an app. The Scanner widget reads these labels here; sibling apps read the same label with their own scanners and land on their own view of the record — a work-order history rather than a live state panel. Nothing ever fetches the decoded string as a destination, and there is deliberately no resolver service to look one up.
- Sized to real stock. 2″ × 1″ and 4″ × 2″ plain thermal labels, in millimetres rather than pixels, so the artwork comes off the printer at the size of the stock. There is deliberately no RFID inlay keep-out — an earlier layout reserved one, at the cost of a third of the small label's text column, for media the platform has no encoder, reader or field to use. If RFID ever arrives it comes back measured against a real inlay's datasheet.
- Every label prints its code in readable text, sized to fit. That is not decoration. The symbol will eventually be scratched, greasy, or in a closet too dark to focus in, and reading the code aloud or typing it into a scanner's manual field is a designed path, not a fallback. The code's point size is fitted per label to its column, so a short code prints large rather than every code printing at the size the longest one needs, and a code that cannot fit wraps at a hyphen rather than mid-token.
- Everything printed is the Organization's own data. The top line is the organization's code (its name only if it has none), then the record's code and name; a Location's label adds a Site marker. The organization code is there because a Thing code is unique only within its organization, so
AHU-1alone is ambiguous to a technician who services several customers — and the code is immutable, on a sticker that stays put for years. There is no provider brand: it is a deployment-wide setting and says nothing true about who owns or services a particular device. And no Type: the name already says what the thing is. (An earlier version did the reverse — printed the brand and left the tenant off as reconnaissance. A device on its owner's premises already tells a passer-by whose it is.)
Because codes are unique only within an Organization, a scanner resolves a code globally and then disambiguates rather than assuming a tenant: DOOR-1 is exactly the code every organization independently invents, so a match list with a picker is honest where a silent guess would be somebody else's door. See ADR 0002.
The Activity Feed
/activity answers "who on my team changed this device, and when" — readable by every role in the organization through the API, dashboard included, and shown in the console to every role except dashboard, whose only screen is the Visualizer. Each entry names the actor, the action, the record and the time. It stores no record values: this is not the audit log, which stays Platform-Operator-only and keeps before/after values for the collections that carry no credential (field names only for the rest). Authorization §5 has the boundary between the two, and why the feed covers the five org-scoped inventory collections and not memberships, invites or the nats_* records.
Two things to know when reading it:
- The record label in a row is a snapshot, not a live join. A Thing renamed since the change shows the name it had at the time. The detail dialog on a row says so explicitly, because a reader who assumes otherwise reads an accurate feed as a stale one.
- "This record's history" is the loop it exists for. The row dialog filters the whole feed to one
resource_id, which is a different question from searching the label — a search would also catch every other record that happens to share that name. Thing and Location detail views, and the three type forms, carry matching Created / Last updated stamps so a record and the feed can be read side by side.
Photos and File Fields
Things and Locations each carry one photo — the install context that otherwise lives in one technician's head, captured while somebody is standing in front of the device. It appears beside the fields on the detail view and opens full size.
A Thing's photo is edit-only: creation goes through POST /api/org/things, a JSON provisioning route that cannot carry a multipart body. Create the Thing, then add the photo. On edit it is sent as its own request, separate from the rest of the form, because the ordinary Thing update is JSON on purpose: the member branch of things.updateRule requires nats_user and nebula_host to be unchanged, and a field left out of a JSON body counts as unchanged. A multipart body has no way to leave a field out — every value is a string and an empty one clears it — so sending the whole edit as a form would turn a member's ordinary inventory edit into a refusal. A Location's photo works on create too, since locations use the plain record API.
Every file field is now protected — an unauthenticated URL will not work
photo, a Location's floorplan, an Organization's logo and a user's avatar are all protected file fields. An unprotected PocketBase file URL is served to anyone with no auth and no expiry — the only obstacle is the random suffix on the stored filename, which makes the URL a non-revocable bearer credential that leaks through Referer headers, screenshots, proxy logs and support tickets for the life of the record. Protected, each request resolves a short-lived file token to an auth record and runs the collection's view rule. The auth token is not a file token; a URL built with one silently "worked" only while the field was unprotected. Anything you have integrated against a bare file URL needs to request a file token instead.
CRUD & Management
The platform provides a standard management interface for all entities. It uses a Responsive List pattern:
- Desktop: High-density tables for bulk management.
- Mobile: Card-based layouts for on-the-go status checks and emergency control.
Delete is not on list rows. A row button is aimed by position, and position moves under sort, search and pagination — by the time the dialog names the record, the decision is already made. Delete lives in a Danger Zone at the foot of a record's detail view (or, for the three type collections, their edit form, which is their only detail surface). Invitations keep a row Delete: revoking an invite is cheap and reversible.
Six deletes make you type the record's identifier first. Thing, Location, Nebula host, NATS user and Organization ask for the record's code (its name where it has none), and a Nebula network for its name, before the confirm button opens. These are the deletes that re-creating the record does not undo. For a Thing in particular, deleting is almost never what you want — see Active in §3: the Thing's NATS credential and Nebula certificate survive the delete.