Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .changeset/exported-types-no-longer-resolve-to-any.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
---
"@objectstack/spec": minor
"@objectstack/platform-objects": patch
"@objectstack/metadata-protocol": patch
"@objectstack/driver-mongodb": patch
---

fix(spec): five exported symbols resolved to `any` — type the recursive schemas and gate it in CI (#4171)

A recursive Zod schema needs an explicit annotation to break its circular
inference, and five of them took the cheapest one available:

```ts
export const NavigationItemSchema: z.ZodType<any> = z.lazy(() => …);
export type NavigationItem = z.infer<typeof NavigationItemSchema>; // → any
```

It compiles, it validates correctly at runtime, and it silently throws the type
away. `NavigationItem`, `FormField`, `JoinNode` and `NormalizedFilter` were all
`any` on the published surface, plus `FieldNodeSchema` — which had no exported
type alias yet, so `z.infer<typeof FieldNodeSchema>` was `any` and
`QueryAST['fields']` with it.

That is worse than a missing export. #4115 tells every consumer that a local
declaration under a spec export's name must be replaced by a binding to the
spec — and for these, obeying it **replaced a precise type with `any`**.
objectui's `NavigationItem` is a 118-line documented interface (`recordId`
template variables, `requiresObject` / `requiresService` capability gates,
`filters` precedence); every key of it exists in the spec's version, so by every
available signal it read as a redundant fork safe to delete. Deleting it swapped
a fully-typed interface for `any`, with no compile error anywhere to say so.

It is hard to catch by inspection because `any` is mutually assignable with
everything, so the natural "are these the same type?" check answers *yes* in both
directions and recommends precisely the wrong action. Same failure family as
#4075's `[key: string]: any` on `ActionDef`: a type that agrees with everything
reads as agreement.

**Now annotated with the real type**, using the pattern `QueryAST` already
follows in `data/query.zod.ts` — infer the non-recursive part, tie the recursive
knot in the type, so the keys stay derived from the schema instead of being
hand-maintained beside it:

```ts
const BaseXSchema = z.object({ …every non-recursive key });
export type X = z.infer<typeof BaseXSchema> & { children?: X[] };
export const XSchema: z.ZodType<X> = z.lazy(() => BaseXSchema.extend({
children: z.array(XSchema).optional(),
}));
```

`z.infer` now resolves to the type it should always have been: `NavigationItem`
is the nine-branch discriminated union, `FormField` the 30-key form-field
contract (with `visibleOn` absent by construction — ADR-0089 D2 folds it into
`visibleWhen` at the boundary), `JoinNode` and the newly exported `FieldNode`
the query AST nodes, `NormalizedFilter` the normalized filter AST. Runtime
validation is unchanged: every schema parses exactly what it parsed before.

**What the types immediately caught**, none of it visible while they were `any`:

- `account.app.ts` set `defaultOpen` on three nav groups — a key the spec has
never declared. It worked only because objectui's `NavigationRenderer` still
falls back to that legacy alias. Fixed at the producer per Prime Directive
#12: the canonical key is `expanded`.
- The MongoDB driver built its projection with `projection[field] = 1` over
`query.fields`, so a relationship `FieldNode` would have keyed the projection
on `"[object Object]"`. It now reads the node's field name.
- `setup.app.ts`, `studio.app.ts` and `setup-nav.contributions.ts` are annotated
with the PARSED `App` / `NavigationContribution` types but omitted
`.default()`ed keys (`expanded`, `target`), as did the form fields
`metadata-protocol` synthesizes for `getUiView` (`span`). Each now states the
default it was relying on, matching what the surrounding literals already do
for `active` / `isDefault` / `collapsible` / `collapsed` / `columns`.

**Gated, not just fixed** (`check:exported-any`, wired into the required
`TypeScript Type Check` job). `api-surface.json` records that an export *exists*
and never what it *resolves to*, which is how these survived a whole major with
every gate green. The new scan reads the built `.d.ts` a consumer's import
actually resolves to and fails on any exported type that resolves to `any` — or
any exported schema whose output is `any`, the root cause, and the only reason
`FieldNodeSchema` was visible at all. Its `KNOWN_ANY` ledger is shrink-only and
currently empty. It self-tests against the real zod first, so if the internals it
reads are ever renamed the gate fails loudly instead of quietly passing
everything forever.
13 changes: 13 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,19 @@ jobs:
- name: Check @objectstack/spec public API surface
run: pnpm --filter @objectstack/spec run check:api-surface

# Same surface, the other axis: api-surface.json records that an export
# EXISTS, never what it resolves to — so four exported types sat at `any`
# across a whole major with every gate green (#4171). #4115 tells consumers
# to replace a local declaration with the spec import, which for those four
# traded a precise type for one that constrains nothing, silently: `any` is
# mutually assignable with everything, so the check that would catch the
# swap reports "identical, safe to re-export". Reads the built dist a
# consumer's import actually resolves to, so it runs after the build step
# with the other consumer gates. Self-tests first — a scan whose green
# result is "nothing found" has to prove it can still find something.
- name: Check no exported spec type resolves to `any`
run: pnpm --filter @objectstack/spec run check:exported-any

# Anti-drift for the skill EXAMPLES, not just the skill reference indexes
# (#3094). The TypeScript in skills/ is the first thing an AI copies when
# authoring metadata, yet nothing type-checked it — so it rotted silently
Expand Down
18 changes: 14 additions & 4 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -274,10 +274,20 @@ believe it, and before you file a bug about `main` being red. (Two phantom "brea
removals" this way while writing this section; `check:generated` now prints this caveat
inline when that gate is the one failing.)

`check:liveness`, `check:empty-state`, `check:skill-examples` and
`check:react-conformance` are pure checks with no generator — a failure there is a real
finding to fix, not an artifact to regenerate. `check:generated` names them as
deliberately not run, so its "all up to date" never reads as "everything passed".
`check:liveness`, `check:empty-state`, `check:skill-examples`,
`check:react-conformance` and `check:exported-any` are pure checks with no generator — a
failure there is a real finding to fix, not an artifact to regenerate. `check:generated`
names them as deliberately not run, so its "all up to date" never reads as "everything
passed".

`check:exported-any` is the one of those that also reads the built `dist/*.d.ts`, so the
stale-`dist` caveat above applies to it too. It asks the other half of the
`api-surface.json` question: that snapshot records an export *exists*, never what it
*resolves to*, which is how five exported symbols sat at `any` for a whole major with
every gate green (#4171). A recursive Zod schema needs an annotation to break its
circular inference, and `z.ZodType<any>` compiles, validates correctly, and silently
throws the type away — annotate with the type instead (`QueryAST` in
`src/data/query.zod.ts` is the pattern).

Two generators have **no** gate at all — `gen:openapi` and `gen:sbom`. Nothing verifies
their output is current; the script reports that each run rather than staying silent
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,12 +158,12 @@ Column footer summary configuration
| **span** | `Enum<'auto' \| 'full'>` | optional | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': whole row at any column count. Prefer this over the absolute `colSpan`. |
| **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) |
| **language** | `string` | optional | Code editor language (for type=code) |
| **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types |
| **keyField** | `{ field?: string; label?: string; placeholder?: string; helpText?: string; … }` | optional | Key column config for record-typed fields |
| **dependsOn** | `string` | optional | Parent field name for cascading |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). e.g. P`record.priority == 'urgent'` |
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
| **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). |
| **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types |


---
Expand Down
9 changes: 8 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2841,7 +2841,14 @@ export class ObjectStackProtocolImplementation implements
readonly: fields[f]?.readonly,
type: fields[f]?.type,
// Default to 2 columns for most, 1 for textareas
colSpan: (fields[f]?.type === 'textarea' || fields[f]?.type === 'html') ? 2 : 1
colSpan: (fields[f]?.type === 'textarea' || fields[f]?.type === 'html') ? 2 : 1,
// `FormField.span` defaults to 'auto'; this view is hand-built
// rather than run through `FormFieldSchema.parse()`, so the
// default is spelled out to make the returned object a real
// parsed `View` (the same reason the section below states its
// own `collapsible` / `collapsed` / `columns` defaults). Was
// unnoticed while `FormField` resolved to `any` (#4171).
span: 'auto' as const
}));

return {
Expand Down
7 changes: 7 additions & 0 deletions packages/platform-objects/src/apps/account.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,13 @@ export const ACCOUNT_APP: App = {
// No `requiredPermissions`: any authenticated user must be able to
// manage their own linked accounts / personal OAuth apps. RLS on each
// object scopes rows to the caller.
//
// Group open-state is `expanded` — the spec key. These entries said
// `defaultOpen`, objectui's legacy alias, which the spec has never declared
// and `.strict()` would reject; it worked only because objectui's
// NavigationRenderer still falls back to it, and because `NavigationItem`
// resolved to `any` so nothing checked the key here (#4171). Per Prime
// Directive #12 the producer is the place to fix that, not the renderer.
navigation: [
// Profile is the canonical landing — a hand-written React settings card
// (Vercel/Linear style) registered in the Console SPA as
Expand Down
25 changes: 15 additions & 10 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,11 @@ import type { NavigationContribution } from '@objectstack/spec/ui';

const BASE_PRIORITY = 100;

// `target: '_self'` on every `url` entry is the schema's own default, spelled
// out: this array is annotated with the PARSED type, where a `.default()`ed key
// is required. Omitting it was only possible while `NavigationItem` resolved to
// `any` and nothing checked these literals at all (#4171).

// Marketplace entries (browse / installed) moved to
// @objectstack/cloud-connection's marketplace plugins (cloud ADR-0009:
// the nav lives and dies with the capability — no plugin, no entry).
Expand DownExpand Up@@ -97,7 +102,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
group: 'group_configuration',
priority: BASE_PRIORITY,
items: [
{ id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_hub', type: 'url', target: '_self', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] },
// Workspace identity first — Localization (order 2) and Company (order 3)
// are the lowest-`order` settings manifests and the first thing a new
// company admin configures. They ship as `service-settings` manifests
Expand All@@ -106,15 +111,15 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
// admin consoles (Salesforce "Company Information", ServiceNow) surface
// both directly. No `requiredPermissions` — matches Branding (read perm is
// the app's base `setup.access`).
{ id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' },
{ id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' },
{ id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' },
{ id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_storage', type: 'url', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_ai', type: 'url', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_knowledge', type: 'url', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_feature_flags', type: 'url', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' },
{ id: 'nav_settings_localization', type: 'url', target: '_self', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' },
{ id: 'nav_settings_company', type: 'url', target: '_self', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' },
{ id: 'nav_settings_branding', type: 'url', target: '_self', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' },
{ id: 'nav_settings_auth', type: 'url', target: '_self', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_mail', type: 'url', target: '_self', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_storage', type: 'url', target: '_self', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_ai', type: 'url', target: '_self', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_knowledge', type: 'url', target: '_self', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_feature_flags', type: 'url', target: '_self', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' },
],
},
{
Expand Down
13 changes: 13 additions & 0 deletions packages/platform-objects/src/apps/setup.app.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,12 +47,18 @@ export const SETUP_APP: App = {
requiredPermissions: ['setup.access'],
// Shell only — the stable group anchors. Children are supplied by
// `navigationContributions` from the packages that own the objects.
//
// `expanded: false` is the schema's own default, spelled out: this literal is
// annotated with the PARSED `App` type, where a `.default()`ed key is
// required. Omitting it was only possible while `NavigationItem` resolved to
// `any` and nothing checked these entries at all (#4171).
navigation: [
{
id: 'group_overview',
type: 'group',
label: 'Overview',
icon: 'layout-dashboard',
expanded: false,
requiredPermissions: ['manage_platform_settings'],
children: [],
},
Expand All@@ -61,27 +67,31 @@ export const SETUP_APP: App = {
type: 'group',
label: 'Apps',
icon: 'package',
expanded: false,
children: [],
},
{
id: 'group_people_org',
type: 'group',
label: 'People & Organization',
icon: 'users',
expanded: false,
children: [],
},
{
id: 'group_access_control',
type: 'group',
label: 'Access Control',
icon: 'shield',
expanded: false,
children: [],
},
{
id: 'group_approvals',
type: 'group',
label: 'Approvals',
icon: 'check-circle',
expanded: false,
requiredPermissions: ['manage_platform_settings'],
children: [],
},
Expand All@@ -90,13 +100,15 @@ export const SETUP_APP: App = {
type: 'group',
label: 'Configuration',
icon: 'sliders-horizontal',
expanded: false,
children: [],
},
{
id: 'group_diagnostics',
type: 'group',
label: 'Diagnostics',
icon: 'stethoscope',
expanded: false,
requiredPermissions: ['manage_platform_settings'],
children: [],
},
Expand All@@ -105,6 +117,7 @@ export const SETUP_APP: App = {
type: 'group',
label: 'Integrations',
icon: 'plug',
expanded: false,
requiredPermissions: ['manage_platform_settings'],
children: [],
},
Expand Down
Loading
Loading