From 37a112e0f2a32aed323add69f32dda16b55b1899 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:38:49 +0000 Subject: [PATCH] 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 --- .../18053-package-registry-capability.md | 12 ++++ .../test/serve-capability-vocabulary.test.ts | 28 +++++++++ .../src/kernel/platform-capabilities.test.ts | 63 ++++++++++++++++++- .../spec/src/kernel/platform-capabilities.ts | 40 ++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 .changeset/18053-package-registry-capability.md diff --git a/.changeset/18053-package-registry-capability.md b/.changeset/18053-package-registry-capability.md new file mode 100644 index 00000000000..9fe516aa53b --- /dev/null +++ b/.changeset/18053-package-registry-capability.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +--- + +`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). + +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. + +- **`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. +- **`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. +- **`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). +- ⚠️ **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. diff --git a/packages/cli/test/serve-capability-vocabulary.test.ts b/packages/cli/test/serve-capability-vocabulary.test.ts index 859c69711ec..48a73884ea0 100644 --- a/packages/cli/test/serve-capability-vocabulary.test.ts +++ b/packages/cli/test/serve-capability-vocabulary.test.ts @@ -42,6 +42,34 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => { expect(PLATFORM_CAPABILITY_TOKENS).toContain(token); } }); + + /** + * #17676 ruling A' item 1, read through the array `serve` actually appends. + * + * `Serve.ALWAYS_ON_CAPABILITIES` is a re-export of the spec slate, so this is + * a SURFACE pin rather than a second copy of the spec-side one: it asserts + * the split survives the hop the CLI takes, and that hop is what decides + * which tokens land in an app's `requires`. + * + * ⚠️ Measured on `serve`'s resolver at c17ff70f3f and deliberately NOT + * asserted: `Serve.CAPABILITY_PROVIDERS` keys `marketplace` and does not yet + * key `package-registry`, so appending this token mounts nothing under + * `objectstack serve` until the runtime half of the same ruling lands + * (#17676 items 2/3/5, the engine lane). Pinning that ABSENCE here would + * turn the engine lane's own fix red for doing the ruled thing, so the gap + * is recorded in words and the pin states only what must hold either side of + * it. + */ + it("appends the package-registry persistence to every app, never the catalogue half (#17676 A')", () => { + expect(Serve.ALWAYS_ON_CAPABILITIES).toContain('package-registry'); + // The other half of the ruling: browsing stays optional, so an app that + // wants a store still declares it. + expect(Serve.ALWAYS_ON_CAPABILITIES).not.toContain('marketplace'); + // …and the split ADDED a token rather than moving one out — both halves + // stay resolvable spellings for `requires`. + expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry'); + expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace'); + }); }); // framework#3366 — the installable-provider registry must classify EVERY diff --git a/packages/spec/src/kernel/platform-capabilities.test.ts b/packages/spec/src/kernel/platform-capabilities.test.ts index 7118bace3e3..b2027bedf5e 100644 --- a/packages/spec/src/kernel/platform-capabilities.test.ts +++ b/packages/spec/src/kernel/platform-capabilities.test.ts @@ -269,7 +269,7 @@ describe('PLATFORM_ALWAYS_ON_CAPABILITIES', () => { [...PLATFORM_ALWAYS_ON_CAPABILITIES, 'secrets'], [...BIND_TARGETS, 'secrets'], ), - ).toEqual(['email', 'storage', 'sms', 'sharing', 'messaging', 'analytics']); + ).toEqual(['email', 'storage', 'sms', 'sharing', 'messaging', 'analytics', 'package-registry']); }); it('every member is a real platform capability token', () => { @@ -299,3 +299,64 @@ describe('PLATFORM_ALWAYS_ON_CAPABILITIES', () => { expect(gated).toEqual([]); }); }); + +/** + * #17676 ruling A' item 1 (decision batch #125 item 2, maintainer verbatim + * 「同意」): the package-registry PERSISTENCE — the `sys_packages` container and + * the boot hydration that replays it — is carved out of `marketplace` into an + * always-on core capability named for what it is; `marketplace` is left naming + * only the optional catalogue / browsing half. + * + * BOTH halves are asserted here, because only the pair states the ruling. A + * case that checked the new token alone would stay green on a slate that + * force-mounted `marketplace` as well — which is the outcome the ruling refused + * ("a token advertising a store that is not there"), and the reason the split + * exists rather than a rename. + */ +describe("package-registry carve-out (#17676 ruling A')", () => { + it('is its own vocabulary token — the persistence is named, not spelled `marketplace`', () => { + expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry'); + expect(isKnownPlatformCapability('package-registry')).toBe(true); + // The catalogue half keeps its token: this is a SPLIT, so the vocabulary + // must carry two tokens afterwards, not one renamed one. + expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace'); + }); + + it('has exactly ONE spelling — no second dialect for the same capability', () => { + // The single-list rule this file already enforces against the removed + // camelCase aliases, applied to the new token while its spelling is still + // young: the near-misses a later author could reach for must stay unknown, + // or `requires` grows two ways to ask for one service and the runtimes are + // free to resolve different ones. + for (const nearMiss of ['packages', 'package', 'sys-packages', 'package-store', 'packageRegistry']) { + expect(PLATFORM_CAPABILITY_TOKENS, `'${nearMiss}' must not be a second spelling`).not.toContain( + nearMiss, + ); + expect(isKnownPlatformCapability(nearMiss)).toBe(false); + } + }); + + it('is mounted ALWAYS, and the catalogue half is NOT — the ruling, both ways round', () => { + expect(PLATFORM_ALWAYS_ON_CAPABILITIES).toContain('package-registry'); + // Browsing stays optional: an app that wants a store still declares it. + expect(PLATFORM_ALWAYS_ON_CAPABILITIES).not.toContain('marketplace'); + }); + + it('resolves through an open-edition provider — a floor entry must mount without a licence', () => { + const provider = PLATFORM_CAPABILITY_PROVIDERS['package-registry']; + expect(provider, 'the carved-out token needs its own provider row').toBeTruthy(); + expect(provider.edition).toBe('open'); + expect(provider.package).toBe('@objectstack/service-package'); + }); + + it('classifies like any other open-edition service — never as a typo', () => { + // The authoring-time half: `defineStack` rejects a token the vocabulary + // does not carry, and the preflight reads the classifier. A carve-out that + // added the slate entry without the provider row would surface HERE, as an + // `unknown` on a token every app now force-declares. + expect(classifyRequiredCapability('package-registry', () => true).status).toBe('ok'); + const absent = classifyRequiredCapability('package-registry', () => false); + expect(absent.status).toBe('installable'); + expect(absent.provider?.package).toBe('@objectstack/service-package'); + }); +}); diff --git a/packages/spec/src/kernel/platform-capabilities.ts b/packages/spec/src/kernel/platform-capabilities.ts index 70219897a7b..b6c0292c5d2 100644 --- a/packages/spec/src/kernel/platform-capabilities.ts +++ b/packages/spec/src/kernel/platform-capabilities.ts @@ -47,7 +47,15 @@ export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([ 'triggers', 'realtime', 'mcp', + // `marketplace` and `package-registry` are two capabilities, not one token + // spelled twice (#17676 ruling A' item 1). A package is a first-class + // persistent entity whether or not the deployment has a store, so the + // PERSISTENCE half — the `sys_packages` container and the boot hydration + // that replays it — is a core capability named for what it is and mounted + // always (see {@link PLATFORM_ALWAYS_ON_CAPABILITIES}); `marketplace` is + // left naming only the optional catalogue / browsing half. 'marketplace', + 'package-registry', 'email', 'sms', 'sharing', @@ -155,6 +163,20 @@ export const PLATFORM_CAPABILITY_PROVIDERS: Readonly