From 5da78658c71cdbe0cc1c321ef0ae4b710f21bf17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:57:34 +0000 Subject: [PATCH] feat(spec)!: six more registered types close, and skill's `permissions` stops pretending to be a gate (#4001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical work on the registered-type line with `strictObject`: `report`, `dataset`, `email_template`, `skill`, `job`, `book`. One call each, plus the aliases that fit the surface's own 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 the six is not mechanical, and it is the class this campaign exists for. `skill` accepted a `permissions` key and dropped it. Skill invocation was never permission-gated, so an author who wrote `permissions: ['order.manage']` believed they had restricted who could invoke the skill, and had not. A silent permission hole — the same shape as `visibleWhen` → `visible` in #3746, where the most valuable alias was not a typo but a key that READS as a security control and silently is not one. A test pinned that strip as correct behaviour. Its comment even carried the right answer — gate at the AGENT via `access`/`permissions`, enforced since #1884 — but a comment in a test file reaches everyone except the author who got it wrong. The rejection now carries the prescription; the test asserts the rejection. That is the fourth test in this campaign found codifying a strip-era fiction as expected behaviour (`position.parent`, `object.namespace`, the retired `compactLayout` alias, and now this one). The pattern is consistent enough to state: when a schema is silently lenient, its tests eventually assert the leniency, and the assertion then reads as intent. 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 warning layer's covered population drops 12 roots → 6, which is the parse taking over rather than coverage rotting; nested strip sites under a closed root still report, unchanged. Verified: spec 284 files / 7187 tests, `tsc --noEmit` clean, all 8 generated artifacts current, all 15 `check:*` gates green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY --- .changeset/registered-types-batch-four.md | 15 +++++++++++++++ packages/spec/src/ai/skill.test.ts | 17 +++++++++++------ packages/spec/src/ai/skill.zod.ts | 15 ++++++++++++++- .../src/kernel/metadata-authoring-lint.test.ts | 13 ++++++++----- packages/spec/src/system/book.zod.ts | 13 ++++++++++++- packages/spec/src/system/email-template.zod.ts | 8 +++++++- packages/spec/src/system/job.zod.ts | 8 +++++++- packages/spec/src/ui/dataset.zod.ts | 8 +++++++- packages/spec/src/ui/report.zod.ts | 8 +++++++- skills/objectstack-ai/references/_index.md | 3 +++ 10 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 .changeset/registered-types-batch-four.md 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