Skip to content

Commit 37a112e

Browse files
committed
feat(spec): carve the package-registry persistence out of marketplace into an always-on core capability
`sys_packages` and the boot hydration that replays it are the persistence half of what the `marketplace` token named; a package is a first-class persistent entity whether or not the deployment has a store. Give that half its own vocabulary token, its own open-edition provider row, and a place on the always-on slate; leave `marketplace` naming only the optional catalogue / browsing half. The slate's ordering contract moves with it: `package-registry` binds into nothing on the slate, so it joins the tail, and the falsifiability control that enumerates the tail literally is updated from the same rule rather than around it. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c17ff70 commit 37a112e

4 files changed

Lines changed: 142 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`package-registry` is a platform capability of its own, and an always-on one: the `sys_packages` container and the boot hydration that replays it no longer hide behind the `marketplace` token, which is left naming only the optional catalogue / browsing half (#18053, director ruling A′ on #17676).
6+
7+
A package is a first-class persistent entity whether or not a deployment has a store — an admin-created package does not depend on the marketplace existing. Until now the only way to get the persistence was `requires: ['marketplace']`, so a stock boot had no `sys_packages` at all and `protocol.installPackage` / `updatePackage` fell back to their in-memory branches: an admin-created package did not survive a restart, under a token advertising a store that was not there.
8+
9+
- **`PLATFORM_CAPABILITY_TOKENS` gains `package-registry`** — one new token, none removed, so `marketplace` keeps working exactly as before for anyone who declares it. The vocabulary is a closed set validated by `defineStack`, so this widens what an app may write, and nothing it already writes stops parsing.
10+
- **`PLATFORM_ALWAYS_ON_CAPABILITIES` gains `package-registry` at the tail.** The slate's ordering contract is a role, not a count: the entry binds into nothing on the slate (its one hard requirement is the ObjectQL engine, which is not a capability token), so it joins after every bind target like any other reader. `--preset minimal` still opts out of the whole slate.
11+
- **`PLATFORM_CAPABILITY_PROVIDERS` gains a row naming `@objectstack/service-package`, `open` edition** — the same package `marketplace` names today, because that package ships exactly one plugin and everything it does is the persistence half. The catalogue surface `marketplace` is left naming ships in `@objectstack/cloud-connection` and is mounted off a resolved marketplace URL, never through the token; repointing the `marketplace` row at it moves the runtime's own resolver with it and is the engine-lane half of the same ruling (#17676 items 2/3/5).
12+
- ⚠️ **Declaration first, runtime second — measured, not assumed.** `objectstack serve` mounts a slate entry only when `Serve.CAPABILITY_PROVIDERS` keys the token, and that registry keys `marketplace`. Until the engine-lane half lands, appending `package-registry` mounts nothing under the standalone CLI: a stock boot is exactly as capable as before, no more and no less. This package is the single list both the CLI and cloud's per-tenant runtime read, which is why the declaration is the half that goes first.

‎packages/cli/test/serve-capability-vocabulary.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,34 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => {
4242
expect(PLATFORM_CAPABILITY_TOKENS).toContain(token);
4343
}
4444
});
45+
46+
/**
47+
* #17676 ruling A' item 1, read through the array `serve` actually appends.
48+
*
49+
* `Serve.ALWAYS_ON_CAPABILITIES` is a re-export of the spec slate, so this is
50+
* a SURFACE pin rather than a second copy of the spec-side one: it asserts
51+
* the split survives the hop the CLI takes, and that hop is what decides
52+
* which tokens land in an app's `requires`.
53+
*
54+
* ⚠️ Measured on `serve`'s resolver at c17ff70f3f and deliberately NOT
55+
* asserted: `Serve.CAPABILITY_PROVIDERS` keys `marketplace` and does not yet
56+
* key `package-registry`, so appending this token mounts nothing under
57+
* `objectstack serve` until the runtime half of the same ruling lands
58+
* (#17676 items 2/3/5, the engine lane). Pinning that ABSENCE here would
59+
* turn the engine lane's own fix red for doing the ruled thing, so the gap
60+
* is recorded in words and the pin states only what must hold either side of
61+
* it.
62+
*/
63+
it("appends the package-registry persistence to every app, never the catalogue half (#17676 A')", () => {
64+
expect(Serve.ALWAYS_ON_CAPABILITIES).toContain('package-registry');
65+
// The other half of the ruling: browsing stays optional, so an app that
66+
// wants a store still declares it.
67+
expect(Serve.ALWAYS_ON_CAPABILITIES).not.toContain('marketplace');
68+
// …and the split ADDED a token rather than moving one out — both halves
69+
// stay resolvable spellings for `requires`.
70+
expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry');
71+
expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace');
72+
});
4573
});
4674

4775
// framework#3366 — the installable-provider registry must classify EVERY

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ describe('PLATFORM_ALWAYS_ON_CAPABILITIES', () => {
269269
[...PLATFORM_ALWAYS_ON_CAPABILITIES, 'secrets'],
270270
[...BIND_TARGETS, 'secrets'],
271271
),
272-
).toEqual(['email', 'storage', 'sms', 'sharing', 'messaging', 'analytics']);
272+
).toEqual(['email', 'storage', 'sms', 'sharing', 'messaging', 'analytics', 'package-registry']);
273273
});
274274

275275
it('every member is a real platform capability token', () => {
@@ -299,3 +299,64 @@ describe('PLATFORM_ALWAYS_ON_CAPABILITIES', () => {
299299
expect(gated).toEqual([]);
300300
});
301301
});
302+
303+
/**
304+
* #17676 ruling A' item 1 (decision batch #125 item 2, maintainer verbatim
305+
* 「同意」): the package-registry PERSISTENCE — the `sys_packages` container and
306+
* the boot hydration that replays it — is carved out of `marketplace` into an
307+
* always-on core capability named for what it is; `marketplace` is left naming
308+
* only the optional catalogue / browsing half.
309+
*
310+
* BOTH halves are asserted here, because only the pair states the ruling. A
311+
* case that checked the new token alone would stay green on a slate that
312+
* force-mounted `marketplace` as well — which is the outcome the ruling refused
313+
* ("a token advertising a store that is not there"), and the reason the split
314+
* exists rather than a rename.
315+
*/
316+
describe("package-registry carve-out (#17676 ruling A')", () => {
317+
it('is its own vocabulary token — the persistence is named, not spelled `marketplace`', () => {
318+
expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry');
319+
expect(isKnownPlatformCapability('package-registry')).toBe(true);
320+
// The catalogue half keeps its token: this is a SPLIT, so the vocabulary
321+
// must carry two tokens afterwards, not one renamed one.
322+
expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace');
323+
});
324+
325+
it('has exactly ONE spelling — no second dialect for the same capability', () => {
326+
// The single-list rule this file already enforces against the removed
327+
// camelCase aliases, applied to the new token while its spelling is still
328+
// young: the near-misses a later author could reach for must stay unknown,
329+
// or `requires` grows two ways to ask for one service and the runtimes are
330+
// free to resolve different ones.
331+
for (const nearMiss of ['packages', 'package', 'sys-packages', 'package-store', 'packageRegistry']) {
332+
expect(PLATFORM_CAPABILITY_TOKENS, `'${nearMiss}' must not be a second spelling`).not.toContain(
333+
nearMiss,
334+
);
335+
expect(isKnownPlatformCapability(nearMiss)).toBe(false);
336+
}
337+
});
338+
339+
it('is mounted ALWAYS, and the catalogue half is NOT — the ruling, both ways round', () => {
340+
expect(PLATFORM_ALWAYS_ON_CAPABILITIES).toContain('package-registry');
341+
// Browsing stays optional: an app that wants a store still declares it.
342+
expect(PLATFORM_ALWAYS_ON_CAPABILITIES).not.toContain('marketplace');
343+
});
344+
345+
it('resolves through an open-edition provider — a floor entry must mount without a licence', () => {
346+
const provider = PLATFORM_CAPABILITY_PROVIDERS['package-registry'];
347+
expect(provider, 'the carved-out token needs its own provider row').toBeTruthy();
348+
expect(provider.edition).toBe('open');
349+
expect(provider.package).toBe('@objectstack/service-package');
350+
});
351+
352+
it('classifies like any other open-edition service — never as a typo', () => {
353+
// The authoring-time half: `defineStack` rejects a token the vocabulary
354+
// does not carry, and the preflight reads the classifier. A carve-out that
355+
// added the slate entry without the provider row would surface HERE, as an
356+
// `unknown` on a token every app now force-declares.
357+
expect(classifyRequiredCapability('package-registry', () => true).status).toBe('ok');
358+
const absent = classifyRequiredCapability('package-registry', () => false);
359+
expect(absent.status).toBe('installable');
360+
expect(absent.provider?.package).toBe('@objectstack/service-package');
361+
});
362+
});

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,15 @@ export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([
4747
'triggers',
4848
'realtime',
4949
'mcp',
50+
// `marketplace` and `package-registry` are two capabilities, not one token
51+
// spelled twice (#17676 ruling A' item 1). A package is a first-class
52+
// persistent entity whether or not the deployment has a store, so the
53+
// PERSISTENCE half — the `sys_packages` container and the boot hydration
54+
// that replays it — is a core capability named for what it is and mounted
55+
// always (see {@link PLATFORM_ALWAYS_ON_CAPABILITIES}); `marketplace` is
56+
// left naming only the optional catalogue / browsing half.
5057
'marketplace',
58+
'package-registry',
5159
'email',
5260
'sms',
5361
'sharing',
@@ -155,6 +163,20 @@ export const PLATFORM_CAPABILITY_PROVIDERS: Readonly<Record<string, PlatformCapa
155163
realtime: { package: '@objectstack/service-realtime', edition: 'open' },
156164
mcp: { package: '@objectstack/mcp', edition: 'open' },
157165
marketplace: { package: '@objectstack/service-package', edition: 'open' },
166+
// ⚠️ The SAME package as `marketplace` above, and that is a measured fact
167+
// about the distribution rather than a duplicate row: today
168+
// `@objectstack/service-package` ships exactly one plugin
169+
// (`PackageServicePlugin`), and everything it does is the persistence half
170+
// this token names — it creates `sys_packages`, replays it at `start()`,
171+
// and serves publish/get/list/delete over it. The catalogue / browsing
172+
// surface `marketplace` is left naming ships elsewhere entirely
173+
// (`MarketplaceProxyPlugin` / `MarketplaceInstallLocalPlugin` in
174+
// `@objectstack/cloud-connection`, mounted off a resolved marketplace URL,
175+
// ADR-0008). So the two rows agreeing on a package is what the carve-out
176+
// INHERITED, not what it decided: repointing `marketplace` at the browse
177+
// surface moves the runtime's own resolver with it and is #17676's
178+
// engine-lane half, which this row deliberately does not pre-empt.
179+
'package-registry': { package: '@objectstack/service-package', edition: 'open' },
158180
email: { package: '@objectstack/plugin-email', edition: 'open' },
159181
sms: { package: '@objectstack/service-sms', edition: 'open' },
160182
sharing: { package: '@objectstack/plugin-sharing', edition: 'open' },
@@ -313,6 +335,24 @@ export const PLATFORM_ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze(
313335
// authored/previewed inline (Studio) and compiled on the fly. Without it the
314336
// dataset preview + dashboard/report analytics widgets silently no-op.
315337
'analytics',
338+
// `package-registry` is foundational per #17676 ruling A' (decision batch
339+
// #125 item 2): a package is a first-class persistent entity whether or not
340+
// the deployment has a marketplace, so `sys_packages` and its boot
341+
// hydration must exist on a stock boot. Without it `protocol.installPackage`
342+
// / `updatePackage` fall back to their in-memory branches and an
343+
// admin-created package does not survive a restart. It binds into nothing on
344+
// this slate (its only hard requirement is the ObjectQL engine, which is not
345+
// a capability token), so it joins the TAIL like any other reader.
346+
//
347+
// ⚠️ SCOPE, measured on `serve`'s capability resolver at c17ff70f3f: a slate
348+
// entry is force-appended to every app's `requires`, and the CLI then mounts
349+
// it only if `Serve.CAPABILITY_PROVIDERS` keys the token. That registry keys
350+
// `marketplace`, not this token, so under `objectstack serve` this entry is
351+
// inert until the runtime half of the same ruling lands (#17676 items 2/3/5,
352+
// the engine lane). The declaration is deliberately first: it is the single
353+
// list both runtimes read, and the thing the runtime half is written
354+
// against.
355+
'package-registry',
316356
]);
317357

318358
/**

0 commit comments

Comments
 (0)