Repository navigation
Unify optional-plugin loading: framework serve.ts and cloud objectos-runtime are parallel implementations with divergent capability tokens (ai-studio vs aiStudio) #3265
Description
Context
Surfaced while finishing #1597 (#3228) and bumping cloud's framework pin (objectstack-ai/cloud#857). #1597 made framework's standalone os serve / os start path (packages/cli/src/commands/serve.ts) load optional service plugins by declared intent — requires → required (fail-fast if the provider package is missing), declared-as-dep → auto (best-effort), otherwise skip with no speculative import; tier gating stays an orthogonal deny.
While verifying the cloud side, I found the multi-tenant control plane does not boot through serve.ts — it uses its own packages/objectos-runtime/src/capability-loader.ts, which already implements the same intent model independently. So there are now two implementations of one contract.
Finding 1 — two parallel implementations of the same contract
- Framework —
serve.ts:CAPABILITY_PROVIDERS,CAPABILITY_TO_TIER, the AI block, and therequiresresolver loop (now intent-driven after feat(cli): make optional-plugin loading intent-driven, fail-fast on declared-but-missing (#1597) #3228). - Cloud —
objectos-runtime/capability-loader.ts: its ownCAPABILITY_PROVIDERSmap withoptionalflags,failedRequired+probeRequiredCapabilities(loud on required-missing, quiet on optional-missing), and it already classifies the ESMCannot find packageshape (the perf(build): OS_SKIP_DTS gating + fix optional AI plugin "Cannot find package" skip #1595 fix).
They encode the same rules but can drift: #1597 had to bring framework up to what cloud already did. A future fix to one won't automatically reach the other — declared ≠ enforced across the repo boundary (Prime Directive #10).
Finding 2 — divergent capability tokens (ai-studio vs aiStudio)
- Framework
requiresuses kebab-case; feat(cli): make optional-plugin loading intent-driven, fail-fast on declared-but-missing (#1597) #3228 addedai-studio(matchingpinyin-search,hierarchy-security). - Cloud uses camelCase:
defaultRequires: ['ai','aiStudio',…](apps/objectos/objectstack.config.ts),requires: ['aiStudio'](capability-loader.test.ts), and the provider keyaiStudioincapability-loader.ts.
So requires: ['ai-studio'] is honored by framework's serve path but ignored by cloud's runtime, and ['aiStudio'] vice-versa. For an app author moving between the standalone and cloud runtimes, the same declaration doesn't mean the same thing — a one-contract violation (Prime Directive #12). Note that unknown requires tokens are silently ignored on both sides, so the mismatch fails silently rather than loudly.
Not a runtime bug today
Both loaders currently behave correctly on their own path (framework after #3228; cloud already). apps/cloud still boots clean (aiStudio is optional: true → quiet skip, cloud#107). This is a maintainability / contract-consistency concern, not an outage — filed rather than silently expanded into #1597's scope.
Proposed directions (decision needed)
- Extract a shared loader (preferred long-term): lift the capability-provider registry + required/optional resolution into a framework-owned module consumed by both
serve.tsandobjectos-runtime, so there is structurally one implementation. Cloud's version is the more complete starting point (optional flags,failedRequired,probeRequiredCapabilities). - At minimum, reconcile the token vocabulary: pick one canonical spelling for the AI-Studio capability (and any other multiword tokens), alias the other through a deprecation path, and validate
requirestokens against a shared allowlist so an unknown/misspelled token is rejected at authoring instead of silently ignored. - Or document a deliberate split: if standalone vs multi-tenant genuinely need different loading, document the boundary + token mapping so it reads as intentional, not accidental drift.
Evidence
- framework
packages/cli/src/commands/serve.ts(post-feat(cli): make optional-plugin loading intent-driven, fail-fast on declared-but-missing (#1597) #3228):CAPABILITY_TO_TIER['ai-studio'] = 'ai',Serve.resolveOptionalPluginLoad(...), thefor (const cap of requires)resolver loop. - cloud
packages/objectos-runtime/src/capability-loader.ts:CAPABILITY_PROVIDERS.aiStudio { optional: true },failedRequired,probeRequiredCapabilities, ESMCannot find packageclassification. - cloud
apps/objectos/objectstack.config.ts:defaultRequires: ['ai', 'aiStudio', 'analytics', 'automation', 'triggers'].
Activity
os-zhuang commentedon Jul 19, 2026
Decision (maintainer-approved) + implementation started.
Direction: Phase 1 + small Phase-2 core — unify the token vocabulary with authoring-time validation, and share only the small drift-prone core; the full shared-loader extraction (option 1) is deliberately deferred as likely over-engineering (the two loaders' glue — tier gating / CLI presets vs per-kernel bundle options / enterprise gates — is genuinely different; the drift-prone core is small).
Canonical spelling: kebab-case
ai-studio(consistent withpinyin-search/hierarchy-security;requiresis a spec-owned contract field, so the spec's convention wins).aiStudio/aiSeatbecome deprecated aliases for one release.Implementation:
PLATFORM_CAPABILITY_TOKENS+DEPRECATED_PLATFORM_CAPABILITY_ALIASES+canonicalizePlatformCapability;defineStackrewrites aliases at authoring time and warns on unknown tokens (warn-first → intended to become an error once the vocabulary proves complete);serve.tscanonicalizes raw artifact input, warns on declared-but-unknown tokens (closing the silent-typo hole), and its registries are statics with a drift test against the vocabulary; the missing-vs-crashed classifier moved to shared@objectstack/types isModuleNotFoundError.capability-loader.tskeys →ai-studio/ai-seatwith local alias canonicalization + deprecation warning (adopts the shared spec/types helpers at its next framework pin bump), kernel-factory AI-seat gate → canonical tokens,enterpriseCapabilityRequires()→['ai-studio', ...], configs/tests/docs migrated. Feature/entitlement ids (FEATURE_AI_STUDIO='aiStudio') and the runtime-config wire contract (features.aiStudio) are separate vocabularies — unchanged.Remaining after both PRs: flip the unknown-token warn to a reject once the vocabulary is proven complete, and drop the aliases after one release.
Generated by Claude Code
feat(spec,cli): one platform capability vocabulary — canonical kebab …
feat(spec): reject unknown requires capability tokens at authoring (#…
os-zhuang commentedon Jul 19, 2026
Done — closing as completed
Both findings are resolved and all work is merged. The framework CLI serve path and cloud's
objectos-runtimenow share one capability vocabulary and one implementation of the canonicalization + module-not-found logic, so the parallel-loader drift and theai-studio/aiStudiodivergence are structurally gone.Merged PRs:
PLATFORM_CAPABILITY_TOKENS+ deprecated aliases + warn validation + sharedisModuleNotFoundErroraiStudio/aiSeat→ai-studio/ai-seat.framework-sha+ adopt the shared@objectstack/spec/@objectstack/typeshelpers (one implementation)requirescapability tokens at authoring (#3265) #3302 — unknownrequirestoken → harddefineStackerror (terminal state, Prime Directive Add comprehensive test suite for Zod schema validation #12)(This traces back to #1597 / #3228, which made optional-plugin loading intent-driven and first exposed the divergence.)
Remaining, tracked separately: the time-gated removal of the
aiStudio/aiSeatdeprecated aliases after one release cycle → #3308.Generated by Claude Code
feat(spec)!: remove deprecated aiStudio/aiSeat capability aliases (#3308
test(spec): the second root-level test file group's titles state each…