Validate metadata before you deploy it
Why ObjectStack metadata mistakes fail silently at runtime, and the one command that catches them at author time — run it after every metadata edit.
ObjectStack metadata is data, not code paths — so most mistakes are not caught
by the TypeScript compiler. They pass tsc, load fine, and then fail silently
at runtime. The fix is one command you run after every metadata edit:
os validate # schema + CEL predicates + widget bindings — no artifactIn a scaffolded project this is wired as npm run validate. Your generated
AGENTS.md instructs coding agents (Claude Code, Cursor, Copilot) to run it
after editing metadata.
Why typecheck isn't enough
Two classes of bug type-check cleanly but break at runtime:
1. Bare-field predicates
Predicates — an action's visible/disabled, a field's requiredWhen, a
validation rule, a flow condition, a sharing rule — are CEL expressions, and
they reference record fields through the record. scope:
// ✗ Wrong — `done` is a bare reference. It type-checks (it's just a string),
// but at runtime it resolves to null → the action is hidden on EVERY record.
{ name: 'mark_done', visible: '!done' }
// ✓ Right
{ name: 'mark_done', visible: '!record.done' }This is the trap behind the recurring "the button never shows / the rule never
fires" bugs (#2183/#2185). os validate parses every predicate and checks that
each record.<field> exists on the target object, so the bare ref fails the
gate with a located, did-you-mean message instead of shipping.
2. Dangling widget bindings
A dashboard widget points at a dataset and reads dimensions/values from it.
If a name doesn't resolve, the chart renders empty — no error (ADR-0021).
os validate resolves every binding against the declared datasets and fails on a
dangling one.
The same gate also checks dashboard-level filter fields. A dashboard's
dateRange and each globalFilters[] entry are broadcast into every widget's
analytics query, so a filter field that doesn't exist on a bound widget's object
emits invalid SQL (no such column: …) and crashes that widget at render time.
os validate fails when a filter's effective field — after any per-widget
filterBindings re-target — is absent on the widget's dataset object, naming the
dashboard, widget, filter, field, and object. Opt a widget out with
filterBindings: { <name>: false }, or re-target the filter to one of that
object's own fields.
This is no longer only a CLI verdict. Since #7529 the same rule also runs at the
runtime publish door for dashboard writes, so a board published from
Studio, PUT /api/v1/meta/* or an MCP/AI author is refused there too — the same
located 422 invalid_metadata envelope, naming the dashboard, the widget and the
key path. All six of the rule's error-tier findings gate that door as one "this
board cannot render" class, the filter-field arm above included, while its
warnings still ride the advisory channel and never block. So an author working
entirely in Studio, who never runs os validate, no longer ships a silently
empty chart. A draft save may still hold a forward reference — the refusal
lands on the draft→active promotion, per
The one gate, four doors below.
3. Dead action/route references
A dashboard header.actions[] button names a target: a script action, a
modal page, or a url route. Nothing in the schema checks that the target
exists, so a button can ship pointing at something defined nowhere — it renders
and then silently does nothing when clicked. This is ADR-0049's
"declared ≠ enforced" gate applied to references.
This check covers the dashboard header only. It used to check a
widgets[].actionUrl too — until #5010 measured that no renderer has ever drawn a
per-widget action button, which made the strictest arm of the rule fail builds
over a control that could not render. The three widget keys
(actionUrl/actionType/actionIcon) were retired in 17.0.0 rather than the
check merely relaxed; authoring one is now a tsc error and a parse error
carrying the fix.
header: {
actions: [
// ✗ script target resolves to no defined action → error
{ label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' },
// ✗ modal target names no declared page → error. A modal target names a
// PAGE, only — `create_opportunity` no longer resolves through the
// retired `<verb>_<object>` convention, however real the object is.
// To open an object's form, use actionType: 'form' with an
// '<object>.<view>' form-view target.
{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' },
// ✓ modal target naming a declared page (`pages: [{ name: 'deal_intake' }]`)
{ label: 'Intake', actionType: 'modal', actionUrl: 'deal_intake' },
// ⚠ url path matching no in-app route → warning
{ label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' },
],
}A script target that names no defined action, or a modal target that names
no declared page, fails validation; a url path whose
objects/reports/dashboards/pages/views route is unregistered is warned
(external, interpolated, and opaque routes are skipped).
4. Dangling object and action names
The same gate covers the reference sites that are plain strings in the schema:
an action param's record-picker target, a dashboard filter's options source, a
navigation capability gate, and every surface that binds an action by name
(bulkActions / rowActions, a page's record:quick_actions, a nav action item).
// ✗ the platform user object is `sys_user` — `user` resolves to nothing → error
{ name: 'owner', type: 'lookup', reference: 'user' }
// ✗ no action named `mass_update` is defined anywhere → error
defineView({ /* … */ bulkActions: ['mass_update', 'mass_delete'] })
// ⚠ platform-shaped, but no known package registers it → warning
{ id: 'nav_approvals', type: 'object', objectName: 'sys_approval_process',
requiresObject: 'sys_approval_process' }Severity follows resolvability, because "this might be provided by another installed package" is a real possibility that must not be guessed at:
| The name… | Result |
|---|---|
| resolves to one of your own objects | ✓ |
| is unresolved and carries no platform prefix | error — your objects are namespace-prefixed and present in the stack, so there is no legitimate elsewhere. This is the typo class (user for sys_user). |
| resolves to a known platform / plugin / cloud object | ✓ |
| carries a platform prefix but no known package registers it | warning — a third-party package may still provide it, so this stays advisory |
Interpolated targets (${…}, {…}) are skipped — they resolve at render time.
Action names get no third-party softening: the runtime ships no built-in action
names, so a name resolving nowhere is always an error.
5. Page components bound to fields that don't exist
A page component's properties is an untyped bag, so a highlights strip, a KPI
card, or a details section can name a field the bound object does not have. The
component silently skips it and the page renders one item short.
// page.object = 'crm_lead'
{ type: 'record:highlights', properties: { fields: ['status', 'total_revenue'] } }
// ↑ not a crm_lead field → warningWhich object a component binds follows dataSource.object → properties.object
→ the page's object, so a multi-object page is checked per element rather than
against one page-wide guess. A record:related_list's columns/sort/filter
resolve against its related object (objectName), and its add-picker against
its own. Advisory, like form-layout field references — every consumer degrades
rather than failing. The same descriptor table drives the react page surface
(§10), so a component is described once and checked wherever it is authored.
Skipped, to keep false positives at zero: relationship paths (account.name,
resolved by the query engine), registry-injected system fields (created_at,
owner_id, …), components bound to an object another package defines, and
unregistered component types.
6. Chart axes naming raw fields instead of dataset measures
Post-ADR-0021 a chart's result rows are keyed by the dataset measure name, not the underlying column — so an axis pointing at the raw field renders with an empty series. Dashboard widgets were already checked; report charts, list-view charts, and dataset-bound page chart components are checked the same way.
// dataset declares measure `est_hours` (sum of `estimate_hours`)
chart: { type: 'bar', xAxis: 'status', yAxis: 'estimate_hours' }
// ↑ the base column, not the measure → errorAn axis naming a measure the dataset declares but this chart does not select
(not in values) is a warning: the query never returns it, so it plots
nothing.
The react <ObjectChart> block is object-bound (objectName + an inline
aggregate) rather than dataset-bound, so it is checked by the react-page prop
gate below against a different rule — its result rows are keyed by the raw field
names, exactly the opposite of the dataset case.
One level down from the axis, the measure itself is checked against the field it
aggregates. A measure pairs an aggregate with a field, and
AGGREGATE_FIELD_TYPE_COMPATIBILITY (@objectstack/spec) declares which pairs
every backend answers the same way. A pair outside it is refused
(measure-aggregate-field-type-refused) naming the aggregate, the field, its
declared type and the accepted set:
// object declares `closed_at` as `datetime`
measures: [{ name: 'avg_closed', aggregate: 'avg', field: 'closed_at' }]
// ↑ refused: the answer would be the dialect's, not the data'savg over a temporal column is where this bites hardest: one SQL family coerces
the stored text and returns a plausible number (the average year), another has
no such function and fails at query time. min/max over the same field are
accepted — they return a real instant of the field's own type — count is
accepted over every type, because it reads no value, and count_distinct over
every type but the JSON-stored ones (the structured-JSON types and
multiselect / checkboxes / tags), whose values no two backends compare
for equality alike. The check reads the field's declaration as well as its
type: a select, radio, lookup, user, file or image field declared
multiple: true holds a list stored as JSON, so count_distinct over it is
refused too, although the table accepts the type. The analytics service
refuses the same pair with 400 DATASET_INVALID when a query is built — or,
for a field reached through a relationship path, with 400 INVALID_FIELD when
the query runs, judged on the object the path reaches; this
is the identical verdict,
from the identical table, one door earlier. The rule stays silent wherever the
field's type cannot be resolved (an object this stack does not define, a
dangling field path, an untyped field) rather than guessing.
The same check judges the dataset's dimensions, under its own id. A
dimension is a group key, and the analytics service refuses a query grouped by
a JSON-stored column with 400 INVALID_FIELD before any SQL is built. So a
dimension whose field is declared with a structured-JSON type, or as a
multi-value field (multiselect / checkboxes / tags, or one of the types
above declared multiple: true), is refused
(dimension-json-stored-field-refused) at datasets[N].dimensions[M].field.
Group by a field that stores one value instead. Like the measure check, it
stays silent when the field's type cannot be resolved.
Both checks also judge the members of an analytics cube (analyticsCubes),
because an authored cube is queried through the same analytics door. A cube
dimension whose sql column is declared with a structured-JSON type or as a
multi-value field is refused (dimension-json-stored-field-refused) at
analyticsCubes[N].dimensions.<key>.sql. A cube measure is judged exactly as a
dataset measure is, its type being the aggregate: a pair outside the table is
refused (measure-aggregate-field-type-refused, at
analyticsCubes[N].measures.<key>.type). That covers sum, avg, min or
max over a column whose type their row does not accept (a sum over a text
column, say), and count_distinct over a JSON-stored column; the analytics
door refuses each of them with 400 INVALID_FIELD when a query names the
measure. count is accepted over every type. The column is read where the door
reads it: on the object the cube's sql names, or, for a relationship path,
on the object the last hop reaches — the join the cube declares for that hop,
else the lookup's reference. A measure of an expression type (number,
string, boolean) and the row wildcard '*' are not judged by this check.
7. Navigation exposing objects nobody can read
Navigation and permissions are separate metadata, each valid on its own — so an app can put an object in its menu that no permission set grants read on. The entry renders; opening it fails permission-denied for everyone except a holder of the platform's built-in wildcard admin set. It works while you browse as an administrator and breaks for the users the app ships permission sets for.
navigation: [{ id: 'nav_forecast', type: 'object', objectName: 'crm_forecast' }]
// …and no permission set lists `crm_forecast` under `objects` → warningAdvisory: the grant may come from a permission set another installed package
ships. Skipped for platform-provided objects (whose own packages grant them),
for stacks that declare no permission sets at all, and when any set carries a
wildcard (objects: { '*': … }) grant.
8. <ObjectChart> axes bound to the wrong result column
A kind:'react' page's <ObjectChart> is object-bound: objectName plus an
inline aggregate, run as one ad-hoc query. Its rows come back keyed by the
raw field names the aggregate was given — groupBy names the category
column, field names the value column (the literal count for a fieldless
count). That is the exact opposite of the dataset case in §6, and mixing the
two conventions up is the usual cause of a chart that renders axes and no bars.
<ObjectChart objectName="invoice" type="bar"
aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }}
xAxis={{ field: 'status' }} series={[{ name: 'sum_total' }]} />
// ↑ a dataset-style measure name; the rows
// are keyed `total` → errorBoth halves are checked: aggregate.field / aggregate.groupBy must be fields
the object declares, and the axes — xAxis.field, yAxis[].field,
series[].name — must name a column the aggregate actually returns (plus
<field>__comparison when a comparison overlay is on).
Skipped, to keep false positives at zero: any prop whose value is not a static
literal (it comes from React state or a variable), a usage carrying a {...spread},
a chart given static data (its columns are the author's own), and objects
another package defines.
9. Authoring keys the schema never declared
Most metadata schemas are deliberately not strict — only the types the
ADR-0049 tier programme has hardened (flow, permission, position, tool,
app, …) reject unknown keys. On every other type a key the schema does not
declare parses clean and is dropped on the way to storage. Nothing fails;
the setting simply is not there.
fields: {
ssn: { label: 'SSN', type: 'text', pii: true, indexed: true },
// ↑ neither is a FieldSchema key → both dropped
}Each one is reported with what to do about it — a rename where the concept survives under another key, or the reason it was retired where it does not:
objects.employee.fields.ssn.pii: 'pii' is not a declared field key, so its value
is dropped at load — the `dataQuality` governance family was pruned in 2026-06
as dead in both layers — it enforced nothing.
objects.employee.fields.ssn.indexed: 'indexed' is not a declared field key, so its
value is dropped at load — never a FieldSchema key; the field-level `index` flag
built no index and was removed for it. Declare the index in the object's `indexes[]`.Plain typos get a "did you mean" (requred → required); a retired key does
not, because the nearest declared key by spelling would be noise rather than
advice.
The check covers every metadata collection — pages, apps, agents, dashboards, views, actions, and the rest — with its coverage derived from the same collection map the loader uses, so a newly registered collection is covered the moment it exists. Types that are already strict are skipped: there the parse itself rejects loudly, with the schema's own guidance. Because the lint reads each schema's real unknown-key posture, it can never disagree with the parse.
It also covers the stack's own top-level keys — the envelope those collections sit in, and the level where the silence is hardest to spot, because an undeclared key there reads as configuration that took effect rather than as a typo:
export default defineStack({
storage: { adapter: 's3', s3: { bucket: 'app-files' } },
// ↑ not a stack key → dropped at load; the app keeps writing to local disk
objects: [...],
});stack.storage: 'storage' is not a declared stack key, so its value is dropped at
load — the file-storage backend is a deployment concern, not an application
declaration. Configure it with the OS_STORAGE_* environment variables, or
per-deployment in Setup → Settings → Storage.This is advisory — the stack still loads. Strict rejection is where these
schemas are headed (ADR-0049 enforce-or-remove), but these are the protocol's
most-authored surfaces, so the tightening is scheduled on what this check finds
rather than assumed. defineStack reports the same findings at config-load
time, so an author sees them without running the CLI at all.
10. React block props naming fields the object doesn't have
§5 is about a metadata page's untyped properties bag. A kind:'react' page
authors the same components as JSX props, and every prop that binds by field
name has the same failure: the block skips the name and renders one column, one
filter chip, or one form field short.
<ListView data={{ provider: 'object', object: 'crm_account' }} columns={['name', 'revenue']} />
// ↑ not a crm_account field → warningChecked on every injected block: <ListView>'s
fields/columns/sort/grouping/userFilters, <ObjectForm>'s fields,
initialValues keys, sections[].fields[] and subforms (each against its own
childObject). <Block type="…"> reaches the §5 descriptor table by the type
the author writes, so the escape hatch is covered rather than left as a hole.
10b. A record:* block on a react page
The record:* family — <RecordDetails>, <RecordHighlights>,
<RecordRelatedList>, <RecordPath>, and the rest — renders from the record
context a record page mounts once for the record it routed to. A
kind:'react' page mounts no such context, so these blocks render empty
whatever props they are given; the react contract published objectName /
recordId for four of them and no renderer ever read either.
<RecordHighlights objectName="crm_account" recordId={sel} fields={['name']} />
// ↑ error: renders empty here — those props are not readThey are withdrawn from the react tier, and using one is an error
(react-block-needs-record-context) — by tag, and through
<Block type="record:…"> alike. On a react page the parent record is ordinary
React state, so bind it with a block that reads its own props: <ListView data={{ provider: 'object', object: '<child>' }} filters={['<lookup>', '=', parentId]}> for a related list, <ObjectForm mode="view" recordId={…}> for a
field panel. To use the family itself, author the page as type:'record'.
On a record page, where these blocks do work, §5 checks their field-bearing
props, and <RecordRelatedList objectName> is the related (child) object
whose records are listed — the parent record comes from the page, and
relationshipField is the child's field pointing back at it. Passing the parent
there is the mistake that check was extended to catch.
A filter position is the exception that gates:
<ListView data={{ provider: 'object', object: 'crm_account' }} filters={['revenu', '=', stage]} />
// ↑ error, not a warningAn unknown column in a predicate is not a skipped column — the SQL drivers
refuse it with INVALID_FILTER / 400, naming the column, instead of running
the predicate. The remedy is to run schema sync so the declared field has its
column, or to name a column the table has. Catching it here, before the page
ships, means an author sees the mistake in the editor instead of a 400 in
production. Each position of a filter is judged on its own, so the field above
is still checked even though stage comes from React state.
Skipped, to keep false positives at zero: the same set as §8 — non-static
values, {...spread} usages, relationship paths, system fields, and objects
another package defines.
The one gate, four doors
os validate, os build (alias of os compile) and os lint run the same
author-time rules, from one table — AUTHORING_RULES in
packages/lint/src/authoring-rules.ts.
There is a fourth door, and it is not a command. Every metadata write —
Studio's designer, PUT /api/v1/meta/*, an MCP/AI author — lands in
saveMetaItem, and since #4463 a write going state: 'active' runs that
same table before it persists. So does the draft→active promotion
(publishMetaItem), because otherwise saving ?mode=draft and then publishing
would be the bypass. Draft saves themselves are deliberately never gated: a
draft is allowed to be half-finished, and it cannot execute until it is
published.
For someone authoring in Studio that door is not one of four — it is the only
one. sys_metadata overlay rows are not in any config file, so there is no
os lint they could have run instead.
The table below has exactly two axes: its rows are rule families, its
columns are the four doors. A ✓ means that door runs that family — it does
not say what that door hands the family to judge. That input is a third thing,
orthogonal to both, and no cell here can carry it; it is written out in
What each door hands the rule table.
os validate | os build | os lint | runtime publish | |
|---|---|---|---|---|
| Protocol schema (Zod) | ✓ | ✓ | — | ✓ |
| CEL / predicate validation (ADR-0032) | ✓ | ✓ | ✓ | ✓ᶠ |
| List-view navigation modes (ADR-0053) | ✓ | ✓ | ✓ | — |
Zod-valid but functionally inert declarations — a summary with no operations (ADR-0078), a managed object advertising an API method its affordances refuse (#7521) | ✓ | ✓ | ✓ | ✓ᵒ |
| View container shape | ✓ | ✓ | ✓ | — |
| Widget-binding integrity (ADR-0021) | ✓ | ✓ | ✓ | ✓ᵈ |
Dataset measure aggregate × the field's declared type — a pair the spec's compatibility table refuses, e.g. avg over a datetime field (#16354) | ✓ | ✓ | ✓ | — |
| Dashboard action/route references (ADR-0049) | ✓ | ✓ | ✓ | — |
| Filter placeholder resolvability (#3574) | ✓ | ✓ | ✓ | — |
Ordering comparands naming a date-range preset — last_30_days in a >= position (#8793) | ✓ | ✓ | ✓ | ✓ᵈᵛᵒᵖᶠ |
Empty filter combinators — $and: [], $or: [], $not: {} (#5330) | ✓ | ✓ | ✓ | ✓ᶠ |
| Object & action name references (#3583) | ✓ | ✓ | ✓ | — |
| Flow reference integrity — node writes, template paths, read-only writes (#3583) | ✓ | ✓ | ✓ | ✓ᶠ |
| Page-component field bindings (#3583) | ✓ | ✓ | ✓ | — |
| React page block field bindings — §10 (#4340) | ✓ | ✓ | ✓ | — |
| Chart bindings outside dashboards (#3583) | ✓ | ✓ | ✓ | — |
| Navigation vs. granted access (ADR-0090 D6) | ✓ | ✓ | ✓ | — |
| SDUI scoped styling (ADR-0065) | ✓ | ✓ | ✓ | — |
| JSX / React page source parses (ADR-0080/0081) | ✓ | ✓ | ✓ | — |
| Approval-node approvers (ADR-0090 D3) | ✓ | ✓ | ✓ | ✓ᶠ |
Security posture (ADR-0090 — e.g. every custom object declares sharingModel) | ✓ | ✓ | ✓ | ✓ˢᵖᵉᵇᵒ |
Security vocabulary freeze (ADR-0090 D3 — the reserved word, replaced by permission_set / position / business_unit) | ✓ | ✓ | ✓ | ✓ᵒᵖᵉᵇᵖᵒᵃ |
| Organization-axis red lines (ADR-0105 D6) | ✓ | ✓ | ✓ | — |
Declared enforcement that cannot run, declared on the object being written — a validation rule's regex / JSON Schema (#4762) and its format names (#5178) | ✓ | ✓ | ✓ | ✓ᵒ |
| Declared enforcement that cannot run, declared on another collection — sharing-rule conditions (#4698), row-level-security predicates (#4983) | ✓ | ✓ | ✓ | — |
Platform-schedule create_record organization (#6285) | — | — | — | ✓ᶠ |
| Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ |
Autonumber {field} interpolation | ✓ | ✓ | ✓ | ✓ᵒ |
| View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — |
| Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ |
| Flow trigger readiness — a flow that looks armed and never launches (#5762) | ✓ | ✓ | ✓ | ✓ᶠ |
views[] conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer, schema path refs (ADR-0089 D3b, #7010) | ✓ | ✓ | ✓ | ✓ᵛ |
| Advisory: record titles, semantic field pointers (ADR-0085), form-section layout, action placement, SDUI component props, seed replay/state safety, capability references, liveness | ✓ | ✓ | ✓ | — |
| Package docs — flatness, prefixes, links (ADR-0046) | ✓ | ✓ | ✓ | — |
| Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) | ✓ | ✓ | — | — |
Declared-unique scope — a bare unique: true index, a field-level and index-level double declaration, legacy organization composites (ADR-0120 D5) | ✓ | ✓ | ✓ | — |
| Naming, labels, the rest of the data-model best-practice sweep, i18n coverage | — | — | ✓ | — |
Emits dist/objectstack.json | — | ✓ | — | — |
Every superscript in the runtime publish column is one metadata type whose
writes that rule inspects: ✓ᶠ flow, ✓ᵛ view, ✓ᵈ dashboard, ✓ᵒ
object, ✓ᵖ page, ✓ˢ seed, ✓ᵇ book, ✓ᵖᵉ permission,
✓ᵖᵒ position, ✓ᵃ app — the two ᵖ-initial pairs carry a second
letter because page already holds ᵖ on its own. Read the codes
longest-first: ᵖᵒ is position, never page followed by object. A cell
lists one superscript per declared type, in the order the rule declares them, and that
rule's own runtimeTypes in AUTHORING_RULES is the authority — the set has
grown a type at a time (#4463 shipped P1 as flow and four rule families, #7220
moved the whole views[] visibility-predicate family across in one edit, #7529
put widget-binding integrity on dashboard, #8307 → #8310 walked the ADR-0090
security-posture block across seed, then permission and book, then
object, #4716 crossed the five remaining gating object rules —
functional completeness, managed API methods, autonumber formats, and both
validation-rule enforceability checks — onto object, and #19370 took the
vocabulary freeze across all five of its own write types at once), so read the
rule rather than assuming a save of some other type reaches storage unjudged.
That last move is also why the vocabulary freeze is a row of its own. It was
split out of the security-posture rule on the day the rest of that block
crossed, because at that moment the door built no snapshot for two of the
collections it judges — positions and apps: one rule id has to sit on ONE
side of the wall, so it stayed behind whole rather than crossing for some of
the collections it judges and not others.
It crossed in #19370, also whole, and the split is what made that possible
one half at a time. position and app are runtime-creatable — Studio's app
designer is a shipped tenant capability — so the two types were mapped at the
door rather than withdrawn from authors, and the rule now declares the write
type of every collection it judges. A runtime write is refused when the
reserved word appears in an object, field, action or field-group identifier or
label, a permission-set name or label, a book's, a position's, or an
app's. The CLI verdict is unchanged: both security entries have run on all
three commands since the split.
Declared enforcement that cannot run is two rows for the mirror-image
reason, and #4716 is where it split. That heading covers four rule ids, and
the split is drawn by which collection carries the declaration each one
reads — not by what the rules have in common, which is everything.
validateRuleCompilability (#4762) and validateRuleSchemaFormats (#5178)
read validationRules[] on the object being written, so an object write
already carries every declaration they judge and they crossed with the rest of
#4716. validateSharingRuleEnforceability (#4698) reads sharingRules[] and
validateRlsPredicateEnforceability (#4983) reads permissions[], so neither
is answered by an object write at all — and each is held by something
different: the door does not accept sharing_rule as a type yet, whereas
permissions has been in the snapshot since #8309 and only the declaration is
missing. The freeze kept one id whole by staying behind and then crossing whole;
here four ids stopped agreeing, and one row cannot say two things. When the next crossing lands on
a shared row, check the rule ids before the cell: if they disagree, split the
row by the collection each id reads, and neither ✓ nor — has to lie.
The — cells above are — for more than one reason, and only the first two
are about the rule being unable to run there: some rules read a stack-wide
collection a one-item write does not carry (pages, dashboards, navigation —
the snapshot has carried permissions and books since #8309 and datasets
since #7529, so those three are no longer in this class, and positions /
apps left it by the other route in #19370: the only rule that reads them
resolves nothing across collections, so mapping the two write types was enough
and the snapshot did not have to grow);
some parse authored source through typescript, which the kernel boot path
must never load; some are snapshot-safe and simply have not been rolled out to
a type yet (a sharing rule or an RLS predicate crosses on a runtimeTypes
edit, not on new wiring); some judge an object declaration at advisory
tier, where what holds them back is advisory VOLUME rather than anything they
are unable to do — #4716 crossed the gating object rules and left these six
measured at ~8 findings per object write on unswept metadata, which Studio's
designer has rendered on every field edit since #4717, so crossing one is a UX
decision with its own card and explicitly not a bare runtimeTypes edit;
and the capability-reference rule would graduate from advisory to gating at
that door, since the live registry decides what the CLI has to hedge — a
severity change on a published rule id, which is its own PR rather than a
wiring change.
The visibility family crossed together, and that is the point rather than an
implementation detail. An earlier attempt wired one of its rules alone, which
would have refused a view whose predicate names an unresolvable path while a
predicate that does not parse at all saved clean through the same door —
sibling verdicts about one predicate, one enforced, none predictable. A rule
family is a wall, and a half-wired wall is worse than an unwired one, so
authoring-rule-wiring.test.ts pins the property directly: every rule on that
surface is gated at this door, or none is.
Both halves of a gate's behaviour are on this door. Gating findings refuse
the write, as the same 422 invalid_metadata envelope a schema failure
produces — issues[] carrying rule, path, where, message and hint, so
Studio can point at the offending field. Advisory findings never block: they
ride back on the save response under advisories, which is the channel a Studio
or MCP/AI author can actually read — server logs are not. A clean save carries no
advisories key at all. OS_ALLOW_UNLINTED_METADATA_WRITES=1 degrades a
refusal to a loud log for a migration window, so rows written before the gate
existed stay re-savable; it converts refusals, and never promotes them into
advisories.
So os validate is the fast inner-loop check (no artifact), os build is what
you run when you need the deployable artifact, and os lint adds its own style
rubric on top. Any rule that can fail a build runs on all three, so a green
os lint means the build's gates are green too, and a stack cannot be published
through the one command that happens to skip a check.
The fourth door does not weaken that, because it is held to the CLI's verdicts
rather than to its own: a test fails if a rule runs at the runtime publish gate
but not on os build — the two publish verbs must not disagree. What that
column narrows is which types it judges, never which verdict it reaches. The
deliberate exceptions are the two rows whose inputs are facts about the
deployment, and a build machine's environment is a false signal for those —
so os build must not judge them at all. The platform-schedule row (#6285) is
runtime-only by ruling: its inputs are the organization this write lands in and
whether this deployment walls organizations. The public-form intake row (#21476)
reads the tenancy posture in force: on a walled posture, an open public form
whose object is walled by an organization column cannot take an anonymous
submission, so the anonymous form endpoints do not offer it, and a save or
publish of the view answers success with a public-form-intake-unavailable
warning in advisories, located at the form's sharing. It never refuses the
write.
Some rows are deliberately not universal across the three commands, and each is
one-directional (none lets a stack through a gate another command enforces):
the Zod parse and the undeclared-key diff need the pre-parse tier and the schema,
which only the two commands that parse actually have; and os lint's own
rubric — snake_case names, missing labels, the best-practice data-model sweep —
is a lint verdict, not a publish gate. os build has never rejected a camelCase
object name.
Read that parenthetical at the scope it is written at. It is a claim about these
rows: no row is ✓ at one door and — at another in a direction that lets a
stack through. The same sentence about the input each door hands the table is
a different claim, it has been false three times, and the two read identically
in prose — so the input claim is stated
separately below rather than folded in
here.
The declared-unique row is the one that looks like that rubric and is not. Its
ADR-0120 D5 rules register for os validate and os build only, because
os lint reaches them through its own data-model sweep and a second
registration would print every finding twice — coverage recorded, not coverage
missing, which is what each of those entries says for itself.
That invariant is enforced, not merely documented. Each rule declares its command
coverage as data, and a CLI test fails if a rule that can emit error runs on
fewer than all three, if a narrowed rule carries no written reason, or if any
command reaches for a rule directly instead of going through the registry. The
fourth door is declared the same way, in the same entry, and checked by the same
test: every rule says whether the runtime publish gate runs it — naming the
metadata types it inspects when it does, and giving a written reason when it does
not. There is no third option, so a rule cannot end up at that door, or off it,
by nobody's decision.
The enforcement exists because the contract drifted four separate times, and the
last audit (#4409) found 23 of 26 rules running on some strict subset of the
three — nine of them able to fail a build. The worst direction was the least
obvious: os build was the weakest of the three gates, so it emitted an
artifact for stacks the other two refuse. A flow whose expression approver did
not parse built and published green; only os lint stopped it, and CI usually
runs the other two.
What each door hands the rule table
Everything above this heading is one axis: which rule family runs at which
door. This is the other one — the input each door hands those families to
judge. They are independent: a door can run every rule in the registry and still
certify a project clean, if what it hands them is an empty stack. A ✓ in the
matrix answers the first question only.
The three CLI commands run the whole table twice on every run.
- The union fold. One stack, every package's collections seen together.
Under the ADR-0130 D4 artifact shape a project's definitions may live entirely
in
packages[]with nothing at the top level, so the fold fills only the collections the top level does not carry, and returns a stack that already carries them unchanged. This is the only pass that can see a finding which spans packages. Until it was wired at all three doors (#17069, #17528), such a project was judged as if it declared nothing —os validateandos lintexited 0 on a stackos buildrefused. - The per-package walk. The same table again, once per entry of
packages[]: each package's body re-read as the stack it was assembled from, handed the artifact's ownpackages[]as resolution context so a sibling package's object still resolves — and so does a translation key naming a sibling's view, page, action, app, dashboard or flow (the orphan-key table lists every rung) — while the collections being judged stay that package's alone. It is de-duplicated against the union run, so what it reports is the set of per-package findings no union finding already carried under the same rule,where, message and non-top-level position — the leadingcollection[N]index is neutralised, because a package body re-bases its collections from 0 and one finding would otherwise get two keys. Every finding it raises carries apackage '<id>' —prefix in itswhere. That key is position-insensitive, not collision-proof: two entries that render the samewhereshare one, so the bound is what the key can tell apart and no wider —packages/cli/src/utils/artifact-packages.tsstates it, and why. A project with nopackages[]skips it: one package by definition, already judged whole by the union run.
The second pass is not the first one in a stricter mood — it is a different question. A rule asking "does this stack's app navigation point at an object this stack defines?" is satisfied by the union for a package that carries neither half; per package it is not. Because the artifact registers per package (ADR-0130 D4/D5), the per-package answer is the one the runtime will live with.
That pass reached the three doors one at a time, and every gap was false-clean
in the author's favour. os build has run it since #16611, os validate since
#18677 and os lint since #18778; until each of those landed, the door in
question ran the union fold and stopped. Symbol presence is not coverage here:
os lint imported the per-package seams for two cards before it ran this pass,
because it walks packages[] for a duplicate-name advisory of its own — and that
advisory is os lint's own rubric, not this table.
The fourth door is on neither pass, and the package narrowing it does have is a
third thing again. A runtime write is one item, not a stack, so the publish
gate builds a per-write snapshot — the written item plus the live registry's
objects as resolution context — and runs the table twice over it: once on the
context alone, once with the item grafted in, attributing to the write only the
findings the item added (#4463). Its package dimension narrows that context to
the written item's package closure — the written package, its declared
dependencies, and unconditionally both platform/system objects and rows carrying
no package provenance (#9612); a provenance it cannot read narrows nothing at all.
That changes what a rule can resolve, never what it judges, and nothing at
this door walks packages[]. So "runs per package" names one thing at the three
CLI doors and another one here, and the runtime publish column says neither.
A clean run walks the registry and reports timing:
◆ Validate
────────────────────────────────────────
→ Loading configuration...
Config: /path/to/support-desk/objectstack.config.ts
Load time: 21ms
→ Validating against ObjectStack Protocol...
→ Running author-time rules (50)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...
✓ Validation passed (64ms)
Support Desk v0.1.0
Data: 2 Objects 6 Fields
UI: 1 Apps 1 Views 1 Actions
Runtime: 3 pluginsOn failure the exit code is non-zero and the error is located and corrective — see the gate in action for the bare-reference example verbatim.
os lint runs every rule the three commands share plus its own style rubric
(snake_case naming, required labels, namespace prefixes, data-model patterns, translation
coverage). It does not replace os validate — it never parses against the Zod
schema, so a schema error is os validate's verdict to give — but a rule that
can fail the build fails os lint too.
The workflow
# after editing any *.object.ts / *.view.ts / *.action.ts / *.flow.ts / *.dashboard.ts
npm run validate # os validate — schema + predicates + bindings
npm run typecheck # tsc --noEmit — types against @objectstack/specRule of thumb: never report a metadata change as done until npm run validate
passes.
Checking a single expression
To validate one CEL expression before you write it into a file — for example
inside an AI build loop — call the validate_expression agent tool, which runs
the same predicate validator inline. See the
objectstack-formula skill.
In CI
Both commands support --json and exit non-zero on failure:
- name: Validate ObjectStack metadata
run: npx objectstack validate --strict --jsonSee also
- Command Line Interface — full
os validate/os buildreference - Formula & Predicates — the CEL surface and
record.scope - Data Modeling — objects, fields, validations, sharing