diff --git a/.changeset/registered-types-batch-four.md b/.changeset/registered-types-batch-four.md new file mode 100644 index 0000000000..121f53d900 --- /dev/null +++ b/.changeset/registered-types-batch-four.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +--- + +Six more registered metadata types reject unknown keys — `report`, `dataset`, `email_template`, `skill`, `job`, `book` — and `skill`'s silently-stripped `permissions` key now says where the real gate lives. + +Mechanical work on the registered-type line, using `strictObject`. Each conversion is one call plus the aliases that fit that surface's vocabulary (`sections`/`chapters`/`toc` → `groups` on a book, `cron`/`interval` → `schedule` on a job, `title`/`content`/`html` → `subject`/`body` on an email template). + +**One of them was a silent permission gate, which is the class this campaign cares most about.** `skill` accepted a `permissions` key and dropped it — skill invocation was never permission-gated. An author who wrote it believed they had restricted who could invoke the skill, and had not. A test even pinned that strip as correct behaviour, with a comment explaining the right answer (gate at the AGENT via `access` / `permissions`, enforced since #1884) — but that comment was only visible to someone reading the test file, never to the author who got it wrong. The rejection now carries the prescription, and the test asserts the rejection. + +Same shape as `visibleWhen` → `visible` in #3746: the most valuable entry in an alias table is rarely a typo, it is a key that reads as a security control and silently is not one. + +Registered types closed at the top level: **16 of 25**, up from 9 when this line started. Still open: `action`, `agent`, `dashboard`, `field`, `mapping`, `page`, `translation`, `view`. + +The unknown-key warning layer's covered population drops from 12 roots to 6 as a result, which is the campaign succeeding rather than coverage rotting — the parse takes over where the lint used to warn. Nested strip sites under a closed root still report, unchanged. diff --git a/packages/spec/src/ai/skill.test.ts b/packages/spec/src/ai/skill.test.ts index 8b5c19a519..203da23a3e 100644 --- a/packages/spec/src/ai/skill.test.ts +++ b/packages/spec/src/ai/skill.test.ts @@ -72,18 +72,23 @@ describe('SkillSchema', () => { expect(result.triggerConditions).toHaveLength(2); }); - it('drops a `permissions` key — skill invocation was never permission-gated (pruned)', () => { - const parsed = SkillSchema.parse({ + it('REJECTS a `permissions` key and points at the agent-level gate', () => { + // This used to be stripped, so an author who wrote it believed they had + // gated skill invocation and had not — a silent permission hole, which is + // the worst thing for this key in particular to be quiet about. The + // rejection now carries the prescription the old comment only told readers + // of this file (#4001). + const result = SkillSchema.safeParse({ name: 'order_management', label: 'Order Management', instructions: 'x', tools: ['create_order'], - // Authored against the retired key: the schema is non-strict, so it is - // stripped rather than rejected. Gate at the AGENT (`access`/`permissions`, - // enforced #1884) or on the underlying tools' actions instead. permissions: ['order.manage'], } as Record); - expect('permissions' in parsed).toBe(false); + expect(result.success).toBe(false); + const message = result.success ? '' : result.error.issues[0].message; + expect(message).toContain('`permissions` is not a skill key'); + expect(message).toContain('Gate at the AGENT'); }); it('should enforce snake_case for skill name', () => { diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index 5da777154f..6a053ef051 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -15,6 +15,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; * Allows context-aware activation based on object type, user role, etc. */ import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; import { retiredKey } from '../shared/retired-key'; export const SkillTriggerConditionSchema = lazySchema(() => z.object({ /** Condition field (e.g. 'objectName', 'userRole', 'channel') */ @@ -62,7 +63,19 @@ export type SkillTriggerCondition = z.infer; * }); * ``` */ -export const SkillSchema = lazySchema(() => z.object({ +export const SkillSchema = lazySchema(() => strictObject({ + surface: 'this skill', + history: + 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + aliases: { prompt: 'instructions', content: 'instructions', body: 'instructions', trigger: 'triggers', tool: 'tools' }, + guidance: { + permissions: + '`permissions` is not a skill key — skill invocation was never permission-gated, ' + + 'so this was stripped in silence and the author believed they had a gate. Gate at ' + + 'the AGENT instead (`access` / `permissions` on the agent, enforced since #1884), ' + + "or on the underlying tools' actions.", + }, +}, { /** Machine name (snake_case, globally unique) */ name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Skill unique identifier (snake_case)'), diff --git a/packages/spec/src/kernel/metadata-authoring-lint.test.ts b/packages/spec/src/kernel/metadata-authoring-lint.test.ts index e2a4b68519..33edded9c3 100644 --- a/packages/spec/src/kernel/metadata-authoring-lint.test.ts +++ b/packages/spec/src/kernel/metadata-authoring-lint.test.ts @@ -38,14 +38,16 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => { // coverage rotting. Lower it only after confirming the shrink against the // list below; that confirmation is the whole point of pinning a number here. // 15 → 13 when `seed` + `doc` graduated (#4001 registered-types batch); - // 13 → 12 when `object` closed on the parse path. Note what did NOT shrink - // with it: `object`'s 71 NESTED strip sites still report, because the walk - // no longer gates a whole collection on its root's posture. - expect(lintables.length).toBeGreaterThanOrEqual(12); + // 13 → 12 when `object` closed on the parse path; 12 → 6 when the seven + // small registered types closed in one batch. Note what did NOT shrink with + // `object`: its 71 NESTED strip sites still report, because the walk no + // longer gates a whole collection on its root's posture — so this number + // tracks ROOTS that graduated, not coverage lost. + expect(lintables.length).toBeGreaterThanOrEqual(6); // `view` matters doubly: it is a UNION (container | ViewItem | overlay), so // its presence pins the union half of the posture logic — a regression that // silently dropped unions would shrink coverage without failing the count. - for (const expected of ['page', 'agent', 'dashboard', 'action', 'report', 'view']) { + for (const expected of ['page', 'agent', 'dashboard', 'action', 'view']) { expect(lintableTypes, `expected '${expected}' to be lint-covered`).toContain(expected); } }); @@ -65,6 +67,7 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => { // helper-built `.strict()` exactly like a hand-wired one. for (const strict of [ 'flow', 'permission', 'position', 'tool', 'app', 'hook', 'datasource', 'seed', 'doc', + 'report', 'dataset', 'email_template', 'skill', 'job', 'book', // `object` graduated by closing its PARSE path — #1535 had only ever // guarded `create()`. Its nested strip sites still report; only the root // moved from warn to reject. diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index 9c7a23abb2..ee4eba246d 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; /** @@ -101,7 +102,17 @@ export const BookAudienceSchema = lazySchema(() => export type BookAudience = 'org' | 'public' | { permissionSet: string }; export const BookSchema = lazySchema(() => - z.object({ + strictObject({ + surface: 'this book', + history: + 'Until #4001 closed this shape these were dropped silently — the book still ' + + 'registered, minus whatever the key was meant to configure.', + aliases: { + title: 'label', sections: 'groups', chapters: 'groups', toc: 'groups', + access: 'audience', visibility: 'audience', sort: 'order', position: 'order', + url: 'slug', path: 'slug', i18n: 'translations', + }, + }, { name: z .string() .regex(/^[a-z][a-z0-9_]*$/, 'name must be lowercase snake_case') diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index e0fcffb430..5ddd49e384 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; /** * Email Template Metadata Protocol @@ -41,7 +42,12 @@ export const EmailTemplateDefinitionVariableSchema = lazySchema(() => z.object({ })); export type EmailTemplateDefinitionVariable = z.infer; -export const EmailTemplateDefinitionSchema = lazySchema(() => z.object({ +export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ + surface: 'this email template', + history: + 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + aliases: { title: 'subject', content: 'body', html: 'body', text: 'body', from: 'fromAddress', sender: 'fromAddress' }, +}, { /** * Stable identifier; used as the `template` key in * `IEmailService.sendTemplate({ template, ... })`. Convention: diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 350f4a7030..1f2c3d1a11 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -8,6 +8,7 @@ import { CronExpressionInputSchema } from '../shared/expression.zod'; * Schedule jobs using cron expressions */ import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; export const CronScheduleSchema = lazySchema(() => z.object({ type: z.literal('cron'), @@ -81,7 +82,12 @@ export type RetryPolicy = z.infer; * } * } */ -export const JobSchema = lazySchema(() => z.object({ +export const JobSchema = lazySchema(() => strictObject({ + surface: 'this job', + history: + 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + aliases: { cron: 'schedule', interval: 'schedule', fn: 'handler', function: 'handler', retry: 'retryPolicy', enabled_: 'enabled', timeoutMs: 'timeout' }, +}, { id: z.string().optional().describe('Unique job identifier (defaults to `name` when omitted)'), name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Job name (snake_case)'), label: z.string().optional().describe('Human-readable label'), diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 8ab6ca4016..84207af56e 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { FilterConditionSchema } from '../data/filter.zod'; @@ -96,7 +97,12 @@ export const DatasetMeasureSchema = lazySchema(() => z.object({ /** * Dataset — the single analytical source of truth (ADR-0021 D1). */ -export const DatasetSchema = lazySchema(() => z.object({ +export const DatasetSchema = lazySchema(() => strictObject({ + surface: 'this dataset', + history: + 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + aliases: { source: 'object', objectName: 'object', measures: 'metrics', dimension: 'dimensions', filter: 'filters' }, +}, { /** Identity. */ name: SnakeCaseIdentifierSchema.describe('Dataset unique name'), label: I18nLabelSchema.describe('Dataset label'), diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 247dc988dc..cec84ad93a 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -12,6 +12,7 @@ import { I18nLabelSchema } from './i18n.zod'; * Report Type Enum */ import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; export const ReportType = z.enum([ 'tabular', // Simple list 'summary', // Grouped by row @@ -166,7 +167,12 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => z.object({ * Report Schema * Deep data analysis definition. */ -export const ReportSchema = lazySchema(() => z.object({ +export const ReportSchema = lazySchema(() => strictObject({ + surface: 'this report', + history: + 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + aliases: { dataSet: 'dataset', source: 'dataset', fields: 'values', columns: 'values', chart: 'chartConfig', filter: 'filters' }, +}, { /** Identity */ name: SnakeCaseIdentifierSchema.describe('Report unique name'), label: I18nLabelSchema.describe('Report label'), diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index 71e60b93f8..171c79a662 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -23,10 +23,13 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies - `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol +- `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum +- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) +- `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities ## How to read these