Skip to content

Commit 8186a70

Browse files
authored
feat(spec,cli): report the authored object/field keys that get silently dropped (#3786) (#4148)
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 setting 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 this package. `lintUnknownAuthoringKeys` reports every such key with what to do about it. Two guidance tables separate a rename (`formula` → `expression`, `capabilities` → `enable`) from a retirement with no successor (`pii`, `indexed`, `encrypted`); a retirement suppresses the edit-distance fallback on purpose, since the nearest key by spelling would read as advice while being noise. Plain typos still get it. It never rejects. Strict is the destination — the enforce side of ADR-0049, the tier programme #4001 began on flow and permission — but object and field are the two most-authored surfaces in the protocol, so the tightening gets scheduled on what this finds rather than guessed at. Wired pre-parse into all three layers that perform the discard, since after the parse there is nothing left to report: `defineStack`, `os validate` (including `--json`), and `os build`/`os compile` for configs that skip `defineStack`. Verified clean against app-todo, app-crm and app-showcase — no false positives.
1 parent dc477bc commit 8186a70

10 files changed

Lines changed: 579 additions & 3 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(spec,cli): report the authored object/field keys that get silently dropped (#3786)
7+
8+
`ObjectSchema` and `FieldSchema` are deliberately not `.strict()`, so a key they
9+
do not declare **parses clean and is stripped on the way to storage**. No error,
10+
no warning — the author configured something and it simply is not there. That is
11+
the ADR-0104 failure class the `FieldSchema` prune tombstone already describes in
12+
prose, and #4120 found five live instances of it inside `@objectstack/spec`
13+
itself: a `pii` toggle, an `indexed` toggle and a `cascadeDelete` select that had
14+
been rendering in Studio for releases while saving nothing.
15+
16+
**New rule — `lintUnknownAuthoringKeys` (advisory).** Every authored key an
17+
object or field sets that its schema does not declare is now reported, naming the
18+
path, the key, and what to do about it:
19+
20+
```
21+
defineStack: objects.crm_case.fields.owner.pii: 'pii' is not a declared field key,
22+
so its value is dropped at load — the `dataQuality` governance family was pruned
23+
in 2026-06 as dead in both layers — it enforced nothing.
24+
defineStack: objects.crm_case.capabilities: 'capabilities' is not a declared object
25+
key, so its value is dropped at load — did you mean 'enable'?
26+
```
27+
28+
Two guidance tables carry the difference between a **rename** (`formula`
29+
`expression`, `cascadeDelete``deleteBehavior`, `capabilities``enable`, …)
30+
and a **retirement** with no successor (`pii`, `indexed`, `encrypted`,
31+
`startingNumber`, …). A retirement deliberately suppresses the edit-distance
32+
fallback: `pii` is three edits from `min`, and "did you mean min?" reads as real
33+
advice while being nonsense. Plain typos still get the fallback (`requred`
34+
`required`). Every entry was found in the wild, and a test asserts each rename
35+
target is a key the schema really declares — so the advice cannot rot into
36+
pointers at keys that no longer exist.
37+
38+
**It never rejects.** Making these two schemas strict is the destination — the
39+
enforce side of ADR-0049, and the tier programme #4001 began on the flow and
40+
permission schemas. But `object` and `field` are the two most-authored surfaces
41+
in the protocol, so flipping them rejects metadata that parses today: a migration
42+
event for every consumer, and one that deserves to be scheduled on evidence
43+
rather than guessed at. This produces that evidence and costs nobody a migration.
44+
45+
Wired into every layer that performs the discard, all **pre-parse** (the parse is
46+
what eats the key, so after it there is nothing left to report):
47+
48+
- **`defineStack`** — warns on the console, once per distinct path, in strict
49+
*and* non-strict mode, since the key is dropped either way.
50+
- **`os validate`** — a non-blocking warning, and included in `--json` output
51+
rather than computed and discarded.
52+
- **`os build` / `os compile`** — the same non-blocking warning. `defineStack`
53+
already covers configs authored through it; this catches the ones that skip it
54+
(a plain object default-export, `strict: false`), which would otherwise emit an
55+
artifact with the key quietly gone.
56+
57+
Verified against the three first-party example apps (`app-todo`, `app-crm`,
58+
`app-showcase`): all clean, no false positives.
59+
60+
New exports from `@objectstack/spec` (root and `/data`): `lintUnknownAuthoringKeys`,
61+
`formatUnknownAuthoringKey`, `FIELD_KEY_GUIDANCE`, `OBJECT_KEY_GUIDANCE`, and the
62+
`UnknownAuthoringKeyFinding` / `AuthoringKeySurface` types. No authoring change is
63+
required by this release: metadata that loaded before still loads, unchanged.

content/docs/deployment/validating-metadata.mdx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,41 @@ literal (it comes from React state or a variable), a usage carrying a `{...sprea
209209
a chart given static `data` (its columns are the author's own), and objects
210210
another package defines.
211211

212+
### 9. Object and field keys the schema never declared
213+
214+
`ObjectSchema` and `FieldSchema` are deliberately not strict, so a key they do
215+
not declare **parses clean and is dropped** on the way to storage. Nothing fails;
216+
the setting simply is not there.
217+
218+
```ts
219+
fields: {
220+
ssn: { label: 'SSN', type: 'text', pii: true, indexed: true },
221+
// ↑ neither is a FieldSchema key → both dropped
222+
}
223+
```
224+
225+
Each one is reported with what to do about it — a rename where the concept
226+
survives under another key, or the reason it was retired where it does not:
227+
228+
```
229+
objects.employee.fields.ssn.pii: 'pii' is not a declared field key, so its value
230+
is dropped at load — the `dataQuality` governance family was pruned in 2026-06
231+
as dead in both layers — it enforced nothing.
232+
objects.employee.fields.ssn.indexed: 'indexed' is not a declared field key, so its
233+
value is dropped at load — never a FieldSchema key; a field-level index flag
234+
built no index (#2377). Declare the index in the object's `indexes[]`.
235+
```
236+
237+
Plain typos get a "did you mean" (`requred``required`); a retired key does
238+
not, because the nearest declared key by spelling would be noise rather than
239+
advice.
240+
241+
This is **advisory** — the stack still loads. Strict rejection is where these
242+
schemas are headed (ADR-0049 enforce-or-remove), but they are the two
243+
most-authored surfaces in the protocol, so the tightening is scheduled on what
244+
this check finds rather than assumed. `defineStack` reports the same findings at
245+
config-load time, so an author sees them without running the CLI at all.
246+
212247
## The one gate, two entry points
213248

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

233269
So `os validate` is the fast inner-loop check (no artifact); `os build` is what

packages/cli/src/commands/compile.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import path from 'path';
55
import fs from 'fs';
66
import chalk from 'chalk';
77
import { ZodError } from 'zod';
8-
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
8+
import {
9+
ObjectStackDefinitionSchema,
10+
normalizeStackInput,
11+
lintUnknownAuthoringKeys,
12+
formatUnknownAuthoringKey,
13+
type ConversionNotice,
14+
} from '@objectstack/spec';
915
import { loadConfig } from '../utils/config.js';
1016
import { lowerCallables } from '../utils/lower-callables.js';
1117
import { validateStackExpressions } from '@objectstack/lint';
@@ -251,6 +257,20 @@ export default class Compile extends Command {
251257
}
252258
}
253259

260+
// 3b-ter. [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and
261+
// so drop silently on the way to storage. PRE-parse for the same
262+
// reason as the rule above. `defineStack` already warns for configs
263+
// authored through it; this covers the ones that skip it (a plain
264+
// object default-export, `strict: false`) and would otherwise emit an
265+
// artifact with the key quietly gone. Advisory, never fatal.
266+
const unknownKeyFindings = lintUnknownAuthoringKeys(normalized as Record<string, unknown>);
267+
if (unknownKeyFindings.length > 0 && !flags.json) {
268+
printWarning(`Undeclared authoring keys (${unknownKeyFindings.length}) — dropped at load (#3786)`);
269+
for (const f of unknownKeyFindings.slice(0, 50)) {
270+
console.log(` • ${formatUnknownAuthoringKey(f)}`);
271+
}
272+
}
273+
254274
// 3c. Widget-binding diagnostics (issues #1719/#1721) — semantic checks
255275
// that need the widget's `dataset` reference resolved to its dataset
256276
// and `dimensions`/`values` resolved to declared names. Errors are

packages/cli/src/commands/validate.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { createRequire } from 'node:module';
66
import { join, dirname } from 'node:path';
77
import chalk from 'chalk';
88
import { ZodError } from 'zod';
9-
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
9+
import {
10+
ObjectStackDefinitionSchema,
11+
normalizeStackInput,
12+
lintUnknownAuthoringKeys,
13+
formatUnknownAuthoringKey,
14+
type ConversionNotice,
15+
} from '@objectstack/spec';
1016
import { loadConfig } from '../utils/config.js';
1117
import { validateStackExpressions } from '@objectstack/lint';
1218
import { validateListViewMode } from '@objectstack/lint';
@@ -85,6 +91,14 @@ export default class Validate extends Command {
8591
const normalized = normalizeStackInput(config as Record<string, unknown>, {
8692
onConversionNotice: (n) => conversionNotices.push(n),
8793
});
94+
// [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so drop
95+
// silently. PRE-parse for the same reason the visibility rule below is:
96+
// the parse is what strips them, so `result.data` no longer carries the
97+
// key the author actually wrote. Computed here rather than down in the
98+
// warnings section so the `--json` path reports it too — the
99+
// "computed, then discarded" shape this file already had to fix once.
100+
const unknownKeyWarnings = lintUnknownAuthoringKeys(normalized as Record<string, unknown>)
101+
.map(formatUnknownAuthoringKey);
88102
const result = ObjectStackDefinitionSchema.safeParse(normalized);
89103

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

775+
// [#3786] Undeclared object/field keys — computed pre-parse above,
776+
// alongside `normalized`, for the same reason.
777+
warnings.push(...unknownKeyWarnings);
778+
761779
// ADR-0087 D2 conversion notices: the source used a deprecated shape that
762780
// was auto-converted at load. No action is required to keep loading, but
763781
// the notice steers the author to the canonical key before it retires.

packages/spec/api-surface.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"AUDIENCE_ANCHOR_POSITIONS (const)",
66
"Agent (type)",
77
"ApplyConversionsOptions (interface)",
8+
"AuthoringKeySurface (type)",
89
"BUILTIN_IDENTITY_METADATA (const)",
910
"BUILTIN_IDENTITY_NAMES (const)",
1011
"BUILTIN_IDENTITY_ORG_ADMIN (const)",
@@ -49,6 +50,7 @@
4950
"ExpressionMetaSchema (const)",
5051
"ExpressionSchema (const)",
5152
"F (const)",
53+
"FIELD_KEY_GUIDANCE (const)",
5254
"GUEST_POSITION (const)",
5355
"MAP_SUPPORTED_FIELDS (const)",
5456
"MEMBERSHIP_ROLE_ADMIN (const)",
@@ -69,6 +71,7 @@
6971
"MigrationStep (interface)",
7072
"MigrationTodo (interface)",
7173
"NormalizeStackInputOptions (interface)",
74+
"OBJECT_KEY_GUIDANCE (const)",
7275
"ORGANIZATION_ADMIN (const)",
7376
"ORGANIZATION_ADMIN_GRANTS (const)",
7477
"ORGANIZATION_ADMIN_NO_BYPASS (const)",
@@ -101,6 +104,7 @@
101104
"SurfaceDiff (interface)",
102105
"TemplateExpressionInputSchema (const)",
103106
"Tool (type)",
107+
"UnknownAuthoringKeyFinding (interface)",
104108
"ViewKeyCollision (interface)",
105109
"applyConversions (function)",
106110
"applyConversionsToFlow (function)",
@@ -145,10 +149,12 @@
145149
"expression (function)",
146150
"findClosestMatches (function)",
147151
"formatSuggestion (function)",
152+
"formatUnknownAuthoringKey (function)",
148153
"formatZodError (function)",
149154
"formatZodIssue (function)",
150155
"isAggregatedViewContainer (function)",
151156
"isKnownPlatformCapability (function)",
157+
"lintUnknownAuthoringKeys (function)",
152158
"mapMembershipRole (function)",
153159
"normalizeMetadataCollection (function)",
154160
"normalizePluginMetadata (function)",
@@ -182,6 +188,7 @@
182188
"ApiOperation (type)",
183189
"ApiOperationSchema (const)",
184190
"ApiPrimitive (type)",
191+
"AuthoringKeySurface (type)",
185192
"AutonumberToken (type)",
186193
"BOOLEAN_VALUE_TYPES (const)",
187194
"BaseEngineOptions (type)",
@@ -327,6 +334,7 @@
327334
"ExternalTable (type)",
328335
"ExternalTableSchema (const)",
329336
"FIELD_GROUP_SYSTEM_FIELDS (const)",
337+
"FIELD_KEY_GUIDANCE (const)",
330338
"FILE_REFERENCE_TYPES (const)",
331339
"FILTER_LOGIC_CASES (const)",
332340
"FILTER_LOGIC_ROWS (const)",
@@ -420,6 +428,7 @@
420428
"NoSQLTransactionOptionsSchema (const)",
421429
"NormalizedFilter (type)",
422430
"NormalizedFilterSchema (const)",
431+
"OBJECT_KEY_GUIDANCE (const)",
423432
"ObjectAccessConfig (type)",
424433
"ObjectAccessConfigSchema (const)",
425434
"ObjectCapabilities (type)",
@@ -546,6 +555,7 @@
546555
"TransformType (const)",
547556
"UniqueScope (type)",
548557
"UniqueScopeSchema (const)",
558+
"UnknownAuthoringKeyFinding (interface)",
549559
"VALID_AST_OPERATORS (const)",
550560
"ValidationRule (type)",
551561
"ValidationRuleSchema (const)",
@@ -571,6 +581,7 @@
571581
"deriveRecordSurface (function)",
572582
"effectiveOperationsArray (function)",
573583
"fieldForm (const)",
584+
"formatUnknownAuthoringKey (function)",
574585
"hasDynamicTokens (function)",
575586
"hookForm (const)",
576587
"isApiOperationAllowed (function)",
@@ -588,6 +599,7 @@
588599
"isTenancyDisabled (function)",
589600
"isTitleEligible (function)",
590601
"isUniqueDeclared (function)",
602+
"lintUnknownAuthoringKeys (function)",
591603
"missingFieldValues (function)",
592604
"nextUtcCalendarDay (function)",
593605
"objectForm (const)",

0 commit comments

Comments
 (0)