Skip to content

Commit 9dba636

Browse files
os-zhuangclaude
andauthored
feat(spec,cli): one platform capability vocabulary — canonical kebab tokens, deprecated aliases, warn-first validation (#3265) (#3281)
Spec becomes the single owner of the `requires` capability vocabulary (PLATFORM_CAPABILITY_TOKENS, canonical kebab-case) with deprecated-alias canonicalization + warn-first validation in defineStack; serve.ts canonicalizes raw artifact input, warns on declared-but-unknown tokens, and its registries are statics with a drift test; the missing-vs-crashed classifier moves to shared @objectstack/types isModuleNotFoundError. Framework half of #3265. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjHfkKmEvgk8v7N8nTe5sH
1 parent 5c31684 commit 9dba636

11 files changed

Lines changed: 581 additions & 190 deletions

File tree

packages/cli/src/commands/serve.ts

Lines changed: 229 additions & 184 deletions
Large diffs are not rendered by default.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
PLATFORM_CAPABILITY_TOKENS,
4+
DEPRECATED_PLATFORM_CAPABILITY_ALIASES,
5+
} from '@objectstack/spec/kernel';
6+
import Serve from '../src/commands/serve.js';
7+
8+
// framework#3265 — drift guard: the serve path's provider registries must stay
9+
// inside the spec-owned platform capability vocabulary, so the standalone
10+
// runtime and cloud's objectos-runtime keep resolving the SAME token set.
11+
12+
describe('serve capability registries vs spec vocabulary (#3265)', () => {
13+
it('every CAPABILITY_PROVIDERS token is in PLATFORM_CAPABILITY_TOKENS', () => {
14+
for (const token of Object.keys(Serve.CAPABILITY_PROVIDERS)) {
15+
expect(PLATFORM_CAPABILITY_TOKENS, `provider token '${token}' missing from spec vocabulary`).toContain(token);
16+
}
17+
});
18+
19+
it('every CAPABILITY_TO_TIER token is in PLATFORM_CAPABILITY_TOKENS', () => {
20+
for (const token of Object.keys(Serve.CAPABILITY_TO_TIER)) {
21+
expect(PLATFORM_CAPABILITY_TOKENS, `tier token '${token}' missing from spec vocabulary`).toContain(token);
22+
}
23+
});
24+
25+
it('registries use only canonical spellings — never deprecated aliases', () => {
26+
const legacy = Object.keys(DEPRECATED_PLATFORM_CAPABILITY_ALIASES);
27+
for (const token of [...Object.keys(Serve.CAPABILITY_PROVIDERS), ...Object.keys(Serve.CAPABILITY_TO_TIER)]) {
28+
expect(legacy).not.toContain(token);
29+
}
30+
});
31+
32+
it('tier-gated and provider-backed tokens do not overlap (each token has ONE resolution path)', () => {
33+
const providerTokens = new Set(Object.keys(Serve.CAPABILITY_PROVIDERS));
34+
for (const tierToken of Object.keys(Serve.CAPABILITY_TO_TIER)) {
35+
expect(providerTokens.has(tierToken)).toBe(false);
36+
}
37+
});
38+
39+
it('ALWAYS_ON_CAPABILITIES stays inside the vocabulary too', () => {
40+
for (const token of Serve.ALWAYS_ON_CAPABILITIES) {
41+
expect(PLATFORM_CAPABILITY_TOKENS).toContain(token);
42+
}
43+
});
44+
});

packages/spec/api-surface.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"ConversionFixture (interface)",
2727
"ConversionNotice (interface)",
2828
"CronExpressionInputSchema (const)",
29+
"DEPRECATED_PLATFORM_CAPABILITY_ALIASES (const)",
2930
"DatasourceMappingRule (type)",
3031
"DatasourceMappingRuleSchema (const)",
3132
"DefineStackOptions (interface)",
@@ -76,6 +77,7 @@
7677
"ObjectUICapabilities (type)",
7778
"ObjectUICapabilitiesSchema (const)",
7879
"P (const)",
80+
"PLATFORM_CAPABILITY_TOKENS (const)",
7981
"PluginContext (type)",
8082
"Predicate (type)",
8183
"PredicateInput (type)",
@@ -100,6 +102,7 @@
100102
"applyConversions (function)",
101103
"applyConversionsToFlow (function)",
102104
"applyMetaMigrations (function)",
105+
"canonicalizePlatformCapability (function)",
103106
"cel (function)",
104107
"collectConversionNotices (function)",
105108
"composeMigrationChain (function)",
@@ -142,6 +145,7 @@
142145
"formatZodError (function)",
143146
"formatZodIssue (function)",
144147
"isAggregatedViewContainer (function)",
148+
"isKnownPlatformCapability (function)",
145149
"mapMembershipRole (function)",
146150
"normalizeMetadataCollection (function)",
147151
"normalizePluginMetadata (function)",
@@ -1290,6 +1294,7 @@
12901294
"CustomizationPolicy (type)",
12911295
"CustomizationPolicySchema (const)",
12921296
"DEFAULT_METADATA_TYPE_REGISTRY (const)",
1297+
"DEPRECATED_PLATFORM_CAPABILITY_ALIASES (const)",
12931298
"DeadLetterQueueEntry (type)",
12941299
"DeadLetterQueueEntrySchema (const)",
12951300
"DependencyConflict (type)",
@@ -1511,6 +1516,7 @@
15111516
"OpsFilePathSchema (const)",
15121517
"OpsPluginStructure (type)",
15131518
"OpsPluginStructureSchema (const)",
1519+
"PLATFORM_CAPABILITY_TOKENS (const)",
15141520
"PROTOCOL_MAJOR (const)",
15151521
"PROTOCOL_VERSION (const)",
15161522
"PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (const)",
@@ -1744,6 +1750,7 @@
17441750
"VersionConstraint (type)",
17451751
"VersionConstraintSchema (const)",
17461752
"VulnerabilitySeverity (type)",
1753+
"canonicalizePlatformCapability (function)",
17471754
"deriveNamespaceFromPackageId (function)",
17481755
"evaluateLockForDelete (function)",
17491756
"evaluateLockForWrite (function)",
@@ -1753,6 +1760,7 @@
17531760
"getMetadataTypeActions (function)",
17541761
"getMetadataTypeSchema (function)",
17551762
"isConsumerInstallable (function)",
1763+
"isKnownPlatformCapability (function)",
17561764
"listMetadataCreateSeedTypes (function)",
17571765
"listMetadataTypeSchemaTypes (function)",
17581766
"lowerRequiresFeature (function)",

packages/spec/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ export * from './migrations/index.js';
120120

121121
export { type PluginContext } from './kernel/plugin.zod';
122122

123+
// Platform SERVICE capability vocabulary for `requires: [...]` (framework#3265).
124+
export {
125+
PLATFORM_CAPABILITY_TOKENS,
126+
DEPRECATED_PLATFORM_CAPABILITY_ALIASES,
127+
canonicalizePlatformCapability,
128+
isKnownPlatformCapability,
129+
} from './kernel/platform-capabilities';
130+
123131
// Expression Protocol (M9 — canonical wire format for formulas / predicates / conditions)
124132
export {
125133
ExpressionDialect,

packages/spec/src/kernel/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './feature.zod';
1010
export * from './manifest.zod';
1111
export * from './metadata-customization.zod';
1212
export * from './namespace-prefix';
13+
export * from './platform-capabilities';
1314
export * from './metadata-loader.zod';
1415
export * from './metadata-plugin.zod';
1516
export * from './metadata-protection.zod';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
PLATFORM_CAPABILITY_TOKENS,
4+
DEPRECATED_PLATFORM_CAPABILITY_ALIASES,
5+
canonicalizePlatformCapability,
6+
isKnownPlatformCapability,
7+
} from './platform-capabilities';
8+
9+
// framework#3265 — one capability vocabulary across the standalone serve path
10+
// and cloud's objectos-runtime loader, canonical spelling kebab-case.
11+
12+
describe('PLATFORM_CAPABILITY_TOKENS', () => {
13+
it('is frozen and duplicate-free', () => {
14+
expect(Object.isFrozen(PLATFORM_CAPABILITY_TOKENS)).toBe(true);
15+
expect(new Set(PLATFORM_CAPABILITY_TOKENS).size).toBe(PLATFORM_CAPABILITY_TOKENS.length);
16+
});
17+
18+
it('every token is canonical lower-case kebab-case', () => {
19+
for (const t of PLATFORM_CAPABILITY_TOKENS) {
20+
expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
21+
}
22+
});
23+
24+
it('contains the tier-gated and headline service tokens', () => {
25+
for (const t of ['ai', 'ai-studio', 'automation', 'analytics', 'pinyin-search', 'hierarchy-security']) {
26+
expect(PLATFORM_CAPABILITY_TOKENS).toContain(t);
27+
}
28+
});
29+
30+
it('contains no deprecated spellings', () => {
31+
for (const legacy of Object.keys(DEPRECATED_PLATFORM_CAPABILITY_ALIASES)) {
32+
expect(PLATFORM_CAPABILITY_TOKENS).not.toContain(legacy);
33+
}
34+
});
35+
});
36+
37+
describe('DEPRECATED_PLATFORM_CAPABILITY_ALIASES / canonicalizePlatformCapability', () => {
38+
it('maps every alias onto a token that exists in the vocabulary', () => {
39+
for (const canonical of Object.values(DEPRECATED_PLATFORM_CAPABILITY_ALIASES)) {
40+
expect(PLATFORM_CAPABILITY_TOKENS).toContain(canonical);
41+
}
42+
});
43+
44+
it('canonicalizes the legacy cloud spellings', () => {
45+
expect(canonicalizePlatformCapability('aiStudio')).toBe('ai-studio');
46+
expect(canonicalizePlatformCapability('aiSeat')).toBe('ai-seat');
47+
});
48+
49+
it('is identity for canonical and unknown tokens', () => {
50+
expect(canonicalizePlatformCapability('ai-studio')).toBe('ai-studio');
51+
expect(canonicalizePlatformCapability('automation')).toBe('automation');
52+
expect(canonicalizePlatformCapability('not-a-capability')).toBe('not-a-capability');
53+
});
54+
});
55+
56+
describe('isKnownPlatformCapability', () => {
57+
it('accepts canonical tokens and deprecated aliases, rejects unknowns', () => {
58+
expect(isKnownPlatformCapability('ai-studio')).toBe(true);
59+
expect(isKnownPlatformCapability('aiStudio')).toBe(true);
60+
expect(isKnownPlatformCapability('governance')).toBe(true);
61+
expect(isKnownPlatformCapability('automations')).toBe(false);
62+
});
63+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Platform SERVICE capability vocabulary — the canonical tokens accepted in a
5+
* stack's `requires: [...]` declaration (framework#3265).
6+
*
7+
* ONE vocabulary across every runtime that resolves `requires`: the standalone
8+
* `os serve` / `os start` path (`@objectstack/cli`) and cloud's multi-tenant
9+
* `objectos-runtime` capability loader. Both loaders key their provider
10+
* registries by these tokens, so a stack declaration means the same thing
11+
* wherever it boots. (These are platform SERVICE capabilities — NOT the
12+
* ADR-0066 authorization capabilities declared in `capabilities: [...]`.)
13+
*
14+
* Canonical spelling is lower-case kebab-case (`ai-studio`, `pinyin-search`,
15+
* `hierarchy-security`). Legacy camelCase spellings that shipped in cloud
16+
* configs are mapped through {@link DEPRECATED_PLATFORM_CAPABILITY_ALIASES}
17+
* for one deprecation cycle — `defineStack` normalizes them with a warning at
18+
* authoring time, and runtimes canonicalize raw artifact input the same way.
19+
*
20+
* Growing the platform: when a new `requires`-resolvable service ships, add
21+
* its token HERE as well as to the runtime's provider registry — the CLI's
22+
* 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).
27+
*/
28+
export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([
29+
// Tier-gated capabilities (framework `serve.ts` CAPABILITY_TO_TIER)
30+
'ai',
31+
'ai-studio',
32+
'i18n',
33+
'ui',
34+
'auth',
35+
// Service capabilities (framework `serve.ts` CAPABILITY_PROVIDERS)
36+
'automation',
37+
'analytics',
38+
'audit',
39+
'cache',
40+
'storage',
41+
'queue',
42+
'job',
43+
'messaging',
44+
'triggers',
45+
'realtime',
46+
'mcp',
47+
'marketplace',
48+
'email',
49+
'sms',
50+
'sharing',
51+
'pinyin-search',
52+
'reports',
53+
'approvals',
54+
'settings',
55+
'webhooks',
56+
// Enterprise / cloud-runtime capabilities (no open-edition provider:
57+
// `hierarchy-security` ships in @objectstack/security-enterprise via
58+
// `plugins[]`; `ai-seat` / `governance` are resolved by cloud's
59+
// objectos-runtime loader only)
60+
'hierarchy-security',
61+
'ai-seat',
62+
'governance',
63+
]);
64+
65+
/**
66+
* Deprecated `requires` spellings → canonical token. One deprecation cycle:
67+
* authoring (`defineStack`) rewrites these with a warning; runtimes accept
68+
* them via {@link canonicalizePlatformCapability} so artifacts built by an
69+
* older spec keep booting. Do not add new aliases — new tokens ship in
70+
* canonical kebab-case only.
71+
*/
72+
export const DEPRECATED_PLATFORM_CAPABILITY_ALIASES: Readonly<Record<string, string>> =
73+
Object.freeze({
74+
aiStudio: 'ai-studio',
75+
aiSeat: 'ai-seat',
76+
});
77+
78+
/**
79+
* Map a `requires` token to its canonical spelling (identity for tokens that
80+
* are already canonical or unknown).
81+
*/
82+
export function canonicalizePlatformCapability(token: string): string {
83+
return DEPRECATED_PLATFORM_CAPABILITY_ALIASES[token] ?? token;
84+
}
85+
86+
/**
87+
* True when the token (after alias canonicalization) is part of the platform
88+
* capability vocabulary.
89+
*/
90+
export function isKnownPlatformCapability(token: string): boolean {
91+
return PLATFORM_CAPABILITY_TOKENS.includes(canonicalizePlatformCapability(token));
92+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { defineStack } from './stack.zod';
3+
4+
// 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).
7+
8+
afterEach(() => {
9+
vi.restoreAllMocks();
10+
});
11+
12+
describe('defineStack requires canonicalization (#3265)', () => {
13+
it('rewrites deprecated aliases to canonical kebab tokens with a warning', () => {
14+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
15+
const stack = defineStack({ requires: ['ai', 'aiStudio', 'automation'] });
16+
expect(stack.requires).toEqual(['ai', 'ai-studio', 'automation']);
17+
expect(warn).toHaveBeenCalledTimes(1);
18+
expect(warn.mock.calls[0][0]).toContain("'aiStudio' is a deprecated spelling");
19+
expect(warn.mock.calls[0][0]).toContain("'ai-studio'");
20+
});
21+
22+
it('canonical declarations pass through untouched and warning-free', () => {
23+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
24+
const stack = defineStack({ requires: ['ai', 'ai-studio', 'hierarchy-security', 'governance'] });
25+
expect(stack.requires).toEqual(['ai', 'ai-studio', 'hierarchy-security', 'governance']);
26+
expect(warn).not.toHaveBeenCalled();
27+
});
28+
29+
it('warns on unknown tokens but does NOT throw (warn-first)', () => {
30+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
31+
const stack = defineStack({ requires: ['automations'] });
32+
expect(stack.requires).toEqual(['automations']);
33+
expect(warn).toHaveBeenCalledTimes(1);
34+
expect(warn.mock.calls[0][0]).toContain("'automations' is not a known platform capability");
35+
});
36+
37+
it('warns once per distinct token, not once per occurrence', () => {
38+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
39+
defineStack({ requires: ['aiStudio', 'aiStudio'] });
40+
expect(warn).toHaveBeenCalledTimes(1);
41+
});
42+
43+
it('non-strict mode skips canonicalization and warnings by contract', () => {
44+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
45+
const stack = defineStack({ requires: ['aiStudio'] }, { strict: false });
46+
expect(stack.requires).toEqual(['aiStudio']);
47+
expect(warn).not.toHaveBeenCalled();
48+
});
49+
});

0 commit comments

Comments
 (0)