Skip to content

Commit 58a2faa

Browse files
fix(rest,runtime)!: /layers and ?layers=true answer a disabled package's non-object item as a missing name, on both transports (#24267)
Fixes #24210 Clause-②: no (narrowing) (owes one `CONTRACT_REVIEW_TIER` review before the queue) The card's last part (claim 6245752510): non-object `GET /meta/:type/:name/layers` and the deprecated `GET /meta/:type/:name?layers=true`, on both transports, now answer a disabled package's item exactly as a missing name, as the list read and the by-name read already do. PR #24223 and PR #24247 closed every other door; this part closes the last two spellings. ## What changed - **`packages/rest/src/meta-item-read-gate.ts`, the shared layered chain `createMetaLayeredAnswer`:** one step, 1b, after step 1 (no layer, so absent) and before step 2 (the per-caller gate). For every type but `object`, it answers `{ reason: 'absent' }` when the new optional source `MetaItemReadGateSources.isItemOfDisabledPackage` says the plain read withholds the item for a disabled owner. Which types are hidden stays the plain read's decision (`package` never is), not a second copy in the chain. Both spellings on both transports call this chain, so no transport carries a step of its own. `MetaLayeredRequest` gains an optional `packageId` (the `?package=` scope), which the step passes to the source. - **The source, wired on each transport:** - REST: the `RestServer` class member `metaItemReadGateSources`. Withheld means the plain read under the same scope serves no `item` and its kept envelope `packageId` is one the engine registry's `isPackageDisabled` answers `true` for. The engine is reached through `wiredEngineOrLoud`, as `/published` reaches it. - Dispatcher: `metaItemReadGateSources` in `runtime/src/domains/meta.ts`. The same rule, through `deps.getObjectQL` and `isServiceItemOfDisabledPackage`. - Both callers of the chain pass the scope they already read: `serveMetaItemLayered` (one property) and `answerMetaLayered` (one property). - **Unchanged:** - `getMetaItemLayered`, per ruling 6241893667 Q1 A: the producer keeps serving its resolution readers. - The object answer: the protocol's own `404 OBJECT_PACKAGE_DISABLED`, thrown by the read before the chain runs. - `package`, which is never hidden. - No route body is edited. Nothing changes inside `registerMetadataEndpointsInner`, `meta-item-read-routes.ts` or `meta-collection-routes.ts`. No spec change, no new error code, no new public protocol method. ## The PM's hypotheses, measured - **H1, the owner: partly falsified.** Measured on the real protocol and a real `SchemaRegistry`, before and after the disable: - A code-shipped item's layered envelope carries `packageId`, and its `code` and `effective` layers carry `_packageId`. - An overlay-only (runtime-saved) item's layered answer carries no owner at all: no envelope `packageId`, and `_packageId` on neither `overlay` nor `effective`. - A combined item carries the envelope `packageId` and `code._packageId`, but not `effective._packageId`. - So the layered answer cannot say who owns the item. The plain read's kept envelope `packageId` names the owner in all three cases, withheld item or not. That field is `extractProtection` of the served document, the same `_packageId` the by-name read's predicate `isItemOfDisabledPackage` asks. It is also what `/published` and plugin-security's `readServedPackageBinding` already read. - **H2, one step for both transports: holds.** One optional source on the gate's sources interface carries it. An unwired host (no engine wired, or a registry that cannot answer) answers as before. On REST, a rejecting engine slot answers `503 SERVICE_UNAVAILABLE`, never a layered view. A plain read that rejects is answered as that fault, never a layered view. On the dispatcher see the Acceptance notes. - **H3, the gate order: holds.** An author (save-door admission true) is served the stored app whole while the package is enabled. After the disable, the author is answered the missing name. The member, served the app pruned while enabled, gets the same absence. Pinned in `meta-disabled-package-doors.test.ts` (REST). - **H4, the resolution readers: hold.** `security-catalog-echo.ts` and `packaged-permission-set-lock-gate.ts` call `getMetaItemLayered` directly; REST's `fetchCurrentMetaDocument` calls `getMetaItem` then `getMetaItemLayered` directly. None goes through the chain. The #7557 `READERS` table gains three resolution rows: the producer for an app, the producer for a permission set (the two plugin readers), and the plain read's envelope `packageId` (the owner every one of these reads). ## Measured: the `?package=` scope matters The same name can be stored in two packages. On the real protocol, `twin_dash` was saved in a disabled package and in an enabled one. `getMetaItemLayered({ packageId })` answers each package's copy, and the unscoped by-name read resolves the disabled package's copy. An unscoped owner check would therefore withhold the enabled package's layered view under `?package=` of the enabled package. So the scope rides into the owner read. That needs `packageId` on `MetaLayeredRequest`, and so a one-property edit in each caller of the chain (see Deviations). ## HTTP, before and after, both transports **REST:** `pnpm dev:crm -- --fresh` over a real socket. "Before" is base `e74ec56ac` (the three source files restored to the base, then `rest` and `runtime` rebuilt; no other bundle carries the step). "After" is this branch after a full `pnpm build` (76/76). - Two Studio-created writable packages: `app.probe-24210c` (disabled) and `app.healthy-24210c` (control). - Specimens in the disabled package: dashboard `probe_saved_dash`, page `probe_page`, app `probe_app`, object `probe_gadget`. - `twin_dash` is stored in both packages. - Callers: the seeded admin (an author) and a member created through `/auth/admin/create-user`. - A code package cannot be disabled over HTTP (`PATCH /packages/com.example.crm/disable` answers `422 WRITABLE_PACKAGE_REQUIRED`). So a code-shipped or combined item of a disabled package is not reachable on a live server. Those shapes are measured on the dispatcher harness below and pinned on REST with the real protocol. | door (each specimen; admin and member alike) | enabled, before = after | disabled, before | disabled, after | | --- | --- | --- | --- | | `/layers`, dashboard, page, app | 200, the layered view | **200, the layered view served** | 404 `RESOURCE_NOT_FOUND`, byte for byte the missing name | | `?layers=true`, same three | 200 | **200 served** | 404, byte for byte the missing name (headers: `Deprecation`; `Link` names its own path) | | plain read, same three | 200 | 404 | 404 (unchanged) | | `/layers` and `?layers=true`, `twin_dash?package=app.probe-24210c` | 200 | **200 served** | 404, the missing name | | same, `twin_dash?package=app.healthy-24210c` (control) | 200 | 200 | 200 | | `/layers` and `?layers=true`, object `probe_gadget` | 200 | 404 `OBJECT_PACKAGE_DISABLED` | unchanged | | control: `healthy_saved_dash`, `pipeline_dashboard` (shipped plus overlay, enabled package) | 200 | 200 | 200 | | re-enabled | | as enabled | as enabled | Mechanical comparison over the stored readings: - After: 90 comparisons, 0 mismatches. Each withheld row equals the missing name's status, headers and body; each re-enabled row equals the enabled row. - Before: the same comparison finds 16 mismatches, exactly the 8 layered rows per caller this PR closes. - Enabled rows before vs after: 66 of 66 identical once version tokens, sequence numbers, timestamps and actors are normalised. **Dispatcher:** the real `createHonoApp` over a real `LiteKernel` with `ObjectQLPlugin` and `SqliteWasmDriver`, through its fetch handler (`app.request`; no in-repo host mounts the dispatcher's `/meta` on a socket). It is the `packages/qa/http-conformance` shape, in a scratch harness that was removed afterwards. The disabled package (installed `code`, disabled on the registry) carries a code-shipped page, an overlay-only dashboard and a combined dashboard; the control package carries the same. Callers are an author (`manage_metadata`) and a member. - Before: 12 rows served 200, 3 specimens times 2 layered spellings times 2 callers. - After: 0. Each disabled row is byte for byte the missing name, `Link` aside (it names its own path). - Enabled rows: 36 of 36 identical before and after. Re-enable restores every row. ## Pins - `packages/rest/src/meta-disabled-package-doors.test.ts` (real protocol and `SchemaRegistry`), five new cases: - both spellings before, during and after the disable, for a code-shipped, an overlay-only and a combined item, byte for byte the missing name (status, body and headers), with a control and a re-enable row, and the producer still serving; - an author and a member caller (H3); - the object row unchanged; - the `?package=` scope; - the engine slot through `wiredEngineOrLoud`: rejecting is `503`, absent serves as before. - `packages/runtime/src/domains/meta-disabled-package-doors.test.ts`, four new cases: - both spellings for an overlay-only and a code-shipped page, byte for byte the missing name, with a control and a re-enable row; - the `?package=` scope, including the scope the plain read was asked under; - the object row; - a rejecting plain read is its fault, and a registry that cannot answer withholds nothing. - `packages/objectql/src/package-disable-enforcement.test.ts`: the three `READERS` resolution rows above. - `packages/rest/src/objectql-slot-consumer-census.test.ts`: the census counts a fifth `objectQLProvider` consumer (the layered owner step, through `wiredEngineOrLoud`) and cites its pin. The census went red on the new consumer before this edit, as designed. ## Verification **Rework after CI (seat message, `Test Core (6/6)` on `ed6b7108ec`).** `@objectstack/runtime`'s `test:repo` (`vitest run --project repo`) failed: `meta-list-projection-parity.test.ts`, "every type the item read keys on, and every translatable type, is a census cell", `AssertionError: expected [ 'package' ] to deeply equal []`. Reproduced locally. The census derives every type literal the item read's handlers and shared steps compare against, and step 1b's `metaType !== 'package'` made `package` a keyed type with no parity cell. The literal also restated a decision the protocol already makes. Fix in `527c7bc4ba` (and the test type in `9d275b85e3`); the lines that moved against `ed6b7108ec`: - `meta-item-read-gate.ts`: the step's condition drops `&& metaType !== 'package'` (one line); the source's and step 1b's docblocks say what withheld means. - `rest-server.ts` (`metaItemReadGateSources` only): the plain read's cast gains `item`, and the return becomes `envelope?.item == null && registry.isPackageDisabled(envelope?.packageId) === true` (two lines; docblock three lines). - `runtime/src/domains/meta.ts` (`metaItemReadGateSources` only): the same two-line change (docblock three lines). - `meta-disabled-package-doors.test.ts` (runtime): one new case, "the plain read decides which types are hidden: an item it still serves keeps its layered view", and the double's `getMetaItem` typed `Promise<any>` so the case can override it. Ablating the `item == null` half turns exactly that case red (1 failed / 13 passed), restored blob equal to HEAD. Final head `9d275b85e3` (with `origin/main` `53ef752436` merged, PR #24260 included, textually clean). - Build: `pnpm --filter '@objectstack/runtime...' --filter '@objectstack/objectql...' build` exit 0; full `pnpm build` 76/76. - Per package, every exit recorded separately: - `@objectstack/rest`: typecheck 0; `test` 0 (295 files, 6047 passed, 326 skipped); `test:repo` 0 (5 files, 270 passed, 16 skipped). - `@objectstack/runtime`: typecheck 0 (on `9d275b85e3`; it was 1 on the commit before, the test double's type, fixed there); `test` 0 (416 files, 6552 passed, 19 skipped); `test:repo` 0 (3 files, 808 passed). - `@objectstack/objectql`: typecheck 0; `test` 0 (437 files, 8244 passed); `test:repo` 0 (1 file, 5 passed). - The suites ran on the merge head; the final commit after them changes one type annotation in a test file, whose own file (14/14), runtime typecheck (0) and runtime `test:repo` (808/808) were re-run on `9d275b85e3`. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 70 commands at `9d275b85e3`. 69 exited 0 on the first pass; `check:dual-build-cjs-loads` refused with `PREREQUISITE NOT MET` (exit 3, no `dist/` in a fresh worktree) and exited 0 after the full build. `--ran`: "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0). - The first round's readings stand for the behaviour: the HTTP tables above, the ablation of step 1b, and the gate union at `ed6b7108ec` (68/68). The rework narrows when the step fires (only when the plain read serves no item), which every withheld row above already satisfies. ## Deviations - **Fence:** the claim names `rest-server.ts`'s `metaItemReadGateSources` only. This PR also adds one property, `packageId`, to the request literal `serveMetaItemLayered` hands the chain. `serveMetaItemLayered` is a class member, outside `registerMetadataEndpointsInner` and outside `meta-item-read-routes.ts`. On the dispatcher the same edit lands in `answerMetaLayered`. The reason is the measured `?package=` collision above: without the scope, the owner step would withhold an enabled package's copy of a shared name. - `packages/rest/src/objectql-slot-consumer-census.test.ts` is not on the claim's file list. Its count moves from 4 to 5, because the census exists to make each new consumer examined, the way the second half moved it from 3 to 4. ## Acceptance notes - **The dispatcher's engine seam absorbs a failed resolution.** `HttpDispatcher.getObjectQLService` catches a failed `objectql` resolution and answers `null`. On the dispatcher, then, an engine that cannot be resolved reads as "no registry to ask", and the layered doors serve as before. That is what the dispatcher's `/published` owner check, landed in PR #24247, already does; REST is loud (`503`). This is read in code, an existing property of the dispatcher's seam, not new here. A loud answer there means changing that seam for every dispatcher consumer, which is outside this card. No class; no carrier. - **Live servers:** a disabled package's item can only be runtime-saved there, because code packages refuse the disable. The code-shipped and combined cases are therefore reachable only on hosts that disable a code package on the registry (tests, embedders). --- _Generated by [Claude Code](https://claude.ai/code/session_01JeRdtdgEYYdtYB4isWp3bh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e001c7 commit 58a2faa

9 files changed

Lines changed: 464 additions & 21 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: `/meta/:type/:name/layers` and the deprecated `?layers=true` answer a disabled package's non-object item as a missing name, on both transports
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of two runtime read doors for an item whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is removed, and nothing must be rewritten; re-enabling the package restores the reads -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
- **Absent now, byte for byte the missing name's answer (`404 RESOURCE_NOT_FOUND`):** the layered view of an item of every type but `object` and `package` whose owning package is disabled, on `GET /meta/:type/:name/layers` and on `GET /meta/:type/:name?layers=true`, to every caller, an author who may write the item included. One step in the shared layered chain (`createMetaLayeredAnswer`) answers it, so the runtime dispatcher's two spellings answer the same. The owner is the one the by-name read's protection envelope names (`packageId`), read under the request's `?package=` scope, asked of the engine registry's `isPackageDisabled`. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here.
14+
- **Unchanged:** items of enabled packages, a name with nothing behind it, and the protocol's `getMetaItemLayered`, which keeps serving every layer to the readers that resolve with it (the security catalog echo, the packaged-permission-set lock gate).
15+
- **Added, optional:** `MetaItemReadGateSources.isItemOfDisabledPackage` and `MetaLayeredRequest.packageId`. A host that supplies neither answers as before.
16+
17+
What changes for you: re-enable the package to read its items' layered views again.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: the dispatcher's `/meta/:type/:name/layers` and `?layers=true` answer a disabled package's non-object item as a missing name
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of two 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 reads -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
- **Absent now, `404 RESOURCE_NOT_FOUND` "Not found", byte for byte the missing name's answer:** `GET /meta/:type/:name/layers` and `GET /meta/:type/:name?layers=true` (with its `Deprecation` header) for an item of every type but `object` and `package` whose owning package is disabled, to every caller. The owner is the one the by-name read's protection envelope names (`packageId`) under the request's `?package=` scope, as `RestServer` asks it. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here.
14+
- **Unchanged:** items of enabled packages, and a name with nothing behind it.
15+
16+
What changes for you: re-enable the package to read its items' layered views again.

‎packages/objectql/src/package-disable-enforcement.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,27 @@ const READERS: Array<{
419419
return ((await (protocol as any).getOverlayRepo().listDrafts({ packageId: PKG })) ?? []).length > 0;
420420
},
421421
},
422+
{
423+
reader: 'getMetaItemLayered({ type: "app", name }) — the producer under GET /meta/app/:name/layers and ?layers=true',
424+
enforces: false,
425+
why: '[#24210] the doors answer the absence (the shared layered chain\'s owner step, pinned per transport in @objectstack/rest and @objectstack/runtime); the producer keeps serving its resolution readers',
426+
serves: async ({ protocol }) =>
427+
(await protocol.getMetaItemLayered({ type: 'app', name: 'demo_app' }))?.effective != null,
428+
},
429+
{
430+
reader: 'getMetaItemLayered({ type: "permission", name }) — the security catalog echo and the packaged-permission-set lock gate',
431+
enforces: false,
432+
why: '[#24210] plugin-security reads the layers directly, not through a door: a disabled package\'s permission set still echoes and stays locked',
433+
serves: async ({ protocol }) =>
434+
(await protocol.getMetaItemLayered({ type: 'permission', name: shippedName('permission', PKG) }))?.effective != null,
435+
},
436+
{
437+
reader: 'getMetaItem(…).packageId — the owner the layered doors\' step and readServedPackageBinding read',
438+
enforces: false,
439+
why: '[#24210] the by-name read withholds the item but its kept envelope still names the owner, which is how the layered doors and the echo learn it',
440+
serves: async ({ protocol }) =>
441+
(await protocol.getMetaItem({ type: 'app', name: 'demo_app' }))?.packageId === PKG,
442+
},
422443
{
423444
reader: 'assertObjectRegistered — the data plane (findData / getData funnel here)',
424445
enforces: true,

0 commit comments

Comments
 (0)