From 83e4daacab496d87512a031df913306335a77495 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:18:02 +0000 Subject: [PATCH 1/2] test(metadata-protocol): pin NO_DRAFT first when promoting a permission set with no draft over a held name Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- ...tocol.permission-set-name-cross-package.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts b/packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts index 82a12308134..a3e589155a1 100644 --- a/packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts +++ b/packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts @@ -524,6 +524,20 @@ describe('the draft promotion refuses a permission-set draft whose name another ]); }); + it('with no draft at the address, the caller is told about its own state first: the repository\'s NO_DRAFT (404), not the collision another owner\'s name would raise', async () => { + const h = makeProtocol(); + // B holds the active name; A has no draft of it at all. + await h.protocol.saveMetaItem({ type: 'permission', name: SET, item: perm(SET), packageId: OWNER_B }); + h.saveDoor.mockClear(); + + const { error } = await attempt(() => h.protocol.publishMetaItem({ type: 'permission', name: SET, packageId: OWNER_A })); + + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe('NO_DRAFT'); + expect(error.status).toBe(404); + expect(storedSets(h)).toEqual([{ row: SET, state: 'active', binding: OWNER_B }]); + }); + it('CONTROL: the owning package publishes its own first set, and later re-publishes its own draft of it', async () => { const h = makeProtocol(); await h.protocol.saveMetaItem({ type: 'permission', name: SET, item: perm(SET), packageId: OWNER_B, mode: 'draft' }); From b393049e2d6f7a4386143b54505d7ae592687999 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:20:09 +0000 Subject: [PATCH 2/2] fix(metadata-protocol): ask the promotion's permission-set name judge only when a draft was read The name-collision judge at the draft promotion ran outside the draftForGate guard its sibling judges use, so a publish with no draft over a name another owner holds answered NAMESPACE_CONFLICT before the repository's NO_DRAFT. Also note on SchemaRegistry.packageHeldSecurityCatalogNames that metadata-protocol reads it by name. Claude-Session: https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F Co-authored-by: Claude --- .../27707-promotion-no-draft-before-name-collision.md | 10 ++++++++++ packages/metadata-protocol/src/protocol.ts | 5 ++++- packages/objectql/src/registry.ts | 5 +++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .changeset/27707-promotion-no-draft-before-name-collision.md diff --git a/.changeset/27707-promotion-no-draft-before-name-collision.md b/.changeset/27707-promotion-no-draft-before-name-collision.md new file mode 100644 index 00000000000..90769b8ece4 --- /dev/null +++ b/.changeset/27707-promotion-no-draft-before-name-collision.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): promoting a permission set with no draft answers `NO_DRAFT` before another owner's name collision + +Clause-②: no +Metadata-protocol: none + +The draft promotion (`POST /api/v1/meta/permission/:name/publish`, and `publishPackageDrafts`, which promotes through the same step) asked the permission-set name judge (`NAMESPACE_CONFLICT` / 422) even when no draft existed at the address, so a publish with nothing to publish, of a name another package holds, answered the collision instead of `NO_DRAFT` / 404. The name judge now runs only when a draft was read, like the promotion's other draft judges, so the caller is told about its own state first. Refusal for refusal: with no draft the promotion was refused before and is refused now, and with a draft the same refusals run in the same order. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3bb92c0e3cd..5fb0787b146 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -23043,7 +23043,10 @@ export class ObjectStackProtocolImplementation implements // binding the active row is written under), before `repo.promoteDraft` // writes — so a batch publish aborts on it as it aborts on the owner // refusal above. See {@link permissionSetNameCollisionRefusal}. - { + // Asked only when a draft was read, like its siblings: with none at + // the address the caller is told `repo.promoteDraft`'s `NO_DRAFT` + // first, the fact about its own state, rather than another owner's. + if (draftForGate) { const nameCollision = await this.permissionSetNameCollisionRefusal( singularType, request.name, promoteInto, 'publish', ); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 4c61b670569..7de0224d1c5 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -3499,6 +3499,11 @@ export class SchemaRegistry { * {@link securityCatalogPackageHolders}'s answer, the one predicate. Built-in * names are skipped, as the cold boot skips them (see the method above). * Sorted by type in that list's order, then name. + * + * Also read BY NAME from `@objectstack/metadata-protocol` + * (`protocol-save-door-refusals.ts`, `registryPackageHoldersOf`): renaming + * this member means updating that caller and + * `protocol-permission-set-name-registry-reading.test.ts`. */ private packageHeldSecurityCatalogNames(): Array<{ catalogType: SecurityCatalogType; name: string; packageIds: string[] }> { const held: Array<{ catalogType: SecurityCatalogType; name: string; packageIds: string[] }> = [];