Skip to content

Commit fade3da

Browse files
huangyiireneclaude
andauthored
test(metadata-protocol): makeImpl gains the service-ABSENT composition (#17676 ruling A' items 2/3) (#19385)
Part of #17676 — ruling A′ items 2 and 3. ⛔ Deliberately **not** a closing reference: ruling item 5's acceptance (three probes agreeing across a restart) is **not met** by this PR, so the card must stay open. What is left, and why it is not in this diff, is stated under "The fork" below. Clause-②: no ## What the ruling asked for Decision batch #125 item 2, letter A′ (maintainer 「同意」 2026-09-13), five items across three lanes. Item 1 was the `packages/spec` carve-out and landed via carrier #18053. Item 4 is the spec seat's ADR read. This PR is the engine lane's half: > **2.** `protocol.installPackage` and `protocol.updatePackage` therefore always find the `package` service on a stock boot; their in-memory-only branches stay as the documented degraded path for reduced hosts. > **3.** `durable-package.test.ts`'s `makeImpl()` gains the service-ABSENT composition so the pin can express the bug this card found. > **5.** The three probes (Studio writable list, `GET /api/v1/data/NS_OBJ`, `GET /api/v1/meta/object/NS_OBJ/published`) agree after a restart — that is the acceptance. ## Item 3 — the service-ABSENT composition `makeImpl()` hard-wired `services = new Map([['package', …]])`, so it could build only the service-PRESENT host and **no pin in this file could fail the way the card failed**. It now composes either host: - `packageService: false` builds a services registry with no `package` entry at all — what a host that mounts no `PackageServicePlugin` hands the protocol. - `hydrate: [...]` replays durable manifests through the registry's own `installPackage` verb, which is literally what `PackageServicePlugin.start()` does when it reconciles `sys_packages` back into the registry. A host composed with the rows a previous host persisted **is** that process after a restart. - The fake `registry` now mirrors the real `SchemaRegistry` package API **by name** (`installPackage` / `getPackage` / `getAllPackages` / `updatePackageManifest`), so a pin written against it is a pin about the primitive's real collaborator rather than an invented one. Four new pins on top of it: the service-absent `installPackage` and `updatePackage` degraded paths (registry write lands, nothing durable, the degradation is loud), the three-way split after a restart, and a **service-present control** where the same sequence agrees instead — the control is what keeps the split pin from being vacuously green. ## Item 2 — the branches stay; the note describing them did not survive item 1 No behaviour change: the in-memory-only branches are exactly as they were. ⭐ Item 2 does **not** say to delete them, and a change that removed them would have over-reached. What did change is documentation that item 1 made false. `installPackage`'s docblock read "when the `package` service is absent (e.g. the `marketplace` capability is off)". After the carve-out that names the wrong capability: persistence is `package-registry`, an always-on core token, and `marketplace` now names only the optional catalogue half. The note now says so, says why the branch is kept, and records the measured state of the split. `updatePackage` points at the same note, because the ruling names both primitives. ## ⚠️ The fork — item 2's factual half is FALSE on today's tree Ruling item 2 says these primitives "therefore always find the `package` service on a stock boot". **Measured on `origin/main` at `c334ba0f3a`, they do not**, and item 1 landing did not change that: - `PLATFORM_ALWAYS_ON_CAPABILITIES` contains `package-registry`, and `serve` force-appends every slate token to each app's `requires` (`serve.ts`, the `ALWAYS_CAPS` loop). - The capability resolver then mounts a token only when `Serve.CAPABILITY_PROVIDERS` keys it. That registry keys **`marketplace`** and does **not** key `package-registry`. - A token with no provider entry hits `if (!spec) … continue`, and because `package-registry` **is** in `PLATFORM_CAPABILITY_TOKENS` the resolver does not even warn. So the always-on entry is silently inert. - ⇒ on a stock `objectstack dev` boot of an app that does not itself declare `requires: ['marketplace']` (`examples/app-crm` declares `['ui', 'automation']`), `POST /api/v1/packages` still reaches `protocol.installPackage` with no `package` service and still registers the package in memory only. **The defect this card reports is live.** This is not a new reading. Both spec-lane artifacts say the same thing in their own words and both name the engine lane as the owner: > ⚠️ SCOPE, measured on `serve`'s capability resolver at c17ff70: a slate entry is force-appended to every app's `requires`, and the CLI then mounts it only if `Serve.CAPABILITY_PROVIDERS` keys the token. That registry keys `marketplace`, not this token, so under `objectstack serve` this entry is inert until the runtime half of the same ruling lands (#17676 items 2/3/5, the engine lane). — `packages/spec/src/kernel/platform-capabilities.ts`, and the same note in `packages/cli/test/serve-capability-vocabulary.test.ts`, which deliberately declines to pin the absence so the engine lane's own change is not turned red for doing the ruled thing. **That remaining half is not in this diff, by scope and by shape.** The dispatch fences this card at `packages/metadata-protocol/src/`; the resolver lives in `packages/cli/src/commands/serve.ts`. And it is not a one-line map entry: the spec's own provider row records that `marketplace` and `package-registry` point at the **same package today**, that this is what the carve-out *inherited* rather than decided, and that "repointing `marketplace` at the browse surface moves the runtime's own resolver with it". Which of those two shapes to take is a decision, not a mechanical edit, and it changes the boot composition of every app — a verification surface well outside this one. ## What is left — ⛔ item 5 is NOT met Item 5's acceptance is three HTTP probes agreeing across a restart of a booted composition. This is a unit tier over the protocol primitive, and a unit tier cannot restart a server. What the new pins do instead is pin the one fact the three probes disagree about — whether the package crosses the boundary at all — at the seam that decides it, with the registry reads standing for the two registry-backed probes and the metadata read for the third. The test file says this in a scope banner so a green run here is not over-read. ⛔ Item 5 stays open, and it cannot be met at all until the resolver half above lands. ## Verification Run under `scripts/pm/os-verify-lock.sh`; exit codes captured before any pipe. | what | command | result | |---|---|---| | package suite | `pnpm --filter @objectstack/metadata-protocol test` | exit 0 — 182 files passed / 3 skipped, **2600 tests passed**, 19 skipped (at `adcd4a161`) | | package typecheck | `pnpm --filter @objectstack/metadata-protocol typecheck` | exit 0 (`tsc --noEmit`; it **does** compile this test file — an earlier run of it caught a TS2353 in the new fixture type) | | dependency closure | `pnpm --filter '@objectstack/metadata-protocol^...' build` | exit 0 | | derived gate families | `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` | 53 derived, 53 run, **50 exit 0** | | gate reconciliation | same tool with `--ran` (each line `command :: exit N`) | exit 0 — 53 accounted, 0 UNRUN | | repo-wide lint | ESLint API over `.` with `allowInlineConfig: false` | exit 0 — **6934 files, 0 errors, 0 warnings** (the union, not a narrowed subset, at `adcd4a161`) | Three gates first exited **3 — PREREQUISITE NOT MET** (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`): each reads built output and the worktree had none. The prerequisite was cleared with `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks, exit 0) and all three then exited **0**. ⛔ An exit 3 is recorded as NOT MEASURED, never as a pass. ### Ablation — the new pin can fail Driven through `scripts/ablation-replace.mjs`, so the mutation is proved on disk rather than assumed from an exit code. The test imports `./index.js` — a same-package relative specifier that vitest resolves to `src/`, not `dist/` (an earlier failing run named `src/protocol.ts:6:1` in its stack), so no rebuild sits between the mutation and the run. - **mutation**: the `console.warn` in `installPackage`'s no-service branch replaced with a no-op — anchor `x1 → x0`, blob `653d2155fb74 → 7072a8718166`, both verified by re-reading the file. - **result**: `1 failed | 10 passed (11)` — the service-absent pin went red on the product change, so it reads the primitive and not the fake. - **restore**: blob back to `653d2155fb74`, identical to `HEAD`, and `git diff HEAD` empty. No ablation artifact remains in the tree. ## Changeset `Clause-②: no`, and a **`patch`** changeset for `@objectstack/metadata-protocol` — `.changeset/17676-package-registry-docblock-capability-name.md`. ⚠️ This PR first proposed `skip-changeset`; the seat **overruled** it, on the measurement this branch itself took. That measurement is recorded here because it is what decides the question rather than a preference. AGENTS.md Post-Task Checklist §3 scopes the label to "a diff that publishes nothing from any released package". Measured: `@objectstack/metadata-protocol` is not private, its `files[]` ships `dist`, and the corrected docblock reaches `dist/index.js`, `dist/index.cjs`, `dist/index.d.ts` and `dist/index.d.cts` — subject count 1 each, with a positive control present in the two JS outputs — because tsup keeps JSDoc. ⇒ the label's precondition is **false by measurement**. And it is a **fix** rather than a cosmetic. The old sentence told a consumer reading `dist/index.d.ts` in their editor that the absent-service branch is what you get "e.g. the `marketplace` capability is off", which stopped being true the moment ruling A′ item 1 landed. A false TSDoc in shipped content is a defect on its own terms — and under `skip-changeset` this correction would sit on `main` **unshipped**, the false sentence still going out, until some unrelated change happened to bump the package. ⛔ The changeset claims no behaviour change and says so in its own body: the in-memory branches are byte-identical, and this card's defect is not repaired here. ⛔ No label write was made in either round. The seat holds the labels endpoint, and with a changeset present there is no longer a label to apply. ## Acceptance notes Noted in passing, ⛔ not filed and ⛔ not addressed here: - **The three degradation sites in these two primitives log at `warn`.** AGENTS.md's degradation-log-level rule grades a durability degradation at `error` ("a write that claims to persist does not… nothing looks broken"), which is exactly this seam's shape — the door answered 201. The same section also says an `if (!service)` composition branch "is usually functional and belongs at `warn`", so the `else` arm is genuinely arguable; the `catch` arm around the `publish` call is the one the rule says it bites. `pnpm check:durability-log-level` does not see either, because `pkgSvc.publish` is not in `DURABILITY_CRITICAL_CALLEES`. Left alone: changing a log level is not in this ruling, and the ruling's own remedy is to stop hosts taking the branch at all. - **`dispatch-gates.mjs` reported STALE TREE** on both derivations (two of its own input scripts moved on `origin/main` while this branch worked). Discharged empirically rather than by merging: the command list was derived twice, before and after a fetch, and the two are byte-identical. - **Comment `5651690676` — the "may item 3 be delivered independently of item 1" question — returns 404** and was destroyed with the suspended `os-musk` identity. It could not be read, so it is recorded NOT MEASURED. This PR proceeds on the PM's reading that item 1 having landed resolves it by circumstance. --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5d3d1d commit fade3da

3 files changed

Lines changed: 256 additions & 11 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
`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).
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
- **⛔ 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.
12+
- **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.
13+
- `updatePackage`'s docblock points at the same note, since the ruling names both primitives.
14+
15+
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.

packages/metadata-protocol/src/durable-package.test.ts

Lines changed: 212 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,88 @@
77
// builder / Setup), which is exactly why those packages vanished on restart.
88
// These tests pin the fixed contract: version is defaulted (never skipped) and
99
// uninstall drops the durable row so packages don't resurrect at boot.
10+
//
11+
// #17676 ruling A' item 3 (decision batch #125 item 2, maintainer 「同意」
12+
// 2026-09-13) — `makeImpl()` gains the service-ABSENT composition, so the pin
13+
// can express the bug THAT card found: a writable package created through
14+
// `POST /api/v1/packages` on a host with no `package` service is registered
15+
// in-memory only, and after a restart the state splits three ways (Studio's
16+
// writable list is empty, the data route 404s, the published metadata is still
17+
// there). Before this, `makeImpl()` could only build the service-PRESENT host,
18+
// so no pin here could fail the way the card failed.
19+
//
20+
// ⭐ The service-ABSENT branch is NOT a bug to delete — ruling item 2 keeps it
21+
// as the documented degraded path for reduced hosts (`--preset minimal`, a host
22+
// that mounts no `package-registry` provider). What the ruling changes is which
23+
// hosts take it, and that half lives outside this package; these pins state
24+
// what the primitive does on each composition, either side of it.
1025

1126
import { describe, it, expect, vi } from 'vitest';
1227
import { ObjectStackProtocolImplementation } from './index.js';
1328

29+
/**
30+
* Compose a protocol implementation over a fake host.
31+
*
32+
* The fake `registry` mirrors the real `SchemaRegistry` package API by NAME
33+
* (`installPackage` / `getPackage` / `getAllPackages` / `updatePackageManifest`,
34+
* `packages/objectql/src/registry.ts`) so a pin written against it is a pin
35+
* about the primitive's real collaborator, not about an invented one.
36+
*/
1437
function makeImpl(overrides?: {
1538
publish?: (d: { manifest: unknown; metadata: unknown }) => Promise<unknown>;
1639
del?: (id: string) => Promise<unknown>;
1740
find?: (obj: string, q: unknown) => Promise<unknown[]>;
41+
/**
42+
* #17676 ruling A' item 3 — the service-ABSENT composition.
43+
*
44+
* `false` builds a services registry with NO `package` entry at all, which
45+
* is what a host that mounts no `PackageServicePlugin` hands the protocol.
46+
* `installPackage` / `updatePackage` then take their in-memory-only branch:
47+
* the registry write lands, nothing reaches `sys_packages`, and the process
48+
* is the only place the package exists.
49+
*/
50+
packageService?: false;
51+
/**
52+
* Manifests to replay into the fresh registry before the test runs — a
53+
* stand-in for the boot hydration `PackageServicePlugin.start()` performs
54+
* (`for (const rec of await packageService.list()) registry.installPackage(rec.manifest)`,
55+
* `packages/services/service-package/src/index.ts`). A host composed with
56+
* the rows a previous host persisted IS that process after a restart; a host
57+
* composed with none is a restart that found `sys_packages` empty.
58+
*/
59+
hydrate?: readonly { id: string; [key: string]: unknown }[];
1860
}) {
1961
const registryCalls: Array<{ manifest: any; settings: any }> = [];
20-
const engine = {
21-
registry: {
22-
installPackage: (manifest: any, settings: any) => {
23-
registryCalls.push({ manifest, settings });
24-
return { manifest, status: 'installed', enabled: true };
25-
},
62+
const installed = new Map<string, any>();
63+
const registry = {
64+
installPackage: (manifest: any, settings?: any) => {
65+
registryCalls.push({ manifest, settings });
66+
const pkg = { manifest, status: 'installed', enabled: true };
67+
installed.set(manifest.id, pkg);
68+
return pkg;
69+
},
70+
getPackage: (id: string) => installed.get(id),
71+
getAllPackages: () => [...installed.values()],
72+
updatePackageManifest: (id: string, patch: Record<string, unknown>) => {
73+
const pkg = installed.get(id);
74+
if (!pkg) return undefined;
75+
Object.assign(pkg.manifest, patch);
76+
return pkg;
2677
},
27-
find: overrides?.find ?? (async () => []),
2878
};
79+
const engine = { registry, find: overrides?.find ?? (async () => []) };
80+
// Seeded through the registry's own verb, exactly as the boot hydration does.
81+
for (const manifest of overrides?.hydrate ?? []) registry.installPackage(manifest);
82+
registryCalls.length = 0; // hydration is the fixture, not an observation
83+
2984
const publish = vi.fn(overrides?.publish ?? (async () => ({ success: true })));
3085
const del = vi.fn(overrides?.del ?? (async () => ({ success: true })));
31-
const services = new Map<string, any>([['package', { publish, delete: del }]]);
86+
const services = new Map<string, any>();
87+
if (overrides?.packageService !== false) {
88+
services.set('package', { publish, delete: del });
89+
}
3290
const impl = new ObjectStackProtocolImplementation(engine as any, () => services);
33-
return { impl, registryCalls, publish, del };
91+
return { impl, registryCalls, publish, del, registry, engine, services };
3492
}
3593

3694
describe('installPackage — durable persistence (#2532)', () => {
@@ -130,3 +188,148 @@ describe('deletePackage — uninstall cleanups (#2747)', () => {
130188
expect(res.cleanups[0].removed).toBe(2);
131189
});
132190
});
191+
192+
// ─────────────────────────────────────────────────────────────────────────────
193+
// #17676 ruling A' item 3 — the service-ABSENT composition, and the restart
194+
// split it exists to express.
195+
//
196+
// ⚠️ SCOPE, stated so a green run here is not over-read. Ruling item 5's
197+
// acceptance is that THREE probes agree after a restart — Studio's writable
198+
// list, `GET /api/v1/data/<ns>_<obj>` and
199+
// `GET /api/v1/meta/object/<ns>_<obj>/published`. Those are HTTP surfaces over
200+
// a booted composition; this is a unit tier over the protocol primitive, and a
201+
// unit tier cannot restart a server. What it CAN do is pin the one fact the
202+
// three probes disagree about — whether the package crosses the boundary at
203+
// all — at the seam that decides it. The registry reads below stand for the
204+
// first two probes (both are registry-backed: the dispatcher's
205+
// `/api/v1/packages` list and `getMetaItems({type:'package'})` feed Studio's
206+
// selector; the data route needs the object registered), and the `find` read
207+
// stands for the third (published `sys_metadata` rows, which no package state
208+
// gates). ⛔ Item 5 is NOT met by these pins.
209+
// ─────────────────────────────────────────────────────────────────────────────
210+
211+
describe("service-ABSENT host — the degraded path (#17676 ruling A' items 2/3)", () => {
212+
it('installPackage registers in memory, writes NOTHING durable, and says so', async () => {
213+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
214+
try {
215+
const { impl, registryCalls, publish, registry } = makeImpl({ packageService: false });
216+
217+
const res: any = await (impl as any).installPackage({
218+
manifest: { id: 'com.example.leave', name: '请假' },
219+
});
220+
221+
// The door still answers 201 — which is exactly why the card calls the
222+
// failure invisible until a restart.
223+
expect(res.package.status).toBe('installed');
224+
expect(registryCalls).toHaveLength(1);
225+
expect(registry.getPackage('com.example.leave')).toBeTruthy();
226+
// Durable half never ran: there was no service to run it.
227+
expect(publish).not.toHaveBeenCalled();
228+
// ⭐ Absence must be loud (AGENTS.md, Route & surface ownership §3): the
229+
// branch is allowed to degrade, never to degrade SILENTLY.
230+
const said = warn.mock.calls.map((c) => String(c[0]));
231+
expect(said.some((m) => m.includes("no 'package' service"))).toBe(true);
232+
expect(said.some((m) => m.includes('will not survive a restart'))).toBe(true);
233+
} finally {
234+
warn.mockRestore();
235+
}
236+
});
237+
238+
it('updatePackage degrades the same way — the ruling names BOTH primitives', async () => {
239+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
240+
try {
241+
const { impl, publish, registry } = makeImpl({
242+
packageService: false,
243+
hydrate: [{ id: 'com.example.leave', name: '请假', version: '0.1.0' }],
244+
});
245+
246+
const res: any = await (impl as any).updatePackage({
247+
packageId: 'com.example.leave',
248+
patch: { name: '请假 v2' },
249+
});
250+
251+
expect(res.package.manifest.name).toBe('请假 v2');
252+
expect(registry.getPackage('com.example.leave').manifest.name).toBe('请假 v2');
253+
expect(publish).not.toHaveBeenCalled();
254+
const said = warn.mock.calls.map((c) => String(c[0]));
255+
expect(said.some((m) => m.includes("no 'package' service"))).toBe(true);
256+
expect(said.some((m) => m.includes('will not survive a restart'))).toBe(true);
257+
} finally {
258+
warn.mockRestore();
259+
}
260+
});
261+
});
262+
263+
describe("the restart split this card reported (#17676 ruling A' items 3/5)", () => {
264+
/** The durable `sys_packages` table — the only package state a restart keeps. */
265+
const makeSysPackages = () => {
266+
const rows = new Map<string, { id: string; [key: string]: unknown }>();
267+
return {
268+
rows,
269+
publish: async (d: any) => {
270+
rows.set(d.manifest.id, d.manifest);
271+
return { success: true };
272+
},
273+
};
274+
};
275+
276+
/**
277+
* The published object metadata, which lives in `sys_metadata` and is gated
278+
* by nothing the package registry holds — that asymmetry IS the card's
279+
* contradiction: "the published metadata outlives both the package and the
280+
* runtime registration".
281+
*/
282+
const publishedMetadata = [{ name: 'leave_request', _packageId: 'com.example.leave' }];
283+
284+
it('service ABSENT ⇒ the package does not cross the boundary, its metadata does', async () => {
285+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
286+
try {
287+
const sysPackages = makeSysPackages();
288+
289+
// ── process 1 ─────────────────────────────────────────────────────────
290+
const before = makeImpl({ packageService: false, publish: sysPackages.publish });
291+
await (before.impl as any).installPackage({
292+
manifest: { id: 'com.example.leave', name: '请假' },
293+
});
294+
expect(before.registry.getPackage('com.example.leave')).toBeTruthy();
295+
expect([...sysPackages.rows.keys()]).toEqual([]);
296+
297+
// ── restart ───────────────────────────────────────────────────────────
298+
const after = makeImpl({
299+
packageService: false,
300+
hydrate: [...sysPackages.rows.values()],
301+
find: async () => publishedMetadata,
302+
});
303+
304+
// Probe 1 — Studio: `No writable packages yet`.
305+
expect(after.registry.getAllPackages()).toEqual([]);
306+
// Probe 2 — the data route: `Object '<ns>_<obj>' is not registered`.
307+
expect(after.registry.getPackage('com.example.leave')).toBeUndefined();
308+
// Probe 3 — published metadata: still 200. The three DISAGREE.
309+
expect(await after.engine.find('sys_metadata', {})).toEqual(publishedMetadata);
310+
} finally {
311+
warn.mockRestore();
312+
}
313+
});
314+
315+
it('CONTROL — service PRESENT ⇒ all three agree after the same restart', async () => {
316+
const sysPackages = makeSysPackages();
317+
318+
const before = makeImpl({ publish: sysPackages.publish });
319+
await (before.impl as any).installPackage({
320+
manifest: { id: 'com.example.leave', name: '请假' },
321+
});
322+
// The durable half ran, so the restart has something to replay.
323+
expect([...sysPackages.rows.keys()]).toEqual(['com.example.leave']);
324+
325+
const after = makeImpl({
326+
publish: sysPackages.publish,
327+
hydrate: [...sysPackages.rows.values()],
328+
find: async () => publishedMetadata,
329+
});
330+
331+
expect(after.registry.getAllPackages()).toHaveLength(1);
332+
expect(after.registry.getPackage('com.example.leave')).toBeTruthy();
333+
expect(await after.engine.find('sys_metadata', {})).toEqual(publishedMetadata);
334+
});
335+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22325,8 +22325,31 @@ export class ObjectStackProtocolImplementation implements
2232522325
* rows back into the registry on boot).
2232622326
*
2232722327
* The DB write is best-effort and non-fatal: when the `package` service is
22328-
* absent (e.g. the `marketplace` capability is off) the package is still
22329-
* registered in-memory and visible for the lifetime of the process.
22328+
* absent the package is still registered in-memory and visible for the
22329+
* lifetime of the process — and that in-memory-only branch STAYS, as the
22330+
* documented degraded path for reduced hosts (#17676 ruling A' item 2,
22331+
* decision batch #125 item 2). ⛔ It is not a bug to delete: a host that
22332+
* mounts no provider (`objectstack serve --preset minimal`, a metadata-only
22333+
* embedding) must still be able to install a package for the life of its
22334+
* process, and the `warn` below is what keeps the degradation from being
22335+
* silent.
22336+
*
22337+
* Which capability OWNS the service is no longer `marketplace`: ruling A'
22338+
* item 1 split the persistence half — the `sys_packages` container and the
22339+
* boot hydration that replays it — out under its own always-on token
22340+
* `package-registry` (`PLATFORM_ALWAYS_ON_CAPABILITIES`,
22341+
* `packages/spec/src/kernel/platform-capabilities.ts`), leaving
22342+
* `marketplace` naming only the optional catalogue / browsing half. ⚠️ The
22343+
* runtime half of that split is NOT landed: measured on `origin/main` at
22344+
* c334ba0f3a, `Serve.CAPABILITY_PROVIDERS`
22345+
* (`packages/cli/src/commands/serve.ts`) keys `marketplace` and does not key
22346+
* `package-registry`, so the always-on token is force-appended to every
22347+
* app's `requires` and then resolves to no provider — silently, because the
22348+
* resolver only warns for tokens outside the vocabulary. ⇒ on a stock
22349+
* `objectstack dev` boot of an app that does not itself declare
22350+
* `requires: ['marketplace']`, this branch is still the one taken, which is
22351+
* the defect #17676 reports. Recorded here rather than worked around: the
22352+
* fix belongs to the capability resolver, not to this primitive.
2233022353
*
2233122354
* [#19277] `request.enableOnInstall` is HONOURED here, under the same rule
2233222355
* the HTTP door implements — 「缺省 = 保持,有旗 = 设置」: `true` enables,
@@ -22469,6 +22492,10 @@ export class ObjectStackProtocolImplementation implements
2246922492
* service so the edit survives a restart. Persistence is best-effort and
2247022493
* non-fatal (matching `installPackage`): the registry write already
2247122494
* succeeded, so a persist failure is logged, never thrown.
22495+
*
22496+
* The service-absent branch below is the same documented degraded path
22497+
* #17676 ruling A' item 2 keeps — see `installPackage`'s note for which
22498+
* capability owns the service and for the measured state of that split.
2247222499
*/
2247322500
async updatePackage(request: {
2247422501
packageId: string;

0 commit comments

Comments
 (0)