Validating Metadata
Why ObjectStack metadata mistakes fail silently at runtime, and the one command that catches them at author time — run it after every metadata edit.
Validating Metadata
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.
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; a field-level index flag
built no index (#2377). 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 objectName="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 objectName="<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 objectName="crm_account" filters={['revenu', '=', stage]} />
// ↑ error, not a warningAn unknown column in a predicate is not a skipped column — the predicate can
never match, the driver's "no such column" is swallowed, and the list comes back
empty and indistinguishable from "there is no data". 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.
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) | ✓ | ✓ | ✓ | ✓ᵈ |
| 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) | — | — | — | ✓ᶠ |
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 — the last
one carries two letters because page already holds ᵖ. 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, and #4716 crossed the five remaining gating object rules —
functional completeness, managed API methods, autonumber formats, and both
validation-rule enforceability checks — onto object), 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 it judges collections the per-write snapshot does not carry: 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.
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; 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,
positions, apps — the snapshot has carried permissions and books since
#8309 and datasets since #7529, so those three are no longer in this class);
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
one deliberate exception is the platform-schedule row (#6285), runtime-only by ruling:
both of its inputs are facts about the deployment (the organization this
write lands in, and whether this deployment walls organizations), and a build
machine's environment is a false signal for them — so os build must not judge
it at all.
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.
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.
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 (41)...
→ Checking capability providers (#3366)...
→ 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