diff --git a/.changeset/fix-objectschema-unknown-key-rejection.md b/.changeset/fix-objectschema-unknown-key-rejection.md new file mode 100644 index 000000000..659fee0b0 --- /dev/null +++ b/.changeset/fix-objectschema-unknown-key-rejection.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": patch +--- + +fix(spec): reject unknown top-level keys on `ObjectSchema.create()` (#1535) + +`ObjectSchemaBase` is a plain `z.object({...})` (Zod default `.strip()`), so any +unknown top-level key passed to `ObjectSchema.create()` — `workflows`, a typo'd +`validation`/`indexs`, etc. — was discarded silently: no error, no warning, and a +green `tsc`. Declarative metadata an author believed they shipped (e.g. object-level +`workflows: [...]`) vanished from every built artifact, dead from day one. This is the +metadata-shape analogue of ADR-0032's "no silent failure" principle. + +`create()` now rejects unknown top-level keys with a precise, fixable build error that +names the offending key(s), suggests the intended key on a likely typo +(`validation` → `validations`), and — for known-confusable keys like `workflows` — +points authors at the supported mechanism (a lifecycle hook `src/objects/.hook.ts` +or a top-level `record_change` flow; there is no object-level `workflows[]` field). The +factory signature also constrains excess keys to `never`, so the mistake is caught at +`tsc` time as well as at build. + +The non-strict `ObjectSchema.parse()` load path (registry/artifact validation) is +unchanged. + +Also fixes two platform objects (`sys_secret`, `sys_setting_audit`) that carried +silently-stripped `views`/`scope`/`defaultViewName` keys: their intended list views are +migrated to the supported `listViews` field (`type: 'list'` → `'grid'`) so they now +render instead of being dropped. The `objectstack-data` skill's CRM blueprint no longer +teaches the non-existent `workflows[]` shape. diff --git a/examples/app-todo/src/objects/task.object.ts b/examples/app-todo/src/objects/task.object.ts index 764fb5ca6..402264b00 100644 --- a/examples/app-todo/src/objects/task.object.ts +++ b/examples/app-todo/src/objects/task.object.ts @@ -1,4 +1,4 @@ -import { P, cel, tmpl } from '@objectstack/spec'; +import { P, tmpl } from '@objectstack/spec'; // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; @@ -199,73 +199,13 @@ export const Task = ObjectSchema.create({ condition: P`record.is_recurring == true && isBlank(record.recurrence_type)`, }, ], - - workflows: [ - { - name: 'set_completed_flag', - objectName: 'todo_task', - triggerType: 'on_create_or_update', - criteria: P`record.status != previous.status`, - active: true, - actions: [ - { - name: 'update_completed_flag', - type: 'field_update', - field: 'is_completed', - value: cel`record.status == "completed"`, - } - ], - }, - { - name: 'set_completed_date', - objectName: 'todo_task', - triggerType: 'on_update', - criteria: P`record.status != previous.status && record.status == "completed"`, - active: true, - actions: [ - { - name: 'set_date', - type: 'field_update', - field: 'completed_date', - value: cel`now()`, - }, - { - name: 'set_progress', - type: 'field_update', - field: 'progress_percent', - value: '100', - } - ], - }, - { - name: 'check_overdue', - objectName: 'todo_task', - triggerType: 'on_create_or_update', - criteria: P`record.due_date < today() && record.is_completed == false`, - active: true, - actions: [ - { - name: 'set_overdue_flag', - type: 'field_update', - field: 'is_overdue', - value: 'true', - } - ], - }, - { - name: 'notify_on_urgent', - objectName: 'todo_task', - triggerType: 'on_create_or_update', - criteria: P`record.priority == "urgent" && record.is_completed == false`, - active: true, - actions: [ - { - name: 'email_owner', - type: 'email_alert', - template: 'urgent_task_alert', - recipients: ['{owner.email}'], - } - ], - }, - ], + + // NOTE (#1535): object-level `workflows[]` is NOT a supported ObjectSchema + // field — it was silently stripped at build and never ran (ADR-0032 "no + // silent failure"). Record-triggered automation for this object lives in the + // supported mechanisms instead: + // • `task.hook.ts` — lifecycle hook (defaults, completion logic) + // • `actions/task.handlers.ts` — stamps `completed_date` on completion + // • `flows/task.flow.ts` — record_change + schedule flows (completion / + // recurrence, reminders, overdue escalation) }); diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index b227de203..b58838753 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -37,12 +37,10 @@ export const SysSecret = ObjectSchema.create({ isSystem: true, managedBy: 'system', description: 'Cipher store referenced by sys_setting handles. Never holds plaintext.', - scope: 'tenant', compactLayout: ['namespace', 'key', 'kms_key_id', 'version', 'rotated_at'], - defaultViewName: 'all', - views: { + listViews: { all: { - type: 'list', + type: 'grid', name: 'all', label: 'All Secrets', columns: ['namespace', 'key', 'kms_key_id', 'version', 'rotated_at', 'created_at'], diff --git a/packages/platform-objects/src/system/sys-setting-audit.object.ts b/packages/platform-objects/src/system/sys-setting-audit.object.ts index d0cf5cb49..af05c79a4 100644 --- a/packages/platform-objects/src/system/sys-setting-audit.object.ts +++ b/packages/platform-objects/src/system/sys-setting-audit.object.ts @@ -34,12 +34,10 @@ export const SysSettingAudit = ObjectSchema.create({ isSystem: true, managedBy: 'system', description: 'Append-only audit trail for SettingsService mutations.', - scope: 'tenant', compactLayout: ['namespace', 'key', 'scope', 'action', 'actor_id', 'created_at'], - defaultViewName: 'recent', - views: { + listViews: { recent: { - type: 'list', + type: 'grid', name: 'recent', label: 'Recent', columns: ['created_at', 'namespace', 'key', 'scope', 'action', 'actor_id', 'source'], diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index b95f7fbe9..c42b1c4b6 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -709,6 +709,54 @@ describe('ObjectSchema.create()', () => { fields: { InvalidField: { type: 'text' } }, })).toThrow(); }); + + // ADR-0032 "no silent failure" for metadata shape (issue #1535): unknown + // top-level keys used to be stripped silently, shipping dead metadata. + describe('unknown-key rejection (#1535)', () => { + it('rejects object-level `workflows` with guidance toward hooks/record_change', () => { + expect(() => ObjectSchema.create({ + name: 'demo', + fields: { status: { type: 'text' } }, + // @ts-expect-error — `workflows` is not an ObjectSchema field + workflows: [{ name: 'stamp', triggerType: 'on_update', actions: [] }], + })).toThrow(/workflows/); + }); + + it('error message points at the supported mechanism, not just "unknown key"', () => { + let message = ''; + try { + ObjectSchema.create({ + name: 'demo', + fields: { status: { type: 'text' } }, + // @ts-expect-error — `workflows` is not an ObjectSchema field + workflows: [], + }); + } catch (e) { + message = (e as Error).message; + } + expect(message).toContain('lifecycle hook'); + expect(message).toContain('record_change'); + expect(message).toContain('#1535'); + }); + + it('suggests the intended key on a typo (`validation` → `validations`)', () => { + expect(() => ObjectSchema.create({ + name: 'demo', + fields: { status: { type: 'text' } }, + // @ts-expect-error — typo'd key + validation: [], + })).toThrow(/did you mean `validations`/); + }); + + it('does not strip — a supported key like `validations` still parses', () => { + const obj = ObjectSchema.create({ + name: 'demo', + fields: { status: { type: 'text' } }, + validations: [], + }); + expect(obj.validations).toEqual([]); + }); + }); }); // ============================================================================ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index a5951b019..a189efcd0 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -715,6 +715,104 @@ function snakeCaseToLabel(name: string): string { .join(' '); } +/** + * Known-confusable schema keys → precise authoring guidance. + * + * ADR-0032's "no silent failure" principle applied to metadata *shape*: an + * unknown top-level key on `ObjectSchema.create()` used to be discarded by + * Zod's default `.strip()`, so a misauthored schema key vanished with no + * error, no warning, and a green `tsc` — shipping dead metadata the author + * believed they had wired up (issue #1535, object-level `workflows: [...]`). + * + * These entries turn the most likely mistakes into a fixable error that points + * at the *supported* mechanism rather than a generic "unknown key". + */ +const UNKNOWN_KEY_GUIDANCE: Record = { + workflows: + '`workflows` is not an ObjectSchema field. Object-level, record-triggered ' + + 'automation is authored as a lifecycle hook (`src/objects/.hook.ts`, ' + + 'registered via `defineHook()`) or as a top-level `record_change` flow — ' + + 'not as `workflows[]` on the object schema.', + workflow: + '`workflow` is not an ObjectSchema field. Record-triggered automation is ' + + 'authored as a lifecycle hook (`src/objects/.hook.ts`) or a top-level ' + + '`record_change` flow.', + hooks: + '`hooks` is not an ObjectSchema field. Lifecycle hooks live in their own ' + + '`src/objects/.hook.ts` module, registered via `defineHook()`.', + triggers: + '`triggers` is not an ObjectSchema field. Use a lifecycle hook ' + + '(`src/objects/.hook.ts`) or a top-level `record_change` flow.', +}; + +/** Levenshtein edit distance — backs the "did you mean" hint for typo'd keys. */ +function editDistance(a: string, b: string): number { + const dp: number[][] = Array.from({ length: a.length + 1 }, () => + new Array(b.length + 1).fill(0), + ); + for (let i = 0; i <= a.length; i++) dp[i][0] = i; + for (let j = 0; j <= b.length; j++) dp[0][j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[a.length][b.length]; +} + +/** Closest known key within a small edit distance, for typo hints (`indexs` → `indexes`). */ +function suggestKey(unknown: string, knownKeys: string[]): string | undefined { + let best: string | undefined; + let bestDist = Infinity; + for (const key of knownKeys) { + const d = editDistance(unknown.toLowerCase(), key.toLowerCase()); + if (d < bestDist) { + bestDist = d; + best = key; + } + } + // Only suggest when the keys are genuinely close (guards against noise). + return best !== undefined && bestDist <= Math.max(2, Math.floor(unknown.length / 3)) + ? best + : undefined; +} + +/** + * Builds a precise, fixable error for unknown top-level keys on + * `ObjectSchema.create()` — the metadata-shape analogue of ADR-0032's "no + * silent failure" (issue #1535). Because authored `*.object.ts` modules call + * `create()`, this surfaces as a located build error instead of a silently + * stripped field. + */ +function unknownKeyError(objectName: unknown, unknownKeys: string[], knownKeys: string[]): Error { + const name = typeof objectName === 'string' && objectName.length > 0 ? objectName : ''; + const lines = unknownKeys.map((key) => { + const guidance = UNKNOWN_KEY_GUIDANCE[key]; + if (guidance) return ` • ${guidance}`; + const suggestion = suggestKey(key, knownKeys); + return suggestion + ? ` • \`${key}\` is not an ObjectSchema field — did you mean \`${suggestion}\`?` + : ` • \`${key}\` is not an ObjectSchema field.`; + }); + return new Error( + `ObjectSchema.create('${name}'): unknown key(s) — ${unknownKeys.join(', ')}.\n` + + 'These keys would previously have been stripped silently at build, shipping ' + + 'dead metadata with no diagnostic (ADR-0032 "no silent failure", issue #1535).\n\n' + + `${lines.join('\n')}\n\n` + + 'Remove the unknown key(s), fix the typo, or move the logic to a supported mechanism.', + ); +} + +/** + * Rejects excess top-level keys at compile time: any key of `T` that is not a + * key of the ObjectSchema input shape is constrained to `never`, turning the + * silent strip into a `tsc` error at the authoring site as well as at build. + */ +type NoExcessObjectKeys = T & + Record>, never>; + /** * Enhanced ObjectSchema with Factory */ @@ -725,7 +823,10 @@ export const ObjectSchema = lazySchema(() => Object.assign(ObjectSchemaBase, { * Enhancements over raw schema: * - **Auto-label**: Generates `label` from `name` if not provided (snake_case → Title Case). * - **Validation**: Runs Zod `.parse()` to validate the config at creation time. - * + * - **No silent strip** (ADR-0032 / #1535): unknown top-level keys (e.g. a + * typo'd `validation`, or an object-level `workflows[]`) are rejected with a + * precise, fixable error instead of being discarded by Zod's `.strip()`. + * * @example * ```ts * const Task = ObjectSchema.create({ @@ -737,10 +838,20 @@ export const ObjectSchema = lazySchema(() => Object.assign(ObjectSchemaBase, { * }); * ``` */ - create: >(config: T): Omit & Pick => { + create: >(config: NoExcessObjectKeys): Omit & Pick => { + // ADR-0032 "no silent failure" for schema shape (issue #1535): an unknown + // top-level key here used to be discarded silently by Zod's `.strip()`. We + // reject it with a located, fixable message *before* parsing so authors get + // a build error instead of vanished metadata. + const cfg = config as T & Record; + const knownKeys = Object.keys(ObjectSchemaBase.shape); + const unknownKeys = Object.keys(cfg).filter((k) => !knownKeys.includes(k)); + if (unknownKeys.length > 0) { + throw unknownKeyError(cfg.name, unknownKeys, knownKeys); + } const withDefaults = { - ...config, - label: config.label ?? snakeCaseToLabel(config.name), + ...cfg, + label: cfg.label ?? snakeCaseToLabel(cfg.name as string), }; return ObjectSchemaBase.parse(withDefaults) as Omit & Pick; }, diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 85d563069..85e740766 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -316,7 +316,7 @@ Mirror these CRM-style patterns when designing enterprise metadata objects: | Capability gating | `src/objects/*.object.ts` | Use `enable` flags (`trackHistory`, `apiMethods`, `files`, `feeds`, `activities`) per object | | Index + validation pairing | `src/objects/*.object.ts` | Keep `indexes[]` aligned to common filters and enforce invariants with `validations[]` | | Relationship constraints | `src/objects/*.object.ts` | Use `lookup` + `referenceFilters` for constrained child selection | -| Lifecycle workflow | `src/objects/*.object.ts` | Use `workflows[]` for field updates triggered by record changes | +| Lifecycle automation | `src/objects/*.hook.ts` | Use a lifecycle **hook** (`defineHook()`) or a top-level `record_change` flow for field updates triggered by record changes. There is **no** object-level `workflows[]` field — authoring one is a build error (#1535). | | State transitions | `src/objects/*.state.ts` | Prefer explicit `stateMachines` for lifecycle-heavy objects | For metadata authoring, keep expressions in CEL (`P\`...\``, `F\`...\``,