From 8cf1602b5c3e3c3757d3386db51f4bc0d1bd9307 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:17:55 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec,cli):=20one=20platform=20capabili?= =?UTF-8?q?ty=20vocabulary=20=E2=80=94=20canonical=20kebab=20tokens,=20dep?= =?UTF-8?q?recated=20aliases,=20warn=20validation=20(#3265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone serve path and cloud's objectos-runtime resolve `requires` tokens through parallel registries that had already diverged: framework shipped `ai-studio` (kebab) while cloud used `aiStudio` (camel), and both sides silently ignored unknown tokens, so the mismatch failed silently. Make the spec the single owner of the vocabulary: - spec/kernel/platform-capabilities: canonical PLATFORM_CAPABILITY_TOKENS (kebab-case; includes cloud-runtime tokens ai-seat/governance and hierarchy-security), DEPRECATED_PLATFORM_CAPABILITY_ALIASES (aiStudio -> ai-studio, aiSeat -> ai-seat, one deprecation cycle), canonicalizePlatformCapability, isKnownPlatformCapability. - defineStack: rewrite deprecated aliases to canonical at authoring time (fix the producer, PD#12) and warn on unknown tokens. Warn-first by design - both runtimes previously ignored unknown tokens, so a hard reject could brick working stacks; intended to become an error once the vocabulary proves complete. - serve.ts: canonicalize raw artifact `requires` through the same helper (covers artifacts compiled by an older spec), warn on declared-but- unknown tokens instead of silently ignoring them, and lift CAPABILITY_PROVIDERS / CAPABILITY_TO_TIER to statics with a drift test asserting every registry key stays inside the spec vocabulary. - types: move the missing-vs-crashed module classifier to @objectstack/types isModuleNotFoundError (the #1595 err.code-first fix); Serve.isModuleNotFoundError delegates. Cloud's objectos-runtime adopts the shared classifier at its next framework pin bump. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TjHfkKmEvgk8v7N8nTe5sH --- packages/cli/src/commands/serve.ts | 413 ++++++++++-------- .../test/serve-capability-vocabulary.test.ts | 44 ++ packages/spec/src/index.ts | 8 + packages/spec/src/kernel/index.ts | 1 + .../src/kernel/platform-capabilities.test.ts | 63 +++ .../spec/src/kernel/platform-capabilities.ts | 92 ++++ packages/spec/src/stack-requires.test.ts | 49 +++ packages/spec/src/stack.zod.ts | 70 ++- packages/types/src/index.ts | 1 + packages/types/src/module-not-found.ts | 22 + 10 files changed, 573 insertions(+), 190 deletions(-) create mode 100644 packages/cli/test/serve-capability-vocabulary.test.ts create mode 100644 packages/spec/src/kernel/platform-capabilities.test.ts create mode 100644 packages/spec/src/kernel/platform-capabilities.ts create mode 100644 packages/spec/src/stack-requires.test.ts create mode 100644 packages/types/src/module-not-found.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 68f457fa45..2f4fdd29d0 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -8,7 +8,8 @@ import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; -import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; +import { PLATFORM_CAPABILITY_TOKENS, canonicalizePlatformCapability } from '@objectstack/spec/kernel'; import { resolveObjectStackHome } from '@objectstack/runtime'; import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js'; import { @@ -139,6 +140,14 @@ const getAvailablePort = async (startPort: number): Promise => { return port; }; +type CapabilitySpec = { + pkg: string; + export: string; // named export to import + nameMatch: string[]; // plugin.name / constructor.name fragments to detect dupes + configKey?: string; // optional config field passed as constructor arg + extras?: Array<{ pkg: string; export: string; nameMatch: string[] }>; +}; + export default class Serve extends Command { static override description = 'Start ObjectStack server. Reads `objectstack.config.ts` if present; otherwise falls back to `dist/objectstack.json` (or OS_ARTIFACT_PATH, including http(s):// URLs) as a portable artifact.'; @@ -214,16 +223,15 @@ export default class Serve extends Command { * module is simply NOT INSTALLED — as opposed to the module being present but * throwing while it loads (a real crash). Checking `err.code` FIRST is the * #1595 fix: ESM reports a missing package as `err.code === - * 'ERR_MODULE_NOT_FOUND'` with the human message `Cannot find package '...'`; - * matching only the older `Cannot find module` string mis-classified that as a - * crash. One owner for this test so the optional-plugin guards and the - * capability resolver can't drift apart and re-introduce that bug (#1597). + * 'ERR_MODULE_NOT_FOUND'` with the human message `Cannot find package '...'`. + * + * Thin delegate to the shared classifier in `@objectstack/types` — one owner + * across the CLI's optional-plugin guards, the capability resolver, and + * (at its next framework pin bump) cloud's objectos-runtime loader, so the + * parallel loaders can't drift apart and re-introduce that bug (#1597, #3265). */ static isModuleNotFoundError(err: unknown): boolean { - const code = (err as { code?: string } | null | undefined)?.code; - if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') return true; - const msg = err instanceof Error ? err.message : String(err); - return msg.includes('Cannot find module') || msg.includes('Cannot find package'); + return isModuleNotFoundError(err); } /** @@ -256,6 +264,177 @@ export default class Serve extends Command { return 'off'; } + /** + * Tier-gated capability tokens → the tier each one opens when listed in + * `requires`. These have no CAPABILITY_PROVIDERS entry — their loading is + * resolved by dedicated blocks in run() (`ai`/`ai-studio` by the intent-driven + * AI block (#1597), `i18n`/`ui`/`auth` by their tier blocks). + */ + static readonly CAPABILITY_TO_TIER: Record = { + ai: 'ai', + // `ai-studio` (AI-driven authoring) rides on the base AI service, so + // requiring it opens the same `ai` tier (#1597). + 'ai-studio': 'ai', + i18n: 'i18n', + ui: 'ui', + auth: 'auth', + }; + + /** + * Registry of `requires` token → built-in service-plugin provider for the + * standalone serve path. Keys are canonical kebab-case platform capability + * tokens — a drift test asserts every key is in the spec-owned + * PLATFORM_CAPABILITY_TOKENS vocabulary (framework#3265). Adding a built-in + * capability = one entry here + its token in the spec vocabulary. + */ + static readonly CAPABILITY_PROVIDERS: Record = { + automation: { + // Self-contained: AutomationServicePlugin seeds all built-in node + // executors itself (ADR-0018), so flows have executors with no + // companion node-pack plugins. + pkg: '@objectstack/service-automation', + export: 'AutomationServicePlugin', + nameMatch: ['service-automation', 'AutomationServicePlugin'], + }, + analytics: { + pkg: '@objectstack/service-analytics', + export: 'AnalyticsServicePlugin', + nameMatch: ['service-analytics', 'AnalyticsServicePlugin'], + configKey: 'analyticsCubes', + }, + audit: { + pkg: '@objectstack/plugin-audit', + export: 'AuditPlugin', + nameMatch: ['audit', 'AuditPlugin'], + }, + cache: { + pkg: '@objectstack/service-cache', + export: 'CacheServicePlugin', + nameMatch: ['service-cache', 'CacheServicePlugin'], + }, + storage: { + pkg: '@objectstack/service-storage', + export: 'StorageServicePlugin', + nameMatch: ['service-storage', 'StorageServicePlugin'], + }, + queue: { + pkg: '@objectstack/service-queue', + export: 'QueueServicePlugin', + nameMatch: ['service-queue', 'QueueServicePlugin'], + }, + job: { + pkg: '@objectstack/service-job', + export: 'JobServicePlugin', + nameMatch: ['service-job', 'JobServicePlugin'], + }, + messaging: { + // Backs the `notify` flow node (ADR-0012): delivers to a user's + // channels (inbox by default → `sys_inbox_message` rows). Without + // this the notify node degrades to a logged no-op. + pkg: '@objectstack/service-messaging', + export: 'MessagingServicePlugin', + nameMatch: ['service-messaging', 'MessagingServicePlugin'], + }, + triggers: { + // Makes autolaunched flows actually fire. The automation engine ships + // the `FlowTrigger` wiring; these plugins are the concrete triggers: + // record-change (ObjectQL lifecycle hooks) + schedule (cron/interval + // via the job service — so pair `triggers` with `job`). + pkg: '@objectstack/trigger-record-change', + export: 'RecordChangeTriggerPlugin', + nameMatch: ['trigger-record-change', 'RecordChangeTriggerPlugin'], + extras: [ + { + pkg: '@objectstack/trigger-schedule', + export: 'ScheduleTriggerPlugin', + nameMatch: ['trigger-schedule', 'ScheduleTriggerPlugin'], + }, + { + // Declarative time-relative sweep (#1874) — arms flows whose start + // node declares `config.timeRelative` (fire daily for records whose + // date field is within N days / at T-minus offsets). Ships in + // @objectstack/trigger-schedule; needs the job service + ObjectQL. + pkg: '@objectstack/trigger-schedule', + export: 'TimeRelativeTriggerPlugin', + nameMatch: ['trigger-schedule', 'TimeRelativeTriggerPlugin'], + }, + { + // Inbound webhook/HTTP trigger (ADR-0041 Tier 1) — arms + // `type: 'api'` flows with HMAC-verified, queue-backed hooks. + pkg: '@objectstack/trigger-api', + export: 'ApiTriggerPlugin', + nameMatch: ['trigger-api', 'ApiTriggerPlugin'], + }, + ], + }, + realtime: { + pkg: '@objectstack/service-realtime', + export: 'RealtimeServicePlugin', + nameMatch: ['service-realtime', 'RealtimeServicePlugin'], + }, + // `feed` removed (ADR-0052 §5): `sys_comment`/`sys_activity` (durable, + // default-loaded, UI-wired) is the canonical record collaboration + + // timeline backend. `@objectstack/service-feed` was an in-memory, + // non-durable, UI-unconsumed parallel implementation — retired to end + // the split-brain. The unified typed timeline lives on `sys_activity`. + mcp: { + pkg: '@objectstack/mcp', + export: 'MCPServerPlugin', + nameMatch: ['mcp-server', 'MCPServerPlugin', 'mcp'], + }, + marketplace: { + pkg: '@objectstack/service-package', + export: 'PackageServicePlugin', + nameMatch: ['service-package', 'PackageServicePlugin'], + }, + email: { + pkg: '@objectstack/plugin-email', + export: 'EmailServicePlugin', + nameMatch: ['plugin-email', 'EmailServicePlugin'], + }, + sms: { + // #2780 — backs phone-number OTP sign-in/reset (plugin-auth) and + // the messaging `sms` channel. Provider config lives in the `sms` + // settings namespace (OS_SMS_* env keys win at the resolver); + // unconfigured ⇒ dev LogSmsTransport (no real send). + pkg: '@objectstack/service-sms', + export: 'SmsServicePlugin', + nameMatch: ['service-sms', 'SmsServicePlugin'], + }, + sharing: { + pkg: '@objectstack/plugin-sharing', + export: 'SharingServicePlugin', + nameMatch: ['plugin-sharing', 'SharingServicePlugin', 'SharingPlugin'], + }, + // #2486 — auto-required above when resolveSearchPinyinEnabled() + // (explicit env, else any configured zh-* locale) says on. + 'pinyin-search': { + pkg: '@objectstack/plugin-pinyin-search', + export: 'PinyinSearchPlugin', + nameMatch: ['plugin-pinyin-search', 'PinyinSearchPlugin'], + }, + reports: { + pkg: '@objectstack/plugin-reports', + export: 'ReportsServicePlugin', + nameMatch: ['plugin-reports', 'ReportsServicePlugin'], + }, + approvals: { + pkg: '@objectstack/plugin-approvals', + export: 'ApprovalsServicePlugin', + nameMatch: ['plugin-approvals', 'ApprovalsServicePlugin'], + }, + settings: { + pkg: '@objectstack/service-settings', + export: 'SettingsServicePlugin', + nameMatch: ['service-settings', 'SettingsServicePlugin'], + }, + webhooks: { + pkg: '@objectstack/plugin-webhooks', + export: 'WebhookOutboxPlugin', + nameMatch: ['plugin-webhook-outbox', 'WebhookOutboxPlugin'], + }, + }; + async run(): Promise { const { args, flags } = await this.parse(Serve); @@ -515,9 +694,22 @@ export default class Serve extends Command { // instance wins over the auto-loader). const presetName = flags.preset ?? (isDev ? 'default' : 'default'); const presetTiers = Serve.TIER_PRESETS[presetName] ?? Serve.TIER_PRESETS.default; - const requires: string[] = Array.isArray((config as any).requires) + const rawRequires: string[] = Array.isArray((config as any).requires) ? (config as any).requires.filter((c: unknown) => typeof c === 'string') : []; + // Canonicalize deprecated capability spellings (aiStudio → ai-studio, …) + // so artifacts compiled by an older spec keep booting; `defineStack` + // already rewrites + warns on the source path, this covers raw artifact + // JSON (framework#3265). Dedupe after mapping (Set keeps first-seen order). + const requires: string[] = [...new Set(rawRequires.map((c: string) => { + const canonical = canonicalizePlatformCapability(c); + if (canonical !== c) { + console.warn(chalk.yellow( + ` ⚠ requires: '${c}' is a deprecated spelling — use '${canonical}' (framework#3265)`, + )); + } + return canonical; + }))]; // Snapshot the app's EXPLICIT capability declarations BEFORE the platform // appends its own convenience defaults (auth→email, mcp, pinyin-search, // ALWAYS_ON_CAPABILITIES, queue/job). Only these explicit declarations carry @@ -583,20 +775,11 @@ export default class Serve extends Command { if (!requires.includes('job')) requires.unshift('job'); } // Capability → tier: any capability that is gated by a tier - // here automatically opens that tier when listed in `requires`. - // Capabilities NOT in this map (e.g. `automation`, `analytics`, - // `audit`) bypass tier gating and are loaded directly by the - // capability-resolver block further down. - const CAPABILITY_TO_TIER: Record = { - ai: 'ai', - // `ai-studio` (AI-driven authoring) rides on the base AI service, so - // requiring it opens the same `ai` tier (#1597). The dedicated AI block - // below resolves its load; it has no CAPABILITY_PROVIDERS entry. - 'ai-studio': 'ai', - i18n: 'i18n', - ui: 'ui', - auth: 'auth', - }; + // (Serve.CAPABILITY_TO_TIER) automatically opens that tier when listed + // in `requires`. Capabilities NOT in that map (e.g. `automation`, + // `analytics`, `audit`) bypass tier gating and are loaded directly by + // the capability-resolver block further down. + const CAPABILITY_TO_TIER = Serve.CAPABILITY_TO_TIER; const requiredTiers = requires .map((c) => CAPABILITY_TO_TIER[c]) .filter((t): t is string => typeof t === 'string'); @@ -1827,165 +2010,12 @@ export default class Serve extends Command { } // 5. Capability resolver — auto-load service plugins declared in - // `requires: [...]` that are NOT tier-gated. Each entry maps to a - // package + factory; if the user already provided an explicit - // instance via `plugins: [...]` we skip (explicit wins). - // - // Adding a new built-in capability is a one-line change here. - type CapabilitySpec = { - pkg: string; - export: string; // named export to import - nameMatch: string[]; // plugin.name / constructor.name fragments to detect dupes - configKey?: string; // optional config field passed as constructor arg - extras?: Array<{ pkg: string; export: string; nameMatch: string[] }>; - }; - const CAPABILITY_PROVIDERS: Record = { - automation: { - // Self-contained: AutomationServicePlugin seeds all built-in node - // executors itself (ADR-0018), so flows have executors with no - // companion node-pack plugins. - pkg: '@objectstack/service-automation', - export: 'AutomationServicePlugin', - nameMatch: ['service-automation', 'AutomationServicePlugin'], - }, - analytics: { - pkg: '@objectstack/service-analytics', - export: 'AnalyticsServicePlugin', - nameMatch: ['service-analytics', 'AnalyticsServicePlugin'], - configKey: 'analyticsCubes', - }, - audit: { - pkg: '@objectstack/plugin-audit', - export: 'AuditPlugin', - nameMatch: ['audit', 'AuditPlugin'], - }, - cache: { - pkg: '@objectstack/service-cache', - export: 'CacheServicePlugin', - nameMatch: ['service-cache', 'CacheServicePlugin'], - }, - storage: { - pkg: '@objectstack/service-storage', - export: 'StorageServicePlugin', - nameMatch: ['service-storage', 'StorageServicePlugin'], - }, - queue: { - pkg: '@objectstack/service-queue', - export: 'QueueServicePlugin', - nameMatch: ['service-queue', 'QueueServicePlugin'], - }, - job: { - pkg: '@objectstack/service-job', - export: 'JobServicePlugin', - nameMatch: ['service-job', 'JobServicePlugin'], - }, - messaging: { - // Backs the `notify` flow node (ADR-0012): delivers to a user's - // channels (inbox by default → `sys_inbox_message` rows). Without - // this the notify node degrades to a logged no-op. - pkg: '@objectstack/service-messaging', - export: 'MessagingServicePlugin', - nameMatch: ['service-messaging', 'MessagingServicePlugin'], - }, - triggers: { - // Makes autolaunched flows actually fire. The automation engine ships - // the `FlowTrigger` wiring; these plugins are the concrete triggers: - // record-change (ObjectQL lifecycle hooks) + schedule (cron/interval - // via the job service — so pair `triggers` with `job`). - pkg: '@objectstack/trigger-record-change', - export: 'RecordChangeTriggerPlugin', - nameMatch: ['trigger-record-change', 'RecordChangeTriggerPlugin'], - extras: [ - { - pkg: '@objectstack/trigger-schedule', - export: 'ScheduleTriggerPlugin', - nameMatch: ['trigger-schedule', 'ScheduleTriggerPlugin'], - }, - { - // Declarative time-relative sweep (#1874) — arms flows whose start - // node declares `config.timeRelative` (fire daily for records whose - // date field is within N days / at T-minus offsets). Ships in - // @objectstack/trigger-schedule; needs the job service + ObjectQL. - pkg: '@objectstack/trigger-schedule', - export: 'TimeRelativeTriggerPlugin', - nameMatch: ['trigger-schedule', 'TimeRelativeTriggerPlugin'], - }, - { - // Inbound webhook/HTTP trigger (ADR-0041 Tier 1) — arms - // `type: 'api'` flows with HMAC-verified, queue-backed hooks. - pkg: '@objectstack/trigger-api', - export: 'ApiTriggerPlugin', - nameMatch: ['trigger-api', 'ApiTriggerPlugin'], - }, - ], - }, - realtime: { - pkg: '@objectstack/service-realtime', - export: 'RealtimeServicePlugin', - nameMatch: ['service-realtime', 'RealtimeServicePlugin'], - }, - // `feed` removed (ADR-0052 §5): `sys_comment`/`sys_activity` (durable, - // default-loaded, UI-wired) is the canonical record collaboration + - // timeline backend. `@objectstack/service-feed` was an in-memory, - // non-durable, UI-unconsumed parallel implementation — retired to end - // the split-brain. The unified typed timeline lives on `sys_activity`. - mcp: { - pkg: '@objectstack/mcp', - export: 'MCPServerPlugin', - nameMatch: ['mcp-server', 'MCPServerPlugin', 'mcp'], - }, - marketplace: { - pkg: '@objectstack/service-package', - export: 'PackageServicePlugin', - nameMatch: ['service-package', 'PackageServicePlugin'], - }, - email: { - pkg: '@objectstack/plugin-email', - export: 'EmailServicePlugin', - nameMatch: ['plugin-email', 'EmailServicePlugin'], - }, - sms: { - // #2780 — backs phone-number OTP sign-in/reset (plugin-auth) and - // the messaging `sms` channel. Provider config lives in the `sms` - // settings namespace (OS_SMS_* env keys win at the resolver); - // unconfigured ⇒ dev LogSmsTransport (no real send). - pkg: '@objectstack/service-sms', - export: 'SmsServicePlugin', - nameMatch: ['service-sms', 'SmsServicePlugin'], - }, - sharing: { - pkg: '@objectstack/plugin-sharing', - export: 'SharingServicePlugin', - nameMatch: ['plugin-sharing', 'SharingServicePlugin', 'SharingPlugin'], - }, - // #2486 — auto-required above when resolveSearchPinyinEnabled() - // (explicit env, else any configured zh-* locale) says on. - 'pinyin-search': { - pkg: '@objectstack/plugin-pinyin-search', - export: 'PinyinSearchPlugin', - nameMatch: ['plugin-pinyin-search', 'PinyinSearchPlugin'], - }, - reports: { - pkg: '@objectstack/plugin-reports', - export: 'ReportsServicePlugin', - nameMatch: ['plugin-reports', 'ReportsServicePlugin'], - }, - approvals: { - pkg: '@objectstack/plugin-approvals', - export: 'ApprovalsServicePlugin', - nameMatch: ['plugin-approvals', 'ApprovalsServicePlugin'], - }, - settings: { - pkg: '@objectstack/service-settings', - export: 'SettingsServicePlugin', - nameMatch: ['service-settings', 'SettingsServicePlugin'], - }, - webhooks: { - pkg: '@objectstack/plugin-webhooks', - export: 'WebhookOutboxPlugin', - nameMatch: ['plugin-webhook-outbox', 'WebhookOutboxPlugin'], - }, - }; + // `requires: [...]` that are NOT tier-gated. Each entry of + // Serve.CAPABILITY_PROVIDERS maps a token to a package + factory; if the + // user already provided an explicit instance via `plugins: [...]` we + // skip (explicit wins). Adding a new built-in capability = one entry on + // the static registry + its token in the spec vocabulary (#3265). + const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS; const hasPluginMatching = (fragments: string[]) => plugins.some((p: any) => { @@ -1996,7 +2026,22 @@ export default class Serve extends Command { for (const cap of requires) { const spec = CAPABILITY_PROVIDERS[cap]; - if (!spec) continue; // tier-gated capabilities (ai/i18n/ui/auth) handled above + if (!spec) { + // No provider in this runtime. Tier-gated tokens (ai/ai-studio/i18n/ + // ui/auth) are handled by their dedicated blocks above; other KNOWN + // vocabulary tokens are provided elsewhere (`hierarchy-security` via + // an explicit enterprise plugin in `plugins[]`, `ai-seat`/`governance` + // by cloud's objectos-runtime) — stay quiet for those. An UNKNOWN + // declared token is a typo that was previously ignored SILENTLY + // (#3265) — warn loudly. Warn-first: intended to become a hard error + // once the vocabulary proves complete (Prime Directive #12). + if (declaredRequires.has(cap) && !PLATFORM_CAPABILITY_TOKENS.includes(cap)) { + console.warn(chalk.yellow( + ` ⚠ requires: "${cap}" is not a known platform capability — check for a typo. It was ignored.`, + )); + } + continue; + } if (hasPluginMatching(spec.nameMatch)) continue; try { diff --git a/packages/cli/test/serve-capability-vocabulary.test.ts b/packages/cli/test/serve-capability-vocabulary.test.ts new file mode 100644 index 0000000000..ed17d4e4ed --- /dev/null +++ b/packages/cli/test/serve-capability-vocabulary.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { + PLATFORM_CAPABILITY_TOKENS, + DEPRECATED_PLATFORM_CAPABILITY_ALIASES, +} from '@objectstack/spec/kernel'; +import Serve from '../src/commands/serve.js'; + +// framework#3265 — drift guard: the serve path's provider registries must stay +// inside the spec-owned platform capability vocabulary, so the standalone +// runtime and cloud's objectos-runtime keep resolving the SAME token set. + +describe('serve capability registries vs spec vocabulary (#3265)', () => { + it('every CAPABILITY_PROVIDERS token is in PLATFORM_CAPABILITY_TOKENS', () => { + for (const token of Object.keys(Serve.CAPABILITY_PROVIDERS)) { + expect(PLATFORM_CAPABILITY_TOKENS, `provider token '${token}' missing from spec vocabulary`).toContain(token); + } + }); + + it('every CAPABILITY_TO_TIER token is in PLATFORM_CAPABILITY_TOKENS', () => { + for (const token of Object.keys(Serve.CAPABILITY_TO_TIER)) { + expect(PLATFORM_CAPABILITY_TOKENS, `tier token '${token}' missing from spec vocabulary`).toContain(token); + } + }); + + it('registries use only canonical spellings — never deprecated aliases', () => { + const legacy = Object.keys(DEPRECATED_PLATFORM_CAPABILITY_ALIASES); + for (const token of [...Object.keys(Serve.CAPABILITY_PROVIDERS), ...Object.keys(Serve.CAPABILITY_TO_TIER)]) { + expect(legacy).not.toContain(token); + } + }); + + it('tier-gated and provider-backed tokens do not overlap (each token has ONE resolution path)', () => { + const providerTokens = new Set(Object.keys(Serve.CAPABILITY_PROVIDERS)); + for (const tierToken of Object.keys(Serve.CAPABILITY_TO_TIER)) { + expect(providerTokens.has(tierToken)).toBe(false); + } + }); + + it('ALWAYS_ON_CAPABILITIES stays inside the vocabulary too', () => { + for (const token of Serve.ALWAYS_ON_CAPABILITIES) { + expect(PLATFORM_CAPABILITY_TOKENS).toContain(token); + } + }); +}); diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index d2616f5cea..f1766e599d 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -120,6 +120,14 @@ export * from './migrations/index.js'; export { type PluginContext } from './kernel/plugin.zod'; +// Platform SERVICE capability vocabulary for `requires: [...]` (framework#3265). +export { + PLATFORM_CAPABILITY_TOKENS, + DEPRECATED_PLATFORM_CAPABILITY_ALIASES, + canonicalizePlatformCapability, + isKnownPlatformCapability, +} from './kernel/platform-capabilities'; + // Expression Protocol (M9 — canonical wire format for formulas / predicates / conditions) export { ExpressionDialect, diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index a14f2a6ae9..9ad3186a3f 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -10,6 +10,7 @@ export * from './feature.zod'; export * from './manifest.zod'; export * from './metadata-customization.zod'; export * from './namespace-prefix'; +export * from './platform-capabilities'; export * from './metadata-loader.zod'; export * from './metadata-plugin.zod'; export * from './metadata-protection.zod'; diff --git a/packages/spec/src/kernel/platform-capabilities.test.ts b/packages/spec/src/kernel/platform-capabilities.test.ts new file mode 100644 index 0000000000..c93ef9eef0 --- /dev/null +++ b/packages/spec/src/kernel/platform-capabilities.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + PLATFORM_CAPABILITY_TOKENS, + DEPRECATED_PLATFORM_CAPABILITY_ALIASES, + canonicalizePlatformCapability, + isKnownPlatformCapability, +} from './platform-capabilities'; + +// framework#3265 — one capability vocabulary across the standalone serve path +// and cloud's objectos-runtime loader, canonical spelling kebab-case. + +describe('PLATFORM_CAPABILITY_TOKENS', () => { + it('is frozen and duplicate-free', () => { + expect(Object.isFrozen(PLATFORM_CAPABILITY_TOKENS)).toBe(true); + expect(new Set(PLATFORM_CAPABILITY_TOKENS).size).toBe(PLATFORM_CAPABILITY_TOKENS.length); + }); + + it('every token is canonical lower-case kebab-case', () => { + for (const t of PLATFORM_CAPABILITY_TOKENS) { + expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + } + }); + + it('contains the tier-gated and headline service tokens', () => { + for (const t of ['ai', 'ai-studio', 'automation', 'analytics', 'pinyin-search', 'hierarchy-security']) { + expect(PLATFORM_CAPABILITY_TOKENS).toContain(t); + } + }); + + it('contains no deprecated spellings', () => { + for (const legacy of Object.keys(DEPRECATED_PLATFORM_CAPABILITY_ALIASES)) { + expect(PLATFORM_CAPABILITY_TOKENS).not.toContain(legacy); + } + }); +}); + +describe('DEPRECATED_PLATFORM_CAPABILITY_ALIASES / canonicalizePlatformCapability', () => { + it('maps every alias onto a token that exists in the vocabulary', () => { + for (const canonical of Object.values(DEPRECATED_PLATFORM_CAPABILITY_ALIASES)) { + expect(PLATFORM_CAPABILITY_TOKENS).toContain(canonical); + } + }); + + it('canonicalizes the legacy cloud spellings', () => { + expect(canonicalizePlatformCapability('aiStudio')).toBe('ai-studio'); + expect(canonicalizePlatformCapability('aiSeat')).toBe('ai-seat'); + }); + + it('is identity for canonical and unknown tokens', () => { + expect(canonicalizePlatformCapability('ai-studio')).toBe('ai-studio'); + expect(canonicalizePlatformCapability('automation')).toBe('automation'); + expect(canonicalizePlatformCapability('not-a-capability')).toBe('not-a-capability'); + }); +}); + +describe('isKnownPlatformCapability', () => { + it('accepts canonical tokens and deprecated aliases, rejects unknowns', () => { + expect(isKnownPlatformCapability('ai-studio')).toBe(true); + expect(isKnownPlatformCapability('aiStudio')).toBe(true); + expect(isKnownPlatformCapability('governance')).toBe(true); + expect(isKnownPlatformCapability('automations')).toBe(false); + }); +}); diff --git a/packages/spec/src/kernel/platform-capabilities.ts b/packages/spec/src/kernel/platform-capabilities.ts new file mode 100644 index 0000000000..a2878539e0 --- /dev/null +++ b/packages/spec/src/kernel/platform-capabilities.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Platform SERVICE capability vocabulary — the canonical tokens accepted in a + * stack's `requires: [...]` declaration (framework#3265). + * + * ONE vocabulary across every runtime that resolves `requires`: the standalone + * `os serve` / `os start` path (`@objectstack/cli`) and cloud's multi-tenant + * `objectos-runtime` capability loader. Both loaders key their provider + * registries by these tokens, so a stack declaration means the same thing + * wherever it boots. (These are platform SERVICE capabilities — NOT the + * ADR-0066 authorization capabilities declared in `capabilities: [...]`.) + * + * Canonical spelling is lower-case kebab-case (`ai-studio`, `pinyin-search`, + * `hierarchy-security`). Legacy camelCase spellings that shipped in cloud + * configs are mapped through {@link DEPRECATED_PLATFORM_CAPABILITY_ALIASES} + * for one deprecation cycle — `defineStack` normalizes them with a warning at + * authoring time, and runtimes canonicalize raw artifact input the same way. + * + * 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). + */ +export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([ + // Tier-gated capabilities (framework `serve.ts` CAPABILITY_TO_TIER) + 'ai', + 'ai-studio', + 'i18n', + 'ui', + 'auth', + // Service capabilities (framework `serve.ts` CAPABILITY_PROVIDERS) + 'automation', + 'analytics', + 'audit', + 'cache', + 'storage', + 'queue', + 'job', + 'messaging', + 'triggers', + 'realtime', + 'mcp', + 'marketplace', + 'email', + 'sms', + 'sharing', + 'pinyin-search', + 'reports', + 'approvals', + 'settings', + 'webhooks', + // Enterprise / cloud-runtime capabilities (no open-edition provider: + // `hierarchy-security` ships in @objectstack/security-enterprise via + // `plugins[]`; `ai-seat` / `governance` are resolved by cloud's + // objectos-runtime loader only) + 'hierarchy-security', + 'ai-seat', + 'governance', +]); + +/** + * Deprecated `requires` spellings → canonical token. One deprecation cycle: + * authoring (`defineStack`) rewrites these with a warning; runtimes accept + * them via {@link canonicalizePlatformCapability} so artifacts built by an + * older spec keep booting. Do not add new aliases — new tokens ship in + * canonical kebab-case only. + */ +export const DEPRECATED_PLATFORM_CAPABILITY_ALIASES: Readonly> = + Object.freeze({ + aiStudio: 'ai-studio', + aiSeat: 'ai-seat', + }); + +/** + * Map a `requires` token to its canonical spelling (identity for tokens that + * are already canonical or unknown). + */ +export function canonicalizePlatformCapability(token: string): string { + return DEPRECATED_PLATFORM_CAPABILITY_ALIASES[token] ?? token; +} + +/** + * True when the token (after alias canonicalization) is part of the platform + * capability vocabulary. + */ +export function isKnownPlatformCapability(token: string): boolean { + return PLATFORM_CAPABILITY_TOKENS.includes(canonicalizePlatformCapability(token)); +} diff --git a/packages/spec/src/stack-requires.test.ts b/packages/spec/src/stack-requires.test.ts new file mode 100644 index 0000000000..11d0e23e1d --- /dev/null +++ b/packages/spec/src/stack-requires.test.ts @@ -0,0 +1,49 @@ +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). + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('defineStack requires canonicalization (#3265)', () => { + it('rewrites deprecated aliases to canonical kebab tokens with a warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stack = defineStack({ requires: ['ai', 'aiStudio', 'automation'] }); + expect(stack.requires).toEqual(['ai', 'ai-studio', 'automation']); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("'aiStudio' is a deprecated spelling"); + expect(warn.mock.calls[0][0]).toContain("'ai-studio'"); + }); + + it('canonical declarations pass through untouched and warning-free', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stack = defineStack({ requires: ['ai', 'ai-studio', 'hierarchy-security', 'governance'] }); + expect(stack.requires).toEqual(['ai', 'ai-studio', 'hierarchy-security', 'governance']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns on unknown tokens but does NOT throw (warn-first)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stack = defineStack({ requires: ['automations'] }); + expect(stack.requires).toEqual(['automations']); + 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', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + defineStack({ requires: ['aiStudio', 'aiStudio'] }); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('non-strict mode skips canonicalization and warnings by contract', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stack = defineStack({ requires: ['aiStudio'] }, { strict: false }); + expect(stack.requires).toEqual(['aiStudio']); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 5aaa27632d..a2830664c5 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -4,6 +4,10 @@ import { z } from 'zod'; import { ManifestSchema } from './kernel/manifest.zod'; import { validateObjectNamespacePrefix } from './kernel/namespace-prefix'; +import { + PLATFORM_CAPABILITY_TOKENS, + DEPRECATED_PLATFORM_CAPABILITY_ALIASES, +} from './kernel/platform-capabilities'; import { ClusterCapabilityConfigSchema } from './kernel/cluster.zod'; import { DatasourceSchema } from './data/datasource.zod'; import { TranslationBundleSchema, TranslationConfigSchema } from './system/translation.zod'; @@ -435,7 +439,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ * }); * ``` */ - requires: z.array(z.string()).optional().describe('Capability names this stack requires from the platform (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; declared-but-missing ⇒ fail-fast at startup)'), /** * Plugin tier presets to auto-register (e.g. `core`, `ai`, `ui`, `auth`). @@ -988,6 +992,56 @@ function validateHierarchyScopeCapability(data: unknown): string[] { return errors; } +/** + * 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. + */ +function canonicalizeStackRequires(config: ObjectStackDefinition): ObjectStackDefinition { + const raw = config.requires; + if (!raw || raw.length === 0) return config; + + const warned = new Set(); + let changed = false; + const canonical = raw.map((token) => { + const mapped = DEPRECATED_PLATFORM_CAPABILITY_ALIASES[token]; + if (mapped) { + if (!warned.has(token)) { + warned.add(token); + console.warn( + `[defineStack] requires: '${token}' is a deprecated spelling — use '${mapped}'. ` + + `The alias is honored for one release, then removed (framework#3265).`, + ); + } + 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; +} + export function defineStack( config: ObjectStackDefinitionInput, options?: DefineStackOptions, @@ -1012,14 +1066,18 @@ export function defineStack( throw new Error(formatZodError(result.error, 'defineStack validation failed')); } - const crossRefErrors = validateCrossReferences(result.data); + // Canonicalize `requires` (deprecated aliases → kebab canon) and warn on + // unknown capability tokens BEFORE the validators below read the definition. + const data = canonicalizeStackRequires(result.data); + + const crossRefErrors = validateCrossReferences(data); if (crossRefErrors.length > 0) { const header = `defineStack cross-reference validation failed (${crossRefErrors.length} issue${crossRefErrors.length === 1 ? '' : 's'}):`; const lines = crossRefErrors.map((e) => ` ✗ ${e}`); throw new Error(`${header}\n\n${lines.join('\n')}`); } - const nsErrors = validateNamespacePrefix(result.data); + const nsErrors = validateNamespacePrefix(data); if (nsErrors.length > 0) { const header = `defineStack namespace-prefix validation failed (${nsErrors.length} issue${nsErrors.length === 1 ? '' : 's'}):`; const lines = nsErrors.map((e) => ` ✗ ${e}`); @@ -1027,21 +1085,21 @@ export function defineStack( throw new Error(`${header}\n\n${lines.join('\n')}${hint}`); } - const appErrors = validateSingleApp(result.data); + const appErrors = validateSingleApp(data); if (appErrors.length > 0) { const header = `defineStack single-app validation failed (${appErrors.length} issue${appErrors.length === 1 ? '' : 's'}):`; const lines = appErrors.map((e) => ` ✗ ${e}`); throw new Error(`${header}\n\n${lines.join('\n')}`); } - const hierErrors = validateHierarchyScopeCapability(result.data); + const hierErrors = validateHierarchyScopeCapability(data); if (hierErrors.length > 0) { const header = `defineStack hierarchy-scope capability validation failed (${hierErrors.length} issue${hierErrors.length === 1 ? '' : 's'}):`; const lines = hierErrors.map((e) => ` ✗ ${e}`); throw new Error(`${header}\n\n${lines.join('\n')}`); } - return mergeActionsIntoObjects(result.data); + return mergeActionsIntoObjects(data); } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7e0620024a..ca4ca3701d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export * from './env.js'; +export * from './module-not-found.js'; // Placeholder for Kernel interface to avoid circular dependency // The actual Kernel implementation will satisfy this interface. diff --git a/packages/types/src/module-not-found.ts b/packages/types/src/module-not-found.ts new file mode 100644 index 0000000000..453c453f33 --- /dev/null +++ b/packages/types/src/module-not-found.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * True when a dynamic `import()` / `require.resolve()` failed because the + * module is simply NOT INSTALLED — as opposed to the module being present but + * throwing while it loads (a real crash). Checking `err.code` FIRST matters: + * ESM reports a missing package as `err.code === 'ERR_MODULE_NOT_FOUND'` with + * the human message `Cannot find package '...'`; matching only the older + * `Cannot find module` string mis-classifies that as a crash (framework#1595). + * + * Single shared owner for this classification (framework#3265): the CLI's + * optional-plugin guards and `requires` capability resolver delegate here, and + * cloud's `objectos-runtime` capability loader is expected to adopt it at its + * next framework pin bump — so the parallel loaders cannot drift apart and + * re-introduce the #1595 false-alarm class. + */ +export function isModuleNotFoundError(err: unknown): boolean { + const code = (err as { code?: string } | null | undefined)?.code; + if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') return true; + const msg = err instanceof Error ? err.message : String(err); + return msg.includes('Cannot find module') || msg.includes('Cannot find package'); +} From 4de157840463393bbf5840f2b0f6d4a85d8fcdf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:25:47 +0000 Subject: [PATCH 2/2] chore(spec): record the 8 new platform-capability exports in the API surface snapshot check:api-surface guards the public API; the vocabulary exports (root + /kernel) are intentional additions (0 breaking), so regenerate and commit the snapshot. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TjHfkKmEvgk8v7N8nTe5sH --- packages/spec/api-surface.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 281212d1b7..850c2e1424 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -26,6 +26,7 @@ "ConversionFixture (interface)", "ConversionNotice (interface)", "CronExpressionInputSchema (const)", + "DEPRECATED_PLATFORM_CAPABILITY_ALIASES (const)", "DatasourceMappingRule (type)", "DatasourceMappingRuleSchema (const)", "DefineStackOptions (interface)", @@ -76,6 +77,7 @@ "ObjectUICapabilities (type)", "ObjectUICapabilitiesSchema (const)", "P (const)", + "PLATFORM_CAPABILITY_TOKENS (const)", "PluginContext (type)", "Predicate (type)", "PredicateInput (type)", @@ -100,6 +102,7 @@ "applyConversions (function)", "applyConversionsToFlow (function)", "applyMetaMigrations (function)", + "canonicalizePlatformCapability (function)", "cel (function)", "collectConversionNotices (function)", "composeMigrationChain (function)", @@ -142,6 +145,7 @@ "formatZodError (function)", "formatZodIssue (function)", "isAggregatedViewContainer (function)", + "isKnownPlatformCapability (function)", "mapMembershipRole (function)", "normalizeMetadataCollection (function)", "normalizePluginMetadata (function)", @@ -1290,6 +1294,7 @@ "CustomizationPolicy (type)", "CustomizationPolicySchema (const)", "DEFAULT_METADATA_TYPE_REGISTRY (const)", + "DEPRECATED_PLATFORM_CAPABILITY_ALIASES (const)", "DeadLetterQueueEntry (type)", "DeadLetterQueueEntrySchema (const)", "DependencyConflict (type)", @@ -1511,6 +1516,7 @@ "OpsFilePathSchema (const)", "OpsPluginStructure (type)", "OpsPluginStructureSchema (const)", + "PLATFORM_CAPABILITY_TOKENS (const)", "PROTOCOL_MAJOR (const)", "PROTOCOL_VERSION (const)", "PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (const)", @@ -1744,6 +1750,7 @@ "VersionConstraint (type)", "VersionConstraintSchema (const)", "VulnerabilitySeverity (type)", + "canonicalizePlatformCapability (function)", "deriveNamespaceFromPackageId (function)", "evaluateLockForDelete (function)", "evaluateLockForWrite (function)", @@ -1753,6 +1760,7 @@ "getMetadataTypeActions (function)", "getMetadataTypeSchema (function)", "isConsumerInstallable (function)", + "isKnownPlatformCapability (function)", "listMetadataCreateSeedTypes (function)", "listMetadataTypeSchemaTypes (function)", "lowerRequiresFeature (function)",