跳转到内容

Manifest spec

此内容尚不支持你的语言。

A manifest is the manifest.json at the root of an app or add-on package. It tells Adminium what the package is, which tables it needs in the operator’s database, and what it adds on top of them: pages in the sidebar, roles, settings, frontends, the add-ons it needs, documents, emails, public endpoints and sample data. Adminium validates it before anything is installed, and builds the install plan from it.

In a project folder the same manifest may be written as a manifest/ folder of part files, one per table, per page and per block. Adminium composes them into this one document, so everything below applies unchanged; only where each field is written differs.

This page describes version 1 of the format, which is what Adminium 0.3 reads. For what an operator sees when they install a package, see Installing apps and Installing add-ons.

There are two kinds of manifest:

  • An app ("kind": "app") is a whole product: its own tables, its own pages and its own staff and customer screens, served at /apps/<key>/<side>/. Most of this page is about apps.
  • An add-on ("kind": "add-on") extends an app or the dashboard with code that runs inside Adminium. It shares the identity and table blocks and adds an addOn block. See Add-on manifests.
{
"manifestVersion": 1,
"kind": "app",
"key": "visits",
"name": "Visits",
"version": "1.0.0",
"publisher": { "id": "adminium", "name": "Adminium" },
"license": "AGPL-3.0-only",
"description": { "key": "mft.visits.desc", "fallback": "Book and track client visits." },
"categories": ["operations"],
"compatibility": { "minAdminiumVersion": "0.3.27" },
"requiredSchema": {
"prefixed": true,
"tables": [
{
"ref": "visits",
"label": { "en-US": "Visit", "de-DE": "Besuch" },
"labelPlural": { "en-US": "Visits", "de-DE": "Besuche" },
"keyField": "client",
"columns": [
{ "ref": "id", "type": "int", "role": "pk" },
{ "ref": "client", "type": "text", "maxLength": 80 },
{ "ref": "starts_at", "type": "timestamptz" },
{ "ref": "status", "type": "enum", "enum": ["booked", "done", "cancelled"], "default": "booked" }
]
}
]
},
"pages": [
{
"ref": "visits-calendar",
"template": "page-calendar",
"title": { "key": "mft.visits.page.calendar", "fallback": "Calendar" },
"nav": { "group": "records", "icon": "calendar-days", "order": 1 },
"bindings": { "rows": "visits" }
}
],
"frontends": [{ "side": "staff", "kind": "spa", "entry": "index.html" }]
}

Several shapes recur throughout the format.

Shape Rule
i18n message { "key": "...", "fallback": "..." }. key is a catalogue key (1–120 characters); fallback is the English text shown when the key is not in the catalogue (1–400 characters). Both are required and nothing else is allowed.
Label Either a plain string (1–256 characters), or an object keyed by BCP 47 tag, such as { "en-US": "Category", "de-DE": "Kategorie" }. A keyed label must include en-US, which every reader falls back to. Each value is 1–120 characters.
snake_case ref ^[a-z][a-z0-9_]*$. Used for table refs, column refs and setting keys.
kebab-case key ^[a-z][a-z0-9-]*$. Used for page refs, role keys, nav group keys and option list names.
Version Strict semver: major.minor.patch, with an optional pre-release and build part (1.2.0, 0.3.0-rc.4).

Every object in a manifest is strict: a field this page does not list is an error, not something Adminium ignores. See Validation.

Field Required Rule
manifestVersion yes Always 1.
kind no "app" or "add-on". A manifest with no kind is read as an app.
key yes ^[a-z][a-z0-9-]{1,79}$, so 2–80 characters. Apps and add-ons share one key namespace. apps, add-on, add-ons, dashboard, demo, index and search are reserved.
name yes Display name, 1–80 characters.
version yes Strict semver.
publisher yes { id, name, url? }. id matches ^[a-z][a-z0-9-]{1,39}$, name is 1–80 characters, url is an optional URL of up to 200 characters.
license yes An SPDX identifier for the package’s own code, 1–80 characters. It is informational.
description yes An i18n message, shown on the app’s card.
categories yes At least one. For an app: commerce, hospitality, operations, crm, internal-tools. Add-ons have their own list; see Add-on manifests.
capabilities no What the package uses; see Capabilities.
"compatibility": {
"minAdminiumVersion": "0.3.27",
"engines": ["postgres", "mysql", "sqlite"],
"requires": ["realtime"]
}
Field Required Rule
minAdminiumVersion yes The oldest Adminium the package runs on. An install on an older server is refused.
maxAdminiumVersion no An exclusive upper bound. It must be greater than minAdminiumVersion. The installer does not enforce it in this release.
engines no The databases the package’s tables work on: postgres, mysql, sqlite. At least one when present. Informational in this release.
requires no Capabilities the package cannot run without, from the same list as capabilities. Informational in this release.
updatesFrom no The installed versions this release can update in place, as a semver range (>=0.2.0, ^0.2.0, >=0.2.0 <1.0.0, ^1.0.0 || >=2.0.0), up to 120 characters. Absent means any older version.

Use updatesFrom when a release changes its tables in a way an update cannot carry. An install outside the range is not offered the update, and an update or upload of it is refused with a message telling the operator to uninstall the old version first. That is better than an update that fails half-way.

Versions are compared on major.minor.patch only: a pre-release tag is ignored, so "minAdminiumVersion": "0.3.0-rc.4" is met by any 0.3.0 build.

When a package starts using a field that an older Adminium does not know, raise minAdminiumVersion to the release that reads it. An older server then tells the operator to upgrade instead of reporting the manifest as invalid.

capabilities (and compatibility.requires) take values from one closed list:

Value Meaning
hosted-only Only runs on a hosted Adminium. Cannot be combined with offline-required.
offline-required Must keep working with no network. Cannot be combined with hosted-only.
receipt-printer Prints receipts.
barcode-scanner Reads barcodes.
payments Takes payments.
file-storage Stores files.
email-delivery Sends email.
realtime Uses live updates.
outbound-http An add-on’s server code calls a third-party API. Needs addOn.network.allow.
oauth-connect Adminium runs an OAuth 2.0 flow for an add-on. Needed by addOn.connect.kind: "oauth2".

The storefront shows an app’s capabilities on its card.

requiredSchema lists the tables the package needs. Apps must declare it; for add-ons it is optional. At install Adminium compares it with the operator’s database and shows a plan: which tables it will create, which existing tables it will reuse, and what it needs from the operator first. Nothing is created until the operator confirms.

"requiredSchema": {
"prefixed": true,
"tables": [ … ]
}
Field Required Rule
tables yes At least one table. Table refs must be unique.
prefixed no true, or leave it out. Apps only.

Without prefixed, each table is created under its ref exactly (visits).

With "prefixed": true, Adminium names each table <key>_<ref>, with any - in the key written as _: the visits table of an app keyed visits becomes visits_visits, and the tickets table of pos becomes pos_tickets. The full name must fit in 63 characters. The app reads the real names at runtime from its surface-config.json (a tables map from each ref to its real name), so its code never hard-codes the prefix. Pages, rules, roles and public endpoints in the manifest always use the short refs; Adminium maps them. The staff side’s config also carries sharedTables: each of the app’s tables another installed app uses too (a menu shared with the till), with those apps’ keys, so a screen can say that a change there reaches the other app.

Prefixing is opt-in because an app built before it existed hard-codes its table names. New apps should use it. No table may end up in Adminium’s own adminium_ namespace.

Every table in requiredSchema falls into one of four cases:

Case When Default action
New Nothing with that name exists. Create it.
This app’s own An earlier install of the same app recorded it. Reuse it.
Shared Another installed app recorded it under the same shape. Share it.
Taken It exists and no install records it as this app’s. The operator chooses before installing.

For a taken table the operator can reuse it (offered only when the table has no required column that the app would never fill), rename the existing table out of the way (to <name>_old unless they choose a name), or give the whole app a different prefix, which applies to every table at once and is meaningful for a prefixed app.

A reused or shared table may need changes first. The plan offers only changes that cannot lose data: adding a missing column (created nullable), widening a type (a longer varchar, varchar to text, int to bigint), making an integer key number itself, adding enum values, and giving a column declared fk the foreign key it lacks (a column first made a plain int: the check names rows that point at nothing, LINK_ORPHANS, and nothing changes until they are put right). A missing id, fk or blob column cannot be added to an existing table, and a column whose type cannot hold the app’s values is refused by name.

A column an update adds starts empty on the rows already there, or holds the manifest’s fixed default, except a column Adminium works out: a copy of the linked row, a formula, a rollup and its balance. The update works each of those out for every row already there, as the next save of the row would: a quote’s copy of its deal’s state, a line’s amount, a deal’s total. So does a column the new version derives another way than the one before (a changed formula), and every derived column that reads one of them, each after what it reads (the total after the line amounts it adds up). It reads the rule as it stands on the connection (the operator’s own, where they keep one); a copy that only fills a blank (mode default) fills only the rows still blank. It runs in one transaction of the database, before the new version is recorded: a failure stops the update (APP_UPDATE_INCOMPLETE, stage derived-columns), the running version stays, and updating again finishes it. Applying an app from its project folder fills them the same way. Needs Adminium 0.3.35.

A foreign key’s target must be one of the package’s own tables or a table that already exists in the database.

Field Required Rule
ref yes snake_case. The table’s short name.
columns yes At least one. Column refs must be unique within the table.
label no A label for one row (“Category”): form titles, buttons, empty states, link fields. Without it Adminium names the table from its real name.
labelPlural no A label for the table (“Categories”). Needs label.
keyField no The column that names a row wherever another table links to it (a category’s name). Must be one of the table’s columns.
shape no <name>@<version>, such as menu@1. Two apps that declare the same shape on a table can use one table between them. See Shared tables.
builtOn no <add-on key>/<shape name>@<version>, such as invoices/invoice@1: the table is built on a shape an add-on defines. Needs part. See Tables built on an add-on’s shape. A table has shape or builtOn, not both.
part with builtOn snake_case: which part of the shape the table is (document, lines, payments). Only a table with builtOn has one.
capacity no A limit on how much of a pool the table’s rows may take (a time slot, a ticket type, a room type’s nights), or a list of up to three; see Capacity.
booking no Rows that book a person’s time, never overlapping; see Booking. A table has capacity or booking, not both.
occupies no Rows that take a resource for a time (a room for an hour, a van for a day), never overlapping; see A resource and a time. A table has one of capacity, booking and occupies. Needs Adminium 0.3.28.
states no The states a row moves through, what is locked in each, and the child tables tied to them; see States.
unique no 1–8 sets of 2–4 columns no two rows may hold the same values in together; see Columns unique together.
module no The part of the app the table belongs to. Needs Adminium 0.3.28.
removal no { "reason"?: true | "required", "history"?: true }: a removal of a row takes a reason, and the removed row stays readable through a row it linked to; see Removed rows. Needs Adminium 0.3.35.

label, labelPlural, keyField and every column label are installed as the operator’s own labels would be. The operator can rename anything; a name they changed is theirs, and a later version of the app does not overwrite it.

{ "ref": "status", "type": "enum", "enum": ["open", "paid", "void"], "default": "open",
"label": { "en-US": "Status", "de-DE": "Status" } }
Field Required Rule
ref yes snake_case.
type yes One of the column types.
nullable no true makes the column nullable. Columns are NOT NULL unless you say otherwise. A primary key is never nullable.
role no pk (primary key), created_at or updated_at.
semantic no A hint for widgets: name, money, image, email, avatar, geo-lat, geo-lng. money (like a money column, or "scale": "currency") makes the column money wherever Adminium draws it, whatever it is called: a board’s totals and card, a chart’s Show data table, a list. Read by Adminium 0.3.28 and later; an older release takes it as a hint only.
enum for enum The allowed values, at least one.
references for fk The ref of the table this foreign key points at. That table must declare exactly one pk column; the foreign key takes its type. A name the manifest does not declare is a table the database already has (a shop’s own customers, with a one-column primary key): the app then installs only on a database with that table, the check warns about it, and anywhere else the install is refused (UNRESOLVED_REFERENCE).
maxLength no text only: creates varchar(n) instead of unbounded text. 1–1000. Leave it out for long text (notes, a description): text with no maxLength has no limit.
scale no decimal and money only: the places kept after the point. See Decimal places.
default no The value the database fills when an insert leaves the column out. See Defaults.
unique no true: no two rows may hold the same value. Empty values do not count, so many rows may leave it empty. Not on the primary key, a json or a blob column; a text column needs maxLength, because MySQL cannot index unbounded text.
index no true: a plain index on a foreign key a limit or a total counts by, so the count reads the rows it needs and not the whole table. Only on such a foreign key, and never on one that is unique already.
label no A label for the column: a form field, a list heading.
rules no Rules Adminium keeps on the column; see Column rules.

A NOT NULL column with no default refuses every insert that leaves it out, which includes every record added from a form that does not show it. Give such columns a default, or make them nullable.

unique becomes a unique constraint named uq_<table>_<column>, where <table> is the real table name (uq_clinic_patients_email). It is made when Adminium creates the table, and by an update that adds the column or finds it without one (on SQLite, a unique index of the same name). Before adding it to a column that already has rows, the update’s check makes sure no two rows hold the same value, and names the column if they do; nothing changes until they differ. A code column and a number without gaps are unique the same way, a number counted per parent row together with its parent. On MySQL a unique text column holds at most 768 characters (maxLength): MySQL indexes no longer key, so a longer one is refused on the check, at install and on update.

A table’s unique lists sets of columns that are unique together: one waitlist entry per show per address, one check-in per ticket per day.

{ "ref": "check_ins", "columns": [ … ],
"unique": [["ticket_id", "day"]] }

Each set names 2–4 of the table’s columns, each once; a table has up to 8 sets, and no set twice. A row with any of the set’s columns empty never collides. The columns must be ones an index can hold: not json or blob, and a text column with maxLength (a code column counts). A set that a number counted per parent already keeps unique (a gapless sequence with a scope, and its scope) is refused as said twice, and so is a set on a table built on an add-on’s shape, whose own writes could break it.

A set becomes a unique constraint when Adminium creates the table (a unique index on SQLite), and an update that adds a set to a table that already has rows first checks that no two rows repeat it: the check names the table and columns (UNIQUE_DUPLICATES), and nothing changes until they differ. A write that would repeat a set is refused 409 UNIQUE_VIOLATION, with details.columns naming the set. A plain index is added the same way, at install or by an update.

Type Created as
id A string key (varchar).
int, bigint Integer. With role: "pk", the key numbers itself (an identity or auto-increment column); an explicit value is still accepted.
text text, or varchar(maxLength). A column with a code rule is exactly as wide as its codes.
decimal, money Decimal.
float Floating point.
bool Boolean.
enum A varchar(32) (or varchar(64) when a value is longer than 32 characters) with a check that limits it to the enum values.
json JSON.
date Date.
timestamptz Timestamp with time zone.
uuid UUID.
fk A foreign key, typed like its target’s primary key.
blob Binary.

A default must mean the same thing on Postgres, MySQL and SQLite, so only these are accepted:

  • timestamptz: only the string "now".
  • text: a string, and the column needs maxLength (MySQL gives unbounded text no default). The default must fit in it.
  • enum: one of its values.
  • int, bigint: a whole number. decimal, money, float: a number.
  • bool: true or false.
  • json, blob, date, id, uuid, fk and primary keys take no default.

A value that comes from somewhere else when the row is made (the connection’s currency, a setting) is not a database default: use the default rule.

"now" is filled by Adminium on every create, through every door, so what the create works out can read it (a visit’s hours are there from the start). On MySQL the database is given no default for it at all: an app’s timestamptz is a DATETIME there, kept on the Adminium server’s clock, and the database — whose sessions Adminium keeps in UTC — could only fill UTC’s. A row written to that table outside Adminium, leaving the column out, gets nothing: it stays empty, or is refused when the column may not be empty. Adminium also fills a MySQL DATETIME whose database default is CURRENT_TIMESTAMP (a table made before, or one of your own) on its own creates, with the server’s own time: before, the database filled it with UTC’s time, so on a server not in UTC a row Adminium makes now differs from one another program stamps. On Postgres and SQLite the database’s own default is kept as well.

scale says how many places a decimal or money column keeps after the point:

Value Places
0–4 That many.
"currency" The decimals of the row’s own currency: 0 for JPY, 2 for EUR, 3 for KWD. The currency is read from the table’s own column named currency when the table has one and the row fills it, else from the connection’s currency, else 2 places. A value that is not a currency code Adminium knows counts as 2 places.
{ "ref": "total", "type": "decimal", "scale": "currency", "nullable": true }

Every value a write sends to the column is rounded to its scale, half away from zero, without ever passing through a floating-point number. Rollup totals and formulas round to it too. With "currency", a document keeps the places of the currency it was written in, even after the connection’s currency changes. A decimal with no scale is made with four places.

rules asks Adminium to keep rules on a column. They are written at install as the operator’s own column rules would be. An operator’s existing rule on the same column wins, and a rule they later change or delete is theirs from then on.

Rule Shape What it does
options { "list": "<name>" } or { "values": [{ "value", "label"?, "tone"? }] } The allowed values. list names one of the app’s option lists, or a built-in list: builtin:countries, builtin:us-states, builtin:gender. Inline values take 1–500 entries. An inline value’s label is a label: a keyed one follows the reader’s language in the form’s choices, the filters, the list and dashboard cards, falling back to en-US. An app’s option list keeps its en-US words.
enumLabels { "labels": { "<value>": label }, "tones"?: { "<value>": "<tone>" } } Display labels (and badge tones) for an enum’s values. Each label is a label: a keyed one follows the reader’s language on pages (the list, the record and the form), and on dashboard cards, falling back to en-US. A page that sets its own labels or tones for the column keeps them.
required true The server requires a value on every write.
requiredWhen { "column", "in" } The server requires a value only while another column of the same row holds one of the values in in (1–32): an away event names who is away, an event in the office names nobody. See Required for some values.
validation { "format"?, "min"?, "max"?, "minLength"?, "maxLength"? } format is email, url or phone.
copy { "via", "from", "mode"?, "follow"? } Copies a value from a linked row. via is a foreign-key column of this table, from a column of the table it points at. With mode: "default" (the default) the copy fills only a value the write leaves out; with "always" it always wins. With "follow": true it keeps in step when that row changes later; see Copies that follow. via may itself be a copied column (a ticket’s show, copied from its type): that copy runs first, in the same write. A column kept from readers is copied only into one kept the same way: a secret into a secret, a personal column into a personal one (or a secret), and a shared link’s code never. The same holds for a stamp that copies a column of its row, and for a formula’s inputs.
default { "from" } A value filled on a create that leaves the column empty, read when the row is made. See Values from elsewhere.
sequence { "start"?, "gapless"?, "startSetting"?, "scope"? } The next number in a running series. Without gapless, the column’s own counter; start is at least 1. With "gapless": true, a number with no gaps and none repeated. See Numbers without gaps.
format { "from", "prefix"?, "prefixSetting"?, "pad"? } A text column written from a running number of the same row, gapless or not: the prefix, then the digits padded with zeros (INV-0042). See Numbers without gaps.
code { "length", "prefix"?, "renew"?, "hiddenFromStaff"?, "givenByLedger"? } A short random code, unique in the column. renew makes a new one when the row changes hands; see Codes that renew. length is 4–16; prefix is upper case, up to 6 characters plus an optional - (MR-). A create that leaves it out or empty gets one, a sample row or an import too; a code they bring is kept, but a sample’s code for a shared link’s column is always made anew. A code is no secret by its rule alone: a name that reads like one (share_token) keeps it one, unless secret: false says otherwise or it is the column a shared link opens a row by, on a table the install made, which staff who read the table see. The public sees a code only where an entry’s select names it, and a shared link’s never. The audit log and the automation logs say [code] in its place, and the assistant never reads it. hiddenFromStaff: true: a code no desk hands out (an online order’s own link) is left out of every staff read, for every role and Super Admin too: rows, exports, live updates and the history. The link still opens its row, and the app’s emails still carry it to the row’s holder. Such a code is kept as a shared link’s code is, whether or not an entry claims by it yet: no column copies it (a copy, a stamp’s copy, a formula), no total keeps it as the newest child row’s value (rollup.latest), no codeLast4 keeps its last four, no posting copies it into a ledger (its map, from the row or its parent), and no public entry shows, filters or orders by it. The manifest check refuses each one, and so does the server when somebody saves such a rule on the schema or makes such an API endpoint (ENDPOINT_SHARE_CODE_READ). A rule never carries it: a rule’s step or e-mail template that names it ({{record.x}}, {{x}}, {{was.x}}, a linked row’s {{link.x}}) is refused by the validator and when a rule is saved, the column named. givenByLedger: true, on a table of an add-on: a row the add-on’s own ledger adds may bring its code (an older gift card, under the code its holder has always had). It is kept when it is the prefix, then 4 to 16 capital letters and digits, fits the column and no other row has it, and refused otherwise; with none given Adminium makes one. No person’s save gains anything by it, and a change never writes a code.
formula an expression A number worked out from the row’s other columns on every write. See Formulas.
normalize "trim", "email" or "code" How a text value is kept: trim without spaces at either end, email trimmed and in lower case, code compared as a code (upper case, spaces and dashes left out) when a person types one; see Typed codes.
lookup { "from", "table", "column", "where"?, "scope"? } A foreign key filled from a code a person types into another column: a discount code, a presale code. See Typed codes.
addOnLink { "addOn", "table" } A link to a row of a table an add-on keeps: an order line’s stock item, a payment’s gift card. No foreign key is made, so the app installs whether or not the add-on is there. The add-on is one the manifest names under addOns. While it is not installed, or not connected to this app, the link is inert: a value sent for it is refused (POSTING_REFUSED, reason add-on-unavailable, with the column) and the rows keep what they named. While it is there, a value must be the key of a row of that table, or the column is refused not-found. An empty link is always taken, and an import keeps whatever it names. A lookup on the same column may name the same table as { "addOn", "table" }: a typed code then fills the link. A default may fill it from a column of the app’s settings row that carries the same addOnLink (same add-on, same table, same type), and from nowhere else: "default": { "from": { "table": "settings", "column": "supplies_place_id" } }. Such a default never refuses a save: it is left empty while the add-on is not there for the app, and when the row the setting names is gone. It needs minAdminiumVersion 0.3.19 or later.
plainText true or { "digits"?, "max"? } On a text column shown to people who did not write it: a name, the message on a card, a note to the kitchen. Letters, spaces and sentence punctuation only: no web address, no email address, no @ handle, no path. true is a name (no digits, up to 80 characters); an object is a note with up to digits digits and max characters. It is the column’s own rule, so it holds every way of writing the row: a staff save, an import, a guest’s entry (which may be stricter still). Refused as plain-text on the column.
mentions true or { "about": link } On a text column people write to each other in (a note, a comment). Its box offers the people who sign in as the writer types @, and the text keeps each one named as @[Name](usr_…). A save that names somebody who does not sign in here is refused by the column, with nothing written. After the save each person newly named is told who mentioned them and on which record, never what was written; one who cannot open that record gets a notice that names the table only (never the record) and has no link. The record is named only to somebody who can open it, by a column their role shows, and only when the writer can read it too. A mention in a row saved under another record’s form tells the people a new row names; a change to such a row tells nobody. A text that already names somebody who has since left can still be edited. about is a link of the table: a mention in a comment is about the deal the comment is on, and that is the record the notice names and opens. Not on a private table. Needs minAdminiumVersion 0.3.28.
place true On a json column: a position on the map, kept as { "lat", "lng", "by" } where by is "town" or "exact". Every write is read and made alike; a value that is not a position is refused by the column. See A place and an area. Needs Adminium 0.3.28.
area true On a json column: an area on the map, kept as a GeoJSON Polygon or MultiPolygon. See A place and an area. Needs Adminium 0.3.28.
customerKey { "of": "<address column>" } On a text column of 64 characters that may be empty: a keyed hash of the address in of, made by Adminium whenever that address is written and by nobody else (a value sent for it is dropped; a guest’s entry may not list it). The same address gives the same key however it is spelled, and another key in another database. It says “the same customer” to whatever counts per customer, without keeping who. An empty address has no key.
codeLast4 { "of": "<code column>" } On a text column that may be empty: the last four characters of the code in of (a column with code), cut by Adminium whenever that code is made — on a new row, when a collision makes it again, when a change of hands renews it. No writer’s value is kept. It is what a list and a receipt show of a gift card; a code another column keeps the last four of is a money code, never shown in a list or a form. Never of a shared link’s code, nor of a code no staff member reads (hiddenFromStaff). From Adminium 0.3.19.
perNight { "from", "to", "rate", "adjust"? } A price worked out night by night: a stay’s room total. See Prices by the night.
notAfter "today" or { "column", "via"?, "when"?, "strict"?, "days"? } A date column is never later than today, in the venue’s time zone; or the column is never later than another column, read as notBefore reads it (a credit’s nights end by its stay’s depart; warm below stale). days, on a date: the other date moved by so many days (1–36600), so an end is at most so many days after a start ("ends_on": { "notAfter": { "column": "starts_on", "days": 365 } }: a cover lasts a year at most; an empty end stays allowed). A later value is refused (out-of-range).
notBefore "now" or { "column", "via"?, "when"?, "strict"?, "days"? } The column is never earlier than another column of the same kind: of the same row, or, with via, of the row its foreign key via points at. A date or timestamptz against a date (a payment never before its invoice’s issued_on), a number (int, bigint, decimal, money, float) against a number, a time of day kept as text (HH:MM, at least 5 characters) against another (work_from before work_to, compared as times: 9:30 is before 17:00). when: 1–8 conditions on the row as the write leaves it; the bound holds only while they are met. strict: the two may not be the same — true, or 1–8 conditions it is out under (a guest who left early is credited from the day after the arrival, one who never came from the arrival itself). "now", on a timestamptz: a new row’s moment is not in the past, to the minute (a visit is never booked to start before now); judged on a create only, never on one that brings history (an import, sample data), so a later change may correct a visit that happened. A bound on a column that is no date, days and "now" need Adminium 0.3.35.
linkable { "where"?, "same"? } On a foreign key: it names only a row of the table it points at that meets 1–8 conditions (where, the shape a move’s linked takes) — a price-list line still offered — and, with same, whose columns hold what this row holds (a visit’s contact works for the company of the visit’s deal). Judged when a new row names one and when a change moves the link, for every writer. See Links to some rows. Needs Adminium 0.3.28.
file { "accept", "maxBytes", "maxCount"? } Files a signed-in person uploads for the column: one in a text column, a list of up to maxCount (1–20) in a json column. It holds Adminium’s own file ids, never a link. accept names the kinds the column takes, judged by the file’s bytes; maxBytes (1 KiB–25 MiB) the size of each. A picture loses what a camera writes beside it as it is uploaded. A write may name only a file its writer uploaded and no record holds yet. See Files in a column. Needs Adminium 0.3.28.
changes [{ "to"?, "from"?, "while"?, "by"?, "notBy"? }] When, and by whom, the column may change: to some values only from others, only while another column holds a value, only by the person a staff column of the row (or of a linked row) names, never by the people others name. Judged by the server on every way a row is written but history. See Who may change a column. Needs Adminium 0.3.35.
retryKey true The column a staff create keeps its retry key in, as a 43-letter hash, on a table no public entry creates rows of: a desk’s payment sent again after a lost reply answers the payment the first one made (see the REST API’s clientKey). A text column, unique, nullable, at least 43 long; one a table.
history "audit" The column’s earlier values are kept for the audit log only, which the install’s owner reads (a removed message’s text). A row’s history (GET …/{record}/history), a removed row’s history and row, a rule’s {{was.<column>}} and the row a removal’s live frame carries leave the column out, for every reader, the owner included; the audit page keeps it. The value a row holds now is read as any other column is. A rule or a template of the manifest that reads {{was.<column>}} of such a column is refused. Not on the key. Needs Adminium 0.3.35.
rollup { "from", "via", "sum" or "count", "times"?, "unlessSet"?, "where"?, "balance"?, "cap"? } A total over child rows, kept up to date as they change. from is the child table, via its foreign key back to this table, sum the column to add up, or "count": true to count the rows instead; see Totals that count and climb. times multiplies each row (a quantity); a child row with a value in unlessSet is left out (a voided line). See Totals and balances for where, balance and cap.
stamp { "set", "on", "clearOnBack"?, "strict"? } A value Adminium writes when something happens: the moment, who did it, or a deadline. See Stamps.
venueLocal true A wall time given with no zone is read in the venue’s time zone.
personal true or false Whether the column is personal data, overriding the guess Adminium makes from the column’s name.
secret true or false Whether the column is a secret no response carries, to anyone, overriding the guess Adminium makes from the column’s name (api_token, password_hash). An entry in public access that names no select leaves a secret column out, and a code column too. false is written only on a table the app’s install made: on a table it reuses, a rule that would show a secret or take a personal column’s mask off is skipped, the check step says so, and only an operator can show the column, in Studio, as Super Admin. A secret the operator set in Studio wins over the app’s. On a column of an add-on’s shape, only true.

Tones are the dashboard’s badge colours: neutral, accent, info, pos, warn and danger.

copy, default, sequence, format, code, rollup, formula, stamp, lookup and perNight are values Adminium decides: they are filled on the server, so a browser never picks a price, a number, a code or a time. So are a rollup’s balance column, a booking’s late-cancellation flag and a late move’s flag. None of them can be listed as writable in public access, and a primary key cannot take sequence or code.

One rule decides a column. The one pair allowed is a copy with a default behind it: the copy comes first, and the default fills the column when the copy comes back empty (a client’s own tax rate, else the business’s). A stamped column takes none of the others.

The outbox’s own columns that Adminium writes (status, sentAt, error, skipReason, approvedBy, effectAt and effectError) take none of these rules, nor options, validation, required, requiredWhen, notAfter or notBefore: a stamp of who approved a message would race Adminium for the column, and a rule that refuses a value would refuse what Adminium writes, so every message the desk makes would be refused, or stuck. Its to and language, which Adminium writes when it looks the address up, take none that decide a value, nor options, required or requiredWhen (a desk leaves to empty to have it looked up); a validation of the address a person types is fine. Nor may another column’s rule read one of them where the read could refuse Adminium’s write: a note requiredWhen the status is sent, a formula worked out from effectAt, a notBefore bound on sentAt. The install names the column, and a Studio save refuses the same rules on an installed outbox’s columns.

Every name a rule uses is checked against the manifest: copy.via must be a foreign key of the table, rollup.via must point back at this table, and so on. normalize is for text columns only.

The rules are kept on every door a row is written through: a form, a bulk edit, an import, an automation, the public API and an outbox’s onSent change. normalize, formula and the rounding to a scale apply on each of them. History keeps what it brings: an import and sample data are not stamped, not capped, and not held to a date’s notAfter or notBefore — today, another date, now (they are held to linkable); an undo puts a row back exactly as it was, with no rule at all. What is no history is judged on an import and on sample data too: a number against a number, a time of day against another, and an end at most days after a start (a file cannot bring in warm above stale, or a cover of two years). A value refused by notAfter or notBefore answers 422 VALIDATION_FAILED, the field’s code out-of-range, on the column and, when the other column is of the same row, on that one too, so a form shows the pair. A bound is judged when the column is written, and when the other column, its via link or a column its conditions read changes (warm changed alone is judged against the stale the row holds). required and requiredWhen hold on an import and on sample data too; a value they refuse answers 422 VALIDATION_FAILED, the field’s code required. On every table, with a rule or without one, text holding the character U+0000 (anywhere in a JSON value too) is refused the same way, the field’s code invalid-character: Postgres cannot store it, and MySQL and SQLite would keep what Postgres refuses.

The public API answers a refused value with its one 400 PUBLIC_WRITE_REFUSED. When the value was refused for itself, in a column the entry lets the caller write, params names the column and why: { "column": "name", "reason": "too-long" }, the reason too-long, format, invalid-character, or on a create required. A batch adds the row’s index. Anything else names no column: a value already taken or pointing at a row that is not there, a value outside options or a link its linkable leaves out, a date out of bounds, and a column only a change leaves empty, since whether it may be empty can turn on what the stored row holds.

requiredWhen asks for a column only while another column of the same row holds one of some values:

{ "ref": "person_id", "type": "fk", "references": "people", "nullable": true,
"rules": { "requiredWhen": { "column": "kind", "in": ["away", "sick"] } } }

The row is judged as the write leaves it, whenever the write changes either column: a create or an update that leaves person_id empty while kind is away or sick is refused, and so is moving an event whose person_id is empty to away. A write that changes neither column is not judged — a column sent back as it is stored, as the record form sends every field, is no change — so a row stored before the rule can still be edited. Empty means no value, or only spaces. On a create that leaves kind out, kind is its database default: a trip that is away unless it says otherwise asks for a person. The record form marks the field required as soon as the other column holds one of the values, and asks nothing of an edit that changes neither.

  • As the database compares. A bool column’s yes is a yes in any spelling (true, on, y, 1, TRUE), and it is stored as the answer it names on every engine; a word that is no yes and no no is refused. On MySQL a plain text column is compared as MySQL compares it, so AWAY is away there, and asks for a person; Postgres and SQLite keep Away apart from away.
  • Two writers at once. An update that moves kind to away over a person it did not send, or empties person_id over a kind it did not send, asks the database, in the same statement, that what it read is still so. Two people changing one event at the same moment cannot together leave it away with nobody named: the second is refused as the first would have been.

column is another column of the same table, and every value in in must fit it (a value of an enum, a number for a number column, true or false for a bool). The rule’s own column must be nullable: one that is never empty is simply required. A column cannot take both required and requiredWhen, and a column Adminium fills (copy, default, sequence, format, code, rollup, formula) takes no requiredWhen, because nobody is asked for it. One exception is a copy that only fills what a write leaves out (mode: "default", without follow): the copy runs first, and the column is asked for only when there was nothing to copy either (an order sent by email needs an address: its supplier’s, or one typed on the order). The other is a stamp: it fills a person’s own save, and never a write that brings history. A line of a brought file is judged as it was sent, before any stamp: a deal brought in as won with no closed_at (closed_at stamped when state becomes won or lost, and requiredWhen state is won or lost) is named in the import’s preview, closed_at required, and not brought in, never dated by the day of the import; one that brings its date keeps it. Sample data is never stamped, so a sample’s won deal carries its date too. A stamp beside requiredWhen needs Adminium 0.3.35.

{ "ref": "subtotal", "type": "money", "default": 0,
"rules": { "rollup": { "from": "ticket_items", "via": "ticket_id", "sum": "unit_price",
"times": "qty", "unlessSet": "voided_at" } } }

linkable says that a foreign key names only a row meeting some conditions: a quote line names a price-list line that is still offered, so a rep cannot price a quote from a retired line the list no longer gives.

{ "ref": "price_id", "type": "fk", "references": "price_list", "nullable": true,
"rules": { "linkable": { "where": [{ "column": "active", "eq": true }] } } },
{ "ref": "list_rate", "type": "decimal", "scale": "currency", "nullable": true,
"rules": { "copy": { "via": "price_id", "from": "list_rate", "mode": "always" } } }

where is 1–8 conditions on the row the link names, in the shape a move’s linked takes: { "column", <one test> }, the test eq or in (values that fit the column), isNull, or gt, gte, lt or lte on a number column. Every condition must hold. The rule’s column is a foreign key, and each condition names a column of the table it points at that is not a secret.

  • When it is judged. On a new row that names a row, and on a change that moves the link to another row. A change that leaves the link as the row holds it, or sends it back as it stands (as the record form sends every field), is no move: a line naming a price retired since keeps it while its quantity changes. An empty link names nothing, and is taken.
  • Who it holds. Everybody, an admin and the owner too: it is the data’s own rule, not a permission. It holds on every door a row is written through: a form, a bulk edit, a record’s action, an automation, the public API, an import (a new line, and a matched line that moves the link) and sample data. An undo puts a row back as it was. A row a rule or an effect moves keeps the link it holds.
  • The refusal. 422 VALIDATION_FAILED, the field’s code not-allowed (the code a value outside options gets), naming nothing of the row the link named. Through the public API it is PUBLIC_WRITE_REFUSED with no column. A key that names no row at all is the foreign key’s to refuse, as it always was; and somebody who does not reach the named row is refused as for a row that is not there, before this rule is asked, so the rule never tells them whether a row out of their reach meets it.
  • Read as it stands. The named row is read when the write is judged. A line retired at the same moment as a quote names it may still be named, as if the quote had come first.
  • A rule that can no longer be read. When the link’s relation is gone, or a column a condition reads is no longer on the table, no row can be shown to meet the rule: every new link is refused, and rows that already hold one keep it. Put the column back, or change the rule in Studio.

same compares the named row with the row being written instead of with a fixed value: a site visit names only a contact who works for the company of the visit’s deal.

{ "ref": "contact_id", "type": "fk", "references": "contacts", "nullable": true,
"rules": { "linkable": { "same": [{ "column": "company_id", "as": "deal_id.company_id" }] } } }

same is 1–4 pairs. column is a column of the row the link names (here a contact’s company_id); as is a column of the row being written, or one link further, <link column>.<column>: the column of the row a foreign key of it names (here the visit’s deal’s company_id). Each pair must hold: both sides hold the same value. An empty side holds nothing, so a deal with no company yet matches no contact; an empty link is still taken. where and same may be given together, and a rule gives at least one of them. Needs Adminium 0.3.28 (linkable.same).

  • When it is judged. As where is, and also when a change moves the column as reads on the row (a visit moved to another deal), the contact staying: the visit would no longer be the deal’s company’s. The far row is read as it stands when the write is judged: a deal moved to another company, or a contact who changes company, keeps the visits that name them.
  • What the refusal says. The same 422 not-allowed on the link’s field, naming nothing of either row. The far row of as is read through the write’s own handle; a writer who names a deal out of their reach is refused for that deal before this rule is asked, whichever contact the visit names.
  • The validator refuses a column the linked table does not have, a secret on either side, a hop through a column that is not a foreign key, the link compared with itself, and two columns that can never hold one value (a number and a text). Studio checks that each column and link is there.

An operator may set the same rule on a column in Studio; Studio refuses one whose column is not a foreign key, or whose conditions name a column the linked table does not have. The screens are still the app’s to filter: a picker that offers only active lines is kinder than a refusal.

changes holds a column’s changes to 1–8 guards. A quote’s approval becomes approved or refused only from asked, and never by the person who asked for it nor by the deal’s owner; an enquiry’s deal is set only while the enquiry is claimed, and only by the person who claimed it:

{ "ref": "approval", "type": "enum", "enum": ["none", "asked", "approved", "refused"],
"rules": { "changes": [
{ "to": ["approved", "refused"], "from": ["asked"], "notBy": ["asked_by_id", "deal_id.owner_id"] } ] } }
{ "ref": "deal_id", "type": "fk", "references": "deals", "nullable": true,
"rules": { "changes": [
{ "while": { "column": "state", "in": ["claimed"] }, "by": "claimed_by_id" } ] } }
Key What it says
to 1–32 values of the column: the guard holds a change to one of them. Without it, every change of the column (a new row’s value is a change from nothing).
from 1–32 values the row holds before the change. A new row holds none, so it is never made with a value the guard holds.
while { "column", "in" } or { "column", "isNull" }: another column of the row holds one of the values (or is empty, or not), before the change and as the change leaves it. Never a secret.
by The only person who makes the change: the one a staff column of the row names (claimed_by_id), or <link>.<staff column>, the staff column of the row one of its foreign keys names (deal_id.owner_id), as the row stands before the change (a new row: as made).
notBy 1–4 people who never make the change, named as by names one, before the change and as it leaves the row: naming somebody else in the same write changes nothing.

A guard says at least one of from, while, by and notBy, and every guard that holds a change must be met. A value sent back as the row holds it is no change and is not judged.

  • On every door. The server judges it where it writes the row: a change on a page, a record’s button (a set or a move is saved as a change), a bulk edit, a file brought in, the public API, a rule’s step. History is not judged: an undo puts back what was, and an app’s sample and seed rows say what happened then. A Super Admin is held to it like anybody.
  • Who is judged. by and notBy judge the signed-in person who writes; an API key or a visitor is somebody no column names (never the by, never a notBy). A write Adminium makes by itself (a rule’s step, a move when a moment passes) has nobody to judge: it is held to from and while only. A person read through a link the database no longer has holds by for nobody and notBy for everybody.
  • The refusal. 403 COLUMN_FORBIDDEN, with the column and the reason: change-from, change-while, change-by or change-not-by; a file’s line is refused by the same code, naming the column. What an update was judged on in the stored row is asked again by its statement: a row changed meanwhile is not written.
  • On the record page. A button whose change a guard leaves to others is not shown to the person the row names in a notBy staff column of its own, and is shown only to the person a by names there (stateActions[].notBy, .by); one read through a link is left to the save. That is presentation only: the save refuses the change whatever a page shows.
  • The validator refuses a value the column cannot hold, a change from the very values it goes to, a while on the column itself or on a column the table has not, a person who is not a staff column (rules.staff) of the row or of the table a foreign key of it names, a person named twice or both by and notBy, and a guard on a column Adminium decides (copy, sequence, code, rollup, stamp, formula, format, lookup, perNight), which no writer changes. Studio checks that each column and link is there. An app may add it to a column of a part of an add-on’s shape.

file makes a column hold files a signed-in person uploads for it: a crew’s sign-off keeps the customer’s signature and the job’s photos.

{ "ref": "signature", "type": "text", "maxLength": 64, "nullable": true,
"rules": { "file": { "accept": ["png"], "maxBytes": 524288 } } },
{ "ref": "photos", "type": "json", "nullable": true,
"rules": { "file": { "accept": ["jpeg", "png", "webp"], "maxBytes": 8388608, "maxCount": 6 } } }

A text column holds one file’s id (file_…, 31 characters, so a maxLength is at least 31); a json column holds a list of ids, of at most maxCount, read back as a list on every database. accept takes png, jpeg, webp, gif, pdf, office, zip, csv, text, markdown, json, mp4, webm, mp3, wav and ogg: never svg, a document that can carry script, and never heic, a picture whose camera notes cannot be taken out of it.

  • The upload. POST /api/v1/files?connectionId=…&table=…&column=…&filename=…, the body the file itself, from a person who may create or change rows of the table. The column’s kinds and size narrow the workspace’s own (files.allowedTypes, files.maxBytes), never widen them: a file of another kind is refused 415, a larger one 413. A PNG, JPEG, WebP or GIF is kept without what a camera writes beside it: the place it was taken, the device, comments, XMP. A picture that cannot be read as one is refused 415. The reply’s data.id is what the column holds. Until a record names it, the file is its uploader’s alone: nobody else reads it, and the uploader may throw it away (DELETE /api/v1/files/{id}). One no record names is thrown away after files.unattachedHours.

  • The write. A create or a change may name, beside what the column already held, only a file its writer uploaded and no record holds yet (or one uploaded for this very record and in none of its other columns), of this database, of a kind the column takes, no larger than it allows, and, a picture, one cleaned as it was uploaded for a file column. A file named twice in one row is refused, and so is a list longer than maxCount. Only a person names a new file, through a form, a bulk edit or a record’s button: an import, a rule, project code, sample data and the public API name none (they may keep or empty what the column holds), but for a picture column’s sample pictures (below). An undo puts back what was. A refusal is 422 VALIDATION_FAILED, the field’s code file-not-here, file-type, file-too-large or too-many-files; whose a file is, whether it exists and which record holds it are all said alike, as file-not-here.

  • A picture column. A text column with "semantic": "image" and a file rule whose accept names only png, jpeg, webp and gif holds one picture: a public entry may show it to every visitor (pictures), and a sample row may give it one of the bundle’s pictures, { "@asset": "<label>" }, which the sample adds as that row’s own file. An image column whose file rule takes any other kind is refused. An image column with no file rule holds links, as before.

    { "ref": "photo", "type": "text", "nullable": true, "semantic": "image",
    "rules": { "file": { "accept": ["png", "jpeg", "webp", "gif"], "maxBytes": 5242880 } } }
  • The record holds it. Once named, the file is read with its record, by whoever reads the record and reaches its row, through every column their role shows. It is changed only through the record: taking it off, renaming it, throwing it away or hanging it on another record by the file routes is refused 409 FILE_HELD, for its uploader and the owner alike. A change that names another file throws the old one away; removing the record throws away its files (an undo brings them back).

  • What a manifest cannot do with one. A public entry neither writes nor defaults a file column, a sample row leaves it empty, and a file column takes no default, no unique and no rule that decides its value (personal, required and requiredWhen are fine). The kit’s signature pad and photo field upload for such a column from an app’s own screen.

A rollup can also filter its child rows, keep a balance beside the total, and refuse a change that would take the balance below zero: what a visit’s fee, its payments and its write-offs need.

Field Rule
where { "column", "eq" }: only child rows whose column equals the value are added up (voided is false). The value must fit the column, and the column must not be nullable: a row left empty would drop out of the total unseen.
balance { "column", "of", "minus"? }: a second column of this row, kept as of − minus… − total (balance = fee − waived − paid). minus lists up to 4 columns. Every column named is a number column of this table, and the balance is a column of its own, with no rules of its own.
cap true: a child write that would take the balance below zero is refused. It needs a balance on the same rollup, or a balance elsewhere on the row whose minus lists this total (a write-off is capped by the balance it lowers).
capUnless { "column" }, with cap: a yes/no column of the same row that lifts the cap while it is on (a stock level a shop sells from whether or not the count is right). The column is never empty. A balance guarded by two capped totals is lifted only when both name the same column.
latest { "of", "by", "row"? }, in place of sum and count: the column holds the value of of in the newest child row, the one whose by (a date, a time or a number) is the greatest (a deal’s amount is the total of its last sent quote: { "from": "quotes", "via": "deal_id", "latest": { "of": "total", "by": "sent_at", "row": "quote_id" } }). A row whose by is empty is left out; of two rows with the same by, the one with the greater key is taken; where and unlessSet leave rows out as they do of a total. The column is nullable (it is empty while no row qualifies) and of the type of of. row names a nullable column of this table that Adminium fills with the key of the row it took: a plain column of the key’s type, not a link (a link would stop that row being deleted), with no rules of its own. No times, balance or cap. of is never a shared link’s code, nor a code no staff member reads (hiddenFromStaff): the parent would show it. Kept in the statement that writes the child row, like a total, and it climbs like one: a line added to the quote that counts moves the deal. Needs Adminium 0.3.28.
{ "ref": "paid", "type": "money", "default": 0,
"rules": { "rollup": { "from": "payments", "via": "visit_id", "sum": "amount",
"where": { "column": "voided", "eq": false },
"balance": { "column": "balance", "of": "fee", "minus": ["waived"] },
"cap": true } } }

of may be another total of the row, so money given back is capped by money taken: two totals over the same payments, taken (where kind taken) and given_back (where kind given_back, balance: { "column": "refundable", "of": "taken" }, cap: true). A refund past what was taken is refused, and so is lowering a payment taken below what already went back.

A capped write is refused with BALANCE_EXCEEDED and the balance it would have gone below. So is a change to the parent that lowers of under what is already paid. Only a write that takes the balance below zero, or further below it, is refused: a row already negative from older data can still be edited or voided. Two payments at once are judged one after the other, so they cannot both pass.

A write that touches several rows of a table feeding a capped total is refused with BALANCE_ONE_AT_A_TIME, because it cannot be judged row by row. Imports and sample data are settled but not capped: they record what already happened.

A total or a balance a writer sends is dropped, not refused, so a form that sends the whole row still saves.

A total may count its child rows instead of adding a column up: how many tickets an order holds, how many lines a kitchen ticket has.

{ "ref": "ticket_count", "type": "int", "default": 0,
"rules": { "rollup": { "from": "tickets", "via": "order_id", "count": true,
"unlessSet": "refunded_at" } } }

A rollup names sum or "count": true, never both. A count is kept in an int or bigint column, and takes no times, balance or cap; where and unlessSet leave rows out as they do for a sum.

A total may also add up another table’s totals: an option’s price into its line, the line into its order, the order into the customer’s lifetime total. Such totals climb, at most three tables high, and never in a circle (a table adding up its own rows, or two tables adding up each other). A write that moves a total at the bottom settles every total above it in the same transaction: each level adds up what the level below has just written, then works out its formulas and balances. Every door that moves a total does this: a create, a change, a delete, a create with child rows, a bulk edit, an import, an undo, a parent form and sample data. Two writers take the rows in one order (the highest parent first), so they never wait on each other crosswise; a line moved to another order while a write was reading it is refused 409 WRITE_CONFLICT with details.retry: true, and the same write a moment later goes through.

Sums are exact on every engine, SQLite included: a total is added up from each row’s decimal text, never through a floating-point number.

A capped balance whose of is a formula (a total of subtotal and tax) is judged against the cap one row at a time; a bulk edit or an import settles it afterwards, without the cap. So validateManifest warns when the formula reads a column that stays writable while the capped rows can exist: lock those columns with the table’s states (and the lines they add up with lock: true) in every state a capped row can be written in or reached from.

A formula works a number out from the other columns of the same row: a line’s amount, a document’s tax and total.

{ "ref": "amount", "type": "decimal", "scale": "currency", "nullable": true,
"rules": { "formula": { "max": [0, { "sub": [{ "mul": ["qty", "rate"] }, { "coalesce": ["discount", 0] }] }] } } }

An expression is a number, a column of the same row by its ref ("qty"), or one of these objects:

Expression Value
{ "add": [a, b, …] } The sum of 2–8 expressions.
{ "sub": [a, b] } a − b.
{ "mul": [a, b, …] } The product of 2–8 expressions.
{ "div": [a, b] } a ÷ b. Empty when b is zero.
{ "min": [a, b, …] }, { "max": [a, b, …] } The smallest or largest of 2–8 expressions.
{ "round": a } or { "round": [a, places] } a rounded half away from zero, to the column’s scale or to places (0–4).
{ "coalesce": [a, b] } a, or b when a is empty.
{ "if": [condition, a, b] } a when the condition holds, else b.
{ "hoursBetween": [start, stop] } The hours from the start column to the stop column, both timestamptz columns of the row. See Hours between two moments.
{ "daysBetween": [from, to] } The whole calendar days from the from date to the to date, two date columns of the row: a stay’s nights.
{ "join": [part, part, …] } Text: 2–8 parts, each a column of the row or a piece of text, joined in order. The whole formula, of a text column; see Joined text.

A condition is one of:

Condition Holds when
{ "eq": [column, value] }, { "neq": [column, value] } The column equals, or does not equal, a string, number or boolean. The column may be of any type (an enum, a bool). neq does not hold for an empty column.
{ "gt": [a, b] }, { "gte": … }, { "lt": … }, { "lte": … } a is greater, greater or equal, less, or less or equal than b. A comparison with an empty side does not hold.
{ "isNull": column } The column is empty.
{ "and": [c, c, …] }, { "or": [c, c, …] } All, or any, of 2–8 conditions.
{ "if": [{ "eq": ["discount_kind", "percent"] },
{ "mul": ["qty", "rate", { "sub": [1, { "div": [{ "coalesce": ["discount", 0] }, 100] }] }] },
{ "sub": [{ "mul": ["qty", "rate"] }, { "coalesce": ["discount", 0] }] }] }

How a formula is worked out:

  • Exactly. Every value is read from its decimal text as an exact fraction, and nothing goes through a floating-point number. 1 ÷ 3 × 3 is exactly 1, and a total is the same on Postgres, MySQL and SQLite to the last minor unit.
  • Rounded once, half away from zero, to the column’s scale. A formula column with no scale rounds to 0 places when it is an int or bigint, and to 4 when it is a decimal. round inside a formula rounds that part early, where the arithmetic calls for it (a tax rounded before it is added).
  • Empty in, empty out. An empty column makes the result empty unless coalesce says what to read instead: a draft line with no rate yet has no amount, rather than an amount of 0 that looks like a price.
  • On every write. A create works out every formula; an update works out the ones whose inputs it changed, reading the stored row with the new values over it. A formula that reads another formula column is worked out after it. A formula that reads a rollup total is worked out again whenever the total moves.

A formula fills a decimal, money, int or bigint column, never a float (a join fills a text column). It reads only columns of its own table, and every column it counts with holds a number; eq, neq and isNull may name any column, hoursBetween names two different timestamptz columns and daysBetween two different date columns. It may not read itself, formulas may not read each other in a circle, and an expression nests at most 8 deep. A value a writer sends to a formula column is dropped. Anything that reads another row is a copy or a rollup, which already keep in step when that other row changes.

hoursBetween works a time entry’s hours out from its start and its stop:

{ "ref": "hours", "type": "decimal", "scale": 2, "nullable": true,
"rules": { "formula": { "hoursBetween": ["started_at", "stopped_at"] } } }

09:15 → 11:45 is 2.50; 22:30 → 01:15 the next day is 2.75. The hours are exact and rounded once to the column’s scale, like any formula, and they can be counted with further: { "mul": [{ "hoursBetween": ["started_at", "stopped_at"] }, "rate"] } is the pay at a rate. An update that moves only the stop works the hours out again from the start as stored.

  • The time that passed. Hours are real elapsed time. A column that keeps a zone (Postgres timestamptz, MySQL TIMESTAMP) holds the moment itself. One that keeps none (MySQL DATETIME, which is what an app’s timestamptz column becomes on MySQL, Postgres timestamp, and SQLite text) is read on the Adminium server’s clock, the clock Adminium writes such times on. So on a server in Europe/London, 00:30 → 03:30 on the night the clocks go forward is 2.00, and 00:30 → 02:30 on the night they go back is 3.00, on every engine. Run the server in the zone the times are kept in.
  • A time written without a zone. Sent for a column that keeps a zone, 2026-09-25 11:45 is 11:45 on the Adminium server’s clock — the moment the hours are counted to and the moment that is stored, whatever zone the database’s session is in. A stop stamped now is that moment too.
  • What SQLite keeps. A start SQLite fills with unixepoch() (seconds since 1970) is read as that moment, and so is a text with its zone after a space (2026-09-25 09:15:00 +02:00).
  • Empty for a missing, impossible or backwards span. An empty start or stop leaves the hours empty, and so does a day or an hour the calendar does not have (30 February stays no time, not 2 March). So does a stop before its start: a negative number of hours would quietly take pay off a total, so the entry shows no hours until it is corrected. A stop equal to the start is 0.00.
  • Too many to keep. Hours the column cannot hold (centuries in a numeric(6, 2)) are refused, 422 VALIDATION_FAILED with the code out-of-range on the moments they are counted from, on every engine — never left for the database to refuse or, on SQLite, to keep. Any formula whose result its column cannot hold is refused the same way.

daysBetween counts calendar days the same way: two date columns of the row, the nights of a stay from its arrival to its departure. A night the clocks change is still one night. An empty date, or a to before its from, leaves the result empty.

A join makes a text column from other columns and pieces of text: a guest’s full name, a line’s description.

{ "ref": "full_name", "type": "text", "maxLength": 160, "nullable": true,
"rules": { "formula": { "join": ["first_name", " ", "last_name"] } } }

A part that reads as a snake_case name is a column of the row; any other part (a space, " · ") is text written as it is. A join reads text, int and bigint columns only: a decimal, a yes or no, or a time would be spelled differently by each database. An empty column is left out, and so is the text between it and its neighbour, so a guest with no last name is “Mia”, not “Mia “. The result is trimmed, and empty when every column is. A join is the whole formula of its column, never a part of a sum.

A join that reads personal data lands it only in a personal column. A guest’s first and last names beside their email read as personal by their names, so the joined name needs "personal": true too; without it the check refuses the manifest. The same holds for a copy and a stamp’s copy.

A tax office expects an unbroken series of invoice numbers: none twice, none skipped. A sequence with "gapless": true numbers rows that way.

{ "ref": "number_seq", "type": "int", "nullable": true,
"rules": { "sequence": { "gapless": true,
"startSetting": { "addOn": "invoices", "setting": "number_start_invoice" } } } },
{ "ref": "number", "type": "text", "maxLength": 24, "nullable": true, "unique": true,
"rules": { "format": { "from": "number_seq",
"prefixSetting": { "addOn": "invoices", "setting": "prefix_invoice" }, "pad": 4 } } }
Field Rule
gapless true. The column is an int or bigint, and nullable.
start The first number, at least 1.
startSetting The first number, read from a setting when the row is made. Not with start.
scope A foreign key of the table: the series restarts for each row it points at (a deliverable’s versions, v1, v2 …).
sample true: the app’s sample rows may take numbers in the series, and the next real row follows them. For a series of the app’s own (a job’s J-01); never on a table built on an add-on’s document. Needs Adminium 0.3.28.

scope and startSetting need gapless.

The number is the largest the table holds (for scope, the largest for that parent row) plus one, and never less than the start. It is taken inside the transaction that inserts the row, so an insert that fails takes its number back with it and the next create takes the same one. Two creates at once never read the same largest number. When one is still taking the next number, the other may be answered 409 NUMBER_BUSY, to try again. A number a writer sends is dropped, from every writer but an import. A numbered row’s create is never undone: undo is refused 409, since a number once taken stays taken; void the row instead.

format writes the number as text in the same statement:

Field Rule
from The row’s numbered column (one with a gapless sequence).
prefix Up to 12 letters, digits and - _ / . (INV-).
prefixSetting The prefix, read from a setting when the row is made. Not with prefix. A change applies to the next number.
pad The digits are padded with zeros to this many (0–12).

The format column is text and nullable, and its maxLength must hold the prefix and the padding. Give the text a unique constraint, as the example does: a second guard against a number twice, which MySQL’s numbering also leans on.

An import brings its own history: it keeps the numbers its rows carry, and a row that carries only the text, written as the table’s prefix followed by digits, gets the number from those digits. The series then carries on after the largest. Sample rows spell a gapless number null, so they stay off the real series, unless the series is marked sample; see Sample data.

A default rule fills a column on a create that leaves it empty, with a value read when the row is made:

{ "ref": "currency", "type": "text", "maxLength": 3, "nullable": true,
"rules": { "default": { "from": "connection.currency" } } },
{ "ref": "tax_rate", "type": "decimal", "scale": 3, "nullable": true,
"rules": { "default": { "from": { "addOn": "invoices", "setting": "default_tax_rate" } } } }

from is one of:

from Reads
"connection.currency" The connection’s currency, a three-letter code. The column is text of at least 3 characters.
{ "table", "column" } A column of the app’s one-row settings table.
{ "addOn", "setting" } A setting of an add-on. The app must require that add-on in addOns.requires, so the setting is always there.

The same three settings (the last two) are what sequence.startSetting and format.prefixSetting read. A column with a default rule is nullable: when there is nothing to read, it stays empty rather than taking a made-up value. An update never refills it.

A { "table", "column" } setting, wherever a rule reads one (a default, a limit’s size, a move’s condition, a moment’s time), names a table that holds one row: the outbox’s settings.table, or a table that stands alone, with no foreign key of its own, none pointing at it, and no states, limits or booking. Adminium reads that row when the rule runs. A moment, a state condition or a stamp’s amount that finds two rows there reads nothing rather than guess which one is meant.

A plain copy is taken when the row is written. With "follow": true it keeps in step: when the row it copies from changes, every row that copies it takes the new value in the same write. A hotel stay’s extras copy the stay’s nights and guests, so breakfast for two over three nights becomes breakfast for three over four the moment the stay changes.

{ "ref": "nights", "type": "int", "nullable": true,
"rules": { "copy": { "via": "stay_id", "from": "nights", "mode": "always", "follow": true } } }

The copy is written, then every formula over it, then the totals it moves are settled into the row it follows (the stay’s total, tax and balance), before the change commits; if any of that is refused, nothing is kept. A paid stay whose balance would fall below zero is refused 409 BALANCE_EXCEEDED.

The rules that keep a follow sound:

  • it always wins (mode: "always"), and follows one level: the column it copies does not itself follow another row;
  • it never follows a total, a balance or a stamp, nor a formula over one: those change without a change of the row, so the copy would keep an old value;
  • the row it follows is not worked out from this table’s own totals (a loop);
  • a total another table keeps of these rows reads none of what the follow writes, including the link it groups by: a follow that moved rows between another table’s totals would leave those totals behind.

More than 500 rows following one changed row refuse the whole write, 409 FOLLOW_TOO_MANY (details.table, details.count), rather than leave some behind. A bulk edit, an import or a public batch that would move a paid balance below zero through its followers, or could not write them under the caller’s role, or would move more than 500, is refused before anything is written: 409 BALANCE_ONE_AT_A_TIME. Make such changes one row at a time.

A row put back by an undo of its removal takes each copy that always wins ("mode": "always") again from the row it copies, as that row is now, with the formulas that read it: a deal’s member removed and brought back after the deal changed hands names the deal’s owner now, not the one it was removed under. A copy that only fills a blank comes back as it was. Needs Adminium 0.3.35.

perNight works a price out night by night: for each night from from up to the day before to, the base rate read from the row rate.via points at, plus every adjustment row that matches that night (a weekend, a season). Each night is rounded to the column’s scale, and the column holds their sum.

{ "ref": "room_total", "type": "money", "scale": "currency", "nullable": true,
"rules": { "perNight": {
"from": "arrive", "to": "depart",
"rate": { "via": "room_type_id", "column": "base_rate" },
"adjust": { "table": "rate_rules",
"match": { "via": "room_type_id", "weekdays": "weekdays", "from": "from_date", "to": "to_date" },
"add": "amount", "name": "name", "where": { "column": "active", "eq": true } } } } }
Field Rule
from, to Two different date columns of the row: the first night, and the day after the last.
rate { "via", "column" }: a foreign key of the row, and the number column of the row it points at that holds the base rate. Never a secret or personal column.
adjust Optional. table holds the adjustments; add is the number added to a night (negative for a discount) and name the text a night’s line is tagged with.
of Optional. { "via", "column" }: this row’s nights are a part of another row’s price by the night (a credit for the nights a stay did not use): via a foreign key of this row, column that row’s priced column. While that row’s rates are the ones it was priced at, the part is today’s price exactly; after they changed, it is scaled to what the row was charged, and never more than it. That column may not itself be a part.
adjust.match Which adjustments apply to a night. via: a foreign key to what rate.via points at (empty on a row: every type). weekdays: a text column of at least 27 characters listing nights like fri,sat (empty: every night). from, to: date columns, the first and last night it applies on, both included (empty: open).
adjust.where { "column", "eq" }: only adjustments whose column holds the value (active is true). The column may not be nullable.

The column is a decimal, money, int or bigint, and a table has one such price. A create always works it out. A change works it out again only when it writes the dates or the rate’s link to something new, so rates edited later never re-price a stay already booked, and a form that sends the whole row back keeps the booked price. An import keeps a figure it brings and works out one it leaves out. Formulas read the price (a subtotal, the tax, the total), so they run after it.

The nights themselves are worked out, never stored: a dry run answers them (date, rate, base, tags), staff read them at GET /api/v1/data/<connection>/<table>/<id>/nightly, and a document can list them. When the rates changed after the stay was priced, the lines come back as one line equal to the stored figure, so a folio never prints lines that disagree with its total. A rate rule that cannot be read refuses the write 409 NIGHTLY_RATE_UNREADABLE.

A lookup fills a foreign key from a code a person types: a discount code on an order, a presale code on a ticket. The browser never names the codes row itself; Adminium finds it.

{ "ref": "promo_code", "type": "text", "maxLength": 32, "nullable": true },
{ "ref": "promo_id", "type": "fk", "references": "promo_codes", "nullable": true,
"rules": { "lookup": { "from": "promo_code", "table": "promo_codes", "column": "code",
"where": [{ "column": "active", "eq": true },
{ "column": "valid_until", "notBefore": "today", "orEmpty": true }],
"scope": [{ "column": "event_id", "equals": "event_id", "orEmpty": true }] } } }
Field Rule
from The nullable text column of this row the code is typed into, up to 64 characters, with no rule of its own that decides it.
table, column The codes table, and its text column the code is found by. The rule’s own column is a nullable foreign key to table. column finds one row: it is unique, a code column, or unique together with the scope columns in one of the table’s sets. It is compared as a code, so it has normalize: "code" unless it is a code column. Never the code a shared link opens its row with.
where Up to 4 conditions on the codes row: { "column", "eq" }; { "column", "notBefore": "now" or "today", "orEmpty"? }, a date or time not yet past (valid_until); { "column", "notAfter": "now" or "today", "orEmpty"? }, one already reached (valid_from). orEmpty lets an empty column pass.
scope 1–2 { "column", "equals", "orEmpty"? }: the codes row’s column equals this row’s equals column (this show’s codes). With orEmpty, a codes row whose column is empty matches any (a code good for every show).

A typed code is read the way codes are kept: upper case, spaces and dashes left out. A column Adminium makes codes in reads it as a claim does, its prefix put back, O as 0, I and L as 1. Two stored codes that fold alike are told apart by the exact spelling.

Every miss is one answer: no such code, a code switched off, expired, another show’s, or two that fold alike are all refused 422 VALIDATION_FAILED on the typed column with the code unknown (through the public API, PUBLIC_WRITE_REFUSED with reason: "unknown"), so a guesser learns no more from one miss than from another. A code whose uses are all taken, counted by a parent limit through the link, is refused on the typed column as used-up. Emptying the typed column empties the link, and every copy made through it: a code taken off takes its discount with it. A bulk edit, a form’s child rows and an import find each row’s code the same way, and an unknown code refuses just that row. A table resolves at most two typed codes.

A code typed to read rows rather than write one (a presale code that shows its ticket type) is a public entry’s unlockBy.

A ticket’s code is the door’s proof. When the ticket goes to somebody else, the old code must stop working at once, and the new holder gets one the old holder never saw. code.renew says what makes a new code:

{ "ref": "code", "type": "text", "nullable": true,
"rules": { "code": { "length": 8, "renew": { "on": { "column": "holder_customer_id", "changed": true } } } } }

on is one trigger or a list of 2–3: { "column", "changed": true }, any change of another column of the row, or { "column", "values" }, that column moving to one of 1–16 values (a transfer accepted). The watched column is not a code itself, not json or blob, and a changed trigger watches a column a person changes or a stamp writes (a holder stamped in as an offer is taken renews too).

The new code is written in the same statement as the change: the old one stops as the write commits, and every session a token link on that column opened stops with it. What never renews: a create (it makes a code anyway), an import or other history, a change that sends back the value the row already holds, and a server action that writes the code itself (a new link). A timed move’s set renews like any other change. Undoing a change of hands renews once more, so neither the old code nor the one handed on works after it; see Undo of a move. A renewed code is never shown to a public caller in the change’s reply, and only to staff who may read the table.

A stamp writes a value when a row is created, or when another column changes to one of a list of values: the time a patient checked in, who took a payment.

{ "ref": "checked_in_at", "type": "timestamptz", "nullable": true,
"rules": { "stamp": { "set": "now", "on": { "column": "status", "values": ["checked_in"] } } } }
Field Rule
set What is written; see the table below.
on When: "create"; { "column", "values" }, another column of the table and 1–16 values it must change to; { "column", "filled": true }, the moment another column, a nullable one, is first filled; { "columns": [...] }, whenever one of 1–8 other columns changes (a create sets them all); or a list of 2–3 of these, any of which writes the stamp.
clearOnBack true: emptied again when a move marked undo takes the row back out of a state the stamp watches (the time an order was marked ready, when the kitchen undoes the Ready). The column is nullable, the stamp watches the table’s state column, and some undo move leaves one of the states it watches.
strict true: the value is always Adminium’s, on every door and for every writer, Super Admin included: a value a create, a change, a bulk edit, a rule, the public API or an add-on sends for the column is dropped (the stamp writes it, or the row keeps what it holds), and a line of a brought file that carries one is refused, the column named, in the preview and in the run (a line that leaves it empty is stamped as its bringer). Who ticked “may be e-mailed” and when is never typed. Only history keeps its own: an app’s sample data and an undo. Not with a byOrigin that leaves staff their own word. Needs Adminium 0.3.35.

What a stamp writes:

set Writes Column
"now" The moment. timestamptz
"today" Today’s date on the venue’s calendar. date
"user-name", "user-id" Who made the write. text
{ "byOrigin": { "public", "staff"? } } One value for a write through the public API and another for everyone else. With no staff, a staff write keeps the value its writer chose (a desk records how a client approved; the portal always says “portal”). Each value must fit the column. text or enum
{ "copy": column } Another column of the same row, as it stands at that moment: a client’s first answer, kept when they edit it later. Both columns have the same type. any
{ "claim": column, "staff"? } A column of the signed-in person’s own row (their email, their name), on a public write. column is a column of a table the app’s people sign in as (an entry with a claim). staff is "user-name" or "user-id": what a staff write stamps instead. text
{ "addDays": { "date", "days", "map"? } } A date so many days after date, a date or timestamptz column of the row: a due date from the issue date and the terms. days is a number (0–3650) or a column: an int, or an enum or text column with map giving each of its values its days. date
{ "hashOf": { "columns", "children"?, "linked"? } } A fingerprint: SHA-256 over the named columns, child rows and linked rows, in a canonical form anyone can recompute. text of at least 64 characters
{ "addMinutes": { "minutes" or "hours", "notAfter"? } } The moment so many minutes or hours from now: a hold for ten minutes, an offer open for a day. The amount is a number or a whole-number setting. notAfter is a moment it never passes (an offer ends at the doors at the latest); a missing moment caps nothing. timestamptz
{ "deadline": { "days", "time", "notAfter"? } } days after today on the venue’s calendar, at time ("HH:MM" or a text setting), but never later than notAfter: a transfer due in five days at 18:00, or three days before the show. timestamptz
{ "moment": <moment> } A moment of the row or a linked row, worked out whenever the stamp fires: a stay’s cancel-by, from its arrival. Read from other columns, never its own. A moment that cannot be found writes nothing. timestamptz

hashOf takes 1–24 of the row’s own columns; up to 4 children, each { "table", "via", "columns", "orderBy"? }, a child table whose foreign key via points at this table, read in orderBy order and then by key; and up to 4 linked, each { "via", "table", "columns", "children"? }, the row this table’s foreign key via points at, with its own children.

{ "ref": "due_on", "type": "date", "nullable": true,
"rules": { "stamp": {
"set": { "addDays": { "date": "issued_on", "days": "terms",
"map": { "net7": 7, "net14": 14, "net30": 30, "on-receipt": 0 } } },
"on": { "column": "status", "values": ["sent"] } } } }

A change is judged against the stored row, so sending a status the row already holds stamps nothing again. A create that already holds one of the values (a walk-in written as checked in) is stamped too. A stamp wins over a value the writer sent. Between its moments a stamped column is Adminium’s, and a value a writer sends there is dropped, with two exceptions for staff: an addDays date (the desk may still move a due date) and a byOrigin column with no staff value. A byOrigin value that is itself now, today, user-name or user-id writes what that stamp would.

A public write stamps like any other write, except user-name and user-id: a browser key is nobody, so they write nothing. What it knows of the person is their own signed-in row, which is what claim reads. For an automation, user-name is the rule’s name. Sample data, an app’s seed rows and undo stamp nothing, since a stamp of today’s time over history would be false.

A file somebody brings in is different: each line is their own create or their own change. A stamp is written into a column the line leaves out or leaves empty, as it would be for a save (now is the moment of the import, user-id and user-name are the person who brought the file, a byOrigin column gets its staff value). So a role that may not write received_at still brings a list of enquiries in, and each row says when it arrived. A value the line does carry is kept, where a save would lose it to the stamp: the day an old enquiry really came is the file’s to say. Whether the person may say it is their role’s create limit, which refuses a file that carries a column they may not give a new row. A line that changes a stored row (an import that updates the rows it matches) fires the stamps a change by hand would, against the row as stored: moving a status stamps its moment once, and the same file brought in again stamps nothing. A stamped column takes no copy, default, sequence, format, code, rollup, formula, lookup or perNight as well, and a stamp watches a column other than its own.

A stamp that works out a moment again whenever what it reads changes: a stay may be cancelled free until 48 hours before 15:00 on its arrival day, and moving the arrival moves the deadline.

{ "ref": "cancel_by", "type": "timestamptz", "nullable": true,
"rules": { "stamp": {
"set": { "moment": { "column": "arrive", "time": { "table": "settings", "column": "arrive_from" },
"minus": { "hours": { "table": "settings", "column": "cancel_hours" } } } },
"on": { "columns": ["arrive"] } } } }

An offer that lasts as many hours as the settings row says, but never past the show’s doors:

{ "ref": "offer_until", "type": "timestamptz", "nullable": true,
"rules": { "stamp": {
"set": { "addMinutes": { "hours": { "table": "settings", "column": "offer_hours" },
"notAfter": { "column": "doors_at", "via": "event_id" } } },
"on": { "column": "status", "values": ["offered"] } } } }

Every rule that reads a point in time reads it as a moment: a move allowed only after or before a time, a move Adminium makes when a time passes, a deadline a stamp writes, a public change open only inside a window, a hold that lasts until a time. A moment is a date or time column of the row, or of the row one of its links points at, at a wall time of the venue’s day, shifted by an amount, with fallbacks when its column is empty.

{ "column": "starts_at", "via": "event_id", "minus": { "minutes": 30 } }
Field Rule
column A date or timestamptz column: this row’s own, or the linked row’s with via.
via A foreign key of this row: the moment is read from the row it points at. One link, never two.
time The wall time on the column’s day, on the venue’s clock; a date column needs one. "HH:MM"; a text setting { "table", "column" } holding one; the venue’s opening or closing hour that weekday, { "hours": { "table", "weekday", "open"?, "opens"?, "closes" }, "edge": "opens" or "closes" }; or a time kept on the row itself, { "column" } (a guest’s arrival time on their stay).
plus, minus A shift forward or back, not both: exactly one of minutes (up to 1,000,000), hours (up to 16,666) or days (up to 36,600), each a number or a whole-number setting.
or 1–3 fallback moments of the same shape (without their own or), read in turn when this one’s column is empty: an event’s own refund deadline, else seven days before it starts.

Minutes and hours are elapsed time: 48 hours before 15:00 is 48 real hours, whatever the clocks did. Days are calendar days at the same wall time: 7 days before 20:00 is 20:00. A day the hours table marks closed, or has no row for, ends at midnight. A moment whose column is empty, whose linked row is missing, or whose setting cannot be read, is no moment at all, and the rule reading it says what that means: a move waiting for it is refused, a deadline capped by it is not capped.

A time kept on the row ({ "column" }) is a text column of at least 5 characters holding HH:MM (H:MM and the database’s HH:MM:SS read too). Every column a moment reads that way is checked when it is written: a value that does not read as a time of day (“9pm”) is refused 422 VALIDATION_FAILED with the code format, on every door. The moment is compared with the clock the write reads under its locks, or with the time a staff device says a scan was made (see occurredAt in the REST API).

capacity limits how much of a pool a table’s rows may take: the guard behind a booking form, a ticket shop and a hotel’s rooms. A rule names its pool one of three ways:

Kind The pool Example
slot (the default) Rows add up per start time. Tables in a restaurant, orders in a pickup slot.
parent Rows take from a limit held on the row they point at. Tickets of a type, today’s portions of a dish, the uses of a code.
night A row takes one unit on every night of its stay, from a pool counted in another table. Rooms of a type, a hotel’s parking spaces.

capacity is one rule, or a list of up to three (a pool per room type and a pool per room on the same stays). A table has one slot rule at most. Numbers can be literal, or read from the app’s one-row settings table as { "table": "<ref>", "column": "<ref>" }, so a venue can change them without a new release. Every table a rule names is one of the app’s own, by its short ref; columns of a row reached through a foreign key (via) are named plainly.

Which rows count is said by countWhere: { "column", "values", "via"? }, only rows whose column holds one of the values (a cancelled order holds nothing), or a list of two, one on the row and one on the row it belongs to (a ticket’s status and its order’s), via being the foreign key to that owner. A rule reads through one owner only: every via of its conditions, hold and day names the same foreign key.

Give the foreign keys a limit counts by index: true, so the count under the limit’s lock reads the rows it needs rather than the whole table.

"capacity": {
"slot": "pickup_at", "amount": 1, "perSlot": { "table": "settings", "column": "orders_per_slot" },
"slotMinutes": 15,
"countWhere": { "column": "status", "values": ["placed", "confirmed", "preparing", "ready"] },
"hours": { "table": "opening_hours", "weekday": "weekday", "open": "open", "opens": "opens", "closes": "closes" },
"closures": { "table": "closures", "from": "from_date", "to": "to_date", "active": "active" },
"pauses": { "table": "slot_pauses", "slot": "slot_at", "active": "active" },
"windowDays": 7, "noticeMinutes": 20
}
Field Required Rule
kind no "slot", or left out. A rule with no kind is a slot rule, and reads exactly as it always has.
slot yes The timestamptz column holding each row’s time.
amount yes How much a row takes: an int column (a party size), or a number from 1 to 1000. A column a guest asks availability about needs a largest value, validation.max.
perSlot yes How much one slot holds. A non-negative integer, or a settings reference.
slotMinutes yes Slot length in minutes, or a settings reference; at least 1.
countWhere no Which rows count (above).
windowDays no How many days ahead bookings are open.
opens, closes no "HH:MM", or a settings reference. Not with hours.
hours no Weekly hours in place of opens and closes: { "table", "weekday", "open"?, "opens", "closes" }, one row per weekday. weekday is an enum of exactly mon … sun, opens and closes are text columns holding HH:MM, open a bool.
closures no Days the venue is closed, { "table", "from", "to", "active"? }, from to to included (date columns).
pauses no Slots the venue has paused (a kitchen that is full): { "table", "slot", "active"? }, slot a timestamptz.
noticeMinutes no How many minutes ahead a guest’s slot must be. Staff are never held to it.
resource no A column (a table, a room): the limit applies per value of it too.
cancelHours no Until how many hours before its time a guest may still cancel through the public API. Staff are never held to it.
hold no Rows count only while their hold lasts; see Holds.

The slots of a day run on the grid from opening. With an hours table the day is [opens, closes): the last slot starts before closing. With plain opens and closes, a slot also ends by closing.

"capacity": {
"kind": "parent", "via": "ticket_type_id",
"size": { "column": "quantity" },
"countWhere": [{ "column": "status", "values": ["valid", "offered", "checked_in"] },
{ "column": "status", "values": ["held", "paid"], "via": "order_id" }],
"window": { "opens": "sales_open_at", "closes": "sales_close_at" },
"perWrite": { "max": 6, "within": "order_id" },
"also": [{ "via": "event_id", "size": { "column": "sell_limit" } }],
"hold": { "column": "held_until", "states": ["held"], "via": "order_id" }
}
Field Required Rule
kind yes "parent".
via yes The foreign key to the row holding the limit. A row with it empty is left out of this rule.
size yes How many the pool holds: a number, a settings reference, or { "column" }, a number column of the row via points at (empty there: no limit). With { "column", "onDay" }, onDay a date column of that row, the number holds only on that venue day and any other day has no limit (today’s portions); it needs day.
amount no How much a row takes: an int column of the row, or a number. Absent: one.
countWhere no Which rows count (above).
window no { "opens"?, "closes"? }: timestamptz columns of the parent a sale must fall between (empty: no bound).
perWrite no { "max", "within" }: at most max (a size, as above) per row of within, a foreign key of this row (up to six tickets an order).
also no 1–2 wider pools the same rows also take from: { "via", "size" }, where via is another foreign key of the row and size a size of the row it points at, or { "via", "column" } one more hop away (a room’s cap across its ticket types).
day no Count only the rows of the same venue day as this time: a timestamptz column, or { "column", "via"? } on the owner.
lockBy no The column whose value names the lock: via, or a wider pool’s via that is itself a copy through via. Absent: one lock for the whole table.
hold no See Holds.
reserved no { "states", "via"? }: counted states whose places are kept back from the public while staff decide what to do with them (a refunded ticket held for the waitlist). Not a held state.
"capacity": [{
"kind": "night", "from": "arrive", "to": "depart",
"countWhere": { "column": "status", "values": ["booked", "in_house"] },
"pool": { "via": "room_type_id",
"count": { "table": "rooms", "column": "room_type_id",
"outOfService": { "table": "room_blocks", "room": "room_id", "from": "from_date", "to": "to_date", "active": "active" } },
"fits": { "column": "sleeps" },
"given": { "via": "room_id", "column": "room_type_id" } },
"nights": { "min": 1, "max": 28, "aheadDays": 365 },
"arrived": { "states": ["in_house"] }
}]

A stay takes one unit on every night from its arrival to the day before it leaves.

Field Required Rule
kind yes "night".
from, to yes The arrival and the departure: date columns of the row, or { "via", "column" } of the row it belongs to (an extra’s nights are its stay’s).
countWhere no Which rows count (above).
pool yes Either the rows of another table: { "via", "count": { "table", "column", "outOfService"? }, "fits"?, "given"? }, the rows of count.table whose column points where the row’s via points (the rooms of a type); or one unit per row pointed at, or a number of that row: { "via", "size": 1 or { "column" }, "outOfService"? } (a room holds one stay a night; an extra’s parking spaces).
pool.count.outOfService, pool.outOfService no { "table", "room", "from", "to", "active"? }: rooms out of service between two dates, room a foreign key to the counted rows. They are taken off the pool on those nights.
pool.fits no { "column" }: a number column of the pool’s row a stay’s guests must fit (how many a type sleeps). It filters what availability offers; to refuse a stay with too many guests, use the entry’s agrees.
pool.given no { "via", "column" }: when the row’s via link is set (a room given), it counts against that row’s column instead (the room’s type, not the one booked).
nights no { "min"?, "max"?, "minByArrival"?, "aheadDays"? }: the shortest and longest stay (a stay is at least one night), a shortest stay per arrival weekday ({ "fri": 2 }, 1–60), and how many days ahead a stay may start.
hold no See Holds.
arrived no { "states", "via"? }: the counted states of a stay whose guest has arrived. A change of such a stay (another room, another type, other dates) is judged from the venue’s today on; the nights already slept are never judged again, so a room closed last night takes nothing from a guest moved today. Extras whose nights come from the stay are judged the same way.

Parent and night availability for guests read the pool’s rows, so the key also needs a plain read entry on the pool’s table (the ticket types, the room types).

A hold makes a row count only for a while: an order a guest is paying for keeps its tickets for ten minutes, and gives them back when it lapses.

"hold": { "column": "held_until", "states": ["held"] }
Field Rule
column When the hold ends: a timestamptz column of the row (or of its owner, with via) that a stamp writes, never one a guest writes. Or { "column", "via", "or"? }: a moment of a linked row, via a foreign key of the row the hold reads, and or 1–2 fallbacks { "column", "via"? } read when the link is empty (a waitlist offer’s end, else the order’s own).
states 1–8 counted states in which the row is held. A row in one of them counts only until its hold ends; a row in any other counted state counts whatever its old hold says (a paid order).
via The owner the states and the column are read on.

One live hold per buyer. A public create sends replaces, the page’s own-link session for the hold it takes the place of (a checkout changed before it was confirmed): that hold is let go in the same write, and the session moves to the new hold. A verified signed-in person’s other holds are let go the same way. A dry run takes replaces too, and judges the places as if the old hold were gone, letting nothing go. Letting a hold go writes its end directly, with no rule run on that write, so nothing may watch a hold’s end column: no stamp, code renewal or followed copy is set off by it.

A row that leaves a hold state (a waitlist claim) counts the places kept back by reserved as staff do, so a returned place offered on is not counted twice.

A write that adds to what a pool counts is judged inside its transaction, under a named lock (a slot limit’s per venue day, a parent limit’s per lockBy value, a night limit’s per table), so two writers never both take the last place. A writer waits at most 10 seconds for another’s lock, then is answered 409 CAPACITY_BUSY (or NUMBER_BUSY) to try again. A bulk edit cannot move a row whose limit needs a lock (409 CONFLICT, details.reason: "CAPACITY_ONE_AT_A_TIME"), nor can a public batch (400 PUBLIC_WRITE_REFUSED); an import records history, and is not judged.

Staff are refused 409 CAPACITY_FULL, with details naming the rule’s kind, the column, the pool (key, at) and left, the places that were left before the write; a place the venue does not offer is refused with a reason (out of range, out of hours, closed, paused, not on sale, too many). A guest is told PUBLIC_SLOT_FULL, PUBLIC_SOLD_OUT or PUBLIC_NO_ROOM (with the night), never how full anything is; see Error codes. Sample rows count against real availability like any other. For the whole picture, see Booking rules and limits.

booking books a person’s time rather than seats: each row takes a resource (a clinician) for its own length, and two counted rows of one resource may never overlap. The time must also fall inside that resource’s hours, off their break, on the booking grid and outside any closure. Capacity adds up a party per start time; booking forbids overlap per resource. They answer different questions, so a table has one or the other, never both.

Every table the rule names is one of the app’s own, by its short ref. A number can be literal or read from the settings table as { "table", "column" }, as in Capacity. For how the pieces fit together, see Booking rules.

"booking": {
"start": "starts_at", "minutes": "minutes", "resource": "clinician_id", "kind": "visit_type_id",
"countWhere": { "column": "status", "values": ["booked", "checked_in", "seen"] },
"eligible": { "table": "clinician_visit_types", "resource": "clinician_id", "kind": "visit_type_id",
"order": { "table": "clinicians", "column": "position", "active": "active", "public": "online" } },
"hours": {
"practice": { "table": "opening_hours", "weekday": "weekday", "opens": "opens", "closes": "closes",
"breakStart": "break_start", "breakEnd": "break_end", "open": "open" },
"own": { "table": "clinician_hours", "resource": "clinician_id", "weekday": "weekday",
"opens": "opens", "closes": "closes" }
},
"closures": { "table": "closures", "from": "from_date", "to": "to_date", "resource": "clinician_id" },
"grid": { "table": "settings", "column": "grid_minutes" },
"windowDays": 60,
"noticeMinutes": 120,
"cancel": { "hours": 24, "mode": "flag", "flag": "late_cancel",
"when": { "column": "status", "to": "cancelled" } }
}
Field Required Rule
start yes A timestamptz column: when the row starts, read in the venue’s time zone.
minutes yes An int column: how long the row lasts. Usually a copy from the kind.
resource yes A foreign key: whose time the row takes. Left empty on a create, it means anyone: Adminium picks the first free person in eligible.order.
kind yes A foreign key: what the row is, which decides who may be booked for it.
countWhere yes { "column", "values" }: only rows whose column holds one of these values take time (a cancelled visit takes none). Each value must fit the column.
eligible yes Who does what: { "table", "resource", "kind", "order"? }, a link table whose two foreign keys point where the row’s resource and kind point. A person with no link row for a kind is never booked for it.
eligible.order no { "table", "column", "active"?, "public"? }, kept on the table resource points at. column is a number: the order in which “anyone” picks. A person whose active bool is false is never booked; one whose public bool is false is never booked through the public API.
hours.practice yes Weekly hours: { "table", "weekday", "opens", "closes", "breakStart"?, "breakEnd"?, "open"? }, one row per weekday. weekday is an enum of exactly mon, tue, wed, thu, fri, sat, sun, in that order. The times are text columns holding HH:MM. A break names both its start and its end. open is a bool.
hours.own no A person’s own weekly hours, the same shape plus resource, a foreign key to the person. A person with rows here follows them every day, and a weekday with no row is a day off. A person with none follows the practice’s hours.
closures no Dated closures: { "table", "from", "to", "resource"?, "active"? }. from and to are date columns. resource must be nullable: an empty one closes for everyone. active is a bool.
grid yes The minutes between bookable starts, counted from the opening time. At least 1.
windowDays no How many working days ahead a booking may be made.
noticeMinutes no How far ahead a public booking must be. Staff are never held to it.
cancel no Late cancellation: { "hours", "mode", "flag"?, "when" }. See below.

cancel.when is { "column", "to" }: a cancellation is a change of the countWhere column to a value that does not count. Inside hours of the start, mode: "refuse" turns a guest’s cancellation away, and mode: "flag" lets it through and sets flag, a bool column of the table, whoever cancels. flag is required with "flag" and not allowed with "refuse". A guest can never move a booking inside the window, in either mode. Staff are never refused.

Nothing may start in the past, with one exception: staff may create a walk-in in the slot that holds the current time, when its status is a counted value other than the first in countWhere (checked_in rather than booked).

The public API answers a clash with PUBLIC_SLOT_FULL; a time outside hours, on a closure, beyond the window or with nobody offered for the kind with PUBLIC_WRITE_REFUSED; and a guest’s late move, or a late cancellation in refuse mode, with PUBLIC_TOO_LATE. An availability entry on a booking table lists the free times of a day, or a strip of days, for a kind.

occupies is the plain rule beside Booking: a row takes a resource for a time, and no two counted rows of one resource overlap. A showroom bay booked from two to three is not booked again at half past two; a demo van is out once a day. It has no kinds of visit, nobody to pick, no grid and no window. Use booking when people are booked for visits, and occupies for everything else that can be taken twice by mistake. Needs Adminium 0.3.28.

"occupies": {
"resource": "space_id", "start": "starts_at", "minutes": "minutes",
"countWhere": { "column": "state", "values": ["tentative", "confirmed"] },
"hours": { "table": "space_days", "resource": "space_id", "weekday": "weekday",
"opens": "opens", "closes": "closes" }
}
Field Required Rule
resource yes What is taken: a column of the table, usually a foreign key.
start one of start, day A timestamptz column: when the row starts, read in the venue’s time zone. Comes with minutes or end, one of the two.
minutes with start An int column: how long the row lasts.
end with start A timestamptz column: when the row ends. It must be after the start.
day one of start, day A date column: the row takes the whole day. Takes no minutes and no end.
until no, with day A date column, usually nullable: the last day the row takes, so the row is a span of whole days from day to until. Empty in a row means the span has no end. Takes no hours and no shared.
countWhere no { "column", "values" }: only rows whose column holds one of these values take the resource (a cancelled booking takes nothing). Without it every row counts.
hours no The resource’s week: { "table", "resource", "weekday", "opens"?, "closes"? }, a table of the app with one row per weekday the resource works. resource names what the row’s resource names; weekday is an enum of exactly mon, tue, wed, thu, fri, sat, sun, in that order. opens and closes are text columns holding HH:MM, both or neither.
shared no true: rows may share the resource’s time, and only its hours are judged (a crew does several jobs on a day it works, and none on its day off). Needs hours.

A row with no resource, or no time yet, takes nothing and is not judged: a visit can wait for its day. A row that ends as another starts does not overlap it. A timed row may run past midnight and lasts 30 days at most.

With hours, a weekday the resource has no row for is a day it does not work. A timed row must also lie inside the weekday’s opens and closes, when the rule names them. A resource with no rows in the table at all keeps no week and is held to none, so a crew made a minute ago can be booked before its days are written down.

With until, a row takes every day from its day to its until, and both ends count: a cover that ends on the 14th and another of the same person that starts on the 14th overlap, and the second is refused. Start it on the 15th instead. A row whose until is empty runs “until further notice”: it overlaps every later row of its resource, and a second open-ended row. An until before its day is refused with BOOKING_OUT_OF_RANGE. A span can be as long as you like.

"occupies": { "resource": "owner_id", "day": "starts_on", "until": "ends_on" }

The refusal names the column and nothing of the row in the way: not its key and not its days, because the person writing may not be allowed to read that row.

A write that overlaps is refused with BOOKING_TAKEN (409), one on a day the resource does not work with BOOKING_CLOSED (409), one outside the opening times with BOOKING_OUT_OF_HOURS and one whose time cannot be read or is too long with BOOKING_OUT_OF_RANGE (both 422). The public API answers as it does for a booking. Two writers of one resource take turns, so two requests for the same hour cannot both win. As on a booking table, rows are written one at a time: a batch of new rows, or a batch change that could move a row’s time or resource, is refused. A create of one row may carry a retry key (clientKey), on this table and on a booking table: it is placed under the same lock and judged by the same rule, the same key again answers the row the first made, and another key for a time that is taken is refused BOOKING_TAKEN. A create with rows below it is still refused (CAPACITY_ONE_AT_A_TIME).

states describes the life of a row: the states it moves through, what may happen in each, and the child tables tied to them. An invoice is a draft, then sent, then perhaps void; once sent, its lines are locked and payments may be recorded against it.

"states": {
"column": "status", "initial": "draft",
"moves": {
"draft": [{ "to": "sent", "requires": { "children": { "invoice_lines": 1 },
"where": [{ "column": "total", "gt": 0 }] } }, "void"],
"sent": [{ "to": "void", "requires": { "where": [{ "column": "paid", "eq": 0 }] },
"roles": ["manager"] }]
},
"lock": { "when": ["sent", "void"], "except": ["due_on", "ladder", "void_reason"] },
"children": {
"invoice_lines": { "via": "document_id", "lock": true },
"payments": { "via": "document_id", "parentIn": ["sent"], "clearOnCreate": ["client_paid_at"] }
},
"noDelete": { "when": "numbered" }
}
Field Required Rule
column yes The enum column that holds the state. Every state named below is one of its values.
initial yes The state a new row starts in.
moves yes From each state, up to 16 states a row may move to. A move is a state ("void"), or { "to", "requires"?, "roles"?, "undo"?, "clears"? }. A move goes to another state. undo: true marks a move that takes back the listed move the other way; clears (1–8 columns, on an undo only) names further columns it empties. See Undo of a move.
lock no { "when", "except"? }. While a row is in one of when (1–16 states), only the columns in except (up to 32) may change, and the state itself through a move. The state column is never in except.
children no Child tables tied to the row’s state, keyed by table ref. See below.
lockedWhenReferencedBy no 1–4 { "table", "via", "in" }: the row is locked once a row of table, whose foreign key via points at it, is in one of the states in (a terms version, once a proposal naming it is sent). Needs lock, which says what stays open.
noDelete no { "when" }: rows that are never deleted, only voided. when is 1–16 states, or "numbered": any row that holds a number from a gapless sequence. "numbered" needs such a column on the table.
onlyLater no 1–8 date or timestamptz columns that may move later, never earlier (a quote’s valid_until). An entry { "column": "depart", "in": ["in_house"] } holds only while the row is in one of in (a stay in the house leaves no earlier by a change of its date).
strict no true, or { "show": [1–4 columns] }: a write naming the state the row already holds is refused rather than passing silently (a ticket let in once). See Once means once.
late no 1–4 moves judged late when made close to a moment; see Late moves.
timed no 1–8 moves Adminium makes by itself once a moment has passed; see Timed moves.
effects no 1–8 moves of the row a link points at, set off by this row’s move or by a change of the link; see Effects.
create no { "requires" }: what a new row must meet to be created; see Conditions on a new row.

A move’s requires says what must be true first:

Field Rule
children { "<table>": n }: at least n (1–1000) rows of a child table. The table must be one of children.
where 1–8 conditions on the row itself: { "column", <one test> }, where the test is eq or in (values that fit the column), isNull (true or false), or gt, gte, lt or lte (a number, on a number column).
linked 1–4 { "via", "where" }: conditions (as where) on the row this row’s foreign key via points at (the order a ticket belongs to is paid).
time { "after"?, "before"? }: a window on the clock, each end a moment (from half an hour before the doors, until the ticket’s day ends).
setting 1–4 { "table", "column", "eq" }: a value of the settings row (door sales switched on).
notPast A date column of the row: the move is refused once that day has passed. See A move’s last day.

A move’s roles (1–8 of the app’s role keys) keeps it for the people holding one of them: any role may void a draft, only a manager a sent invoice.

Each entry of children names a child table whose foreign key via points at this table, with at least one of:

Field Rule
lock true: the child’s rows are locked while this row is. Needs a lock on this table.
parentIn 1–16 states: the child’s rows may be written only while this row is in one of them (payments on a sent invoice). Not with lock. It is createIn and changeIn at once.
createIn 1–16 states: a child row may be added only while this row is in one of them (a payment taken on a stay still booked or in house). Not with lock or parentIn.
changeIn 1–16 states: a child row may be changed or deleted only while this row is in one of them (a payment voided on a cancelled stay too). Not with lock or parentIn.
clearOnCreate 1–8 nullable columns of this row, emptied when a child row is created (a recorded payment clears the client’s “I’ve sent it”).
moveOnCreate A state of this row: a child row created moves it there, by one of this table’s listed moves (a sign-off made on site signs its job off). A row already there is left as it is. Needs Adminium 0.3.30. See A new child moves its parent.
lockLinked { "<link>": ["<column>", …] }: for 1–8 of the child’s foreign keys to other tables, 1–16 columns of the row the link points at that do not change while a child row points at it (the hours of time an invoice line bills). The key is never one.

and, with lock:

Field Rule
release { "when", "columns" }: while this row is in one of when (states the lock holds, and that no move leaves), a child row may still empty the listed columns (1–8 nullable columns of the child, never via or the key), and change nothing else. A released state must be final: a released link stops locking the row it points at, so a document that could move on would bring its line back billing a row that changed meanwhile.

Time and purchases billed on invoice lines, so that nothing is billed twice (each link unique), and billed again once the invoice is void:

"children": {
"invoice_lines": {
"via": "invoice_id", "lock": true,
"release": { "when": ["void"], "columns": ["time_entry_id", "expense_id"] },
"lockLinked": {
"time_entry_id": ["hours", "logged_hours", "project_id", "date"],
"expense_id": ["amount", "currency", "project_id", "client_id"]
}
}
}

A void invoice’s line may set time_entry_id to null; setting it to another entry, changing anything else on the line, or deleting it stays RECORD_LOCKED, and so does emptying it while the invoice is sent. While a line points at an entry, a change to one of the entry’s listed columns is refused 409 RECORD_LOCKED, details.column naming it, and the entry’s record page draws those fields read-only. A line on an invoice in a state that releases its link (release.when, with the link among release.columns) keeps nothing: once the invoice is void the hours may change, and once the line lets go the time may go on another invoice. A line whose invoice is gone, or has no state, keeps them. A value the line copies from the entry (a copy through the link) is read again when the line is written, holding the entry: an entry changed in between refuses the line 409 WRITE_CONFLICT, to be written again, so a line never bills hours its entry no longer has. A listed column Adminium works out (a formula) is kept too: a change to what it is worked out from is refused naming it. A total over the entry’s own child rows (a rollup) is Adminium’s, and moves.

A lockLinked link is followed through its foreign key. Studio refuses to save states whose link has none, or names a column the linked table does not have. If the link later stops leading anywhere Adminium can read (its relation removed in Studio, its foreign key or a kept column dropped), the rule fails closed: a line may no longer be linked through it, and creating one or changing its link is refused 409 RECORD_LOCKED with details.unresolved: true and the link as details.column. Emptying the link, and every other change to the line, still goes through, and the columns that can still be followed stay kept. Put the relation back, or change the states in Studio, to bill through it again. A refusal names tables by their own names (linkedFrom, parent), never with a schema in front.

A move that is not listed is not one the row may make. Columns Adminium keeps (totals, balances, formulas, stamps) are Adminium’s to write whatever the state. A new row starts in initial. A locked row cannot be deleted either, with or without noDelete.

The states hold on every write to the table: a form, a bulk edit, an automation, the public API, and an outbox’s onSent change. A refusal is 409: STATE_MOVE_REFUSED for a move the row may not make (or a new row that does not start in initial), RECORD_LOCKED for a change to a locked row or to a child row its parent’s state does not allow (details.on says create or change, beside the parent’s state and the states that allow it), and DELETE_REFUSED for a delete. An onlyLater column moved earlier, or emptied, is refused 422 with the code out-of-range. Through the public API each of these is PUBLIC_WRITE_REFUSED.

Adding sample data and importing past records are history. A new row they write may start in any state, and a child row may follow a parent the same import or sample brought in; a child row under a parent that was already there is judged as any other write. An import that updates a row already there is judged in full, and a history write empties no clearOnCreate column. An undo is never given for a write to a table with states, or to its child tables: a mistake is moved on (voided, sent back), never unwritten. The one exception is a status move the app lists an undo move for: its Undo is that move back, judged like any move. A table whose columns a lockLinked keeps keeps its undo, and an undo is judged like any other change: an edit of the hours made before the time was billed is not taken back after.

Beyond children and where, a move may wait for the row one of its links points at, for a window on the clock, and for the settings row:

"moves": {
"valid": [{ "to": "checked_in",
"requires": { "linked": [{ "via": "order_id", "where": [{ "column": "status", "eq": "paid" }] }],
"time": { "after": { "column": "doors_at", "via": "event_id", "minus": { "minutes": 30 } } },
"setting": [{ "table": "settings", "column": "door_open", "eq": true }] } }]
}

Everything is read inside the write’s transaction, holding the linked row, so a ticket scanned while its order is being paid sees the order as it committed. A condition that cannot be read (an empty link, a linked row that is gone, a moment with no value) refuses: a move waiting for something is never let through on nothing. A link that another writer moved while the write was waiting refuses 409 WRITE_CONFLICT (details.retry: true). The window is judged by the write’s own clock, or by the time a staff device says a scan was made (occurredAt). A refused move is 409 STATE_MOVE_REFUSED, its details naming what failed: requires (linked, time or setting), and via, column, bound (after or before) and at where they apply, so the door can say “Not paid yet”, “Not today” or “Too early”.

requires.notPast names a date column of the row. The move is made up to and on that day, and refused from the day after: a quote is accepted no later than its valid_until.

"moves": {
"sent": [{ "to": "accepted", "requires": { "notPast": "valid_until" } },
{ "to": "declined", "requires": { "notPast": "valid_until" } },
"withdrawn"]
}
  • The day is the business’s: today in the install’s time zone, never UTC’s and never the caller’s.
  • It holds every writer alike: a person on the dashboard, a bulk edit, a record’s button, a rule, an import that moves a row already there, another row’s effect, and a customer through the public API.
  • The date read is the one the row held before the write. A later date sent in the same write does not open the move; a person who may change the date changes it first, and then makes the move.
  • A row whose date is empty is held to none.
  • No public entry on the table may list the date column as writable: a guest would move their own deadline.
  • An undo and a planned move take no last day.

A refused move is 409 STATE_DATE_PASSED, never STATE_MOVE_REFUSED, so a screen can tell “its day has passed” from “it is not in a state that allows this”. details carry column (the state column), from, to, date (the date column) and until (the day it held). Through the public API it is 409 PUBLIC_TOO_LATE with no params: the entry may not show the date. The row is still read as before, so a page opened by its link can say the quote ran out. Needs Adminium 0.3.28.

A public entry can also close a change by a date with writableWhen ("from-today"), which refuses a row whose date is empty and holds only that entry’s writes. The move’s last day holds the staff too.

create holds a new row to the same conditions before it is created at all: a check-in recorded only for a paid ticket, on its day, from half an hour before the doors.

"create": { "requires": {
"linked": [{ "via": "ticket_id", "where": [{ "column": "status", "in": ["valid", "offered"] }] }],
"time": { "after": { "column": "doors_at", "via": "event_id", "minus": { "minutes": 30 } } } } }

requires takes where, linked, time and setting, at least one, in the shapes above (no children). They are judged inside the create’s transaction on every door but an import, which is history; a staff device’s occurredAt stands in for now. A refusal is 409 STATE_MOVE_REFUSED with details.create: true and from: null, beside to, requires and the parts above. The row’s formulas, and its copies of a linked row’s columns, are worked out before the conditions are judged. On a table built on an add-on’s shape the app may add only where conditions on columns it added itself (Adminium 0.3.35).

strict refuses a write that names the state the row already holds, rather than letting it pass: a ticket let in once is not let in again. The refusal is 409 STATE_UNCHANGED, with details.at and details.by, when and by whom the row got there. { "show": [...] } repeats up to 4 more columns of the row in the refusal (the door it came in by); never a secret or personal one. Through the public API it is PUBLIC_WRITE_REFUSED with reason: "unchanged". A parent form that sends a child row’s unchanged state back is not refused.

late judges a move made close to a moment: a cancellation inside the last 48 hours before a stay’s arrival.

"late": [{ "to": "cancelled", "from": ["booked"], "moment": { "column": "arrive", "time": "15:00" },
"within": { "hours": 48 }, "mode": "flag", "flag": "late_cancel" }]
Field Rule
to The state moved to. Each late rule judges its own move; a move the booking rule’s cancel already judges takes no second one.
from 1–16 states the move comes from, each a listed move to to. Absent: any.
moment The moment it is close to, read from the row as stored: a guest who types a later arrival in the same change does not move the window.
within How close: one of minutes, hours or days, a number or a setting. A moment already past is inside the window too.
mode "flag": the move goes through and sets flag, a bool column of the table no other rule writes, whoever writes. "refuse": the move is turned away, for a public writer, or with "refuse": "everyone" for every writer.
where 1–8 conditions on the row as the write leaves it. Only a move that meets them is judged: [{ "column": "cancel_code", "in": ["guest", "no_card"] }] leaves a cancellation by the house out, even inside the window. A flag sent for a move the where leaves out is dropped.

A refused late move is 409 STATE_TOO_LATE for staff, and PUBLIC_TOO_LATE through the public API.

timed lists moves Adminium makes by itself once a moment of the row has passed: an order still held when its hold ends is released, an order still placed at the kitchen’s closing hour is cancelled.

"timed": [{ "from": "placed", "to": "cancelled",
"at": { "column": "pickup_at", "time": { "hours": { "table": "opening_hours", "weekday": "weekday", "open": "open", "opens": "opens", "closes": "closes" }, "edge": "closes" } },
"set": { "cancel_code": "closed" } }]
Field Rule
from, to A listed move, never one marked undo. One timed move leaves each state.
at A moment of the row’s own columns (no via). A move whose requires.time.after is certainly later than at could never be made in time, and is refused by the validator.
set 1–8 other columns of the row and the fixed value (or null on a nullable column) the move writes with it: why an order still open at closing was cancelled. Never the state column, the key, or a column another rule writes.

A timed move is a write by Adminium like any other: it is judged as the declared move (its conditions, limits and totals), and sets off stamps, code renewals, effects and emails. See Timed moves on the venue’s clock.

An effect moves the row one of this row’s links points at, in the same write: a guest checked out turns the room to cleaning.

"effects": [
{ "on": { "to": "in_house" }, "via": "room_id", "set": { "status": "occupied" } },
{ "on": { "to": "departed" }, "via": "room_id", "set": { "status": "cleaning" } },
{ "on": { "change": "room_id", "in": ["in_house"] },
"old": { "set": { "status": "cleaning" } }, "new": { "set": { "status": "occupied" } } }
]

There are two kinds, up to 8 effects a table:

  • On a move (on.to): when this row moves to the state, the row via points at moves to the state set names. At most one such effect per state and link.
  • On a changed link (on.change, a foreign key): when the link really changes while the row is in one of on.in (before and after the write; absent: any state), the row it pointed at moves by old and the row it now points at by new; either may be left out. A link first set or emptied moves only the side there is. One such effect per link. Without in, a booked guest’s room assignment would flip rooms too; a hotel says in: ["in_house"].

set names one column, the linked table’s state column, and a state one of its listed moves goes to. The linked row is moved by that declared move and judged as it: its conditions, the limits and totals it moves, its own states. A row already in the state an effect’s old side names is left as it is; a new row already in the state new names refuses the whole write (409 STATE_MOVE_REFUSED with details.effect: "new": the new room is not ready). Any refusal refuses the whole write.

The linked row is moved by its own table’s declared move, with that move’s conditions, its lock and its totals, and not by what the person who made the first move may do to it: whoever may make the move that sets an effect off thereby moves the linked row too, whatever their role writes of that table and whichever of its rows they reach. Give an effect only a link whose row belongs with the row that moves. A link that points at the moving row itself moves nothing.

An effect moves one row, one link away, never a chain. The linked table may not be one whose rows are lines of another table’s states, one that books people by the day, or the app’s outbox; the state it sets may not be one the linked table’s own effects start from (a table with effects may be moved to any other state, its own rows included: a quote that is sent withdraws the quote it replaces); and the move it makes may not wait for another row, be an undo, or be judged late by another row’s time. A table a project hook watches is refused at run time. History (an import, sample data) sets off no effect. A move that set off an effect offers no Undo.

A child row may move the row it belongs to, in the same write: the crew’s sign-off, made on a phone by somebody who reads no job, signs the job off, and the office’s counts and the rules that run on a job signed off follow. Say it on the child, under the parent’s children, with moveOnCreate (Adminium 0.3.30):

"states": {
"column": "state", "initial": "open",
"moves": {
"open": ["installed"],
"installed": [{ "to": "signed-off",
"requires": { "children": { "sign_offs": 1 }, "where": [{ "column": "blocking_snags", "eq": 0 }] } }]
},
"children": {
"sign_offs": { "via": "job_id", "createIn": ["open", "installed", "signed-off"], "moveOnCreate": "signed-off" }
}
}

When a sign_offs row is created, its job moves to signed-off by the job’s own listed move, once the new row is in: that move’s conditions are judged on the job as held, with the new row already counted (children: { "sign_offs": 1 } passes on the first sign-off), and its stamps, totals and limits are written as for any move of the job. It is announced as a change of the job by the same person: the job’s screens refresh, its audit names the sign-off’s maker, and a rule that runs on the job’s move to signed-off runs.

  • A job already signed-off is left as it is, and the row is made: a correction, a new sign-off that names the one it corrects, moves nothing.
  • A job in a state with no listed move to signed-off (still open above), or whose move’s conditions do not hold (a snag still blocking), refuses the whole write: 409 STATE_MOVE_REFUSED, and no row is made. List "open": ["installed", "signed-off"] and a sign-off made on an open job signs it off too.
  • Whoever may create the child moves the parent. The move is the app’s own declared move, as an effect’s is: the roles a move is kept for do not stop it, and the person needs no right to read or change the parent. A refusal about the parent is told as that person reads the parent’s table. Give moveOnCreate only to a child whose rows belong with the parent they move.
  • Only a create moves the parent. A change of the child, its move to another parent, or its delete moves nothing. Importing rows and loading sample data are history: they move nothing either. A save that moved its parent offers no Undo.

The move is made as an effect’s is, so the same tables are refused: the state must be one a listed move goes to, from a state the child is added in (createIn or parentIn, when given); not only by an undo, nor only by an add-on’s ledger (planned); not one that waits for another row or is judged late by another row’s time; not one that sets off this table’s own effects (an effect moves one row, never a chain). The parent may not be a line of another table’s states, book people by the day, keep a limit of its own, be private, be the app’s outbox, or be watched by a project hook (refused when the row is made). An add-on’s shape keeps its children’s moveOnCreate as it wrote it.

A move marked "undo": true takes back the listed move the other way: the kitchen marked an order ready by mistake, and moves it back to preparing.

"moves": {
"preparing": ["ready"],
"ready": [{ "to": "preparing", "undo": true,
"requires": { "time": { "before": { "column": "ready_at", "plus": { "minutes": 1 } } } } }]
}

An undo move is made only by a write that names the state it saw the row in (from on a staff change): a stale screen’s tap never takes back another screen’s move, and is refused 409 STATE_MOVE_REFUSED naming both states. What it waits for is judged on the row as it stands. The stamps written when the row entered the state it returns to keep what they had; the stamps marked clearOnBack that watch the state it leaves are emptied. A state reached only by undo moves is never written by a door that names no state it saw: a timed move, an effect, an email’s onSent, or a value a guest may write. An outbox producer with holdSeconds waits long enough for an undo to drop its message.

An undo may empty further columns that the move it takes back filled, with clears: a hand-over taken back is unpaid again.

"moves": {
"ready": [{ "to": "picked_up", "requires": { "where": [{ "column": "paid_method", "isNull": false }] } }],
"picked_up": [{ "to": "ready", "roles": ["manager"], "undo": true, "clears": ["paid_method"] }]
},
"lock": { "when": ["picked_up"], "except": ["link_stopped"] }
  • The move empties each column it clears, whether or not the writer sends it. A writer may leave it out or send it as null; a value is refused 409 STATE_MOVE_REFUSED, with details.clears naming the column, and nothing is written.
  • What an undo empties, its clearOnBack stamps and its clears, is open to the table’s lock for that move only, and only to be emptied. The same column changed by any other write stays locked (409 RECORD_LOCKED). The lock’s other columns and the rows of children tied to it stay as they are.
  • clears names 1–8 columns of the table, each once, that may be empty. It never names the state column, the key, or a column another rule writes (a stamp is emptied by clearOnBack instead). It is refused on a move not marked undo: only a move marked undo empties columns as it goes. A states rule saved in Studio is held to the same checks.
  • A move is taken by its target: the first listed from a state to another is the one made. A second move between the same two states is refused when either is marked undo or names clears: another move from "picked_up" to "ready" comes first, so this one is never made.
  • The dashboard’s Undo of the forward move makes this move back when the forward change filled a column the undo clears from empty. When the change overwrote a value already there, the move back would lose it, so no Undo is offered. Nor is one offered to a person who holds none of the move back’s roles.
  • A take-back whose row was moved back and on again by someone else while it was made is refused 409 WRITE_CONFLICT with details.retry: true: make it again.

A code nothing renews is put back by an undo as it was. A code a change of hands renewed is never put back: undoing the hand-over makes a code neither holder had. See Undo a status move.

states.actions lists the buttons a generated record page offers on a row, in the app’s own words: up to 12, each with a kebab-case id and a label (one text, or one per language). A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.

"states": {
"column": "status", "initial": "draft",
"moves": { "draft": ["sent"], "sent": ["received"] },
"actions": [
{ "id": "send", "label": "Send", "move": { "to": "sent" }, "tone": "primary",
"confirm": "Send this order to the supplier?", "set": { "sent_at": { "now": true } } },
{ "id": "send-again", "label": "Send again", "set": { "resent_at": { "now": true } }, "in": ["sent"] },
{ "id": "receive", "label": "Receive", "link": { "page": "shop-receipts", "param": "order" }, "in": ["sent"] },
{ "id": "note", "label": "Add a note", "in": ["sent", "received"],
"child": { "table": "order_notes", "via": "order_id", "form": ["text"] }, "set": { "kind": "desk" } }
]
}

An action is one of four forms:

Form Keys What the button does
A move move: { "to" }, set?, ask?, confirm? Moves the row to the state. It is offered in every state that lists a move there.
A write set, in, ask?, confirm? Writes columns and moves nothing.
A link link: { "page" | "addOnPage", "param" }, in Opens another page with the row’s key as param. page is a page of the manifest; addOnPage is <ref> (a page of the same add-on) or <add-on key>:<ref>.
A child row child: { "table", "via", "form" }, set?, in, confirm? Opens a small form (up to 6 columns) and adds a row of the child table, linked through via.

in lists the states the button is shown in. tone is primary, neutral (the default) or danger. ask names up to 4 columns typed in the confirm before the action is made. set is always a key of the action itself: on a move or a write it names columns of the row, on a child row it names columns of the new row. A value is a text, a number or a yes/no the column takes, or { "now": true } on a timestamptz column of the row: the moment the action is made.

Checked against the manifest:

  • A move goes to a state some listed move reaches by hand. A state only a planned move reaches has no button.
  • A column the action sets or asks for is the row’s to write: not the state column, not the key, not a column Adminium fills. A column kept from staff is never asked for.
  • Where the table is locked in a state the button is shown in, or moves to, each column it writes is listed in lock.except.
  • A child row’s table has a foreign key via to this table. via is neither typed nor set.
  • A column is set or typed, not both.

A table’s postings hand its rows to a ledger an add-on keeps, in the same save. How they behave, with every way of writing a row, is in Rows that post into an add-on’s ledger.

"postings": [
{
"id": "stock",
"into": { "addOn": "inventory", "ledger": "stock", "action": "use" },
"via": "order_id",
"reserve": { "on": { "create": true } },
"post": { "on": { "to": ["picked_up"] } },
"reverse": { "on": { "to": ["cancelled"], "from": ["placed", "ready"] } },
"map": { "what": "item_id", "quantity": "quantity" },
"heldUntil": { "parent": "hold_until" },
"unlessSet": "voided_at"
}
]
Field Required Rule
id yes A lowercase name, unique on the table. Up to six postings a table. Two tables of lines under one parent may not share an id.
into yes { "addOn", "ledger", "action" }. The add-on is one the manifest names under addOns.requires or addOns.suggests (or the add-on itself, in its own manifest).
needs no An app’s addOns.features id: the posting runs only while that feature is on.
via no A foreign key of this table: its rows are lines of the row the key names, and every point but a create is judged on that parent row.
reserve, post, reverse one of the first two { "on": <point> }. reverse is required for an action that holds, and for an action that decides an amount when the row is created.
map yes An input of the action → where it is read: a column, { "row": true } (the row itself), { "parent": "<column>" } (under via), { "setting": "<column>" } (the add-on’s settings row), { "value": … }. Every input the action needs is mapped, and nothing else.
multipliers no The same, for night, guest and guest_night (which is night × guest when not mapped).
heldUntil with a reserve of an action that holds A date-and-time column of the row, or { "parent": "<column>" }. Nothing else: a hold ends at a moment a row keeps.
unlessSet no A column of the line: while it is filled, the line is left out of every call.
only no { "column", "eq" }, { "column", "in": [...] } or { "column", "set": true }: only matching lines are handed over.
refuses no Up to four { "column", "set": true } (a line of the same table) or { "table", "via", "column", "set": true } (a line of another table under the same parent): the save is refused while such a line has the column filled. Under via only.

A point is one of:

Point Fires
{ "create": true } when the row is created (under via: when the line is).
{ "to": [...], "from"?: [...] } when the row’s state moves into one of to — from one of from, when given — or it is created in one. Needs states on the judged table.
{ "column", "in": [...], "from"?: [...], "own"?: true } when a column takes one of the values.
{ "column", "set": true, "own"?: true } when a nullable column is filled.

own is said under via: the point is the line’s own column, not the parent’s.

A mapped column is the row’s own to say. It may be a default, a formula, a stamp or a copy made once; it may not be a total, a balance or a copy that follows another row, since Adminium settles those in the same save. A column mapped to an input the action decides is written by Adminium and by nobody else: it is not the key, the state column, a formula, a running number or a total.

A table’s adjust has an add-on that keeps offers lower the price of its rows, inside the save. How it behaves is in Discounts, codes and refunds. From Adminium 0.3.19.

Field Rule
by { "addOn" }: the add-on that answers. It must declare addOn.adjuster.
needs Optional. A feature of the app (addOns.features[].id): the rule runs only while it is on.
lines 1–3 parts. A child table: table, via (its link to this table), price, quantity? (absent: one), discount (written by Adminium), what (1–4 of { "column", "as": "item" | "category" | "type" | "tag" }: what the line sells), excludes? ({ "column", "set": true }: nothing reduces a row whose column is filled), paidBy?, only? ({ "column", "eq" } or { "column", "in" }), unlessSet? (a row whose column is filled is no line). Or the row itself: { "self": true, "price", "quantity?", "discount", … }, once at most, with nights? for a price by the night. A child table’s part must add up into this table through the same via.
order.discount The order’s reduction: a decimal column, written by Adminium.
order.customer link (the foreign key to the customer), address (that table’s address column), proved (a yes/no Adminium writes: whether the customer’s identity was proved), counts? ({ "column", "in" }: which of the customer’s other rows count as earlier orders).
order.staff A reduction by hand: kind (an enum with percent and amount, and comp where offered), value, reason, by (written by Adminium: who gave it).
order.currency A column, { "value" } or { "setting" }.
codes Where codes are typed: table, via, typed (text, up to 64), code and voucher (columns carrying addOnLink into the add-on’s codes and vouchers tables, filled by Adminium), removed? (a row with it set is no code any more).
uses The id of a posting on this table into the same add-on: where what the order used is recorded, once.
frozen From when the price stands: { "to": [states] }, { "column", "in" } or { "column", "set": true }.
expect The money column a price check compares.
refunds Money given back: table, via, amount and tax? (decided by Adminium), of and taxOf? (this table’s columns the order’s cost is read from), against? (the refund’s link to the payment it gives back), lines? ({ "table", "via", "line", "quantity" }: the lines a refund returns).

Every column Adminium writes here — a reduction, proved, staff.by, a code’s links, a refund’s amount and tax — is read-only to every writer and in no public entry’s writable.

removal, on a table, says what a removal of one of its rows takes and keeps:

{ "ref": "deal_members", "columns": [ … ], "removal": { "reason": "required", "history": true } }
  • reason: every door a person removes a row through takes a reason: the delete route (DELETE /api/v1/data/{connection}/{table}/{record} with the body { "reason": "…" }), a bulk delete (POST …/{table}/bulk with "reason", one for the batch) and the add-on kit’s useWrite(table).remove(key, { reason }). It is trimmed, at most 500 characters, and written into the removal’s audit entry (changes.reason). true asks for one; "required" refuses a removal without one (422, fields.reason.code required). Every other removal (an undo, a row taken off under its parent’s save, a rule’s or a move’s delete, a public entry’s) is written with "reason": null, no reason. A table without reason refuses one (not-allowed): nothing would keep it. The reason travels in the body, never in the address, which logs keep. The console’s delete dialogs, for one row and for the rows ticked in a list, ask “Why is it removed?” on such a table (marked optional where it is true) and hold the Delete button until something is written where it is "required". The page assistant is told which tables ask: a delete it proposes carries the person’s reason, shown on the card before it is confirmed, and one without a reason on a "required" table is refused at the check.
  • history: true: a removed row stays readable, through a row it linked to that the reader reads now (a deal’s removed member, a job’s cancelled visit), until an undo brings it back. Every removal keeps it, by whichever door: who removed it, when, why, and the row as it was, masked as the audit log keeps it (no personal data, no code). Never on a private table, and only on a table with a link (fk) to the row it is read through.

The removed rows are read by two routes:

GET /api/v1/data/{connection}/{table}/{record}/removed/{childTable}
GET /api/v1/data/{connection}/{table}/{record}/removed/{childTable}/{childRecord}/history

The first lists the rows of childTable that linked to the record and were removed, newest first, paged by cursor like a row’s history: each with its key, removedAt, who removed it (actorKind, actorLabel), its reason (null for none), the link column that named the record (via) and the row as it was. The second is one of those rows’ own history, as a live row’s history route answers it. Both answer 404 for a record the reader does not read now (as for a record that is not there), and for a child table that keeps no removed rows (This table keeps no removed rows.) or does not link to the record; 403 where the reader’s role may not read the child table. A removed row is shown only to a reader whose read of the child table would reach it: their row limit there is judged on the row as it was, and the columns their role does not read are left out, as a live read leaves them out. A table that never says history keeps nothing: a rep cannot read a contact the admin removed, through anything.

Two apps may use one table between them: a restaurant’s point of sale and its online ordering read and write the same menu. Each app declares the table with the same shape, a name and a version ("shape": "menu@1"); the second app’s install plan finds the first app’s table and offers to share it (the Shared case in What the plan does with each table), with the same safe changes it would make to a table it reuses.

{ "ref": "menu_items", "shape": "menu@1", "columns": [ … ] }

While a table is shared:

  • Rules both apps keep on it (labels, choices, column rules) are kept once: the install skips a rule the other app already keeps there, naming it.
  • Uninstalling either app names the other on the uninstall preview, keeps the table, and hands the rules it kept there to the other app. Those rules survive the other app’s updates, unless a version of it declares its own value for the same column.
  • An app reinstalled beside a menu it used to share is offered it again. A table another app still uses is never offered for renaming out of the way.
  • An app update that stops declaring a table’s shape stops sharing it, but only while no other app shares the table; while one does, the update is refused 409 SHAPE_IN_USE, naming it.

A shared table’s real rows are the venue’s: the second app’s sample data can leave its demo rows for it out (skipWhenShared). A table has shape or builtOn, never both. See A menu two apps share.

An add-on can define a shape: a set of named parts, each with its columns, the rules Adminium keeps on them and its states. The Invoices & Receipts add-on defines invoice@1, whose parts are a document, its lines and its payments. An app builds its own tables on it:

{ "ref": "invoices", "builtOn": "invoices/invoice@1", "part": "document",
"columns": [ … every column of the part, then the app's own … ],
"states": { … the part's states … } }

builtOn is <add-on key>/<shape name>@<version>. The app must require that add-on in addOns.requires, or put the table in a part whose feature needs the add-on: the part is then left out of an install without it. builtOn is not the table’s shape field: that lets two apps share one table, while two apps built on one add-on’s shape each get tables of their own.

The table spells out every column of its part, with the part’s type, nullability, enum values, maxLength, scale, unique, default and rules, and the part’s states. So every check of the app reads its own manifest alone. Where a part’s column refers to another part ("references": "document"), the app’s column refers to its own table built on that part ("references": "invoices"), and a rule that names a part (a rollup’s from: "lines") names the app’s table instead.

What the app may add to a part, and nothing else:

  • columns of its own, with any rules;
  • a label, and the rules that label or narrow a part’s column: enumLabels, personal, validation, required, requiredWhen, options, notAfter and notBefore;
  • a copy in front of a column the part fills with a default (a client’s own tax rate before the add-on’s default rate): the part’s default still answers when the copy comes back empty;
  • in the states: more lock.except columns (its own columns that stay writable), roles on a move, a condition added to a move the part has (requires.where on a column the app itself added: a quote is sent only while its own may_send is 1; the part’s own conditions stay as written, a condition of the app’s never reads a column of the part’s, and the app adds no move), a condition on a new row (create.requires.where, on a column the app itself added: no new quote on a closed deal, read from the deal’s state the app keeps on the quote; a create the part has stays as written, a condition of the app’s never reads a column of the part’s, and a linked, setting or time is the part’s alone; history is not held to it; needs Adminium 0.3.35), a last day on a move the part gives none (requires.notPast, read from any date column of the table, the part’s included: a last day only ever refuses), an effect that runs through a link the app itself added (effects with via, or on.change, naming the app’s own link: sending a quote withdraws the one its replaces_id names, in the same write; the part’s own effects stay as written and in their order), more tables in children, more columns in a child’s clearOnCreate, and on a child the part ties to the state a release and lockLinked entries naming only columns the app added to that child (a line’s own time_entry_id), never the part’s. A release the part has is kept as it is, and a lockLinked entry it has keeps at least its columns.

Everything else, from a column’s type to a rule that decides a value or a move, is the part’s own.

The install checks each table against the shape of the add-on it will really run on: the installed add-on, or the version the install brings with the app. A column dropped or retyped, or a rule changed, refuses the install or update 409 SHAPE_MISMATCH, naming the column, before anything is written. The app pins the shape’s version: invoice@1 is checked against the add-on’s invoice@1, whatever else a newer add-on ships beside it. An add-on update that no longer has a shape version an installed app is built on is refused 409 ADD_ON_SHAPE_IN_USE; update the app first. The tables themselves are the app’s: two apps built on one shape get two sets of tables.

When the shape sends email, the app sends it too: its outbox has a kind and a producer for every kind the shape’s outbox sends. The shape’s document profiles (an invoice, a receipt) are made for the app’s tables when it is installed; see Documents. For the whole story, see Building on an add-on.

An app can be cut into parts its owner switches on and off: a business that sends no crews out switches “Jobs on site” off and never sees its pages, its roles or its rules. The screens say “parts”; the manifest word is modules. A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.28 or later. An add-on declares none.

"modules": [
{ "id": "selling", "label": { "en-US": "Selling" }, "default": "always" },
{ "id": "jobs", "label": { "en-US": "Jobs on site" }, "default": "off" },
{ "id": "quotes", "label": { "en-US": "Quotes" }, "default": "on", "feature": "quotes" }
]
Field Required Rule
id yes kebab-case, up to 40 characters, unique in the manifest.
label yes A keyed label, en-US among them.
default yes on or off: how the part starts; the install check offers it as a tick, and the owner switches it afterwards. always: the part has no switch.
feature no One of the app’s addOns.features: the part is also off while that feature’s add-ons are not all installed and connected to the app, whatever its switch says.

1–16 parts. What belongs to a part says so with "module": "<id>": a table, a page, a dashboard card (an entry of a page’s layout.items), a rule in automations, an entry in documents, an outbox producer and a publicAccess entry each name one part. A role names one or several with "modules": [...]. Anything that names no part is always there.

What “off” means. While a part is off:

  • its pages are out of the sidebar, and their addresses answer 409 PART_OFF;
  • its cards are not drawn on a dashboard of another part: the page is served without them, in its shared layout and in each person’s own. A layout saved meanwhile keeps them where they were, so they are back in their place when the part is on;
  • its rules start no run. A run that was already waiting is passed over, with a line in its log. A scheduled rule that visits each row once does not use a row up while the part is off;
  • its documents are not drawn, its outbox producers queue nothing, its public entries answer as entries that are not there, and a scheduled report over one of its pages waits;
  • a role whose parts are all off gives nothing and is offered to nobody new. Somebody whose every role is like that is told “nothing is switched on for you”;
  • a role that belongs to the part holds nothing on the part’s tables while it is off.

What it does not mean. The tables and their rows stay. A role of another part that is granted one of those tables goes on reading it: an account manager still sees a won deal’s job while “Jobs on site” has no page. Nobody writes it: a part that is off is stopped, so a grant to create, change, delete or import its rows holds for no role until it is on again. So a table’s read grant lapses with the table’s part only for a role that is itself of that part.

Switching a part on needs no update and no restart. Nothing that happened while it was off is caught up: its rules did not run for those changes and do not run for them later. The app’s own screens are told which parts are on (modules in their config, session.modules from connectStaff), and hide a screen of a part that is off; what such a screen may read is still decided by the roles.

A part built on an add-on the app only suggests. A table built on an add-on’s shape cannot be made without the add-on. Put it in a part whose feature requires that add-on and the app need not require the add-on at all. Installed without it, the whole part is left out: its tables are not made, and what names them elsewhere goes with them —

  • the part’s pages, cards, rules, documents, producers and public entries;
  • a dashboard card over one of its tables, whether or not it names the part, so the dashboard is built without it;
  • a key of publicKeys every entry of which went with the part: a shared link’s key is made only with the part it opens, and it is made when the part is put in and the app’s public access allowed;
  • a link column into one of its tables (and an index or a belongs way that reads the column);
  • a kept total over one of its tables: the column stays, empty, with no rule;
  • a page bound to one of its tables, a role’s grants and limits on them, a starting row and a sample row for them;
  • a role left with no part.

When the add-on is connected to the app later, the part is put in: the app is applied again from its own package, the tables and the link columns are made, and nothing that was there is touched. A part that is in stays in; switching its add-on off for the app only turns the part off. The manifest must still be valid with the part left out, and the manifest check tries it: a formula of another table that reads the link column, for instance, is refused with “installed without <add-on>”.

"maps": true at the top of an app’s manifest says the app shows maps. A map’s pictures are fetched by each person’s browser from a map server, so whether maps are shown is one switch for the whole install. The install check of the first app that says this asks, with the tick on; once somebody has answered, no later install asks again. Needs Adminium 0.3.28.

Until somebody answers, maps are shown: the core’s own map widget works on a fresh install. The answer is changed in Settings → Maps, and the operator can switch maps off for the install whatever it says (ADMINIUM_MAP_SERVER=off). Maps, places and routes says what a map asks of which server.

Two column rules, each on a json column. A column takes one of them, not both. They need Adminium 0.3.28.

{ "ref": "spot", "type": "json", "nullable": true, "rules": { "place": true } },
{ "ref": "zone", "type": "json", "nullable": true, "rules": { "area": true } }

A place is kept as one object, or null:

Field
lat, lng The position, in degrees.
by "town" or "exact": how the position is known.
placeId The town’s id in Adminium’s list of places. Always there for by: "town"; on an exact point it names the town the point is in.
name The town’s name as the list has it; or, on an exact point with no placeId, a label the writer gave (up to 200 characters).
country Two letters, upper case.

What a pin by town means. by: "town" is the middle of a town, not an address: “somewhere in Cairo”. It is what an app has when all it was given is a town’s name. Adminium fills the position in itself from its list (placeId), whatever position the writer sent beside it, so a point nobody measured can never be stored as a town’s or the other way round. A map draws such a pin differently from an exact one, a distance from it is an estimate, and the way between two stops known only as the same town is answered as unknown, never as zero, with why (why: "same-town": both stops are pinned to the town; place them to measure). If your app needs the door, ask for an exact point.

Every way of writing the row takes the same things for a place (a staff save, an import, a rule’s step, a public entry that may write the column), and stores them alike:

Given Kept as
{ "placeId": 360630 } { "lat": 30.06263, "lng": 31.24967, "by": "town", "placeId": 360630, "name": "Cairo", "country": "EG" }
{ "lat": 30.0444, "lng": 31.2357 } { "lat": 30.0444, "lng": 31.2357, "by": "exact" }
"30.0444,31.2357" or "geo:30.0444,31.2357" The same exact point.
A map link with the position written in it The same exact point. Read from the link’s text: Google Maps (@lat,lng, ?q=, ll=, a pin’s !3d…!4d…), OpenStreetMap (#map=zoom/lat/lng, mlat/mlon), Apple Maps (ll=).

Adminium never opens a pasted link. A short link (maps.app.goo.gl/…), which says nothing until it is followed, is refused.

A value that is not one of these is refused with 422, the column named in details.fields with one of:

Code
place-unreadable Not a position: free text, a link with no position in it, by: "town" without a placeId.
place-short-link A link that would have to be followed to say where it points.
place-out-of-range A latitude beyond ±90 or a longitude beyond ±180.
place-unknown A placeId the list of places does not have.

An area is kept as a GeoJSON geometry, or null: a Polygon or a MultiPolygon, with positions east first ([lng, lat]). A Feature wrapping one is taken and kept as the bare geometry; a height after a position is dropped. Every ring is closed (its last position is its first) and has at least four positions; the whole shape has at most 2,000 positions and 64 KB. Refused with area-unreadable (not such a shape, an open ring, a position off the map) or area-too-large.

Both are read back as objects on every database, in a list, on a record and through the dashboard’s own routes. An export to CSV writes a place as lat,lng, which an import reads back as an exact point (JSON Lines keeps the whole object), and an area as its JSON.

A map widget takes its points from a place column: a geo-points query that selects a name, then the place column, then any numbers to show ("select": ["name", "spot", "visits"]). Each point says how it is known (by), and a row with no place is left off the map.

optionLists ships lists of answers a column can name with options: { "list": "<name>" }. It is an object keyed by kebab-case list name.

"optionLists": {
"zones": {
"label": { "en-US": "Zones", "de-DE": "Bereiche" },
"values": [
{ "value": "Window", "label": { "en-US": "Window", "de-DE": "Fenster" } },
{ "value": "Patio", "tone": "info" }
]
}
}

Each list has a label and 1–500 values, each with a value (1–256 characters) and an optional label and tone. A list is installed once as <key>-<name> (pos-zones). The operator can edit it like any other list, so a later version never overwrites it, and a list of that key someone already made is left alone.

pages declares the dashboard pages an app adds. An app needs at least one. Each page is built at install over the app’s real table, with the same generator the dashboard’s own create screen uses, and appears in the app’s own sidebar section.

{
"ref": "pos-menu",
"template": "page-crud",
"title": { "key": "mft.pos.page.menu", "fallback": "Menu" },
"titles": { "de-DE": "Speisekarte", "fr-FR": "Carte" },
"nav": { "group": "manage", "icon": "utensils", "order": 1 },
"bindings": { "rows": "menu_items" }
}
Field Required Rule
ref yes kebab-case. It becomes the page’s address, /p/<ref>. Every app installed on the same database shares these addresses, so start it with the app key (pos-menu). The plan refuses an install whose page ref another app’s page already uses there.
template yes The page template; see below.
title yes An i18n message. fallback is the English title.
titles no The title in other languages, keyed by BCP 47 tag, each 1–120 characters. The sidebar shows the operator’s language until the operator renames the page.
nav yes { "group", "icon", "order", "offRail"? }. group is 1–80 characters, icon a Lucide icon name (1–60 characters), order an integer. "offRail": true keeps the page off the sidebar; see A page kept off the sidebar.
bindings no Which of the app’s tables the page reads: an object from a page-local name to a requiredSchema table ref.
config no Page configuration: a form for a record page, a layout for page-dashboard, a calendar for page-calendar, a board for page-board.
feature no The id of one of the app’s addOns.features. Until every add-on the feature requires is installed, attached to the app and switched on there, the page leaves the sidebar and is listed apart, with the add-ons it needs.
module no The part of the app the page belongs to. While that part is off the page leaves the sidebar and its address answers 409 PART_OFF. Needs Adminium 0.3.28.

Templates. A table-bound template reads one table: page-crud, page-board, page-calendar, page-scheduler, page-directory, page-master-detail, page-queue-inbox, page-log-viewer, page-files and page-chat. Give it bindings. A single entry names its table whatever its key ({ "items": "menu_items" }); with several entries, the one keyed rows is the page’s own table. page-dashboard reads from several tables and takes its widgets from config.layout.

Config. config.form and config.layout name the app’s tables and columns by their short refs; Adminium binds them to the real tables at install. The plan refuses a form or layout that is not well formed or that names a column or table the manifest does not declare. Other problems do not block the install: a page with no bindings, an unknown template, or a table that cannot back its template gives a page that is created empty and says so, and the install report lists it.

A form’s chips field ("control": "reference-chips") may name the table it picks from or the link table between the two ("relation": "clinician_visit_types"). A link table is one that holds the two foreign keys and nothing else to fill, with or without its own id. A field the install cannot bind is listed in the install report, and the page gets the form Adminium makes. A designed field the form cannot show says so in its place.

config.calendar names the columns a page-calendar plots by:

"config": { "calendar": { "start": "starts_at", "title": "patient_id.name", "category": "visit_type_id" } }
Field Required Rule
start yes A date or timestamptz column of the page’s table: where each row is plotted.
end no A date or timestamptz column: where a row that spans time ends.
title no A column of the page’s table, or <fk column>.<column>: a column of the table that foreign key points at (the patient’s name).
category no A column of the page’s table: what the rows are coloured and filtered by.

The manifest is refused when a name is not a column of the right table and type. Without calendar, a table with a booking rule is plotted by the booking’s start; any other table by the first date Adminium finds. On a page with a form, Add event and a click on an empty day open that form, with the day filled in.

config.board says what the columns of a page-board are. It needs "minAdminiumVersion": "0.3.28".

"config": { "board": {
"column": "stage_id",
"columnsFrom": { "table": "stages", "label": "name", "order": "position", "badge": { "column": "odds", "as": "percent" } },
"title": "name",
"totals": [{ "column": "amount", "fn": "sum" }, { "column": "weighted_value", "fn": "sum" }],
"only": { "state": "open" },
"card": {
"link": "company_id.name",
"person": "owner_id",
"amount": "amount",
"secondAmount": { "column": "weighted_value", "label": "w", "labels": { "de-DE": "gew." } },
"daysSince": { "column": "entered_stage_at", "warnFrom": 14, "alertFrom": 30, "label": "in stage", "labels": { "de-DE": "in der Phase" } },
"date": "expected_close"
},
"toolbar": { "total": true, "person": "owner_id" },
"note": { "text": "Columns come from Stages", "texts": { "de-DE": "Die Spalten kommen aus den Phasen" }, "href": "/p/crm-stages" }
} }
Field Required Rule
column yes The column of the page’s table a card is placed by. With columnsFrom it is a link ("type": "fk") to that table. Without columnsFrom it is an enum column, and the board’s columns are its values.
columnsFrom no { "table", "label", "order", "badge" }: the table of the app whose rows are the board’s columns, the column of it a board column is headed with, and the column the board’s columns are ordered by. order may be left out; the columns are then in the order of their key. badge is { "column", "as" }: one more column of that table, shown small beside each heading. as is "percent" (a share kept as a fraction: 0.1 reads “10%”), "number", or "text" (the default: as kept, up to 24 characters).
title no The column of the page’s table a card is titled with. Left out: the table’s own title column.
totals no One or two { "column", "fn" }: a number or money column of the page’s table, and sum or avg. Each column’s header shows the figure beside its count.
only no Which rows of the page’s table are cards on the board: one to three "column": value or "column": [values] (up to 20 values each). A row is a card when every column named holds its value, or one of its values. Left out: every row.
card no What a card shows under its title, each from a column of the page’s table; at least one of: link ("<link column>.<column>": a column of the row the link points at, such as a company’s name), person (a staff column, rules.staff, shown as the person’s name), amount (a number or money column), secondAmount ({ "column", "label", "labels" }: a second number or money column, drawn at the end of the amount’s line after its short label, such as a deal’s weighted value “w €1,240”; labels gives the label in other languages by their tag; needs minAdminiumVersion 0.3.35), daysSince ({ "column", "warnFrom", "alertFrom", "label", "labels" }: whole days from a date or time column to today; from warnFrom days the figure is amber and from alertFrom red, both optional, 1 to 3650, warnFrom the smaller; label says what the days are, after the figure: “12 days in stage”, with labels by language; the label needs minAdminiumVersion 0.3.35) and date (a date or time column, shown as a day). A label is 1 to 40 characters, and labels needs label.
toolbar no { "total", "person" }, at least one (0.3.35): a line drawn above the board. "total": true shows how many cards the board holds and each of its totals over all of them. person (a staff column, rules.staff) is a filter: “Everyone” and each person who holds a card on the board. The board’s note is drawn at the line’s end.
note no { "text", "texts", "href" }: one sentence of up to 200 characters drawn above the board; texts gives it in other languages by their tag, as a page’s titles does; with href, a path of the workspace starting with one / (a page is /p/<its ref>), the sentence is a link.

With columnsFrom the board draws one column for each row of that table, a row with no card too, and reads them when the page opens. A row added, renamed, moved or removed there changes the board for everybody looking at it who may read that table, about a second later, with no update of the app; so does a card a colleague saves. A card whose link is empty is on no column. Dropping a card on a column saves that row’s key in the card’s link, as any save of the record would: where a role’s limit or a state rule refuses the save, the card goes back and the board shows the reason.

The count and the totals in a column’s header are worked out on the server, over the rows the person may read: a rep held to their own deals sees the count and the worth of their own. Money is shown in the column’s own currency when it has one, else in the install’s. A board draws the first 200 cards of its table; the count is of all of them. It draws the first 100 rows of columnsFrom.table as columns, and says so when that table has more.

Everything card draws is read by the server as the person looking, like the headers. The linked row’s column is empty when their roles do not read that table, or do not reach that row. A person is shown by name and never by id, on the rule of the people list: a signed-in reader is told the name of somebody who is here or is a sample person; anybody else (suspended, only invited, removed) is drawn as nobody. The days are counted on the server’s clock, on the calendar of where the business is, so a browser with the wrong date draws the same card; a row with no date has no figure, and a date still to come counts 0. With daysSince the cards are listed longest-waiting first. The two totals of a header are each named by their column’s label (“Amount €12,400 · Weighted value €1,240”); a total takes a line of its own where the heading has no room. A role that may not read the badge column sees the board without the badge.

The toolbar’s total is the server’s, worked out over every card the person may read (their row limits, and only), never a sum of the cards drawn: a board of 340 deals draws 200 cards and its toolbar counts 340. The person filter offers the people who hold a card the person reads, named as the people list names them, the most cards first (30 at most); a card with nobody in the column is among “Everyone” only. Choosing a person narrows the cards, each column’s count and totals, and the toolbar’s total to that person’s cards, still within what the person looking may read: a rep held to their own deals who chooses a colleague sees no card. The choice holds while the page is open.

only makes the board a board of some of its table’s rows: "only": { "state": "open" } is the open deals, "only": { "kind": ["new", "renewal"], "hot": true } the hot deals of two kinds. It holds for the cards and for the headers alike, so a column’s count and totals are of the cards only keeps. A column of only is an enum, a bool, a link (fk, by the key it holds), text or a whole number (int, bigint) of the page’s table, and not the board’s own column; a value of an enum is one of its values, and a value of a link is of the kind the table it links to is keyed by: a whole number for an int or bigint key, text for any other. A card added on the board is given each value only names alone ("state": "open", not a list), so it is on the board; where the table’s own rules refuse that value for a new row, the add is refused and the board says why. A role that may not read a column only names (it is not among the role’s readable columns, or it is personal data the role may not read) gets neither the cards nor the figures: the headers show no count and no total, never those of every row. So name a column every role that opens the board may read.

The manifest is refused when column is not a link to columnsFrom.table (or, with no columnsFrom, not an enum), when a name is not a column of the right table, when a total is not a number or money column, when only names the board’s own column, a column of another type or a value its column cannot hold, when a card names a link that is no link, a person that is no staff column, an amount that is no number or a day that is no date, when a note opens anything but a path of the workspace, and on a page that is not a page-board. The install checks the same against the tables as they really are: a board that cannot be built as named is not built as another one. Its page is created empty and the install reports why (PAGE_BOARD_INVALID). Without board, a board’s columns are the values of the workflow column Adminium finds, as described under A board with its own columns.

Links that open a list filtered. A link in a layout (a metric card’s href, the toolbar’s link) may open a records page on some of its rows: /p/<page ref>?f.<column>=<op>:<value>, with one f. piece per column, up to 8. “Overdue” leads to /p/studio-invoices?f.status=eq:sent&f.due_on=before:today, and the page shows a chip for each piece.

Piece Rows it keeps
eq:<v>, neq:<v>, in:<a,b> The value, not the value, or one of up to 50 values.
gt:, gte:, lt:, lte: A number, or a day.
before:<day>, after:<day> A date or time before or after that day.
before:now, after:now A time before or after this moment.
month:this, month:last A date or time in this or last month.
set, unset Has a value, or has none.

A day is YYYY-MM-DD, today, or a whole number of days from it: today-30, today+7 (at most 3660 either way). Days are the venue’s: on a time column a day is the whole day where the venue is. now is for time columns only. The pieces are worked out on the server when the page opens, so today is always the day it is read. A piece the column cannot take (an unknown column, a personal column the reader may not see, gt on a yes/no, a value that is not a number) is left out, and its chip says so; the page never fails.

Updates. A page nobody has edited is rebuilt when the app is updated. A page the operator edited is left as they left it. Uninstalling an app does not delete its pages.

A page-crud page bound to a table may say three more things in its config. A manifest that uses tabs or bulk sets compatibility.minAdminiumVersion to 0.3.18 or later.

"config": {
"tabs": { "receipts": { "empty": "Nothing received yet", "emptyBody": "A receipt shows here.", "noNew": true } },
"filters": [{ "column": "status", "control": "any-of" }, { "column": "ordered_on" }],
"bulk": [{
"id": "reorder", "label": "Reorder",
"child": { "table": "reorder_requests", "via": "point_id", "form": ["note"] },
"set": { "source": "list" },
"where": { "column": "state", "eq": "low" },
"confirm": { "title": "Reorder {count} items?", "body": "One request is made for each.", "columns": ["name", "on_hand"] },
"done": "{count} requested"
}]
}
Key Rule
tabs Keyed by a child table: one with a foreign key to the page’s table. empty and emptyBody are what the tab says while it holds no row; noNew: true takes its “New” away, for rows made elsewhere. It words a tab the page already has: it adds none and renames none.
filters Up to 6 columns of the page’s table, each once. control is one-of or any-of for a choice, yes-no, record for a link, date-range or number-range; left out, the column’s type decides. A free text column and the key cannot be filtered.
bulk Up to 2 actions on the rows ticked in the list. Each adds one row of child.table per ticked row, linked through via, with form (up to 4 columns) typed once for all of them and set written on each. where keeps only the ticked rows that hold a value. confirm.columns (up to 8) are shown for each row before anything is made.

A bulk action’s confirm.title, confirm.body and done may name {count}, the number of rows, and nothing else. Its where and confirm.columns never name a secret column or a code kept from staff. A dashboard page’s toolbar takes layout.toolbar.links in place of link: one or two links of the same shape, at most one of them with "tone": "primary".

A link to the app’s own staff side. A toolbar link, or a card’s href, may name "@staff": the app’s staff screens, wherever the app placed them (inside the dashboard, or on their own address in a new tab), as the sidebar’s “Open the staff screens” opens them. "@staff/<path>" opens one screen of them: "@staff/approvals" is /a/<app key>/approvals inside the dashboard, or approvals under the side’s own address. The path is a path under the side, as a navLinks entry’s is: plain segments of letters, digits, ., _, ~ and -, with no query, no # and no ..; the manifest is refused otherwise, and when the app has no staff frontend. A link is drawn only when the side can be opened. @staff/<path> needs Adminium 0.3.28 (an older release draws no such link).

"layout": { "version": 1, "toolbar": { "links": [
{ "label": "Approvals", "labels": { "de-DE": "Freigaben" }, "href": "@staff/approvals", "icon": "clipboard-check", "tone": "primary" }
] }, "items": [] }

A dashboard card of a part, and its column headings. A card of an app’s page-dashboard (an entry of layout.items) may say which part of the app it belongs to with "module": "<id>": while that part is off the card is not drawn, for everybody, and the page stays whole. A table card’s columns (config.columns, as a data-grid heads them and a detail-key-value names its rows) may give their heading in other languages with labels, keyed by tag as a page’s titles are: each reader sees the heading in their language, else label. Both need Adminium 0.3.28.

{ "i": "unclaimed", "widget": "kpi-stat-card", "x": 3, "y": 3, "w": 3, "h": 3, "module": "inbox",
"config": { "title": "Unclaimed enquiries", "binding": { "kind": "table-query", "source": { "name": "enquiries", "type": "table" }, "shape": "metric+delta", "aggregations": [{ "fn": "count", "alias": "n" }] } } }

A card’s figures (aggregations[].fn) are the query descriptor’s: count, count_distinct, sum, avg, min, max, median and percentile. median (from Adminium 0.3.35) is the middle value: the mean of the two middle numbers of an even count, the earlier of the two middle days of a date column. min, max and median of a date or a time column answer the day itself (0.3.35), in a single figure, a figure and its prior window, or beside a number in a ranking: see a figure is a number, or a day.

"columns": [{ "name": "name", "label": "Rep", "labels": { "de-DE": "Verkäufer", "ar-EG": "المندوب" } }]

navGroups names the headings inside the app’s sidebar section. Up to 12.

"navGroups": [
{ "key": "manage", "label": { "en-US": "Manage", "de-DE": "Verwalten" }, "order": 1 },
{ "key": "records", "label": { "en-US": "Records", "de-DE": "Aufzeichnungen" }, "order": 2 }
]

Each group has a kebab-case key, a keyed label (must include en-US) and an integer order. A page whose nav.group is one of these keys is listed under that heading, in nav.order. A page whose group is not declared (an Overview, say) is listed first, with no heading.

An app with many tables has many pages, and its sidebar section need not list them all. A page whose nav says "offRail": true is left out of the sidebar and is a page in every other way:

{ "ref": "crm-stages", "template": "page-crud", "title": { "key": "mft.crm.page.stages", "fallback": "Stages" },
"nav": { "group": "selling", "icon": "milestone", "order": 1, "offRail": true },
"bindings": { "rows": "stages" } }

It opens at its address (/p/crm-stages), a record of its table opens on it, and search, the command palette and the assistant’s “where is” find it, for whoever may view it (page:@crm-stages:view). Somebody who may not view it finds it nowhere, as with any page: the key hides a page from the menu and from nobody’s reach. A heading of the section whose every page is kept off the sidebar is not drawn. The only value is true; leave the key out for a page on the sidebar. An owner who edits the page keeps their edit and this key stays the app’s to say. Needs Adminium 0.3.28.

navLinks adds entries to the app’s sidebar section that are not pages. Up to 24. An entry does not write an address: it names one of a closed list of places, and Adminium writes the address.

"navLinks": [
{ "ref": "approvals", "label": { "en-US": "Approvals", "de-DE": "Freigaben" },
"nav": { "group": "selling", "icon": "badge-check", "order": 7 },
"to": { "side": "staff", "path": "/approvals" } },
{ "ref": "rules", "label": { "en-US": "Rules" },
"nav": { "group": "setup", "icon": "workflow", "order": 1 },
"to": { "screen": "automations" } }
]
Field Required Rule
ref yes kebab-case, up to 60 characters, used once.
label yes A keyed label (must include en-US). Shown in the reader’s language.
nav yes { "group", "icon", "order" }, as a page’s: the entry sits among the pages of its group, in order.
to yes Where it opens: { "screen": … } or { "side": "staff", "path"? }, below.
module no The part of the app the entry belongs to. It leaves the sidebar while the part is off.
to Opens Shown to
{ "screen": "automations" } The dashboard’s Automations, showing only the rules this app brought (/automations?app=<key>). Whoever may manage automations.
{ "screen": "email-templates" } The dashboard’s Emails, showing only the documents this app brought (/email-templates?app=<key>). Whoever may manage settings.
{ "screen": "settings" } The app’s own page in the console: its parts, its sample data, its add-ons. Whoever may manage apps.
{ "side": "staff" } The app’s staff side: its first screen, or the screen at path. Inside the dashboard for a side shown there, else on its own address in a new tab. Whoever may open the staff side (app:@:staff), while the side is served.

path is a path under the side: / then plain segments (letters, digits, ., _, ~, -), with no query, no # and no ... An entry is listed only to somebody who can open what it opens; nobody else is told it exists. The two narrowed screens show a line saying what is shown, with a way back to everything, and they only narrow: nobody sees a rule or an email there that the whole screen would not list for them. An entry to the staff side needs a staff frontend. Needs Adminium 0.3.28.

frontends declares the app’s own screens: a staff side, a customer side, a mobile side (a phone app), or any of them together. At least one is required, and each side may appear once. The Adminium dashboard itself is always there and is not declared.

"frontends": [
{ "side": "staff", "kind": "spa", "entry": "index.html",
"routes": { "pos": "/" }, "placement": "external" },
{ "side": "customer", "kind": "spa", "entry": "index.html",
"routes": { "book": "/", "manage": "/manage" } }
]
Field Required Rule
side yes staff, customer or mobile.
kind yes spa, electron or none; expo for the mobile side, and for it alone.
entry no The frontend’s entry file (index.html).
env no The environment variables the frontend reads, each { "required": boolean, "example"?: string }.
routes no The views this side owns, from a view name to a path.
placement no Staff side only: internal opens the screens inside the dashboard, external on their own address (a till). The operator can change it. Absent means internal.
enabled no Whether the side starts switched on. Absent means on.
records no Staff side only: where a record opens on this side, from a table ref to a path with one {id}; see Where a record opens. Needs Adminium 0.3.28.

Adminium gives people links to records: a notice in the bell, the button of the notice’s email, a search hit. Each opens the dashboard’s record page. An app whose staff side has a screen of its own for a record says where, per table:

{ "side": "staff", "kind": "spa", "entry": "index.html", "placement": "external",
"records": { "deals": "/deals/{id}", "quotes": "/quotes/{id}", "companies": "/companies/{id}" } }

Each key is one of the app’s table refs. Each value is a path under the side: /, then plain segments (letters, digits, ., _, ~, -), and {id} as one whole segment, exactly once. It has no query, no #, no .. and no host: the address can only be a screen of this side. {id} is the record’s key as the dashboard’s record page takes it (a key of several columns is their values as a JSON list), written as one segment whatever it holds.

Who gets the app’s address is decided for each person. Somebody whose roles open the staff side (app:@:staff) gets it, while the app is on, the side is switched on and the table’s part is on. Everybody else keeps the dashboard’s record page. Then:

  • A notice (GET /api/v1/me/notifications) carries the address in actionUrl (/apps/<key>/staff/deals/12; /a/<key>/deals/12 for a side shown inside the dashboard) and opens: { "appKey", "path" } with the path under the side (/deals/12), which the app’s own screens route on. It is asked again each time the list is read, so a role given or taken later moves a notice already written.
  • The notice’s email carries the same address in its button: on the host mapped to the staff side when there is one, else under the server’s public address.
  • A search hit (GET /api/v1/search) carries opens: { "appKey", "path", "url" }. A table with an address is searched for somebody who reads it and opens the staff side, even with no page of the dashboard to land on; pageSlug is then empty.

Somebody who is signed out and follows the address is sent to sign in and brought back to it. The way back is followed only to a path of the same address, and on the dashboard’s own address only into an app’s screens. Only the staff side takes records. A table left out with its part takes its address with it.

A mobile side is { "side": "mobile", "kind": "expo" } and nothing more: it is a phone app in apps/<key>/mobile/, which Adminium serves to nobody and no package carries, so it has no entry, routes, placement or switch. What it has is a key of its own kind for the public API, made at install beside the customer key: see A phone app for an app. A manifest with a mobile side needs the Adminium that knows it or a newer one (compatibility.minAdminiumVersion); an older one refuses the whole manifest.

Adminium serves each web side at /apps/<key>/<side>/. The staff side needs a signed-in user; the customer side is public and calls the public API with the app’s customer browser key, which the install creates. A staff side can be handed a second key for a screen of its own; see publicKeys. Each side reads its surface-config.json at boot: the real table names, the app’s settings and, for the staff side, the connection, the venue’s time zone and currency, who is signed in, the language they chose in Adminium (locale, as a tag such as ar-EG, with its dir), and access: which of read, create, update and delete they hold on each of the app’s tables, and the app’s roles they hold. A staff screen uses it to leave out a button whose write would be refused; the data API still checks every write. The staff side’s config also carries kit, the address of Adminium’s own larger parts for a screen that draws one itself (see Adminium’s own parts on an app’s screen), and maps: whether maps are on, the map server’s address, and whether routes along roads are on.

A role can be held to some rows of a table (see Whose a row is). Five words say how. They need Adminium 0.3.28.

A staff column holds a staff member’s id. It is a text column, and the only kind of column a person is read from:

{ "ref": "owner_id", "type": "text", "maxLength": 36, "nullable": true, "rules": { "staff": true } }

belongs, on a table, lists the ways a person belongs to one of its rows. Any one is enough:

"belongs": [
{ "column": "owner_id", "set": "team" },
{ "through": { "table": "deal_members", "link": "deal_id", "user": "user_id" } },
{ "through": { "table": "covers", "link": "owner_id", "on": "owner_id", "user": "cover_id", "from": "starts_on", "to": "ends_on" } },
{ "via": "company_id" }
]
Way Fields
a column column: a staff column of this table. set says whom a writer limited on the table may put there: self (the default: only themselves), team (somebody in a team they lead) or anyone. With person, column is instead a link to a row of the app, and person names the staff column on that row (a ticket’s assignee_id to an agent’s user_id).
a listing through: table lists people against rows; link is the listing’s column that equals this row’s key, or this row’s on column when it is not the key; user is the listing’s staff column (or, with person, a link to a row that holds one). from and to are date columns of the listing: the listing counts from the first to the second, by the business’s day, and either may be empty. while makes a listing row count only while another column of it holds one of some values ({ "column": "kind", "in": ["helper"] }) or is empty, or not ({ "column": "ended_on", "isNull": true }, for a nullable column): a member row whose end is set lists nobody, from that moment, on every read. Not the user or the link column. while needs Adminium 0.3.28.
a parent via: a link of this table. Whoever reaches the row it points at reaches this one.

A row is reached through at most two links (via of a table that is itself reached via another). The listing a way goes through cannot itself be reached through the table it lists for. Each staff column a way names, and each link a via follows, should be first in one of the table’s indexes: a limited list is as fast as an unlimited one with the index, and several times slower without it.

private, on a table, with a belongs of plain staff columns only (no via, through or person): its rows are read by their own people and by nothing else. No other table’s rows are reached through a private table but by those same people (below), nothing is copied out of it, no total of its rows is kept elsewhere, and it is not exported, imported, reported on a schedule, drawn into a document, read by a rule or handed to an owner’s code. An update of the app is the only thing that sets or removes the mark.

A row hung on a private row, reached by its own people. Another table may say its rows are reached through a link to a private table, with person naming one of that private table’s own belongs columns: exactly the people who already read the private row. A step of a coaching area is the rep’s and the manager’s of that area:

{ "ref": "coaching_steps", "belongs": [{ "column": "area_id", "person": "rep_id" }, { "column": "area_id", "person": "manager_id" }], "columns": [ ... ] }

Any other staff column of the private row as person is refused, as are a through listing or a via through it. Such a way is never widened by a team: a team reach of the steps reaches, through the private row, only the rows whose private row names the reader, never one that names somebody they lead (another manager’s one-to-one with her rep stays out). A writer held to some rows may set the link only to a private row that names them; with several such ways on one link, any one that names them is enough. Needs Adminium 0.3.35.

rows, in a role’s limits for a table, says which rows its read, update and delete reach, each on its own:

"limits": { "deals": { "rows": { "read": { "reach": "team", "unowned": true }, "update": { "reach": "own" } } } }

reach is own, team or all; unowned: true adds the rows nobody belongs to; while ({ "column", "in": [...] }) holds the reach to rows whose column has one of those values now, and all is written only with a while. An action left out reaches every row. The table must declare belongs. A manifest is refused when a role holds a limit on a table of which another table keeps a stored total (a rollup), unless that table is reached via the very link the total adds up by.

by narrows a reach of own or team to the rows a staff column of the table names the person in (with team, somebody they lead). It is for a table reached through a parent, where everybody on the parent reads every row and each changes only their own:

"limits": { "notes": { "rows": {
"read": { "reach": "own" },
"update": { "reach": "own", "by": "author_id" } } } }

A rep reads every note of the deals they reach, and changes the ones author_id names them in. The column must be a staff column (rules.staff).

notBy, on an update or a delete reach, takes out the rows a staff column names the person in: of the row itself, or <link>.<staff column> of the row one of its foreign keys names. A sales manager changes her team’s targets and never her own, and any starter’s goal but her own:

"limits": {
"targets": { "rows": { "read": { "reach": "team" }, "update": { "reach": "team", "notBy": "rep_id" } } },
"starter_goals": { "rows": { "update": { "reach": "all", "notBy": "starter_id.user_id" } } } }

It judges the person alone, never the people they lead, and all takes it in place of a while. It holds on every door the reach holds on (a change on a page, a bulk edit, a file brought in, a record’s button): a row it leaves out is a row that is not there to them (404, and a file’s line is not matched). It also judges the row as a write leaves it: a target made for herself, or a colleague’s moved onto her, is refused (403 COLUMN_FORBIDDEN, reason not-by, naming the column). A record page does not show her the buttons of a row whose own staff column names her, where every reach of hers says so. Never on a read, a private table, or a grant on an add-on’s table. Needs Adminium 0.3.35 (rows.notBy).

via beside by judges a row of a table that hangs off a parent (its belongs has { "via": "<link>" }) by a staff column of THAT parent, and by nothing else: by then names a column of the row the link points at. A sales manager reaches the visits of the deals somebody she leads owns, and not a visit on another team’s deal, even where her rep helps on that deal or the visit itself names her rep:

"limits": {
"deals": { "rows": { "read": { "reach": "team", "by": "owner_id" }, "update": { "reach": "team", "by": "owner_id" } } },
"site_visits": { "rows": { "read": { "reach": "team", "via": "deal_id", "by": "owner_id" },
"update": { "reach": "team", "via": "deal_id", "by": "owner_id" } } } }

reach is own (the parent names the person) or team (the person, or somebody they lead); unowned is not taken. via must be one of the table’s own belongs links and by a staff column (rules.staff) of the table it points at. It holds on every door a reach holds on: the list and the record (another team’s visit is a row that is not there, 404), a change, a new row (hung only under a parent whose by names them or somebody they lead, else 403 reason parent-not-theirs, whoever the new row names), a dashboard figure (worked out again as soon as the parent changes hands), a file out, the live frames (a bare “this table changed”, never a row) and the role editor’s count. It is opt-in: a reach without via reads the table’s every way as before (a helper listed on the deal, the visit’s own rep). Needs Adminium 0.3.35 (rows.via).

notThrough, on an update or a delete reach, names a listing the reach does not count: a through way whose table is that listing makes no row the person’s for this reach, on the table itself or on a row it hangs off (via, up to two links up). Every other way counts as before: a staff column, another listing, the rows nobody owns. A helper listed on a deal (deal_members) reads the deal, its quotes, their lines and its visits, and comments on them, and changes none of them; whoever covers for the deal’s owner (covers), and the owner, change them as they always did:

"limits": {
"deals": { "rows": { "read": { "reach": "own" }, "update": { "reach": "own", "notThrough": "deal_members" } } },
"quotes": { "rows": { "read": { "reach": "own" }, "update": { "reach": "own", "notThrough": "deal_members" } } },
"quote_lines": { "rows": { "read": { "reach": "own" }, "delete": { "reach": "own", "notThrough": "deal_members" } } },
"follow_ups": { "rows": { "read": { "reach": "own" }, "update": { "reach": "own", "by": "owner_id", "notThrough": "deal_members" } } } }

It is one listing’s ref, or a list of one to four. Each must be the table of a through way of this table, or of a table it hangs off by via (two links at most); it is refused on a read (a person listed on a row still reads it), beside via (that reach counts no listing already), with reach: all, on a private table, and on a grant on an add-on’s table. It holds on every door an update or a delete reach holds on (a change on a page, a child row saved with it, a bulk edit, a file brought in, a record’s button, a document): a row it leaves out is a row that is not there to them (404). A new row hung under a parent (a quote of the deal, a follow-up or a visit on it) is judged by the parent without the listing too, 403 reason parent-not-theirs: where every reach of the writer on the new row’s table leaves a listing out, a staff column of the new row that names the writer does not make it theirs while it is hung under a parent. A row hung under no parent is theirs by its own column as before, and a row of theirs made before is changed and removed as before. The role editor keeps it through a save and says it in words (“but not as someone listed in Deal members”). Needs Adminium 0.3.36 (rows.notThrough).

An action may name several reaches, as a list of one to four: any one of them is enough, and each is its own whole test (its while, unowned and by hold for it alone), as two roles’ reaches are. An account manager reads every won deal, and an open one only while he is listed on it:

"limits": { "deals": { "rows": { "read": [
{ "reach": "all", "while": { "column": "state", "in": ["won"] } },
{ "reach": "own" } ] } } }

No reach of the list is written twice, and the checks above hold for each. A private table’s reach stays { "reach": "own" } alone. A list needs Adminium 0.3.28.

A row of a table reached through its parent (via) is hung, by a writer held to some rows of it, only under a parent their reach of THAT table reaches: a parent that is theirs (or somebody’s they lead, with team), or nobody’s when the reach takes unowned rows. Reading the parent is not enough: the account manager above reads every won deal and comments only on a deal he is on (403, reason parent-not-theirs). A row a staff column of its own names them in (a stamp of its maker, say) is theirs whatever its parent, unless every reach of theirs on the table leaves a listing out (notThrough, above); a table that also lists people through, or reads a person from a linked row, is not judged this way.

Apart from any limit, the columns that decide whose a row is (the staff columns the table’s own belongs names) are changed only by somebody the stored row names in one of them, or who leads that person where the role’s reach is team. Somebody who reaches the row another way (a helper listed on it, a cover, the owner of its parent) works on the row and cannot give it away or take it. A record’s handOver button frees whom the row may go to, not who may hand it over.

Where such a column is kept as a copy of the row’s parent (copy with mode: "always"), the person is decided by the link: a writer held to some rows sets that link only to a parent that names them there (or somebody they lead, with set: "team"), and is refused 403 with reason parent-not-theirs otherwise. And the person column of a table that another table’s belongs reads through takes only somebody who reads at least one of those tables (422, the field marked cannot-open), whoever writes: a person on the dashboard, a bulk edit, an import (by somebody nothing limits too), a rule’s write step, the row an effect moves, a timed move, and the owner’s code. Every one of them is judged on the row as its rules leave it, a person’s own change and new row included (from 0.3.35), so a stamp that puts a person into such a column (its writer, or a person copied from another column) is held to the same. See Whose a row is.

handOver, on one of a table’s states.actions, marks the one write in which a person limited to their own rows may name a colleague: the button asks for a staff column and sets it to anybody.

{ "id": "hand-over", "label": { "en-US": "Hand over" }, "set": {}, "in": ["open"], "ask": ["owner_id"], "handOver": true }

Anybody, that is, who can work the row: an active person whose roles read and change the table, with a reach that holds the row in the state it is in (an account manager who reaches a deal once it is won is not given an open one). Anyone else is refused 422, the field marked cannot-open. A writer held to some rows who names somebody by a plain change, in a column whose belongs way says set: "anyone", is held to the same: the button is not the only door to the column.

carries, beside handOver, moves the old person’s child rows to the new one in the hand-over’s own write: a deal handed over takes its owner’s booked visits and open follow-ups along, and leaves a visit already made, and a colleague’s rows on the deal, where they are.

{ "id": "hand-over", "label": { "en-US": "Hand over" }, "set": {}, "in": ["open"], "ask": ["owner_id"], "handOver": true,
"carries": [
{ "table": "site_visits", "via": "deal_id", "column": "rep_id", "where": [{ "column": "state", "in": ["booked"] }] },
{ "table": "follow_ups", "via": "deal_id", "column": "owner_id", "where": [{ "column": "done", "eq": false }] } ] }
Key Meaning
table A table of the app with a foreign key to this one.
via That foreign key.
column The child’s staff column (rules.staff) that moves: only rows where it names the row’s OLD person move, and they are given the new one.
from The staff column of this row the person is read from: one of the columns the button asks for. Left out when the button asks for one staff column.
where 1–8 conditions on the child row, as a move’s requires.where writes them (eq, in, isNull, gt…): every one must hold.

One to eight carries, each table and column once, on a button with handOver: true. Each carried row is changed as its own update, inside the hand-over’s transaction: its rules and hooks run, it is written to the audit log, its live frame is sent and the rules and the outbox that watch its table are told. The person handing over must hold the child table’s update and reach each row moved (else 403, and nothing moves); the column itself is the button’s to write, as the columns it sets are, whatever the role’s writable says. Each row is judged as a hand-over judges its own row: the new person works the child table, and a child that is a listing lists them only against rows they can open (422, the field marked cannot-open). A child table that takes a resource for a time (occupies or booking) has each carried row placed as a single change places it, under its day’s lock: a visit the new person is already busy for refuses the hand-over (409 BOOKING_TAKEN). One refusal refuses the whole hand-over. A hand-over that carried rows offers no Undo. Needs Adminium 0.3.35 (states.carries).

roles declares roles the app brings, such as a cashier and a manager. Each is installed as <key>-<role key> (pos-cashier) and belongs to the app.

{
"key": "cashier",
"name": "POS cashier",
"screensOnly": true,
"permissions": ["table:@tickets:read", "table:@tickets:create", "page:@pos-menu:view", "app:@:staff"]
}
Field Required Rule
key yes kebab-case. The full <key>-<role key> must fit in 40 characters.
name yes 1–80 characters. The English name.
names no The name in other languages, keyed by BCP 47 tag ({ "de-DE": "Kassierer" }), each 1–80 characters. A reader sees their own language until the owner renames the role; a renamed role is shown as the owner wrote it, in every language. Needs Adminium 0.3.28.
modules no 1–16 ids of the parts of the app the role belongs to. The role is offered while any of them is on. Needs Adminium 0.3.28.
permissions no Grants, in the forms below.
cloneFrom no The key of another of this app’s roles, whose grants this role also gets, and its limits.
screensOnly no true: people with this role open the app’s own screens and never the dashboard.
limits no Per table ref, what the role’s update there may change, what its read there shows, and which rows its read, update and delete reach. See below.

A manifest cannot know the real table names or page ids, so it grants through placeholders:

Grant Actions
table:@<table ref>:<action> read, create, update, delete, export, import, read_pii
page:@<page ref>:<action> view, edit
app:@:staff Open the app’s staff screens.
addOn:<add-on key>:settings Edit that add-on’s non-secret settings (the letterhead of an invoicing add-on). Only an add-on the app requires or suggests in addOns.

read_pii shows the table’s personal columns in clear. Without it they read as null, listed in the row’s _masked. A lookup into another table needs read_pii on that other table. See Personal data.

The plan refuses a system: grant, a wildcard, and a reference to a table or page the manifest does not declare. Grants are given once: an update adds what a new version asks for, and an operator’s narrowing of an app role survives it.

limits narrows the role’s update on a table to some columns and, for some of those, to some values. The names are the ones public access uses:

{
"key": "clinician",
"name": "Clinician",
"permissions": ["table:@appointments:read", "table:@appointments:update", "app:@:staff"],
"limits": {
"appointments": {
"writable": ["status"],
"writableValues": { "status": ["roomed", "with_clinician", "ready"] },
"writableFrom": { "status": ["checked_in", "roomed", "with_clinician"] }
}
}
}
Field Required Rule
writable no The columns the update may change, at least one.
writableValues no For a column in writable, the only values it may set (1–32, each a value of the column). Needs writable.
writableFrom no { "<column>": [1–32 values] }: the update reaches a row only while that column of the row holds one of these values. Any column of the table, in writable or not. Needs writable, and minAdminiumVersion 0.3.19 or later.
readable no The only columns the role’s read of the table shows, 1–200, each once. The key is always read; a link to another row is read only when it is listed, as any column (0.3.35; before, every link was read). Needs the role’s table:@<ref>:read.
creatable no The only columns a new row the role creates may be given; the rest take their defaults or what Adminium decides. Needs the role’s table:@<ref>:create.
creatableValues no For a column in creatable, the only values a new row may be given (1–32). Needs creatable.

A limit names writable, readable, creatable, or any of them.

The table must be one the app declares, and the role must grant table:@<ref>:update itself or through cloneFrom. Someone who also holds a role with an unlimited update on the table is not limited. Creating rows is not limited by writable; creatable limits it the same way, on the create itself, a row added from a parent’s form, and an import (which may not bring in a column outside creatable, nor one whose values are limited). A value left empty, and the state column at its first state, are no choice and always pass. Every install and update writes the manifest’s current limits.

writableValues says what a column may be moved to; writableFrom says what a row may be moved from. It is judged on the row as it is stored, never on what the request says of it, and whatever the update changes: a clinician who may move a visit along from checked_in, roomed or with_clinician cannot change a visit that is already seen at all — not its status, not another column, not a line under it sent through the visit’s form. The refusal is 403 COLUMN_FORBIDDEN with reason: "update-from", the column and the values. It holds on a single change, a bulk change (one row out of reach refuses the whole batch), a row-by-row change (that row only) and a record’s own action. Roles add up here too: a role with an unlimited update lifts it; of two limited roles, a column only one of them judges by is not judged, and where both do, a value either lists is enough. A create is not held by it: a new row under a parent is the child table’s create. See Edits limited to some columns.

readable limits what the role reads the same way: housekeeping reads a stay’s room and dates, and none of its guest or its money.

"limits": { "stays": { "readable": ["room_id", "arrive", "depart", "late_checkout", "status"] } }

Every staff read of the table holds to it: lists and records, links and lookups, search, exports, imports, dashboard cards and their live updates, documents, the audit log, files kept in a hidden column, and the page assistant. Selecting, filtering or sorting by a hidden column is refused 403 COLUMN_FORBIDDEN with details.reason: "read-limit"; a lookup into one reads as masked. A refusal never repeats a hidden value, and the desk’s limit counts and nightly lines are refused when the rule reads a hidden column. A hidden column is not written through the role either, creates included, unless writable names it. Roles add up: two limited roles read the union of their columns, one unlimited read of the table reads it all, and a Super Admin is never limited.

A role that signs in a staff-bound browser key (a check-in tablet; see publicKeys) must be screensOnly, with no cloneFrom and no grant but app:@:staff.

An app’s role may reach tables of an add-on the app names in addOns, with tables. A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.

"tables": [
{ "addOn": "inventory", "table": "transfers", "actions": ["read", "create", "update"],
"limit": { "creatable": ["from_place_id", "to_place_id", "note"], "writable": ["status"] } },
{ "addOn": "inventory", "table": "stock_points", "actions": ["read"],
"limit": { "readable": ["id", "item_id", "place_id", "on_hand"] } }
]

Up to 12 entries, one per table. addOn is the add-on’s key and table its own name for the table. actions are read, create and update, never a delete, an export or an import. limit is the object a role’s limits hold for one table, and narrows only an action the entry gives: readable needs read, writable needs update, creatable needs create. The grant is live while the add-on is connected to the app; the add-on’s table and column names are checked then. An add-on’s own roles grant its tables through permissions, not tables.

The add-on may say how far an app’s role goes on each of its tables (appGrants). An entry is then cut to it: an action or a column past it is left out, and an entry that asks for every column is given the add-on’s columns, never all of them. Nothing past it is ever written. The install check lists each role’s hold with its columns, and says what was asked for and not given. An update whose roles would hold more than the running version’s (a new action, a new column, a column list that becomes every column) is refused with ROLE_TABLES_WIDER until the update is sent with "roleTables": true. The update’s check in Studio shows what is new, and pressing Update sends it.

settings declares values the operator sets for the app, such as a business type or a currency. They appear on the app’s settings page, and the app reads them, with their defaults, from surface-config.json.

{ "key": "business_type", "type": "enum", "enum": ["restaurant", "retail"], "default": "restaurant",
"label": { "key": "mft.pos.setting.businessType", "fallback": "Business type" } }

Every setting has a snake_case key, a type, and optionally required, secret, a label and a help sentence shown under the field (both i18n messages). By type:

type Extra fields
string default (string)
number default, min, max (numbers), unit (up to 20 characters)
boolean default (boolean)
enum enum (at least one value, required), default (string)
file accept (a list of accepted types)
json default (any JSON)

A setting marked secret is never sent to an app’s screens. In this release an app’s settings page does not show or store secret settings; they are used by add-ons.

addOns names the add-ons an app needs, the ones it works better with, and the features that stop without one.

"addOns": {
"requires": [{ "key": "invoices", "range": ">=1.1.0",
"reason": { "en-US": "Invoices, quotes and receipts are made by this add-on." } }],
"suggests": [{ "key": "holiday-calendars", "range": ">=1.1.0", "checked": true,
"reason": { "en-US": "Marks public holidays as days off." } }],
"features": [{ "id": "capacity-holidays", "label": { "en-US": "Holidays in Capacity" },
"requires": ["holiday-calendars"] }]
}
Field Required Rule
requires no 1–8 add-ons the app cannot run without: { "key", "range", "reason" }.
suggests no 1–8 add-ons offered with the app: the same, plus checked, whether the offer starts ticked.
features no 1–16 { "id", "label", "requires" }: a part of the app that works only with some add-ons. id is kebab-case, up to 40 characters; requires lists 1–4 add-on keys, each one the app requires or suggests.

key is an add-on’s key. range is a semver range over its version (>=1.1.0, ^1.1.0), up to 120 characters. reason and label are keyed labels, with en-US among them. An add-on is named once, in requires or in suggests, and never the app itself.

What each list does:

  • Required. The install check shows each required add-on, where its version comes from and its own install plan. Installing the app installs, updates or connects every required add-on first, before the app’s own tables, so a table built on its shape and a foreign key into its tables resolve. One that cannot be had, or is out of range and not ticked to update, refuses the install before anything is written. An add-on an installed app requires cannot be removed or switched off: that is refused 409 ADD_ON_REQUIRED_BY, naming the apps. Nor can it be updated to a version outside the app’s range: that is refused 409 ADD_ON_RANGE, and the app is updated first.
  • Suggested. Offered on the install check, ticked when checked says so. The operator may leave it out.
  • Features. A page that names a feature in feature leaves the sidebar while the feature’s add-ons are not all installed and switched on for the app. A document may name one too, and so may a part of the app.

If a later step of the app’s install fails, the add-ons it installed stay: another app may already rely on them. Uninstalling the app keeps them too; only their link to the app goes, and the uninstall preview lists them. Only a required add-on’s settings may be read by the app’s rules (see Values from elsewhere); a role may be given any named add-on’s settings (see Roles).

documents lists document profiles the app ships for its own tables: which columns make a printed invoice, receipt or statement, drawn by an add-on that renders documents. Up to 16.

"documents": [{
"kind": "receipt", "addOn": "invoices", "table": "sales",
"name": { "en-US": "Till receipt" },
"mapping": {
"number": { "column": "number" },
"customerName": { "via": "customer_id", "column": "name" },
"lines": { "collection": { "table": "sale_lines", "via": "sale_id", "orderBy": "position",
"columns": { "description": "name", "qty": "qty", "amount": "amount" } } }
}
}]
Field Required Rule
kind yes kebab-case: the kind of document (invoice, receipt, statement). One of each kind per table.
addOn yes The add-on that draws it. The app requires or suggests it.
table yes The app’s table a document is drawn for, one per row.
name yes A plain string or a keyed label.
mapping yes From each of the add-on’s slots (letters, digits and _, starting with a letter) to where its value comes from. See below.
statement no Makes the document a statement. See below.
feature no One of the app’s addOns.features.
module no The part of the app the document belongs to: it is not drawn while that part is off. Needs Adminium 0.3.28.
requestValues no 1–8 slots the app’s own screen may fill when it asks for the document (a label sheet’s count). Each must be a slot the entry does not map; when the add-on is attached, its outline must have the slot, with no default, holding a number or a text. See below.
where no { "column", "in": [...] }: only rows whose column holds one of 1–16 values have this document (a receipt for payments.kind taken, not for money given back). Asked for another row, the render answers 409 DOCUMENT_NOT_FOR_ROW; an email that would carry it goes without it.

The slots are the add-on’s words (number, issuedAt, items): a shape’s own profiles, in the add-on’s manifest, show the ones it draws. Each slot reads one of:

Source Reads
{ "column", "form"? } A column of the row.
{ "via", "column", "form"? } A column of the row a foreign key via of this table points at: the client’s name on an invoice.
{ "collection": { "table", "via", "orderBy"?, "columns", "where"?, "unless"? } } A list of child rows, whose foreign key via points at this table, in orderBy order. columns maps the add-on’s names for a line’s values to the child’s columns. where ({ "column", "in": [1–16 values] }) keeps only the rows whose column holds one of the values; a row whose unless column is true or set is left out (a voided line).
{ "collection": { "nightly", "columns" } } The nights a price by the night of the row is made of, one line each, worked out when the document is drawn. nightly names the priced column; columns maps a line’s values to a night’s own date, rate, base, qty and tags, or to <rate via>.<column>, a column of the row the rate comes from (room_type_id.name).
{ "collection": { "addOn", "table", "match": { "table", "row" }, "orderBy"?, "columns", "takenOff"?, "where"?, "unless"? } } Rows an add-on keeps for this row, found by the pair each stores: this table’s stored name in match.table and the row’s key in match.row (what Offers & gift cards applied to an order). addOn is one the manifest names; table and the columns are the add-on’s own names. With the add-on away the list is empty. Rows of what an add-on took off an order print one row for each offer, code or voucher, its amounts added, named in the document’s language. takenOff lists the line values printed as taken off: an amount kept as 4.85 reads −4.85, so a reduction can sit among charges.
{ "collections": [1–6 sources] } One list from several sources, in order, each a table source, a nightly source or an add-on’s rows as above: a folio’s nights, then its extras, its charges, and what was taken off.

A line’s value in a table source may also be a list of names one level below the line, printed one after another (a dish’s options, “Farro · Grilled chicken · Avocado”): { "list": { "table", "via", "column", "orderBy"? } }, where column is a text or enum column of the rows whose via points at the line. Such a list never names a secret, personal, code or withheld column. "form": "grouped" prints a code column in groups of four (K7QX-M2PD), as a person reads it out; only on a code column.

Some slots have a default in the add-on’s outline, which fills the slot when nothing else does: when it is not mapped, and when the column it is mapped to is empty on this row (a draft with no issue date yet).

Default Fills the slot with
now The moment the document is made; a date slot takes that day on the venue’s clock. A document drawn again (after an edit, in another language) is the same document and keeps the day it was first made.
connection The document’s currency: the row’s own currency column when it holds one, else the connection’s.
sequence The number the document prints.
setting Nothing on the server: it is one of the add-on’s own settings, which the add-on reads itself. A required slot with this default still needs a mapping.

A required slot that nothing fills makes the document fail, naming the slot (“unmapped or empty”).

A statement is drawn for one row (a client) over a period. It lists the documents and the payments that point at that row, with a running balance, so it names two sources rather than one list:

"statement": {
"documents": { "table": "invoices", "via": "client_id", "date": "issued_on", "amount": "total",
"number": "number", "where": { "column": "status", "in": ["sent", "void"] } },
"payments": { "table": "payments", "via": "client_id", "date": "paid_on", "amount": "amount",
"unless": "voided" }
}

Each source is { "table", "via", "date", "amount", "number"?, "where"?, "unless"? }: a table whose foreign key via points at the document’s table, its date and amount columns, and optionally its number. where is { "column", "in": [1–16 values] }, only rows whose column holds one of them (sent invoices, never drafts); a row whose unless column is set is left out (a voided payment).

The period is chosen when the statement is drawn, never as dates: all (the default), year (from 1 January) or 12m (the last twelve months). What came before the period is carried in as the opening balance, and rows dated after today are left out. A statement is issued on the day it is drawn, on the venue’s clock: that fills its issue date (issuedAt) unless the profile maps a column to it.

A profile is the app’s. It is made with the real table names when the app is installed, changed in place by an update so documents already issued keep pointing at it, and removed when the app is uninstalled. A profile whose add-on is not installed is skipped, with a reason on the install reply. An operator’s own profile is never changed. A document of a kind the attached add-on does not draw yet waits for the add-on’s update; see below.

When a table is built on an add-on’s shape, the shape’s own profiles are made for it at install. An app entry of the same kind on that table does not make a second profile: its slots are added to the shape’s mapping (the app’s slot wins on the same name, so an invoice can print the client’s address from the app’s own clients table), and its name replaces the shape’s.

The app’s staff screens ask for a document by the app’s own names: POST /api/v1/apps/<key>/documents/render with { "kind", "ref", "pk", "period"?, "locale"?, "values"? }, where ref is the table’s short ref and pk the row’s key. values fills the slots the entry lists in requestValues, by slot id, typed by the add-on’s outline ({ "count": 12 }); a value for any other slot, for one a value typed into the profile fills, or one its type cannot hold is 400 DOCUMENT_VALUE_REFUSED, naming the slot, and nothing is drawn. A document drawn with values lists the slots they filled under requestValues in its subject, is never emailed on its own (pending-review, as a stranger’s is), and a later draw of the same row takes nothing from it. The document is drawn now, or the one already drawn is handed back while the row is unchanged, with contentUrl for its bytes and printUrl for a copy to print. The caller must be signed in and able to read every table the document reads, a statement’s sources included. An app, kind, row or table they cannot reach is the one 404; an add-on that is detached, or a feature switched off, is 409 FEATURE_OFF; a document that cannot be drawn (a required slot left empty) is 422 DOCUMENT_NOT_DRAWN.

An add-on’s outline may have a picture slot (Adminium 0.3.35): the document then shows a picture the row keeps, such as a customer’s drawn signature on a signed copy. Map it to a column of the document’s own row that holds one file, a text column with "rules": { "file" } and no maxCount, whose accept lists png or jpeg:

"mapping": { "signer": { "column": "signed_by" }, "signature": { "column": "signature" } }

The add-on never reads the file store: Adminium reads the file each time the document is drawn and hands the add-on the picture, or nothing. It hands it only when the file is the row’s own (attached to that very record, alive, not a file of another row written into the column by hand), a PNG or a JPEG by its own bytes, no larger than the column’s maxBytes and 5 MiB, and at most 8192 pixels a side; what a camera writes beside a picture (the place, the device) is taken out. A file that is none of that leaves the slot empty, and a required picture slot is then a slot left empty (“unmapped or empty”). The picture is read as the column is: a staff member whose role does not read the column, or the row, is drawn nothing at all, as for any column a document prints; a column withheld from a guest is empty for them, and so is the picture. A secret column gives no picture. A value typed into the profile, or sent by a request, never fills a picture slot.

A picture slot mapped through a link (via), to a column that holds no file, a list of files, or a file column that takes no PNG or JPEG is refused when the app is installed, naming the slot. A picture is never a column of a collection.

The add-on receives the slot’s value in subject.fields as { "mediaType", "dataUrl", "width", "height" }: mediaType is image/png or image/jpeg, dataUrl is data:<mediaType>;base64,<bytes> (an HTML page puts it in an <img>; a PDF writer decodes the base64 after the comma and embeds the PNG or JPEG as it is), and width and height are the picture’s pixels. @adminium/add-on-contracts exports the slot type (OUTLINE_SLOT_TYPES), the value’s type and schema (DocumentPictureValue, documentPictureValueSchema) and, for an add-on’s own tests, SAMPLE_DOCUMENT_PICTURE. The documents register keeps what was printed without the bytes: a picture is stored as { "mediaType", "width", "height", "sha256" }, and the document’s own file holds it as drawn.

An app may name a document kind that the add-on attached to it does not draw yet: the app was released for the add-on’s next version, and the installed one is older. The install and the update go through all the same (Adminium 0.3.35; an older release refused them). That document is left off, and so is the attachment a message makes of it: a template whose attach is optional goes without it, and one whose attach is not fails as for any document that cannot be drawn. The reply of POST /api/v1/apps/install and the app of POST /api/v1/apps/<key>/update say which documents wait, in documents.waiting ([{ "kind", "table", "addOn", "addOnName", "name", "reason" }]), and the console shows it on the install’s last screen and under the update’s notice. When the add-on is updated to a version that draws the kind, the document is made then, with nothing to do in the app. A profile already made for it is kept meanwhile.

A collection may list rows of an add-on’s table that belong to the document’s row. The add-on’s table has no foreign key into the app, so the rows are found by the pair the add-on keeps: the stored name of a table and a row’s key. A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.

"applied": { "collection": { "addOn": "offers", "table": "applications",
"match": { "table": "source_table", "row": "source_row" },
"orderBy": "id", "columns": { "label": "label", "amount": "amount" } } }

addOn is an add-on the manifest names in addOns. table, match, orderBy, where, unless and the columns are the add-on’s own short names; match.table is a column that carries "rules": { "tableRef": true } and match.row a text column. Only plain columns are listed, none read through a link. While the add-on is absent, detached or switched off the list is empty and the document is drawn without it. The rows are the row’s own, so whoever may draw the document needs no grant on the add-on’s table. Keep a money code out of a table listed this way.

A money code is a code worth something to whoever holds it, a gift card’s: a code column of which another column of the same table keeps the last four. A document that prints one, from its own row, a row it links to or the rows listed under it, is drawn when it is asked for and kept nowhere: no row in the documents register, no stored file, and it cannot be drawn again from the register. Whoever made the code prints it from the page that made it, once, with the ticket the create answered (see the once block in the REST API).

A signed-in person reaches the documents of their own rows through a public entry’s documents.

An app declares the emails it sends with two blocks: outbox, which names one of its tables as the outbox and says what queues rows in it, and emailTemplates, the templates those rows are sent with. For a walk-through, see Emails.

The outbox table is the log. Every email is a row in it, queued by a producer below, by the app’s own screens or by the operator. Adminium sends queued rows and records the outcome on each: sent once the message is handed to the mail queue, skipped when there is nothing to send to (“No email on file”, or a reserved example address), and failed with a reason. A row already there for the same kind and source is what stops a second send.

"outbox": {
"table": "messages",
"columns": { "kind": "kind", "status": "status", "to": "to_address", "language": "language",
"due": "due_at", "sentAt": "sent_at", "error": "error" },
"links": { "appointment": "appointment_id", "patient": "patient_id" },
"recipient": { "via": "patient_id", "table": "patients", "email": "email", "name": "name",
"language": "language", "optIn": "reminders",
"fallback": { "via": "appointment_id", "email": "new_email", "name": "new_name" } },
"settings": { "table": "settings", "enabled": "emails_on", "name": "practice_name", "phone": "phone" },
"pages": { "manage": "/my-visits", "booking": "/" },
"kinds": { "confirmation": "clinic-confirmation", "reminder": "clinic-reminder" },
"producers": [
{ "kind": "confirmation", "link": "appointment_id", "gate": "enabled",
"onCreate": { "table": "appointments" } },
{ "kind": "reminder", "link": "appointment_id", "gate": "enabled", "optIn": true,
"before": { "table": "appointments", "at": "starts_at",
"lead": { "via": "patient_id", "table": "patients", "column": "reminder_hours",
"fallback": { "table": "settings", "column": "reminder_hours" }, "max": 72 },
"where": { "column": "status", "eq": "booked" } } }
]
}
Field Required Rule
table yes One of the app’s tables: the outbox.
columns yes The outbox’s columns. kind is an enum of the kinds. status is an enum holding at least queued, sent, failed and skipped, and held when a producer holds. to is text, the address. Optional: language (text), due (timestamptz, required by a before producer and by one with hold or due), sentAt (timestamptz) and error (text), and the columns of held messages: skipReason (text or enum), subjectOverride, bodyOverride, approvedBy and effectError (text), and effectAt (timestamptz). repeatKey (text of at least 43 characters) keeps which value a repeatBy producer sent for; was (text, unbounded or at least 1000 characters) keeps a was producer’s values from before the change.
links no The outbox’s foreign keys, by the name a template reads them under: { "appointment": "appointment_id" } gives a template appointment.*. Names are snake_case. Every foreign key of the outbox table that the outbox names must be nullable: not every email is about one.
recipient yes Who the email goes to. See below.
settings no The app’s one-row settings table: { "table", "enabled"?, "name"?, "phone"? }. Templates read it as practice.*. enabled is a bool that pauses producers with gate: "enabled". name is a text column the app’s emails are signed with, the emailed code included; without it, the workspace’s name is used. phone is a text column: when it holds a number, the notice sent to a person’s old address after a change of email tells them to ring it; without one, the notice says to contact you. replyTo is a text column: when it holds one plain address, every message the app sends carries it as its Reply-To, so a guest’s reply reaches the house rather than the no-reply sender; empty, or anything but one address, adds none.
pages no { "manage"?, "booking"? }: paths on the app’s customer side (/my-visits) that a template’s manage_url and booking_url lead to. Up to 120 characters. Default: the side’s front page.
kinds yes Each value of the kind column, and the key of the template it is sent with. Every value must be one of the enum’s, and every template one of emailTemplates.
producers no Up to 24 rules that queue rows by themselves. See below.

recipient is { "via", "table", "email", "name"?, "language"?, "optIn"?, "fallback"? }. via is the outbox’s foreign key to the person, table the person’s table, and the rest are its columns: email, name and language are text, and optIn is a bool the person sets (false means nothing from a producer that asks optIn). language may instead be { "column" }, a text column of the row the message is about: an order placed in German is written to in German, whatever the person’s own row says. The row every producer’s message is about must have that column. It is used only when it holds one usable language tag that fits the outbox’s language column (which must be named); otherwise the person’s language is. fallback is { "via", "email", "name"?, "language"? }: where the address comes from when via is empty. Its via is another foreign key of the outbox, and its columns belong to the table that key points at; a first visit by someone not yet on file carries their details on the visit itself.

Producers. Each has a kind (a key of kinds) and a link, the outbox’s foreign-key column that points at the row the message is about and must be one of links. Optionally module, the part of the app the email belongs to (nothing is queued while it is off), gate and optIn: true (needs recipient.optIn: a person who opted out gets nothing). gate: "enabled" pauses the producer while the settings row’s settings.enabled bool is false; gate: { "setting": { "table", "column" } } pauses it while that bool of the settings row is false, so each notice can have its own switch; gate: { "feature": "<id>" } sends only while one of the app’s addOns.features is on (a receipt, while Invoices & Receipts is attached); gate: { "feature": "<id>", "setting": { "table", "column" } } queues only while both hold: the feature is on AND that bool of the settings row is true (a receipt, while Invoices & Receipts is attached and the manager’s switch is on). Each half is checked as it is alone. Every gate is judged when the message is queued: a message already waiting (held, or due later) still goes when the gate closes after it was queued, unless its dropWhen drops it. A message of the kind sent from a new link or a “Send it again” is judged by the same gate, and a kind held for approval (hold: true) cannot be sent from a link at all. Then exactly one of:

Producer Shape Queues a row
onCreate { "table", "via"?, "where"? } When a row of the table is created.
onChange { "table", "via"?, "column", "to", "where"? } When the column changes to to, a value or a list of 1–16 values.
onChange { "table", "via"?, "columns", "changed": true, "where"? } When any of 1–8 columns changes, compared with the row as it was stored (a stay’s dates, whatever they became). Numbers compare as numbers and dates by day.
onDelete { "table", "where"? } When a row of the table is removed, by anyone: one message for each removal, written once the row is gone. See A row that is removed. Needs Adminium 0.3.28.
before { "table", "at", "lead", "where"? } A lead time before at, a timestamptz of the row: a reminder. lead.at ("HH:MM") sends it at that wall time on the venue’s day the lead reaches: 24 hours before a 20:00 show, at 09:00, is 09:00 the day before.

With via, the source is a child row and the message is about the row its foreign key via points at: a version posted to a deliverable makes a message linked to the deliverable, and addressed through it. link then points at that parent’s table.

onDelete writes a message when a row of table is removed: from the dashboard, by a bulk removal (one message a row), from an app’s own screen, or through a public entry that may delete. where is judged on the row as it was. The row is gone when the message is written, so:

  • the message’s link (the outbox’s link to that table) is empty, and nothing is read from the row again. What the template says of it comes from was: {{was.starts_at.date}}. The outbox’s other links are filled from the removed row as on any message (the contact and the deal a visit named), so {{contact.name}} and {{deal.name}} read as usual;
  • it goes to the outbox’s recipient, found through those links, or to recipient.column, an address the removed row held. That address is read once, as the row goes, and kept in the message’s own address column and nowhere else; the name beside it is not kept, so {{recipient.first_name}} is empty for such a message;
  • it takes none of hold, due, batchMinutes, holdSeconds, dropWhen, supersede, repeatBy, onSent and replyTo: each reads the row again after the message is written. A reply goes to the outbox’s own settings.replyTo, when it has one;
  • each removal is its own message. A sample row, a row taken back by an undo of its creation, and a child row removed inside its parent’s own save write none.

Naming a table in onDelete also means its rows are never held by their messages. Without it, a row that any message links (sent last year, or still waiting) cannot be removed: the database refuses it (409). With it, as a row of that table goes, every message that named it through the producer’s link loses that link and stays in the log with what it kept, and one that had not gone yet (queued, or held) is skipped with the reason no-longer-needed. A skipReason column that is an enum therefore offers no-longer-needed. This holds for every removal made through Adminium, also while the app is switched off; a row removed by SQL of your own is still refused by the database while a message names it. Nothing is altered in the database for it, so an install made before this word gets it with the update that adds the producer.

{ "kind": "visit-cancelled", "link": "visit_id",
"onDelete": { "table": "visits" },
"was": ["starts_at", "place"] }

A producer may also say:

Field Rule
hold true: its rows are written held and sent only once a person approves one. See Held messages.
due { "date", "days", "at"? }: when the message comes due, days after date (a date or timestamptz of the row it is about), at at ("HH:MM", 09:00 by default) on the venue’s clock. Not on a before producer, which is due by its lead.
supersede A group name (kebab-case, up to 40 characters). When a message of the group comes due for a row, the earlier ones not yet sent are skipped as overtaken, so an invoice never has two reminders ready at once. Needs due.
dropWhen 1–4 conditions on the row the message is about: { "column", <one test>, "reason" }, the test being eq, in, isNull, lte or gte. While one holds, its waiting messages are skipped with the reason: paid, void or no-longer-needed. Only a message that waits (hold, or due) can be dropped.
recipient { "setting" }: send to the address a setting holds (a studio’s own reply_to), never to the person the row links. The setting is { "table", "column" } of the settings row, or { "addOn", "setting" } of a required add-on. Or { "column", "name"?, "language"? }: send to the address a text column of the producing row holds (the friend a ticket is offered to), with that row’s name column as the name and its language column as the message’s language.
replyTo { "staff": column }: a reply to the message goes to the person a staff column names, at the address they sign in with. The column is one of the row the message is about (owner_id), or of the row one of its links names (deal_id.owner_id: the owner of a quote’s deal). See A reply to the rep. Needs Adminium 0.3.28.
holdSeconds 1–3600: the message waits this many seconds before it may go, so a move taken back at once (an order marked ready by mistake) drops it by dropWhen before anyone is told. A message dropped so does not stop the next one of its kind. Needs the outbox’s due column; not on a before producer, nor with hold, due or batchMinutes.
repeatBy A column of the row the message is about: one message for each value it holds, not one for the row for ever (a ticket offered again, its link made afresh, is emailed again). A message for an earlier value not yet sent is skipped as overtaken. Needs the outbox’s repeatKey column; not on a before producer, nor with batchMinutes.
repeat true: one message for each change it hears of, not one for the row for ever (“Resend tickets” twice is two messages). Only on an onChange producer, and not with repeatBy or batchMinutes.
was 1–8 columns of the changed row kept as they were before the change (a stay’s old dates, its old total), read by the template as {{was.<column>}} in every form the column has. On an onChange producer, and on an onDelete producer, where they are the removed row’s own values; needs the outbox’s was column. Never a secret, personal, code, share-code, withheld or typed-code column.
batchMinutes 1–240: one message per linked row in each window of this many minutes. The first event opens a message due at the window’s end; every event while it still waits is taken in by it (five versions posted in ten minutes make one email). The window’s end is its due, so it takes no due; it may still be dropped (dropWhen) or overtaken (supersede) while it waits. Not on a before producer.
onSent { "table", "via"?, "set" }: a change made once the message has gone. Without via, table is the row the message is about; with it, via is that row’s foreign key to table. set maps columns to values (null empties a nullable column).

due.days is one of:

days Days after the date
a number, 0–3650 That many.
{ "byColumn", "values" } One number per value of a column of the row (ladder: gentle 7, firm 1). Every value of the column needs its days.
{ "setting", "byColumn"?, "index"? } Read from a setting when the message is made, and again whenever its inputs move: a number, a list (index, 0–9, picks one), or, with byColumn, lists per value of that column ({ "gentle": [7, 21, 45], … }).

A due that cannot be worked out (no date yet, a value with no days, a setting that is not there) is left empty. A held message with no due waits for a person: it is never ready and overtakes nothing. A queued message with no due goes at once, so give a producer that queues for later a date it always has.

where is one condition on the source row: { "column", "eq" }, { "column", "in": [values] } or { "column", "isNull": true|false }, exactly one of the three. before.lead is { "via", "table", "column", "fallback"?, "max" }: the number of hours before, read from column (an int) of the row that via points at, else from the settings fallback, and never more than max hours (1–336). max is also how far ahead Adminium looks. A reminder is queued with its moment in columns.due.

A source row produces each kind once, unless its producer says repeat or repeatBy. A reminder is produced again only when its moment moves; a batched message, once its window has closed. An undo of a change is heard by the producers of "changed": true alone, so the dates it puts back are mailed; a { "column", "to" } producer never fires on an undo. Sample data and imports fire no producer when they are written. A sample row never has a message at all. An imported row is a real one, so the minute’s look-over still makes its before reminder when the moment comes, and a held producer’s messages for it (an imported sent invoice gets its reminders).

A message row an import or an undo brings back never goes by itself: nothing here made it and nobody approved it. One that arrives queued comes in held for a person to approve, or failed with a sentence saying why where the outbox has no held (queue it again to send it); one that says it went, with a sent time and no error, comes in sent.

A message about a quote is sent by the business, and the customer’s reply should reach the person who owns the quote. A producer says so with replyTo:

{ "kind": "quote-sent", "link": "quote_id", "replyTo": { "staff": "deal_id.owner_id" },
"onChange": { "table": "quotes", "column": "status", "to": "sent" } }

staff is a staff column of the row the message is about, the one the producer’s link names: "owner_id". One link further is allowed: "deal_id.owner_id" on a quote is the owner of the quote’s deal. The validator refuses any other column: a text column that holds an address is not one, and there is no way to type an address here.

When the message goes, Adminium reads who the column names and looks up the address that person signs in with. The address is read then, not when the message was queued, and it is kept nowhere in the app’s tables. The message carries it as its Reply-To.

Sometimes there is nobody to reply to: the column or the link is empty, the person has been removed or suspended, has not accepted their invitation yet, or is a sample person. Then the message carries the house’s own address (settings.replyTo) if the outbox has one, and no Reply-To otherwise. It is sent either way: a missing rep never holds a message back.

The customer sees the rep’s sign-in address in the reply field of their mail program. Use replyTo only where that is what you want.

A producer with hold writes its messages held, each with its own due moment, and nothing is sent until a person approves one. An invoice’s three reminders are made when it is sent, each due some days after its due date; “ready” means held and due.

{ "kind": "invoice-rung-1", "link": "invoice_id", "hold": true,
"onChange": { "table": "invoices", "column": "status", "to": "sent" },
"due": { "date": "due_on", "at": "09:00",
"days": { "setting": { "addOn": "invoices", "setting": "ladders" }, "byColumn": "ladder", "index": 0 } },
"supersede": "rungs",
"dropWhen": [{ "column": "balance", "lte": 0, "reason": "paid" },
{ "column": "status", "eq": "void", "reason": "void" }],
"onSent": { "table": "projects", "via": "project_id", "set": { "status": "paused" } } }

What a person may do to a message, through any screen or the data API:

  • Approve a held one: it becomes queued. They may rewrite it first: bodyOverride is sent in place of the template’s blocks, as plain paragraphs, and subjectOverride in place of its subject. approvedBy records who approved it. One approved before its day, or with no day worked out, goes at once.
  • Skip a held or queued one: it becomes skipped, with the reason by-hand.
  • Queue again a failed one.

Only Adminium marks a message sent or failed, and a sent message stays as it was sent. A new row a person makes starts queued or held.

Once a minute Adminium looks over waiting messages: it re-dates any whose date inputs moved (a new due date, a changed ladder), skips those dropWhen no longer needs, and skips as overtaken the earlier messages of a supersede group once a later one has come due. It also makes a held producer’s message for a watched row in the state that has none of that kind yet (after a crash between a write and its producer, or for an imported sent invoice). It judges all of this again just before it sends. skipReason records why a message was skipped: overtaken, paid, void, no-longer-needed or by-hand.

A held message is made even with no address on file (never for a person who opted out of a producer that asks optIn): the address is looked up again when it is approved and when it is sent.

An onSent change is made after the message’s status is saved, as an ordinary write of that row, and is never a reason to send the message again. It is held to the row’s rules and states like any write, made by Adminium itself, which holds no role: a move kept for some roles is refused to it. effectAt records when it was made, or effectError why it was refused.

Up to 32 templates. Each is stored as the app’s: an operator can edit it, and an edited template is kept as they left it across updates and uninstalls. A template a new version no longer ships is removed if nobody edited it.

{
"key": "clinic-reminder",
"name": { "en-US": "Visit reminder", "de-DE": "Terminerinnerung" },
"vars": ["recipient.first_name", "appointment.starts_at.relative_day", "manage_url"],
"locales": {
"en-US": {
"subject": "Your visit {{appointment.starts_at.relative_day}}",
"blocks": [
{ "block": "email.text", "data": { "text": "Hello {{recipient.first_name}}, see you {{appointment.starts_at.relative_day}} at {{appointment.starts_at.time}}." } },
{ "block": "email.button", "data": { "label": "Manage your visit", "url": "{{manage_url}}" } }
]
}
}
}
Field Required Rule
key yes kebab-case, 2–80 characters, starting with the app’s key and - (clinic-reminder). Unique in the manifest.
name yes A plain string or a keyed label.
vars no Up to 60 variable names the template reads, for the editor’s list.
locales yes The template in each language it ships, keyed by BCP 47 tag. en-US is required.
attach no { "kind", "link", "optional"? }: a document the email carries, drawn by an add-on for the row the outbox link link names (a receipt for a sale). kind is a document kind (see Documents); link is one of the outbox’s links. A message whose document cannot be drawn fails rather than going without it. With "optional": true, a message goes without the document while the add-on that draws its kind is not attached and switched on for the app, and every block marked "data": { "withAttachment": true } is left out with it (“Your receipt is attached”). Any other failure to draw it still fails the message.

Each language is { "subject", "preheader"?, "blocks", "footer"? }: a subject and a preheader of up to 200 characters, 1–40 blocks, and a footer of up to 1000. A block is { "block", "id"?, "label"?, "data"? }, where block is an email block kind such as email.text, email.heading or email.button. email.html is refused: its variables are not escaped, and an app’s emails show values a stranger typed. The install’s check step refuses a block kind the renderer does not know, or data of the wrong shape.

A template reads variables as {{name}}: each link by its name (appointment.*, and one foreign key further, such as appointment.clinician.*), recipient.name and recipient.first_name, practice.*, appName, manage_url and booking_url. A time has the forms .date, .time, .day_month and .relative_day (“tomorrow”), in the recipient’s language and the venue’s zone. A number has .number, .percent and .money, written exactly as stored in the message’s language ({{order.tax_rate.percent}} is “8.25%” on every engine, never “8.250”); a choice has .label, its label in the message’s language ({{order.paid_method.label}} is “Card”, not “card”); a text column of up to 8 characters holding a time of day has .time, in the reader’s clock (“3:00 PM” in the US, “15:00” in Britain); a yes/no (bool) column reads “Yes” or “No” in the message’s language (“Ja”, “Oui”, “Ano”, “نعم”…), in its text and in a list’s rows, never true or 1 (Adminium 0.3.35; a rule’s e-mail reads it the same way); a code column has .grouped (K7QX-M2PD) and .qr (see Emails that list rows). A {{was.<column>}} reads a was column as it was before a change, or as a removed row held it, in the same forms. A staff column (rules.staff) has .name: the person it names, by the name the people list gives them ({{visit.rep_id.name}}, {{was.rep_id.name}}, {{row.rep_id.name}} in a list), and nothing else of them, never their address. Somebody suspended, only invited or removed, and an id that is nobody’s, read empty, so a block sent onlyWith the name is left out; such a template needs Adminium 0.3.28 (an older release fails the message). A date has .day_month and .days_since only: a template asking a column for a form its type does not have ({{invoice.due_on.date}} on a date column) is refused at install and on update with EMAIL_TEMPLATE_INVALID, naming the template and the variable. The guide lists every variable. A message whose email names a variable nothing fills (a secret column, a personal column of a linked row, a link the row does not have, a misspelt name) is not sent: it is failed, and its error names the variable. recipient.name and recipient.first_name are always filled, empty when the recipient has no name on file.

The code a shared link opens a row with (a claim: { "by": "token" } column, a project’s share_token) opens a page to whoever holds it, so an email carries it only to the person it belongs to: the message goes to the address the recipient row keeps, and the row with the code is that person’s row or links to it by the recipient’s via column where it has one (project.client_id), else by every link it has to the recipient’s table, all naming them. Any other code column (a booking’s reference) is printed like any other value. A message addressed by hand to another address, or one linking one client and another client’s project, is failed with a sentence naming the code. A person or an API key making a message (by hand, or by an import they started), or queueing a held or failed one to go, may link it only to rows they can read.

Templates are sent through an outbox or by a rule’s own email or digest step, so a manifest with no outbox is refused when it has a template no rule sends. The install’s check step warns when the server cannot send email.

An email.rows block lists the rows of a child table that link to the row an outbox link names, one line each: an order’s dishes, an order’s tickets with a QR code each.

{ "block": "email.rows",
"data": {
"from": { "link": "order", "table": "order_items", "via": "order_id", "orderBy": "position",
"unless": "voided", "limit": 50 },
"joins": { "options": { "table": "order_item_options", "via": "order_item_id", "column": "name",
"orderBy": "position", "separator": " · " } },
"row": { "title": "{{row.qty}} × {{row.name}}", "meta": "{{row.options}}",
"amount": "{{row.line_total.money}}" },
"empty": "Nothing on this order." } }
Field Rule
from link, one of the outbox’s links; table, the child table, and via, its foreign key back to the linked row; then orderBy, where ({ "column", "in" }), unless (a bool column that leaves a row out) and limit, at most 50 rows.
joins 1–2 lists one level further down, gathered into one text per row under their name (a dish’s options): { "table", "via", "column", "orderBy"?, "separator"? }. column is a text column, never a secret, personal, code, share-code or withheld one.
row The text of each line: title, meta, amount, note, reading {{row.<column>}} in every form the column has, {{row.<link>.<column>}} through one of the row’s own links, and a join by its name. image is a QR code of a code column, {{row.<column>.qr}}, and nothing else; without it the line has no image cell.
empty Said when there are no rows. Without it, the block is left out.

{{row.…}} is read only inside an email.rows block, and every language of a template lists the same rows (from and joins are the same in each). A message that lists rows is never sent without its list: when its table or link can no longer be read (after a rename), the message is failed (“Not sent: the email lists rows from a table or link that is not there”). An empty list still sends.

A QR code is the whole value of an image: an email.image block’s qr, written {{<link>.<column>.qr}} (80–200 pixels across, size), or a row’s image. It is drawn when the message is delivered, only of a code column, and only for a code of at most 64 bytes. A code the withhold or share-code rules keep from the message’s recipient is printed empty, in every form, its QR code included.

A block’s data may carry onlyWith or onlyWithout, the name of one variable the template reads ("onlyWith": "card.balance"). With onlyWith the block is sent when the variable holds something; with onlyWithout, when it does not. Empty means missing, blank, or held back from this reader. The block is dropped before the message is checked, so a variable read only inside a dropped block may stay empty, and the HTML and the plain text drop the same blocks. A manifest that uses either sets compatibility.minAdminiumVersion to 0.3.18 or later. A template with such a block is sent by the outbox only, never by a rule the manifest ships.

A variable may carry a backup after a bar: {{recipient.first_name|guest}} writes “guest” when the value is missing or blank, and {{recipient.first_name|}} writes nothing. The backup is plain text with no braces. A name written with a backup everywhere need not be filled.

A block may be tied to a value beside its block and data: "showWhen": { "var": "stay.note" } sends it only when that variable holds something, and on an email.text or email.heading block "otherwise" is the text sent in its place when it does not. Unlike onlyWith, both are read by the renderer itself, so a template that uses them may be sent by a rule too. A manifest that uses either sets compatibility.minAdminiumVersion to 0.3.22 or later.

An add-on has no customer side of its own. Its outbox may name routes of whichever app it serves:

"pages": { "app": { "balance": "giftCard" } }

app maps a kebab-case name of the add-on’s own to the key of a route an app declares on its customer side. A template reads it as {{app_url.balance}}: the address of that route in the first app, by key, that names the add-on, has it connected and switched on, declares the route and has a customer address. With no such app the variable is empty, so put the button that uses it in a block marked onlyWith.

An email.rows block may list an add-on’s rows for the row a link names, found by the same pair a document uses:

"from": { "link": "sale", "addOn": "offers", "table": "applications",
"match": { "table": "source_table", "row": "source_row" }, "orderBy": "id", "limit": 20 }

While the add-on is absent, detached or switched off the list is empty and the message still goes (the block’s empty text is said, or the block is left out). A match column the add-on’s table no longer has fails the message with a reason.

publicAccess says what the app’s public screens may do. Each entry becomes an endpoint of the public API on the real table, served through one of the app’s browser keys and marked as the app’s: switching the app off stops it and uninstalling removes it. Up to 64 entries.

An entry is served through the app’s customer key unless it names another in key. The install creates one key for customer and one for each name in publicKeys. A key the operator revoked is not made again by an update.

Every update takes back what its version no longer declares: each of the app’s keys loses the entries dropped (the customer key is kept, holding nothing if nothing is left), and a key whose name the version no longer lists in publicKeys is revoked. What a version adds — an entry, a key, or a staff screen’s key turned into a shared link’s — is given only when the operator allows it on the update’s check, which sends "publicAccess": true to POST /api/v1/apps/{key}/update.

"publicAccess": [
{ "table": "booking_rules", "methods": ["GET"], "select": ["opens", "closes", "slot_minutes"] },
{ "table": "reservations", "kind": "availability", "methods": ["GET"] },
{ "table": "reservations", "methods": ["POST"],
"select": ["id", "code", "starts_at", "status"],
"writable": ["party_size", "starts_at", "name", "mobile", "email"],
"defaults": { "status": "confirmed", "channel": "online" },
"confirm": { "template": "booking-confirmation", "to": "email", "code": "code",
"when": "starts_at", "link": "manage?code={code}" } },
{ "table": "reservations", "methods": ["GET", "PATCH"],
"claim": { "match": ["code", "mobile"] },
"writable": ["starts_at", "party_size", "status"] }
]
Field Required Rule
table yes One of the app’s table refs.
methods yes At least one of GET, POST, PATCH. PATCH needs a claim, a claimedBy that is not optional, or a visibleWith.
kind no records (the default) or availability.
key no The browser key that serves the entry: customer (the default) or a name in publicKeys.
module no The part of the app the entry belongs to: while that part is off the entry answers as one that is not there. Needs Adminium 0.3.28.
select no The columns a response carries. Default: every column the app declares for the table. An entry with claimedBy must list them.
writable no The columns a create or change may set. Never a column whose value Adminium decides.
writableValues no { "<column>": [1–32 values] }: the only values a browser may write into a writable column, on a create or a change. Each value must fit the column.
writableWhen no The state a row must be in to be changed, per column: [1–32 values], where null stands for “still empty” (on a nullable column); "from-now", a timestamptz still ahead; { "within": <minutes> }, a timestamptz no more than that many minutes ahead (1–1440; a past time always passes; at most one per entry); "from-today", a date of today or later on the venue’s calendar; "before-today", a date already past; or a window on a moment, { "after"?, "before"?, "where"? }, see Windows on a moment. Needs PATCH, except a window keyed by a visibleWith link on a create.
requires no 1–8 writable columns every write through the entry must fill: accepting a proposal carries the name typed as its signature.
requiresWhen no { "<column>": { "<other column>": [values] } }, 1–8 columns: a writable column a write must fill when it sets the other column to one of the values, and may write at no other time. See A value asked for with a choice.
filters no Rows the endpoint can reach at all. See Filters.
defaults no Values the server writes whatever the browser sends.
claim no How a person proves who they are: by a row’s details, by a link emailed to them, or by a token. Makes the entry its key’s identity. See A person’s own rows.
claimedBy no { "table", "column", "optional"? }. The entry reaches only the rows of the person its key’s identity claimed.
visibleWith no { "table", "via" }. The entry reaches a row only where the entry on table (on the same key) reaches the row it belongs to: an invoice’s lines are exactly as visible as the invoice. See Rows visible with their parent.
level no lookup (the default) or verified: the session the entry needs. Only with claimedBy or visibleWith.
files no 1–8 text columns holding a file, which a signed-in person may download: the file the row names, never one by its id. Each is in select. Needs a claim, claimedBy or visibleWith.
documents no 1–8 document kinds (see Documents) a signed-in person may list and open for the rows the entry reaches. Needs a claim, claimedBy or visibleWith.
sensitive no Whether the rows need a verified session to see. See A person’s own rows.
reason with sensitive: false Why the entry is not sensitive, 1–200 characters. Only with sensitive: false.
onClaim no { "clear": [1–12 columns] }: on an entry whose claimedBy is optional, the nullable columns a signed-in create empties (the name and number a first visit would type).
maxOpen no { "column", "values", "n", "upcoming"? }: a claimed person may hold at most n (1–50) rows whose column holds one of values. With upcoming, a timestamptz, only rows still ahead count. Needs claimedBy and POST.
rank no { "orderBy", "where"? }: a create also answers where the new row stands, as the number of rows ordered by orderBy at or before it. where is { "column", "eq" }. Needs POST.
humanCheck no true: the browser solves a small proof of work before a create or a claim is taken. Only on an entry that creates or claims.
anonymous no Limits on a create nobody signed in for. See Limits on a stranger’s create.
requireSetting no Up to 4 { "table", "column", "when"? }, each a bool of the settings table. While one is false, every write through the entry is refused.
confirm no An email Adminium sends when a guest creates a row; needs POST. See below.
children no The rows a create carries with it, by child table, two levels at most. See A create with its child rows.
agrees no 1–8 checks the created row’s own values must pass (guests no more than a room sleeps). See A create with its child rows.
dryRun no true: the create or change may be tried without writing, to see every figure Adminium would work out. See Dry runs, price checks and retries.
expect no A money column Adminium works out, which the write may send its expected value for; a different figure writes nothing.
clientKey no A column holding a key the browser mints, so a retried create lands on the same row.
identity no On a create, the person it is made for, found or made by the address typed. See A person found by address.
shareLink no A share-code column: a create answers, once, the new row’s own link. See A row’s own link.
newLink no { "column", "kind", "when"? }: “Make a new link” for a signed-in person’s row, or “Send it again” through a row’s own link. See A row’s own link.
forget no On an identity entry: what “delete my details” empties. See Delete my details.
withhold no Columns left out of a row for some readers. See Withheld columns.
limits no Limits on a change a guest makes. See Limits on a guest’s change.
unlockBy no Rows readable only with a code that unlocks them. See Codes that unlock rows.
pictures no 1–4 image columns any visitor may see. See Pictures.
rule, showLeft, under no On an availability entry: which limit it answers, whether it says what is left, and the column a page asks by. See Availability.

writableValues and writableWhen pin both ends of a change: status may become cancelled, and only while it is booked. writableWhen is part of the change itself, never of a read, so a finished visit still lists but cannot be moved; a change to a row in any other state answers as if the row were not there. An entry with claimedBy that changes an enum column must list its writableValues: permission to cancel is not permission to mark a visit seen.

One entry often carries two answers: a customer accepts a quote, typing a name, or declines it, typing none. requires would ask for the name on both. requiresWhen asks for it only with the choice it belongs to:

{ "table": "quotes", "key": "quote", "methods": ["GET", "PATCH"],
"claim": { "by": "token", "column": "share_code", "own": true, "address": "sent_to_email" },
"select": ["number", "status", "valid_until", "total"],
"writable": ["status", "accepted_name"],
"writableValues": { "status": ["accepted", "declined"] },
"writableWhen": { "status": ["sent"] },
"requiresWhen": { "accepted_name": { "status": ["accepted"] } } }
  • A write that sets status to accepted must fill accepted_name; without it the write is refused 400 PUBLIC_WRITE_REFUSED with { column }, as for requires.
  • A write that does not set status to accepted may not write accepted_name at all: a name sent with a decline, or on its own, refuses the whole write. A declined quote never holds a name a stranger typed. An empty value sent along is left out, and empties nothing.
  • Both columns are writable, the other column lists its values in writableValues, and each value named is one of them. A column is in requires or in requiresWhen, not both.

Hold the typed column to plain text as well: a column’s own plainText rule, or the entry’s limits.plainText, keeps a name to a name (no link, no markup, no digits) and to its length. Needs Adminium 0.3.28.

A null in writableWhen pins a value that may be written once: "signed_name": [null] takes a signature while the column is still empty, and never changes it after. The calendar words pin a date: a proposal still in date may be accepted ("valid_until": "from-today"), and one past it may ask for a new price ("before-today"). "before-today" takes a date column only, never a time, and the same entry may not write that date: a caller who could move it forward would put the row back in date on its old terms.

A window lets a change wait for its time: "starts_at": { "within": 60 } takes a check-in up to an hour before the visit, or any time after it. A change asked for earlier is refused 409 PUBLIC_TOO_EARLY, and the reply’s params carry at, the row’s time, and from, when the window opens, both as instants — even when select leaves the column out; naming the window agrees to that. The refusal is said only for a row the caller’s own read reaches, with every other state in writableWhen met; any other miss answers as if the row were not there.

confirm takes template (only booking-confirmation today), to (the column holding the guest’s address) and optionally code, when, party and name (columns the email shows), venue ({ "table", "name"?, "address"?, "phone"? }, the app’s one-row venue table) and link (a path under the customer side, up to 200 characters).

A file in files stays private: a browser downloads it only at GET /api/v1/public/files/<ref>/<row>/<column> (<ref> is the entry’s endpoint), which reads the row exactly as the entry’s list would (its filters, its claim, its parent), then serves the file that column names, from the key’s own database. A row the session cannot reach, an empty column or a link to somewhere else serves nothing (404), and no other public route serves a file.

An availability entry answers free or full and never returns a row. It is GET only, and the table must declare a capacity or a booking. On a capacity table it answers by the limit’s kind; see Availability. On a booking table it answers the times of a day, or a strip of up to 31 days, for a kind and optionally a person; a guest is offered only people bookable online, and is never told who. An availability entry reads no rows, so it is never the parent another entry is visible with.

The install’s check step lists every endpoint it will create, and warns about anything that would stop the key working, such as the public API being off or no time zone set on the database.

A filter limits every read and every write of the entry.

Filter Rows it keeps
{ "column", "op", "value" } op is eq, neq, in, gte or lte.
{ "column", "op": "today" } Rows whose date or time falls on today.
{ "column", "op": "from-today", "days"? } Rows from today onwards; days (1–366) limits how far, today included.

today and from-today need a date or timestamptz column, and are worked out on every request in the venue’s time zone. A filtered column can be writable only when both writableWhen and writableValues pin it; otherwise a write could move the row out of the endpoint half-way.

A writableWhen entry may open a change only inside a window read from moments: a refund until seven days before the show, a guest’s own change of their stay until the cancel-by time.

"writableWhen": {
"status": ["booked"],
"cancel_by": { "before": {} },
"event_id": { "before": { "column": "starts_at", "minus": { "days": 7 } },
"where": [{ "column": "refunds_on", "eq": true }] }
}

The key says what the window is read from:

  • A date or time column of the row (cancel_by): the window’s moments are that column’s, so each end names no other column; it may add a time, a shift and or fallbacks.
  • A foreign key of the row (event_id): each end names a column of the linked row, and where (1–8 conditions) must hold on that row too (refunds switched on for the event).

after and before are the window’s ends, either or both. The window is judged on the row as it is stored before the write, so a change that moves the time cannot reopen its own window, and a guest may never write a column that opens their own window, a time kept on the row included (an arrival_time a moment reads). A change asked for too early is refused 409 PUBLIC_TOO_EARLY with params.at (the row’s time) and from (when the window opens); one too late, 409 PUBLIC_TOO_LATE with at. As with within, the refusal is said only for a row the caller’s own read reaches.

A create entry that is visible with its parent (POST, no PATCH) may carry a window keyed by its visibleWith link, with the parent’s moments at each end: an extra may be added to a stay only until its cancel-by time. It is judged in the create’s transaction, on the parent.

An entry with claim is its key’s identity. A caller proves who they are and gets a session on that row. Each key has at most one identity. A claim takes one of three forms.

By the row’s details: { "match", "verify"?, "email"? }. The caller proves they know a row’s details (a mobile number and a date of birth).

Field Rule
match 1–3 columns the caller must match.
verify "email-code": the session starts at lookup, and a code emailed to the row’s own address raises it to verified.
email With verify only: the text column holding the address.

By a link emailed to the person: { "verify": "email-link", "email" }. The person types only their address, and Adminium emails a one-use link (and a code, for another device). The session opens at verified from the link; a typed address alone opens nothing. email is the text column holding the address. The entry asks the human check ("humanCheck": true), because anyone can type an address, and every entry its sessions read says level: "verified".

The link opens a page that asks the person to continue and greets them by first name, before anything is proved. It shows the first word of one column and nothing else: the name the outbox’s recipient.name declares, when the recipient lives in the same table; with no outbox recipient there, the first text column in the entry’s select that is not email. It never reads a number, a date, the address, a masked or secret column, or a column the entry does not select. A declared name the entry does not select greets nobody by name, so list it in select (a client portal that shows ["id", "company", "contact_name"] greets by contact_name, not by company).

By a token: { "by": "token", "column", "expires"?, "stopped"? }. An unguessable code in a column opens that one row, with no email at all: a handover page shared by link. column is a text column with a code rule of length 16, so Adminium fills it. expires is a date or timestamptz after which the link opens nothing; stopped a bool that switches it off. A token opens its row to whoever holds the link, so it is served on a key of its own that no staff signs in, and every entry on that key only reads (GET) — unless the token is the row’s own link ("own": true, with address), which opens a verified session that may change the row; see A row’s own link.

A session opened by a token is checked against the row on every request: once the row is stopped, past expires, or given a new code, the link and every session it opened reach nothing, at once. Nobody types a code, not staff and not an import. Staff who read the table see the code and can copy the link: the install says the column is no secret, on a table it made (on a table it reuses, it stays whatever it was). No entry of its table shows it, filters or orders by it — the one the link opens or any other, under any key — so a select, a filter or a rank that names the column is refused, and an endpoint or a generated one added later is held to the same. To make a new link, staff with update on the table call POST /api/v1/data/<connection>/<table>/<record>/regenerate-code with { "column": "share_token" }: a fresh code is written, and the old one never opens anything again. The answer carries the new code only to a caller who may also read the table.

An entry with claimedBy reaches only the claimed person’s rows. table is the table its key’s identity claims. column is a foreign key of this entry’s table pointing at it, or that table’s own primary key when the entry is on the identity’s table. The column is filled from the session, so it cannot be writable. With optional: true, a create goes through with no session at all: a first visit by someone not yet on file. Only an entry whose methods is exactly ["POST"] may be optional. An identity takes no claimedBy.

"publicAccess": [
{ "table": "patients", "methods": ["GET"], "select": ["name"],
"claim": { "match": ["mobile", "date_of_birth"], "verify": "email-code", "email": "email" },
"sensitive": true, "humanCheck": true },
{ "table": "appointments", "methods": ["GET", "PATCH"],
"claimedBy": { "table": "patients", "column": "patient_id" }, "level": "verified",
"sensitive": false, "reason": "Times and visit types only, no clinical notes.",
"select": ["id", "starts_at", "visit_type_id", "clinician_id", "status"],
"writable": ["status"], "writableValues": { "status": ["cancelled"] },
"writableWhen": { "status": ["booked"], "starts_at": "from-now" } },
{ "table": "appointments", "methods": ["POST"],
"claimedBy": { "table": "patients", "column": "patient_id", "optional": true },
"sensitive": false, "reason": "A new booking answers with its own time only.",
"select": ["id", "starts_at"],
"writable": ["starts_at", "visit_type_id", "clinician_id", "new_name", "new_mobile", "new_email"],
"defaults": { "status": "booked" },
"humanCheck": true,
"maxOpen": { "column": "status", "values": ["booked"], "n": 2, "upcoming": "starts_at" },
"onClaim": { "clear": ["new_name", "new_mobile", "new_email"] },
"anonymous": { "perValue": { "columns": ["new_mobile", "new_email"], "n": 2 },
"perKeyHour": 30, "plainText": ["new_name"] },
"requireSetting": [{ "table": "settings", "column": "online_booking" },
{ "table": "settings", "column": "new_patients_online", "when": "anonymous" }] }
]

The rules that tie these together:

  • Levels. An entry with level: "verified" refuses a lookup session with PUBLIC_CLAIM_LEVEL, and needs an identity that sends a code or a link. A code is six digits, lasts 10 minutes and allows 5 tries; a verified session lasts 30 minutes. See The emailed code.
  • Sensitive rows. An identity marked sensitive: true shows a lookup session only its own select, and must send a code. Every claimedBy entry of its key must then say sensitive either way: true needs level: "verified", and false needs a reason.
  • The address. Where a claim sends a code, no entry may write the identity’s email or match columns or create rows on its table: a session could otherwise send the next code to itself. The install refuses such an endpoint.
  • The human check. A claimedBy entry with humanCheck needs an identity with humanCheck too, or claiming first would skip the proof. A found person’s own create on an entry with maxOpen asks no proof. See The human check.
  • maxOpen counts only for a signed-in create, and refuses the next one with PUBLIC_LIMIT_REACHED.

For the whole flow, see A person’s own rows.

An invoice’s lines and payments are not the person’s own rows by a column of their own: they are the invoice’s. visibleWith reaches them through the entry that reaches the invoice, so a draft’s lines stay as hidden as the draft.

{ "table": "invoices", "methods": ["GET", "PATCH"], "level": "verified",
"claimedBy": { "table": "clients", "column": "client_id" },
"filters": [{ "column": "status", "op": "in", "value": ["sent", "void"] }],
"select": ["number", "total", "balance", "client_paid_at"],
"writable": ["client_paid_at"], "writableWhen": { "client_paid_at": [null] },
"documents": ["invoice"] },
{ "table": "invoice_lines", "methods": ["GET"], "level": "verified",
"visibleWith": { "table": "invoices", "via": "document_id" },
"select": ["description", "qty", "rate", "amount"] },
{ "table": "payments", "methods": ["GET"], "level": "verified",
"visibleWith": { "table": "invoices", "via": "document_id" },
"select": ["number", "amount", "paid_on"], "documents": ["receipt"] }
Field Rule
table The parent’s table. Exactly one other entry on the same key reads it with GET: that entry is the parent.
via This table’s foreign key to the parent, or the parent’s foreign key to this table.

A row is reached only where the parent entry reaches the row it belongs to, with the parent’s filters and claim. An entry may be at most two steps from the entry its person claims (a project’s versions, through the project), and the chain must lead to a claimed person. An entry with visibleWith takes no claim or claimedBy. It may PATCH the rows it reaches, naming what it may write in writable, but not when a parent on its way up is on its own table: one statement cannot change such a row on every database. On a key whose identity signs in by an emailed link, it says level: "verified" like every other entry.

Where via is the parent’s foreign key (a proposal naming its terms), that column is the link the child’s rows are read by. No entry on the key that creates or changes the parent’s rows may write it, choose its values or fill it by default (writable, writableValues, defaults): a browser that could would re-point its own row at another person’s child and read it. An entry that only reads writes nothing, so it does not count.

A create may carry the rows that belong to it, two levels at most, in one write: an order, its lines, and each line’s options. children maps each child table to what the browser may send for its rows.

{ "table": "orders", "methods": ["POST"],
"select": ["id", "number", "pickup_at", "subtotal", "tax", "total"],
"writable": ["name", "email", "pickup_at", "note", "client_key"],
"requires": ["name", "email"], "humanCheck": true,
"children": {
"order_items": {
"via": "order_id", "writable": ["menu_item_id", "qty", "note"],
"select": ["id", "qty", "unit_price", "line_total"],
"requires": ["menu_item_id"], "position": "position", "min": 1, "max": 40,
"plainText": ["note"],
"sumMax": { "column": "qty", "max": { "table": "settings", "column": "max_items" } },
"children": {
"order_item_modifiers": {
"via": "order_item_id", "writable": ["modifier_id"], "select": ["id", "name", "price"], "max": 20,
"agrees": [{ "column": "modifier_id", "path": ["group_id", "item_id"], "eq": { "parent": "menu_item_id" } }],
"counts": [{ "by": ["modifier_id", "group_id"],
"every": { "column": "item_id", "eq": { "parent": "menu_item_id" } },
"min": "min", "max": "max" }] } } } },
"dryRun": true, "expect": "total", "clientKey": "client_key" }

Each child table, 1–4 at each level, takes:

Field Rule
via The child’s foreign key to the row it belongs to.
writable The columns a child row may set. Never a column Adminium decides, nor a link to the people who sign in (Adminium fills it).
select What the reply shows of each child row; without it, the child’s key only.
defaults, writableValues, requires As on the entry itself, for the child’s rows.
position A whole-number column Adminium numbers 1, 2, 3… in the order the rows were sent.
min, max Rows per parent row: max 1–200, min no more than max.
agrees 1–8 checks that tie a row’s values to its parent or to what it points at (below).
counts 1–2 limits on how many sibling rows fall in one group (below).
plainText 1–8 writable text columns that hold plain text only, as a guest’s change does: no digits, no web address and no @ handle. A column may be { "column": "note", "digits": 4, "max": 140 } instead: up to 4 digits in all, up to 200 characters.
sumMax { "column", "max" }: the most a number column may add up to across the rows of one write (a dozen items to an order), max a number or a whole-number setting.
children One more level, the same shape without children of its own.

An agreement is { "column", "path"?, "when"?, <one of "eq", "lte", "gte">: target }: the row’s column, followed along path (1–3 foreign keys, the last naming the column compared), equals, is at most or is at least the target. The target is { "parent", "path"? }, a column of the row it belongs to; { "via", "column" }, a column of a row this row points at; or { "value" }. when ({ "path"?, "in" }) applies it only to rows whose value is one of in. On the entry itself, agrees checks the created row’s own values (guests no more than a room sleeps), with no parent target. A root agrees is held on staff writes to the table too: creates and changes at the desk, single, bulk or with links. An import is history, and is not held.

A count is { "by", "every"?, "min", "max" }: by follows the row’s foreign keys to its group (an option’s group), whose whole-number min and max columns bound how many sibling rows fall in it. every ({ "column", "eq": { "parent" } }) names the groups judged even when no row falls in them, those whose column equals the parent row’s (a required size left out).

A guest’s number that feeds a price Adminium works out (a line’s qty) declares validation.min (at least 0) and validation.max. A create anyone may make with child rows asks the human check once, for the whole write. Everything is checked by Adminium, never trusted from the browser, and the whole write is kept or nothing is. For the wire, see An order with its lines.

dryRun: true lets a page ask for the same create or change without writing: the reply carries every figure Adminium would work out (the totals, a stay’s nights, the stamps it would decide), and refuses what the save would refuse. A quote takes no locks, keeps nothing and is never charged against a stranger’s limits. A row visible with a parent is created alone, so a quote there belongs to a change.

expect names a money column Adminium works out (a decimal or money column that a rule decides), which the entry shows in select. A write may send the figure the guest was shown ("expect": { "total": "34.10" }); when the save comes to another figure, nothing is written and the answer is 409 PUBLIC_PRICE_CHANGED, with params.total and, on a create, each line’s figures (params.lines).

clientKey names a text column, unique and writable, that holds a key the browser mints for the create and sends as that column’s value: 22–64 letters, digits, - or _. A retry of a create that already landed (a reply lost to a dropped connection) answers the same row, marked replayed, even after the entry’s switch was turned off; a new create with no matching key is refused as before. Adminium keeps only a keyed hash of the key, 43 characters: give the column room for it. No entry shows, filters or orders by a retry-key column. clientKey belongs to a create, and not to a row visible with a parent.

A guest checking out types their address. identity finds the person it belongs to, or makes one, and links the new row to them; the guest is never told which happened.

"identity": { "table": "customers", "email": "email", "link": "customer_id",
"fill": { "name": "name" } }
Field Rule
table The people table: its identity entry signs people in by an emailed link.
email The text column of this entry’s row the address is typed into: writable, in requires, with validation.format: "email". The people table’s address column is unique, nullable (it is emptied when a person is forgotten), kept normalize: "email", and at least as wide.
link This row’s nullable foreign key to the person. Adminium fills it; it is never shown, since it would tell whether the address was on file.
fill Up to 4 { "<people column>": "<this row's column>" }: a new person’s text columns, filled from what the guest typed. Never a column Adminium decides, a secret, or a unique one.
on { "to": "<state>" }, on a change through a row’s own link: find the person only on the save that moves the row to that state (a ticket accepted), not on every save made while it has no person.

A person is found by address whatever its case, and a second person is never made for an address stored in another case. The entry asks the human check and limits creates per address (anonymous.perValue counting email); a signed-in guest’s create links them as it always has (claimedBy with optional: true). The people table may carry no running number, no limit and no message on create: each would tell a stranger’s order apart from its owner’s. Nothing on the row reads through link.

A row’s own link opens that one row to its owner: an order’s confirmation link, a ticket sent to a friend. It is a token claim with "own": true, served on a key of its own:

"publicKeys": { "ticket": {} },
"publicAccess": [
{ "table": "tickets", "key": "ticket", "methods": ["GET", "PATCH"],
"claim": { "by": "token", "column": "link_token", "own": true, "address": ["pending_email", "holder_email"] },
"select": ["id", "status", "code", "pending_name", "offer_until"],
"withhold": { "columns": ["code"], "when": { "where": [{ "column": "holder_customer_id", "isNull": true }] } },
"writable": ["status"], "writableValues": { "status": ["valid"] },
"writableWhen": { "status": ["offered"] },
"identity": { "table": "customers", "email": "pending_email", "link": "holder_customer_id", "on": { "to": "valid" } } }
]
  • Handed once. A create entry’s shareLink names the row’s own-link column: the create answers the new row’s link, once, to whoever made it. Otherwise the link is only ever emailed, to the row’s own address: address names the 1–2 text columns it may go to (the holder’s, and the friend it is offered to).
  • Verified. The link opens a verified session, and the key’s entries may change the row within their writable list; every entry on the key is read at level: "verified". A link that opens nothing (stopped, expired, renewed) answers 410 LINK_EXPIRED.
  • Bound to its address. When the address it was sent to changes, the sessions it opened stop. An entry whose change writes one of the address columns must renew the link’s code on that change: rules.code.renew.on { "column": "<address>", "changed": true }. No browser writes the link’s own column, its expires or its stopped.

newLink: { "column", "kind" } on an entry that reads a signed-in person’s rows (claimedBy) lets the person ask for a fresh link: POST /api/v1/public/records/<ref>/<id>/new-link. The row’s own link gets a new code, which stops every session the old one opened, and the new link is emailed as the app’s outbox message kind, sent once per code. The outbox needs a repeatKey column, a link to the table, and its recipient table must be the claimedBy table. Only a verified sign-in may ask, five times a day per row; a second ask within a minute changes nothing; when the email cannot go, the answer is 503 PUBLIC_CODE_UNAVAILABLE. See Guests, their details and their own links.

On a row’s own-link entry, newLink makes another own link of the row again (“Send it again” for a confirmation email): column is a code another key opens this table by with own: true, never the entry’s own. The email goes to the row’s own address, as the outbox’s other messages about the row, and the asking session stays open. The address is the one the kind’s own producer uses, read from the row, and no entry may let a guest change it. Here when: { "where": [conditions] } is required (it is optional on a signed-in entry): the ask goes only while the row holds every condition, and never while that link is stopped; otherwise it is refused 409 PUBLIC_WRITE_REFUSED, with nothing made, sent or counted. Besides 5 a day per row, it is 5 a day per mailbox over the whole table.

forget on an identity entry that signs people in by email lets a person delete their details (DELETE /api/v1/public/account, with a code confirmed or a link pressed in the last ten minutes, else 403 PUBLIC_CODE_STEP_UP).

"forget": { "columns": ["name", "email", "phone"], "stamp": "forgotten_at", "links": true }

columns lists 1–16 nullable columns of the person’s row that are emptied, the address they sign in with among them; never the key, nor a column Adminium decides. stamp is a nullable timestamptz written with the time they were forgotten. With "links": true, every own link of the rows the person holds is renewed first and the sessions those links opened are ended (a device learns it by the x-adminium-session-ended: forgotten header, once); if that cannot be done, nothing is forgotten. A message about a forgotten person is not sent, even one queued before they asked. Every session the person holds can also be ended without forgetting anything: POST /api/v1/public/session/revoke-all.

An entry with a level and no claim, claimedBy or visibleWith reads its table’s rows (within its filters) for a signed-in session of its key alone, never for a stranger: a hotel’s rate cards shown to a guest who has booked. It only reads, lists what it shows in select, and says the level its key’s sessions are.

withhold leaves some columns out of a row for some readers: a ticket sent on to a friend keeps its code from the buyer who sent it, and tickets of an order not yet paid show no code at all.

"withhold": { "columns": ["code", "holder_email"], "unlessHolder": "holder_customer_id",
"when": { "linked": [{ "via": "order_id",
"where": [{ "column": "status", "in": ["held", "awaiting_transfer"] }] }] } }
Field Rule
columns 1–8 columns of the entry’s select, each once.
unlessHolder On rows read through a parent (visibleWith or claimedBy): a foreign key to the person the key signs in. The columns are left out while it names someone other than the reader.
when { "where"?, "linked"? }: the columns are left out while the row, or a row one of its links points at, meets the conditions.

A withhold names unlessHolder, when, or both; either one holding leaves the columns out. A when holds for readers of the key the declaring entry is served through; mail and documents drawn for a person read as the customer key, and a message to an address column as the row’s own link whose address names that column. A reader of no key at all (a document drawn for nobody) meets every when about the row’s state, and none declared through a row’s own-link key. On a row’s own link, which names nobody, a withhold says when alone. No browser writes a column a withhold’s conditions read.

The columns are left out of every public read of the table, whichever entry declared the rule: lists, one row, a change’s reply, a replayed create, files, emails (values, rows and QR codes) and documents. Filtering, searching or sorting by one is refused 400 PUBLIC_QUERY_REFUSED; a file in one is served to its holder only; an email’s joins and a document’s lists never name one.

limits holds a change a guest makes, signed in or not, on a PATCH entry: a ticket sent on to a friend’s address.

"limits": { "perValue": { "columns": ["pending_email"], "n": 5 }, "plainText": ["pending_name"] }

perValue ({ "columns", "n" }, 1–4 text columns, 1–20) allows each value at most n changes a day that write it: an address a ticket is sent on to. plainText lists 1–8 writable text columns that hold plain text only: letters, spaces and sentence punctuation (Latin, CJK and Arabic), up to 80 characters, no digits, no web address (a known ending such as .com, or a /) and no @ handle; “Mary.Ann”, “J.R.R. Tolkien” and “St. John” pass. A column given as { "column", "digits", "max" } takes up to digits (1–4) digits and max (at most 200) characters. A stranger’s create’s plainText is the same rule. A limit names perValue, plainText, or both. Over a limit is 409 PUBLIC_LIMIT_REACHED; a value that is not plain text is 400 PUBLIC_WRITE_REFUSED.

unlockBy makes an entry’s rows readable only with a code that unlocks them: a presale code that reveals its hidden ticket type.

{ "table": "ticket_types", "methods": ["GET"], "select": ["id", "name", "price"],
"unlockBy": { "table": "presale_codes", "column": "code", "link": "ticket_type_id",
"where": [{ "column": "active", "eq": true }] } }

A row of table whose column holds the typed code, and whose link points at the entry’s row, unlocks it. column is compared as a typed code (normalize: "code", or a code column) and finds one row; where takes the same conditions as a lookup’s. The entry only reads, and is its own entry: no claim, no parent, not an availability entry. The code travels in the x-adminium-code request header, never in the address: ?code= is refused 400 PUBLIC_QUERY_REFUSED.

A row may also open with its own code — a gift card’s balance, read by whoever types the card’s code:

{ "table": "cards", "methods": ["GET"], "select": ["balance"],
"unlockBy": { "header": true, "column": "code", "self": true, "length": 12,
"where": [{ "column": "status", "eq": "active" }] } }

column is a code column of the entry’s own table, and the entry never shows it. length is needed, 10 to 16: the code is all that keeps a stranger from the row, and a shorter one is found by trying. A typed code of any other length — counted without the column’s own prefix — opens nothing and is never looked up. A wrong code here is counted apart from wrong discount codes: five a minute for a visitor, sixty for the key. Needs Adminium 0.3.18.

pictures lists 1–4 image columns any visitor may see through the rows an entry reads: a dish’s photo on the menu, a room type’s picture.

{ "table": "menu_items", "methods": ["GET"], "select": ["id", "name", "price", "photo"], "pictures": ["photo"] }

Each is a text column with semantic: "image" (a picture column’s file rule beside it, or none), in select, never writable, never personal or kept from readers, and not one of the entry’s files. The entry only reads rows, on the app’s customer key. A picture is served at GET /api/v1/public/pictures/… only for the row it is attached to, cleaned once and kept beside its file, and only up to 5 MiB (5242880 bytes). So a column listed here takes no larger file: an upload or a chosen file above 5 MiB is refused, saying customers see the column’s pictures, whatever the column’s own maxBytes says. A file rule with a larger maxBytes is a warning. See Pictures on public pages.

An availability entry on a table with a capacity answers by the limit’s kind:

Kind A page asks The answer
slot A day (or a run of days) and a party Each slot, free or full.
parent The pools under a row (under: a column of the pool’s rows, such as an event’s ticket types) Each pool, on sale or sold out.
night Arrival and departure, and the guests Each pool that has a room on every night.
Field Rule
rule Which of the table’s limits it answers, 0–2 (absent: the first).
showLeft { "below": n } or { "belowShare": 1–100 }: say how many are left, but only when little is (below a number, or a share of the pool). A parent or night limit only.
under A parent limit only: the column of the pool’s rows a page asks by.

A night rule counting one unit per row (size: 1) has no pool to answer. A slot rule whose amount is a column needs that column’s validation.max: a party asked about is capped at it, or a page asking ever larger parties would learn how full each time is. Parent and night answers read the pool’s rows, so the key also needs a plain read entry on the pool’s table. Rows of the session’s own open hold are left out of a count when asked (exclude); ids outside the session are ignored. A code that unlocks a hidden pool travels in the x-adminium-code header. For the query parameters, see the REST API.

An entry may instead be answered by an add-on: "words": "<add-on key>:<words id>" names one of its stock words, and each row asked about is in, low or out. The add-on is one the manifest names.

anonymous limits a create that nobody signed in for: an entry with no claim at all, or an optional claimedBy create made with no session. It needs POST.

Field Rule
perValue { "columns", "n" }: at most n (1–20) creates a day for one phone number or address in any of these text columns (1–4), through any key or page. A phone number counts by its last nine digits, and an address in lower case, so two spellings of one number are one number.
perKeyHour At most this many (1–1000) such creates an hour through the key, from everyone.
perIpHour At most this many (1–60) such creates an hour through this entry from one visitor (an IPv6 subscriber’s whole /64). Every visitor is held to 60 an hour on any key; this only lowers it.
plainText 1–8 text columns that hold plain text only: letters, spaces and sentence punctuation (Latin, CJK such as ,。 and Arabic such as ،), up to 80 characters, with no digits and no web or email address in its common forms (a known ending such as .com); “Mary.Ann”, “J.R.R. Tolkien” and “K.Y.Ng” pass. A column given as { "column": "note", "digits": 4, "max": 140 } takes up to digits (1–4) digits in all and up to max (at most 200) characters (Plain text). The same rule as a guest’s change, held on every create (signed in or not) and on every change that writes the column.

A create over a limit is refused with PUBLIC_LIMIT_REACHED, and one that breaks plainText with PUBLIC_WRITE_REFUSED. A single create refused for another reason (the slot was taken) does not count, except against the visitor’s hour. A create with child rows gives its charge back only when a value the guest typed was refused; any other refusal of the whole write (sold out, busy, a changed price) keeps it. A quote is never charged.

An entry anyone may call (no claim, no parent, or a create a session is optional on) may not select a personal column: one marked personal, or one whose name the install reads as personal data (an address, a phone number, a birth date, a person’s name on a table of people). See Limits on a stranger’s create.

requireSetting switches an entry off from the settings row: while one of its bools is false, writes through the entry are refused with PUBLIC_SWITCHED_OFF. With "when": "anonymous", the switch holds only for a create with no session, so a found patient can still book while new patients are turned away; it is allowed only on an optional claimedBy entry. A switch is read from the settings table and trusted for 15 seconds. No row, no column or a failed read counts as off. See Switches in the settings row.

publicKeys declares browser keys besides customer, keyed by a kebab-case name of up to 32 characters. Such a key is never published on the customer side. The staff side is handed it, in its surface-config.json under publicKeys, only when the person signed in holds the key’s role. Every request on the key must then carry that same staff sign-in, from the same origin, with the CSRF token on a write. A token copied out of the page opens nothing on its own.

"publicKeys": {
"kiosk": { "requiresStaff": { "role": "kiosk" },
"enabledBy": { "table": "settings", "column": "kiosk_on" } }
},
"roles": [
{ "key": "kiosk", "name": "Check-in tablet", "screensOnly": true, "permissions": ["app:@:staff"] }
],
"publicAccess": [
{ "table": "patients", "key": "kiosk", "methods": ["GET"], "select": ["name"],
"claim": { "match": ["surname", "date_of_birth"] } },
{ "table": "appointments", "key": "kiosk", "methods": ["GET", "PATCH"],
"claimedBy": { "table": "patients", "column": "patient_id" },
"filters": [{ "column": "starts_at", "op": "today" }],
"select": ["id", "starts_at", "status"],
"writable": ["status"], "writableValues": { "status": ["checked_in"] },
"writableWhen": { "status": ["booked"], "starts_at": { "within": 60 } } }
]
Field Required Rule
requiresStaff yes, but see below { "role" }: one of the app’s roles. It must be screensOnly, with no cloneFrom and no grant but app:@:staff, because the screen stands where anyone can walk up to it.
enabledBy no { "table", "column" }: a bool of the settings table. While it is false the key answers PUBLIC_KEY_OFF. Read as requireSetting is, and trusted for 15 seconds.
peak no { "reads", "writes" }: the key’s own budget a minute at the app’s peak (a show going on sale), from Adminium’s own 3,000 reads and 300 writes up to five times that. Each visitor still gets a twelfth. Written at install, and changed by an update.

customer is declared here only for its peak: "customer": { "peak": { "reads": 9000, "writes": 600 } }. At least one entry must name every other key. A request without the right staff sign-in is refused with PUBLIC_STAFF_REQUIRED; a super-admin is refused too. Claims through a staff-bound key ask no proof, and their sessions last 3 minutes. See A kiosk.

A key with no requiresStaff is a share key: nobody signs it in, so it opens one row by its token to whoever holds the link, and reads nothing else. Its identity entry claims by: "token", and every entry on it is GET only, reaching the rest through visibleWith:

"publicKeys": { "handover": {} },
"publicAccess": [
{ "table": "projects", "key": "handover", "methods": ["GET"], "select": ["name", "handover_file"],
"claim": { "by": "token", "column": "share_token", "expires": "share_expires_on", "stopped": "share_stopped" },
"files": ["handover_file"] },
{ "table": "deliverable_versions", "key": "handover", "methods": ["GET"], "select": ["v", "file"],
"visibleWith": { "table": "projects", "via": "project_id" }, "files": ["file"] }
]

automations lists up to 24 rules (more than 12 needs minAdminiumVersion 0.3.28) an app or an add-on brings with it: “when a stock point falls to its reorder level, write a request and tell the managers”. A rule is written the way the rules page stores one, a trigger and then a flow of steps. A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.

"automations": [{
"key": "low-stock",
"name": { "en-US": "Tell the managers when stock is low" },
"enabled": true,
"trigger": { "kind": "record", "event": "updated", "table": "stock_points", "changedColumn": "state",
"when": [{ "left": { "field": "state" }, "op": "is", "right": "low" }] },
"graph": { "version": 1, "nodes": [
{ "id": "t", "kind": "trigger", "title": "A stock point turns low" },
{ "id": "n", "kind": "action", "title": "Tell the managers",
"action": { "kind": "notification", "to": { "roles": ["manager"] },
"title": { "en-US": "{{record.name}} is running low" } } }
] }
}]
Field Rule
key kebab-case, up to 80 characters, unique in the manifest. It is how an update finds the rule it shipped before.
name, description One text, or one per language with en-US among them. The install keeps the one the workspace speaks. So for a step’s title and sub. A notice’s title and body keep every language: each person is told in their own language.
enabled Whether the rule is switched on when it is first installed. After that the owner’s switch stands.
trigger { "kind": "record", "event", "table", "changedColumn"?, "when"? } with event one of created, updated, deleted; or { "kind": "schedule", "schedule", "forEach"? }. A schedule is { "kind": "interval", "everyMinutes" } ("5", "10", "15", "30", "60") or daily, weekly (with dayOfWeek, 0 = Sunday) or monthly (with dayOfMonth, 1–28) at a time such as "17:00". forEach is { "table", "where", "once" }: the rows a run visits.
module Optional. The part of the app the rule belongs to: it starts no run while that part is off. Needs Adminium 0.3.28.
graph { "version": 1, "nodes" }: up to 40 steps with unique ids, the trigger first and only once. A step is a trigger, an action, a condition, a wait (up to 30 days), a stop, or a branch with two branches of up to 20 steps.

A step’s action is one of six:

kind Keys What it does
notification to: { "roles" }, to: { "column", "lead"?, "else"? } or to: { "listed" }, title, body?, urgent? Tells everyone who holds one of the manifest’s own roles and can open the record, or the person on the row, or the lead of that person, or the people listed on the row. "urgent": true tells them at once, also in their quiet hours.
email templateKey, to: { "kind": "field", "column" }, to: { "kind": "staff", "column" } or to: { "kind": "listed", "table" }, vars?, replyTo: { "staff": column }? Sends one of the manifest’s own templates to the address a column of the record holds, to the person on the row, or one to each of the people listed on the row.
digest templateKey, to: { "roles" }, sections, sendEmpty? Makes a summary for each person who holds one of the roles: their own rows, or theirs and those of the people they lead, read as they read.
record.create table, values, name? Adds a row of one of the manifest’s tables. With a name, the steps after it read the row it made.
record.update values Writes columns of the record.
add-on.step addOn, step, inputs Runs a step an add-on gives: the add-on’s key, the step’s key, and a text for each input. An add-on’s own step is checked here; another add-on’s is checked where the rule is installed, and fails by name while that add-on is not there. Needs minAdminiumVersion 0.3.22.

A value is a text, which may carry {{record.<column>}}, or { "now": true }. It may also carry the record as it was and the name of a person, or be a value of the settings row, or a day so many days on.

A mail never carries a secret column of its record, whoever it goes to: not to the address a column holds, not to a person, not written as a date or an amount, not as it was. A personal column goes in a mail to the address a column of the record holds (the mail goes to that person), and in a mail to a person only when their role is shown that table’s personal columns.

A notice is about its record, and its title and body carry the record’s values. So a notice to roles, or to people listed by hand in the dashboard, goes only to those of them whose roles open the record: they read its table, and the row is among the rows they reach. A sales manager whose reach is her own team is not told of another team’s quote. Nobody left is a line in the run’s log (notified 0 people), not a failure. A notice about a row that was removed goes only to those whose read of its table is not limited to some rows, since a removed row cannot be read again to ask. A rule that runs for no record (a clock with no rows to visit) tells everybody it names. Each of them is told the record as they read it: a column their role is not shown is empty in their notice ({{record.<column>}}, {{was.<column>}} and the name a staff column names alike), so give such a placeholder a backup, {{record.cost|-}}.

A notice’s title and body may each be given in several languages, en-US among them, as an email template’s locales are. The install keeps them all on the rule, beside the text in the workspace’s language, and each person is told in their own language as the notice is written: their language’s text (de-DE), else another of the same language (de-AT reads de-DE), else the rule’s own text in the workspace’s language. Their bell shows what they were told, and an email copy of the notice says the same. A notice already in the bell keeps the words it was told in when the person later changes language.

{ "kind": "notification", "to": { "roles": ["manager"] },
"title": { "en-US": "{{record.name}} is running low", "de-DE": "{{record.name}} wird knapp" },
"body": { "en-US": "Order more.", "de-DE": "Bitte nachbestellen." } }

A title and a body both given in several languages give the same ones (a reader is never told half a notice in theirs); one text on either side is the same for everybody. Each language’s text is held to the same placeholders. The rules page shows that a step carries other languages; a title or message changed there is one text for everybody from then on. Needs Adminium 0.3.35 to be told per language; an older server tells everybody in the workspace’s language.

{ "kind": "notification", "to": { "column": "asked_by_id", "lead": true, "else": { "roles": ["admin"] } },
"title": { "en-US": "Quote {{record.number}} needs your approval" } }

With "lead": true the notice goes not to the person the staff column names but to whoever leads a team that person is in (Settings → Teams), never to the person themselves, and only to a lead who can open the record. When there is none (she is in no team, she leads her own team and nobody leads her, or her lead’s roles do not reach the record) the holders of the else roles who can open the record are told instead, again never the person. A notice to a lead without else tells nobody then, and the run’s log says so: a second step to the roles tells them every time, so the roles hear once when there is a lead and once when there is none (Adminium 0.3.35, automations.leadAlone; a release before refused it). else also works without lead: a record whose column names nobody then tells those roles instead of nobody. A team she leads herself, alone or with others, leads her not: a co-lead of her own team is not told as her lead. Needs Adminium 0.3.28.

{ "kind": "notification", "to": { "listed": "crew_members" }, "urgent": true,
"title": { "en-US": "Your day sheet changed: {{record.what}} at {{record.start_time}}" } }

listed names a table that lists people against the rule’s record: one of the record’s table’s belongs ways reads through it (a deal’s deal_members, a visit’s crew through crew_members, a deal’s owner’s covers). The step tells the people that listing names for the record when it runs, each once; a dated listing (from, to) counts on the business’s day, so a cover that ended or has not begun names nobody. On an e-mail step, "to": { "kind": "listed", "table": "deal_members" } sends one mail to each of them, in their own language, and may carry {{recordUrl}}.

Being listed is no right to be told. Of the people listed, only those who sign in (not suspended, not only invited, not a sample person) and whose own roles open the record at that moment are told: they read its table, and the row is among the rows they reach. A fitter on a crew whose role reads no visit, a marketer listed on a deal, a rep who has since lost her role, an account manager whose role reads won deals only, listed on an open one: none of them hears. A record that lists nobody, or nobody who may be told, is a line in the run’s log, not a failure. The validator refuses a table the record’s ways do not read through, and a rule that runs for no record. At most 100 people are told through one listing of one record; a listing that names more fails the step by name. Needs Adminium 0.3.28.

A rule run when its record is updated may read the record as it was before the change: {{was.<column>}}, in a notice, in a value a step writes, and in the template its e-mail sends.

"trigger": { "kind": "record", "event": "updated", "table": "deals", "changedColumn": "stage" },
"graph": { "version": 1, "nodes": [
{ "id": "t", "kind": "trigger", "title": "A deal moves" },
{ "id": "k", "kind": "action", "title": "Keep where it came from",
"action": { "kind": "record.update", "values": { "came_from": "{{was.stage}}" } } }
] }

It follows the record’s own rules: a personal column only in an e-mail, a secret never, and a mail writes a date or an amount as it writes the record’s. A rule run when a row is made or removed, or by the clock, has no “as it was”, and the validator refuses {{was.*}} there. Of the row as it was, the run keeps only the columns its steps and its e-mails’ templates read, never a secret one; they are never shown in Workflow Logs, and are dropped when the run ends, or when it cannot go on. Needs Adminium 0.3.28.

A person’s name, and the record’s address

Section titled “A person’s name, and the record’s address”

{{<staff column>.name}} is the name of the person a staff column names, as Adminium keeps it: {{asked_by_id.name}}, {{record.owner_id.name}}, or one link further {{deal_id.owner_id.name}}. A column that names nobody, or somebody since removed, writes nothing, so give it a backup: {{asked_by_id.name|nobody}}. A staff column is never a link to another table, whatever its name: rep_id beside a reps table names a person, not a rep’s row.

An e-mail step sent to a person ("to": { "kind": "staff", … }) may carry the record’s address, {{recordUrl}}: the address a notice’s e-mail button gives, the app’s own screen for the record when the person’s roles open it, on the operator’s host. Somebody who cannot open the record gets no address, so give it a backup or tie its block to it. A step’s own vars never set it. A mail to an address a column holds has no {{recordUrl}}; the validator refuses it there. Both need Adminium 0.3.28.

A notice’s to: { "column": "owner_id" } and an email’s to: { "kind": "staff", "column": "owner_id" } tell the person a staff column of the rule’s record names. One link further is allowed: "deal_id.owner_id" on a follow-up is the owner of the deal the follow-up is on. The column must be a staff column; the validator refuses any other.

A record that names nobody there (a deal with no owner yet, an empty link, a person since removed or suspended) tells nobody, and the run goes on: its log says who was not there. That is not a failure. Nor does it tell somebody who cannot open the record: an approver named on a deal whose roles do not read deals, or reach other people’s only, is neither told nor mailed. A notice then tells its else roles (those of them who can open it); without them, and for an email always, the log says so. An email to a person goes to the address they sign in with, in their language, and waits for the end of their quiet hours like a notice does. It reads the record as they do: a column their role is not shown is empty, and a personal column is there only for somebody shown that table’s personal columns.

An email step may also say where a reply goes: "replyTo": { "staff": "owner_id" }, or one link further, "deal_id.owner_id". The mail then carries the sign-in address of that person as its Reply-To, whoever it is sent to. It follows the rules of A reply to the rep, and a rule has no house address to fall back on: with nobody there, the mail goes without a Reply-To. Needs Adminium 0.3.28.

A relative condition (within_next, within_last, more_than_ago, more_than_ahead) may read its amount from a whole-number column of the app’s settings row (the row of that table with the lowest key):

{ "left": { "field": "last_activity_at" }, "op": "more_than_ago",
"right": { "amount": 7, "unit": "days", "setting": { "table": "settings", "column": "nudge_quiet_days" } } }

“Quiet for more than settings.nudge_quiet_days days”: the number is read at each tick of a rule by the clock (in forEach.where) and as a run reaches a condition step, so a change on the app’s Settings screen counts from the next tick. amount is the number used while the setting holds none from 1 to 3650 (empty, 0, a text). A trigger’s own when, a summary’s sections and a count do not read settings; the validator refuses one there, and a column that is not a whole number. Which rows a rule visits tells what the number is, so it is read like any other table a rule reads: a secret or personal column, and a private table, are refused (as is a private table whose rows a condition counts), and somebody who saves such a rule in the dashboard must read the table the number is kept in. A run reads it as a rule reads the connection: a table made private since holds nothing, and amount is used. Needs Adminium 0.3.28.

A value from the settings row, and a day so many days on

Section titled “A value from the settings row, and a day so many days on”

A value a record.create or record.update step writes may be read from the app’s settings row, or be a day so many days after a date of the record. An aftercare call 28 days after a job is signed off, owned by the person the business names on its Settings screen:

{ "kind": "record.create", "table": "follow_ups", "values": {
"job_id": "{{record.job_id}}",
"owner_id": { "setting": { "table": "settings", "column": "aftercare_owner_id" } },
"due_at": { "daysAfter": { "column": "signed_at", "days": 28,
"setting": { "table": "settings", "column": "aftercare_days" }, "at": "09:00" } },
"kind": "aftercare", "text": "Aftercare call" } }
Value What it writes
{ "setting": { "table", "column" } } What that column of the settings row (the row of that table with the lowest key) holds when the step runs. Into a staff column, from a staff column only, and only somebody who signs in: a setting that names nobody, somebody since removed or suspended, leaves the value empty, and the run’s log says so. Any other column takes it from a column of its own type. "else": { "staff": "<column>" } beside it names, for a staff column, the person to write instead: a staff column of the rule’s record, or one link further (deal_id.owner_id), again only somebody who signs in (the rep who sold the job, when nobody is named for aftercare). Needs Adminium 0.3.29 (automations.settingElse). One exception, for the sample: on a run about a sample row, a sample person may be written too, by the setting or by else (a sample job’s sign-off books its aftercare call for the sample rep who sold it); on a real row a sample person is nobody. Adminium 0.3.35.
{ "daysAfter": { "column" or "now", "days", "setting"?, "at"? } } A day days (0–3650) after a date or timestamptz column of the rule’s record, or after the run ("now": true), counted on the business’s calendar (the connection’s time zone). setting: a whole-number column of the settings row whose number is used while it holds one from 1 to 3650 (else days: an empty setting or a 0 is none). Written into a date column as the day; into a timestamptz column at at ("HH:MM") on the business’s clock, else at the wall time it counts from (midnight for a date), so a call booked at 09:30 is due at 09:30 after the clocks change. A record whose date is empty leaves the value empty, and the log says so.

A value is no way to read a table. The settings are read as a rule reads the connection, when the step runs: a private table, a secret or a personal column are refused by the validator and when a rule is saved, and a rule stored before holds nothing from one; so is a day counted from a secret or personal date of the record, which it would tell. Somebody who saves such a rule in the dashboard must read the table the setting is kept in. A clock with no rows to visit has no record to count from: "now": true only. Both need Adminium 0.3.28 (automations.settingValue, automations.daysAfter).

forEach.once is false (every time the rule runs), true (once ever, for each row) or "per-spell": once for each time the row starts to meet the conditions. A deal that has gone quiet is told to its owner once; when somebody works on it and it goes quiet again a month later, its owner is told again. Adminium keeps, for each rule and row, whether the row met the conditions at the last look. A rule whose forEach is changed ends every spell, so a row that still matches is told once more.

Each person may set, under their own notification settings, quiet hours for each weekday on their own clock, and the day and time their summaries are sent. A notice that arrives in quiet hours is in the app at once and its email waits for the hours to end; so does a rule’s email to a person. "urgent": true on a notice is not held (a change to today’s day sheet), and neither is a kind of notice the person marked “always tell me”. A person who chose no time zone is read on the business’s. Where one window ends as the next begins (Friday evening into a Saturday that is quiet all day), what is held waits for the end of the last of them.

Two switches are kept beside these (quietOn and summariesOn on /api/v1/me/notification-schedule). Quiet hours switched off hold nothing, and keep their hours for when they are switched on again. Summaries switched off are “not me”: no rule’s digest step makes this person one, whichever of its roles they hold, and one already waiting for its hour is not sent; the day and time are kept. A summary the person asks for themselves (POST /api/v1/me/summaries/{ruleId}/send) is still sent. A write that leaves either switch out leaves it as it was.

{ "kind": "digest", "templateKey": "crm-week", "to": { "roles": ["rep", "sales-manager"] },
"sections": [
{ "key": "open", "table": "deals", "owner": "owner_id",
"where": [{ "left": { "field": "state" }, "op": "is", "right": "open" }],
"orderBy": { "column": "expected_close" }, "limit": 20 }
] }

The step runs in a rule by the clock with no forEach. For each person who holds one of the roles it queues one email, sent at the day and time that person chose (at once for a person who chose none). When it is sent, each section is read as that person: the rows of table whose owner staff column names them, that meet where, among the rows their roles reach and with the columns their roles show. A manager who reads every deal still gets a summary of their own; a person who lost the role between the run and the send gets none; a person with nothing in any section gets no email unless "sendEmpty": true. A summary that is waiting for its hour is not sent once its rule is switched off or deleted, or its part is off. {{count.<section>}} is how many rows there are, also when the section lists fewer.

The template draws each section with a rows block that names it, { "block": "email.rows", "data": { "section": "open", "row": { "title": "{{row.name}}" }, "empty": "…" } }, and may read {{name}}, {{appName}}, {{ruleName}}, {{readAt}} and {{count.<section>}}. Up to 6 sections of up to 50 rows. The validator refuses a section whose owner is not a staff column, a section no rows block draws, and a rows block that names no section.

A section of a team, with sums, a line a person and target cover

Section titled “A section of a team, with sums, a line a person and target cover”

Four more words on a section, each read from Adminium 0.3.28 (and within, below, from 0.3.35):

{ "key": "reps", "table": "deals", "owner": "owner_id",
"reach": "team",
"where": [{ "left": { "field": "state" }, "op": "is", "right": "open" }],
"sum": ["amount", "weighted_value"],
"by": "owner",
"target": { "table": "targets", "owner": "rep_id", "column": "amount", "year": "year", "of": "weighted_value" } }
Key What it does
"reach": "team" The rows whose owner is the person or somebody they lead when the summary is sent (the other members of every team they lead). Default "own". There is no "all": somebody whose role reads every row still gets their own, or their team’s. Somebody who leads nobody gets their own.
"sum": [columns] Up to 4 number columns, each added up by the database over every row of the section, not over the rows it lists: {{sum.<section>.<column>}}, written as money where the column is money and as a number otherwise. A section with no row sums to 0.
"by": "owner" The section lists one line a person instead of one a row, by name: {{row.owner}} (their name in Adminium), {{row.count}}, {{row.sum.<column>}}, and with a target, {{row.target}} and {{row.cover}}. Such a section has no orderBy; limit is the most lines.
"target" What the sum of is measured against: column of the rows of table whose owner staff column names the same people. year, a whole-number column, keeps only the rows of the calendar year the business is in. Fills {{target.<section>}} and {{cover.<section>}} (the sum as a share of the target, a whole percentage), and each line’s own.

A section of this year, or of the last days

Section titled “A section of this year, or of the last days”

"within" holds a section to the rows whose date column falls in the current calendar year, or in the last N days, on the business’s calendar (the connection’s time zone), from Adminium 0.3.35:

{ "key": "won", "table": "deals", "owner": "owner_id",
"where": [{ "left": { "field": "state" }, "op": "is", "right": "won" }],
"within": { "column": "closed_on", "this": "year" }, "sum": ["amount"] }
Form The rows it keeps
{ "column": <col>, "this": "year" } From the business’s midnight on 1 January of the year it is in when the rule runs, to the next 1 January. A summary made at 01:00 on 1 January in Auckland counts the new year, whatever the server’s clock says.
{ "column": <col>, "lastDays": N } Today and the N − 1 days before it, each a whole day on the business’s calendar (1 to 366). "lastDays": 7 made on a Monday is that Monday and the six days before it.
"orEmpty": true, beside either The rows whose column is empty count too: { "column": "closed_on", "this": "year", "orEmpty": true } with state not lost is the open deals and the ones won this year (a cover).

The column is a date or a timestamptz of the section’s table; a date is compared with the business’s days, a moment with the instants its midnights begin. Like every condition of a section, it is counted from when the rule ran (a summary held for its reader’s hour counts the same days), and its rows, counts and sums are the reader’s own reach. The validator refuses a column that is not a date, and any other form.

Everything is the reader’s own reach or nothing. Each figure is read through the same row limit and the same columns as a list the person opens: a sum is never of a row they could not open, a line never names somebody whose rows they do not reach, and a target is read through their reach of the targets’ table (their own for a rep, their team’s for a manager whose role reads the team’s). A figure that cannot be answered for a person is left out, never written as zero:

  • a column their role does not show is not added up, and nothing is measured by it;
  • a section that chooses or sorts rows by a column their role does not show lists nothing and counts nothing;
  • rows in more than one currency (a currency column) have no single sum;
  • a person with no target they reach has no {{row.target}} and no {{row.cover}}, and the section has no {{cover.<section>}} while anybody whose rows are in its sum has none.

So a template writes {{target.<section>}} and {{cover.<section>}} with a backup ({{cover.reps|not set}}) or in a block tied to them with showWhen: the validator refuses them bare. It also refuses a sum of a column that is not a number, a target.of that is not one of the section’s sums, a target.owner that is not a staff column, and a line of a "by": "owner" section that reads a column of a row.

When the figures were read, and “send me mine now”

Section titled “When the figures were read, and “send me mine now””

A summary may wait hours for the end of its reader’s quiet hours. Every summary therefore says the moment its figures were read, in the reader’s language and on the business’s clock: where the template writes {{readAt}}, or as a last line (“Figures read on …”) when it does not.

A signed-in person asks for their own summary with POST /api/v1/me/summaries/{ruleId}/send (GET /api/v1/me/summaries lists the rules that make them one). It takes no body: the rule’s own sections, read as that person at that moment, sent to their own address, also when nothing is in any section and also while the rule is switched off. Three an hour for each person and rule. A rule that is not there and a rule that writes to roles the person does not hold answer the same 404.

  • It never reads “all rows”, and it has no section without an owner column: whose a row is in a summary is that one staff column, not every way of the table’s belongs. A rep who covers for a colleague, or helps on a colleague’s deal, reads those deals on the screen and does not get them in their summary.
  • “The people they lead” is read when the summary is sent, not when the rule ran: somebody moved out of a team, and a manager who stopped leading it, are out of the next summary.
  • It reads no private table, no column through a link, and no count of related rows.
  • It adds no two sections together and divides no sum by another: one sum, one target. A figure made of two conditions (booked plus weighted) is a column of the table, or two figures.
  • A line a person has only people with a row in the section or a target; it sorts by name.
  • It keeps no copy: the job that makes it holds the rule’s sections and the person’s id, and the mail’s own job holds the mail sealed. A summary job cannot be queued by hand.
  • “This week” is still counted from when the rule ran, whatever hour the person reads it.

A record.create step with "name": "contact" keeps the row it made, and every step after it may read {{steps.contact.<column>}}: an enquiry makes a contact, then a deal for that contact, then a follow-up on that deal, in one rule. The row is kept in the run’s stored trace, so it is still there after a wait.

A notice, or a mail to a person, carries what that person reads of the row: nothing of it for somebody whose roles do not read its table or reach that row (a costing made for an approver, told to the deal’s owner), and nothing of a column their role is not shown. A personal or secret column of the row is never kept for the steps after.

A {{steps.…}} that names a row no step before it makes, or a column that row’s table has not, is refused by the validator and when a rule is saved. A run that reaches a step whose row was not made (it was made on the other side of a branch) stops there and fails by name: the text is never written with the braces left in. A test run makes no rows; it reads a stand-in and says so.

A manifest knows nothing of the install it lands on, so a shipped rule differs from one drawn on the rules page in three ways:

  • Tables are the manifest’s own, by their short names. It names no connection and no time zone: a rule by the clock runs on the database’s zone, else the server’s.
  • A notice goes to the manifest’s own roles and an email to a column of the record: never a fixed address, a user, a web address or a document.
  • An email’s template carries no document and no block marked withAttachment, onlyWith or onlyWithout: those are the outbox’s.

A condition is { "left", "op", "right"? }. left is { "field" }, a column of the record, or { "count": { "table", "matchColumn", "equalsField", "where"? } }, a count of related rows. On a yes/no column only is and is_not are allowed, with "true" or "false". A count cannot be used in a schedule’s forEach.where: keep the count in a rollup column and compare that. A changedColumn that is a total Adminium settles after the save needs a formula column over it with "announce": true. A rule that visits each row once needs a condition that picks the row.

Each rule is installed with the app or add-on and listed on the rules page under “From your add-ons” or “From your apps”. The owner may switch it, test it and copy it; a shipped rule itself is never edited or deleted there (409 AUTOMATION_MANAGED). An update rewrites a rule nobody changed, keeping the owner’s switch, and removes one the new version no longer ships. While its add-on is switched off a rule is skipped. Uninstalling removes the shipped rules.

Whether each of an app’s own rules is switched on can be read by the app’s own people, for a screen that depends on one: see Whether a rule is switched on.

sampleData points at a file of demo rows inside the package:

"sampleData": { "file": "seeds/visits.sample.json" }

The file must be in seeds/ and end in .json. skipWhenShared ({ "table", "skip": [1–50 table refs] }) leaves the listed tables’ sample rows out when table is a shared table another installed app uses and it already holds real rows (rows no app’s sample added): a venue’s real menu never gains sample dishes, nor sample orders of them. The list must include every table that links to a skipped one. After installing, the operator can add the sample data in one step and later remove it. Removal shows a preview first. It keeps every sample row the operator’s own records still point at and, if they choose, the rows they have changed since.

A sample business has people: a rep per pipeline, a crew per van. people and teams say who they are, and a sample row names one with { "@person": "<key>" }:

"sampleData": {
"file": "seeds/crm.sample.json",
"people": [
{ "key": "dana", "name": "Dana Whitlock", "email": "dana@meridian.example", "role": "rep" },
{ "key": "ivy", "name": "Ivy Sandoval", "role": "sales-manager" }
],
"teams": [{ "name": "Sales", "lead": "ivy", "members": ["dana"] }]
}
Field Rule
people 1–200 of { "key", "name", "email"?, "role"? }. key is lower-case letters, digits, _ and -, at most 40, and listed once. role is the key of one of this app’s own roles. email is shown beside the name and never mailed; where it is left out, or somebody already has it, Adminium gives the person an address of its own (dana.crm@sample.invalid).
teams 1–50 of { "name", "lead"?, "members" }: the people of one team, by key. lead leads it, so a role limited to its team’s rows reaches the members’ rows. A team name the operator already uses is left to them: the sample’s team is made beside it as “Sales (sample)”.

“Add sample data” makes each person a staff member marked as a sample person: no password and no way to get one, nothing is ever mailed to them, and nothing about them is changed by hand. They are listed on the Team page under their own status and are in no other count. “Remove sample data” takes them and their teams away, but for a person a row that stays still names (a deal the operator made and gave to a sample rep): that person stays until the row names somebody else, and the next “Add sample data” takes them back instead of making a second one. An add-on’s manifest takes neither field. Needs Adminium 0.3.28.

A rule hands a row to nobody who cannot sign in, so a rule’s write that names a person from the settings row or the record leaves a sample person out, with one exception: when the row the run is about is a sample row, it may name a sample person (a sample job’s sign-off books its aftercare call for the sample rep who sold it). A real row is never handed to a sample person. The rows such a run creates are sample rows too: they are listed with the sample, and “Remove sample data” takes them with the rest, and with them the sample row they hang off. A rule-made row somebody has since written a row of their own under stays, as any sample row their records use does, and a row a rule made from a real row is never listed. Adminium 0.3.35.

The file uses the adminium.sample/1 format:

{
"format": "adminium.sample/1",
"app": "visits",
"tables": [
{ "ref": "clients", "rows": [{ "@label": "ada", "name": "Ada Byrne" }] },
{ "ref": "visits", "rows": [
{ "client_id": { "@ref": "ada" }, "starts_at": { "@day": 1, "@time": "09:30" },
"note": { "@t": { "en-US": "First visit", "de-DE": "Erster Besuch" } } }
] }
]
}
Field Rule
format Always "adminium.sample/1".
app The app’s key.
tables At least one { "ref", "rows" }, each with 1–5000 rows, written in the order listed (parents first) and removed in reverse. Every ref and column must be declared in requiredSchema.
assets Optional files for @asset: an object from a label to { "file": "seeds/…", "sha256": "<64 hex>" }.
weekAnchor Optional: the weekday (mon … sun) the sample was written for, which @week days count from.

A row may carry an @label (letters, digits and : . _ -, unique in the file) so a later row can point at it.

A column numbered without gaps is spelled null in every sample row ("number_seq": null); its format text is whatever the row gives (its own spelling, such as INV-S2041), or empty. A sample row then stays off the real series: the first real invoice is still number one. Adding the sample is refused when a row leaves a gapless column out or gives it a number.

A series marked "sample": true is the exception, for numbers that are the app’s own and no tax document’s (a job’s J-01). Each sample row gives the column its number, with the format text written the table’s way ("code_seq": 7, "code": "J-07"), or null for a row with no number. A number twice in one series is refused, and so is a text that does not match its number. The next real row takes the number after the largest, and goes on from there once the sample is removed.

A value is plain JSON, or one of these directives:

Directive Value
{ "@ref": "<label>" } The key of an earlier row with that label. In an add-on’s sample it may also be the label of one of the add-on’s seeds rows (a unit the install wrote).
{ "@person": "<key>" } One of the manifest’s sample people: the staff member made for that key. In a staff column or another text column that holds a staff member’s id, and anywhere inside a json value (a saved filter that names a rep). Only in the app’s own sample file, not in its rows for an add-on.
{ "@ago": "PT19M" } An ISO 8601 duration before now.
{ "@in": "PT20M", "@grid": 15 } An ISO 8601 duration after now. With @grid, rounded up to the next step of that many minutes on the venue’s own clock, counted from its midnight (never past the next midnight): the first pickup slot at least 20 minutes away.
{ "@in": "PT20M", "@slot": "orders" } The first open time of that table’s slot limit at least that far ahead: its hours, closures and pauses, on its grid, with room left after the sample’s own rows placed so far; on the next open day when today has none, looking two weeks ahead, else the plain @in time. Not with @grid.
{ "@day": -1, "@time": "09:30" } A wall time in the venue’s time zone, a number of days from today (−366 to 366).
{ "@day": 3 } A date: that many days from today, as the venue’s calendar has it. For a date column.
"@workdays": true Added to either @day form: the days count Monday to Friday only, and day 0 on a weekend is the Monday after. So the sample’s busy day is never a Saturday.
"@week": true Added to either @day form instead: the days count from the bundle’s weekAnchor in the week nearest today (three days either way), so every date keeps the weekday it was written for: a weekend stay stays on a weekend, whatever day the sample is added on. Needs weekAnchor.
{"@month": -2, "@dom": 14} A date in the venue’s time zone: that day of the month, so many months back (0 is this month). Add "@time": "10:00" for a time on that day. A day past the month’s end is its last day, and a day that has not come yet is today (a time not yet come, now). Use it for history counted in calendar months: “this month” and “in May” read the same whether the sample is added on the 3rd or the 28th.
{ "@t": { "en-US": "…" } } Text in the language of the person adding the sample. Keys are xx or xx-XX.
{ "@asset": "<label>" } A file from assets, added to the Files library.

Beside its @label, a row may carry the row directive @byClock, so that a sample day’s statuses match the time it is added at:

{ "@label": "visit-2", "starts_at": { "@day": 0, "@time": "10:00", "@workdays": true },
"status": "booked",
"@byClock": { "at": "starts_at",
"before": { "status": "seen" },
"around": { "status": "checked_in" } } }
Field Rule
at The row’s time: the name of a column the row sets, or a @day with a @time.
before Columns merged into the row when its time is more than half an hour before the moment the sample is added.
around Columns merged when its time is within half an hour of it.
after Columns merged when its time is later.

Each set holds columns of the table, and may use directives. A set with "@skip": true leaves the row out altogether: a payment for a visit that has not happened yet.

A row that lasts from one time to another (a stay) may carry @byStay instead, placing it by where the adding moment falls against its two times:

{ "@label": "stay-4", "arrive": { "@day": -1, "@week": true }, "depart": { "@day": 2, "@week": true },
"status": "booked",
"@byStay": { "from": "arrive", "to": "depart", "times": { "from": "15:00", "to": "11:00" },
"during": { "status": "in_house" }, "after": { "status": "departed" } } }
Field Rule
from, to The arrival and the departure: columns the row sets, or @days.
times { "from"?, "to"? }: the wall times a date is read at (arriving from 15:00, leaving by 11:00); else its midnight.
before, during, after Columns merged when the adding moment is before the stay, during it, or after it.

A row takes one of @byClock and @byStay. A later @ref to a row a @skip left out fails.

Every table that keeps totals is settled once all the sample rows are in, so a sample visit’s balance is right from the start. The totals so far are also settled before each table’s rows go in, so a row that copies a total from an earlier table (a stage invoice copying its quote’s subtotal) reads it worked out.

A row may also carry "@onlyIfEmpty": true, for a table that holds one row, such as the app’s own settings. The row is added only when the table is empty. When the operator already has a row there, the sample leaves theirs alone, and a @ref to the sample row’s label points at theirs.

Add "@fillEmpty": true beside it when the row that is already there should still take the sample’s values: a settings row the install wrote with its defaults (seeds) gets the sample’s depot name and address in the columns it holds nothing in. A column that holds a value is never written over. “Remove sample data” empties those columns again where nobody changed them since, and leaves the row.

A table of many rows the operator sets up themselves (a kitchen’s opening hours, one row a weekday) takes "onlyIfEmpty": true on the table instead: its rows go in only when the table holds none, and otherwise all of them stay out. No row may @ref a row of such a table, since it may not be written.

Adminium keeps track of the rows it added in a ledger table named <key>_sample_data (with - in the key written as _), so avoid a table of that name.

seeds are the rows an app’s tables hold from the first second: the one row of its settings, its list of reasons, its units. They are not demo rows; use sampleData for those.

"seeds": [
{ "table": "settings", "rows": [{}] },
{ "table": "reasons", "rows": [
{ "@label": "late", "label": { "@t": { "en-US": "Late", "de-DE": "Verspätet" } } },
{ "label": "Damaged" }
] },
{ "table": "checks", "rows": [{ "name": "Call back", "reason_id": { "@ref": "late" } }] }
]

Each entry names one of the app’s own tables and gives its rows inline (rows) or in a file of the package ("file": "seeds/reasons.json", a JSON list of rows), exactly one of the two. A table is seeded with at most 200 rows.

  • When they are written. Once, by the install, after the tables and their rules exist: a row gets its table’s defaults, copies, formulas, numbers and codes like any new row, so {} is a row of defaults. All of an install’s seeds go in one statement group: a refused row leaves none behind and the install stops at its seeds step (APP_INSTALL_INCOMPLETE); installing again takes it up from there.
  • Only into an empty table. A table that already holds a row (tables kept from an earlier install, a table the owner filled) is left exactly as it is, and a row that points at a row left out that way is left out with it.
  • An update writes the seeds of the tables that version brings and of no other: a table the version before already had is the owner’s by now, empty or not.
  • They are the owner’s rows afterwards. “Remove sample data” never takes one, and the app’s roles decide who may change them.
  • A value is a plain value, { "@t": { "en-US": "…", "de-DE": "…" } } (written in the installing person’s language) or { "@ref": "<label>" } (the key of an earlier seed row that carries "@label"). No other directive is read.
  • Not allowed: a table built on an add-on’s shape (the add-on numbers its own rows), a private table, and a value in a column that holds a staff member (a manifest does not know who works here; leave the column out).

An app’s seeds are read from Adminium 0.3.28 on, so a manifest that has them sets compatibility.minAdminiumVersion to 0.3.28 or later; earlier releases accepted the block and wrote nothing. An add-on’s seeds are described under Add-on manifests.

widgets (a list of { "id", "entry" }) are accepted by the schema, but this release does not act on them.

An add-on manifest has "kind": "add-on" and shares the identity fields, compatibility, capabilities, settings, widgets and requiredSchema with an app. It differs in these ways:

  • Categories come from a separate list: artwork, delivery, payments, email, data.
  • No frontends, no addOns.requires and no addOns.features: an add-on has no screens outside the dashboard and never requires another add-on (it may suggests one).
  • An add-on that keeps tables of its own sets compatibility.minAdminiumVersion to 0.3.18 or later and requiredSchema.prefixed to true. It may then declare, in an app’s words, pages, navGroups, roles, optionLists, documents, seeds, outbox, emailTemplates, sampleData, publicAccess and publicKeys. See below. An add-on released before that floor declares none of them and installs exactly as it always did: it uses its host app’s tables, or creates unprefixed ones that are kept when it is disconnected.
  • A page’s ref starts with the add-on’s key (stock-kit-items), so no two add-ons, and no add-on and app, can declare the same page.
  • An addOn block is required:
Field Required Rule
attaches yes At least one { "app", "range"?, "table"? }: an app key, or "*" when the add-on is not specific to one app. range is 1.2.3, ^1.2.3, ~1.2.3 or *. table is only for the record editor panel slot.
connect yes { "kind" }, with kind one of none, api-key, oauth2. oauth2 needs authorizeUrl, tokenUrl and the oauth-connect capability; scopes is optional.
provides no Contracts the add-on implements: { "contract", "version", "server" }.
consumes no Contracts it uses: { "contract", "version" }.
slots no Places in the host’s screens it fills: { "slot", "client", "order" }.
events no Events it handles: { "on", "server" }.
scopes no What it may reach, such as records:<table>:write. A records: scope must name a table of the host app or of the add-on.
network no { "allow": [hostnames] }: the exact HTTPS hosts its server code may call. Required, and non-empty, with the outbound-http capability. No wildcards, IP addresses or ports.
publicSettings no The setting keys its browser code may read. Never a secret setting.
demoTransport no The module that stands in for the real third-party service in a demo.
pages no Dashboard pages it renders from its own bundle: { "ref", "title", "titles"?, "icon", "client", "nav"?, "detail"? }, served at /add-ons/<key>/<ref>. Needs hostApi. titles is the title in the other languages, by tag ({ "de-DE": "Zählen" }): the sidebar is drawn before the page’s code loads, so a title that is only in the bundle’s own strings shows in English.
navGroups no Sidebar groups for those pages: { "key", "label", "labels"?, "order" } (labels: the label in the other languages, by tag). A group may not reuse a built-in key (workspace, library, planning, people, account), and every declared group must be used by a page.
hostApi with pages The version of the host API its pages are built against: 1, or 2 for a page that reads the data kit. 2 needs compatibility.minAdminiumVersion 0.3.18 or later.
words no 1–4 questions asked of a ledger with nothing written. See Stock words.
recordTabs no 1–6 tabs of its rows shown on another table’s record. See A tab on another table’s record.
shapes no 1–8 shapes apps build their tables on. See below.

Contract ids and slot ids come from closed registries in the add-on contracts package. How Adminium runs add-on code, and why only first-party add-ons are accepted, is explained in Add-on trust.

Field Rule
requiredSchema.prefixed true. Every table is created as <key with _ for ->_<ref>: stock-kit and items make stock_kit_items. A name that is taken is a problem on the check, never a rename.
requiredSchema.tables[].indexes Up to six sets of one to five columns, each made as an index. A set equal to a unique set is refused.
seeds [{ "table", "rows" }] or [{ "table", "file" }]: rows written once, at install, into a table that is empty. A value is a plain value, { "@t": { "en-US": "…", "de-DE": "…" } } (written in the installing person’s language) or { "@ref": "<label>" } (the key of an earlier row that carries "@label"). Never for a ledger or a receipt table. The add-on’s sample data may point at a labelled row written in rows (not one in a file) with the same { "@ref" }: the row is found again by its one-of-a-kind column ("unique": true), else by its table’s keyField. If the owner deleted it, the sample row that names it is left out.
requiredSchema.tables[].appGrants The most an app’s role may hold of the table. See What an app’s role may hold of an add-on’s table.
addOn.settingsTable A table that holds exactly one row. It is made at install from the columns’ defaults, so every required column has one.
roles As an app’s. A role grants the add-on’s own tables (table:@items:read), its own pages (page:@stock-kit-items:view) and its settings (addOn:<key>:settings). Never screensOnly.
sampleData As an app’s, for its own tables.
publicAccess Entries as an app’s. One with no key is served through the customer key of an app that names the add-on, once that is allowed. One with key set to the add-on’s link key is served through that key.
publicKeys At most one key, under a name that is not customer, with no requiresStaff, enabledBy or peak. It opens one row by its link and only reads: every entry on it has "methods": ["GET"] and either claims by token ("claim": { "by": "token", "column": "…" }, on a column with rules.code { "length": 16, "hiddenFromStaff": true }) or is read with the row a link opened (visibleWith, claimedBy).

A column may keep the name of a table ("rules": { "tableRef": true }): Adminium stores it so that renaming the table it names keeps the rows pointing at it.

What an app’s role may hold of an add-on’s table

Section titled “What an app’s role may hold of an add-on’s table”

An app gives its roles a hold on an add-on’s tables with roles[].tables. The table is the add-on’s, so the add-on says how far that may go, on the table itself:

{ "ref": "suppliers", "columns": [ … ],
"appGrants": { "actions": ["read"], "readable": ["id", "name", "lead_days", "active"] } }
Field Rule
actions The most an app’s role is given here: read, create, update.
readable The only columns an app’s read shows. Absent: every column but those in never, and never a secret.
writable The only columns an app’s update may change. Required when actions has update.
creatable The only columns a new row an app’s role adds may be given. Required when actions has create.
never Columns no app’s grant reaches, whatever else is listed. Not the key: every read shows it.

Once an add-on sets appGrants on one table, a table without it is given to no app’s role. An add-on that sets it on none is held only by its own shape: no hold on a ledger’s receipt table, read only on a table a ledger’s code writes. A ledger’s receipt table takes no appGrants, and a table a ledger’s code writes allows only read. Each column named must be one of the table’s.

An app’s entry is cut to it when it is written, and again whenever the add-on is installed, updated, connected or switched back on. Whatever is cut is named: in the install check, and in the reply as narrowed with skipped: "ceiling", or skipped: "not-offered" for a table given to no app. A grant on such a table always carries its column lists. The key is read with any read; a link to another row only when readable lists it, as with every read limit.

A release that widens appGrants asks first. When an add-on’s new version would let an app’s role hold more of its tables than the running version did (an action, a column, or every column where before it was some), the update’s check lists each such grant (roleTables: the app, the role, the table and what is new), and the update is refused with 422 ROLE_TABLES_WIDER until it is sent with "roleTables": true. Both doors take it: POST /add-ons/{key}/update and the old POST /add-ons/{key}/upgrade. An app’s install or update that brings such a release along asks the same, with the same "roleTables": true, for its own roles and for those of every other app the add-on is connected to. A release that gives no role more is not asked about; one that narrows is applied at once.

A manifest that uses appGrants sets compatibility.minAdminiumVersion to 0.3.28 or later.

An add-on with tables of its own may keep up to four ledgers under addOn.ledgers: tables whose rows it alone plans, written by Adminium inside the save of the row that posts into them.

"ledgers": [
{
"id": "stock",
"refusal": "stock",
"receipts": "postings",
"writes": {
"movements": { "insert": ["level_id", "quantity", "kind"] },
"reservations": { "insert": ["level_id", "quantity", "state"], "update": { "by": ["id"], "set": ["state"] } }
},
"actions": {
"use": {
"inputs": { "item": "link", "quantity": "decimal", "note": "text?" },
"phases": ["reserve", "post", "reverse"],
"reads": [
{ "as": "levels", "table": "levels", "by": [{ "column": "item_id", "from": "input.item" }] },
{ "as": "mine", "table": "reservations", "by": [{ "column": "receipt_id", "from": "receipt.id" }] }
],
"locks": [{ "read": "levels", "column": "id", "table": "levels" }],
"holds": true
}
}
}
]
Field Rule
id A lowercase name.
refusal stock or value: which of the two public refusals a guest hears (PUBLIC_OUT_OF_STOCK, PUBLIC_CARD_REFUSED).
receipts One of the add-on’s tables, with exactly the receipt columns. Adminium alone writes it.
writes Up to twelve of the add-on’s own tables, each with the columns an answer may insert and, by a key, update. Never a delete. Never a total, a balance, a formula, a stamp, a number, a code, a copy or the key. A table an answer inserts into carries receipt_id, a link to the receipt table that may be empty; Adminium fills it. Such a table carries no booking rule, no slot or night limit and no number without gaps.
actions 1–16, by name.

An action:

Field Rule
inputs What a posting maps: each link, number, decimal, text, date, bool (with ? when it may be empty), tableRef or rowRef (a row of any table: { "row": true } in a posting for the row itself, or a link column for the row it points at).
phases Which of reserve, post, reverse it has.
reads Up to six { "as", "table", "by", "where"?, "limit"? }: the rows Adminium reads for the code, from the add-on’s own tables only. by is 1–3 { "column", "from" }; from is input.<name>, <an earlier read>.<column>, receipt.id, setting.<column>, or a list of up to three of them. Up to 1,000 rows a read, 3,000 a call.
locks 1–4 { "read", "column", "table" }: the rows a save stands on. Two saves that would take from the same row wait for one another. Every table in writes that feeds a capped balance has that balance’s table (or the table it stands for) here.
writes Optional: the tables of the ledger this action writes, when not all of them.
holds true: reserve writes something that ends. A posting into it says until when.
decides { "input", "min": "0", "max": { "input" } | { "read", "column" } } entries: an amount Adminium writes to the posting row itself, between the bounds it reads. Two entries for one input are two ceilings.
unavailable { "allow": { "read", "column" } }: while the code cannot be asked, a save may still go through when every row of that read has the yes/no column on. It leaves a receipt to be worked out later.

Three rules of the add-on’s own tables are for ledgers:

  • capUnless on a capped total.
  • "announce": true on a formula column (up to four a table): when a posting’s settle changes it (an item that turns low), the change is told to automations, emails and open screens as a change of that row — though no one saved the row.
  • "planned": true on a state move: only a row the add-on’s own code writes makes it. It is not offered to any person, effect or timed move. A row that code adds may start in the state such a move reaches from the first state, where that move asks nothing (requires): adding it there is the add and the move in one.

One row a phase, written by Adminium: id, source_table and line_table (text, with "rules": { "tableRef": true }), source_row, source_line, ledger, action, posting (text), phase (reserve, post, reverse), round (a whole number), state (planned, unplanned), rows (a whole number), add_on_version, origin (staff, public, system), by (text), at (a date and time) and held_until (a date and time that may be empty). Adminium makes its unique key and its indexes itself.

The add-on’s package provides the contract posting-rows, version 1, as one self-contained script. Adminium calls its rows(input) with the lines, the rows it read and the add-on’s settings, and writes what it answers. See A ledger, and code that decides.

An add-on that keeps offers declares addOn.adjuster and provides the contract price-adjust (provides: [{ "contract": "price-adjust", "version": 1, "server": "dist/server.js" }]). Adminium reads what the adjuster names, asks the add-on’s code, and writes the answer. From Adminium 0.3.19.

Field Rule
offers Up to 6 reads of the add-on’s own tables, in order: { "as", "table", "by": [{ "column", "from" }], "where"?, "limit"? }. from is now, input.codes (the keys of the code rows the typed codes found), setting.<name> or <as>.<column> of an earlier read. The first read’s table is the table of offers.
codes { "table", "column", "where"?, "reserved"? }: discount codes. reserved: 1–8 words of 2–4 capital letters no discount code may start with (a voucher’s and a gift card’s routing words).
vouchers { "table", "column", "prefixes" }: vouchers and packs, found by the typed text with its prefix cut (VC-).
applied Where Adminium keeps what was applied: table, source (table, row, line), columns (offer, code, voucher, name, kind, amount, reason, typed, at).
person Optional, read only for a proved customer: groups (table, member, group), uses (table, customer, offer, state, counted), orders?.
ceilings Optional: { "table", "role", "maxPercent", "maxAmount", "comp"? } — what each role may take off by hand.
customerKey "hash": a customer reaches the add-on as a keyed hash, never as an address.

The codes and the vouchers are two tables. A discount code written with a reserved start is refused (VALIDATION_FAILED, the code column reserved), whichever way it is written; a code Adminium makes for that column never has one.

A shape is what an app’s tables are built on: named parts, each with its columns, rules and states, the document profiles made for an app’s tables, and the messages an app built on it sends.

Field Required Rule
name yes kebab-case. An app names the shape <add-on key>/<name>@<version>.
version yes 1–99. A change an app’s tables cannot follow is a new version.
parts yes At least one, keyed by a snake_case part name: { "columns", "states"? }, with 1–60 columns and optional states.
documentProfiles no Up to 8 documents, each naming the part it is drawn for instead of a table, with no addOn and no feature.
outbox no { "producers", "templates"? }: 1–16 producers, whose tables are the shape’s parts, and up to 16 templates, each an email template with a kind (the producer’s) instead of a key.

A part is checked the way an app’s tables are, with the parts of all the add-on’s shapes as the tables. A column’s references names another part of the same shape ("document"), or a part of another of the add-on’s shapes ("quote@1/document"). A rule in a part that reads a setting reads one of the add-on’s own ({ "addOn": "<its key>", "setting" }), since the add-on cannot know an app’s settings row.

A part may also carry the rules an app’s table gets: postings, adjust and indexes. A shape with such a part is not built as new tables: an app adds it to tables it already has, under its own names, and writes no builtOn. A part of such a shape may say what its rows are to a price rule that prices the same table, with inAdjust: { "excludes": "<column>" } (a row that fills the column takes no reduction, as a gift-card load) or { "paidBy": "<column>" } (a row that fills it is something sold that pays later, as a voucher). The column is one of the part’s. The app’s price rule then names it on its line (excludes, paidBy); a tool that adds the shape writes it there.

addOn.steps gives the owner’s rules up to 8 steps of the add-on’s own. A step is a named write of one row of one of the add-on’s tables: Adminium writes the row through the same door as a rule’s own “create a record”, so the table’s column rules, its ledgers and the add-on’s outbox do the rest. No code of the add-on runs in a rule.

"steps": [{
"key": "issue-voucher",
"name": { "en-US": "Issue a voucher", "…": "…" },
"does": { "en-US": "Sends a voucher to one person", "…": "…" },
"inputs": [
{ "key": "to", "kind": "email", "required": true, "label": { "en-US": "Send to", "…": "…" } },
{ "key": "worth", "kind": "choice", "required": true, "label": { "…": "…" },
"options": [{ "value": "percent", "label": { "…": "…" } }] }
],
"writes": { "table": "vouchers", "values": {
"holder_email": { "input": "to" }, "worth": { "input": "worth" },
"note": { "text": "From a rule" }, "issued_note_at": { "token": "now" } } }
}]
Field Rule
key Kebab-case, unique among the add-on’s steps. A rule names the step by it.
name, does, each label A text in every one of the eight languages (60, 160 and 60 characters).
inputs Up to 8. kind is text, email, number, choice (with options) or record (with table: a row of one of the add-on’s tables is picked, and its key is the value). required makes the rule unfinished until it is filled.
writes.table One of the add-on’s own tables.
writes.values A column → { "input": key }, { "text": "…" } (plain text, no braces) or { "token": "now" | "ruleName" | "recordLabel" }.

The validator refuses a step that could not run: a table or column that is not the add-on’s, a column Adminium decides (its key, a code, stamp, rollup, formula… rule), an input no value reads, a column that must hold a value and that nothing fills, and such a column filled from an input that is not required. An input the rule leaves empty is left out of the row, so the column’s own default or rule stands.

What an owner fills an input with is text that may carry {{record.<column>}}. A personal column of the rule’s record is read only for an input whose value goes only into columns the add-on marks personal itself; such a value is never written to a run’s log.

A manifest that gives steps sets compatibility.minAdminiumVersion to 0.3.22 or later.

addOn.assistant is text, in two parts, and either may be left out:

"assistant": {
"tables": {
"vouchers": { "is": "A single-use code issued to one person.", "columns": { "uses_left": "How many times it can still be used." } }
},
"questions": [
{ "key": "unused", "text": { "en-US": "Which vouchers were never used?", "…": "…" } },
{ "key": "batch", "page": "offers-voucher-batches", "text": { "…": "…" } }
]
}
Part Rule
tables One of the add-on’s own tables → is, one English line (200 characters) on what a row of it is, and columns, a line (160) for a column that needs one. The assistant is given them with the schema, as the add-on’s words about its table, wherever it looks at that table.
questions Up to 8 questions a person might ask, in every one of the eight languages (120 characters). Shown as starters in the assistant’s panel on the add-on’s pages, under the add-on’s name. With page (one of the add-on’s page refs, generated or its own) a question is shown on that page only.

It names no tool, grants no read and switches nothing on. A reader who may not read a table is told nothing of it; the lines travel as data inside the schema tool’s answer, never as instructions; and no other key is accepted. A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.22 or later.

addOn.words names a question a page may ask of one of the add-on’s ledgers with nothing written: for each row asked about, is one of it in, low or out. A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.

"words": [{ "id": "units-left", "ledger": "units", "action": "use", "input": "what",
"showLeftBelow": { "setting": "show_left_below" } }]
Field Rule
id kebab-case, unique among the add-on’s words.
ledger, action One of the add-on’s ledgers and one of its actions. The add-on’s code is asked what that action would do for a quantity of one, in the action’s words mode.
input The action’s input that takes the row asked about.
showLeftBelow { "setting" }: a column of the add-on’s settings table. A customer is told how many are left only when fewer than the number it holds are, and only for today. Without it, never.

A customer’s page asks through a public entry of the table the rows are of, with "kind": "availability", "methods": ["GET"] and "words": "<add-on key>:<id>" in place of a limit of the table; such an entry takes no rule, showLeft, under or unlockBy. The page asks GET /public/availability/<entry>?under=<id>,<id> with up to 60 row ids. The answer lists { "id", "state", "left"? } for each row a plain read of the same key would show; any other row is left out. Answers are kept for five seconds. An add-on that cannot answer (switched off, not loaded, its code failing) says nothing and every row reads in: a page never says “sold out” because an add-on is off, and the save still refuses what is not there.

Staff ask GET /api/v1/words/<add-on key>/<id>?table=<stored name>&ids=<id>,<id> and are told the same, never from a kept answer. A caller who also reads the add-on’s stock tables is given the figure behind the word: the exact count, the batch a use would take and its expiry, and the line that runs out first. When the add-on cannot answer, staff are told so (409), never “in stock”.

addOn.recordTabs adds a tab of the add-on’s rows to the record page of another table: the stock a dish uses, on the dish. The tab is declared, not coded. Adminium draws it with the page’s own parts and the reader’s own grants. A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.

"recordTabs": [{
"id": "stock", "label": { "key": "kit.tab.stock", "fallback": "Stock" },
"table": "links", "match": { "table": "source_table", "row": "source_row" },
"on": "linked",
"columns": ["item_id", "qty", "note"], "edit": ["qty"],
"add": { "pick": { "table": "items", "label": "name" } }, "remove": true,
"empty": { "key": "kit.tab.empty", "fallback": "Nothing is linked yet." },
"summary": { "words": "units-left" },
"actions": [{ "id": "use", "label": { "key": "kit.tab.use", "fallback": "Use stock" },
"child": { "table": "uses", "form": ["qty"] } }]
}]
Field Rule
table, match The add-on’s own table whose rows the tab lists, and its two columns that say which record a row belongs to: match.table holds a table’s stored name ("rules": { "tableRef": true }), match.row the row’s key as text.
on "linked": every table a posting rule hands in to this add-on as the row asked about. Or up to 24 tables by their stored names.
columns 1–8 columns shown. edit lists those a reader who may change the rows edits in place.
add { "pick" }: a row is added by picking a row of another of the add-on’s tables, shown by its label column. One pick, or two (an item or a kit).
remove true: a row may be removed from the tab.
form 1–8 columns: one row edited as a form, in place of a list.
summary { "words" }: one of the add-on’s stock words, whose answer heads the tab.
actions 1–2 buttons that add a row of another own table for the same record.

An app’s table has the tab only while the add-on is connected to that app and switched on. A reader who cannot read the add-on’s table has no tab; one who can is offered only what their role allows. A tab is drawn before any code of the add-on runs, so its words in the other languages sit beside each message, by tag: labels beside label, empties beside empty, and labels on each of its actions ("labels": { "de-DE": "Bestand" }). A reader whose language is not there reads the English. The rest of the tab’s words are Adminium’s.

An add-on never requires another, and may name up to 8 in addOns.suggests. Naming one lets it ship a document the other draws and list the other’s rows by a pair. Each works only while both are installed: a document whose add-on is absent is not made, and is made the moment that add-on is installed, and an email that would carry it goes without it.

addOn.lookUp says which of the add-on’s tables a typed or scanned code is looked for in, and what the answer may carry. Adminium reads the row; the add-on’s code does not. A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.

"lookUp": {
"kinds": [
{ "id": "gift-card", "table": "gift_cards", "code": "code", "prefix": "GC-",
"show": ["label", "status", "balance"],
"rows": { "table": "card_ledger", "via": "card_id", "columns": ["at", "kind", "amount"] } },
{ "id": "pack", "table": "vouchers", "code": "code", "prefix": "PK-",
"where": [{ "column": "worth", "eq": "pack" }], "show": ["status", "uses_left"] }
],
"address": { "table": "gift_cards", "column": "owner_email", "show": ["status", "balance"] }
}
Field Rule
kinds 1–6, tried in order. table is one of the add-on’s own; code is a text column with a code rule, or a unique one.
prefix Two capitals and a dash. A typed value that starts with it is that kind’s and no other’s; no two kinds share one.
where Up to 2 conditions { "column", "eq" } or { "column", "in" } on the row. Two kinds that read one table differ by it.
show 1–16 columns of the found row the answer carries.
rows One table of history, newest first: via is its foreign key to table, columns 1–8 of its columns.
address Rows found by a customer’s address instead of a code. column carries normalize: "email".

An answer never carries the code column, a secret column or a code kept from staff: a page shows what was typed. A document kind may also be drawn on a6, a card of 105 × 148 mm, beside a4, letter and receipt-80mm.

Adminium validates a manifest when a package is uploaded or downloaded from the catalogue, and refuses an invalid one before anything is written. The refusal lists every problem, each with the path of the field (requiredSchema.tables.0.columns.2.default) and a message. The checks are:

  • The schema. Every field on this page, its type and its limits. Every object is strict, so a misspelled or unknown field is an error.
  • Cross-references. Every name the manifest uses must be declared in it, and be of the right type: a rule’s columns, a formula’s inputs, a setting a rule reads, a capacity’s or a booking’s tables and columns, a table’s states and the child tables tied to them, a public entry’s table and columns, a public key’s role, the outbox’s columns and templates, a document’s columns, an add-on a shape or a document needs, a page’s feature, an option list, a foreign key’s target. A PATCH without a claim, a writable column that Adminium decides, and an availability entry on a table with neither capacity nor booking are refused, along with every rule stated in the sections above.
  • Policy. The publisher must be adminium, or local for an app installed from a file, and the key must not be reserved.
  • The version floor. An app or add-on whose minAdminiumVersion is newer than the server is refused with a message naming both versions, including when an older server cannot parse a newer manifest.
  • The install plan, against the operator’s database: name lengths for that database (63 bytes on Postgres and SQLite, 64 on MySQL, including foreign key names fk_<table>_<column>), role names, page forms, email template blocks, public endpoints, and tables that are taken.

There is no published JSON Schema file for manifests. The schema itself is published as the @adminiumjs/manifest npm package. Its validateManifest(document) runs the schema, cross-reference and policy checks and returns every issue, so an app’s own CI can refuse a bad manifest before it is released.

Beside issues, validateManifest returns warnings: advice that never refuses a manifest. A manifest with warnings validates. Today there are four:

  • a column that is neither nullable nor given a default, a role or a rule that fills it will be NOT NULL once installed, so every new row must give it a value, and a draft saved half-filled is refused;
  • a text column in a unique set with no normalize (and no code rule): MySQL compares text ignoring case and accents, Postgres and SQLite do not, so give it normalize: "email" or "trim";
  • a setting whose key reads like bank details and is not secret: a setting that is not secret is published to the customer side;
  • a capped balance whose of is a formula reading a column that stays writable while the capped rows can exist: a bulk edit or an import settles the balance without the cap (see Totals that count and climb).

Keep warnings apart from issues in your CI, so that new advice never fails a build.

{ "ok": true, "manifest": { … }, "warnings": [
{ "path": "requiredSchema.tables.2.columns.4",
"message": "\"invoices.client_id\" has no default and is not nullable, so it will be required at install: every new row must give it a value" } ] }