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
63 changes: 63 additions & 0 deletions .changeset/unknown-authoring-key-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
"@objectstack/cli": patch
---

feat(spec,cli): report the authored object/field keys that get silently dropped (#3786)

`ObjectSchema` and `FieldSchema` are deliberately not `.strict()`, so a key they
do not declare **parses clean and is stripped on the way to storage**. No error,
no warning — the author configured something and it simply is not there. That is
the ADR-0104 failure class the `FieldSchema` prune tombstone already describes in
prose, and #4120 found five live instances of it inside `@objectstack/spec`
itself: a `pii` toggle, an `indexed` toggle and a `cascadeDelete` select that had
been rendering in Studio for releases while saving nothing.

**New rule — `lintUnknownAuthoringKeys` (advisory).** Every authored key an
object or field sets that its schema does not declare is now reported, naming the
path, the key, and what to do about it:

```
defineStack: objects.crm_case.fields.owner.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.
defineStack: objects.crm_case.capabilities: 'capabilities' is not a declared object
key, so its value is dropped at load — did you mean 'enable'?
```

Two guidance tables carry the difference between a **rename** (`formula` →
`expression`, `cascadeDelete` → `deleteBehavior`, `capabilities` → `enable`, …)
and a **retirement** with no successor (`pii`, `indexed`, `encrypted`,
`startingNumber`, …). A retirement deliberately suppresses the edit-distance
fallback: `pii` is three edits from `min`, and "did you mean min?" reads as real
advice while being nonsense. Plain typos still get the fallback (`requred` →
`required`). Every entry was found in the wild, and a test asserts each rename
target is a key the schema really declares — so the advice cannot rot into
pointers at keys that no longer exist.

**It never rejects.** Making these two schemas strict is the destination — the
enforce side of ADR-0049, and the tier programme #4001 began on the flow and
permission schemas. But `object` and `field` are the two most-authored surfaces
in the protocol, so flipping them rejects metadata that parses today: a migration
event for every consumer, and one that deserves to be scheduled on evidence
rather than guessed at. This produces that evidence and costs nobody a migration.

Wired into every layer that performs the discard, all **pre-parse** (the parse is
what eats the key, so after it there is nothing left to report):

- **`defineStack`** — warns on the console, once per distinct path, in strict
*and* non-strict mode, since the key is dropped either way.
- **`os validate`** — a non-blocking warning, and included in `--json` output
rather than computed and discarded.
- **`os build` / `os compile`** — the same non-blocking warning. `defineStack`
already covers configs authored through it; this catches the ones that skip it
(a plain object default-export, `strict: false`), which would otherwise emit an
artifact with the key quietly gone.

Verified against the three first-party example apps (`app-todo`, `app-crm`,
`app-showcase`): all clean, no false positives.

New exports from `@objectstack/spec` (root and `/data`): `lintUnknownAuthoringKeys`,
`formatUnknownAuthoringKey`, `FIELD_KEY_GUIDANCE`, `OBJECT_KEY_GUIDANCE`, and the
`UnknownAuthoringKeyFinding` / `AuthoringKeySurface` types. No authoring change is
required by this release: metadata that loaded before still loads, unchanged.
36 changes: 36 additions & 0 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,41 @@ literal (it comes from React state or a variable), a usage carrying a `{...sprea
a chart given static `data` (its columns are the author's own), and objects
another package defines.

### 9. Object and field keys the schema never declared

`ObjectSchema` and `FieldSchema` are deliberately not strict, so a key they do
not declare **parses clean and is dropped** on the way to storage. Nothing fails;
the setting simply is not there.

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

This is **advisory** — the stack still loads. Strict rejection is where these
schemas are headed (ADR-0049 enforce-or-remove), but they are the two
most-authored surfaces in the protocol, 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.

## The one gate, two entry points

`os validate` and `os build` (alias of `os compile`) run the **same** validator:
Expand All @@ -228,6 +263,7 @@ another package defines.
| View references — form targets, view-key collisions (#2554) | ✓ | ✓ |
| Flow authoring anti-patterns (#1874) | ✓ | ✓ |
| Liveness author-warnings | ✓ | ✓ |
| Undeclared object/field keys (#3786) | ✓ | ✓ |
| Emits `dist/objectstack.json` | — | ✓ |

So `os validate` is the fast inner-loop check (no artifact); `os build` is what
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import path from 'path';
import fs from 'fs';
import chalk from 'chalk';
import { ZodError } from 'zod';
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
import {
ObjectStackDefinitionSchema,
normalizeStackInput,
lintUnknownAuthoringKeys,
formatUnknownAuthoringKey,
type ConversionNotice,
} from '@objectstack/spec';
import { loadConfig } from '../utils/config.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { validateStackExpressions } from '@objectstack/lint';
Expand Down Expand Up @@ -251,6 +257,20 @@ export default class Compile extends Command {
}
}

// 3b-ter. [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and
// so drop silently on the way to storage. PRE-parse for the same
// reason as the rule above. `defineStack` already warns for configs
// authored through it; this covers the ones that skip it (a plain
// object default-export, `strict: false`) and would otherwise emit an
// artifact with the key quietly gone. Advisory, never fatal.
const unknownKeyFindings = lintUnknownAuthoringKeys(normalized as Record<string, unknown>);
if (unknownKeyFindings.length > 0 && !flags.json) {
printWarning(`Undeclared authoring keys (${unknownKeyFindings.length}) — dropped at load (#3786)`);
for (const f of unknownKeyFindings.slice(0, 50)) {
console.log(` • ${formatUnknownAuthoringKey(f)}`);
}
}

// 3c. Widget-binding diagnostics (issues #1719/#1721) — semantic checks
// that need the widget's `dataset` reference resolved to its dataset
// and `dimensions`/`values` resolved to declared names. Errors are
Expand Down
22 changes: 20 additions & 2 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { createRequire } from 'node:module';
import { join, dirname } from 'node:path';
import chalk from 'chalk';
import { ZodError } from 'zod';
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
import {
ObjectStackDefinitionSchema,
normalizeStackInput,
lintUnknownAuthoringKeys,
formatUnknownAuthoringKey,
type ConversionNotice,
} from '@objectstack/spec';
import { loadConfig } from '../utils/config.js';
import { validateStackExpressions } from '@objectstack/lint';
import { validateListViewMode } from '@objectstack/lint';
Expand Down Expand Up @@ -85,6 +91,14 @@ export default class Validate extends Command {
const normalized = normalizeStackInput(config as Record<string, unknown>, {
onConversionNotice: (n) => conversionNotices.push(n),
});
// [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so drop
// silently. PRE-parse for the same reason the visibility rule below is:
// the parse is what strips them, so `result.data` no longer carries the
// key the author actually wrote. Computed here rather than down in the
// warnings section so the `--json` path reports it too — the
// "computed, then discarded" shape this file already had to fix once.
const unknownKeyWarnings = lintUnknownAuthoringKeys(normalized as Record<string, unknown>)
.map(formatUnknownAuthoringKey);
const result = ObjectStackDefinitionSchema.safeParse(normalized);

if (!result.success) {
Expand Down Expand Up @@ -732,7 +746,7 @@ export default class Validate extends Command {
// the suite's warnings, though the failure path (above) and the console
// both did. Same shape of bug as the dropped errors — computed, then
// discarded — so it is fixed rather than reproduced under a new name.
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...refWarnings, ...readonlyWriteWarnings, ...authoringLintWarnings, ...securityAdvisories, ...capProviderWarnings],
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...refWarnings, ...readonlyWriteWarnings, ...authoringLintWarnings, ...unknownKeyWarnings, ...securityAdvisories, ...capProviderWarnings],
conversions: conversionNotices,
specVersionGap: specGap,
duration: timer.elapsed(),
Expand All @@ -758,6 +772,10 @@ export default class Validate extends Command {
warnings.push(`${f.where}: ${f.message} — ${f.hint}`);
}

// [#3786] Undeclared object/field keys — computed pre-parse above,
// alongside `normalized`, for the same reason.
warnings.push(...unknownKeyWarnings);

// ADR-0087 D2 conversion notices: the source used a deprecated shape that
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
Expand Down
12 changes: 12 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"AUDIENCE_ANCHOR_POSITIONS (const)",
"Agent (type)",
"ApplyConversionsOptions (interface)",
"AuthoringKeySurface (type)",
"BUILTIN_IDENTITY_METADATA (const)",
"BUILTIN_IDENTITY_NAMES (const)",
"BUILTIN_IDENTITY_ORG_ADMIN (const)",
Expand Down Expand Up @@ -49,6 +50,7 @@
"ExpressionMetaSchema (const)",
"ExpressionSchema (const)",
"F (const)",
"FIELD_KEY_GUIDANCE (const)",
"GUEST_POSITION (const)",
"MAP_SUPPORTED_FIELDS (const)",
"MEMBERSHIP_ROLE_ADMIN (const)",
Expand All @@ -69,6 +71,7 @@
"MigrationStep (interface)",
"MigrationTodo (interface)",
"NormalizeStackInputOptions (interface)",
"OBJECT_KEY_GUIDANCE (const)",
"ORGANIZATION_ADMIN (const)",
"ORGANIZATION_ADMIN_GRANTS (const)",
"ORGANIZATION_ADMIN_NO_BYPASS (const)",
Expand Down Expand Up @@ -101,6 +104,7 @@
"SurfaceDiff (interface)",
"TemplateExpressionInputSchema (const)",
"Tool (type)",
"UnknownAuthoringKeyFinding (interface)",
"ViewKeyCollision (interface)",
"applyConversions (function)",
"applyConversionsToFlow (function)",
Expand Down Expand Up @@ -145,10 +149,12 @@
"expression (function)",
"findClosestMatches (function)",
"formatSuggestion (function)",
"formatUnknownAuthoringKey (function)",
"formatZodError (function)",
"formatZodIssue (function)",
"isAggregatedViewContainer (function)",
"isKnownPlatformCapability (function)",
"lintUnknownAuthoringKeys (function)",
"mapMembershipRole (function)",
"normalizeMetadataCollection (function)",
"normalizePluginMetadata (function)",
Expand Down Expand Up @@ -182,6 +188,7 @@
"ApiOperation (type)",
"ApiOperationSchema (const)",
"ApiPrimitive (type)",
"AuthoringKeySurface (type)",
"AutonumberToken (type)",
"BOOLEAN_VALUE_TYPES (const)",
"BaseEngineOptions (type)",
Expand Down Expand Up @@ -327,6 +334,7 @@
"ExternalTable (type)",
"ExternalTableSchema (const)",
"FIELD_GROUP_SYSTEM_FIELDS (const)",
"FIELD_KEY_GUIDANCE (const)",
"FILE_REFERENCE_TYPES (const)",
"FILTER_LOGIC_CASES (const)",
"FILTER_LOGIC_ROWS (const)",
Expand Down Expand Up @@ -420,6 +428,7 @@
"NoSQLTransactionOptionsSchema (const)",
"NormalizedFilter (type)",
"NormalizedFilterSchema (const)",
"OBJECT_KEY_GUIDANCE (const)",
"ObjectAccessConfig (type)",
"ObjectAccessConfigSchema (const)",
"ObjectCapabilities (type)",
Expand Down Expand Up @@ -546,6 +555,7 @@
"TransformType (const)",
"UniqueScope (type)",
"UniqueScopeSchema (const)",
"UnknownAuthoringKeyFinding (interface)",
"VALID_AST_OPERATORS (const)",
"ValidationRule (type)",
"ValidationRuleSchema (const)",
Expand All @@ -571,6 +581,7 @@
"deriveRecordSurface (function)",
"effectiveOperationsArray (function)",
"fieldForm (const)",
"formatUnknownAuthoringKey (function)",
"hasDynamicTokens (function)",
"hookForm (const)",
"isApiOperationAllowed (function)",
Expand All @@ -588,6 +599,7 @@
"isTenancyDisabled (function)",
"isTitleEligible (function)",
"isUniqueDeclared (function)",
"lintUnknownAuthoringKeys (function)",
"missingFieldValues (function)",
"nextUtcCalendarDay (function)",
"objectForm (const)",
Expand Down
Loading
Loading