ObjectStackObjectStack

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 artifact

In 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 prefixerror — 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 itwarning — 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 → warning

Which 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 → error

An 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's

avg 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` → warning

Advisory: 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` → error

Both 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 → warning

Checked 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 read

They 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 warning

An 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 validateos buildos lintruntime 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.

  1. 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 validate and os lint exited 0 on a stack os build refused.
  2. 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 own packages[] 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 leading collection[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 a package '<id>' — prefix in its where. That key is position-insensitive, not collision-proof: two entries that render the same where share one, so the bound is what the key can tell apart and no wider — packages/cli/src/utils/artifact-packages.ts states it, and why. A project with no packages[] 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 plugins

On 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/spec

Rule 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 --json

See also

On this page