Skip to content

Pages & components

The presentation layer is metadata, not code. A page template declares named regions; a page places fields and components into those regions and binds to one object; a record page additionally renders that object’s page layout. None of it is compiled into the engine. The component library that supplies the primitives arrives in a package, the drag-and-drop builder that assembles pages is itself a surface delivered by the platform administration package, and the engine’s only job is to interpret the template / page / component metadata and render the result inside the shell’s content region — resolving every field and record it reads through the access engine. This is the same engine/UI separation that governs everything else on the platform (How the platform works); this page is where it lands on the record surface.

There are two authored constructs. A component is a self-contained web-component bundle plus a manifest — one entry with a key, label, type, and body — where the manifest declares what the component is, where it may be placed, and which inputs a page author may configure. A page is a declared tree of regions and sections containing field and component instances, bound to exactly one object. There is one component model — no legacy-vs-modern framework split — and a single artifact, the page manifest, owns the whole record surface: fields, sections, related lists, actions, and required/read-only behavior. Neither construct is business logic; both belong to the presentation layer. The predicates they carry (component and section visibility rules) are pure — deterministic, read-time-evaluated, statically analyzable — and reuse the same pure expression subset documented for formula fields.

Four facts about where the presentation layer comes from are worth stating before the model, because they are what changed when the engine and the interface were separated:

  • The component library is package-delivered. The primitives a page places — the field renderer, the related-list renderer, custom bundles like a price-override panel — are metadata plus web-component bundles that arrive in a package and register with the engine. They are not features of the kernel; an org with no UI package installed has no component library. Adding a new component is deploying metadata, not shipping an engine release.
  • A component reads the design system’s tokens. A component never bakes in colors, spacing, or type. It styles against a fixed vocabulary of design-token custom properties that the design system defines and the shell distributes at the root. Flipping the theme or swapping the brand is a token change; a conformant component repaints with no edit, because it reads the seam rather than embedding a palette.
  • The page builder is a surface, not an engine feature. The drag-and-drop editor that assembles fields and components into regions and writes the page manifest is delivered by the platform administration package — the same package that hosts Object Manager and every other administration screen. It edits metadata the engine interprets; it is not a built-in capability of the engine, and it can be replaced without touching the kernel. Setup is where the builder is reached, not what owns it: under the ownership rule a page belongs to the package that defines what it administers, and the builder administers page, pageTemplate and layout, all kernel-defined (CAOS-447, CAOS-460).

What the kernel does with all of this is narrow and fixed: it interprets the resolved route’s app → tab → object → page → template → components, renders that interpretation inside the shell’s content region, and passes every read along the way through the access engine. It holds no opinion about how a page looks — it only executes the metadata it was handed. The full resolution order lives in the kernel; the content region and the rest of the frame are defined by the shell contract.

Never ship a component whose purpose is embedding a foreign runtime, an arbitrary page, or an iframe pointed wherever the author likes. It is not a component; it is a hole in the shape of one.

The reason follows from the rule above about the manifest being the seam. Everything the platform knows about a component it knows because the component declared it — what it needs, where it may be placed, whether it requires a record, what it touches. An embed answers none of those, and it answers none of them by construction rather than by omission: the whole point of one is that the platform does not know what is inside. The incumbent ships such a piece and is right to, because they have a decade of previous-generation pages that still work and cannot be rewritten. We have no previous generation, so the same piece here would only be a way to put arbitrary content on a page with a height and nothing else declared.

If you want one, you have a real surface the component model cannot express, and that is the ticket: raise it against the component model, naming the surface that could not be expressed and what it needed to say. An embed is worth refusing precisely because it would work — and would then permanently remove the pressure that would otherwise have made the model able to express the thing properly.

This does not cover an authored rich-text block, a spacer with a declared size, or anything else carrying a bounded declared contract. The line is not markup versus no markup; it is whether the contract is one the platform can reason about, or one that is by construction unknowable. Where an author has no control over geometry, a spacer with a size parameter is the correct tool rather than a workaround, and every question this model asks about one has an answer.

If an embed is ever adopted it is a deliberate foundation decision with its cost written down first — what stops being knowable about a page, and what the platform gives up being able to check — never a library addition.

One such decision has been taken, for one shape only: a page embed, which places another deployed page in a section. It is not the piece refused above and it is not a component at all. Everything the rule above says is unknowable about a foreign-runtime embed stays knowable here, because what is placed is metadata this platform already owns — the reference resolves at deploy, a dangling one is refused, a loop is refused, and every question you can ask of a page you can still ask of the page inside it. Its cost is written down where it belongs, under the model.

A page is metadata: a template plus a declared tree of regions and sections, each holding field instances, component instances and — where one application shows another’s screen — page embeds, bound to one object. Assembly is data, not code — the interpreter reads the manifest and renders it under a strict CSP (see Semantics & evaluation).

Five things live in the model:

Construct What it is Where its value lives Bound to
Page template A named set of regions plus the geometry that arranges them, authored and installed like any other metadata entry A metadata row; nothing about it is compiled into the engine Nothing — a page picks it
Component A web-component bundle + a manifest entry (key/label/type/body), delivered by a package The bundle is a deployed artifact; the manifest entry is a metadata row Nothing until placed
Component instance A component dropped onto a page, with per-instance input values Inline on the page manifest (an inputs map on the instance) The page, and through it the record in context
Field instance A field of the bound object placed directly in a section The field’s own column on the object; the instance carries only placement + display behavior The bound object’s field catalog
Page embed Another deployed page placed in a section, so one application can show another’s screen Inline on the page manifest (a page reference on the item); the page it names owns everything else The page it names — resolved at deploy, never at render

One application often needs to show another’s screen. The developer tools carry the logger’s screen so a developer reads their logs without leaving the tools they are working in; the same shape turns up wherever two applications are about the same work. A page embed places one deployed page in another page’s section:

{ "id": "logs", "type": "embed", "page": "logger_home" }

That is the whole of it. The item names a page and says nothing else about it — no inputs, no filters, no configuration. The embedded page brings its own template, its own sections and its own data source, exactly as it does when somebody opens it directly. A placement that could configure the page it embeds would be a second description of what that page shows, and the two would stop agreeing; the same reasoning keeps a placed report unconfigurable.

What you give up, and it is deliberately little. A page embed is the adoption the embed refusal holds to a written-down cost, so here is the cost. You lose the ability to read one page manifest and know everything that will be on the screen — you now have to follow one reference to a second manifest. That is the entire loss, and it is the same loss a placed component already carries. What you keep is everything the refused kind of embed destroys: the platform can still enumerate what is on the screen, still resolve every field through the access engine, still tell you which packages a screen depends on, and still refuse a change that would break it.

Three rules make that true, and all three are enforced at deploy rather than discovered at render:

  • The page must exist. The reference resolves like every other, so a page embedding one the org does not have is refused when it is deployed. Deleting a page another page embeds is refused for the same reason, from the other direction.
  • The loop is refused. A page cannot embed itself, directly or around a chain, and the refusal names the loop it found. This is the one rule the model did not need before: every other reference a page carries points at something that is not a page, so none of them could close a circle.
  • It is not a way to see more. Access on this platform is resolved when data is read, for whoever is looking. An embedded page’s reads are made as the viewer with the viewer’s own permissions, identically to the same page opened directly, and the item carries nowhere to name a principal or an elevation. Embedding a page shows it somewhere else; it never widens it.

One page may not be embedded at all: an app landing. An app landing is not built from what its page places — it is derived from the application the address is in, which is what stops one application’s landing from reporting another’s work. An embedded page has no address of its own, so an embedded app landing would either draw nothing or report the host application’s work under the embedded application’s name. The second is the dangerous one — a wrong number on a screen with nothing saying so — and it is refused at deploy. Link to the application instead; an app landing is reached by opening the application.

An embedded page is told that it is embedded, so it draws its body and leaves the screen’s heading to the page around it. Nothing on the item names an application — it names a page — which is what makes this one seam rather than a growing list of pairings.

A page never starts from a blank canvas — it starts from a page template, a metadata type in the object model whose whole job is to declare a set of named regions (header, main, sidebar, and so on; the open single-container template is the simplest one). A page picks a template and places its sections and instances into those regions. The template supplies the region skeleton; the page supplies the contents.

What a template declares. Verified 2026-08-20 — live. A template is one metadata entry like any other, deployable from a project, installable, and shippable in a package: a key and label, the surface kind it serves, its regions — each with a stable key, a display label, and how many columns the region lays its own contents out in — and, optionally, its geometry: the grid that fixes which region sits beside which, the column relationship that holds those proportions as the browser resizes, and the order the regions stack in on a narrow screen. A template that states no geometry has none, and its regions stack in the order they are declared; that is different from stating an empty one. It declares shape and nothing else: no content, no record data, no placements, no styling.

Where the standard ones come from. A page cannot draw itself without the blueprint it names, so an organisation with none has no page that can render. The standard set — seven record shapes, a list and a home surface — is therefore seeded with the organisation itself, alongside its base theme, rather than delivered by any one package: nothing every other package’s surfaces depend on should be owned by one of them. A package or a customer authors additional blueprints the same way it authors anything else, which is what the pageTemplates folder in a project is for. Organisations that predate the type were given the same set automatically, at the version they were already running, and one they had authored themselves under a standard key was never overwritten.

A surface draws through the organisation’s own templates. Rendering resolves the named template against the organisation’s metadata first and the standard set second, so an authored template is drawn as authored. Only a name nothing has anywhere falls back to the single plain region, and the fallback records the miss against the trace — it is the guarantee for a template that changed underneath a live surface, not the thing standing between a page and arbitrary geometry.

Because it is a real entry, everything that points at it is checked before anything is written. Deploying a page whose template no organisation has is refused and names the missing template. Deploying a page whose section sits in a region the template does not declare is refused and names both the region and the template. A template whose grid places a region it never declares — or leaves a declared region nowhere on the grid, or declares the same region twice, or lays out uneven rows, or places one region in pieces rather than as a single rectangle (a browser throws an arrangement like that away whole, and the page would draw with none), or names a different number of column tracks than the grid has columns (a named grid line is not a column), or pins a column to a width rather than a fraction, whether in pixels or in a unit such as rem, ch or vw — is refused before it can accept a placement it could never draw. Revising a live template to drop a region that pages are already placed in is refused too, naming the region and the page it would strand. So is changing a live template’s kind in a way a page already starting from it could not have been deployed with: into a list template, or into a kind other than the one that page declares. A revision that keeps the kind is not a reason to re-check those pages. And a page may start from any template except a list one, which is the single surface kind a page can never serve, because a collection is assembled from a list view instead. Region keys are held to the same discipline: a key names what the region holds, so region1, region_1, col2, col_2, r1_c2, top, top_left and bottom_right are all rejected outright, while a key that merely begins with one of those words — topics — is fine.

The names matter because other artifacts address regions by name, never by pixel. This is the same discipline the shell uses for its own frame — global-header, primary-nav, record-tabs, and the content region a page renders inside are all named regions (the shell contract). A template’s regions are the in-page continuation of that vocabulary: a differently-shaped template can move a region without breaking anything that targeted it, because targeting is by name.

A record page does one thing more than a home or app page: it renders the bound object’s page layout. The page layout is its own metadata type — the field-and-section arrangement an object’s record type selects — and the record page composes it alongside the components placed in the template’s regions. Where a home page is templated regions plus components and nothing else, a record page is templated regions plus components plus the object’s layout, all resolved together.

Two things arrange a page, and they answer different questions.

  • A template’s regions decide where a section goes. The template fixes which region sits beside which, and the order they stack in on a narrow screen. Each section names the region it is placed in. A region’s columns says only how many columns that region lays its own contents out in.
  • A layout decides how the pieces inside one section sit. layout is a component, placed in a section like any other piece. It holds one layout_item per column, and each item holds what goes in that column.

A layout never holds a section and never stands in for a region. No template or region property arranges the pieces inside a section. So “where does this section go” has one answer, the template’s regions, and “how is this section arranged” has another, the layout placed in it.

What an author declares with the pair:

  • Proportional widths. A layout_item’s size is a whole number of twelfths of the row, from 1 to 12. An item with no size is as wide as what it holds.
  • Narrow-width behaviour. small_size, medium_size and large_size take over at and above the small (520px), medium (720px) and large (1200px) breakpoints. A size of 12 with a medium_size of 6 is full width on a phone and half the row from 720px. Items that no longer fit on a line wrap onto the next one, which is how columns side by side become a stack. No input takes a pixel width.
  • Alignment and distribution. On the layout, horizontal_align says how the items share the space left along the row: start, center, end, space (equal room around each) or spread (the first and last reach the edges). vertical_align says where items of different heights sit across it: start, center, end or stretch. On an item, flexibility says whether it grows or shrinks to share the row, and alignment_bump pushes it away from one edge.
  • Nesting. A layout_item can hold another layout, at any depth. That is how one column is split into two stacked halves.

Reading order and tab order follow the order the pieces are placed in, at every width. A size changes an item’s width, never its place in that order.

A section with a wide column beside a narrow one that holds two stacked halves. On a phone, the two columns stack:

{
"id": "summary",
"section": "Summary",
"region": "main",
"columns": 1,
"items": [
{
"id": "summary_layout",
"type": "component",
"key": "layout",
"inputs": { "vertical_align": "stretch" },
"children": [
{
"id": "main_column",
"type": "component",
"key": "layout_item",
"inputs": { "size": 12, "medium_size": 8 },
"children": [
{ "id": "main_note", "type": "component", "key": "tag", "children": ["Main column"] }
]
},
{
"id": "side_column",
"type": "component",
"key": "layout_item",
"inputs": { "size": 12, "medium_size": 4 },
"children": [
{
"id": "side_layout",
"type": "component",
"key": "layout",
"children": [
{
"id": "upper_half",
"type": "component",
"key": "layout_item",
"inputs": { "size": 12 },
"children": [
{ "id": "upper_note", "type": "component", "key": "tag", "children": ["Upper half"] }
]
},
{
"id": "lower_half",
"type": "component",
"key": "layout_item",
"inputs": { "size": 12 },
"children": [
{ "id": "lower_note", "type": "component", "key": "tag", "children": ["Lower half"] }
]
}
]
}
]
}
]
}
]
}

Each layout and layout_item is a placed piece, so the rules under What a placed piece holds apply at every depth: every piece has to be installed, exposed, and placeable on the page’s surface. The pair may be placed on a record page, an app’s home page and a page inside an app. Inputs are named as the component’s manifest declares them, so medium_size is accepted and mediumSize is refused, and a size written as text rather than a number is refused.

Object binding. A record page names its object once, and the binding is immutable — re-targeting a page to a different object means building a new page, not re-pointing the old one. Every field instance resolves against that object’s field catalog; every component instance receives the in-context record id and object type. Immutability matches the incumbent’s sobjectType (fixed after creation) and is the safer contract: a page’s every field instance, visibility rule, and prop is written against one field catalog, so a silent rebind would strand each of them against a catalog that no longer exists. A page is cheap to create; a rebind that half-resolves is not.

Single-owner surface. Unlike the incumbent’s split (a Lightning page and a page layout co-own the record — see How Salesforce does it), the page manifest is the sole owner of the record surface. Fields, sections, related lists, actions, and required/read-only enforcement are all declared in the one manifest. The object’s page layout is composed by the record page rather than co-owning it: there is no second surface-definition artifact to keep in sync.

Tier. Pages and components are presentation, not the pure/effectful logic tiers. The one place the tier system touches them is the visibility rule: a boolean predicate over record fields, the current user, permissions, and form factor. That predicate is pure — no writes, no side effects — and is evaluated by the interpreter at read/render time. The compiler enforces the pure subset here exactly as it does in a formula body.

Every instance in the tree — a section, a field instance, a component instance — carries an id, and the pair of a page key and an instance id is an address: invoice.recordPage.pricing#price_override_panel. The address is what any other artifact holds when it needs to name a place on a surface rather than a surface: a deep link that opens a record scrolled to one panel, a saved view that restores a collapsed panel, an impact query asking which stored references break if a component is removed. In-product guidance is the notable thing that does NOT hold one — it binds to an anchor ROLE instead, for reasons set out under Guidance — so a step survives the interface changing and not merely the page being edited.

The id is derived by default and author-owned when it matters. Dropping something in the builder derives an id from the thing itself — sell_price for a field instance of invoice.sell_price, price_override_panel for that component, pricing for a section named Pricing — suffixed _2, _3 on collision within the page. An author who intends to reference an instance renames the id to something they will recognize in a link or an impact report six months later. Nothing else ever changes it: the builder assigns once, at drop time, and never reassigns.

Position is not part of the address. An id survives reordering within a section, moving to a different section or region, re-parenting under a different template, and any change to its input values or its visibility rule. The alternative — addressing by path, region[1].section[2].item[0] — costs nothing to implement and breaks on the first drag, which makes it useless to precisely the artifacts that need addresses. An id that only holds while nobody edits the page is not an identity.

Uniqueness is scoped to the page. The same component placed on twenty pages has twenty addresses, so a reference naming one of them is unambiguous about which surface it is talking about. Cloning a page mints a new page key, so the clone’s addresses are distinct from the original’s without anything inside the copy being renamed.

Changing or removing an id is a deploy, and the deploy proves who cared. A rename shows in the diff as a rename rather than a delete-plus-add — the instance’s placement and input values are unchanged — and the diff lists every artifact in the repository that referenced the old address. Deploy validation resolves every address held in the repository, and a dangling one is a deploy-class error naming both the referencing artifact and the address it could not resolve. That check is what makes an address safe for another artifact to store.

An address that arrives from outside the repository can still dangle, because a bookmark, a link in a support ticket, or a step in a package installed before the page was edited are not things a deploy can see. The run-time rule for those is that the page renders and the consumer degrades: the surface itself is valid, so it is never an error page. A deep link with an unresolvable fragment opens the record with nothing highlighted; a walkthrough step whose target does not resolve says so and offers to skip, rather than anchoring a coach card to empty space. An instance that resolves but is not rendered — hidden by its visibility rule, or filtered out because the user cannot read the field — is reported to the user identically, with nothing highlighted and no explanation of what is missing, because distinguishing the two on screen would answer a question about a field the user was not shown. The distinction is drawn on the execution trace instead, where each unresolved address is recorded with the page key, the address, and the correlation id.

Two authoring surfaces exist, and both are metadata editors, not engine features. The manifest is a component’s declaration of itself and its eligibility, authored in source. The page builder is the drag-and-drop surface that assembles field and component instances into regions and writes the page manifest — and it is a screen delivered by the platform administration package, not a built-in of the engine. Everything the builder does could be done by editing the page manifest directly through the metadata deploy pipeline; the builder is the ergonomic on-ramp, not a privileged path.

Every component declares itself with four fields plus an eligibility block. The eligibility block is the analogue of the incumbent’s isExposed + targets + targetConfigs — it tells the builder where the component may be dropped, against which object types, and which inputs are author-editable.

Manifest field Meaning
key Stable, namespaced identity of the component (content-addressed to its bundle)
label Human name shown in the builder palette
type The construct kind — component (custom bundle) or a built-in kind (field, relatedList, action)
body.placements Where the component may be dropped: recordPage, homePage, appPage, recordAction, utilityBar, screen, seam. (recordAction, utilityBar and screen name surfaces the platform has not built yet; they are declarable so the enum does not have to be widened on a live metatype later.)
body.objects Optional object-type restriction (omitted = any object); the analogue of a per-target object filter
body.formFactors desktop, tablet, phone — the form factors the component supports
body.inputs Author-editable inputs: each a name, label, type, required, default, source, and optionally deprecated; these become builder-editable fields on every instance. Absent means NOT DECLARED — which is a different answer from an empty list, and a component may not be exposed while it is absent
body.exposed Whether the component appears in the palette at all (an unexposed component is deployable but not placeable)

A component that omits placements is not placeable anywhere — eligibility is opt-in, matching the incumbent’s isExposed=false default. placements and inputs were called surfaces and props in earlier material; they are the same design under clearer names (surface already meant a rendered screen elsewhere in the platform, and a placed instance’s value map was called props on the consuming side while nothing was called props on the declaring side).

The contract is immutable, and a major version does not change that

Section titled “The contract is immutable, and a major version does not change that”

Decided 2026-08-19, researched against the incumbent’s own documentation — full record in the doc system of record. The incumbent does not version an individual component at all: its component bundle carries no version field, and the field that looks like one pins the surrounding framework release, not the component. This platform reaches the same place by a different, independent route — a package’s dependency range resolves to exactly one installed version across an org, so two shapes of the same component can never coexist in one org either — and states the same conclusion as policy: key (component identity) and body.tag (its element identity) are never versioned, and the declared inputs are the contract. Both are immutable the moment anything can place the component.

A publisher who needs to ship a genuinely different contract does not get a version bump that redefines the existing one — there is no such operation. The supported route is a new component, declared as what the old one became via supersedes (lineage, forever — both keep resolving indefinitely) or a componentMigration (a real retirement, with a stated window during which both resolve while every placement moves off the old one). Publishing over an existing key with a changed contract is refused at deploy, unconditionally, on every one of the platform’s write paths (the CLI, the API, and the eventual builder) — the same validator, not three different implementations of the same intent. Refused, specifically:

  • removing an input,
  • renaming the component (its tag),
  • making an existing input required, or adding a new required input with no default,
  • removing the default from a required input,
  • changing an existing input’s type — refused with the guidance to add a new input instead; nothing migrates a stored value from one type’s meaning to another’s.

A major version bump does not relax any of this. Bumping the package’s own major version is a signal to whatever installs it; it is never permission to redefine an existing component’s contract in place. This is stated explicitly because the incumbent leaves it to the publisher’s judgment and documents no route at all for a breaking change — which is exactly how “make a copy” became the de facto convention there rather than a stated rule.

Deprecation, not removal, is the tool for “please stop using this.” Both a whole component and a single input on it can be marked deprecated: true — still working, still resolving exactly as declared, surfaced in the component library so an author sees the notice before placing it again. Deprecating an input grants no exemption from the rules above: a deprecated input still cannot be removed, retyped, or newly required. It is signal, not a loophole.

If a version pin is ever introduced (tracked separately, not built yet), it will cover the whole dependency closure or it will not ship. A pin that binds a component to one platform release but silently lets its data-access layer or base components float to whatever is current — the incumbent’s own documented behavior — is worse than no pin, because a publisher reading “pinned” reasonably believes the whole component is frozen.

A page declares an object binding, a template (the region skeleton), an ordered tree of sections, and a small set of page-level properties. Each section holds instances; each instance carries placement, a visibility rule, and — for component instances — an inputs map keyed by the names the component declares in its own body.inputs. Two of the page-level properties govern how the page treats the shell’s chrome, and both are flags a developer sets on the page at design time — not runtime decisions the page makes for itself. opensFocused asks the shell to open with the app nav and record tabs folded into the (still-painted) global header, giving a builder-style surface its room from the first paint. immersive asks the shell to let the whole top band recede as the reader scrolls the page and return it on the first upward scroll. Both are described in the shell contract; both are flags, not code — the behavior lives in the shell, and the page only opts in, so the developer who knows the page’s content runs past the fold is the one who declares the experience.

A component instance can also hold what goes inside it. children lists, in order, text and further component instances. Each of those instances may name the slot of its parent it goes into, and may hold children of its own. With no slot, it goes into the parent’s default content. A bar holding its actions, a section holding its rows and a menu holding its items are all stored this way, and the workspace draws the same arrangement after a save and a reload.

{
"id": "bulk_actions",
"type": "component",
"key": "caos_action_bar",
"children": [
{ "id": "selection", "type": "component", "key": "caos_tag", "slot": "start", "children": ["3 selected"] },
{ "id": "submit", "type": "component", "key": "caos_button", "inputs": { "variant": "primary" }, "children": ["Submit"] }
]
}

Rules the deploy validator applies:

  • slot exists only on a piece placed inside another; a section has no slots to name.
  • A field instance cannot be placed inside a piece. A field is read from the record, not held by a piece.
  • Text cannot be empty.
  • A piece at any depth is a placement like any other, with its own id in the page’s one address space. It has to be installed and exposed, and placeable on the page’s surface.
  • Absent and empty stay different: a piece that never said what it holds has not said “nothing”.

A visibility rule is a pure boolean expression in the one typed language — the same grammar, operators, and static analysis as a formula field or a validation rule:

can("dgn__pricing.edit") && client.formFactor == "desktop"

What is in scope is set by the contract of the surface: record (the bound record, including reference hops as typed property access), user, org, can("<system permission>"), and client (formFactor, theme). prior is not in scope — a page renders a record, it does not save one.

The incumbent models the same idea as an array of {left, operator, right} criteria combined by an index string like "1 AND (2 OR 3)", because its rules are assembled by a click-builder that has no expression parser behind it. Storing a predicate as numbered rows and a separate combiner string means a rule cannot be read without cross-referencing two structures, cannot be diffed meaningfully (inserting a criterion renumbers the combiner), and cannot share the formula analyzer. One expression avoids all three, and the click-builder still emits it — the builder is an on-ramp onto the language, not a substitute for it.

A page manifest section that places two fields and a custom component, the component gated to desktop users who hold a permission:

{
"id": "pricing",
"section": "Pricing",
"columns": 2,
"items": [
{ "id": "sell_price", "type": "field", "field": "invoice.sell_price" },
{ "id": "margin_pct", "type": "field", "field": "invoice.margin_pct", "uiBehavior": "readonly" },
{
"id": "price_override_panel",
"type": "component",
"key": "dragon.price_override_panel",
"inputs": { "mode": "inline", "showHistory": true },
"visibility": "can(\"dgn__pricing.edit\") && client.formFactor == \"desktop\""
}
]
}

The matching component manifest entry that makes price_override_panel eligible here:

{
"key": "dragon.price_override_panel",
"label": "Price Override Panel",
"type": "component",
"body": {
"exposed": true,
"placements": ["recordPage"],
"objects": ["invoice"],
"formFactors": ["desktop"],
"inputs": [
{ "name": "mode", "label": "Mode", "type": "text", "default": "inline", "options": ["inline", "panel"] },
{ "name": "showHistory", "label": "Show history", "type": "boolean", "default": false }
]
}
}

The builder reads body.placements/body.objects to decide the component is droppable on an Invoice record page and nowhere else, and reads body.inputs to render mode and showHistory as editable instance fields. An input that only ever takes one of a few named words says so in options, like mode above. That list is what a page editor needs to offer a picker instead of a free-text box. The list must be non-empty and name each value once. It is not checked against default and is not tied to type (CAOS-710). The component itself is supplied by whichever package delivered dragon.price_override_panel; the page merely pins its key.

placements and inputs were called surfaces and props in earlier material, and a placed instance’s value map was called props too. They are one design under one pair of names now — surface already meant a rendered screen elsewhere in the platform, and the declaring and consuming halves of the same contract were using different words. Placement is OPT-IN: a component is offered where it says it may go and nowhere else, so one that declares no placements is placeable nowhere. And a component may not be exposed until it has declared its inputs — an input name is the key a stored placement writes under, so the contract has to exist before anything can be placed against it.

In-product guidance — the help a user opens beside the surface they are looking at, and the walkthroughs that point at real controls — is metadata, authored and deployed exactly like the pages it describes. It lives here rather than in a documentation system for one reason: the things guidance binds to are the things this page defines. Guidance held anywhere else would be guidance whose targets nothing can validate.

Two metatypes carry it, and they are a pair. A guidanceAnchor is a stable, role-named handle onto a piece of the interface — whichever package provides a surface registers the anchors for it. A guidanceRecipe is an intent plus ordered steps, each step naming an anchor by its role.

{
"key": "platform.anchor.app_launcher",
"type": "guidanceAnchor",
"body": {
"role": "app-launcher",
"label": "App Launcher",
"icon": "grid",
"region": "global-header",
"selector": "caos-app-launcher"
}
}
{
"key": "platform.guide.switch_apps",
"type": "guidanceRecipe",
"body": {
"label": "Switch between apps",
"intent": "Open the App Launcher and choose an app to work in.",
"steps": [
{ "action": "open", "anchor": "app-launcher", "text": "Click the grid icon in the tab row to open the App Launcher." },
{ "action": "explain", "text": "Pick an app — Sales Atlas, Setup, or any other installed app." }
]
}
}

A step names a role, never an address and never a pixel. This is the part worth understanding, because the obvious alternative looks simpler and is not. A step could target an instance address — invoice.recordPage.pricing#price_override_panel — and that address is stable against editing, which is most of what a walkthrough needs. What it is not stable against is the interface: the same task is reached from a different control when the shell changes, when an org picks a different page template, or when the surface is provided by a different package altogether. A role survives all of that. Move the record tabs from a top strip to a left rail and every step referencing record-tab:* regenerates its location from the new region, with no change to any recipe. Choose a template with no record tabs and those steps resolve to nothing rather than instructing someone to click something that is not there.

The two halves of that division are worth stating plainly: the recipe is portable and the anchor is local. A recipe written once is correct in every org that has an anchor for the roles it names; the anchor is where one org’s label, icon, region and live target are recorded. Nothing in a recipe has to change when an interface does.

The step vocabulary is closed. action is one of navigate, open, click, input, select, wait, explain — the kernel rejects any other verb at deploy. anchor is optional: a step that references no surface (an explain, or a step carrying only prose) simply omits it. text is optional too; a step with neither says only what its action is.

The anchor’s selector is what turns describing into doing. role, label, icon and region are enough to say where a control is. A selector is a live target for the element on screen, and it is what lets a walkthrough outline the real control and step a person through a task. It is optional, and a recipe naming a role whose anchor carries no selector is still perfectly good guidance — it describes rather than demonstrates.

Guidance degrades rather than misleads. A role no installed anchor serves, and an anchor whose element is not on the screen being viewed, are both ordinary conditions — an org installs different packages, and a control lives on a surface the reader is not currently looking at. A step in that position says it cannot be carried out here and offers to move past it, rather than pointing at empty space. What it must never do is explain why: the run-time rule for an unresolvable address applies to guidance for the same reason, because a reader who can tell “this org has no such control” from “it is here and not shown to you” is reading their own permissions off a help panel.

Who registers what. Anchors belong to whoever draws the surface: a package that ships the shell registers the shell’s anchors, one that ships an app registers its own. Recipes belong to whoever explains the task, and may name a role another package registered — that is the point of a role. A package declares both as ordinary members and roots both — the guidance package that ships with the platform roots its three anchors alongside its four recipes. Rooting an anchor is the ownership statement that it is this package’s to supply, rather than something inferred because a recipe happened to name it. Guidance then installs, upgrades and uninstalls with the package that supplies it, like any other metadata.

The canonical model is summarised alongside the rest of the registry in the setup metadata model.

Assembly timing. A page is assembled at render time by the interpreter reading its manifest — the template’s regions, the section tree, the field instances, the component instances, and their props. There is no compile-to-page step that bakes a snapshot; the manifest is the live source and edits take effect on next render. (The engine caches the resolved page shape between deploys for performance, keyed by metadata version — but that cache is invisible to authoring: an edit bumps the version and the next render resolves the new manifest.)

Activation & routing. More than one page can bind to the same object; an assignment decides which one a given user sees, and the interpreter resolves it as part of the route walk. The incumbent assigns a Lightning record page by a combination of app + record type + profile, with a most-specific-wins precedence — an app/record-type/profile assignment overrides an app default, which overrides the org default. The platform keeps the same three-axis routing but substitutes its own security substrate for the profile axis: an assignment matches on app + record type + permission-set group + form factor, since permission sets and groups — not profiles — are the identity primitive. Precedence is deterministic and mirrors the incumbent’s specificity ordering: an assignment naming more axes wins over one naming fewer, an org default is the least specific fallback, and exactly one page resolves for any (user, record, form-factor) tuple. A user in several permission-set groups that match competing assignments resolves by the ranked specificity of the assignment, not by group membership order, so routing never depends on set-assignment sequence. Routing selects which manifest renders; it never alters the manifest, so a page renders identically wherever it is assigned.

The routing axes follow the page’s binding, and not every page binds to an object. The four-axis form above is the record-page case: record type is an axis only because the page is bound to an object that has record types. A home page or an app page binds to no object, so it drops that axis and routes on app + permission-set group + form factor — same precedence, one axis shorter. The binding also decides where the page is assigned from: a record page is object-scoped, assigned in Object Manager, while home and app pages are app-scoped — a home page belongs to an app that declares its own, and an app page is surfaced by being added to an app’s navigation (see the developer surface) rather than through per-object activation. The invariant holds either way: exactly one page resolves for any (user, context, form factor), the most specific assignment wins, and removing the last assignment falls back to the standard default rather than leaving a page that looks active but reaches no one.

Visibility evaluation. Visibility rules evaluate at render, and — because they are pure — re-evaluate reactively as their inputs change during an edit. The evaluation is read-only and cannot write a record or fire an effect; it sits entirely outside the save order of execution. A section’s or component’s visibility predicate is a pure boolean over the same inputs a formula may read.

Visibility is not access control. A hidden field or component is a presentation decision. It does not remove data access — the value remains reachable through the API, list views, and reports. Field-level security is a separate, authoritative layer (permission sets & FLS); a visibility rule must never be relied on to secure a value. The incumbent documents this identical trap (hidden ≠ secured) but enforces it only as a written warning.

The platform makes the boundary structural, not advisory, by ordering the two layers so a visibility rule cannot widen access — and the layer doing the enforcing is the kernel’s access engine, not the page. FLS resolves first, in the access engine at read time, and produces the field set the page is even allowed to consider; the visibility predicate then runs only over that already-FLS-filtered set. A field the evaluating user cannot read never enters the render candidate set, so no visibility rule — however written — can surface it, and a visibility rule that hides an otherwise-readable field changes only the rendered DOM, never the field’s reachability through the API. The two layers compose as an intersection (rendered = readable ∧ visible), and the pipeline order guarantees the presentation layer can only ever narrow what the security layer already permitted, never expand it.

Inputs & determinism. An instance’s input values are stored on the page manifest, keyed against the component’s declared inputs. Rendering a page is deterministic given the manifest, the record, and the evaluating user’s permissions and form factor.

What deploy checks about those values (CAOS-604, CAOS-625). A page is refused, naming the page, the placement and the input, when a placement:

  • sets a name the component does not declare, which would otherwise land on the element, be read by nothing, and leave the piece drawing its default;
  • leaves out a required input that the placement is the source of (source absent or literal) and that has no default. A stored null counts as left out, which is how the workspace treats it;
  • sets a value that is not of the declared type: a string for text, recordId, objectRef and fieldRef; a finite number for number; true or false for boolean; a date that exists, written YYYY-MM-DD, for date; an ISO 8601 date and time with its offset for datetime; anything for json;
  • sets a word outside the options the input declares.

A required input whose value comes from somewhere else when the page renders (recordField, urlParameter, orgContext, userContext, automationVariable) is not demanded of the placement. A value the placement does set for one is still held to the declared type. A component whose manifest declares no inputs at all has no contract to hold a placement to, so its placements are not judged.

One name is not a value for the component: on a page whose dataSource is controlPlane, valueAt names the path in the fetched payload that the component’s value comes from. The workspace resolves it and passes the result as value. It is accepted there, as a non-empty path, on a component that declares value, and it then counts as supplying value. Anywhere else it is refused like any other undeclared name. A placement that sets value and binds it with valueAt is refused too: the bound value always replaces the one written on the page, so that one would never be drawn.

The check is on the shared write path, so it holds the same way from the page builder, the metadata API, the command line and a package install. It applies to external site pages and to pieces placed inside other pieces as well.

A page with no record behind it. Verified 2026-08-20 — live. A page declares what feeds it. The default is a record: it binds an object, and every field placed on it resolves against that binding. A page that is about no record says so by declaring dataSource: { kind: 'content' } — a documentation page, a static landing page, a library reference page. It binds nothing, reads nothing, and draws only what it places. The binding stays required for a record page, which is the guarantee that has not moved: a page about a record cannot be authored without the record type its fields resolve against. Before this, there was no way to say “nothing feeds this”, and the pages that needed it bound a synthetic, field-less object invented only to satisfy the rule.

A field placed on a content page is refused at deploy, naming the field: a field placement means “the value this record holds here”, and there is no record for it to read from.

A page that is an application’s landing. Verified 2026-09-26 — live. An application opens on the destination it declares as its landing, and a page declaring dataSource: { kind: 'appLanding' } is the surface built for that: what needs attention in this application, derived from what the application itself declares — the record types on its tabs, the lifecycle stages those keep, who owns a record, and what is sitting at an approval gate. The figures behind it sit below that, under their own heading, so an app opens on what to do rather than on a report of what already happened.

It carries no properties, and that is the design rather than a simplification. Which application it is about comes from the address the surface was reached at, and whose work it shows comes from the authenticated session; a page that could name an application could name a different one, and an application’s landing reporting another application’s work would read as entirely ordinary on screen. It is what makes the mechanism general as well: a second application declares the same page and gets its own landing, with nothing application-specific written anywhere.

Because the whole surface is derived, an app landing draws nothing it places, and anything placed on one is refused at deploy — a field or a component alike, naming the piece. This is stricter than the content-page rule above, deliberately: a component on a content page is what a content page is for, while a component on an app landing is not merely unbound, it is invisible, and nothing on screen would tell its author which of the two had happened.

What the workspace draws from a page today. Verified 2026-08-20 — live. Opening a page draws its name, then one titled block per section, and inside each block the fields and the components the author placed. Blocks draw in the order the page’s template declares its regions, so a block placed in main draws before one placed in sidebar when the blueprint says so; if the workspace does not have the named blueprint, authored order stands. The template’s grid geometry is not applied by this surface — the regions stack. A placed component is resolved against the components the organisation actually has installed, its declared element is created, and the placement’s stored inputs are put on it — a plain value as an attribute, a value with structure as a property. A tab may point at a page instead of a record type, and opening that tab opens the page.

Every way that can come up short says so on the screen, because a blank rectangle and a component that draws nothing on purpose are indistinguishable to a reader. A placed component the organisation does not have names the component and says it is not installed here. One that is installed but whose code is not present in the workspace says that instead, which is a different problem with a different fix. A field the bound record type does not have names the field and the record type. A placement of a kind this workspace has no renderer for is named rather than dropped. A page whose kind this surface does not draw — a form, or a live control-plane feed — says which it is, rather than being described as containing nothing. And where a page declares related lists, a highlights band or header buttons that this surface does not draw, it says so instead of losing them silently.

The highlights band. A record page names the fields for the band across the top of a record in compactLayout. The first entry is the record’s title, which the header already shows, and Setup requires it to be the name field; the band draws the entries after it, in order, each with the label and formatting the body gives that field. A field the reader cannot read is left out of the record read, so the band skips it and the next named field takes its place instead of leaving a gap. A field the object no longer defines is skipped the same way, and a readable field with no value keeps its place and shows the empty placeholder. Counting the title, a desktop shows the first seven entries and a phone the first four, which is the incumbent’s rule. The list itself is not capped at deploy, and an entry past that point stays on the page without being drawn in the band: naming 30 fields draws the first six the reader can see, or three on a phone. The band draws on a phone as well, with the cells past the third hidden at the record header’s phone width. The record screen does not read page assignments yet, so when more than one record page binds the object, the first by key supplies the band. The kernel’s assembled page carries the same band and places it in the template’s highlights region. A page opened directly through a tab has no record behind it, so that screen still says it does not draw the band.

The header’s status badge. A record page names the one field whose value the record header shows as a badge beside the record’s name, in statusField. It is a key of a field on the bound object, and a page that names none has no badge — the header simply does not draw one. It is separate from compactLayout rather than the first entry of it, because the two answer different questions: the band is an ordered list of values read out under the name, the badge is one distinguished value painted as a pill, and a page may want a field in one, the other, both, or neither. The tint is not named here. A value set’s values carry their own display intent, so the colour belongs to the value rather than to the surface placing it, and a field with no value set still badges — plainly — because the page asked for it and this layer does not overrule what was authored. A field the reader cannot read draws no badge, and a page that has no badge and a badge the reader may not see are the same state to the screen: there is nothing to paint, and distinguishing them would tell a reader that a field exists which they are not allowed to read. A field the object no longer defines is skipped the same way. As with the band, this screen does not read page assignments yet, so where more than one record page binds the object the first by key supplies the badge — the same page that supplies the band, deliberately, since a header taking its band from one page and its badge from another would be two answers to which page describes this record.

Unsaid is not empty. Verified 2026-08-20 — live. sections on a page and items on a section are both optional, so a page that has never said what it contains is a state the model can hold, distinct from a page that has said it contains nothing. The screen gives them two different sentences, because a reader would go to a different place to act on each, and nothing between the manifest and the screen collapses the first into the second. This is the same rule a template’s geometry and a section’s region already follow.

Encapsulation & theming. Every component renders in its own shadow DOM. Style isolation is therefore the standards-based default: parent page CSS does not leak in, and a component’s internal styles do not leak out, so one component can never break another’s layout by construction. The well-known trade-off is that a shadow boundary that blocks stray CSS also blocks intentional theming. The platform closes that gap the way the standard intends: inherited CSS custom properties pierce the shadow boundary, so the design system — itself deployable metadata, not compiled UI — defines a fixed vocabulary of design-token custom properties (--caos-*) that a component reads for color, spacing, and typography, and the shell sets those tokens once at the root for light and dark themes. A component author styles against tokens rather than hard-coded values; a theme is a token set, not a per-component override. This is the same theme-and-brand seam the shell contract specifies: surfaces read the seam, they do not bake it in. Isolation and theming are thus both delivered — isolation by the shadow boundary, theming by the tokens allowed to cross it — and neither depends on leaky global CSS.

CSP. Components render under a strict Content-Security-Policy: no eval, no inline unhashed script, no external-host fetch beyond the declared data adapters. Encapsulation between components is by construction. This is a property of the runtime, not an opt-in per component.

The incumbent’s page limits are cost signals, not arbitrary ceilings. Some guard a constraint the runtime shares; some are the platform’s own decided ceilings where the primary source could not be cleanly fetched.

Concern Salesforce exact limit (cited) Why the limit exists Platform equivalent
Components per region 100 per region (FlexiPage — Metadata API) Each instance is a rendered node with its own data/reactive lifecycle; the cap bounds the DOM + reactive-graph cost of one page render. The runtime shares this DOM/reactive ceiling. A documented, enforced per-region ceiling is retained — an uncapped manifest is a rendering footgun.
Instance property value length 10,000 chars per property value (FlexiPage — Metadata API) Property values serialize into the page metadata blob; a hard cap keeps the metadata row within storage/transport bounds. Props are metadata; a sane per-value bound is retained, not an architectural limit.
Instance identifier length 120 chars (FlexiPage — Metadata API) The per-instance key is used for addressing and merge; a bounded string keeps keys index-friendly. The instance id is bounded for the same reason.
Visibility reference depth max 5 levels of relationship traversal in a rule reference (FlexiPage — Metadata API) Each level is a join at evaluation time; 5 bounds the query cost per rule on every render/edit tick. Depth is a real cost; the pure dependency graph makes it measurable per edge, but a budget/guard is still needed — the cost does not vanish.
Visibility conditions per component incumbent wall is ~10; platform sets 15 Filters are AND/OR-combined predicates evaluated on every render/edit tick; a cap bounds evaluation cost. The incumbent’s escape hatch is a formula-based rule. 15 — a modest raise over the incumbent’s ~10. Deeper logic pushes down to a formula. The real guard behind the number is an evaluation-cost budget on the pure predicate, so 15 is the authoring guideline, not a hard mechanism ceiling.
Fields / field sections per page incumbent publishes no total field cap Would bound total field-instance render cost per page. No hard cap — matches the incumbent, governed by a per-page render-cost budget. The builder shows a soft advisory past ~150 field instances, because a page that dense is a UX problem before it is a render problem.
Total components per page incumbent confirms 100 per region, no per-page total Would bound whole-page render cost. 100 per region retained (a real DOM/reactive-graph cliff — not worth raising). No separate hard per-page total; the whole-page guard is the render-cost budget.

Mechanism — four systems, not one. The incumbent’s presentation layer is four stacked systems that its own UI conflates:

  1. Lightning page (FlexiPage) — the assembly surface. Persisted metadata (.flexipage, since API v29.0): a tree of flexiPageRegions[] → itemInstances[], each item a componentInstance (a dropped component) or a fieldInstance (a directly-placed field). itemInstances replaced componentInstances in API 49.0 — the schema change that introduced Dynamic Forms (FlexiPage — Metadata API).
  2. Lightning App Builder — the drag-and-drop editor that reads component eligibility and writes the FlexiPage tree.
  3. Two component frameworks — Aura (2014-era, namespace:component, proprietary events) and LWC (standards-based, namespace-component, W3C Web Components, shadow DOM). Two metadata types back them: LightningComponentBundle and AuraDefinitionBundle. The Aura developer guide itself says “For new components, create Lightning web components instead of Aura components” and notes LWC “doesn’t yet support everything that Aura does” (Aura Components Developer Guide) — so both frameworks persist; Aura is steered-away-from, not formally deprecated.
  4. Page layout (Layout) — the older record-detail system. Even with a Lightning page built, the page layout still owns related lists, required/read-only enforcement, actions, and record-page save options (Dynamic Forms overview). Dynamic Forms migrates fields/sections off the layout onto the page as fieldInstances, but the layout does not die — admins maintain two artifacts for one record surface.

Component eligibility is declared per component: LWC’s js-meta.xml carries isExposed (bool) + targets (lightning__RecordPage, lightning__AppPage, lightning__HomePage, lightning__RecordAction, lightning__UtilityBar, lightning__FlowScreen, …) + targetConfigs (object restriction, form factors, editable @api props) (LightningComponentBundle — Metadata API, js-meta.xml configuration tags). A record FlexiPage binds to one object via sobjectType, immutable after creation — re-targeting means building a new page.

Where this platform is genuinely better:

  • One component model. A single web-component bundle + manifest eliminates two frameworks, two metadata types, two naming conventions, two event systems, and the incumbent’s own “you may still need Aura” caveat. This is a real advantage — conditional on the one model covering every surface (record, home, app, action, utility, screen) from day one; the incumbent’s split exists partly because its modern model still lags the old one, and this platform must not recreate that gap under one name.
  • Single-owner record surface. Collapsing the page-layout-vs-page duality into one manifest that owns fields, sections, related lists, actions, and required/read-only removes the incumbent’s #1 admin-confusion source — conditional on never shipping a shadow “layout” object that later needs reconciling. The moment two artifacts can define a record surface, the wart returns. (The object’s page layout is composed by the record page, not a second owner of it.)
  • Presentation is installed, not compiled. The component library, the builder, and the design system all arrive as packages/metadata over the same deploy pipeline, so a record surface is portable across orgs and a component upgrade is a deploy, not an engine release. The incumbent ships its App Builder and base components as part of the platform runtime.
  • CSP-safe by construction. Standards-based web components under a strict CSP (hashed/inlined, no eval, no arbitrary external fetch) give a simpler sandbox story than the incumbent’s Locker / Lightning Web Security machinery, and a portable artifact.
  • Security-aware boundary. Visibility and access are designed as distinct layers with the boundary made impossible to misuse — enforced in the access engine, not documented as a footgun after the fact.

Where it is mere parity (not oversold): page = template regions/sections → instances persisted as declarable metadata; component eligibility declaration (placements + object + form-factor + editable inputs); per-instance input store; fields as first-class placeable page items; visibility predicates with record/user/permission/form-factor sources and AND/OR logic; object binding on the record page; activation + persona routing. These are table stakes the incumbent already meets — match them, ship no less.

Costs and risks:

  • The dual-ownership wart exists for a reason: backward compatibility. The incumbent cannot retire page layouts because millions of orgs depend on them. This platform is greenfield and can unify — but only if it never introduces a second surface-definition artifact; two ways to define a record surface is the wart.
  • Shadow-DOM isolation carries a real theming cost. Choosing shadow-DOM-per-component buys clean isolation but blocks external CSS, so it forces a styling-hook/design-token system — exactly what the incumbent had to build. That cost is paid up front (the --caos-* token vocabulary and its light/dark root sets in the design system), not avoided; the alternative, lighter isolation, would trade that work for leakier styling and cross-component breakage. The token system is the deliberate, standing cost of picking isolation over leakiness.
  • Visibility-rule evaluation cost is real. The incumbent caps filters (~10) and depth (5) precisely because predicates evaluate on every render/edit tick. This platform sets a slightly higher 15, but the same wall applies; without explicit evaluation budgets behind the number, complex pages jank.
  • The per-region 100 is a rendering-cost signal, not arbitrary. The same DOM/reactive-graph ceiling applies; an uncapped manifest is a performance footgun that needs a documented, enforced ceiling.
  • “One model covers everything” is the hard part. Delivering every surface with a single component model — and every primitive from a package rather than compiled in — is precisely what the incumbent’s two-framework reality shows is difficult; falling short silently recreates the split.

Both a component and a page are metadata rows in the canonical key / label / type / body form, moved by the same retrieve → diff → validate → apply pipeline as every other component. A component’s body carries its eligibility and props; a page’s body carries its object binding, template, and section tree.

A component:

{
"key": "dragon.price_override_panel",
"label": "Price Override Panel",
"type": "component",
"body": {
"exposed": true,
"placements": ["recordPage"],
"objects": ["invoice"],
"formFactors": ["desktop"],
"inputs": [
{ "name": "mode", "label": "Mode", "type": "text", "default": "inline", "options": ["inline", "panel"] },
{ "name": "showHistory", "label": "Show history", "type": "boolean", "default": false }
]
}
}

A record page:

{
"key": "invoice.recordPage.pricing",
"label": "Invoice Record Page",
"type": "recordPage",
"body": {
"object": "invoice",
"template": "record.standard",
"sections": [
{
"id": "pricing",
"section": "Pricing",
"region": "main",
"columns": 2,
"items": [
{ "id": "sell_price", "type": "field", "field": "invoice.sell_price" },
{ "id": "margin_pct", "type": "field", "field": "invoice.margin_pct",
"uiBehavior": "readonly" },
{ "id": "price_override_panel", "type": "component",
"key": "dragon.price_override_panel",
"inputs": { "mode": "inline", "showHistory": true } }
]
}
],
"relatedLists": ["invoice.line_items"],
"actionKeys": ["invoice.submit_for_approval"]
}
}

The surface names its actions by KEY. An action is DEFINED once on the object, in the object’s own actions, and a record surface PLACES an ordered subset of them in actionKeys — the same definition/placement split fields have, where a field is defined on the object and placed on a surface many times. Two surfaces for one object place different subsets in different orders, and the label or the condition is changed once, on the object, for every surface that draws it. A surface may also still DEFINE its actions inline in actions, the older shape, but never both at once: two answers to what a record can do on one surface, with no rule saying which wins the day they disagree. Both validators refuse the pair, and the deploy gate refuses a placement key its object does not define — naming the key, the surface, and the actions that are on offer, because the renderer can only drop a key it cannot resolve and has nobody to tell.

The platform offers some actions itself — New on a list, and editing a record in place. An object can say that one of its own actions stands in for one of them:

{
"key": "invoice",
"label": "Invoice",
"type": "object",
"body": {
"actions": [
{ "id": "edit_in_wizard", "label": "Edit", "kind": "component",
"component": "dragon.invoice_wizard", "replaces": "edit" }
]
}
}

From then on the platform offers that action wherever it would have offered the standard one, and does not offer the standard one. Replacing edit takes both halves of editing at once: the action appears, and the record’s fields stop converting in place when clicked — a wizard beside fields somebody can still edit around it has not replaced anything.

Four rules, each of which is refused at deploy rather than advised:

  • replaces goes on the OBJECT, never on a surface. A page or a layout places actions; it does not say what a verb means. Declared once, two surfaces cannot disagree about what Edit does.
  • One action per verb. Two would be two answers with no rule saying which wins.
  • The vocabulary is create and edit — the verbs the workspace actually draws a control for. delete is refused: nothing offers it yet, so a replacement would have nothing to replace and, more to the point, nothing to fall back to.
  • The action must be a kind that can actually perform the verb. A clone copies this record and a create makes a record of something else; neither is a way to edit THIS record, whatever it is labelled, and a replacement that cannot perform the verb takes the verb away rather than merely looking odd. create accepts navigate and component; edit accepts those and update.
  • A replacement for create is offered with no record, because New is drawn by a list. Its path may carry no {field} placeholder, and it may not carry an appliesWhen.

Replacing the action never replaces the rule. The permission that governs the standard verb still governs its replacement — canCreate for New, canUpdate for editing — on top of whatever the replacement itself requires, and the write it eventually makes travels the same boundary, validation and audit as any other.

Scoping it to a record type needs no new vocabulary: appliesWhen is a pure predicate read against the record, and an object’s record type is discriminated by a real field (recordTypeField), so a replacement that applies only to closed records is "appliesWhen": "status == \"closed\"". A predicate that does not match means the standard action applies, quietly — that is the author saying “here, Edit is ordinary”. Scoping to a SURFACE is not offered, and that is deliberate: it is how the same object ends up behaving one way from its record page and another from a list row.

When a replacement breaks — it opens a piece this workspace does not have, changes a field the object lost, goes somewhere that cannot be reached — the standard action is offered instead, and the surface says so in words beside it. A person who may not run the replacement is a different case and is not a fallback: the verb is then not offered at all, because handing over the plain editor to somebody the author routed through a wizard would make the mechanism a way around itself.

Because relatedLists and the surface’s actions live in the same page body as the sections, one artifact owns the whole surface — the retrieve → diff → deploy cycle produces exact diffs and exact rollbacks over the entire record page, with no second layout artifact to reconcile. The template names the region skeleton the page fills; the component’s identity is content-addressed to its bundle, so a page pins the exact component version it renders — and because that bundle is package-delivered, the page’s diff records which package version supplies it.

Salesforce Metadata API analog: the page is a FlexiPage (flexipages/<name>.flexipage-meta.xml) — regions → itemInstances → componentInstance/fieldInstance, with sobjectType, platformActionList, and a template (FlexiPage — Metadata API). The custom component is a LightningComponentBundle (.js/.html/.css/.js-meta.xml) whose isExposed + targets + targetConfigs declare eligibility (LightningComponentBundle — Metadata API), or an AuraDefinitionBundle for the legacy model. Related lists, required/read-only, and actions are split into a separate Layout metadata component — the two-artifact split this platform collapses into one page body.