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
11 changes: 7 additions & 4 deletions packages/spec/src/kernel/platform-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 25 additions & 6 deletions packages/spec/src/stack-requires.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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', () => {
Expand Down
80 changes: 54 additions & 26 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand All @@ -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`).
Expand Down Expand Up @@ -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;
Expand All @@ -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<string>();
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,
Expand All @@ -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'}):`;
Expand Down
Loading