Skip to content

Latest commit

 

History

History
307 lines (242 loc) · 13.7 KB

File metadata and controls

307 lines (242 loc) · 13.7 KB
title Validating Metadata
description 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 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.

3. Dead action/route references

A dashboard header.actions[] button (and a widget's actionUrl) names a target: a script/modal action, or a url route. Nothing in the schema checks that the target exists, so a button can ship pointing at an action defined nowhere — it renders and then silently does nothing when clicked. This is ADR-0049's "declared ≠ enforced" gate applied to references.

header: {
  actions: [
    // ✗ script/modal target resolves to no defined action → error
    { label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' },
    // ✓ modal <verb>_<object> against a real `opportunity` object
    { label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' },
    // ⚠ url path matching no in-app route → warning
    { label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' },
  ],
}

script/modal targets that resolve nowhere fail 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 → warning

Which object a component binds follows dataSource.objectproperties.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.

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.

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.

The one gate, two entry points

os validate and os build (alias of os compile) run the same validator:

os validate os build
Protocol schema (Zod)
CEL / predicate validation
Widget-binding integrity
Dashboard action/route references (ADR-0049)
Object & action name references (#3583)
Page-component field bindings (#3583)
Chart bindings outside dashboards (#3583)
Navigation vs. granted access (ADR-0090 D6)
Security posture (ADR-0090 — e.g. every custom object declares sharingModel)
Emits dist/objectstack.json

So os validate is the fast inner-loop check (no artifact); os build is what you run when you need the deployable artifact. A config that passes os validate will not fail os build on schema/predicate/binding grounds. Both entry points also check SDUI styling (ADR-0065), and os validate additionally runs a set of view- and page-shape checks — list-view navigation modes (ADR-0053), view container shape, and JSX/React page sources (ADR-0080/0081) — that catch UI metadata which would otherwise be silently dropped.

A clean run walks each gate and reports timing:

◆ Validate
────────────────────────────────────────
  → Loading configuration...
  Config: /path/to/support-desk/objectstack.config.ts
  Load time: 21ms
  → Validating against ObjectStack Protocol...
  → Validating expressions (ADR-0032)...
  → Checking list-view navigation modes (ADR-0053)...
  → Checking view container shape...
  → Checking dashboard widget bindings (ADR-0021)...
  → Checking dashboard action references (ADR-0049)...
  → Checking SDUI styling (ADR-0065)...
  → Checking JSX-source pages (ADR-0080)...
  → Checking React-source pages (ADR-0081)...
  → Checking React-source page props (ADR-0081)...
  → Checking source-page styling (ADR-0065)...
  → Checking capability references (ADR-0066)...
  → Checking flow trigger wiring...
  → Checking security posture (ADR-0090 D7)...

  ✓ Validation passed (64ms)

  Support Desk v0.1.0
  Data: 1 Objects  6 Fields
  UI: 1 Apps  1 Views  1 Actions

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` is a **separate** pass — style and convention checks (snake_case naming, required labels, namespace prefixes, data-model patterns). Run it too, but it does not replace `os validate`, and `os validate` does not replace it.

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