Audience: consultant. Prerequisite: 01 — Engagement lifecycle.
Everything here is Engagement ▸ Build. This page covers the static app: what data exists, what screens show it, who may see it, and what it looks like. Making the app act is 03 — Behavior.
Schema — what things exist
Build ▸ Schema (/engagements/:id/build/schema). Two views of the same thing:
- Canvas — the visual map: entities as boxes, relationships as lines.
- List (
/engagements/:id/build/entities) — the same entities as a table.
An entity is a thing the app stores (a Customer, a Request, a Shipment). It has
fields, each with a type (string, number, boolean, date, enum,
reference), optional validation (required, unique, ranges), and a label.
What a good schema looks like
A field-service app, four entities, and the shape is the point:
flowchart TD
CUST["<b>Customer</b><br/>name · account_no ⭐<br/>region <i>enum</i>"]
SITE["<b>Site</b><br/>label · address<br/>customer_ref 🔗"]
JOB["<b>Job</b><br/>title · scheduled_for<br/>status <i>enum</i><br/>site_ref 🔗"]
VISIT["<b>Visit</b><br/>started_at · notes<br/>job_ref 🔗"]
CUST --> SITE --> JOB --> VISIT
⭐ natural key · 🔗 reference field (delete-protected) · enum — a knowable set
Why this shape:
- Every link is a reference field, so deleting a Customer with Sites is refused and names
the referrers. Model those links as
relationshipsinstead and the delete succeeds, leaving Sites pointing at nothing. statusandregionare enums, so every screen gets a picker and every rule condition is chosen from a list rather than typed.account_nois the natural key, so re-importing the customer list updates rows instead of duplicating them.- Visit is its own entity, not fields on Job. A job visited twice is two rows; as fields you
would be adding
visit_2_noteswithin a month.
What goes wrong when it is not
| Shortcut | What it costs, concretely |
|---|---|
status as a string | A rule tests status eq "complete"; someone types Complete. The rule matches nothing, silently, and nobody is told. |
| Link by relationship rather than reference | Deleting a Customer succeeds and leaves orphaned Sites. The canvas draws the broken link red — after the fact. |
| No natural key | The second import creates a duplicate of every customer. |
| Pages before schema | Every widget binding, rule target and permission grant is authored against entities that then move. Redoing them is the whole afternoon. |
⚖️ Judgement, not mechanism. The ordering advice above is experience, not something the platform enforces or that any check can verify. The refusals it describes are real and anchored; “do it in this order” is a recommendation you may reasonably override.
Decisions that are expensive to change later
| Decision | Why it matters |
|---|---|
| The entity’s key | it is referenced by pages, rules, watchers, permissions and imports |
| Which field is the natural key | de-duplication on import matches on it |
| Reference fields | they are the links the platform ENFORCES — deleting a referenced record is refused |
| Field types | an enum gives you pickers everywhere; a string gives you free text everywhere |
Prefer enums to strings wherever the set of values is knowable. The platform turns a knowable set into a picker automatically — and a picker cannot emit a typo. This is not cosmetic: a mistyped value silently matches nothing in a rule.
Referential integrity — and which links actually get it
Deleting a record that other records point at through a reference field is refused (409), and the error names the referrers. That is deliberate: silent orphaning is worse than a blocked delete. You can instead set that field’s delete behaviour to null the reference — a clean detach rather than a block.
⚠️ A relationships entry is not the same thing. It records intent and lets rules
test the link, but nothing consults it when a record is deleted — the target goes and
the pointer is left dangling. If losing the parent would corrupt the child, link them
with a reference field.
⚙️ You do not have to remember which is which. On the canvas, an accented solid connector is protected and a faint dashed one is not (there is a legend), and every row in the Relationships panel is badged Delete protected or No delete protection.
⚠️ If you archive an entity something still links to, the link is not hidden. The canvas draws it red and dotted, ending in an ✕, the legend gains a BROKEN row, and the Relationships panel badges that row Target missing — a link pointing at nothing must look worse than a healthy one, never invisible.
⚙️ Deleting an ENTITY follows the same rule as deleting a record. If another entity points at it with a reference field, the delete is refused and names the referrers (“Can’t delete “Customer”: 1 reference field in 1 other entity still point at it — order — customer_ref”) — repoint or remove those fields first. If only relationship entries point at it, the delete is allowed after a confirmation naming what will break, because nothing enforces those and the UI says so.
Pages — the screens
Build ▸ Pages (/engagements/:id/build/pages).
A page is a set of widgets on a grid. A widget is bound to an entity and a query, and renders it: tables, forms, charts, KPI tiles, kanban boards, work queues, timelines, activity feeds, maps, markdown, diagrams.
The authoring loop:
- Add a page; give it a key and a title.
- Drop widgets on it.
- Bind each widget: choose the entity, then the fields.
- Configure the widget in the Property Inspector on the right.
- Add interactions: what a click does — filter other widgets, or write a record. The inspector marks navigate, drilldown and modal Not executed in deployed apps: they do nothing in the client’s app yet. A page button cannot run a rule there, so Run rule is not offered.
- Add it to navigation so users can reach it.
Choosing a field. Wherever a widget asks which field to use — a chart’s axes, a table’s columns, a queue’s assignee, a timeline’s date, a map’s latitude, a feed’s timestamp, a filter button’s field — you pick it from a list of the fields the widget’s data carries, by the names you see on the Schema tab. The list is grouped: the record’s own fields, fields reached through a reference (“Storage unit ▸ Code”), names the query works out (a date bucket, a computed field) and, where a value can be one, measures. A slot that can only use a date or a number lists only those. A filter on a reference field lists the records themselves by name, from the deployed app, and stores the record’s id for you. If a widget names something that is not a field of its data, the picker says so. Type a name… at the foot of each list is there for a name the studio cannot know, such as a column another source supplies.
Showing a defined number. When a widget shows a number your app has defined — a term on Definitions — choose it under Implements term. On a widget that shows nothing yet, that gives it the term’s own measure: you do not rebuild the query. On a widget that already has a query, the pane says how the two stand: the term’s own measure; the term’s measure, refined (narrowed to one region, grouped by day, sorted — this publishes); or a different number, with Use the term’s measure, because a widget showing a different number under the term’s name will not publish. A widget that shows rows — a table, a queue — takes the rows the term measures (its entity and filters) rather than the number, and a list of exactly those rows counts as the term refined: on an argument page it is the evidence a reader checks the figure against. Grouping a count by project leaves it a count. A stat in a stat grid picks its term the same way, and a sentence in a markdown block can take a figure straight from a term (…or add one from a term).
Defaults the app already knows. A new KPI card or trend carries no placeholder caption — its
title names the number; add a caption only if it says something more. A stat picks up its term’s
label when you choose the term. A date bucket or “in the last…” filter starts in the app’s declared
calendar. Grouping a board by a field that declares its values lays out one column per value, in
order. When you add a value to a list field on the Schema tab, its label follows what you type
(personal_care → “Personal care”) until you write one yourself.
⚙️ These are pickers, not text boxes. You choose the field rather than typing its key, so a
label (“Peak °C”) can never be saved where the key (peakC) belongs and drawn as an empty chart.
A row that does something. A table or a work queue can carry actions on each row —
Actions ▸ + Add row action. Open an incident on a delivery table is mutate · create ·
Incident, with Set fields pop = {{row.pop}}, isp = {{row.isp}}, status = open:
a value is written as typed, and {{row.…}} takes it from the row that was clicked ({{now}}
is the moment of the click, {{user.id}} who clicked). The server checks that the person may
write before it does. A new action starts as update this row; change the operation first.
A form. Add a Form, choose what it records, and it lays itself out: every field a person may set when creating that record, in the order the schema declares them, each named as the schema names it, marked required where the schema requires it, and edited as its type says (a date as a date, a status as a list of its values, long text as a text area), and a submit that creates the record. The id, computed fields, hidden and read-only fields are left out, because the server owns them. Mode carries the submit with it — Create creates, Edit updates the record the page has open. Remove or add fields from there.
⚙️ A laid-out form carries every field the record requires. The studio lays out the same set the
app’s own form completes to, and Preview completes a form exactly as the app does — so a form that
submits in Preview submits for the client. ⚙️ A field whose key ends in _at (seen_at) is treated as an audit timestamp and
not laid out; add it by hand if a person sets it.
A filter a reader sets. A Filter Control puts one choice at the top of a page — a country, a market, a status — and applies it to the widgets you tick under Filters these (none ticked: every widget that has the field). Filter on lists the fields of what it filters. A field that declares its values (a market, a status) fills its select by itself; one that does not (a country typed as text) needs its values listed under Options, the search control, or Options come from — another entity whose same field declares them. Tick the widgets, then pick the field — the list follows what you ticked.
A new control filters on nothing until you pick the field: the canvas says Choose the field this filters on, and the app shows nothing in its place. A widget whose data has no such field is left alone rather than emptied, and Filter on names any it will leave alone (“Not filtered — no Country: Licence windows”). Without a Label, the control is named by its field. Preview and Demo filter exactly as the app does, so try it there before you deploy.
⚙️ A page opens in View, and View is the page as last published — not your draft. Until you publish, View has nothing to show and says so, with Open the draft; after that it says when your draft has changes it is not showing. Your work is in Edit, saved as you go.
⚙️ Preview shows the client’s real data only to a seat allowed to read it. Once the app is deployed, Preview asks it for the page’s real figures — reading a client’s live data, which needs the Read a live app’s end-user data capability (administrators, by default), and each read is recorded against your name. A seat without it sees sample figures, and the canvas says so in one line. Edit and Demo work the same for every seat.
Saying what the page means
A page that only shows numbers makes the reader do the work. Three things carry the argument instead, and they are all authored per widget:
A subtitle. One line under the title: the unit, the window, the source, the caveat. “Revenue” can say excludes intercompany, trailing 12 months without turning its title into a sentence. Plain text and deliberately short — a caption that needs formatting is prose and belongs in a markdown widget.
An emphasis level. Three, and no more: leave it blank for an ordinary card, set Takeaway for the point of the page (an accent rule, roomier type, a quieter frame — a conclusion, not an alert), or Quiet for context a reader may want and should not be pulled towards. If everything can be emphasised, nothing is.
Markdown that can hold an argument. The markdown widget renders headings, lists, paragraphs, links, tables, images and > callouts. A comparison can be a table rather than a bulleted list; a site photo or a diagram can sit in the page; a caveat can be an aside instead of costing its own tile.
⚠️ Prose is capped at a readable measure (about 72 characters a line) whatever the width of the card. A full-width card would give a paragraph roughly 250 characters a line, which is about three times what anyone reads comfortably. Tables are exempt and scroll inside themselves — a column set can need more width than a sentence should have.
A page that makes a case — argument pages
Most pages a board reads are arguments: a claim, the reasons it is true, and the evidence under each reason. The studio can hold that shape and lay the page out from it — nobody places a tile.
Open the page panel. Click the canvas background, or Page at the top of the inspector. With nothing selected, the inspector is the page: its claim, its kind, and everything below.
- Claim. The page’s name is its claim — the sentence you would say if you had ten seconds. “Two hauliers bring three quarters of everything we take in”, not “Hauliers”. The menu carries its own short label.
- Kind. Choose An argument for a page that makes a case, A tool for a queue, a form or a log (it keeps its free grid, and nothing is checked). Leave it undeclared and the page publishes exactly as it always has.
- Layout — picked, not dragged. Lead: the lead exhibit across the page, one column per reason under it. Lead and aside: the lead two thirds wide, the reasons stacked beside it. Sequence: the lead, then each reason as a band, read top to bottom.
- Key line. Two to four reasons, in words. Each becomes a heading on the page.
- Lead exhibit. The one exhibit a reader should see first — the evidence for the claim itself.
- Place every other exhibit. Select it; in the inspector, Argument ▸ This exhibit proves, choose the reason. That is what puts it on the page, under its reason.
The canvas of an argument page is the page as the client will see it — the same layout, the same headings — and nothing on it drags. A takeaway sits directly under the claim; filters sit above the lead.
⚠️ An argument page has to be one before it ships. The page panel lists, as you work, what would stop it: no key line or one outside two to four, no lead exhibit, an exhibit that proves nothing, more than five exhibits (figures under one reason count as one), or two takeaways. The publish dialog names every argument page that is not ready, and Publish, capturing a version, Deploy and promotion all refuse one — with the page and the fix. Wording is only pointed out, never refused: a claim that reads like a label (“How Cascade makes money”) gets a note.
⚙️ Sections are not drawn on an argument page — the key line is its chapters.
Chapters, and what the grid does for you now
Sections give a long page chapters. Select a widget, open Section in the inspector, and either pick a section or name a new one — “And now the money”. A section renders as a heading with a rule under it and its own grid beneath. A widget sits in at most one section; leave it blank and it renders above the sections, where it already is. A section can be made collapsible, and a collapsed one still prints expanded, so a chapter cannot vanish from a PDF a client was sent.
⚙️ Three things the grid does without being told:
- Prose sizes its own row. A markdown tile is measured and its row grows or shrinks to fit. You do not author a height for it, and you never re-author one when you edit a sentence. Before this, a prose tile was a guessed number that was wrong in both directions — either dead space under the text or text quietly cut off inside an invisible scrollbar.
- Narrow screens stack. Below about 768px of available width, every tile goes full-width, one per row, in reading order. A four-tile KPI row at 700px was four unreadable 160px tiles. ⚠️ The container is measured, not the window, so a page embedded in a narrow panel stacks for the same reason a phone does.
- Everything else keeps the height you gave it. A chart with no height is 0px and draws nothing, and a table has to be told how tall it is. Only prose is measured.
The inspector is schema-driven
The Property Inspector builds its controls from the widget’s config schema. A field that is an enum in the schema renders as a select; a field that references an entity’s fields renders as a picker over that entity’s real fields. You should almost never be typing a field name by hand — if you are, that is a bug worth reporting, and CI has a guard that fails when a value with a knowable set is rendered as free text.
Measures and calculations
A widget bound to an entity can show the records. Most of the time what a reader wants is a number about the records — revenue by month, win rate by region, margin against last quarter. That is three layers, and they are worth separating because they fail differently.
A measure is a question, not a column. In the binding, a measure is a function over a
field with a name: sum of dealValue, count of anything. The name is the alias, and it is
the identifier everything downstream refers to — an axis, a calculation, a KPI. If you do
not give one, the alias is the form you never typed: sum(dealValue). Either way it is a real
name, and the deploy readiness check treats it as one.
A measure can carry its own condition. This is the difference between a report you can
build and a report your client maintains by hand forever. A win rate is two counts over
the same rows — one filtered to won deals, one unfiltered — and a division. Without a
per-measure condition the only way to express it is a 1/0 column somebody has to keep
correct on every record, in perpetuity. Set the condition on the measure itself, in the
inspector, next to the measure it belongs to.
Calculations run over the measures. Once the measures exist, a calculation is an expression over their aliases — the same expression language a computed field uses, on purpose, because a second language would be a second thing to learn and a second thing to drift. Eight prefixes are reserved, and they are how growth, share, rank and trailing totals are expressed. You do not have to remember them: the insert menu on each calculation offers every one in words (“the previous period”, “the last 3 periods, summed”).
| Write | And you get |
|---|---|
prev_revenue | the previous position’s value — this is how growth is written |
cum_revenue | the running total up to this position |
total_revenue | the total across every position — the denominator of “share of total” |
rank_revenue | 1-based rank by value, descending |
trail3_revenue | this period and the 2 before it, summed — a trailing quarter on a monthly chart |
trail6_revenue · trail12_revenue · trail24_revenue | … the last 6, 12 or 24 periods — the last year of months, the last day of hours |
⚙️ A trailing window counts calendar periods, not bars. A month with no records is simply not
on the axis, so “the two bars before” can reach back four months while every number still looks
right. A trailing window reads the axis as months (or weeks, days, hours…) and counts those: a
month with no records adds nothing and still counts. Trailing churn is
trail3_cancels / trail3_opening. The first two months of a trailing-3 series are gaps — there
is no three-month total yet, and a partial one would read as a real, small number. A trailing
window is refused on a chart that is not grouped by one date bucket, and on a two-dimension table;
the calculation row says so before you preview.
How a calculation reads. Give it a unit — percent or currency — and a chart whose plotted series are all calculations of that unit puts the unit on its axis (a churn rate reads 4%, not 0.04). A small rate keeps its digits: 0.63% reads 0.63%, not 1%; 3.75% reads 3.8%; whole percents from 10. A sum over a month that has records but none with a value is 0, drawn as 0; a month with no records at all is still a gap. In Visual ▸ Series, name the series to draw; a colour pinned to a series is the colour it is drawn in (Q1 neutral, Q2 accent), whatever order the series come in.
Months since joining — a cohort’s own axis. A computed field can subtract dates:
monthsBetween(cohortMonth, period) gives whole calendar months (2026-01-01 → 2026-02-01 is 1;
2026-01-31 → 2026-02-28 is 0), and daysBetween(a, b) whole days. Group a retention curve by it.
Both read the date as written, so a date field needs no timezone; anything that is not a date gives
nothing rather than a guess.
⚙️ A computed field is the kind of value its expression makes — arithmetic a number, text
joined with + or concat a text, a comparison a yes/no, monthsBetween a number — so it is
filtered, sorted and totalled like a field of that kind. Rows that do not reconcile is
gap = opening + gross adds + reactivations − cancels − closing and the filter gap ≠ 0,
which compares numbers.
A calculation may reference another calculation. They are resolved in dependency order, so you can build a number out of numbers you already built; a circular reference is refused and the message names the cycle rather than leaving you to find it.
⚠️ An alias may not start with one of those prefixes. A measure named trail3_cancels would be
both a series and the trailing window of cancels, and which one a formula got would depend on
the order things were read. A calculation over such a measure is refused, naming it — rename the
measure.
⚠️ A unit is presentational and never transforms the value. Marking a calculation as a
percent changes how it is read, not what it is. A margin written as a fraction stays a
fraction — if you want percentage points, write * 100 in the expression. An automatic
×100 would make the same expression disagree with itself between a chart and a KPI tile.
⚙️ Dates need a bucket, and a bucket needs a timezone. Grouping a chart by a raw timestamp gives one bucket per record — a flat line that is drawing exactly what it was asked to. Group by a date bucket (day, week, month, quarter, year) instead. The bucket belongs to a timezone, because an invoice at 23:30 on the 31st is next month in London and this month in New York, and there is no default — whoever builds the chart chooses. The caption under the chart says which zone the totals were computed in, and how many records were left off for having no date at all.
⚙️ “In the last…” is a filter, and it is read when the page is. On a date field, the first operator the filter offers is in the last…: how many, which period, rolling, ending now or whole periods, before this one, and whose calendar. The last 48 hours is 48 · hours · rolling; last quarter is 1 · quarters · whole periods — 1 April to 30 June on any day in July. It is worked out at the moment the page is read, so an operations page never shows a fixed date that has gone stale and “last quarter” means last quarter on the day the board reads it. Like a bucket, it has a timezone and no default: a whole month in UTC is not your client’s month. It works in a seat’s row filter too — “incidents in the last 30 days”.
In the next… is the same range looking forward, second in the list: licences ending in the next 180 days is 180 · days · rolling, starting now; next quarter is 1 · quarters · whole periods, after this one — the whole of the quarter after this one, not what is left of this one. A date already past is never “in the next”. A renewal queue built on it does not need retyping every morning.
On a field that declares its values — a market, a status — in (any of) is a set of ticks, one per value, not a box for typed, comma-separated text.
⚙️ On a pivot, a calculation needs to know which way it runs. A window like cum_ or
total_ accumulates along an axis, and a matrix has two — so once a calculation exists AND
the pivot has at least one column dimension, a third control appears on the calculation
row, between the unit and the insert menu: across the columns or down the rows. It is
not offered before then, because a single-dimension result has only one axis and a choice
that does not exist is worse than no choice at all.
⚠️ The axis is not a formatting preference — it changes the question. A retention curve is
cum_retained / total_retained accumulated across the period columns within each cohort row.
Run the same expression down the rows and you get “share of this period across all cohorts” —
a different question, with numbers just as plausible. That is why it is never defaulted
silently.
Two entities on one chart — “Another source”. Cost per hour viewed is a licence’s fee over
the hours its title was watched, and those live in two entities fed at two cadences. Below the
measures, Another source adds a second entity: choose it, the field it is matched on
(title against title; on a date axis, one of its dates, which takes the chart’s bucket and
calendar — a month in two calendars would be two months with one label), its measures, and, if
you need them, its own filters. Each source is totalled on its own and set beside the chart’s on
the axis, so nothing is counted twice — which is exactly what a join would do to a fee
repeated on every month of viewing. Its measures appear in Names you can use, so
fee / hours is a calculation like any other. A source that is not finished says what it still
needs and is left out of the chart until it has it; if you change the chart’s bucket afterwards,
the source says its labels have stopped lining up and offers Match the chart. Two sources may not
produce the same name — rename one.
⚙️ Whose labels make the axis. By default, every label any source has: on a month axis that is right — a month with spend and no customers is a real month. On a category axis the chart’s own rows are usually the population, and the other source knows more names than that: the hours behind cost per hour of our licences include every original the service made, each with hours and no licence. Tick Only this chart’s own … values and the other sources give values for the chart’s labels and add none of their own. (Not offered on a date axis.)
Ranking. In Group by, by a measure ranks the axis by any name the chart has — its own measures, another source’s, or a calculation — highest first or lowest first, and Top N keeps the first N of that ranking: cost per hour, lowest first. A long ranking reads best as horizontal bars (Visual ▸ Style ▸ Orientation): every bar is named that has room for its name.
Diagrams — a picture that carries live numbers
A diagram widget is boxes on a grid joined by arrows, and each box or arrow can carry a number from the same binding every other widget uses. That last part is the point. A process map drawn in a slide deck is a picture of how the work was supposed to go the week somebody drew it; a diagram here is bound to the data, so it is a picture of how the work is going now — and it is scoped to whoever is looking, because the number arrives through the same permission checks a table or a KPI goes through.
Two things are deliberate:
- You say what a step MEANS, not what colour it is. A box is a
stage,start,end,decision,bottleneckorexternal; an arrow isflow,rework,exceptionorhandoff. The theme decides how each one looks, so changing the app’s appearance restyles every diagram and no two diagrams drift into private palettes. There is no colour to pick. - Positions are yours. Boxes sit where you put them, in whole grid cells. Nothing auto-arranges them, because where a step sits on a process map is usually something a person meant.
What it refuses, and why. An arrow pointing at a step that does not exist takes the whole widget down rather than drawing the picture without it — a missing arrow between two boxes reads as “these steps are not connected”, which is a claim, not a gap. Two boxes placed in the same cell are refused for the same reason: one would sit on top of the other and the covered step would never be seen. Both messages name the ids so it is a typo to fix.
A box or an arrow can also claim a term from the definitions layer, and the publish gate holds it to that claim like any other measure — see Behavior. A number on an arrow is a number somebody will quote in a meeting.
⚠️ First release is JSON-authored. You can drop a diagram from the palette and it works, but adding steps and arrows means editing the widget’s config, not dragging boxes. A visual editor is the obvious next thing; what it needs is being learned from real diagrams first.
Navigation
Build ▸ Navigation (/engagements/:id/build/navigation) defines the app’s menu: the entries,
their order and nesting, which page each one opens, and which roles can see it. A nav entry
pointing at a page that does not exist is caught by validation before deploy — dead nav is not
shippable.
Each entry can carry an icon, chosen from a picker of the runtime’s own icons — the same component that draws the client’s rail, so what you pick is exactly what they see.
⚠️ Edit the app’s menu from inside the app. The flat /navigation route runs outside app scope
and edits the FIRM’s own navigation, not the client’s — if a menu edit seems to have gone nowhere,
check which of the two you were on.
Appearance — what it looks like
Build ▸ Appearance (/engagements/:id/build/appearance).
Set the client’s palette, typography and logo. This is one derivation: the same theme drives the app shell, the content surfaces, and the login screen the client’s staff see before they authenticate. If the login looks off-brand relative to the app, that is a bug, not a setting you missed.
Roles and permissions — who may do what
Two surfaces, in this order:
- Build ▸ Permissions ▸ + New role (
/engagements/:id/build/permissions) — define the roles that exist in the delivered app (ops_manager,account_manager,viewer…). These are the client’s roles, not your firm’s operator roles. (There is no separate Roles item in Build;…/build/rolesopens the firm’s own roles.) - Build ▸ Permissions (the same screen) — the matrix: which role may read/create/update/delete which entity, and which pages they see.
Why permissions come last
Permissions are granted per entity, per role, and page visibility is granted per page. Both of those are references to things that must already exist and must already be named what they will finally be called.
Author them first and every entity rename, every entity you split in two, every page you merge invalidates a grant you already made — silently, because a grant pointing at a renamed entity does not error, it simply stops matching. You find out when a role sees nothing.
⚙️ The order that works — schema → pages → roles → permissions — is not a style preference. Each step references the one before it, so doing them in that order means each reference is made once against something that has settled.
⚖️ Judgement, not mechanism. Nothing in the platform enforces this ordering, and no check can verify you followed it. The consequences named above are real — a grant against a renamed entity really does stop matching — but the recommendation is experience.
Two rules worth internalising
-
Deny on unset. A permission that has not been granted is denied. There is no implicit allow. An empty matrix means a role can do nothing — which is the safe failure, but it does mean a new role needs explicit grants before it is useful.
-
Row-level access is available via user attributes: a permission can be scoped so a user only sees rows matching an attribute on their account (their market, their team). Two places, in this order:
- The scope — on the entity’s page, under Permissions, the role’s
Row scope ▸ + condition: the field (
market),=, user attribute…, and the attribute’s name (market). One per entity the role reads. - The value — Build ▸ Permissions ▸ Users, open the person: the
attributes their roles are scoped by are listed by name; type the value
(
DACH) and Save attributes.
⚙️ Adding the person asks for no password. + New user takes an email, a name and a role; the password is generated and shown once, right there, when you create them — save it then, because the studio keeps no copy. If it is lost, an administrator resets it under Build ▸ Deploy ▸ Sign-ins.
A seat without the attribute sees none of those rows — it fails closed, it never widens. The Users tab marks such a seat: no market — sees no Licence, Viewing, …. Set in Build, an attribute ships with the deploy; changed later in the app’s own Users page, it stays changed.
- The scope — on the entity’s page, under Permissions, the role’s
Row scope ▸ + condition: the field (
-
A page is seen by what the seat is, too. Row scope keeps the DACH rows from a Brazil seat — and a page’s own words can still name them: a claim, a caption, a headline written about DACH. So a page carries the same kind of condition. In the page editor, select the page (click empty canvas) and, under Visible to seats whose…, tick the values — market is DACH. The attributes offered are the ones your row scopes already use, with the values the field allows. A page may also be limited by role, in the same panel — Visible to roles lists this app’s own roles.
⚙️ The limit follows the rows. It binds the seats whose rows the attribute scopes: a regional manager scoped by market sees the DACH page only if their market is DACH, and none of the per-market pages with no market set (it fails closed, as their rows do). A seat whose role reads every market’s rows — an executive — sees every market’s page: it can already read everything the page says.
⚙️ One rule, read at every door a page’s words can leave by: the page list, the page itself, its PDF export, the navigation (a seat is served only the pages it may open), earlier versions, and a saved draft. To any other seat the page does not exist — it is not refused, it is absent; opened from a link, the app says “This page isn’t available”. The Publish dialog says who will see the page (“Regional manager — where scoped by market, only BR”), and Build ▸ Permissions ▸ Test as user lists, for each seat, exactly the pages the app will give it. You and the app’s administrator see every page.
⚙️ Every role list here is this app’s own. The page’s Visible to roles, a widget’s permissions, approvers, rule recipients and behaviour conditions all read the roles the app actually has — and say so while loading, or if the list cannot be loaded. Ticking a role an app does not have is how a page ends up visible to nobody but the administrator.
How the app actually decides what a member sees
Every read of an entity runs this. It is worth knowing in full, because the first branch is the one that wastes days:
flowchart TD
Q["A user opens a screen<br/>bound to an entity"] --> ADMIN{"Does their role hold<br/><code>*</code> or <code>records.admin</code>?"}
ADMIN -->|YES| BYPASS["<b>FULL create · read · update · delete</b><br/>the permission matrix is NEVER consulted"]
ADMIN -->|no| MATCH["Collect the entity's permissions<br/>whose role the user actually holds"]
MATCH --> ANY{"Any matched?"}
ANY -->|none| DENY["<b>DENIED</b> — deny on unset.<br/>The widget reads<br/><i>'You don't have access to this data'</i>"]
ANY -->|one or more| UNION["<b>Actions</b> = the UNION of every matched permission"]
UNION --> ROWS["<b>Rows</b> — row filters from the matched<br/>permissions narrow which records"]
ROWS --> FIELDS["<b>Fields</b> — a field is hidden only if EVERY<br/>matched permission restricts it;<br/>the most permissive wins"]
FIELDS --> SHOW["What they see"]
BYPASS --> SHOW
⚠️ An admin never touches the matrix. The first branch short-circuits before entity
permissions are read at all — so walking your app as an admin proves nothing about what your
client’s staff will see. It is not a shortcut that usually agrees; it is a different code
path. Sign in as a real member or you have tested nothing.
⚙️ Union, not intersection, for actions — but effectively intersection for fields. Two
roles granting read and update give you both. A field, though, is only hidden when every
matched permission hides it: one permissive grant re-exposes it. Adding a role can therefore
only ever widen what someone sees, never narrow it.
Test roles honestly: the delivered app is where the permission actually applies, so verify by signing in as a user with that role, not by reading the matrix.
What row scope cannot say
Know these before you promise a client an access design.
- An attribute holds one value. A person responsible for several projects cannot be scoped
by
projectCode in {{user.projects}}: the Users API refuses a list (attributesareRecord<string, string>), and a comma-separated string matches nothing, becauseintreats a single string as a one-item list. What works: name the person on the record itself (projectManager, copied onto every child record) and scope byprojectManager = {{user.email}}. The cost is a copied field, and a data change whenever the person is reassigned. - A scope reads the record’s own fields, never a related record’s. “The service users on my rota” (the ones some visit assigned to me points at) cannot be written. Copying the person onto the record does not help either when many people share it. A care worker then sees every service user their role can read. Hide what you can by field, and tell the client.
App Settings
Engagement ▸ App Settings (/engagements/:id/app-settings) holds the delivered
app’s own settings — its name, its identity, and the toggles the client’s admins get.
What “done” looks like for Build
Before you move to deploy, you should be able to answer yes to all of these:
- Every entity a page binds to still exists, with the fields the page uses.
- Every nav entry points at a real page.
- Every role that will exist has explicit permissions (remember: deny on unset).
- The Appearance is the client’s, and the login screen matches it.
- Nothing in the inspector required you to hand-type a reference.
The platform checks most of this for you at publish time and refuses to ship a configuration with dangling references — see 05 — Deploy and run.