From 0e51ce3060daf857999ceb6fcb7107e76cfb3340 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 01:54:59 +0000 Subject: [PATCH 1/3] docs(runtime,metadata-protocol): the scope-less booted row is a marketplace / offline import, not a multi-package artifact's module Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../get-meta-items-package-writable.test.ts | 15 ++++++--- packages/metadata-protocol/src/protocol.ts | 11 +++++-- .../domains/packages-writable-verdict.test.ts | 33 +++++++++++++------ packages/runtime/src/domains/packages.ts | 16 ++++++--- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/packages/metadata-protocol/src/get-meta-items-package-writable.test.ts b/packages/metadata-protocol/src/get-meta-items-package-writable.test.ts index 961b4912b4..4e5fd2afca 100644 --- a/packages/metadata-protocol/src/get-meta-items-package-writable.test.ts +++ b/packages/metadata-protocol/src/get-meta-items-package-writable.test.ts @@ -10,8 +10,13 @@ * enforce — #8146's "one answer to 'is this package writable?'" applied to the * read side. It reads `engine.manifests` FIRST: a package booted from an * artifact through `registerApp` is read-only whatever its scope says, and a - * scope-less `type: module` carried by a multi-package artifact lands there - * too, while a scope-less Studio-created base does not. Only the server holds + * scope-less BOOTED package — a marketplace install / offline file import, + * which reaches the registry with no `ManifestSchema` parse — lands there + * too, while a scope-less Studio-created base (`POST /api/v1/packages`) does + * not. ⛔ Neither is a module carried by a multi-package artifact: + * `defineStack` parses every `packages[]` entry through `ManifestSchema`, whose + * `scope` is `.default('project')`, so no package of a compiled artifact is + * ever scope-less. Only the server holds * `engine.manifests`, which is why the client could never derive this. * * The engine is the same shape `meta-overlay-cache.test.ts` drives: the @@ -24,7 +29,7 @@ import { ObjectStackProtocolImplementation } from './protocol.js'; /** Booted code package, explicit `scope: 'project'`. */ const CODE_PROJECT = 'app.acme.crm'; -/** Booted, SCOPE-LESS module — the multi-package-artifact sub-package. */ +/** Booted and SCOPE-LESS — a marketplace / offline import, registered unparsed. */ const CODE_MODULE = 'app.acme.crm.billing'; /** Platform / marketplace delivered. */ const SYSTEM_SCOPED = 'com.objectstack.platform'; @@ -47,7 +52,9 @@ function make() { row(DB_BASE), ]; const byId = new Map(records.map((r) => [r.manifest.id as string, r])); - // What `ObjectQL.registerApp` records for every package of a loaded artifact. + // What `ObjectQL.registerApp` records — for every package of a loaded artifact + // (CODE_PROJECT, parsed and therefore `scope: 'project'`) and for a + // marketplace / offline import (CODE_MODULE, unparsed and therefore scope-less). const manifests = new Map([ [CODE_PROJECT, byId.get(CODE_PROJECT)!.manifest], [CODE_MODULE, byId.get(CODE_MODULE)!.manifest], diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 89436302d0..188b23c0d8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7290,9 +7290,14 @@ export class ObjectStackProtocolImplementation implements // package switcher reads that list; it used to derive "writable" // client-side from `manifest.scope` alone, which is not this server's // rule — ADR-0070 D2 (`isWritablePackage`) reads `engine.manifests` - // FIRST, so a scope-less module booted from a multi-package artifact is - // read-only while a scope-less Studio-created base is writable, and only - // the server can tell the two apart. Same predicate the authoring and + // FIRST, so a scope-less BOOTED package — a marketplace install / offline + // file import, which reaches the registry through `ql.registerApp` with no + // `ManifestSchema` parse — is read-only while a scope-less Studio-created + // base (`POST /api/v1/packages`) is writable, and only the server can tell + // the two apart. ⛔ Neither is a module carried by a multi-package + // artifact: `defineStack` parses every `packages[]` entry through + // `ManifestSchema`, whose `scope` is `.default('project')`, so no package + // of a compiled artifact is ever scope-less. Same predicate the authoring and // lifecycle gates use (#8146: one answer), computed on a spread COPY: // the registry record is never mutated and the verdict is never stored. // The runtime dispatcher door decorates its own read of the same diff --git a/packages/runtime/src/domains/packages-writable-verdict.test.ts b/packages/runtime/src/domains/packages-writable-verdict.test.ts index 6e7b933b76..983faa35cc 100644 --- a/packages/runtime/src/domains/packages-writable-verdict.test.ts +++ b/packages/runtime/src/domains/packages-writable-verdict.test.ts @@ -11,10 +11,16 @@ * `isWritablePackage` (ADR-0070 D2), and it is a different predicate: it reads * `engine.manifests` FIRST — a package booted from an artifact through * `registerApp` is read-only whatever its scope says — and only then the - * `system` / `cloud` scopes. The two rules split on exactly the row ADR-0130 - * introduces: a scope-less `type: module` carried by a multi-package artifact - * is in `engine.manifests` (read-only) while a scope-less Studio-created base - * is not (writable). Nothing in the raw row distinguishes them; `engine.manifests` + * `system` / `cloud` scopes. The two rules split on the scope-less pair: a + * scope-less BOOTED package is in `engine.manifests` (read-only) while a + * scope-less Studio-created base is not (writable). ⛔ Neither half of that + * pair is a module carried by a multi-package artifact: `defineStack` parses + * every `packages[]` entry through `ManifestSchema`, whose `scope` is + * `.default('project')`, so no package of a compiled artifact is ever + * scope-less. A row is scope-less only where it reached the registry WITHOUT + * that parse — a marketplace install / offline file import for the booted + * half, `POST /api/v1/packages` for the base. Nothing in the raw row + * distinguishes them; `engine.manifests` * does, and only the server holds it. So the server says it. * * ## What is asserted @@ -36,9 +42,14 @@ import { HttpDispatcher } from '../http-dispatcher.js'; /** Pin 1 — booted code package, explicit `scope: 'project'` (today's hotcrm shape). */ const CODE_PROJECT = 'app.acme.crm'; /** - * Pin 2 — booted code package with NO scope key: the `type: 'module'` sub-package a - * multi-package artifact carries (ADR-0130 D4/D5). The raw body is what the - * load path registers (D7), so the row has no `scope` at all. THE row #14375 + * Pin 2 — booted code package with NO scope key: a marketplace install / offline + * file import, whose RAW body reaches the registry through + * `manifestService.register(rawBody)` → `ql.registerApp` with no + * `ManifestSchema` parse, so the row has no `scope` at all. ⛔ Not the + * sub-package a multi-package artifact carries — `defineStack` parses every + * `packages[]` entry through `ManifestSchema`, whose `scope` is + * `.default('project')`, so no package of a compiled artifact is ever + * scope-less. THE row #14375 * exists for: the client heuristic said "writable"; the server says read-only. */ const CODE_MODULE = 'app.acme.crm.billing'; @@ -72,8 +83,10 @@ function make() { registry.installPackage(manifest(DB_BASE)); registry.installPackage(manifest(DB_PROJECT, { scope: 'project' })); - // Only the two code packages booted from an artifact — this is what - // `ObjectQL.registerApp` records for every package of a loaded artifact. + // Only the two code packages are booted — this is what `ObjectQL.registerApp` + // records, both for every package of a loaded artifact (which is CODE_PROJECT, + // parsed and therefore `scope: 'project'`) and for a marketplace / offline + // import (CODE_MODULE, unparsed and therefore scope-less). const manifests = new Map([ [CODE_PROJECT, manifest(CODE_PROJECT, { scope: 'project', type: 'app' })], [CODE_MODULE, manifest(CODE_MODULE, { type: 'module' })], @@ -117,7 +130,7 @@ describe('GET /packages — every row carries the server\'s writable verdict (#1 expect(byId(rows, CODE_PROJECT).writable).toBe(false); }); - it('pin 2: a booted, SCOPE-LESS module (multi-package artifact sub-package) is writable: false', async () => { + it('pin 2: a booted, SCOPE-LESS package (marketplace / offline import) is writable: false', async () => { const rows = await list(make().dispatcher); const row = byId(rows, CODE_MODULE); // The raw row really has no scope — the verdict is not coming from it. diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 6c7bba0e7a..b9d7881b26 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -492,10 +492,18 @@ function requireDuplicableSource( * `manifest.scope` alone (`scope !== 'project'`). That is not the rule this * server enforces: {@link isWritablePackage} (ADR-0070 D2) reads * `engine.manifests` FIRST — a package booted from an artifact through - * `registerApp` is read-only whatever its scope says, and a scope-less module - * carried by a multi-package artifact (ADR-0130 D4/D5) lands there too. The - * client cannot see `engine.manifests`, so it cannot tell that module - * (read-only) from a scope-less Studio-created base (writable); only the + * `registerApp` is read-only whatever its scope says, and a scope-less BOOTED + * package lands there too. ⛔ That scope-less row is NOT a module carried by a + * multi-package artifact: `defineStack` parses every `packages[]` entry through + * `ManifestSchema` (`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), + * whose `scope` is `.default('project')`, so no package of a compiled artifact + * is ever scope-less. A row reaches the registry scope-less only WITHOUT that + * parse: a marketplace install / offline file import + * (`manifestService.register(rawBody)` → `ql.registerApp` — booted, hence + * read-only), or a Studio-created base through `POST /api/v1/packages` + * (`body.manifest || body` → `installPackage`, which stores a key-by-key copy + * and applies no defaults — hence writable). The client cannot see + * `engine.manifests`, so it cannot tell those two apart; only the * server can, so the server says it — with the SAME predicate the authoring * and lifecycle gates use, which is #8146's ruling ("one answer to 'is this * package writable?'") applied to the read door. From 33c6aab9ac807b55c60c5636a3501e75ffb26149 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 02:11:04 +0000 Subject: [PATCH 2/3] chore(changeset): record the scope-less-row attribution correction for the next release notes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../scope-less-booted-row-attribution.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/scope-less-booted-row-attribution.md diff --git a/.changeset/scope-less-booted-row-attribution.md b/.changeset/scope-less-booted-row-attribution.md new file mode 100644 index 0000000000..5d02576295 --- /dev/null +++ b/.changeset/scope-less-booted-row-attribution.md @@ -0,0 +1,35 @@ +--- +"@objectstack/runtime": patch +"@objectstack/metadata-protocol": patch +--- + +docs(runtime,metadata-protocol): correct the `writable` verdict's illustration — the scope-less booted row is a marketplace / offline import, never a multi-package artifact's module (#14803) + +Comment and prose only. No predicate, no assertion and no served shape changes; +every pin behind the `writable` verdict stays green as written. + +The `writable` verdict shipped in 17.3.0 with a **false attribution** in its own +explanation, and this corrects it at every site that repeated it. The claim was +that the scope-less booted row `isWritablePackage` answers `false` for is *the +`type: module` sub-package a multi-package artifact carries*. It is not, and it +never was: + +- `defineStack` parses every `packages[]` entry through `ManifestSchema` + (`spec/src/stack.zod.ts`, `ArtifactPackageEntrySchema`), whose `scope` is + `.default('project')` (`spec/src/kernel/manifest.zod.ts`), so **no** package of + a compiled artifact is ever scope-less — `dist/objectstack.json` and both + served rows carry `scope: "project"`. +- A genuinely scope-less row arises only where a manifest reaches the registry + **without** that parse, because `installPackage` stores a key-by-key copy that + applies no defaults: a marketplace install / offline file import + (`manifestService.register(rawBody)` to `ql.registerApp`) for the **booted, + read-only** half, and `POST /api/v1/packages` (`body.manifest || body` to + `installPackage`) for the **database base, writable** half. + +Measured: `ManifestSchema.parse` of the `app-multi-package` orders body turns an +unauthored `scope` into `scope: "project"`, while `SchemaRegistry.installPackage` +of the same unparsed body yields a record with no `scope` key at all. + +What stays, because it is true and load-bearing: a scope-less **booted** package +is read-only while a scope-less **database base** is writable, and only +`engine.manifests` tells them apart — which is why the server owns the verdict. From f8e396c99bf4a0bc7de2a2428d27b1c9b8f8130f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 02:21:15 +0000 Subject: [PATCH 3/3] fix(docs): re-anchor the system-context census row 52 onto the line the elevation read moved to Pure line rot from this PR's own comment expansion in packages/runtime/src/domains/packages.ts: the shouldDenyAnonymous read moved from :535 to :543, so row 52's anchor and the read no longer met. Written by `node scripts/check-system-context-census.mjs --fix`; the anchor's number is the only thing that changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0c8ebf140e..ab0f3cc9b0 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -160,7 +160,7 @@ The largest single consumer — **17 of the 105 sites**. | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | | 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5231`, `:6657`, `:6905`, `:7336`, `:7529` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | -| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | +| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:543`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` |