Skip to content

Commit 5da7865

Browse files
committed
feat(spec)!: six more registered types close, and skill's permissions stops pretending to be a gate (#4001)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY
1 parent 5ef0b5b commit 5da7865

10 files changed

Lines changed: 91 additions & 17 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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.
6+
7+
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).
8+
9+
**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.
10+
11+
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.
12+
13+
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`.
14+
15+
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.

packages/spec/src/ai/skill.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,23 @@ describe('SkillSchema', () => {
7272
expect(result.triggerConditions).toHaveLength(2);
7373
});
7474

75-
it('drops a `permissions` key — skill invocation was never permission-gated (pruned)', () => {
76-
const parsed = SkillSchema.parse({
75+
it('REJECTS a `permissions` key and points at the agent-level gate', () => {
76+
// This used to be stripped, so an author who wrote it believed they had
77+
// gated skill invocation and had not — a silent permission hole, which is
78+
// the worst thing for this key in particular to be quiet about. The
79+
// rejection now carries the prescription the old comment only told readers
80+
// of this file (#4001).
81+
const result = SkillSchema.safeParse({
7782
name: 'order_management',
7883
label: 'Order Management',
7984
instructions: 'x',
8085
tools: ['create_order'],
81-
// Authored against the retired key: the schema is non-strict, so it is
82-
// stripped rather than rejected. Gate at the AGENT (`access`/`permissions`,
83-
// enforced #1884) or on the underlying tools' actions instead.
8486
permissions: ['order.manage'],
8587
} as Record<string, unknown>);
86-
expect('permissions' in parsed).toBe(false);
88+
expect(result.success).toBe(false);
89+
const message = result.success ? '' : result.error.issues[0].message;
90+
expect(message).toContain('`permissions` is not a skill key');
91+
expect(message).toContain('Gate at the AGENT');
8792
});
8893

8994
it('should enforce snake_case for skill name', () => {

packages/spec/src/ai/skill.zod.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
1515
* Allows context-aware activation based on object type, user role, etc.
1616
*/
1717
import { lazySchema } from '../shared/lazy-schema';
18+
import { strictObject } from '../shared/strict-object';
1819
import { retiredKey } from '../shared/retired-key';
1920
export const SkillTriggerConditionSchema = lazySchema(() => z.object({
2021
/** Condition field (e.g. 'objectName', 'userRole', 'channel') */
@@ -62,7 +63,19 @@ export type SkillTriggerCondition = z.infer<typeof SkillTriggerConditionSchema>;
6263
* });
6364
* ```
6465
*/
65-
export const SkillSchema = lazySchema(() => z.object({
66+
export const SkillSchema = lazySchema(() => strictObject({
67+
surface: 'this skill',
68+
history:
69+
'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.',
70+
aliases: { prompt: 'instructions', content: 'instructions', body: 'instructions', trigger: 'triggers', tool: 'tools' },
71+
guidance: {
72+
permissions:
73+
'`permissions` is not a skill key — skill invocation was never permission-gated, '
74+
+ 'so this was stripped in silence and the author believed they had a gate. Gate at '
75+
+ 'the AGENT instead (`access` / `permissions` on the agent, enforced since #1884), '
76+
+ "or on the underlying tools' actions.",
77+
},
78+
}, {
6679
/** Machine name (snake_case, globally unique) */
6780
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Skill unique identifier (snake_case)'),
6881

packages/spec/src/kernel/metadata-authoring-lint.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,16 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => {
3838
// coverage rotting. Lower it only after confirming the shrink against the
3939
// list below; that confirmation is the whole point of pinning a number here.
4040
// 15 → 13 when `seed` + `doc` graduated (#4001 registered-types batch);
41-
// 13 → 12 when `object` closed on the parse path. Note what did NOT shrink
42-
// with it: `object`'s 71 NESTED strip sites still report, because the walk
43-
// no longer gates a whole collection on its root's posture.
44-
expect(lintables.length).toBeGreaterThanOrEqual(12);
41+
// 13 → 12 when `object` closed on the parse path; 12 → 6 when the seven
42+
// small registered types closed in one batch. Note what did NOT shrink with
43+
// `object`: its 71 NESTED strip sites still report, because the walk no
44+
// longer gates a whole collection on its root's posture — so this number
45+
// tracks ROOTS that graduated, not coverage lost.
46+
expect(lintables.length).toBeGreaterThanOrEqual(6);
4547
// `view` matters doubly: it is a UNION (container | ViewItem | overlay), so
4648
// its presence pins the union half of the posture logic — a regression that
4749
// silently dropped unions would shrink coverage without failing the count.
48-
for (const expected of ['page', 'agent', 'dashboard', 'action', 'report', 'view']) {
50+
for (const expected of ['page', 'agent', 'dashboard', 'action', 'view']) {
4951
expect(lintableTypes, `expected '${expected}' to be lint-covered`).toContain(expected);
5052
}
5153
});
@@ -65,6 +67,7 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => {
6567
// helper-built `.strict()` exactly like a hand-wired one.
6668
for (const strict of [
6769
'flow', 'permission', 'position', 'tool', 'app', 'hook', 'datasource', 'seed', 'doc',
70+
'report', 'dataset', 'email_template', 'skill', 'job', 'book',
6871
// `object` graduated by closing its PARSE path — #1535 had only ever
6972
// guarded `create()`. Its nested strip sites still report; only the root
7073
// moved from warn to reject.

packages/spec/src/system/book.zod.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { z } from 'zod';
44
import { lazySchema } from '../shared/lazy-schema';
5+
import { strictObject } from '../shared/strict-object';
56
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
67

78
/**
@@ -101,7 +102,17 @@ export const BookAudienceSchema = lazySchema(() =>
101102
export type BookAudience = 'org' | 'public' | { permissionSet: string };
102103

103104
export const BookSchema = lazySchema(() =>
104-
z.object({
105+
strictObject({
106+
surface: 'this book',
107+
history:
108+
'Until #4001 closed this shape these were dropped silently — the book still '
109+
+ 'registered, minus whatever the key was meant to configure.',
110+
aliases: {
111+
title: 'label', sections: 'groups', chapters: 'groups', toc: 'groups',
112+
access: 'audience', visibility: 'audience', sort: 'order', position: 'order',
113+
url: 'slug', path: 'slug', i18n: 'translations',
114+
},
115+
}, {
105116
name: z
106117
.string()
107118
.regex(/^[a-z][a-z0-9_]*$/, 'name must be lowercase snake_case')

packages/spec/src/system/email-template.zod.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { z } from 'zod';
44
import { ProtectionSchema } from '../shared/protection.zod';
55
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
66
import { lazySchema } from '../shared/lazy-schema';
7+
import { strictObject } from '../shared/strict-object';
78

89
/**
910
* Email Template Metadata Protocol
@@ -41,7 +42,12 @@ export const EmailTemplateDefinitionVariableSchema = lazySchema(() => z.object({
4142
}));
4243
export type EmailTemplateDefinitionVariable = z.infer<typeof EmailTemplateDefinitionVariableSchema>;
4344

44-
export const EmailTemplateDefinitionSchema = lazySchema(() => z.object({
45+
export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({
46+
surface: 'this email template',
47+
history:
48+
'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.',
49+
aliases: { title: 'subject', content: 'body', html: 'body', text: 'body', from: 'fromAddress', sender: 'fromAddress' },
50+
}, {
4551
/**
4652
* Stable identifier; used as the `template` key in
4753
* `IEmailService.sendTemplate({ template, ... })`. Convention:

packages/spec/src/system/job.zod.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CronExpressionInputSchema } from '../shared/expression.zod';
88
* Schedule jobs using cron expressions
99
*/
1010
import { lazySchema } from '../shared/lazy-schema';
11+
import { strictObject } from '../shared/strict-object';
1112
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
1213
export const CronScheduleSchema = lazySchema(() => z.object({
1314
type: z.literal('cron'),
@@ -81,7 +82,12 @@ export type RetryPolicy = z.infer<typeof RetryPolicySchema>;
8182
* }
8283
* }
8384
*/
84-
export const JobSchema = lazySchema(() => z.object({
85+
export const JobSchema = lazySchema(() => strictObject({
86+
surface: 'this job',
87+
history:
88+
'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.',
89+
aliases: { cron: 'schedule', interval: 'schedule', fn: 'handler', function: 'handler', retry: 'retryPolicy', enabled_: 'enabled', timeoutMs: 'timeout' },
90+
}, {
8591
id: z.string().optional().describe('Unique job identifier (defaults to `name` when omitted)'),
8692
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Job name (snake_case)'),
8793
label: z.string().optional().describe('Human-readable label'),

packages/spec/src/ui/dataset.zod.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { z } from 'zod';
44
import { lazySchema } from '../shared/lazy-schema';
5+
import { strictObject } from '../shared/strict-object';
56
import { ProtectionSchema } from '../shared/protection.zod';
67
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
78
import { FilterConditionSchema } from '../data/filter.zod';
@@ -96,7 +97,12 @@ export const DatasetMeasureSchema = lazySchema(() => z.object({
9697
/**
9798
* Dataset — the single analytical source of truth (ADR-0021 D1).
9899
*/
99-
export const DatasetSchema = lazySchema(() => z.object({
100+
export const DatasetSchema = lazySchema(() => strictObject({
101+
surface: 'this dataset',
102+
history:
103+
'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.',
104+
aliases: { source: 'object', objectName: 'object', measures: 'metrics', dimension: 'dimensions', filter: 'filters' },
105+
}, {
100106
/** Identity. */
101107
name: SnakeCaseIdentifierSchema.describe('Dataset unique name'),
102108
label: I18nLabelSchema.describe('Dataset label'),

packages/spec/src/ui/report.zod.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { I18nLabelSchema } from './i18n.zod';
1212
* Report Type Enum
1313
*/
1414
import { lazySchema } from '../shared/lazy-schema';
15+
import { strictObject } from '../shared/strict-object';
1516
export const ReportType = z.enum([
1617
'tabular', // Simple list
1718
'summary', // Grouped by row
@@ -166,7 +167,12 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => z.object({
166167
* Report Schema
167168
* Deep data analysis definition.
168169
*/
169-
export const ReportSchema = lazySchema(() => z.object({
170+
export const ReportSchema = lazySchema(() => strictObject({
171+
surface: 'this report',
172+
history:
173+
'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.',
174+
aliases: { dataSet: 'dataset', source: 'dataset', fields: 'values', columns: 'values', chart: 'chartConfig', filter: 'filters' },
175+
}, {
170176
/** Identity */
171177
name: SnakeCaseIdentifierSchema.describe('Report unique name'),
172178
label: I18nLabelSchema.describe('Report label'),

skills/objectstack-ai/references/_index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ from `node_modules` — there is no local copy in the skill bundle.
2323
## Transitive dependencies
2424

2525
- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol
26+
- `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum
27+
- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification
2628
- `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010)
2729
- `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol
2830
- `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema
2931
- `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3)
32+
- `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities
3033

3134
## How to read these
3235

0 commit comments

Comments
 (0)