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
30 changes: 30 additions & 0 deletions .changeset/fix-objectschema-unknown-key-rejection.md
Original file line number Diff line number Diff line change
@@ -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/<name>.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.
80 changes: 10 additions & 70 deletions examples/app-todo/src/objects/task.object.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)
});
6 changes: 2 additions & 4 deletions packages/platform-objects/src/system/sys-secret.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
48 changes: 48 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
});

// ============================================================================
Expand Down
119 changes: 115 additions & 4 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
workflows:
'`workflows` is not an ObjectSchema field. Object-level, record-triggered ' +
'automation is authored as a lifecycle hook (`src/objects/<name>.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/<name>.hook.ts`) or a top-level ' +
'`record_change` flow.',
hooks:
'`hooks` is not an ObjectSchema field. Lifecycle hooks live in their own ' +
'`src/objects/<name>.hook.ts` module, registered via `defineHook()`.',
triggers:
'`triggers` is not an ObjectSchema field. Use a lifecycle hook ' +
'(`src/objects/<name>.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<number>(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 : '<unnamed>';
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> = T &
Record<Exclude<keyof T, keyof z.input<typeof ObjectSchemaBase>>, never>;

/**
* Enhanced ObjectSchema with Factory
*/
Expand All @@ -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({
Expand All @@ -737,10 +838,20 @@ export const ObjectSchema = lazySchema(() => Object.assign(ObjectSchemaBase, {
* });
* ```
*/
create: <const T extends z.input<typeof ObjectSchemaBase>>(config: T): Omit<ServiceObject, 'fields'> & Pick<T, 'fields'> => {
create: <const T extends z.input<typeof ObjectSchemaBase>>(config: NoExcessObjectKeys<T>): Omit<ServiceObject, 'fields'> & Pick<T, 'fields'> => {
// 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<string, unknown>;
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<ServiceObject, 'fields'> & Pick<T, 'fields'>;
},
Expand Down
2 changes: 1 addition & 1 deletion skills/objectstack-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\`...\``,
Expand Down
Loading