Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/scope-less-booted-row-attribution.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand All @@ -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<string, unknown>([
[CODE_PROJECT, byId.get(CODE_PROJECT)!.manifest],
[CODE_MODULE, byId.get(CODE_MODULE)!.manifest],
Expand Down
11 changes: 8 additions & 3 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 23 additions & 10 deletions packages/runtime/src/domains/packages-writable-verdict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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<string, any>([
[CODE_PROJECT, manifest(CODE_PROJECT, { scope: 'project', type: 'app' })],
[CODE_MODULE, manifest(CODE_MODULE, { type: 'module' })],
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 12 additions & 4 deletions packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading