Skip to content

Commit b7eb869

Browse files
os-zhuangclaude
andauthored
feat(spec): reject unknown requires capability tokens at authoring (#3265) (#3302)
defineStack now rejects a `requires` token outside the platform capability vocabulary (PLATFORM_CAPABILITY_TOKENS) — a typo/stale reference no runtime provides, previously silently ignored (declared ≠ enforced, Prime Directive #12). Deprecated aliases still canonicalize with a warning; the serve resolver keeps warning (not throwing) on a raw artifact so a pre-built/older-spec artifact never crash-boots a running server. Terminal state of the #3265 vocabulary work. Vocabulary audited complete across both repos. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjHfkKmEvgk8v7N8nTe5sH
1 parent 92f5f19 commit b7eb869

3 files changed

Lines changed: 86 additions & 36 deletions

File tree

packages/spec/src/kernel/platform-capabilities.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
* Growing the platform: when a new `requires`-resolvable service ships, add
2121
* its token HERE as well as to the runtime's provider registry — the CLI's
2222
* vocabulary-drift test fails if the registries and this list fall out of
23-
* sync. Unknown tokens are warn-only for now (`defineStack` and the serve
24-
* resolver both warn) so third-party experimentation isn't bricked; once the
25-
* vocabulary has proven complete the warn is intended to become a reject
26-
* (Prime Directive #12: surface producer mistakes at authoring, loudly).
23+
* sync. An unknown token is REJECTED by `defineStack` at authoring time
24+
* (framework#3265) — the vocabulary is the union of every token the framework
25+
* CLI and cloud's objectos-runtime resolve, so a token outside it is a typo or
26+
* stale reference no runtime provides (Prime Directive #12: surface producer
27+
* mistakes at authoring, loudly). The serve resolver still only WARNS on an
28+
* unknown token in a raw artifact — a pre-built/older-spec artifact must not
29+
* crash-boot a running server over a no-op token; authoring is the gate.
2730
*/
2831
export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([
2932
// Tier-gated capabilities (framework `serve.ts` CAPABILITY_TO_TIER)

packages/spec/src/stack-requires.test.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
22
import { defineStack } from './stack.zod';
33

44
// framework#3265 — defineStack canonicalizes deprecated `requires` spellings at
5-
// the PRODUCER (authoring time) and warn-validates tokens against the platform
6-
// capability vocabulary. Warn-first: unknown tokens must not throw (yet).
5+
// the PRODUCER (authoring time) and validates tokens against the platform
6+
// capability vocabulary: deprecated aliases are rewritten with a warning, an
7+
// unknown token is a hard error (no runtime provides it → declared ≠ enforced).
78

89
afterEach(() => {
910
vi.restoreAllMocks();
@@ -26,12 +27,30 @@ describe('defineStack requires canonicalization (#3265)', () => {
2627
expect(warn).not.toHaveBeenCalled();
2728
});
2829

29-
it('warns on unknown tokens but does NOT throw (warn-first)', () => {
30+
it('THROWS on an unknown token (a typo no runtime provides), naming it', () => {
31+
expect(() => defineStack({ requires: ['automations'] })).toThrowError(
32+
/capability validation failed[\s\S]*'automations' is not a known platform capability/,
33+
);
34+
});
35+
36+
it('reports every distinct unknown token but not known ones', () => {
37+
let msg = '';
38+
try {
39+
defineStack({ requires: ['ai', 'automations', 'analytiks', 'ai'] });
40+
} catch (e) {
41+
msg = e instanceof Error ? e.message : String(e);
42+
}
43+
expect(msg).toContain("'automations'");
44+
expect(msg).toContain("'analytiks'");
45+
expect(msg).toContain('(2 issues)');
46+
expect(msg).not.toContain("'ai' is not"); // known token isn't flagged
47+
});
48+
49+
it('a deprecated alias is NOT treated as unknown — it canonicalizes and passes', () => {
3050
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
31-
const stack = defineStack({ requires: ['automations'] });
32-
expect(stack.requires).toEqual(['automations']);
51+
expect(() => defineStack({ requires: ['aiStudio', 'ai-seat'] })).not.toThrow();
52+
// aiStudio → ai-studio (warned); ai-seat is already canonical
3353
expect(warn).toHaveBeenCalledTimes(1);
34-
expect(warn.mock.calls[0][0]).toContain("'automations' is not a known platform capability");
3554
});
3655

3756
it('warns once per distinct token, not once per occurrence', () => {

packages/spec/src/stack.zod.ts

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,12 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({
427427
* intent). Use this for the AI service too: `requires: ['ai']` makes a missing
428428
* `@objectstack/service-ai` a hard boot error rather than a broken-but-booted app.
429429
*
430+
* Tokens must be canonical members of the platform vocabulary
431+
* (`PLATFORM_CAPABILITY_TOKENS`; deprecated aliases like `aiStudio` are
432+
* rewritten with a warning). An UNKNOWN token — a typo or stale reference no
433+
* runtime provides — is a `defineStack` **error**, not a silent no-op
434+
* (framework#3265).
435+
*
430436
* If a capability is also provided explicitly via `plugins[]`, the
431437
* explicit instance wins (and the resolver does not double-register).
432438
*
@@ -439,7 +445,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({
439445
* });
440446
* ```
441447
*/
442-
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)'),
448+
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)'),
443449

444450
/**
445451
* Plugin tier presets to auto-register (e.g. `core`, `ai`, `ui`, `auth`).
@@ -993,21 +999,13 @@ function validateHierarchyScopeCapability(data: unknown): string[] {
993999
}
9941000

9951001
/**
996-
* Canonicalize `requires` tokens and warn-validate them against the platform
997-
* capability vocabulary (framework#3265).
998-
*
999-
* - Deprecated alias spellings (`aiStudio` → `ai-studio`, `aiSeat` →
1000-
* `ai-seat`) are REWRITTEN to canonical at authoring time — the producer is
1001-
* fixed, so every consumer (serve resolver, cloud loader, discovery) sees
1002-
* one spelling (Prime Directive #12).
1003-
* - Tokens outside the vocabulary get a console warning. Warn-only for now —
1004-
* both runtimes previously ignored unknown tokens silently, so a hard reject
1005-
* could brick working stacks; once the vocabulary proves complete this is
1006-
* intended to become a defineStack error.
1007-
*
1008-
* Returns the (possibly rewritten) definition; emits warnings via
1009-
* `console.warn`. Strict mode only — non-strict mode skips all validation by
1010-
* contract.
1002+
* Canonicalize `requires` tokens against the platform capability vocabulary
1003+
* (framework#3265): deprecated alias spellings (`aiStudio` → `ai-studio`,
1004+
* `aiSeat` → `ai-seat`) are REWRITTEN to canonical at authoring time — the
1005+
* producer is fixed, so every consumer (serve resolver, cloud loader,
1006+
* discovery) sees one spelling (Prime Directive #12). Emits a deprecation
1007+
* warning per rewritten token. Unknown tokens are NOT handled here — they are
1008+
* rejected by {@link validateKnownCapabilities}. Strict mode only.
10111009
*/
10121010
function canonicalizeStackRequires(config: ObjectStackDefinition): ObjectStackDefinition {
10131011
const raw = config.requires;
@@ -1028,20 +1026,41 @@ function canonicalizeStackRequires(config: ObjectStackDefinition): ObjectStackDe
10281026
changed = true;
10291027
return mapped;
10301028
}
1031-
if (!PLATFORM_CAPABILITY_TOKENS.includes(token) && !warned.has(token)) {
1032-
warned.add(token);
1033-
console.warn(
1034-
`[defineStack] requires: '${token}' is not a known platform capability — ` +
1035-
`check for a typo (known tokens are kebab-case, e.g. 'ai-studio', 'pinyin-search'). ` +
1036-
`Unknown tokens are ignored by the runtime today; this will become a validation error (framework#3265).`,
1037-
);
1038-
}
10391029
return token;
10401030
});
10411031

10421032
return changed ? { ...config, requires: canonical } : config;
10431033
}
10441034

1035+
/**
1036+
* Reject `requires` tokens that are not part of the platform capability
1037+
* vocabulary (framework#3265). Runs AFTER {@link canonicalizeStackRequires}, so
1038+
* deprecated aliases have already resolved to canonical tokens — an unknown
1039+
* token here is a genuine typo or a stale reference that NO runtime provides, so
1040+
* every runtime would otherwise SILENTLY ignore it (declared ≠ enforced). Fail
1041+
* at the producer, loudly (Prime Directive #12): the warn-first grace period
1042+
* this replaced is over — the vocabulary is the union of every token the
1043+
* framework CLI and cloud's objectos-runtime resolve, plus the enterprise
1044+
* plugin-provided ones (`hierarchy-security` / `ai-seat` / `governance`).
1045+
* Returns one error per distinct unknown token.
1046+
*/
1047+
function validateKnownCapabilities(config: ObjectStackDefinition): string[] {
1048+
const raw = config.requires;
1049+
if (!raw || raw.length === 0) return [];
1050+
const seen = new Set<string>();
1051+
const errors: string[] = [];
1052+
for (const token of raw) {
1053+
if (PLATFORM_CAPABILITY_TOKENS.includes(token) || seen.has(token)) continue;
1054+
seen.add(token);
1055+
errors.push(
1056+
`requires: '${token}' is not a known platform capability — check for a typo ` +
1057+
`(known tokens are kebab-case, e.g. 'ai-studio', 'pinyin-search', 'automation'). ` +
1058+
`No runtime provides it, so it would be silently ignored.`,
1059+
);
1060+
}
1061+
return errors;
1062+
}
1063+
10451064
export function defineStack(
10461065
config: ObjectStackDefinitionInput,
10471066
options?: DefineStackOptions,
@@ -1066,10 +1085,19 @@ export function defineStack(
10661085
throw new Error(formatZodError(result.error, 'defineStack validation failed'));
10671086
}
10681087

1069-
// Canonicalize `requires` (deprecated aliases → kebab canon) and warn on
1070-
// unknown capability tokens BEFORE the validators below read the definition.
1088+
// Canonicalize `requires` (deprecated aliases → kebab canon) BEFORE the
1089+
// validators below read the definition, then REJECT any unknown capability
1090+
// token (framework#3265): no runtime provides it, so it would otherwise be
1091+
// silently ignored (declared ≠ enforced, Prime Directive #12).
10711092
const data = canonicalizeStackRequires(result.data);
10721093

1094+
const capErrors = validateKnownCapabilities(data);
1095+
if (capErrors.length > 0) {
1096+
const header = `defineStack capability validation failed (${capErrors.length} issue${capErrors.length === 1 ? '' : 's'}):`;
1097+
const lines = capErrors.map((e) => ` ✗ ${e}`);
1098+
throw new Error(`${header}\n\n${lines.join('\n')}`);
1099+
}
1100+
10731101
const crossRefErrors = validateCrossReferences(data);
10741102
if (crossRefErrors.length > 0) {
10751103
const header = `defineStack cross-reference validation failed (${crossRefErrors.length} issue${crossRefErrors.length === 1 ? '' : 's'}):`;

0 commit comments

Comments
 (0)