From eaa68fb866939adaff5cc02b682e298bdb42510c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:19:09 +0000 Subject: [PATCH 1/7] feat(spec): declare the served disableable package verdict beside writable Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzWzEjBsCfzgaacoEm --- .../spec/src/api/package-api-assembled.zod.ts | 13 +++++- packages/spec/src/api/package-api.test.ts | 41 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/api/package-api-assembled.zod.ts b/packages/spec/src/api/package-api-assembled.zod.ts index b6c5a535693..a90d936dd3f 100644 --- a/packages/spec/src/api/package-api-assembled.zod.ts +++ b/packages/spec/src/api/package-api-assembled.zod.ts @@ -157,13 +157,24 @@ export type AssembledInstalledPackageParsed = z.infer { }); }); +// [#28318] The disable door's verdict, served beside `writable` and declared the +// same way: on both stage branches of the served row, on neither record schema. +// The two verdicts are independent statements — a module of an installed app is +// `disableable: true` while `writable: false` — so neither is required to follow +// the other. +describe('the served installed-package row declares `disableable`', () => { + it('requires a boolean `disableable` on either stage', () => { + for (const row of [GLOB_ROW, ASSEMBLED_ROW]) { + expect(InstalledPackageAtEitherStageSchema.safeParse(row).success).toBe(true); + const { disableable: _omitted, ...bare } = row; + const verdict = InstalledPackageAtEitherStageSchema.safeParse(bare); + expect(verdict.success).toBe(false); + expect(InstalledPackageAtEitherStageSchema.safeParse({ ...row, disableable: 'yes' }).success).toBe(false); + } + }); + + it('declares it on BOTH stage branches, beside `writable`', () => { + const branches = InstalledPackageAtEitherStageSchema.options; + expect(branches).toHaveLength(2); + for (const branch of branches) { + expect(Object.keys(branch.shape)).toEqual(expect.arrayContaining(['writable', 'disableable'])); + } + }); + + it('keeps `disableable: true` beside `writable: false` — neither verdict is derived from the other', () => { + const parsed = InstalledPackageAtEitherStageSchema.parse({ ...GLOB_ROW, writable: false, disableable: true }); + expect(parsed).toMatchObject({ writable: false, disableable: true }); + }); + + it('is not part of the registry RECORD: the record schemas strip it', () => { + expect(InstalledPackageSchema.parse(GLOB_ROW)).not.toHaveProperty('disableable'); + expect(AssembledInstalledPackageSchema.parse(ASSEMBLED_ROW)).not.toHaveProperty('disableable'); + }); +}); + describe('`InstalledPackageAtEitherStageSchema` admits both stages and NOTHING else', () => { it('parses the authoring row', () => { expect(InstalledPackageAtEitherStageSchema.safeParse(GLOB_ROW).success).toBe(true); From 666ad15238e0b1ba171e586fdd0ebf1b9fb4f980 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:24:45 +0000 Subject: [PATCH 2/7] docs(spec): regenerate package-api-assembled reference for disableable Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzWzEjBsCfzgaacoEm --- content/docs/references/api/package-api-assembled.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index fbbd805ff83..e79e8497e41 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -211,6 +211,7 @@ Get installed package response | **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | | **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | | **writable** | `boolean` | ✅ | Whether this server lets an org author into and manage this package (a writable base); false for a read-only package. Computed per read by the server's own predicate, never stored | +| **disableable** | `boolean` | ✅ | Whether this server lets an org disable this package: the disable door's own verdict, true for a writable base and for a module of an installed app, false for every other read-only package. A client offers Disable / Enable from it, never from writable. Computed per read by the server's own predicate, never stored | ### Nested Shape: `GetInstalledPackageResponse.data[option 2]` @@ -230,6 +231,7 @@ Get installed package response | **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | | **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | | **writable** | `boolean` | ✅ | Whether this server lets an org author into and manage this package (a writable base); false for a read-only package. Computed per read by the server's own predicate, never stored | +| **disableable** | `boolean` | ✅ | Whether this server lets an org disable this package: the disable door's own verdict, true for a writable base and for a module of an installed app, false for every other read-only package. A client offers Disable / Enable from it, never from writable. Computed per read by the server's own predicate, never stored | --- @@ -262,6 +264,7 @@ This schema accepts one of the following structures: | **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | | **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | | **writable** | `boolean` | ✅ | Whether this server lets an org author into and manage this package (a writable base); false for a read-only package. Computed per read by the server's own predicate, never stored | +| **disableable** | `boolean` | ✅ | Whether this server lets an org disable this package: the disable door's own verdict, true for a writable base and for a module of an installed app, false for every other read-only package. A client offers Disable / Enable from it, never from writable. Computed per read by the server's own predicate, never stored | ### Nested Shape: `InstalledPackageAtEitherStage[option 1].manifest` @@ -325,6 +328,7 @@ This schema accepts one of the following structures: | **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | | **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | | **writable** | `boolean` | ✅ | Whether this server lets an org author into and manage this package (a writable base); false for a read-only package. Computed per read by the server's own predicate, never stored | +| **disableable** | `boolean` | ✅ | Whether this server lets an org disable this package: the disable door's own verdict, true for a writable base and for a module of an installed app, false for every other read-only package. A client offers Disable / Enable from it, never from writable. Computed per read by the server's own predicate, never stored | ### Nested Shape: `InstalledPackageAtEitherStage[option 2].manifest` From 0c17a1eb4635b01009ed2f96044ade9469ced085 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:26:55 +0000 Subject: [PATCH 3/7] feat(runtime): serve the disable door's verdict as disableable on the package read doors Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzWzEjBsCfzgaacoEm --- .../packages-disableable-verdict.test.ts | 281 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 53 +++- 2 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 packages/runtime/src/domains/packages-disableable-verdict.test.ts diff --git a/packages/runtime/src/domains/packages-disableable-verdict.test.ts b/packages/runtime/src/domains/packages-disableable-verdict.test.ts new file mode 100644 index 00000000000..addfe7ea654 --- /dev/null +++ b/packages/runtime/src/domains/packages-disableable-verdict.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/packages` read doors carry the disable door's OWN verdict, `disableable` + * (#28318), beside `writable`. + * + * ## The defect this pins against + * + * The disable door (`PATCH /packages/:id/disable`) accepts a module of an + * installed app (#28117): a `type: 'module'` package that directly depends on + * an installed `type: 'app'` package, outside a `system` / `cloud` scope. Such + * a module is read-only for authoring, so its row says `writable: false`, and + * the Console offered Disable only from `writable`. The server accepted a + * disable no client could offer, and the answer was nowhere on the wire. + * + * ## What is asserted + * + * - The verdict per shape, on the list door and the detail door: the suite + * module is `disableable: true` while `writable: false`; the controls + * (`system` / `cloud` scope, no installed app, a non-module package) answer + * what the door answers. + * - AGREEMENT: on the whole fixture set, row by row, the served `disableable` + * equals the door's accept (200, package disabled) or refusal (422 + * `WRITABLE_PACKAGE_REQUIRED`, package still enabled). This is the pin that + * holds the two readers to one predicate: a served constant reds it. + * - The served body parses through its declared contract with both verdicts + * kept, and the registry's own records never gain the key. + * + * A REAL {@link SchemaRegistry} (the records are what `installPackage` + * produces) through the real dispatcher, with `manifests` standing in for the + * engine's boot map exactly as `packages-writable-verdict.test.ts` does. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from '../http-dispatcher.js'; +import { GetInstalledPackageResponseSchema, ListInstalledPackagesResponseSchema } from '@objectstack/spec/api-assembled'; + +// ── the fixture set ────────────────────────────────────────────────────────── +const APP = 'com.acme.suite'; +/** The subject: a module of the installed app, booted with it. */ +const SUITE_MODULE = 'com.acme.suite.orders'; +/** The same tie, installed from the marketplace with no scope key on the row. */ +const SUITE_MODULE_SCOPELESS = 'com.acme.suite.billing'; +const SYSTEM_MODULE = 'com.acme.suite.platform_bits'; +const CLOUD_MODULE = 'com.acme.suite.cloud_bits'; +/** A module that declares no dependency at all. */ +const ORPHAN_MODULE = 'com.acme.orphan'; +/** A module whose declared app is not installed here. */ +const MODULE_OF_ABSENT_APP = 'com.acme.ghost.reports'; +const ABSENT_APP = 'com.acme.ghost'; +/** A module whose installed dependency is a plugin, not an app. */ +const PLUGIN = 'com.acme.connector'; +const MODULE_OF_PLUGIN = 'com.acme.connector.extras'; +/** Non-module packages: the door answers them by `writable` alone. */ +const DB_BASE = 'com.acme.mybase'; +const SYSTEM_BASE = 'com.objectstack.platform'; + +function manifest(id: string, extra: Record = {}) { + return { id, name: id, version: '1.0.0', ...extra } as any; +} + +const onApp = { dependencies: { [APP]: '^1.0.0' } }; + +/** id → [manifest, install source, booted into `engine.manifests`]. */ +const FIXTURES: Record = { + [APP]: [manifest(APP, { type: 'app', scope: 'project' }), 'code', true], + [SUITE_MODULE]: [manifest(SUITE_MODULE, { type: 'module', scope: 'project', ...onApp }), 'code', true], + [SUITE_MODULE_SCOPELESS]: [manifest(SUITE_MODULE_SCOPELESS, { type: 'module', ...onApp }), 'marketplace', true], + [SYSTEM_MODULE]: [manifest(SYSTEM_MODULE, { type: 'module', scope: 'system', ...onApp }), 'code', true], + [CLOUD_MODULE]: [manifest(CLOUD_MODULE, { type: 'module', scope: 'cloud', ...onApp }), 'code', true], + [ORPHAN_MODULE]: [manifest(ORPHAN_MODULE, { type: 'module', scope: 'project' }), 'code', true], + [MODULE_OF_ABSENT_APP]: [ + manifest(MODULE_OF_ABSENT_APP, { type: 'module', scope: 'project', dependencies: { [ABSENT_APP]: '^1.0.0' } }), + 'code', + true, + ], + [PLUGIN]: [manifest(PLUGIN, { type: 'plugin', scope: 'project' }), 'code', true], + [MODULE_OF_PLUGIN]: [ + manifest(MODULE_OF_PLUGIN, { type: 'module', scope: 'project', dependencies: { [PLUGIN]: '^1.0.0' } }), + 'code', + true, + ], + [DB_BASE]: [manifest(DB_BASE, { type: 'app' }), 'authored', false], + [SYSTEM_BASE]: [manifest(SYSTEM_BASE, { type: 'app', scope: 'system' }), 'code', false], +}; + +/** What the door is expected to answer for each row — the verdict the row must serve. */ +const EXPECTED: Record = { + [APP]: { writable: false, disableable: false }, + [SUITE_MODULE]: { writable: false, disableable: true }, + [SUITE_MODULE_SCOPELESS]: { writable: false, disableable: true }, + [SYSTEM_MODULE]: { writable: false, disableable: false }, + [CLOUD_MODULE]: { writable: false, disableable: false }, + [ORPHAN_MODULE]: { writable: false, disableable: false }, + [MODULE_OF_ABSENT_APP]: { writable: false, disableable: false }, + [PLUGIN]: { writable: false, disableable: false }, + [MODULE_OF_PLUGIN]: { writable: false, disableable: false }, + [DB_BASE]: { writable: true, disableable: true }, + [SYSTEM_BASE]: { writable: false, disableable: false }, +}; + +function make() { + const registry = new SchemaRegistry({ logLevel: 'silent' } as any); + const manifests = new Map(); + for (const [id, [m, source, booted]] of Object.entries(FIXTURES)) { + registry.installPackage(m, source); + if (booted) manifests.set(id, m); + } + const objectql = { registry, manifests }; + const kernel: any = { + context: { getService: (name: string) => (name === 'objectql' ? objectql : null) }, + }; + return { dispatcher: new HttpDispatcher(kernel), registry }; +} + +/** Holds `manage_metadata` and the ADR-0106 D4 read set; the caller gate is not this file's subject. */ +const admin = (): any => ({ + request: {}, + environmentId: 'pkg-disableable-verdict-test', + executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['manage_metadata', 'studio.access'], tenantId: 'org_acme' }, +}); + +type Row = { manifest: { id: string }; writable?: boolean; disableable?: boolean } & Record; + +async function list(dispatcher: HttpDispatcher, query: Record = {}): Promise { + const r = await dispatcher.handlePackages('/', 'GET', undefined, query, admin()); + expect(r.response?.status ?? 200).toBe(200); + return r.response?.body?.data?.packages as Row[]; +} + +const byId = (rows: Row[], id: string): Row => { + const row = rows.find((p) => p.manifest?.id === id); + if (!row) throw new Error(`row ${id} missing from listing`); + return row; +}; + +// `setPackageDisabled` writes a real JSON file under OS_HOME on the accept path. +let home: string; +let priorHome: string | undefined; +beforeAll(() => { + priorHome = process.env.OS_HOME; + home = mkdtempSync(join(tmpdir(), 'os-pkg-disableable-')); + process.env.OS_HOME = home; +}); +afterAll(() => { + if (priorHome === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = priorHome; + rmSync(home, { recursive: true, force: true }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 1. The verdict, per shape — both read doors +// ══════════════════════════════════════════════════════════════════════════════ + +describe('GET /packages — every row carries the disable door\'s verdict (#28318)', () => { + it('a module of an installed app is disableable: true while writable: false', async () => { + const rows = await list(make().dispatcher); + for (const id of [SUITE_MODULE, SUITE_MODULE_SCOPELESS]) { + expect(byId(rows, id)).toMatchObject({ writable: false, disableable: true }); + } + }); + + it('CONTROL: a module of the installed app in a system or cloud scope is disableable: false', async () => { + const rows = await list(make().dispatcher); + expect(byId(rows, SYSTEM_MODULE).disableable).toBe(false); + expect(byId(rows, CLOUD_MODULE).disableable).toBe(false); + }); + + it('CONTROL: a module with no installed app is disableable: false', async () => { + const rows = await list(make().dispatcher); + expect(byId(rows, ORPHAN_MODULE).disableable).toBe(false); + expect(byId(rows, MODULE_OF_ABSENT_APP).disableable).toBe(false); + // An installed dependency that is a plugin, not an app, is no tie either. + expect(byId(rows, MODULE_OF_PLUGIN).disableable).toBe(false); + }); + + it('CONTROL: a non-module package is disableable exactly when it is writable (the door\'s answer)', async () => { + const rows = await list(make().dispatcher); + for (const id of [APP, PLUGIN, DB_BASE, SYSTEM_BASE]) { + const row = byId(rows, id); + expect(row.disableable).toBe(row.writable); + } + expect(byId(rows, DB_BASE).disableable).toBe(true); + expect(byId(rows, APP).disableable).toBe(false); + }); + + it('serves the expected pair on every row of the fixture set', async () => { + const rows = await list(make().dispatcher); + expect(Object.fromEntries(rows.map((r) => [r.manifest.id, { writable: r.writable, disableable: r.disableable }]))) + .toEqual(EXPECTED); + }); + + it('carries the verdict through the ?type= filter too', async () => { + const rows = await list(make().dispatcher, { type: 'module' }); + expect(byId(rows, SUITE_MODULE).disableable).toBe(true); + expect(byId(rows, SYSTEM_MODULE).disableable).toBe(false); + }); + + it('the detail door serves the same verdict as the list door, row by row', async () => { + const { dispatcher } = make(); + const rows = await list(dispatcher); + for (const id of Object.keys(FIXTURES)) { + const r = await dispatcher.handlePackages(`/${encodeURIComponent(id)}`, 'GET', undefined, {}, admin()); + expect(r.response?.status ?? 200).toBe(200); + expect(r.response?.body?.data?.disableable).toBe(byId(rows, id).disableable); + expect(r.response?.body?.data?.writable).toBe(byId(rows, id).writable); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 2. AGREEMENT — the served verdict IS the door's answer +// ══════════════════════════════════════════════════════════════════════════════ + +describe('PATCH /packages/:id/disable answers exactly what the row served (#28318)', () => { + for (const id of Object.keys(FIXTURES)) { + it(`${id}: served disableable equals the door's accept / refuse`, async () => { + // A fresh registry per row: an accepted disable changes the state the + // next row would be read against. + const { dispatcher, registry } = make(); + const served = byId(await list(dispatcher), id).disableable; + expect(typeof served).toBe('boolean'); + + const r = await dispatcher.handlePackages(`/${encodeURIComponent(id)}/disable`, 'PATCH', {}, {}, admin()); + const accepted = (r.response?.status ?? 200) === 200; + expect(accepted).toBe(served); + if (accepted) { + expect(registry.getPackage(id)?.enabled).toBe(false); + } else { + expect(r.response?.status).toBe(422); + expect(r.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + expect(registry.getPackage(id)?.enabled).toBe(true); + } + }); + } + + it('non-vacuity: the fixture set holds both answers, and a disableable row that is not writable', () => { + const verdicts = Object.values(EXPECTED); + expect(verdicts.some((v) => v.disableable)).toBe(true); + expect(verdicts.some((v) => !v.disableable)).toBe(true); + expect(verdicts.some((v) => v.disableable && !v.writable)).toBe(true); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 3. The contract, and nothing stored +// ══════════════════════════════════════════════════════════════════════════════ + +describe('GET /packages — the verdict is declared, computed and never stored (#28318)', () => { + it('the served bodies parse through their declared contracts with both verdicts kept', async () => { + const { dispatcher } = make(); + const r = await dispatcher.handlePackages('/', 'GET', undefined, {}, admin()); + const parsed = ListInstalledPackagesResponseSchema.parse(r.response?.body); + expect(Object.fromEntries(parsed.data.packages.map((row) => [row.manifest.id, { writable: row.writable, disableable: row.disableable }]))) + .toEqual(EXPECTED); + const detail = await dispatcher.handlePackages(`/${SUITE_MODULE}`, 'GET', undefined, {}, admin()); + expect(GetInstalledPackageResponseSchema.parse(detail.response?.body).data) + .toMatchObject({ writable: false, disableable: true }); + }); + + it('the registry\'s own records never gain a `disableable` key (spread copy, no mutation)', async () => { + const { dispatcher, registry } = make(); + await list(dispatcher); + await dispatcher.handlePackages(`/${SUITE_MODULE}`, 'GET', undefined, {}, admin()); + for (const record of registry.getAllPackages() as unknown as Row[]) { + expect('disableable' in record).toBe(false); + } + }); + + it('the verdict follows the running engine: uninstall the app and its module stops being disableable', async () => { + const { dispatcher, registry } = make(); + expect(byId(await list(dispatcher), SUITE_MODULE).disableable).toBe(true); + expect(registry.uninstallPackage(APP)).toBe(true); + expect(registry.getPackage(APP)).toBeUndefined(); + expect(byId(await list(dispatcher), SUITE_MODULE).disableable).toBe(false); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 53825564f8b..24535658be0 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -630,9 +630,8 @@ function requireWritablePackage( * ⛔ A reading at the disable door, never a change to {@link isWritablePackage}: * `writable` keeps meaning "may author into", and stays `false` for a module, * so edit, publish, revert, delete and overwrite still refuse it - * (`WRITABLE_PACKAGE_REQUIRED`, ADR-0070 D2). The served row does not carry - * this verdict yet: it needs a key in `package-api-assembled.zod.ts`'s - * `SERVED_ROW_VERDICTS`, a spec-lane edit. + * (`WRITABLE_PACKAGE_REQUIRED`, ADR-0070 D2). The door asks it through + * {@link isDisableablePackage}, which the read doors serve as `disableable`. */ function isDisableableAppModule(engine: unknown, id: string): boolean { const registry = (engine as PackageRowRegistryHost | null | undefined)?.registry; @@ -652,6 +651,23 @@ interface PackageRowRegistryHost { }; } +/** + * [#28318] The disable door's verdict on `id`: `PATCH /packages/:id/disable` + * accepts the package (as far as the PACKAGE goes) exactly when this answers + * `true` — a writable base ({@link isWritablePackage}) or a module of an + * installed app ({@link isDisableableAppModule}). + * + * ONE predicate, asked by both readers: the door, before it switches the + * package off, and {@link withDisableableVerdict}, which serves the same answer + * as the row's `disableable` so a client offers Disable only where the door + * will accept it (#8146: one answer, served by the server). ⛔ Never re-spell + * the rule at either site. The caller's own permission and a catalog wall are + * not package facts: the door still asks them, and the verdict does not. + */ +function isDisableablePackage(engine: unknown, id: string | undefined): boolean { + return (id !== undefined && isDisableableAppModule(engine, id)) || isWritablePackage(engine, id); +} + /** * [#14451] `POST /packages/:id/duplicate` — the SOURCE must be a BASE. * @@ -832,6 +848,24 @@ function withWritableVerdict( + engine: unknown, + row: T, +): T & { disableable: boolean } { + const manifestId = row?.manifest?.id; + const id = typeof manifestId === 'string' ? manifestId : (typeof row?.id === 'string' ? row.id : undefined); + return { ...row, disableable: isDisableablePackage(engine, id) }; +} + /** * What `?version=` on `GET /packages/:id` asked for (#17416). * @@ -1315,8 +1349,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // `manifest.id`, which the projection keeps, so it is indifferent to // running second. Pinned in `packages-serializable-response.test.ts` // so a future reorder is a red test rather than a missing field. + // [#28318] `disableable` (see `withDisableableVerdict`) is stamped + // the same way, after the projection, for the same reason. const rows = packages.map( - (p: any) => withWritableVerdict(qlService, toPackageResponse(p) as any), + (p: any) => withDisableableVerdict(qlService, withWritableVerdict(qlService, toPackageResponse(p) as any)), ); // [#16781] `hasMore` is REQUIRED by // `ListInstalledPackagesResponseSchema` and this door did not send @@ -1785,8 +1821,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // installed app is the customer admin's to switch off // ({@link isDisableableAppModule}). Every other door below still // asks `requireWritablePackage` alone, so the module stays - // read-only for authoring. - const readOnly = isDisableableAppModule(qlService, id) + // read-only for authoring. [#28318] Asked through + // `isDisableablePackage`, the predicate the read doors serve as + // `disableable`, so the row's verdict and this door cannot differ. + const readOnly = isDisableablePackage(qlService, id) ? null : requireWritablePackage(deps, qlService, id, 'disable'); if (readOnly) return readOnly; @@ -2472,9 +2510,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin } // [#14375] Same verdict, same predicate as the list door — and // [#14309] the same project-then-stamp order, for the same reason. + // [#28318] `disableable` likewise, beside `writable`. return { handled: true, - response: deps.success(withWritableVerdict(qlService, toPackageResponse(pkg) as any)), + response: deps.success(withDisableableVerdict(qlService, withWritableVerdict(qlService, toPackageResponse(pkg) as any))), }; } From b581d4193b28835500c896b58c3ba32e91c0eb65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:31:53 +0000 Subject: [PATCH 4/7] test(runtime): pin 5 sets aside the disableable verdict; hyphenated fixture ids Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzWzEjBsCfzgaacoEm --- .../src/domains/packages-disableable-verdict.test.ts | 4 ++-- .../runtime/src/domains/packages-writable-verdict.test.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/domains/packages-disableable-verdict.test.ts b/packages/runtime/src/domains/packages-disableable-verdict.test.ts index addfe7ea654..2800325e004 100644 --- a/packages/runtime/src/domains/packages-disableable-verdict.test.ts +++ b/packages/runtime/src/domains/packages-disableable-verdict.test.ts @@ -45,8 +45,8 @@ const APP = 'com.acme.suite'; const SUITE_MODULE = 'com.acme.suite.orders'; /** The same tie, installed from the marketplace with no scope key on the row. */ const SUITE_MODULE_SCOPELESS = 'com.acme.suite.billing'; -const SYSTEM_MODULE = 'com.acme.suite.platform_bits'; -const CLOUD_MODULE = 'com.acme.suite.cloud_bits'; +const SYSTEM_MODULE = 'com.acme.suite.platform-bits'; +const CLOUD_MODULE = 'com.acme.suite.cloud-bits'; /** A module that declares no dependency at all. */ const ORPHAN_MODULE = 'com.acme.orphan'; /** A module whose declared app is not installed here. */ diff --git a/packages/runtime/src/domains/packages-writable-verdict.test.ts b/packages/runtime/src/domains/packages-writable-verdict.test.ts index 5bdfa1164d1..7f62ccbc715 100644 --- a/packages/runtime/src/domains/packages-writable-verdict.test.ts +++ b/packages/runtime/src/domains/packages-writable-verdict.test.ts @@ -199,14 +199,17 @@ describe('GET /packages/:id — the detail door carries the same verdict (#14375 // ══════════════════════════════════════════════════════════════════════════════ describe('GET /packages — the verdict is additive and computed, never stored (#14375)', () => { - it('pin 5: apart from `writable`, every row deep-equals the registry\'s own record', async () => { + it('pin 5: apart from the served verdicts, every row deep-equals the registry\'s own record', async () => { const { dispatcher, registry } = make(); const rows = await list(dispatcher); const records = registry.getAllPackages() as unknown as Row[]; expect(rows).toHaveLength(records.length); for (const record of records) { - const { writable, ...rest } = byId(rows, record.manifest.id); + // [#28318] `disableable` is the other served verdict, pinned in + // `packages-disableable-verdict.test.ts`; set aside here by name. + const { writable, disableable, ...rest } = byId(rows, record.manifest.id); expect(typeof writable).toBe('boolean'); + expect(typeof disableable).toBe('boolean'); // Built from the pre-change shape (the registry record itself), not // re-derived from the new code. expect(rest).toEqual(record); From f559c37c847ee6a99c219cfd738805f6ab8df0e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:33:29 +0000 Subject: [PATCH 5/7] chore(changeset): disableable served package verdict Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzWzEjBsCfzgaacoEm --- .changeset/28318-served-disableable-verdict.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/28318-served-disableable-verdict.md diff --git a/.changeset/28318-served-disableable-verdict.md b/.changeset/28318-served-disableable-verdict.md new file mode 100644 index 00000000000..ba0bd87f75c --- /dev/null +++ b/.changeset/28318-served-disableable-verdict.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +--- + +feat(spec,runtime): every served package row carries `disableable`, the disable door's own verdict, beside `writable` + +Clause-②: yes (widening) +Metadata-protocol: added + +- **New key.** `SERVED_ROW_VERDICTS` in `@objectstack/spec/api-assembled` declares `disableable: boolean` on both stage branches of `InstalledPackageAtEitherStageSchema`, so `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve it on every row. It answers "may an org disable this package here?": `true` for a writable base and for a module of an installed app (a `type: 'module'` package that directly depends on an installed `type: 'app'` package, outside a `system` / `cloud` scope), `false` for every other read-only package. +- **One predicate.** The runtime computes the served value with the same predicate `PATCH /api/v1/packages/:id/disable` asks before it switches a package off, so the row and the door cannot disagree. The caller's own permission and a catalog wall are still asked at the door; the verdict is about the package only. +- **`writable` is unchanged.** A module of an installed app is `disableable: true` and `writable: false`: it may be switched off, never authored into. A client offers Disable / Enable from `disableable`, and authoring from `writable`. +- **Who sees it.** A customer admin on the Packages page: once the Console reads this key, it can offer Disable on a suite module the server already accepts. From c2a97b40c6a17c068f259bd6e7c7e5fe3258a7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:53:47 +0000 Subject: [PATCH 6/7] test(runtime): register disableable in the door-computed stamp ledger Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzWzEjBsCfzgaacoEm --- .../src/domains/package-door-producer-key-carry.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts index d1e2d74d5ad..7a12cd8fc45 100644 --- a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts +++ b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts @@ -97,12 +97,16 @@ const DELIBERATELY_NOT_SERVED: readonly string[] = []; * Not a record field: it is a property of the running engine, recomputed per * read and never stored. `isWritablePackage` is the same predicate the * authoring and lifecycle gates use. + * - `disableable` — the disable door's verdict (`withDisableableVerdict`, + * #28318). Not a record field either, for the same reason: it is computed + * per read by `isDisableablePackage`, the predicate `PATCH + * /packages/:id/disable` asks, and never stored. * * Compared by SET EQUALITY against the measured `served − record`, so both * directions are loud: a stamp that stops reaching the wire (the reorder this * card is about) and a stamp that arrives without a decision. */ -const DOOR_COMPUTED_STAMPS: readonly string[] = ['writable']; +const DOOR_COMPUTED_STAMPS: readonly string[] = ['writable', 'disableable']; /** Authenticated caller holding the ADR-0106 D4 read capability. */ const reader = (): any => ({ From 138dbe7f222d07be15597922abcf9594a6d5f855 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:18:27 +0000 Subject: [PATCH 7/7] test(client): served-row type fixtures carry disableable beside writable Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzWzEjBsCfzgaacoEm --- packages/client/src/return-type-precision.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index e5819f272ff..4074d749f24 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -645,10 +645,11 @@ export async function returnTypePrecisionPins12034(): Promise { void (await scoped.packages.get('com.acme.crm')).package; } -// A SERVED row is the registry record plus the read door's own verdict, -// `writable` (#23972 declared it on the served row, not on the record). -declare const authoringRow: InstalledPackage & { writable: boolean }; -declare const assembledRow: AssembledInstalledPackage & { writable: boolean }; +// A SERVED row is the registry record plus the read door's own verdicts, +// `writable` (#23972 declared it on the served row, not on the record) and +// `disableable` (#28318, declared beside it). +declare const authoringRow: InstalledPackage & { writable: boolean; disableable: boolean }; +declare const assembledRow: AssembledInstalledPackage & { writable: boolean; disableable: boolean }; /** * [#17536] The four `/packages` READ members declare the stage the DOOR is