Skip to content

Commit afb7f17

Browse files
committed
fix(metadata-protocol): the per-item publish refuses an object without its package namespace prefix
The third door that makes an object live under a package now asks the same judge as the direct save and the publish-drafts pre-flight, on the package the promotion writes under, and refuses with NAMESPACE_PREFIX / 422 and the same sentence before anything is promoted. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 52b8ccf commit afb7f17

3 files changed

Lines changed: 122 additions & 30 deletions

File tree

‎.changeset/24547-active-save-namespace-prefix.md‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,27 @@
22
'@objectstack/metadata-protocol': minor
33
---
44

5-
fix(metadata-protocol)!: the direct metadata save refuses an object whose name lacks its package's namespace prefix, as the package draft publish already does
5+
fix(metadata-protocol)!: the direct metadata save and the per-item publish refuse an object whose name lacks its package's namespace prefix, as the package draft publish already does
66

77
Clause-②: no (narrowing)
88

9-
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the direct (publish-mode) metadata save door of an object name that the package draft publish pre-flight, `os validate` and `os build` already refuse under ADR-0129 D2: no authorable key, spelling, export, error code or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose name lacks its package's prefix keeps loading and serving; the repair is a new object under the prefixed name, which moves its table and so is the author's decision, not something a ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration or an export (not runtime-interface-only or type-surface-only). -->
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the direct (publish-mode) metadata save door and at the per-item publish door of an object name that the package draft publish pre-flight, `os validate` and `os build` already refuse under ADR-0129 D2: no authorable key, spelling, export, error code or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose name lacks its package's prefix keeps loading and serving; the repair is a new object under the prefixed name, which moves its table and so is the author's decision, not something a ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these doors (not already-registered); and the change is a door verdict, not a declaration or an export (not runtime-interface-only or type-surface-only). -->
1010

11-
**BREAKING** (an accept-set narrowing on the metadata save door), shipped as `minor` under the launch-window convention for breaking changes.
11+
**BREAKING** (an accept-set narrowing on the metadata save and publish doors), shipped as `minor` under the launch-window convention for breaking changes.
1212

13-
ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and every object it defines is named `${namespace}_${shortName}`. The package draft publish (`POST /api/v1/packages/:id/publish-drafts`) refused an object draft without that prefix with `NAMESPACE_PREFIX`, while a direct save of the very same name under the very same package was stored `active` and went live. That left an object whose own package could never publish a draft of it again, and two doors giving opposite verdicts on one name.
13+
ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and every object it defines is named `${namespace}_${shortName}`. The package draft publish (`POST /api/v1/packages/:id/publish-drafts`) refused an object draft without that prefix with `NAMESPACE_PREFIX`. Two other doors made that very same name `active` under the very same package: a direct save, and the publish of the single draft. That left an object whose own package could never publish a draft of it again, and three doors giving opposite verdicts on one name.
1414

15-
- FROM: a publish-mode object save bound to a package that declares a namespace answered `200` and stored the object `active` when its name lacked the `${namespace}_` prefix. This covers `PUT /api/v1/meta/object/:name?package=:id` (and `saveMetaItem` in publish mode), including a save naming no package that the door writes into such a package because the object's stored rows are bound to it.
16-
- TO: it answers `422`, code `NAMESPACE_PREFIX`, with the sentence the draft publish gives for the same name, for example "Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup')." Nothing is written.
15+
- FROM: an object made `active` under a package that declares a namespace was accepted with `200` when its name lacked the `${namespace}_` prefix. Two doors did this:
16+
- a publish-mode save: `PUT /api/v1/meta/object/:name?package=:id` (and `saveMetaItem` in publish mode), including a save naming no package that the door writes into such a package because the object's stored rows are bound to it;
17+
- the promotion of a single draft: `POST /api/v1/meta/object/:name/publish?package=:id` (and `publishMetaItem`), including a publish naming no package whose draft the door promotes into such a package.
18+
- TO: both doors answer `422`, code `NAMESPACE_PREFIX`. The sentence is the one the draft publish gives for the same name, for example "Object 'v1_report' is missing the package namespace prefix. Rename it to 'rollup_v1_report' (namespace = 'rollup')."
19+
- The save writes nothing.
20+
- The publish promotes nothing, and the draft stays a draft.
1721
- Fix: name the object `${namespace}_${shortName}` (here `rollup_v1_report`), as `os validate`, `os build` and the draft publish already require. A package's namespace is the one it declares, or the one derived from its id when it was installed without one (`com.v1.rollup` is `rollup`).
1822

1923
**Unchanged.**
2024

21-
- A package that declares no namespace, and a save bound to no package, are not judged.
22-
- A draft save (`mode=draft`) stores as before; the draft publish judges the draft, as before, now with the same sentence the direct save gives.
25+
- A package that declares no namespace, and a save or publish bound to no package, are not judged.
26+
- A draft save (`mode=draft`) stores as before. The door that promotes the draft judges it, now with one sentence on all three doors.
2327
- `sys_*` names are exempt, as everywhere the rule runs.
24-
- Stored rows are not migrated and not refused on read. The next direct save of a stored object whose name lacks its package's prefix is refused.
25-
- Not judged by this change: the promotion of a single draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`).
28+
- Stored rows are not migrated and not refused on read. The next direct save or single-draft publish of a stored object whose name lacks its package's prefix is refused.

‎packages/metadata-protocol/src/protocol.active-save-namespace-prefix.test.ts‎

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFin
99
import { ObjectStackProtocolImplementation } from './protocol.js';
1010

1111
/**
12-
* [#24547 · ADR-0129 D2] The package namespace-prefix rule holds at BOTH
13-
* runtime doors that make an object live.
12+
* [#24547 · ADR-0129 D2] The package namespace-prefix rule holds at ALL THREE
13+
* runtime doors that make an object live under a package: the direct save,
14+
* the package draft publish, and the per-item publish.
1415
*
1516
* ADR-0129 D2: a package declares its prefix once (`manifest.namespace`), and
1617
* every object it defines MUST be named `${namespace}_${shortName}`. The
@@ -38,7 +39,10 @@ import { ObjectStackProtocolImplementation } from './protocol.js';
3839
* - a package-less publish save the door resolves INTO a namespaced package
3940
* is judged as a save naming that package;
4041
* - a draft save is unchanged, and the publish door still refuses its draft —
41-
* with the same sentence the active door now answers.
42+
* with the same sentence the active door now answers;
43+
* - the per-item publish (`POST /meta/object/:name/publish`), which promoted
44+
* that draft `active` as well, refuses it with the same envelope and
45+
* sentence and promotes nothing, while a prefixed draft still publishes.
4246
*/
4347

4448
interface Row {
@@ -272,8 +276,8 @@ describe('[#24547 · ADR-0129 D2] the active save door refuses an object without
272276
packageId: PKG,
273277
}).then(() => undefined, (e: Error) => e);
274278

275-
// The draft save stores, as before — the publish pre-flight is the
276-
// gate a draft answers to.
279+
// The draft save stores, as before — the door that promotes a draft
280+
// is the gate it answers to.
277281
await protocol.saveMetaItem({
278282
type: 'object',
279283
name: 'v1_report',
@@ -284,14 +288,67 @@ describe('[#24547 · ADR-0129 D2] the active save door refuses an object without
284288
expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]);
285289

286290
const res = await protocol.publishPackageDrafts({ packageId: PKG } as any);
291+
const itemRefusal = await protocol.publishMetaItem({
292+
type: 'object',
293+
name: 'v1_report',
294+
packageId: PKG,
295+
}).then(() => undefined, (e: Error) => e);
287296

288297
expect(res).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0, failedCount: 1 });
289298
expect(res.failed[0]).toMatchObject({ type: 'object', name: 'v1_report', code: 'NAMESPACE_PREFIX' });
290-
// ONE rule, ONE sentence: the two doors cannot disagree on the verdict
291-
// or on what the author is told to do about it.
299+
// ONE rule, ONE sentence: the three doors cannot disagree on the
300+
// verdict or on what the author is told to do about it.
292301
expect(activeRefusal).toBeInstanceOf(Error);
302+
expect(itemRefusal).toBeInstanceOf(Error);
293303
expect(res.failed[0].error).toBe(activeRefusal!.message);
304+
expect(itemRefusal!.message).toBe(activeRefusal!.message);
294305
// Nothing went live.
295306
expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]);
296307
});
308+
309+
it('the per-item publish refuses an unprefixed object draft under a namespaced package — NAMESPACE_PREFIX / 422, the draft stays a draft', async () => {
310+
const { engine, rows } = makeStubEngine();
311+
const protocol = new ObjectStackProtocolImplementation(engine);
312+
await protocol.saveMetaItem({
313+
type: 'object',
314+
name: 'v1_report',
315+
item: objectBody('v1_report'),
316+
packageId: PKG,
317+
mode: 'draft',
318+
});
319+
320+
const outcome = await protocol.publishMetaItem({
321+
type: 'object',
322+
name: 'v1_report',
323+
packageId: PKG,
324+
}).then(() => 'published', (e: unknown) => e);
325+
326+
expect(outcome).toMatchObject({ code: 'NAMESPACE_PREFIX', status: 422 });
327+
expect((outcome as Error).message).toMatch(/^Object 'v1_report' is missing the package namespace prefix\. /);
328+
expect((outcome as Error).message).toContain(`Rename it to '${NS}_v1_report'`);
329+
// Nothing promoted: no active row, the draft untouched.
330+
expect(rowsFor(rows, 'v1_report')).toEqual([{ state: 'draft', package_id: PKG }]);
331+
});
332+
333+
it('control: the per-item publish promotes a prefixed object draft under the same package', async () => {
334+
const { engine, rows } = makeStubEngine();
335+
const protocol = new ObjectStackProtocolImplementation(engine);
336+
await protocol.saveMetaItem({
337+
type: 'object',
338+
name: `${NS}_v1_report`,
339+
item: objectBody(`${NS}_v1_report`),
340+
packageId: PKG,
341+
mode: 'draft',
342+
});
343+
344+
const res = await protocol.publishMetaItem({
345+
type: 'object',
346+
name: `${NS}_v1_report`,
347+
packageId: PKG,
348+
});
349+
350+
expect(res.success).toBe(true);
351+
expect(rowsFor(rows, `${NS}_v1_report`)).toContainEqual({ state: 'active', package_id: PKG });
352+
expect(rowsFor(rows, `${NS}_v1_report`)).not.toContainEqual({ state: 'draft', package_id: PKG });
353+
});
297354
});

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

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22900,11 +22900,13 @@ export class ObjectStackProtocolImplementation implements
2290022900
* `name` lacks the `${namespace}_` prefix of the package it is written
2290122901
* under, else `null`.
2290222902
*
22903-
* ONE judge for both runtime doors that ask the rule — the publish
22904-
* pre-flight of {@link publishPackageDrafts} and the direct (publish-mode)
22905-
* save of {@link saveMetaItem} — so they cannot give opposite verdicts on
22906-
* one name in one package again, nor tell the author two different things
22907-
* about it.
22903+
* ONE judge for the three runtime doors that make an object live under a
22904+
* package — the publish pre-flight of {@link publishPackageDrafts}, the
22905+
* direct (publish-mode) save of {@link saveMetaItem}, and the per-item
22906+
* publish of {@link publishMetaItem} (asked in
22907+
* {@link promoteDraftForPublish}, on the package the promotion writes
22908+
* under) — so they cannot give opposite verdicts on one name in one
22909+
* package again, nor tell the author different things about it.
2290822910
*
2290922911
* The namespace is the one the package DECLARES: its installed manifest,
2291022912
* where {@link installPackage} derives and persists one when the author
@@ -23376,9 +23378,12 @@ export class ObjectStackProtocolImplementation implements
2337623378
// ({@link publishPackageDrafts}) refused an unprefixed object draft
2337723379
// with `NAMESPACE_PREFIX`, while this save stored the very same name
2337823380
// `active` under the very same package — an object live under a name
23379-
// its own package's drafts could never publish again. Both doors now
23380-
// ask one judge ({@link objectNamespacePrefixViolation}) and answer
23381-
// its one authored prescription.
23381+
// its own package's drafts could never publish again. The three doors
23382+
// that make an object live under a package — this save, the publish
23383+
// pre-flight and the per-item publish ({@link publishMetaItem}, asked
23384+
// in {@link promoteDraftForPublish}) — ask one judge
23385+
// ({@link objectNamespacePrefixViolation}) and answer its one authored
23386+
// prescription.
2338223387
//
2338323388
// Judged against the package this save WRITES: the one it names, or
2338423389
// the one a package-less publish save resolved to above — the save IS
@@ -23388,9 +23393,10 @@ export class ObjectStackProtocolImplementation implements
2338823393
// wrong one. Before every gate that reads or reshapes the body, so a
2338923394
// refused name costs no parse and writes nothing.
2339023395
//
23391-
// Asked of a publish-mode save only. A draft is not live: the publish
23392-
// pre-flight judges it, batch-atomically and in one report with every
23393-
// other violation, which is that door's contract. Not asked of the
23396+
// Asked of a publish-mode save only. A draft is not live: whichever
23397+
// door promotes it judges it — the publish pre-flight batch-atomically
23398+
// and in one report with every other violation, the per-item publish
23399+
// before its promotion writes. Not asked of the
2339423400
// server-stated rewrites of a row this store already holds (`source`
2339523401
// set: the stored-metadata migration, the stored flow-credential move)
2339623402
// — they define no object, and refusing one would strand a repair of a
@@ -25618,6 +25624,31 @@ export class ObjectStackProtocolImplementation implements
2561825624
);
2561925625
}
2562025626
}
25627+
// [#24547 · ADR-0129 D2] …and the namespace-prefix rule, the third
25628+
// door's turn: the per-item publish (`POST /meta/object/:name/publish`)
25629+
// made an object `active` under a namespaced package although its name
25630+
// lacks the `${namespace}_` prefix — the name the direct save and the
25631+
// publish pre-flight refuse. Asked with their judge
25632+
// ({@link objectNamespacePrefixViolation}) and their envelope
25633+
// (`NAMESPACE_PREFIX` / 422, the judge's sentence), on `promoteInto` —
25634+
// the package the active row is written under, as the D9.9 refusal
25635+
// above is — and before `repo.promoteDraft` writes, so nothing is
25636+
// promoted and the draft stays a draft. A promotion into no package,
25637+
// or into a package that declares no namespace, is not judged; nor is
25638+
// sealed managed content, for the D9.9 block's reason (its store
25639+
// refusal is that row's one answer). `publishPackageDrafts` also
25640+
// promotes through here, but its pre-flight has refused the same name
25641+
// with the same judge before any promotion runs, so this answers the
25642+
// per-item publish alone.
25643+
if (draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name)) {
25644+
const prefixViolation = this.objectNamespacePrefixViolation(request.name, promoteInto);
25645+
if (prefixViolation) {
25646+
const err: any = new Error(prefixViolation);
25647+
err.code = 'NAMESPACE_PREFIX';
25648+
err.status = 422;
25649+
throw err;
25650+
}
25651+
}
2562125652
// [#21470] …and the divergent `name` refusal, on the same body and for
2562225653
// the same reason: a draft stored before `saveMetaItem` judged every
2562325654
// type can carry a `name` that is not its row's, and promoting it
@@ -26598,8 +26629,9 @@ export class ObjectStackProtocolImplementation implements
2659826629
// one by the time they publish.
2659926630
//
2660026631
// [#24547] The verdict is {@link objectNamespacePrefixViolation}'s, the
26601-
// judge the direct save in {@link saveMetaItem} asks too, so the two
26602-
// doors give one verdict and one sentence for one name in one package.
26632+
// judge the direct save in {@link saveMetaItem} and the per-item
26633+
// publish ({@link promoteDraftForPublish}) ask too, so the three doors
26634+
// give one verdict and one sentence for one name in one package.
2660326635
/**
2660426636
* Every PRE-FLIGHT refusal of this batch, in one report.
2660526637
*

0 commit comments

Comments
 (0)