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
15 changes: 15 additions & 0 deletions .changeset/registered-types-batch-four.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 11 additions & 6 deletions packages/spec/src/ai/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>);
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', () => {
Expand Down
15 changes: 14 additions & 1 deletion packages/spec/src/ai/skill.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') */
Expand Down Expand Up @@ -62,7 +63,19 @@ export type SkillTriggerCondition = z.infer<typeof SkillTriggerConditionSchema>;
* });
* ```
*/
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)'),

Expand Down
13 changes: 8 additions & 5 deletions packages/spec/src/kernel/metadata-authoring-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand All @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion packages/spec/src/system/book.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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')
Expand Down
8 changes: 7 additions & 1 deletion packages/spec/src/system/email-template.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,7 +42,12 @@ export const EmailTemplateDefinitionVariableSchema = lazySchema(() => z.object({
}));
export type EmailTemplateDefinitionVariable = z.infer<typeof EmailTemplateDefinitionVariableSchema>;

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:
Expand Down
8 changes: 7 additions & 1 deletion packages/spec/src/system/job.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -81,7 +82,12 @@ export type RetryPolicy = z.infer<typeof RetryPolicySchema>;
* }
* }
*/
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'),
Expand Down
8 changes: 7 additions & 1 deletion packages/spec/src/ui/dataset.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'),
Expand Down
8 changes: 7 additions & 1 deletion packages/spec/src/ui/report.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down
3 changes: 3 additions & 0 deletions skills/objectstack-ai/references/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading