Summary
validateCrossReferences() in packages/spec/src/stack.zod.ts currently validates object-level references (workflow→object, view.data→object, app.navigation→object/page/dashboard/report, hook→object, action→flow/page/object, seed dataset→object) — but does not validate field-level references inside views, pages, dashboards, reports, indexes, validation rules, compact layouts, or translation bundles.
Combined with the engine's silent-drop behavior for unknown projection fields (packages/objectql/src/engine.ts:1414), this means a typo or stale rename in any of these metadata files silently produces an empty column / empty form field / missing facet in production, with:
- ✅
pnpm build passing
- ✅
objectstack validate passing
- ✅
objectstack lint passing
- ✅
objectstack doctor passing
- ✅
tsc --noEmit passing
- ✅ No runtime warning, no 4xx/5xx, no log line
Real-world reproduction
Found in objectstack-ai/hotcrm after a recent rename pass. The contact object declares the FK as account:
// src/objects/contact.object.ts
fields: {
account: Field.masterDetail('crm_account', { ... }), // field name = "account"
}
But every downstream consumer references it as crm_account (the FK-named-after-target Salesforce convention the team intended):
// src/views/contact.view.ts
columns: [
{ field: 'crm_account', width: 200 }, // ← not a real field
],
grouping: { fields: [{ field: 'crm_account', order: 'asc' }] },
// src/objects/contact.object.ts (same file as the field!)
indexes: [{ fields: ['crm_account'] }], // ← index never built
compactLayout: ['full_name', 'email', 'crm_account', 'phone'],
validationRules: [{
name: 'email_unique_per_account',
fields: ['email', 'crm_account'], // ← constraint never enforced
}],
// src/translations/zh-CN.ts
crm_contact: { fields: { crm_account: { label: '所属客户' } } },
Database is correct (seed loader resolves the lookup via target object's externalId — that path works):
sqlite> SELECT c.email, c.account, a.name FROM crm_contact c JOIN crm_account a ON a.id = c.account;
john.smith@acme.example.com | 5J4j…AnHy | Acme Corporation
sarah.j@globex.example.com | J91e…rcMN | Globex Industries
…
UI shows the "Account" column completely empty. The engine receives fields: [..., 'crm_account', ...], drops crm_account at line 1414, returns rows without that key, and the grid renders blank. No error anywhere.
Same shape of bug exists across opportunity, quote, contract, case, campaign_member, quote_line_item, opportunity_line_item — 8 objects, ~70 stale field references, all of them slid through every existing gate.
Why the existing gates miss it
| Gate |
What it actually checks |
Why it misses |
ObjectStackDefinitionSchema.safeParse |
Shape (columns[].field is a string) |
Doesn't know what strings are valid |
defineStack → validateCrossReferences() |
Object-level cross-refs only |
Skips columns[].field, sections.fields[], indexes.fields[], etc. |
objectstack lint |
snake_case, label, label-case, i18n coverage |
No reference/field-exists rule |
objectstack doctor |
Lookup cycle, orphan views (view.object), unused objects |
Walks view.object but not column lists |
tsc --noEmit |
column.field: string |
No generic linking view columns to keyof Object['fields'] |
objectql engine |
Drops unknown fields silently |
Defensive by design; hides typos |
objectstack test |
HTTP QA Protocol scenarios |
Behavioral; no static assertion |
Proposal
Four changes, ordered by impact. Each is independently shippable.
1. Extend validateCrossReferences() with field-level checks (highest leverage)
In packages/spec/src/stack.zod.ts, build a fieldsByObject: Map<string, Set<string>> from config.objects, then walk:
- View —
list.columns[].field, list.sort[].field, list.grouping.fields[].field, list.filter[].field, form.sections[].fields[] (string + {field} shape), every entry under listViews.* mirroring the same paths, gallery.coverField, gallery.titleField, gallery.visibleFields[], kanban.groupByField. Target object = view.list.data.object (or per-listView).
- Page —
sections[].fields[], recursively through nested layout containers (tabs[].sections, accordions[].sections, etc.).
- Object (self-reference) —
compactLayout[], indexes[].fields[], validationRules[].fields[], searchLayout[].
- Dashboard — table widget
columns[].accessorKey, chart widget xField/yField/groupBy, KPI widget valueField. Target object = widget's object.
- Report —
columns[].field, groupBy[], aggregates[].field, filters[].field. Target = objectName.
- Translation — for each
translations.objects.{name}.fields.{key}, verify key is in fieldsByObject.get(name). Catches stale translations after a field rename.
For each miss, emit a structured error with a Levenshtein "did you mean":
✗ View 'all_contacts' column[3].field 'crm_account' not found on object 'crm_contact'.
Did you mean 'account'? (available: id, first_name, last_name, account, email, …)
Add a validateFieldReferences() helper called right after validateCrossReferences() so the existing throw-with-bulleted-list code path is reused.
2. Engine: opt-in strict-projection mode for dev
packages/objectql/src/engine.ts line 1414 (and its twin in find() around 1200). Add a config flag:
const dropped = (ast.fields as string[]).filter(f => !known.has(String(f).split('.')[0]));
if (dropped.length > 0) {
if (this.config?.strictUnknownFields) {
throw new EngineError(
`Unknown field(s) on '${objectName}': ${dropped.join(', ')}. ` +
`Available: ${[...known].sort().join(', ')}`
);
} else {
this.logger.warn(`[engine] Dropped unknown field(s) on '${objectName}': ${dropped.join(', ')}`);
}
}
CLI defaults:
objectstack dev → strictUnknownFields: true
objectstack start / production → false (preserve current defensive behavior)
This turns "empty column" into "loud failure during local dev", and into a single warn line in production logs.
3. Generic-typed builders so tsc catches it at write time
defineDataset<TObj>() already proves the pattern — that's why the seed records (account: 'Acme Corporation') compiled correctly while every view did not. Apply the same shape to:
defineView<TObj>({ list: { columns: [{ field: /* keyof TObj['fields'] */ }] } })
definePage<TObj>(…)
defineDashboard widgets carrying an object literal
defineForm<TObj>(…)
Each field: string becomes field: Extract<keyof TObj['fields'], string>. With ObjectStack's existing typed object exports this is a pure-types change — no runtime cost, and authors get red squiggles in the editor before they ever save.
4. Lint rule reference/field-exists (independent CI gate)
packages/cli/src/commands/lint.ts currently has 3 rules. Add a 4th that reuses the walker from (1) so objectstack lint can be wired into CI without depending on defineStack's strict-mode throw path firing (some teams set strict: false).
Acceptance criteria
Notes for the implementer
- The engine's silent-drop is intentional and load-bearing for live multi-version clients — do not remove it, only gate it behind
strictUnknownFields.
view.list.filter and sort entries may be either {field} objects or shorthand strings — handle both.
- Page section
fields entries are union types (string | {field, …}) — same treatment.
- Translations: only validate keys that are direct field names (
fields.{key}); skip options.{value} and other nested namespaces.
- Some teams use
field: 'some_field.nested_path' (relation expansion). The .split('.')[0] rule mirrors what the engine does — preserve that.
- For
defineView typing, look at defineDataset<TObj> in packages/spec/src/data/seed.ts for the exact pattern that's already working in production.
Repro repo
objectstack-ai/hotcrm @ HEAD. After pnpm install && pnpm demo:reset && pnpm dev, open Contacts list — Account column renders blank for all 5 rows despite crm_contact.account being populated correctly in .objectstack/data/hotcrm.db.
Summary
validateCrossReferences()inpackages/spec/src/stack.zod.tscurrently validates object-level references (workflow→object, view.data→object, app.navigation→object/page/dashboard/report, hook→object, action→flow/page/object, seed dataset→object) — but does not validate field-level references inside views, pages, dashboards, reports, indexes, validation rules, compact layouts, or translation bundles.Combined with the engine's silent-drop behavior for unknown projection fields (
packages/objectql/src/engine.ts:1414), this means a typo or stale rename in any of these metadata files silently produces an empty column / empty form field / missing facet in production, with:pnpm buildpassingobjectstack validatepassingobjectstack lintpassingobjectstack doctorpassingtsc --noEmitpassingReal-world reproduction
Found in
objectstack-ai/hotcrmafter a recent rename pass. The contact object declares the FK asaccount:But every downstream consumer references it as
crm_account(the FK-named-after-target Salesforce convention the team intended):Database is correct (seed loader resolves the lookup via target object's externalId — that path works):
UI shows the "Account" column completely empty. The engine receives
fields: [..., 'crm_account', ...], dropscrm_accountat line 1414, returns rows without that key, and the grid renders blank. No error anywhere.Same shape of bug exists across
opportunity,quote,contract,case,campaign_member,quote_line_item,opportunity_line_item— 8 objects, ~70 stale field references, all of them slid through every existing gate.Why the existing gates miss it
ObjectStackDefinitionSchema.safeParsecolumns[].fieldis a string)defineStack→validateCrossReferences()columns[].field,sections.fields[],indexes.fields[], etc.objectstack lintreference/field-existsruleobjectstack doctorview.objectbut not column liststsc --noEmitcolumn.field: stringkeyof Object['fields']objectqlengineobjectstack testProposal
Four changes, ordered by impact. Each is independently shippable.
1. Extend
validateCrossReferences()with field-level checks (highest leverage)In
packages/spec/src/stack.zod.ts, build afieldsByObject: Map<string, Set<string>>fromconfig.objects, then walk:list.columns[].field,list.sort[].field,list.grouping.fields[].field,list.filter[].field,form.sections[].fields[](string +{field}shape), every entry underlistViews.*mirroring the same paths,gallery.coverField,gallery.titleField,gallery.visibleFields[],kanban.groupByField. Target object =view.list.data.object(or per-listView).sections[].fields[], recursively through nested layout containers (tabs[].sections,accordions[].sections, etc.).compactLayout[],indexes[].fields[],validationRules[].fields[],searchLayout[].columns[].accessorKey, chart widgetxField/yField/groupBy, KPI widgetvalueField. Target object = widget'sobject.columns[].field,groupBy[],aggregates[].field,filters[].field. Target =objectName.translations.objects.{name}.fields.{key}, verifykeyis infieldsByObject.get(name). Catches stale translations after a field rename.For each miss, emit a structured error with a Levenshtein "did you mean":
Add a
validateFieldReferences()helper called right aftervalidateCrossReferences()so the existing throw-with-bulleted-list code path is reused.2. Engine: opt-in strict-projection mode for dev
packages/objectql/src/engine.tsline 1414 (and its twin infind()around 1200). Add a config flag:CLI defaults:
objectstack dev→strictUnknownFields: trueobjectstack start/ production →false(preserve current defensive behavior)This turns "empty column" into "loud failure during local dev", and into a single warn line in production logs.
3. Generic-typed builders so
tsccatches it at write timedefineDataset<TObj>()already proves the pattern — that's why the seed records (account: 'Acme Corporation') compiled correctly while every view did not. Apply the same shape to:defineView<TObj>({ list: { columns: [{ field: /* keyof TObj['fields'] */ }] } })definePage<TObj>(…)defineDashboardwidgets carrying anobjectliteraldefineForm<TObj>(…)Each
field: stringbecomesfield: Extract<keyof TObj['fields'], string>. With ObjectStack's existing typed object exports this is a pure-types change — no runtime cost, and authors get red squiggles in the editor before they ever save.4. Lint rule
reference/field-exists(independent CI gate)packages/cli/src/commands/lint.tscurrently has 3 rules. Add a 4th that reuses the walker from (1) soobjectstack lintcan be wired into CI without depending ondefineStack's strict-mode throw path firing (some teams setstrict: false).Acceptance criteria
validateCrossReferences()flagsfieldstrings that don't exist on the referenced object across views, pages, dashboards, reports, object self-references, and translations.View[7].list.columns[3].field), the offending value, the target object, and a "did you mean" suggestion when edit-distance ≤ 2.objectstack devthrows on unknown projection fields;objectstack startkeeps the silent-drop + warn behavior.defineView/definePage/defineFormaccept a generic object type; writing a stale field name produces atscerror.hotcrm@mainsurfaces all ~70 stale refs in a single pass.packages/specandpackages/objectqlstill pass; new tests cover each metadata kind.Notes for the implementer
strictUnknownFields.view.list.filterandsortentries may be either{field}objects or shorthand strings — handle both.fieldsentries are union types (string | {field, …}) — same treatment.fields.{key}); skipoptions.{value}and other nested namespaces.field: 'some_field.nested_path'(relation expansion). The.split('.')[0]rule mirrors what the engine does — preserve that.defineViewtyping, look atdefineDataset<TObj>inpackages/spec/src/data/seed.tsfor the exact pattern that's already working in production.Repro repo
objectstack-ai/hotcrm@ HEAD. Afterpnpm install && pnpm demo:reset && pnpm dev, open Contacts list — Account column renders blank for all 5 rows despitecrm_contact.accountbeing populated correctly in.objectstack/data/hotcrm.db.