Skip to content

Commit 434d074

Browse files
committed
fix(runtime,metadata): publish and revert refuse a read-only package with 422 WRITABLE_PACKAGE_REQUIRED
Triage's answer on the card: the publish door refuses a non-writable package before publishPackage, and the revert door refuses one after the protocol's stored-row answer, both through requireWritablePackage (ADR-0070 D2). With the publish door guarded, publishPackage reads the same membership as revertPackage (collectPackageMembers). Pins flipped to the new answers; controls for a writable package, an unknown id and a stored row bound to a code package. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60ed94f commit 434d074

6 files changed

Lines changed: 243 additions & 134 deletions
Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
---
22
'@objectstack/metadata': patch
3+
'@objectstack/runtime': patch
34
---
45

5-
fix(metadata): reverting a code-shipped package no longer answers 404 "No metadata items found" for items every read serves
6+
fix(runtime,metadata): publishing or reverting a read-only package is refused with `422 WRITABLE_PACKAGE_REQUIRED`, and package membership reads the `_packageId` stamp
67

78
Clause-②: no
89

9-
`MetadataManager.revertPackage` collected a package's members by an item's own `packageId` or `package` key. A code-shipped item carries only the private `_packageId` stamp: the artifact loader writes it through `applyProtection`, and the ObjectQL object bridge copies it onto every object it registers in the metadata service. So `POST /api/v1/packages/:id/revert` answered `404 RESOURCE_NOT_FOUND` "No metadata items found for package '…'" for a code-shipped package, for example `com.objectstack.platform-objects`, while every read served that package's items.
10+
ADR-0070 D2 makes a code or installed package read-only. `POST /api/v1/packages/:id/publish` and `POST /api/v1/packages/:id/revert` did not check this. These two answers change:
1011

11-
`revertPackage` now also counts an item stamped with `_packageId` as a member. A code-shipped package that nothing was ever published for now gets the declared `409 RESOURCE_CONFLICT` "Package '…' has never been published". A package that was published is restored exactly as before, and an id that nothing carries still answers `404`.
12+
- **Publish of a code or installed package: 200 → 422.** Before, the publish door answered 200. For a package whose items carry an authored `packageId`, for example `com.example.showcase`'s two capabilities, it answered `success: true` and wrote `publishedDefinition`, `state` and `version` onto those read-only items. For every other code package it answered `success: false`, "No metadata items found". It now answers `422 WRITABLE_PACKAGE_REQUIRED` before anything is written, the same refusal that `PATCH /packages/:id/disable` and `DELETE /packages/:id` already give.
13+
- **Revert of a code or installed package: 404 or 409 → 422.** Before, the revert door answered `404 RESOURCE_NOT_FOUND` "No metadata items found" (for example `com.objectstack.setup` and the platform packages that ship objects) or `409 RESOURCE_CONFLICT` "Package '…' has never been published" (for example `com.example.showcase`). It now answers `422 WRITABLE_PACKAGE_REQUIRED`. The check runs after the protocol's stored-row answer, so a revert of a code package that has a stored row bound to it, such as an organization overlay draft, keeps its answer.
1214

13-
`publishPackage` still finds members by `packageId` and `package` only. If it also read the stamp, nothing in it would refuse a read-only code package: the package's objects would be snapshotted and re-registered as published, which ADR-0070 D2 rules out. So a code package's stamped items are still not published by it.
15+
To customise what a code package provides, use an ADR-0005 organization overlay. Overlay drafts publish through `POST /api/v1/packages/:id/publish-drafts` and the per-item publish door, and this change leaves both alone.
1416

15-
A package whose items the metadata service never holds still answers `404`. This covers a package that ships only apps, navigation or docs, such as `com.objectstack.setup`.
17+
**Unchanged:** a writable package's publish and revert, and an id that nothing carries (revert 404; publish 200 with `success: false`).
18+
19+
`MetadataManager.publishPackage` and `revertPackage` now find a package's members by `packageId`, `package` or the private `_packageId` stamp. The artifact loader writes that stamp through `applyProtection`, and the ObjectQL object bridge copies it onto every object it registers. Before, an item that carried only the stamp was not a member. `MetadataManager` has no notion of package kind; the refusal of read-only packages lives at the two doors above.

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

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,8 +1796,8 @@ export class MetadataManager implements IMetadataService {
17961796
}
17971797

17981798
/**
1799-
* [#22113] The registry items that belong to `packageId`, for
1800-
* {@link revertPackage}.
1799+
* [#22113] The registry items that belong to `packageId`, for the two
1800+
* package-wide writes ({@link publishPackage}, {@link revertPackage}).
18011801
*
18021802
* An item belongs to a package when any of three keys names it:
18031803
*
@@ -1811,12 +1811,17 @@ export class MetadataManager implements IMetadataService {
18111811
* carries ONLY this key, and it is the key the metadata protocol scopes
18121812
* this registry's items by when it serves a package's reads.
18131813
*
1814-
* Reading the first two alone made a code-shipped package's revert answer
1815-
* 404 "No metadata items found" while every read served its items. With the
1816-
* members found, the per-item branches answer as they do for any package:
1817-
* a code package nothing was ever published for is refused with the
1818-
* declared 409 "has never been published", never a silent no-op success
1819-
* (ADR-0070 D2: a code package is read-only).
1814+
* Reading the first two alone made a package's publish and revert miss
1815+
* every item that carries only the stamp ("No metadata items found" while
1816+
* every read served them).
1817+
*
1818+
* ⛔ Not a writability check. This class has no package-kind concept, so it
1819+
* cannot tell a read-only code package (ADR-0070 D2) from a writable one,
1820+
* and with the stamp read it would publish a code package's items like any
1821+
* other. The refusal belongs to the caller that holds the predicate: the
1822+
* `POST /packages/:id/publish` and `/revert` doors (`@objectstack/runtime`)
1823+
* refuse a non-writable package with `422 WRITABLE_PACKAGE_REQUIRED` before
1824+
* calling either method.
18201825
*/
18211826
private collectPackageMembers(packageId: string): Array<{ type: string; name: string; data: any }> {
18221827
const members: Array<{ type: string; name: string; data: any }> = [];
@@ -1868,25 +1873,10 @@ export class MetadataManager implements IMetadataService {
18681873
const shouldValidate = options?.validate !== false;
18691874
const publishedBy = options?.publishedBy;
18701875

1871-
// Collect all items belonging to this package.
1872-
//
1873-
// [#22113] Deliberately NOT `collectPackageMembers`: these two keys miss a
1874-
// code-shipped item, which carries only the `_packageId` stamp, and that
1875-
// miss is what keeps this method from publishing a read-only code package
1876-
// (ADR-0070 D2). With the stamp read here, nothing below refuses one: a
1877-
// platform package's objects validate, are snapshotted and are
1878-
// re-registered as published (measured on a showcase boot). How publish
1879-
// answers a read-only package is a decision this method does not make by
1880-
// changing its member set.
1881-
const packageItems: Array<{ type: string; name: string; data: any }> = [];
1882-
for (const [type, typeStore] of this.registry) {
1883-
for (const [name, data] of typeStore) {
1884-
const meta = data as any;
1885-
if (meta?.packageId === packageId || meta?.package === packageId) {
1886-
packageItems.push({ type, name, data: meta });
1887-
}
1888-
}
1889-
}
1876+
// Collect all items belonging to this package — the same membership
1877+
// `revertPackage` reads. A read-only code package never gets here through
1878+
// the publish door, which refuses it first (see `collectPackageMembers`).
1879+
const packageItems = this.collectPackageMembers(packageId);
18901880

18911881
if (packageItems.length === 0) {
18921882
return {

‎packages/metadata/src/metadata-service.test.ts‎

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -823,12 +823,13 @@ describe('MetadataManager — IMetadataService Contract', () => {
823823
});
824824
});
825825

826-
// [#22113] A code-shipped item carries ONLY the private `_packageId`
827-
// stamp — `applyProtection` writes it on every item an artifact registers,
828-
// and the ObjectQL object bridge copies it onto every object. Collecting
829-
// members by `packageId` / `package` alone answered 404 "No metadata items
830-
// found" for a package every read serves.
831-
it('finds a code-shipped package by the _packageId stamp: never published ⇒ 409, not 404', async () => {
826+
// [#22113] An item can carry ONLY the private `_packageId` stamp —
827+
// `applyProtection` writes it on every item an artifact registers, and the
828+
// ObjectQL object bridge copies it onto every object. Collecting members by
829+
// `packageId` / `package` alone answered 404 "No metadata items found" for a
830+
// package every read serves. (Whether the package may be reverted at all is
831+
// the door's question — it refuses a read-only one with 422 first.)
832+
it('finds a package by the _packageId stamp: never published ⇒ 409, not 404', async () => {
832833
await manager.register('object', 'code_item', {
833834
name: 'code_item', label: 'Code Item', _packageId: 'com.acme.code', _provenance: 'package',
834835
});
@@ -868,23 +869,23 @@ describe('MetadataManager — IMetadataService Contract', () => {
868869
});
869870
});
870871

871-
// [#22113] `publishPackage` deliberately keeps the two-key lookup: with the
872-
// stamp read, nothing in it refuses a read-only code package (ADR-0070 D2),
873-
// so a code package's items would be snapshotted and re-registered as
874-
// published. This pins that an item carrying only the stamp is NOT
875-
// published by this method until that decision is made.
872+
// [#22113] `publishPackage` reads the same membership as `revertPackage`:
873+
// an item carrying only the stamp is a member. (Flipped from the pin that
874+
// held it OUT while nothing refused a read-only code package; the
875+
// `POST /packages/:id/publish` door now refuses one with
876+
// `422 WRITABLE_PACKAGE_REQUIRED` before this method runs.)
876877
describe('publishPackage — an item carrying only the _packageId stamp', () => {
877-
it('is not a member: nothing is snapshotted', async () => {
878-
await manager.register('object', 'code_item', {
879-
name: 'code_item', label: 'Code Item', _packageId: 'com.acme.code', _provenance: 'package',
878+
it('is a member: it is snapshotted and published', async () => {
879+
await manager.register('object', 'stamped_item', {
880+
name: 'stamped_item', label: 'Stamped Item', _packageId: 'com.acme.stamped', _provenance: 'package',
880881
});
881882

882-
const result = await manager.publishPackage('com.acme.code', { validate: false });
883+
const result = await manager.publishPackage('com.acme.stamped', { validate: false });
883884

884-
expect(result).toMatchObject({ success: false, itemsPublished: 0 });
885-
const item = await manager.get('object', 'code_item') as any;
886-
expect(item.publishedDefinition).toBeUndefined();
887-
expect(item.state).toBeUndefined();
885+
expect(result).toMatchObject({ success: true, itemsPublished: 1, version: 1 });
886+
const item = await manager.get('object', 'stamped_item') as any;
887+
expect(item.state).toBe('active');
888+
expect(item.publishedDefinition).toMatchObject({ name: 'stamped_item', label: 'Stamped Item' });
888889
});
889890
});
890891

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,12 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon
378378
* authoring one ("switch to a writable package in the package selector") names
379379
* a remedy that makes no sense for a delete.
380380
*
381+
* [#22113] `POST /packages/:id/publish` and `POST /packages/:id/revert` ask it
382+
* too, with the same predicate and refusal: publishing or reverting a code
383+
* package's shipped items is an authoring write into a read-only package
384+
* (ADR-0070 D2). Revert asks it only after the protocol's stored-row answer, so
385+
* a `sys_metadata` row bound to the package keeps its own answer.
386+
*
381387
* [#14451] The remedy it names is ADR-0005 org overlay, and it used to name
382388
* `POST /packages/:id/duplicate` instead. That sentence sent a caller holding a
383389
* read-only package at a route which — for exactly the packages this refusal
@@ -1459,6 +1465,16 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
14591465
if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') {
14601466
const denied = requireManageMetadata(deps, _context); if (denied) return denied;
14611467
const id = decodeURIComponent(parts[0]);
1468+
// [#22113] ADR-0070 D2: a code or installed package is read-only, so
1469+
// this door refuses it BEFORE `publishPackage`, which would otherwise
1470+
// snapshot and re-register the package's items as published (measured:
1471+
// a platform package's objects, and the showcase's capabilities, at
1472+
// 200). The metadata service has no package-kind concept to refuse
1473+
// with; the door's one predicate does. An organization overlay draft
1474+
// on a code package is not published here: it is a `sys_metadata` row
1475+
// the protocol publishes through `publish-drafts` and the per-item
1476+
// publish door, neither of which passes this branch.
1477+
const readOnly = requireWritablePackage(deps, qlService, id, 'publish'); if (readOnly) return readOnly;
14621478
const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata);
14631479
if (metadataService && typeof (metadataService as any).publishPackage === 'function') {
14641480
const result = await (metadataService as any).publishPackage(id, body || {});
@@ -1917,6 +1933,14 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
19171933
return { handled: true, response: deps.errorFromThrown(e, 500) };
19181934
}
19191935
}
1936+
// [#22113] ADR-0070 D2: a code or installed package has nothing of its
1937+
// own to revert, so it is refused here, AFTER the protocol's stored-row
1938+
// answer above. A stored row bound to it (an organization overlay
1939+
// draft) is still answered by the protocol, exactly as before; only the
1940+
// metadata service's revert of the package's shipped items is refused.
1941+
// "Has never been published" would point the caller at publishing,
1942+
// which the publish door above refuses for the same package.
1943+
const readOnly = requireWritablePackage(deps, qlService, id, 'revert'); if (readOnly) return readOnly;
19201944
const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata);
19211945
if (metadataService && typeof (metadataService as any).revertPackage === 'function') {
19221946
await (metadataService as any).revertPackage(id);

0 commit comments

Comments
 (0)