Skip to content

Commit 03181fc

Browse files
committed
refactor(core,cli,verify): the served-token rule takes the declared tokens, and the migrate boot reads it too
The boot-preparation parity pin (#22579) holds every step serve runs to a shared function the migrate boot runs as well. Reading `requires` stays each boot's `stackDeclaredCapabilities` call; `resolveServedCapabilities` expands what it read, and `os migrate plan`'s declaration boot now expands its tokens through it instead of its own requires-plus-slate copy. The argument rule is serve-only for that boot, which builds providers for their declarations alone. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8969daa commit 03181fc

7 files changed

Lines changed: 60 additions & 37 deletions

File tree

‎.changeset/22301-verify-boots-the-served-slate.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the O
2626
- a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS;
2727
- an `analyticsCubes` entry the analytics service refuses (a cube over an object the API does not serve) fails the boot with the analytics service's own refusal, where the cubes were never handed to it.
2828

29-
**`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (which capability tokens a served boot mounts providers for — the declared `requires`, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI.
29+
**`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI.
3030

3131
**`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed.
3232

33-
**`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module.
33+
**`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ import {
117117
stackDeclaresMetadata,
118118
bundleDeclaresTranslations,
119119
resolveStackCollection,
120+
stackDeclaredCapabilities,
120121
stackBootPlugins,
121122
} from '../utils/stack-collections.js';
122123
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from '../utils/connection-display.js';
@@ -2700,10 +2701,11 @@ export default class Serve extends Command {
27002701
// instance wins over the auto-loader).
27012702
const presetName = flags.preset ?? (isDev ? 'default' : 'default');
27022703
// [#22301] WHICH tokens a served boot mounts providers for is ONE rule,
2703-
// `resolveServedCapabilities` (`@objectstack/core`), read by this command
2704-
// and by `@objectstack/verify`'s `bootStack`, so an app's tests mount the
2705-
// services its users' server mounts. In its order: the tokens the stack
2706-
// declares (`stackDeclaredCapabilities` — the top-level list, otherwise
2704+
// `resolveServedCapabilities` (`@objectstack/core`), read by this command,
2705+
// by `@objectstack/verify`'s `bootStack` and by `os migrate plan`'s
2706+
// declaration boot, so an app's tests mount the services its users'
2707+
// server mounts. In its order: the tokens the stack declares, as
2708+
// `stackDeclaredCapabilities` reads them (the top-level list, otherwise
27072709
// each package body's, #22288), deduplicated; `email` when `auth` is
27082710
// declared; the host defaults below; the always-on slate
27092711
// (`Serve.ALWAYS_ON_CAPABILITIES`) unless `--preset minimal`; and `job`
@@ -2735,7 +2737,7 @@ export default class Serve extends Command {
27352737
const hostDefaults: string[] = [];
27362738
if (isMcpServerEnabled()) hostDefaults.push('mcp');
27372739
if (stampSearchPinyinEnabled((config as any).i18n)) hostDefaults.push('pinyin-search');
2738-
const served = resolveServedCapabilities(config, { preset: presetName, hostDefaults });
2740+
const served = resolveServedCapabilities(stackDeclaredCapabilities(config), { preset: presetName, hostDefaults });
27392741
const requires: string[] = [...served.tokens];
27402742
const declaredRequires: ReadonlySet<string> = served.declared;
27412743
// Capability → tier: any capability that is gated by a tier

‎packages/cli/src/utils/boot-preparation-parity.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
providesCapability,
6262
resolveAuthSecret,
6363
resolvePlatformAuthComposition,
64+
resolveServedCapabilities,
6465
resolveStackTiers,
6566
} from '@objectstack/core';
6667
import { createStandaloneStack } from '@objectstack/runtime';
@@ -110,6 +111,9 @@ const STEPS: readonly Step[] = [
110111
stackDeclaresMetadata,
111112
resolveStackCollection,
112113
stackDeclaredCapabilities,
114+
// [#22301] Which tokens get a provider — the declared ones, `email` for
115+
// `auth`, the always-on slate, `job` / `queue` ahead — read by both boots.
116+
resolveServedCapabilities,
113117
stampSearchPinyinEnabled,
114118
providesCapability,
115119
],
@@ -156,6 +160,11 @@ const SERVE_ONLY: Record<string, string> = {
156160
stackSuppliesAuthPlugin:
157161
'serve\'s own "is an AuthPlugin already composed" read ahead of its auth block; the auth-family gate '
158162
+ '(resolvePlatformAuthComposition, called by both boots) asks the same predicate first',
163+
resolveCapabilityArgument:
164+
'what serve constructs each capability provider WITH (the app\'s cubes, its mail and SMS configuration, '
165+
+ 'the storage root; [#22301], shared with @objectstack/verify\'s bootStack): the declaration boot constructs '
166+
+ 'each provider for its declarations only, with a measured posture per token '
167+
+ '(DECLARATION_PROVIDER_POSTURES), and runs no start() that would read a configuration',
159168
findSqlDriverForKernel:
160169
'the artifact boot migration gate, a kernel:ready hook over a booted kernel — a serving-boot policy that '
161170
+ 'runs after preparation, not a step that shapes what is prepared',

‎packages/cli/src/utils/schema-migration-plugins.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1864,12 +1864,17 @@ async function materializeHostPlugins(
18641864
* host plugins, so a config's own instance of the same name supersedes it.
18651865
* - **The capability providers** (`serve` 5): the stack's `requires`, read by
18661866
* `serve`'s rule (a host config's own; otherwise the compiled artifact's when
1867-
* it declares any; otherwise the config's), plus the always-on slate every
1868-
* served boot mounts (`PLATFORM_ALWAYS_ON_CAPABILITIES`, `@objectstack/spec`)
1869-
* — looked up in `CAPABILITY_PROVIDERS` and skipped when already held, by
1870-
* `providesCapability`'s exact identity match (`@objectstack/core`). The
1867+
* it declares any; otherwise the config's), expanded by the rule `serve`
1868+
* and `@objectstack/verify`'s `bootStack` read — `resolveServedCapabilities`
1869+
* (`@objectstack/core`, #22301): `email` for a declared `auth`, the
1870+
* always-on slate every served boot mounts (`PLATFORM_ALWAYS_ON_CAPABILITIES`,
1871+
* `@objectstack/spec`), `job` / `queue` ahead of what schedules background
1872+
* work — looked up in `CAPABILITY_PROVIDERS` and skipped when already held,
1873+
* by `providesCapability`'s exact identity match (`@objectstack/core`). The
18711874
* slate is `serve`'s with no `--preset`: these commands take no preset, as
1872-
* they take no `--dev`.
1875+
* they take no `--dev`; and no host default (MCP, pinyin search), as
1876+
* `bootStack` passes none. Each provider is still constructed for its
1877+
* declarations only (below), never from the deployment's configuration.
18731878
* - **The REST API plugin**, which `serve` composes on every boot and whose
18741879
* `init()` declares `sys_import_job` (#22202).
18751880
*
@@ -1902,8 +1907,7 @@ async function composeServedPlatform(input: {
19021907
artifactRequires?: readonly string[];
19031908
packageRoot: string;
19041909
}): Promise<{ authFamily: unknown[]; plugins: unknown[]; notes: string[] }> {
1905-
const { CAPABILITY_PROVIDERS, providesCapability } = await import('@objectstack/core');
1906-
const { PLATFORM_ALWAYS_ON_CAPABILITIES } = await import('@objectstack/spec/kernel');
1910+
const { CAPABILITY_PROVIDERS, providesCapability, resolveServedCapabilities } = await import('@objectstack/core');
19071911
const config = input.config ?? {};
19081912
// `serve`'s `requires` — the rule {@link composeAuthGatedSecurity} states.
19091913
const requires = !isHostConfig(config) && Array.isArray(input.artifactRequires)
@@ -1954,7 +1958,7 @@ async function composeServedPlatform(input: {
19541958
}
19551959
return composeProviderForDeclarations(new Ctor() as object);
19561960
};
1957-
for (const token of [...new Set([...requires, ...PLATFORM_ALWAYS_ON_CAPABILITIES])]) {
1961+
for (const token of resolveServedCapabilities(requires).tokens) {
19581962
const spec = CAPABILITY_PROVIDERS[token];
19591963
// A tier token (`auth`, `ui`, `i18n`, `ai`) or one no open package
19601964
// provides mounts nothing in `serve`'s resolver either.

‎packages/core/src/capability-composition.test.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { afterEach, describe, expect, it } from 'vitest';
1212
import { composeStacks, defineStack } from '@objectstack/spec';
1313
import { PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel';
1414
import { resolveCapabilityArgument, resolveServedCapabilities } from './capability-composition.js';
15+
import { stackDeclaredCapabilities } from './stack-collections.js';
1516

1617
const SLATE = [...PLATFORM_ALWAYS_ON_CAPABILITIES];
1718

@@ -38,50 +39,50 @@ const twoPackages = (svcExtra: Record<string, unknown>): Record<string, unknown>
3839

3940
describe('resolveServedCapabilities — which tokens a served boot mounts', () => {
4041
it('an app that declares nothing gets the always-on slate, in the slate\'s order', () => {
41-
const served = resolveServedCapabilities({});
42+
const served = resolveServedCapabilities([]);
4243
// Anti-vacuity: the slate is the spec's, and it is not empty.
4344
expect(SLATE.length).toBeGreaterThan(0);
4445
expect(served.tokens).toEqual(SLATE);
4546
expect([...served.declared]).toEqual([]);
4647
});
4748

4849
it('the declared tokens come first, deduplicated, and a slate member declared is not repeated', () => {
49-
const served = resolveServedCapabilities({ requires: ['automation', 'cache', 'automation'] });
50+
const served = resolveServedCapabilities(['automation', 'cache', 'automation']);
5051
expect(served.tokens.slice(0, 2)).toEqual(['automation', 'cache']);
5152
expect(served.tokens.filter((token) => token === 'cache')).toEqual(['cache']);
5253
expect(served.tokens.slice(2)).toEqual(SLATE.filter((token) => token !== 'cache'));
5354
expect([...served.declared]).toEqual(['automation', 'cache']);
5455
});
5556

5657
it('`--preset minimal` mounts no slate — only what the stack declares', () => {
57-
expect(resolveServedCapabilities({ requires: ['automation'] }, { preset: 'minimal' }).tokens).toEqual(['automation']);
58+
expect(resolveServedCapabilities(['automation'], { preset: 'minimal' }).tokens).toEqual(['automation']);
5859
// Control: any other preset name keeps the slate.
59-
expect(resolveServedCapabilities({ requires: ['automation'] }, { preset: 'default' }).tokens).toEqual([
60+
expect(resolveServedCapabilities(['automation'], { preset: 'default' }).tokens).toEqual([
6061
'automation',
6162
...SLATE,
6263
]);
6364
});
6465

6566
it('a declared `auth` brings `email`, and `job` / `queue` move ahead of what schedules background work', () => {
66-
const served = resolveServedCapabilities({ requires: ['auth'] }, { preset: 'minimal' });
67+
const served = resolveServedCapabilities(['auth'], { preset: 'minimal' });
6768
expect(served.tokens).toEqual(['job', 'queue', 'auth', 'email']);
6869
// `email` was appended by the platform, not declared by the stack.
6970
expect([...served.declared]).toEqual(['auth']);
7071
// Control: nothing that schedules background work, nothing moved.
71-
expect(resolveServedCapabilities({ requires: ['automation'] }, { preset: 'minimal' }).tokens).toEqual(['automation']);
72+
expect(resolveServedCapabilities(['automation'], { preset: 'minimal' }).tokens).toEqual(['automation']);
7273
});
7374

7475
it('the host defaults land after the declared tokens and before the slate', () => {
75-
const served = resolveServedCapabilities({ requires: ['automation'] }, { hostDefaults: ['mcp', 'pinyin-search'] });
76+
const served = resolveServedCapabilities(['automation'], { hostDefaults: ['mcp', 'pinyin-search'] });
7677
expect(served.tokens).toEqual(['automation', 'mcp', 'pinyin-search', ...SLATE]);
7778
expect(served.declared.has('mcp')).toBe(false);
7879
});
7980

80-
it('a package body\'s `requires` is read when the top level carries none', () => {
81+
it('a package body\'s `requires`, as `stackDeclaredCapabilities` reads it for the boots, is what the rule mounts', () => {
8182
const stack = twoPackages({ requires: ['approvals'] });
8283
// Anti-vacuity: the producer really left `requires` out of the top level.
8384
expect(stack.requires).toBeUndefined();
84-
const served = resolveServedCapabilities(stack, { preset: 'minimal' });
85+
const served = resolveServedCapabilities(stackDeclaredCapabilities(stack), { preset: 'minimal' });
8586
expect(served.tokens).toEqual(['job', 'queue', 'approvals']);
8687
expect([...served.declared]).toEqual(['approvals']);
8788
});

‎packages/core/src/capability-composition.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
import { PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel';
5959
import { readEnvWithDeprecation } from '@objectstack/types';
6060
import { CAPABILITY_PROVIDERS } from './capability-providers.js';
61-
import { resolveStackCollection, stackDeclaredCapabilities } from './stack-collections.js';
61+
import { resolveStackCollection } from './stack-collections.js';
6262

6363
type Bag = Record<string, unknown>;
6464

@@ -70,7 +70,7 @@ export interface ServedCapabilities {
7070
/** Every token, deduplicated, in the order a served boot mounts its provider. */
7171
readonly tokens: readonly string[];
7272
/**
73-
* The tokens the stack itself declares in `requires` — the "required" intent
73+
* The tokens the stack itself declares — the "required" intent
7474
* (#1597): under `serve` a declared token whose provider cannot be provided
7575
* is a hard boot error, where a token the platform appended is best-effort.
7676
*/
@@ -84,12 +84,17 @@ export interface ServedCapabilities {
8484
const NEEDS_JOB_AND_QUEUE: readonly string[] = ['email', 'approvals', 'auth'];
8585

8686
/**
87-
* The capability tokens a served boot of `stack` mounts providers for, in
88-
* mount order — the rule `os serve` reads, in its order:
87+
* The capability tokens a served boot mounts providers for, in mount order,
88+
* given the tokens the stack DECLARES — the rule `os serve` reads, in its
89+
* order:
8990
*
90-
* 1. the tokens the stack declares in `requires`
91-
* ({@link stackDeclaredCapabilities}: the top-level list, otherwise each
92-
* package body's), deduplicated in first-seen order;
91+
* 1. the declared tokens, deduplicated in first-seen order. A reader hands
92+
* them in as it reads them: `serve` and the handle by
93+
* `stackDeclaredCapabilities` (the top-level `requires`, otherwise each
94+
* package body's); `os migrate plan`'s declaration boot by the same reader,
95+
* or off a compiled artifact for a host config. Reading `requires` stays
96+
* each boot's own step, so it is ONE call shared with the other readers of
97+
* `requires`, never a second copy inside this rule;
9398
* 2. `email` when `auth` is declared — auth callbacks (password reset, email
9499
* verification, magic link, invitation) depend on the email service, which
95100
* falls back to its log transport when no provider is configured;
@@ -106,15 +111,15 @@ const NEEDS_JOB_AND_QUEUE: readonly string[] = ['email', 'approvals', 'auth'];
106111
* which tokens mount nothing is each reader's lookup in `CAPABILITY_PROVIDERS`.
107112
*/
108113
export function resolveServedCapabilities(
109-
stack: unknown,
114+
declaredTokens: readonly string[],
110115
opts: {
111116
/** `serve`'s `--preset`; `minimal` mounts no slate. */
112117
readonly preset?: string;
113118
/** The host's process-level default tokens, appended before the slate. */
114119
readonly hostDefaults?: readonly string[];
115120
} = {},
116121
): ServedCapabilities {
117-
const tokens = [...new Set(stackDeclaredCapabilities(stack))];
122+
const tokens = [...new Set(declaredTokens)];
118123
const declared: ReadonlySet<string> = new Set(tokens);
119124
if (tokens.includes('auth') && !tokens.includes('email')) tokens.push('email');
120125
for (const token of opts.hostDefaults ?? []) {

‎packages/verify/src/required-providers.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
//
1616
// ## What is `serve`'s, read from where `serve` reads it (`@objectstack/core`)
1717
//
18-
// · WHICH tokens: `resolveServedCapabilities` — the tokens the app declares
19-
// (the top-level `requires` when the stack carries one, otherwise each
20-
// package body's), `email` for a declared `auth`, the always-on slate
18+
// · WHICH tokens: `resolveServedCapabilities` over the tokens the app
19+
// declares as `stackDeclaredCapabilities` reads them (the top-level
20+
// `requires` when the stack carries one, otherwise each package body's) —
21+
// `email` for a declared `auth`, the always-on slate
2122
// (`PLATFORM_ALWAYS_ON_CAPABILITIES`), and `job` / `queue` ahead of the
2223
// tokens that schedule background work. No preset: `bootStack` takes none,
2324
// so the slate is always mounted, as on a `serve` with no `--preset`.
@@ -62,6 +63,7 @@ import {
6263
providesCapability,
6364
resolveCapabilityArgument,
6465
resolveServedCapabilities,
66+
stackDeclaredCapabilities,
6567
} from '@objectstack/core';
6668

6769
/** Why this boot constructs a provider, as a refusal names it. */
@@ -133,7 +135,7 @@ export async function constructServedProviders(opts: {
133135
held: readonly unknown[];
134136
packageRoot: string;
135137
}): Promise<unknown[]> {
136-
const { tokens, declared } = resolveServedCapabilities(opts.config);
138+
const { tokens, declared } = resolveServedCapabilities(stackDeclaredCapabilities(opts.config));
137139
const constructed: unknown[] = [];
138140
const all = (): unknown[] => [...opts.held, ...constructed];
139141

0 commit comments

Comments
 (0)