Skip to content

Commit 2bcd406

Browse files
fix(metadata-protocol)!: the by-name /meta read answers a disabled package's non-object item as absent, as the list omits it (#24223)
Part of #24210 (it covers the by-name read and the dispatcher's doors; the doors listed under "Stopped at the fence" stay open and are why this does not close the card) Clause-②: no (narrowing) Implements triage 6239967422 on #24210 (absence for a non-object type; `OBJECT_PACKAGE_DISABLED` stays the object answer) under claim 6240246860, dispatched by the `domain:engine` seat 1 (`session_01JtSQQ3EWsbNPzyYMug1tXy`). Rework round: seat ruling 6241893667 under the re-declared claim 6241900550 (items 4 and 5 below; Q1, Q2 and Q4 settled A). ## What changed Disabling a package already took its items out of every `GET /meta/:type` list. The by-name read still served them. It now answers them the way it answers a name with nothing behind it. 1. **`getMetaItem`** (`packages/metadata-protocol/src/protocol.ts`). After the body read (`readMetaItem`, unchanged), an item of any type except `object` and `package` whose `_packageId` names a disabled package is answered as absent: - the active read and `previewDrafts` serve no `item`, and both transports answer that `404 RESOURCE_NOT_FOUND`, byte for byte the answer for a missing name; - the strict draft read throws the missing draft's `404 NO_DRAFT` (one constructor, `noPendingDraftError`, for both); - a flow's `registration` is dropped with the item; - the protection envelope (`lock`, `editable`, `deletable`, `version` and the rest) stays. It reports the write doors' verdicts about the address, and a disable does not change how the lock gate judges a save there. The CLI ownership ceremony gates its own raw-SQL writes on exactly these fields (`lock.verdict`), so this keeps a locked item locked for it. 2. **One predicate (H1).** `isItemOfDisabledPackage(registry, item)` is the list read's filter (`readFlattenedMetaItems`) and the by-name read's check. Both ask `registry.isPackageDisabled(item._packageId)` of the item the read resolved, so they hide the same items. There is no new public method, spec key or error code. 3. **The dispatcher** (`packages/runtime/src/domains/meta.ts`, cross-lane, declared to `domain:cli`). Four doors read past the protocol and served a disabled package's object or item: - `/meta/object/NAME/state/FIELD` and the legacy one-segment `/meta/NAME` exit read `registry.getObject`. Both now ask `disabledObjectAnswer` first, which answers the protocol's own `404 OBJECT_PACKAGE_DISABLED`; - `/meta/:type/:name/published` swallowed the layered read's refusal and fell through to the published snapshot. `OBJECT_PACKAGE_DISABLED` is now the answer, as on `RestServer`'s `/published`; - the item read of a non-object type fell back to the metadata service when the protocol served no item. The service knows nothing of a disable, so the fallback now skips an item whose `_packageId` names a disabled package (`isServiceItemOfDisabledPackage`, the same predicate). 4. **`fetchCurrentMetaDocument`** (`packages/rest/src/rest-server.ts`, the class member only, nothing inside `registerMetadataEndpointsInner`; ruling item 1). It is the current document the `/history`, `/audit` and `/diff` doors' per-caller gate judges. With item 1 a disabled package's item read as "no current document", so those doors served events, and a view's or dashboard's diff, past the gate. When the plain read serves no item but the layered read (which keeps serving every layer) still resolves one, the member now throws the plain read's absence, `404 RESOURCE_NOT_FOUND`, which the doors' own catch renders. An item that is really gone (every layer null, such as a deleted item) keeps today's answer. 5. **`readServedPackageBinding`** (`packages/plugins/plugin-security/src/security-catalog-echo.ts`, that function only; ruling item 2). It is a resolution read, so it reads the binding off the protection envelope's declared `packageId` (the served document's `_packageId`, kept when the item is withheld) instead of `item._packageId`. A disabled package's permission set or position echoes owned, and stays owned after a re-enable. No new public method, no enforcement change. `packages/rest/src/meta-item-read-gate.ts` is not edited. ## Premise: the callers of a by-name read of a non-object item Measured before any edit. "Today" is `origin/main` `b7f29ddfe`; "after" is this branch. The case is an item of a disabled package. objectui is read at `origin/main` `54e79f9` (read-only clone). No caller has to tell "disabled" from "missing", and none must be served the item, so absence holds. | Call site | What it does | Today | After | |---|---|---|---| | objectui `data-objectstack` `probeMetaItemAccess` and `getAppAccessVerdict` (via `AppContent.tsx`, `useMetaItemRefusal.ts`) | explains why an app, dashboard or view in the URL is not in the list | `granted`, and `AppContent` shows the neutral "App not available" screen: its own comment names this case, "the by-name route served the app while the refreshed list still does not carry it" | `not_found`: "This app can't be opened". Its verdict set has no disabled arm; denied stays denied | | objectui `data-objectstack` `getApp`, `getPage`, `getView`, the batch `getItem(category, …)` | render-time reads | served | `null` (absence), the face a missing item gets | | objectui `MetadataProvider.getItem(type, name)`, `useActionModal` (`page`), `FormPage.loadInternalForm` (`view`), `DocPage` / `DocsSlug` (`doc`) | render the item | served | not found. Each uses the item, and the list already omits it | | objectui `DraftChangesPanel` (`/meta/TYPE/NAME` and `?state=draft`) | diff a pending draft against its published side | both sides served | both `404`, read as `null` sides. Not a reader that must be served: see Acceptance notes on `_drafts` | | objectui `PackagesPage`, `PackagedAutomationPage`, `PackagedActionsSection` (Studio package screens, re-enable) | `/api/v1/packages`, `_drafts`, lists | not a by-name reader | unchanged. `package` is never hidden | | objectui editors on `/layers` (`ResourceEditPage`, `EmbeddedItemEditor`, `PermissionMatrixEditor`, `LayeredDiff`) | layered view | served | unchanged here (`/layers` is a stopped door); every entry point lists first | | `packages/cli` ownership ceremony `lock.verdict` (`organization-ownership.ts`), `PRESENTATIONAL_TYPES` = view, dashboard, report, translation, email_template | gates raw-SQL writes on `editable` / `lock` | the item's real verdict | the same verdict: the envelope is kept (pinned: "the protection envelope stays") | | `packages/plugins/plugin-security` `readServedPackageBinding` (`security-catalog-echo.ts`), `permission` / `position` | reads the package that owns a stored overlay, for an environment-kernel echo | the package id | the package id, read off the kept envelope (item 5; pinned owned through disable and re-enable). `getMetaItemLayered`, which decides whether the echo exists, is not touched | | `packages/rest` `fetchCurrentMetaDocument` (`/history`, `/audit`, `/diff`) | the current document the per-caller gate judges | the item | absence: the doors answer `404 RESOURCE_NOT_FOUND` (item 4; measured below) | | `packages/rest` import mapping, `service-automation` connector pull (`mapping`) | use the mapping | served | not found. Each uses the item | | `packages/runtime` `applyPublishedSeeds`, `service-analytics` seed draft probe (`seed`) | apply a seed / find a pending seed draft | served | not found / `NO_DRAFT` (already caught). The seed's objects are refused by the data plane anyway | | `packages/core` import runner, `plugin-auth` admin user import, `rest` data-action routes | read `type: 'object'` only | unchanged by this PR | unchanged | ## Doors, measured over HTTP **REST:** `pnpm dev:crm -- --fresh` (real socket), before at `b7f29ddfe`, after at this branch with a full `pnpm build` (76/76). **Dispatcher:** the real store (`SqliteWasmDriver`) and the real protocol (`ObjectQLPlugin`) under `createHonoApp`, driven through its fetch handler, the `packages/qa/http-conformance` harness shape (scratch script, not committed). Both against a writable package `app.probe-24210` holding object `probe_gadget`, app, page, dashboard, flow and view. | Request, package disabled | REST before | REST after | Dispatcher before | Dispatcher after | |---|---|---|---|---| | `GET /meta/TYPE` contains the item (6 types) | no | no | no | no | | `GET /meta/TYPE/NAME`, app, page, dashboard, flow, view | 200, served | **404 `RESOURCE_NOT_FOUND`**, = missing name | 200, served | **404 `RESOURCE_NOT_FOUND`**, = missing name | | `GET /meta/TYPE/NAME?preview=draft`, same six | 200, served | **404 `RESOURCE_NOT_FOUND`** | 200, served | **404 `RESOURCE_NOT_FOUND`** | | `GET /meta/TYPE/NAME?state=draft`, same six | 404 `NO_DRAFT` | 404 `NO_DRAFT` | 404 `NO_DRAFT` | 404 `NO_DRAFT` | | `GET /meta/TYPE/NAME?layers=true` / `/layers`, same six | 200, served | 200, served (stopped) | 200, served | 200, served (stopped) | | `GET /meta/TYPE/NAME/published`, same six | 200, served | 200, served (stopped) | 200, served | 200, served (stopped) | | `GET /meta/object/probe_gadget/published` | 404 `OBJECT_PACKAGE_DISABLED` | 404 `OBJECT_PACKAGE_DISABLED` | 404 `RESOURCE_NOT_FOUND` (snapshot fall-through; a host with a metadata service serves the snapshot) | **404 `OBJECT_PACKAGE_DISABLED`** | | `GET /meta/object/probe_gadget/state/status?from=open` | 200, served | 200, served (stopped) | 200, served | **404 `OBJECT_PACKAGE_DISABLED`** | | `GET /meta/probe_gadget` (one segment) | 400 `INVALID_REQUEST` (the list route) | 400 | 400 `INVALID_REQUEST`, enabled and disabled alike | 400 | | `GET /meta/object/probe_gadget`, `/data/probe_gadget` | 404 `OBJECT_PACKAGE_DISABLED` | unchanged | 404 `OBJECT_PACKAGE_DISABLED` | unchanged | | control: another package's object, by name and `/state` | 200 | 200 | 200 | 200 | Byte equality: on REST the disabled item and a missing name both answer `{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}`; on the dispatcher both answer `{"success":false,"error":{"code":"RESOURCE_NOT_FOUND","message":"Not found","httpStatus":404}}`. The enabled readings after are identical to the enabled readings before on both transports, and re-enabling restores every row. The REST server log carried 0 `failing open` warnings. ## Mechanism hypotheses (measured) - **H1, one predicate: holds.** Every layer the by-name read serves carries the owning package as `_packageId` on the resolved item: the stored row's `package_id` (grafted in `readMetaItem`), the registry item (stamped by `registerItem`), the artifact envelope. The list filter reads the same field off its merged items, so one helper serves both. - **H2, absence without a new path: holds.** The missing name's exit is an item-less envelope, and both transports judge absence on `item == null` (`createMetaItemAnswer` on REST, the `data?.item != null` hit test on the dispatcher); the cached arm throws `metadataItemNotFoundError` on a falsy item, and REST maps both to `sendMetaItemAbsent`. The dispatcher half needed the service-fallback guard above, because its miss fell through to the metadata service. - **H3, `/published`: half holds.** For objects, REST already re-throws the layered refusal (`typeof status === 'number'`), measured 404 `OBJECT_PACKAGE_DISABLED` before any edit; the dispatcher's blanket catch is now narrowed in `meta.ts`. For non-object types the producer side cannot carry it: see below. - **H4, the dispatcher's two doors: half holds.** `/state/FIELD` reads the registry directly and served the object; closed. The one-segment `/meta/NAME` legacy exit is not reachable over HTTP on a real composition: `refuseUnknownMetaListType` answers 400 first, enabled and disabled alike. It is reachable only on a host whose protocol declares no type set; closed in code and pinned there. ## Stopped at the fence (open on #24210 for a follow-up dispatch after this PR lands; #23050's move series is no longer in flight, notice 6243012505) Ruling 6241893667 items 3, 4 and 6: these stay on the card, on the door side, both transports together, after slice 13e. 1. **`?layers=true`, `/layers` and `/published` for a non-object item, both transports.** Producer `getMetaItemLayered` keeps serving its resolution reader, the security catalog echo (`echoSecurityCatalogMutation`, `echoStoredSecurityCatalogOverlays`), which turns a stored `permission` / `position` overlay into what an environment kernel enforces; absence there would be an enforcement change. The doors close on the door side (`rest-server.ts`'s layered and `/published` handlers inside `registerMetadataEndpointsInner`, and `meta.ts`'s twins). `/published`'s non-object fall-through is also `MetadataManager.getPublished` (`packages/metadata`), whose document carries no `_packageId`. 2. **REST `/meta/object/NAME/state/FIELD`, the interim transport split.** REST's state route reads `ql.registry.getObject(name)` directly and serves a disabled package's object (measured 200 after this PR); the dispatcher twin answers 404 `OBJECT_PACKAGE_DISABLED`. The one-door split holds until the REST half lands with item 1. 3. **`GET /meta/_drafts`** still lists a disabled package's drafts while the by-name draft read answers `NO_DRAFT` for them: another door of the same rule, joining item 1's follow-up. ## The event and diff doors, measured over HTTP (item 4) `pnpm dev:crm -- --fresh`, real socket, full build after merging `origin/main`. "Before" is this branch without item 4 (head `8946a02d4`, the merge on top of `b2f89e8f`); "after" is `232339ab8`. A writable package `app.gate-24210` holds dashboard `gate_dash` and app `gate_app`, each saved twice, each with `requiredPermissions: ['gate_24210_cap']`; dashboard `gate_gone` was saved and deleted. - **Gated caller:** the seeded admin. It holds `studio.access` / `manage_metadata`, so the three doors admit it, and not `gate_24210_cap`, so the item's own gate refuses it the item whole: the caller the bypass reaches. - **Member caller:** a user created by `POST /auth/admin/create-user` (no capabilities). The doors' capability gate refuses it first on every row: `403 FORBIDDEN` on `/history`, `/audit`, `/diff`, enabled and disabled, before and after. | Gated caller, package state | Request | Before | After | |---|---|---|---| | enabled | `GET /meta/dashboard/gate_dash` and `/meta/app/gate_app` | 403 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | | enabled | `/history`, `/audit`, `/diff` of both | 403 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | | disabled | `GET /meta/dashboard/gate_dash` and `/meta/app/gate_app` | 404 `RESOURCE_NOT_FOUND` | 404 `RESOURCE_NOT_FOUND` | | disabled | `/meta/dashboard/gate_dash/history` | **200, the events** | **404 `RESOURCE_NOT_FOUND`** | | disabled | `/meta/dashboard/gate_dash/audit` | **200, the audit trail** | **404 `RESOURCE_NOT_FOUND`** | | disabled | `/meta/dashboard/gate_dash/diff` | **200, the diff** (`label` v1 to v2) | **404 `RESOURCE_NOT_FOUND`** | | disabled | `/meta/app/gate_app/history`, `/audit` | **200, the events** | **404 `RESOURCE_NOT_FOUND`** | | disabled | `/meta/app/gate_app/diff` | 404 `RESOURCE_NOT_FOUND` | 404 `RESOURCE_NOT_FOUND` | | deleted `gate_gone` | `/history`, `/diff` | 200 (events; empty diff) | 200 (unchanged) | | re-enabled | every row | as enabled | as enabled | Envelope position: the plain read answers `{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}`; the event and diff doors now answer status 404 and code `RESOURCE_NOT_FOUND` in the classification door's flat shape, `{"error":"Metadata item not found or access denied.","code":"RESOURCE_NOT_FOUND"}`, because the member throws and the doors' catch (`handleRouteError`) renders it. The app `/diff` row, which answered the nested body before through the door's own absent check, now answers the flat one. A byte-identical nested body needs the door side (`eventDoorRefusal` and the `/diff` branch), outside this fence. ## Pins - `packages/objectql/src/package-disable-enforcement.test.ts`, the #7557 pin. `READERS` gains four app rows (`getMetaItem` active, plural, `state: 'draft'`, `previewDrafts`) and two generated rows per type `MetadataTypeSchema` declares except `object` (27 types today; the schema does not list `package`): listing and by-name, so a new type gets its rows with nothing to write. The seed registers one item of every such type per package, as a code package registers it, plus a pending app draft. New cases: absence equals the missing name on every type (no `item`, no `registration`, and `NO_DRAFT` with the missing draft's exact message); the control, another package's item of every type still listed and served by name; the envelope is kept byte for byte apart from `item`. - `packages/runtime/src/domains/meta-disabled-package-doors.test.ts` (new), through `HttpDispatcher.dispatch`: `/state/FIELD` and the one-segment exit refuse `OBJECT_PACKAGE_DISABLED` and never ask `registry.getObject` for the name; `/published` answers the refusal and never asks `getPublished`; the service fallback's answer for a disabled package's page is byte-identical to a missing name's; a control for each. - `packages/rest/src/meta-alternate-door-read-gates.test.ts`: a gated dashboard and app the plain read withholds while their layers resolve, driven with a caller the item's own gate refuses: `/history`, `/audit`, `/diff` answer 404 `RESOURCE_NOT_FOUND` (403 `PERMISSION_DENIED` while enabled), no secret in the body, and `historyMetaItem` / `auditMetaItem` / `diffMetaItem` never called. The existing "nothing behind the name" case now says so on both reads (its layered double served the doc), so it still pins the deleted item's answer: `/diff` absent, `/history` its events. - `packages/plugins/plugin-security/src/security-catalog-echo.test.ts`: a disabled package's set echoes owned (by-name item withheld, anti-vacuity), stays owned after the re-enable, and re-echoes owned; the `stubProtocol` double now answers the envelope's `packageId` as the protocol's `servedLockState` does. - Pin sweep: `git grep` of `disablePackage(`, `/disable`, `isPackageDisabled` and the `NO_DRAFT` sentence across every test: no existing pin asserts a disabled package's non-object item is served by name, so nothing flips; the suites of every package with a hit were run (below). ## Verification (head `232339ab8`, after merging `origin/main` at `b9ac35cbf`) The head is now `ec7d6af8b`, one commit later. It changes only the `BY_NAME_TYPES` filter line and its docblock in `package-disable-enforcement.test.ts` (TS2367). At `ec7d6af8b`, the typecheck scripts (`tsc` plus `check:test-typecheck`) of `objectql`, `metadata-protocol`, `runtime`, `rest` and `plugin-security` each exit 0, and the pin is 15/15. - Tests, full suites at this head: `@objectstack/metadata-protocol` 244 files / 20641 passed (3 skipped files); `@objectstack/objectql` 438 / 8249 passed; `@objectstack/runtime` 413 / 7095 passed; `@objectstack/rest` 299 / 6284 passed (339 skipped); `@objectstack/plugin-security` 188 / 3737 passed (45 skipped). - Typecheck clean: `metadata-protocol`, `runtime`, `rest`, `plugin-security`. Full `pnpm build` 76/76 after the merge. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 73 commands at `232339ab8`; all 73 exited 0 (the clone is unshallow now, so the two shallow-clone refusals of the first round did not recur). `--ran` with exit codes: "73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN". `check-changeset-no-major` and `check-adr-0087-registration --base origin/main`: exit 0. - **Ablations**, one per changed tested behaviour, each through `scripts/ablation-replace.mjs` with a `trap` restore proven by `blob == HEAD`: - (a) `getMetaItem`'s check replaced by `|| true`, `pnpm --filter @objectstack/metadata-protocol build`, `ablation-dist-preflight --absent` passed (the suite resolves `dist/`). 31 by-name `READERS` rows went red (the 27 generated rows, one per declared type but `object`, and the 4 app rows), 0 listing rows; 3 failed / 12 passed. Restored and rebuilt, marker back in `dist/index.js`: 15 / 15. - (b)–(e) in `meta.ts` (the suite imports `src`): the `/state` check, the one-segment check, the `/published` re-throw and the service-fallback guard, each removed alone, each turned exactly its own case red (1 failed / 6 passed); restored, 7 / 7. - (f) `fetchCurrentMetaDocument`'s throw disabled: only the new withheld-item case went red (1 failed / 426 passed); restored, 427 / 427. - (g) `readServedPackageBinding` back on `item._packageId`: only the new disabled-package echo case went red (1 failed / 18 passed); restored, 19 / 19. ## Acceptance notes - `origin/main` was merged into the branch (`8946a02d4`, a merge commit; no rebase, no force-push), with no conflict. - The member caller cannot reach the event and diff doors at all (their capability gate); the bypass reaches an author who lacks the item's own `requiredPermissions`, which is the gated caller measured above. - No changeset for `@objectstack/objectql`: its diff is a test file, outside its published `files`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Mr42C6J5TavuAMC2uqtFbR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a9a80b commit 2bcd406

12 files changed

Lines changed: 606 additions & 20 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: the by-name `/meta` read answers a disabled package's item of every type but `object` and `package` as absent, as the list already omits it
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of a runtime read door, getMetaItem for an item whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
Disabling a package already took its apps, views, pages, dashboards, flows and every other item out of `GET /meta/:type`. The by-name read `GET /meta/:type/:name` still served them. It now answers such an item the way it answers a name with nothing behind it.
14+
15+
- **Absent now:** `getMetaItem({ type, name })` for an item whose `_packageId` names a disabled package, for every type except `object` and `package`. The active read and `previewDrafts` serve no `item`, which both transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the answer for a missing name. The strict draft read (`state: 'draft'`) throws the missing draft's `404 NO_DRAFT`. The list read and the by-name read ask one predicate, so they hide the same items.
16+
- **Kept:** the protection envelope (`lock`, `editable`, `deletable`, `version` and the rest) still reports the write doors' verdicts about the address, because a disable does not change how the lock gate judges a save there. Only `item` and a flow's `registration` are withheld.
17+
- **Unchanged:** objects, which keep their `404 OBJECT_PACKAGE_DISABLED`; packages, which are never hidden, so a disabled package can still be read and re-enabled; items of enabled packages; and `getMetaItemLayered` (`?layers=true`, `/layers`) and `/published`, which still serve a disabled package's non-object item.
18+
19+
What changes for you: a client that read a disabled package's app, view, page or other item by name now gets `404 RESOURCE_NOT_FOUND`, as for a missing name. Re-enable the package to read it again.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): the security catalog echo keeps naming a disabled package's permission set or position as that package's
6+
7+
The echo reads which package owns a stored overlay from the protocol's by-name read. That read now withholds a disabled package's item and keeps its protection envelope, so the echo reads the envelope's `packageId` instead of `item._packageId`. Before, a disabled package's set was echoed unowned, lost its ADR-0090 D5 audience-binding suggestion, and stayed unowned after the package was re-enabled until the next save, publish or boot. Enforcement is unchanged.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: `/history`, `/audit` and `/diff` answer a disabled package's item with the plain read's absence, never past the per-caller gate
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of three runtime read doors for an item whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The by-name read now answers a disabled package's item as absent. The event and diff doors judge the item the plain read would judge, and they read "no item" as "nothing behind the name": events served whole, and a view's or dashboard's diff served whole, to a caller the item's own gate refuses.
14+
15+
- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name/history`, `/audit` and `/diff` for an `app`, `view`, `dashboard`, `doc` or `book` the plain read withholds while its layers still resolve, which is a disabled package's item. No events and no diff are read.
16+
- **Unchanged:** an item that is really gone (every layer null, such as a deleted item) keeps its answer: its events, and its diff as before. Items of enabled packages keep the per-caller gate as before.
17+
18+
What changes for you: re-enable the package to read its items' history, audit trail and diff again.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: the dispatcher's `/meta` doors that read past the protocol answer a disabled package's object or item as the protocol's by-name read does
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of runtime read doors on the dispatcher transport: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The registry keeps a disabled package's objects on purpose, and the metadata service knows nothing of a disable. Four dispatcher doors read one of them for a disabled package's object or item and served it.
14+
15+
- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/state/:field`, and the legacy one-segment `GET /meta/:name` exit, for an object whose owning package is disabled. Both read `registry.getObject`; they now answer the protocol's refusal, the answer `GET /meta/object/:name` gives.
16+
- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/published`. When the layered read refused the object, this door fell through to the published snapshot and served it. The refusal is now the answer, as on `RestServer`'s `/published`.
17+
- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name` for a non-object item of a disabled package. The protocol answers it as absent, and the door no longer serves the metadata service's copy in its place. The body is byte for byte the answer for a missing name.
18+
- **Unchanged:** objects and items of enabled packages, and every other fall-through.
19+
20+
What changes for you: re-enable the package to read the object or item again.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,35 @@ function isObjectOfDisabledPackage(registry: any, name: string): boolean {
533533
&& registry.isObjectPackageDisabled(name) === true;
534534
}
535535

536+
/**
537+
* [#7557, #24210] Whether `item` belongs to a disabled package: its
538+
* `_packageId`, asked of `registry.isPackageDisabled`. The ONE predicate for a
539+
* non-object item, asked by the list read ({@link
540+
* ObjectStackProtocolImplementation.getMetaItems}, which omits the item) and
541+
* the by-name read ({@link ObjectStackProtocolImplementation.getMetaItem},
542+
* which answers as for a name with nothing behind it), so the two cannot
543+
* disagree about which items a disable hides. Optional-called, as {@link
544+
* isObjectOfDisabledPackage} is: a host whose registry cannot answer has no
545+
* item hidden.
546+
*/
547+
function isItemOfDisabledPackage(registry: any, item: unknown): boolean {
548+
return typeof registry?.isPackageDisabled === 'function'
549+
&& registry.isPackageDisabled((item as { _packageId?: unknown } | null | undefined)?._packageId) === true;
550+
}
551+
552+
/**
553+
* The strict draft read's `404 NO_DRAFT`: no pending draft at this address
554+
* (ADR-0005 §draft-lifecycle). [#24210] One constructor for the read that
555+
* finds no draft row and the read of a disabled package's draft, so the two
556+
* answers are byte-identical.
557+
*/
558+
function noPendingDraftError(type: string, name: string): Error & { code: 'NO_DRAFT'; status: 404 } {
559+
const err = new Error(`No pending draft exists for ${type}/${name}.`) as Error & { code: 'NO_DRAFT'; status: 404 };
560+
err.code = 'NO_DRAFT';
561+
err.status = 404;
562+
return err;
563+
}
564+
536565
/**
537566
* [#17501] Does a converted JSON Schema carry any authorable surface at all?
538567
*
@@ -10375,10 +10404,11 @@ export class ObjectStackProtocolImplementation implements
1037510404
// `assertObjectRegistered` (`OBJECT_PACKAGE_DISABLED`) — absence here
1037610405
// and refusal there are the two halves of one answer, pinned together
1037710406
// by `protocol.package-disable-enforcement.test.ts`.
10407+
//
10408+
// [#24210] The predicate is {@link isItemOfDisabledPackage}, the one the
10409+
// by-name read asks, so the list and the by-name read hide the same items.
1037810410
if (request.type !== 'package') {
10379-
items = (items as any[]).filter(
10380-
(it) => !this.engine.registry.isPackageDisabled((it as any)?._packageId),
10381-
);
10411+
items = (items as any[]).filter((it) => !isItemOfDisabledPackage(this.engine.registry, it));
1038210412
}
1038310413

1038410414
// Canonical-shape exposure (ADR-0017, "Object has-many View"): a
@@ -11046,6 +11076,19 @@ export class ObjectStackProtocolImplementation implements
1104611076
* that must still see the object (the save's destructive-change check)
1104711077
* read {@link readMetaItem}, the shared body, instead: the reader-class
1104811078
* split `package-disable-enforcement.test.ts` pins.
11079+
*
11080+
* [#7557, #24210] Every other type but `package` has no data plane, so a
11081+
* disabled package's item is answered as ABSENT, the answer a name with
11082+
* nothing behind it gets: the list read omits that item, by the same
11083+
* predicate ({@link isItemOfDisabledPackage}) asked of the item this read
11084+
* resolved, in every state. The strict draft read throws the missing
11085+
* draft's `404 NO_DRAFT`. The other reads serve no `item`, which both
11086+
* transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the missing
11087+
* name's answer, and drop `registration`, the engine's verdict on a served
11088+
* flow. The protection envelope stays: it reports the write doors'
11089+
* verdicts about this address (#21670), and a disable does not change how
11090+
* the lock gate judges a save there. `package` is never hidden, so a
11091+
* disabled package can still be read and re-enabled.
1104911092
*/
1105011093
async getMetaItem(request: {
1105111094
type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean,
@@ -11057,7 +11100,14 @@ export class ObjectStackProtocolImplementation implements
1105711100
if (request.type === 'object' && isObjectOfDisabledPackage(this.engine?.registry, request.name)) {
1105811101
throw objectPackageDisabledError(request.name);
1105911102
}
11060-
return this.readMetaItem(request);
11103+
const answer = await this.readMetaItem(request);
11104+
if (request.type === 'object' || request.type === 'package'
11105+
|| !isItemOfDisabledPackage(this.engine?.registry, answer?.item)) {
11106+
return answer;
11107+
}
11108+
if (request.state === 'draft') throw noPendingDraftError(request.type, request.name);
11109+
const { item: _withheld, registration: _servedOnly, ...envelope } = answer as typeof answer & { registration?: unknown };
11110+
return { ...envelope, item: undefined };
1106111111
}
1106211112

1106311113
/**
@@ -11292,14 +11342,7 @@ export class ObjectStackProtocolImplementation implements
1129211342
// status code to decide "no pending edit" without sniffing
1129311343
// envelope shape. See ADR-0005 §draft-lifecycle.
1129411344
if (readState === 'draft') {
11295-
if (item === undefined) {
11296-
const err: any = new Error(
11297-
`No pending draft exists for ${request.type}/${request.name}.`,
11298-
);
11299-
err.code = 'NO_DRAFT';
11300-
err.status = 404;
11301-
throw err;
11302-
}
11345+
if (item === undefined) throw noPendingDraftError(request.type, request.name);
1130311346
return {
1130411347
type: request.type,
1130511348
name: request.name,

0 commit comments

Comments
 (0)