From aad353d24c8dc6e39a3f44fa04ef20b37ab3246e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 14:35:51 +0000 Subject: [PATCH 1/3] test(metadata-protocol): makeImpl gains the service-ABSENT composition (#17676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling A' item 3 (decision batch #125 item 2, maintainer approved 2026-09-13): `durable-package.test.ts`'s `makeImpl()` could only build the service-PRESENT host, so no pin in it could fail the way #17676 failed. It now composes either host, mirrors the real `SchemaRegistry` package API by name, and can replay a durable `sys_packages` row the way `PackageServicePlugin.start()` does — which is what lets a pin cross a restart boundary at the unit tier. Four new pins: the service-absent `installPackage` / `updatePackage` degraded path (in-memory write lands, nothing durable, the degradation is loud), the three-way split after a restart, and the service-present control where the same sequence agrees instead. Item 2 is docs-only here: the in-memory branches STAY. `installPackage`'s note no longer names `marketplace` as the owning capability (item 1 carved the persistence half out as always-on `package-registry`) and records the measured gap — `Serve.CAPABILITY_PROVIDERS` does not key the new token, so the branch is still the one a stock boot takes. Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude --- .../src/durable-package.test.ts | 221 +++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 31 ++- 2 files changed, 241 insertions(+), 11 deletions(-) diff --git a/packages/metadata-protocol/src/durable-package.test.ts b/packages/metadata-protocol/src/durable-package.test.ts index 9fbe6fde22d..08484b413d1 100644 --- a/packages/metadata-protocol/src/durable-package.test.ts +++ b/packages/metadata-protocol/src/durable-package.test.ts @@ -7,30 +7,88 @@ // builder / Setup), which is exactly why those packages vanished on restart. // These tests pin the fixed contract: version is defaulted (never skipped) and // uninstall drops the durable row so packages don't resurrect at boot. +// +// #17676 ruling A' item 3 (decision batch #125 item 2, maintainer 「同意」 +// 2026-09-13) — `makeImpl()` gains the service-ABSENT composition, so the pin +// can express the bug THAT card found: a writable package created through +// `POST /api/v1/packages` on a host with no `package` service is registered +// in-memory only, and after a restart the state splits three ways (Studio's +// writable list is empty, the data route 404s, the published metadata is still +// there). Before this, `makeImpl()` could only build the service-PRESENT host, +// so no pin here could fail the way the card failed. +// +// ⭐ The service-ABSENT branch is NOT a bug to delete — ruling item 2 keeps it +// as the documented degraded path for reduced hosts (`--preset minimal`, a host +// that mounts no `package-registry` provider). What the ruling changes is which +// hosts take it, and that half lives outside this package; these pins state +// what the primitive does on each composition, either side of it. import { describe, it, expect, vi } from 'vitest'; import { ObjectStackProtocolImplementation } from './index.js'; +/** + * Compose a protocol implementation over a fake host. + * + * The fake `registry` mirrors the real `SchemaRegistry` package API by NAME + * (`installPackage` / `getPackage` / `getAllPackages` / `updatePackageManifest`, + * `packages/objectql/src/registry.ts`) so a pin written against it is a pin + * about the primitive's real collaborator, not about an invented one. + */ function makeImpl(overrides?: { publish?: (d: { manifest: unknown; metadata: unknown }) => Promise; del?: (id: string) => Promise; find?: (obj: string, q: unknown) => Promise; + /** + * #17676 ruling A' item 3 — the service-ABSENT composition. + * + * `false` builds a services registry with NO `package` entry at all, which + * is what a host that mounts no `PackageServicePlugin` hands the protocol. + * `installPackage` / `updatePackage` then take their in-memory-only branch: + * the registry write lands, nothing reaches `sys_packages`, and the process + * is the only place the package exists. + */ + packageService?: false; + /** + * Manifests to replay into the fresh registry before the test runs — a + * stand-in for the boot hydration `PackageServicePlugin.start()` performs + * (`for (const rec of await packageService.list()) registry.installPackage(rec.manifest)`, + * `packages/services/service-package/src/index.ts`). A host composed with + * the rows a previous host persisted IS that process after a restart; a host + * composed with none is a restart that found `sys_packages` empty. + */ + hydrate?: readonly { id: string }[]; }) { const registryCalls: Array<{ manifest: any; settings: any }> = []; - const engine = { - registry: { - installPackage: (manifest: any, settings: any) => { - registryCalls.push({ manifest, settings }); - return { manifest, status: 'installed', enabled: true }; - }, + const installed = new Map(); + const registry = { + installPackage: (manifest: any, settings?: any) => { + registryCalls.push({ manifest, settings }); + const pkg = { manifest, status: 'installed', enabled: true }; + installed.set(manifest.id, pkg); + return pkg; + }, + getPackage: (id: string) => installed.get(id), + getAllPackages: () => [...installed.values()], + updatePackageManifest: (id: string, patch: Record) => { + const pkg = installed.get(id); + if (!pkg) return undefined; + Object.assign(pkg.manifest, patch); + return pkg; }, - find: overrides?.find ?? (async () => []), }; + const engine = { registry, find: overrides?.find ?? (async () => []) }; + // Seeded through the registry's own verb, exactly as the boot hydration does. + for (const manifest of overrides?.hydrate ?? []) registry.installPackage(manifest); + registryCalls.length = 0; // hydration is the fixture, not an observation + const publish = vi.fn(overrides?.publish ?? (async () => ({ success: true }))); const del = vi.fn(overrides?.del ?? (async () => ({ success: true }))); - const services = new Map([['package', { publish, delete: del }]]); + const services = new Map(); + if (overrides?.packageService !== false) { + services.set('package', { publish, delete: del }); + } const impl = new ObjectStackProtocolImplementation(engine as any, () => services); - return { impl, registryCalls, publish, del }; + return { impl, registryCalls, publish, del, registry, engine, services }; } describe('installPackage — durable persistence (#2532)', () => { @@ -130,3 +188,148 @@ describe('deletePackage — uninstall cleanups (#2747)', () => { expect(res.cleanups[0].removed).toBe(2); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #17676 ruling A' item 3 — the service-ABSENT composition, and the restart +// split it exists to express. +// +// ⚠️ SCOPE, stated so a green run here is not over-read. Ruling item 5's +// acceptance is that THREE probes agree after a restart — Studio's writable +// list, `GET /api/v1/data/_` and +// `GET /api/v1/meta/object/_/published`. Those are HTTP surfaces over +// a booted composition; this is a unit tier over the protocol primitive, and a +// unit tier cannot restart a server. What it CAN do is pin the one fact the +// three probes disagree about — whether the package crosses the boundary at +// all — at the seam that decides it. The registry reads below stand for the +// first two probes (both are registry-backed: the dispatcher's +// `/api/v1/packages` list and `getMetaItems({type:'package'})` feed Studio's +// selector; the data route needs the object registered), and the `find` read +// stands for the third (published `sys_metadata` rows, which no package state +// gates). ⛔ Item 5 is NOT met by these pins. +// ───────────────────────────────────────────────────────────────────────────── + +describe("service-ABSENT host — the degraded path (#17676 ruling A' items 2/3)", () => { + it('installPackage registers in memory, writes NOTHING durable, and says so', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { impl, registryCalls, publish, registry } = makeImpl({ packageService: false }); + + const res: any = await (impl as any).installPackage({ + manifest: { id: 'com.example.leave', name: '请假' }, + }); + + // The door still answers 201 — which is exactly why the card calls the + // failure invisible until a restart. + expect(res.package.status).toBe('installed'); + expect(registryCalls).toHaveLength(1); + expect(registry.getPackage('com.example.leave')).toBeTruthy(); + // Durable half never ran: there was no service to run it. + expect(publish).not.toHaveBeenCalled(); + // ⭐ Absence must be loud (AGENTS.md, Route & surface ownership §3): the + // branch is allowed to degrade, never to degrade SILENTLY. + const said = warn.mock.calls.map((c) => String(c[0])); + expect(said.some((m) => m.includes("no 'package' service"))).toBe(true); + expect(said.some((m) => m.includes('will not survive a restart'))).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it('updatePackage degrades the same way — the ruling names BOTH primitives', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { impl, publish, registry } = makeImpl({ + packageService: false, + hydrate: [{ id: 'com.example.leave', name: '请假', version: '0.1.0' }], + }); + + const res: any = await (impl as any).updatePackage({ + packageId: 'com.example.leave', + patch: { name: '请假 v2' }, + }); + + expect(res.package.manifest.name).toBe('请假 v2'); + expect(registry.getPackage('com.example.leave').manifest.name).toBe('请假 v2'); + expect(publish).not.toHaveBeenCalled(); + const said = warn.mock.calls.map((c) => String(c[0])); + expect(said.some((m) => m.includes("no 'package' service"))).toBe(true); + expect(said.some((m) => m.includes('will not survive a restart'))).toBe(true); + } finally { + warn.mockRestore(); + } + }); +}); + +describe("the restart split this card reported (#17676 ruling A' items 3/5)", () => { + /** The durable `sys_packages` table — the only package state a restart keeps. */ + const makeSysPackages = () => { + const rows = new Map(); + return { + rows, + publish: async (d: any) => { + rows.set(d.manifest.id, d.manifest); + return { success: true }; + }, + }; + }; + + /** + * The published object metadata, which lives in `sys_metadata` and is gated + * by nothing the package registry holds — that asymmetry IS the card's + * contradiction: "the published metadata outlives both the package and the + * runtime registration". + */ + const publishedMetadata = [{ name: 'leave_request', _packageId: 'com.example.leave' }]; + + it('service ABSENT ⇒ the package does not cross the boundary, its metadata does', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const sysPackages = makeSysPackages(); + + // ── process 1 ───────────────────────────────────────────────────────── + const before = makeImpl({ packageService: false, publish: sysPackages.publish }); + await (before.impl as any).installPackage({ + manifest: { id: 'com.example.leave', name: '请假' }, + }); + expect(before.registry.getPackage('com.example.leave')).toBeTruthy(); + expect([...sysPackages.rows.keys()]).toEqual([]); + + // ── restart ─────────────────────────────────────────────────────────── + const after = makeImpl({ + packageService: false, + hydrate: [...sysPackages.rows.values()], + find: async () => publishedMetadata, + }); + + // Probe 1 — Studio: `No writable packages yet`. + expect(after.registry.getAllPackages()).toEqual([]); + // Probe 2 — the data route: `Object '_' is not registered`. + expect(after.registry.getPackage('com.example.leave')).toBeUndefined(); + // Probe 3 — published metadata: still 200. The three DISAGREE. + expect(await after.engine.find('sys_metadata', {})).toEqual(publishedMetadata); + } finally { + warn.mockRestore(); + } + }); + + it('CONTROL — service PRESENT ⇒ all three agree after the same restart', async () => { + const sysPackages = makeSysPackages(); + + const before = makeImpl({ publish: sysPackages.publish }); + await (before.impl as any).installPackage({ + manifest: { id: 'com.example.leave', name: '请假' }, + }); + // The durable half ran, so the restart has something to replay. + expect([...sysPackages.rows.keys()]).toEqual(['com.example.leave']); + + const after = makeImpl({ + publish: sysPackages.publish, + hydrate: [...sysPackages.rows.values()], + find: async () => publishedMetadata, + }); + + expect(after.registry.getAllPackages()).toHaveLength(1); + expect(after.registry.getPackage('com.example.leave')).toBeTruthy(); + expect(await after.engine.find('sys_metadata', {})).toEqual(publishedMetadata); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index e7a78741e04..653d2155fb7 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22325,8 +22325,31 @@ export class ObjectStackProtocolImplementation implements * rows back into the registry on boot). * * The DB write is best-effort and non-fatal: when the `package` service is - * absent (e.g. the `marketplace` capability is off) the package is still - * registered in-memory and visible for the lifetime of the process. + * absent the package is still registered in-memory and visible for the + * lifetime of the process — and that in-memory-only branch STAYS, as the + * documented degraded path for reduced hosts (#17676 ruling A' item 2, + * decision batch #125 item 2). ⛔ It is not a bug to delete: a host that + * mounts no provider (`objectstack serve --preset minimal`, a metadata-only + * embedding) must still be able to install a package for the life of its + * process, and the `warn` below is what keeps the degradation from being + * silent. + * + * Which capability OWNS the service is no longer `marketplace`: ruling A' + * item 1 split the persistence half — the `sys_packages` container and the + * boot hydration that replays it — out under its own always-on token + * `package-registry` (`PLATFORM_ALWAYS_ON_CAPABILITIES`, + * `packages/spec/src/kernel/platform-capabilities.ts`), leaving + * `marketplace` naming only the optional catalogue / browsing half. ⚠️ The + * runtime half of that split is NOT landed: measured on `origin/main` at + * c334ba0f3a, `Serve.CAPABILITY_PROVIDERS` + * (`packages/cli/src/commands/serve.ts`) keys `marketplace` and does not key + * `package-registry`, so the always-on token is force-appended to every + * app's `requires` and then resolves to no provider — silently, because the + * resolver only warns for tokens outside the vocabulary. ⇒ on a stock + * `objectstack dev` boot of an app that does not itself declare + * `requires: ['marketplace']`, this branch is still the one taken, which is + * the defect #17676 reports. Recorded here rather than worked around: the + * fix belongs to the capability resolver, not to this primitive. * * [#19277] `request.enableOnInstall` is HONOURED here, under the same rule * the HTTP door implements — 「缺省 = 保持,有旗 = 设置」: `true` enables, @@ -22469,6 +22492,10 @@ export class ObjectStackProtocolImplementation implements * service so the edit survives a restart. Persistence is best-effort and * non-fatal (matching `installPackage`): the registry write already * succeeded, so a persist failure is logged, never thrown. + * + * The service-absent branch below is the same documented degraded path + * #17676 ruling A' item 2 keeps — see `installPackage`'s note for which + * capability owns the service and for the measured state of that split. */ async updatePackage(request: { packageId: string; From adcd4a1612ac23e4e89e089706371b87ce15e9fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:00:08 +0000 Subject: [PATCH 2/3] test(metadata-protocol): widen the hydrate fixture type so tsc accepts a real manifest Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude --- packages/metadata-protocol/src/durable-package.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/durable-package.test.ts b/packages/metadata-protocol/src/durable-package.test.ts index 08484b413d1..4817f025ac2 100644 --- a/packages/metadata-protocol/src/durable-package.test.ts +++ b/packages/metadata-protocol/src/durable-package.test.ts @@ -56,7 +56,7 @@ function makeImpl(overrides?: { * the rows a previous host persisted IS that process after a restart; a host * composed with none is a restart that found `sys_packages` empty. */ - hydrate?: readonly { id: string }[]; + hydrate?: readonly { id: string; [key: string]: unknown }[]; }) { const registryCalls: Array<{ manifest: any; settings: any }> = []; const installed = new Map(); @@ -263,7 +263,7 @@ describe("service-ABSENT host — the degraded path (#17676 ruling A' items 2/3) describe("the restart split this card reported (#17676 ruling A' items 3/5)", () => { /** The durable `sys_packages` table — the only package state a restart keeps. */ const makeSysPackages = () => { - const rows = new Map(); + const rows = new Map(); return { rows, publish: async (d: any) => { From e12c577eafa716d81d964ee5922ba4f5d715ab52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:14:24 +0000 Subject: [PATCH 3/3] chore(changeset): patch @objectstack/metadata-protocol for the corrected docblock (#17676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat overruled the `skip-changeset` judgement on the measurement this branch itself took: `@objectstack/metadata-protocol` is not private, `files[]` ships `dist`, and the corrected JSDoc reaches all four dist entries because tsup keeps it. AGENTS.md Post-Task Checklist §3 scopes that label to a diff that publishes nothing from any released package, so its precondition is false here — and a false TSDoc in shipped content is a defect, which makes this a patch rather than a cosmetic. No behaviour moves; the changeset says so explicitly so it cannot be read as the card's defect being repaired. Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude --- ...6-package-registry-docblock-capability-name.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/17676-package-registry-docblock-capability-name.md diff --git a/.changeset/17676-package-registry-docblock-capability-name.md b/.changeset/17676-package-registry-docblock-capability-name.md new file mode 100644 index 00000000000..c382c366b1d --- /dev/null +++ b/.changeset/17676-package-registry-docblock-capability-name.md @@ -0,0 +1,15 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +`installPackage`'s docblock no longer names `marketplace` as the capability that governs package persistence — after #17676 ruling A′ item 1 that half is `package-registry`, and `marketplace` names only the optional catalogue (#17676). + +Clause-②: no + +The shipped note read *"when the `package` service is absent (e.g. the `marketplace` capability is off)"*. That parenthetical was accurate when it was written and stopped being accurate when the carve-out landed: `packages/spec/src/kernel/platform-capabilities.ts` now carries `marketplace` and `package-registry` as two tokens, with the `sys_packages` container and its boot hydration under the second — a core capability mounted always — and browsing left under the first. A consumer reading this docblock in an editor, out of `dist/index.d.ts`, was being pointed at the wrong switch. + +- **⛔ No behaviour moves, and this is not the card's defect being repaired.** The in-memory-only branches in `installPackage` and `updatePackage` are byte-identical. Ruling A′ item 2 keeps them deliberately, as the documented degraded path for reduced hosts — a host that mounts no provider must still be able to install a package for the life of its process — so the note now says that too, rather than leaving the branch reading like an oversight. #17676 stays open. +- **The note also records what is NOT true yet, measured rather than assumed.** `Serve.CAPABILITY_PROVIDERS` (`packages/cli/src/commands/serve.ts`) keys `marketplace` and does not key `package-registry`, so the always-on token is force-appended to every app's `requires` and then resolves to no provider — silently, because the resolver warns only for tokens outside the vocabulary. A stock boot still takes the absent-service branch. That half of the ruling belongs to the capability resolver and is not in this package. +- `updatePackage`'s docblock points at the same note, since the ruling names both primitives. + +Published surface: doc comments only. `dist/index.js`, `dist/index.cjs`, `dist/index.d.ts` and `dist/index.d.cts` all carry the corrected text — tsup keeps JSDoc, which is why this ships at all — and no export, type, signature or runtime string moves.