ObjectStackObjectStack

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 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.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. 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.

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; a field-level index flag
  built no index (#2377). Declare the index in the object's `indexes[]`.

Plain typos get a "did you mean" (requredrequired); 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 → 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 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 warning

An 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 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)✓ᵈ
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 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