From 5c59137b3c6fa948c366074d0957078696d833e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 16:49:54 +0000 Subject: [PATCH] feat(spec): reject unknown `requires` capability tokens at authoring (#3265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warn-first phase (framework#3281) is over: the platform capability vocabulary is now the proven-complete union of every token the framework CLI and cloud's objectos-runtime resolve (audited across both repos — the only out-of-vocabulary token in either is a test fixture). So defineStack now REJECTS an unknown `requires` token instead of warning: - Split canonicalizeStackRequires (alias rewrite + deprecation warn only) from a new validateKnownCapabilities that returns one error per distinct unknown token; defineStack throws on any, consistent with its other validators. - Deprecated aliases (aiStudio/aiSeat) still canonicalize with a warning — they are valid, just deprecated; only genuinely unknown tokens (typos / stale references no runtime provides) throw. - The serve resolver keeps WARNING (not throwing) on an unknown token in a raw artifact: authoring is the gate (Prime Directive #12), and a pre-built or older-spec artifact must not crash-boot a running server over a no-op token. Closes the "declared ≠ enforced" gap for capability tokens: a typo used to be silently ignored by every runtime; now it fails loudly at build time. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TjHfkKmEvgk8v7N8nTe5sH --- .../spec/src/kernel/platform-capabilities.ts | 11 ++- packages/spec/src/stack-requires.test.ts | 31 +++++-- packages/spec/src/stack.zod.ts | 80 +++++++++++++------ 3 files changed, 86 insertions(+), 36 deletions(-) diff --git a/packages/spec/src/kernel/platform-capabilities.ts b/packages/spec/src/kernel/platform-capabilities.ts index a2878539e0..51386da529 100644 --- a/packages/spec/src/kernel/platform-capabilities.ts +++ b/packages/spec/src/kernel/platform-capabilities.ts @@ -20,10 +20,13 @@ * Growing the platform: when a new `requires`-resolvable service ships, add * its token HERE as well as to the runtime's provider registry — the CLI's * vocabulary-drift test fails if the registries and this list fall out of - * sync. Unknown tokens are warn-only for now (`defineStack` and the serve - * resolver both warn) so third-party experimentation isn't bricked; once the - * vocabulary has proven complete the warn is intended to become a reject - * (Prime Directive #12: surface producer mistakes at authoring, loudly). + * sync. An unknown token is REJECTED by `defineStack` at authoring time + * (framework#3265) — the vocabulary is the union of every token the framework + * CLI and cloud's objectos-runtime resolve, so a token outside it is a typo or + * stale reference no runtime provides (Prime Directive #12: surface producer + * mistakes at authoring, loudly). The serve resolver still only WARNS on an + * unknown token in a raw artifact — a pre-built/older-spec artifact must not + * crash-boot a running server over a no-op token; authoring is the gate. */ export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([ // Tier-gated capabilities (framework `serve.ts` CAPABILITY_TO_TIER) diff --git a/packages/spec/src/stack-requires.test.ts b/packages/spec/src/stack-requires.test.ts index 11d0e23e1d..560a24426a 100644 --- a/packages/spec/src/stack-requires.test.ts +++ b/packages/spec/src/stack-requires.test.ts @@ -2,8 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { defineStack } from './stack.zod'; // framework#3265 — defineStack canonicalizes deprecated `requires` spellings at -// the PRODUCER (authoring time) and warn-validates tokens against the platform -// capability vocabulary. Warn-first: unknown tokens must not throw (yet). +// the PRODUCER (authoring time) and validates tokens against the platform +// capability vocabulary: deprecated aliases are rewritten with a warning, an +// unknown token is a hard error (no runtime provides it → declared ≠ enforced). afterEach(() => { vi.restoreAllMocks(); @@ -26,12 +27,30 @@ describe('defineStack requires canonicalization (#3265)', () => { expect(warn).not.toHaveBeenCalled(); }); - it('warns on unknown tokens but does NOT throw (warn-first)', () => { + it('THROWS on an unknown token (a typo no runtime provides), naming it', () => { + expect(() => defineStack({ requires: ['automations'] })).toThrowError( + /capability validation failed[\s\S]*'automations' is not a known platform capability/, + ); + }); + + it('reports every distinct unknown token but not known ones', () => { + let msg = ''; + try { + defineStack({ requires: ['ai', 'automations', 'analytiks', 'ai'] }); + } catch (e) { + msg = e instanceof Error ? e.message : String(e); + } + expect(msg).toContain("'automations'"); + expect(msg).toContain("'analytiks'"); + expect(msg).toContain('(2 issues)'); + expect(msg).not.toContain("'ai' is not"); // known token isn't flagged + }); + + it('a deprecated alias is NOT treated as unknown — it canonicalizes and passes', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const stack = defineStack({ requires: ['automations'] }); - expect(stack.requires).toEqual(['automations']); + expect(() => defineStack({ requires: ['aiStudio', 'ai-seat'] })).not.toThrow(); + // aiStudio → ai-studio (warned); ai-seat is already canonical expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0][0]).toContain("'automations' is not a known platform capability"); }); it('warns once per distinct token, not once per occurrence', () => { diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index a2830664c5..0ef9b97cb8 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -427,6 +427,12 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ * intent). Use this for the AI service too: `requires: ['ai']` makes a missing * `@objectstack/service-ai` a hard boot error rather than a broken-but-booted app. * + * Tokens must be canonical members of the platform vocabulary + * (`PLATFORM_CAPABILITY_TOKENS`; deprecated aliases like `aiStudio` are + * rewritten with a warning). An UNKNOWN token — a typo or stale reference no + * runtime provides — is a `defineStack` **error**, not a silent no-op + * (framework#3265). + * * If a capability is also provided explicitly via `plugins[]`, the * explicit instance wins (and the resolver does not double-register). * @@ -439,7 +445,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ * }); * ``` */ - requires: z.array(z.string()).optional().describe('Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; declared-but-missing ⇒ fail-fast at startup)'), + requires: z.array(z.string()).optional().describe('Capability names this stack requires from the platform (canonical kebab-case tokens from PLATFORM_CAPABILITY_TOKENS; an unknown token is a defineStack error, declared-but-missing ⇒ fail-fast at startup)'), /** * Plugin tier presets to auto-register (e.g. `core`, `ai`, `ui`, `auth`). @@ -993,21 +999,13 @@ function validateHierarchyScopeCapability(data: unknown): string[] { } /** - * Canonicalize `requires` tokens and warn-validate them against the platform - * capability vocabulary (framework#3265). - * - * - Deprecated alias spellings (`aiStudio` → `ai-studio`, `aiSeat` → - * `ai-seat`) are REWRITTEN to canonical at authoring time — the producer is - * fixed, so every consumer (serve resolver, cloud loader, discovery) sees - * one spelling (Prime Directive #12). - * - Tokens outside the vocabulary get a console warning. Warn-only for now — - * both runtimes previously ignored unknown tokens silently, so a hard reject - * could brick working stacks; once the vocabulary proves complete this is - * intended to become a defineStack error. - * - * Returns the (possibly rewritten) definition; emits warnings via - * `console.warn`. Strict mode only — non-strict mode skips all validation by - * contract. + * Canonicalize `requires` tokens against the platform capability vocabulary + * (framework#3265): deprecated alias spellings (`aiStudio` → `ai-studio`, + * `aiSeat` → `ai-seat`) are REWRITTEN to canonical at authoring time — the + * producer is fixed, so every consumer (serve resolver, cloud loader, + * discovery) sees one spelling (Prime Directive #12). Emits a deprecation + * warning per rewritten token. Unknown tokens are NOT handled here — they are + * rejected by {@link validateKnownCapabilities}. Strict mode only. */ function canonicalizeStackRequires(config: ObjectStackDefinition): ObjectStackDefinition { const raw = config.requires; @@ -1028,20 +1026,41 @@ function canonicalizeStackRequires(config: ObjectStackDefinition): ObjectStackDe changed = true; return mapped; } - if (!PLATFORM_CAPABILITY_TOKENS.includes(token) && !warned.has(token)) { - warned.add(token); - console.warn( - `[defineStack] requires: '${token}' is not a known platform capability — ` + - `check for a typo (known tokens are kebab-case, e.g. 'ai-studio', 'pinyin-search'). ` + - `Unknown tokens are ignored by the runtime today; this will become a validation error (framework#3265).`, - ); - } return token; }); return changed ? { ...config, requires: canonical } : config; } +/** + * Reject `requires` tokens that are not part of the platform capability + * vocabulary (framework#3265). Runs AFTER {@link canonicalizeStackRequires}, so + * deprecated aliases have already resolved to canonical tokens — an unknown + * token here is a genuine typo or a stale reference that NO runtime provides, so + * every runtime would otherwise SILENTLY ignore it (declared ≠ enforced). Fail + * at the producer, loudly (Prime Directive #12): the warn-first grace period + * this replaced is over — the vocabulary is the union of every token the + * framework CLI and cloud's objectos-runtime resolve, plus the enterprise + * plugin-provided ones (`hierarchy-security` / `ai-seat` / `governance`). + * Returns one error per distinct unknown token. + */ +function validateKnownCapabilities(config: ObjectStackDefinition): string[] { + const raw = config.requires; + if (!raw || raw.length === 0) return []; + const seen = new Set(); + const errors: string[] = []; + for (const token of raw) { + if (PLATFORM_CAPABILITY_TOKENS.includes(token) || seen.has(token)) continue; + seen.add(token); + errors.push( + `requires: '${token}' is not a known platform capability — check for a typo ` + + `(known tokens are kebab-case, e.g. 'ai-studio', 'pinyin-search', 'automation'). ` + + `No runtime provides it, so it would be silently ignored.`, + ); + } + return errors; +} + export function defineStack( config: ObjectStackDefinitionInput, options?: DefineStackOptions, @@ -1066,10 +1085,19 @@ export function defineStack( throw new Error(formatZodError(result.error, 'defineStack validation failed')); } - // Canonicalize `requires` (deprecated aliases → kebab canon) and warn on - // unknown capability tokens BEFORE the validators below read the definition. + // Canonicalize `requires` (deprecated aliases → kebab canon) BEFORE the + // validators below read the definition, then REJECT any unknown capability + // token (framework#3265): no runtime provides it, so it would otherwise be + // silently ignored (declared ≠ enforced, Prime Directive #12). const data = canonicalizeStackRequires(result.data); + const capErrors = validateKnownCapabilities(data); + if (capErrors.length > 0) { + const header = `defineStack capability validation failed (${capErrors.length} issue${capErrors.length === 1 ? '' : 's'}):`; + const lines = capErrors.map((e) => ` ✗ ${e}`); + throw new Error(`${header}\n\n${lines.join('\n')}`); + } + const crossRefErrors = validateCrossReferences(data); if (crossRefErrors.length > 0) { const header = `defineStack cross-reference validation failed (${crossRefErrors.length} issue${crossRefErrors.length === 1 ? '' : 's'}):`;