Repository navigation
Commit 58a2faa
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
File tree
- .changeset
- packages
- objectql/src
- rest/src
- runtime/src/domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
419 | 419 | | |
420 | 420 | | |
421 | 421 | | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
422 | 443 | | |
423 | 444 | | |
424 | 445 | | |
| |||
0 commit comments