Skip to content

Commit 7733604

Browse files
authored
feat(spec,cli): publish the foundational capability slate so every runtime reads one list (#3786, cloud#925) (#4170)
`Serve.ALWAYS_ON_CAPABILITIES` was declared in the CLI under a comment saying cloud / multi-environment hosts "mirror this list on their per-project kernels". Nothing made that true, and they had already diverged: cloud's per-tenant slate was missing `sms`, `messaging` and `analytics`, so an app that worked under `objectstack serve` could lose `notify` deliveries and dataset previews once hosted — silently. The slate and its per-entry rationale move to PLATFORM_ALWAYS_ON_CAPABILITIES in @objectstack/spec, beside PLATFORM_CAPABILITY_PROVIDERS — published a release earlier for exactly this reason. Serve.ALWAYS_ON_CAPABILITIES becomes a re-export, kept as a stable handle for existing callers. Four assertions make the single declaration trustworthy for both readers: frozen, deduped, non-empty; the foundational prefix pinned (mount order matters when services bind during kernel:ready); every member a real PLATFORM_CAPABILITY_TOKENS entry with a declared provider; and every member edition:'open', since a floor the open distribution cannot mount is not a floor. Verified by mutation. No behaviour change — the published slate is byte-identical to the CLI's list and serve-defaults / serve-capability-vocabulary pass unchanged. What changes is that cloud now has something to derive from, closing the half cloud#925 left open.
1 parent 75b9e51 commit 7733604

6 files changed

Lines changed: 158 additions & 14 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(spec,cli): publish the foundational capability slate so every runtime reads one list (#3786, cloud#925)
7+
8+
`Serve.ALWAYS_ON_CAPABILITIES` — the capabilities auto-added to every app's
9+
`requires` outside `--preset minimal` — was declared in the CLI, under a comment
10+
noting that "cloud / multi-environment hosts (which live in a separate
11+
distribution) mirror this list on their per-project kernels."
12+
13+
Nothing made that true, and they had already diverged. Cloud's per-tenant slate
14+
was missing **`sms`, `messaging` and `analytics`**, so an app that worked under
15+
`objectstack serve` could lose `notify` deliveries and dataset previews once
16+
hosted — silently, with no error anywhere. The framework's own comment on
17+
`analytics` spells out the failure mode it was made always-on to prevent:
18+
"Without it the dataset preview + dashboard/report analytics widgets silently
19+
no-op."
20+
21+
**New export: `PLATFORM_ALWAYS_ON_CAPABILITIES`** (`@objectstack/spec`, and
22+
`@objectstack/spec/kernel`). The slate and its per-entry rationale now live
23+
beside `PLATFORM_CAPABILITY_PROVIDERS` — the map published for exactly this
24+
reason one release earlier, "so cloud's objectos-runtime and the framework CLI
25+
classify a `requires` token identically". `Serve.ALWAYS_ON_CAPABILITIES` is now
26+
a re-export of it, kept as a stable handle for existing callers rather than
27+
deleted: one declaration, two readers.
28+
29+
Four assertions make the single declaration trustworthy for both of them — the
30+
slate is frozen, deduped and non-empty; its foundational prefix
31+
(`queue, job, cache, settings, email, storage`) is pinned, because mount order
32+
matters when services bind to each other during `kernel:ready`; every member is
33+
a real `PLATFORM_CAPABILITY_TOKENS` entry; every member has a declared provider;
34+
and every member is `edition: 'open'`, since a floor the open distribution
35+
cannot mount is not a floor. Verified by mutation: an unknown token, an
36+
enterprise-edition token, and a reordered prefix each turn the gate red.
37+
38+
**No behaviour change.** The published slate is byte-identical to the list the
39+
CLI already had, and `serve-defaults.test.ts` / `serve-capability-vocabulary.test.ts`
40+
pass unchanged. What changes is that there is now something to derive from:
41+
cloud's hosted runtime can drop its copy and read this instead, which is the
42+
follow-up cloud#925 left open — it lands there once the `.objectstack-sha` pin
43+
moves past this release.

packages/cli/src/commands/serve.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { mergeBootConfig } from '../utils/merge-boot-config.js';
1111
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
1212
import { resolveDriverType, resolveStorageDefinition, UnsupportedDriverError } from '../utils/storage-driver.js';
1313
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
14-
import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
14+
import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel';
1515
import { missingProviderMessage } from '../utils/capability-preflight.js';
1616
import { resolveObjectStackHome } from '@objectstack/runtime';
1717
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
@@ -200,20 +200,19 @@ export default class Serve extends Command {
200200
*
201201
* Opt out: `objectstack serve --preset minimal`.
202202
*
203-
* Cloud / multi-environment hosts (which live in a separate distribution)
204-
* mirror this list on their per-project kernels.
203+
* DERIVED from `@objectstack/spec`'s `PLATFORM_ALWAYS_ON_CAPABILITIES`, where
204+
* the slate and its per-entry rationale now live. This used to BE the
205+
* declaration, under a comment noting that cloud / multi-environment hosts
206+
* "mirror this list on their per-project kernels" — with nothing making that
207+
* true. They had diverged: the hosted slate was missing `sms`, `messaging` and
208+
* `analytics`, so an app that worked under `objectstack serve` silently lost
209+
* dataset previews and `notify` deliveries once hosted (cloud#925, #3786).
210+
*
211+
* Kept as a re-export rather than deleted so `Serve.ALWAYS_ON_CAPABILITIES`
212+
* stays a stable handle for existing callers and tests — one declaration, two
213+
* readers.
205214
*/
206-
static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([
207-
// The first six form the pinned foundational prefix (see
208-
// serve-defaults.test.ts) — grow the slate AFTER them.
209-
'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sms', 'sharing', 'messaging',
210-
// `analytics` is foundational post-ADR-0021: the AnalyticsService backs the
211-
// dataset/cube query endpoints (`/api/v1/analytics/*`). It must exist even
212-
// when an app declares no `analyticsCubes`, because a `dataset` can be
213-
// authored/previewed inline (Studio) and compiled on the fly. Without it the
214-
// dataset preview + dashboard/report analytics widgets silently no-op.
215-
'analytics',
216-
]);
215+
static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = PLATFORM_ALWAYS_ON_CAPABILITIES;
217216

218217
/**
219218
* Auto-registered plugin tiers. Plugins explicitly listed in

packages/spec/api-surface.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"ObjectStackDefinitionSchema (const)",
8282
"ObjectStackSchema (const)",
8383
"P (const)",
84+
"PLATFORM_ALWAYS_ON_CAPABILITIES (const)",
8485
"PLATFORM_CAPABILITY_PROVIDERS (const)",
8586
"PLATFORM_CAPABILITY_TOKENS (const)",
8687
"PlatformCapabilityProvider (interface)",
@@ -1612,6 +1613,7 @@
16121613
"OpsFilePathSchema (const)",
16131614
"OpsPluginStructure (type)",
16141615
"OpsPluginStructureSchema (const)",
1616+
"PLATFORM_ALWAYS_ON_CAPABILITIES (const)",
16151617
"PLATFORM_CAPABILITY_PROVIDERS (const)",
16161618
"PLATFORM_CAPABILITY_TOKENS (const)",
16171619
"PROTOCOL_MAJOR (const)",

packages/spec/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ export {
135135
PLATFORM_CAPABILITY_TOKENS,
136136
isKnownPlatformCapability,
137137
PLATFORM_CAPABILITY_PROVIDERS,
138+
// The foundational slate every server-side runtime mounts (cloud#925, #3786) —
139+
// one declaration for `objectstack serve` and cloud's per-tenant runtime alike.
140+
PLATFORM_ALWAYS_ON_CAPABILITIES,
138141
classifyRequiredCapability,
139142
type CapabilityEdition,
140143
type PlatformCapabilityProvider,

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
PLATFORM_CAPABILITY_TOKENS,
44
isKnownPlatformCapability,
55
PLATFORM_CAPABILITY_PROVIDERS,
6+
PLATFORM_ALWAYS_ON_CAPABILITIES,
67
classifyRequiredCapability,
78
} from './platform-capabilities';
89

@@ -114,3 +115,57 @@ describe('classifyRequiredCapability (#3366)', () => {
114115
expect(seen).toEqual(['@objectstack/service-automation']);
115116
});
116117
});
118+
119+
/**
120+
* The foundational slate (cloud#925, #3786).
121+
*
122+
* It moved here from `Serve.ALWAYS_ON_CAPABILITIES` because two runtimes mount
123+
* it — `objectstack serve` and cloud's per-tenant objectos-runtime — and the
124+
* second kept a copy under a comment that only said hosts "mirror this list".
125+
* They had already diverged by three entries. These assertions are what makes
126+
* the single declaration trustworthy for both readers.
127+
*/
128+
describe('PLATFORM_ALWAYS_ON_CAPABILITIES', () => {
129+
it('is frozen, non-empty and free of duplicates', () => {
130+
expect(Object.isFrozen(PLATFORM_ALWAYS_ON_CAPABILITIES)).toBe(true);
131+
expect(PLATFORM_ALWAYS_ON_CAPABILITIES.length).toBeGreaterThan(0);
132+
expect(PLATFORM_ALWAYS_ON_CAPABILITIES).toHaveLength(
133+
new Set(PLATFORM_ALWAYS_ON_CAPABILITIES).size,
134+
);
135+
});
136+
137+
it('pins the foundational prefix — grow the slate AFTER these six', () => {
138+
// Order matters at mount time: settings/queue/job must precede the services
139+
// that bind to them during their own `kernel:ready` phase.
140+
expect(PLATFORM_ALWAYS_ON_CAPABILITIES.slice(0, 6)).toEqual([
141+
'queue', 'job', 'cache', 'settings', 'email', 'storage',
142+
]);
143+
});
144+
145+
it('every member is a real platform capability token', () => {
146+
// A slate entry outside the vocabulary could never be classified, and every
147+
// runtime mounting the slate would force-add a token nothing provides.
148+
const unknown = PLATFORM_ALWAYS_ON_CAPABILITIES.filter(
149+
(c) => !(PLATFORM_CAPABILITY_TOKENS as readonly string[]).includes(c),
150+
);
151+
expect(unknown).toEqual([]);
152+
});
153+
154+
it('every member has a declared provider', () => {
155+
// The slate is force-mounted, so a member with no provider entry would be
156+
// added to every app's `requires` and then fail to resolve for all of them.
157+
const providerless = PLATFORM_ALWAYS_ON_CAPABILITIES.filter(
158+
(c) => !PLATFORM_CAPABILITY_PROVIDERS[c],
159+
);
160+
expect(providerless).toEqual([]);
161+
});
162+
163+
it('is open-edition only — the floor must be mountable without a licence', () => {
164+
// A cloud/enterprise-edition entry in the FLOOR would make the open
165+
// distribution unable to satisfy its own defaults.
166+
const gated = PLATFORM_ALWAYS_ON_CAPABILITIES.filter(
167+
(c) => PLATFORM_CAPABILITY_PROVIDERS[c]?.edition !== 'open',
168+
);
169+
expect(gated).toEqual([]);
170+
});
171+
});

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,48 @@ export const PLATFORM_CAPABILITY_PROVIDERS: Readonly<Record<string, PlatformCapa
168168
governance: { package: null, edition: 'cloud', note: 'cloud governance tier' },
169169
});
170170

171+
/**
172+
* The foundational capability slate: what every server-side runtime is expected
173+
* to mount whether or not an app names it in `requires`.
174+
*
175+
* These are the services the platform assumes exist — background work, settings
176+
* persistence, transactional mail, file uploads, notifications, analytics — so
177+
* an app that never declares them still behaves the way its authors (and the
178+
* Studio surfaces) expect.
179+
*
180+
* Published here rather than left on `Serve.ALWAYS_ON_CAPABILITIES` for the same
181+
* reason {@link PLATFORM_CAPABILITY_PROVIDERS} was: **more than one runtime
182+
* mounts this slate.** The CLI's `serve` builds one kernel per process; cloud's
183+
* objectos-runtime builds one per tenant environment and carried its own copy,
184+
* under a CLI comment that merely said such hosts "mirror this list". They had
185+
* already diverged — the hosted slate was missing `sms`, `messaging` and
186+
* `analytics` — so an app that worked under `objectstack serve` could lose
187+
* dataset previews and `notify` deliveries once hosted, silently, with no error
188+
* anywhere (cloud#925, framework#3786). A second list nobody checks is how that
189+
* happens; one exported list is how it stops.
190+
*
191+
* This is the FLOOR, not the ceiling: a host may mount more (cloud adds
192+
* `observability`), and `objectstack serve --preset minimal` opts out entirely.
193+
*/
194+
export const PLATFORM_ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([
195+
// The first six are the pinned foundational prefix — grow the slate AFTER them.
196+
'queue', 'job', 'cache', 'settings', 'email', 'storage',
197+
'sms',
198+
'sharing',
199+
// `messaging` is foundational post-ADR-0030: notifications flow through a
200+
// single ingress (`NotificationService.emit`) — collaboration `@mention` /
201+
// assignment (plugin-audit) and the `notify` flow node both deliver through
202+
// the messaging pipeline, and the Console bell reads its materialization
203+
// (`sys_inbox_message`). Without it those notifications silently no-op.
204+
'messaging',
205+
// `analytics` is foundational post-ADR-0021: the AnalyticsService backs the
206+
// dataset/cube query endpoints (`/api/v1/analytics/*`). It must exist even
207+
// when an app declares no `analyticsCubes`, because a `dataset` can be
208+
// authored/previewed inline (Studio) and compiled on the fly. Without it the
209+
// dataset preview + dashboard/report analytics widgets silently no-op.
210+
'analytics',
211+
]);
212+
171213
/**
172214
* Outcome of classifying one `requires` token against the installed providers:
173215
* - `ok` — provider resolvable (installed); nothing to do.

0 commit comments

Comments
 (0)