Skip to content

Commit 9ae6384

Browse files
fix(metadata-protocol): the draft promotion answers NO_DRAFT before a permission-set name collision (#27825)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27707 Clause-②: no Metadata-protocol: none Skills-docs: none — a refusal-order change with no new authorable capability; nothing in `skills/**` teaches the promotion's refusal order. ## 维护者速读 - **改了什么:** 草稿发布(promotion)时,权限集"重名"检查改为只在确实读到草稿时才执行,与其他三个同类检查一致;另在 objectql 注册表的私有成员上加一行注释,注明 metadata-protocol 按名字调用它。 - **为什么改:** 以前没有草稿却去发布一个别人已占用名字的权限集,会先报 `NAMESPACE_CONFLICT`(别人的事),而不是 `NO_DRAFT`(调用方自己没草稿);现在先告诉调用方它自己的状态。 - **风险与回滚:** 极低——只是"拒绝换拒绝"的顺序调整,无草稿时之前和现在都拒绝,有草稿时检查与顺序不变;回滚即 revert 本 PR。 - **先看:** `packages/metadata-protocol/src/protocol.ts`(promotion 处 `if (draftForGate)` 一处);`packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts`(新增一条 pin);`packages/objectql/src/registry.ts`(仅注释)。 - **两分钟验证:** `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.permission-set-name-cross-package.test.ts`,预期 18/18 通过。 - **需要你做的:** 无,席位落地。 ## What changes - `packages/metadata-protocol/src/protocol.ts`, the draft promotion (`publishMetaItem`'s promotion step, which `publishPackageDrafts` also goes through): the `permissionSetNameCollisionRefusal` call now sits under `if (draftForGate)`, like its siblings `savedItemNameRefusal`, `permissionSetOwnerBindingRefusal`, `defaultRecordPageSaveRefusal` and `externalBindingSaveRefusal`. Before, it ran in a bare block. - **Refusal before/after.** If you promote with no draft while another owner holds the name, the answer used to be `NAMESPACE_CONFLICT` / 422 and is now `NO_DRAFT` / 404, the repository's answer from `repo.promoteDraft`. - **The accept set does not change.** With `draftForGate` null, `repo.promoteDraft` gets `expectedDraftHash: null`. It then answers `NO_DRAFT` when no draft row exists, or `DraftConflictError` when a draft was saved after the gate read. A promotion with no judged draft never succeeds, before or after this change. With a draft, the same refusals run in the same order. - `packages/objectql/src/registry.ts`: the doc comment of the private `packageHeldSecurityCatalogNames` now says that `protocol-save-door-refusals.ts` (`registryPackageHoldersOf`) reads it by name. Renaming the member means updating that caller and `protocol-permission-set-name-registry-reading.test.ts`. This is a comment only. - Changeset: `patch` for `@objectstack/metadata-protocol`. objectql gets none because its change is a comment, so nothing published changes. ## Tests - New pin, through the real promotion door (`publishMetaItem`, using the harness the PR #27535 tests use): B holds the active name and A has no draft. Promoting as A answers `NO_DRAFT` / 404 and leaves only B's active row stored. - Control, a draft plus a collision: the existing pins still answer `NAMESPACE_CONFLICT` / 422, for both `publishMetaItem` and `publishPackageDrafts`. - Control, a clean promote: the existing CONTROL pin still succeeds, for a first publish and a re-publish. ## Acceptance notes - None out of scope. ## Verification <details><summary>Red before the fix (test committed first, 83e4daa)</summary> ``` × 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 Expected: "NO_DRAFT" Received: "NAMESPACE_CONFLICT" Tests 1 failed | 17 passed (18) ``` </details> <details><summary>Ablation (scripts/ablation-replace.mjs) on the fix commit</summary> Anchor `if (draftForGate) {` + newline + `const nameCollision` was replaced by `if (true /* ablation: guard removed */) {`, landing x1 to x0, blob 5fb0787 to 2d3ef72c. ``` × with no draft at the address ... Expected: "NO_DRAFT" Received: "NAMESPACE_CONFLICT" Tests 1 failed | 17 passed (18) ok restored: blob == HEAD (5fb0787) and `git diff HEAD` is empty ``` The first ablation attempt used a replacement that was a substring of the anchor. The tool refused it before running anything and restored the file (blob == HEAD), so that attempt was a no-op and is not counted as a reading. </details> <details><summary>Local gates at 98bfa9d (after merging origin/main 8f25cee)</summary> - `@objectstack/metadata-protocol` test: 280 files passed, 4 skipped; 21803 tests passed, 26 skipped. Exit 0. - `@objectstack/objectql` test: 490/490 files, 9244/9244 tests. Exit 0. - typecheck for both packages: exit 0. - eslint `--no-inline-config` on `packages/metadata-protocol` and `packages/objectql`: exit 0 each. - `dispatch-gates --commands`: 63 families derived, and every one exits 0. `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3 (PREREQUISITE NOT MET) the first time and exited 0 when re-run after the build. `--ran` reconciliation: 63 run, 0 NOT-MEASURED (a derived zero). - Overlap check: `git merge-tree` against #27780 (the other open PR on `protocol.ts`) is clean. No other open PR touches these files. - Dogfood: not owed, because the title has no `!` and there is no `(narrowing)` arm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ddab8b9 commit 9ae6384

4 files changed

Lines changed: 33 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): promoting a permission set with no draft answers `NO_DRAFT` before another owner's name collision
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.

‎packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,20 @@ describe('the draft promotion refuses a permission-set draft whose name another
524524
]);
525525
});
526526

527+
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 () => {
528+
const h = makeProtocol();
529+
// B holds the active name; A has no draft of it at all.
530+
await h.protocol.saveMetaItem({ type: 'permission', name: SET, item: perm(SET), packageId: OWNER_B });
531+
h.saveDoor.mockClear();
532+
533+
const { error } = await attempt(() => h.protocol.publishMetaItem({ type: 'permission', name: SET, packageId: OWNER_A }));
534+
535+
expect(error).toBeInstanceOf(Error);
536+
expect(error.code).toBe('NO_DRAFT');
537+
expect(error.status).toBe(404);
538+
expect(storedSets(h)).toEqual([{ row: SET, state: 'active', binding: OWNER_B }]);
539+
});
540+
527541
it('CONTROL: the owning package publishes its own first set, and later re-publishes its own draft of it', async () => {
528542
const h = makeProtocol();
529543
await h.protocol.saveMetaItem({ type: 'permission', name: SET, item: perm(SET), packageId: OWNER_B, mode: 'draft' });

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23062,7 +23062,10 @@ export class ObjectStackProtocolImplementation implements
2306223062
// binding the active row is written under), before `repo.promoteDraft`
2306323063
// writes — so a batch publish aborts on it as it aborts on the owner
2306423064
// refusal above. See {@link permissionSetNameCollisionRefusal}.
23065-
{
23065+
// Asked only when a draft was read, like its siblings: with none at
23066+
// the address the caller is told `repo.promoteDraft`'s `NO_DRAFT`
23067+
// first, the fact about its own state, rather than another owner's.
23068+
if (draftForGate) {
2306623069
const nameCollision = await this.permissionSetNameCollisionRefusal(
2306723070
singularType, request.name, promoteInto, 'publish',
2306823071
);

‎packages/objectql/src/registry.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3499,6 +3499,11 @@ export class SchemaRegistry {
34993499
* {@link securityCatalogPackageHolders}'s answer, the one predicate. Built-in
35003500
* names are skipped, as the cold boot skips them (see the method above).
35013501
* Sorted by type in that list's order, then name.
3502+
*
3503+
* Also read BY NAME from `@objectstack/metadata-protocol`
3504+
* (`protocol-save-door-refusals.ts`, `registryPackageHoldersOf`): renaming
3505+
* this member means updating that caller and
3506+
* `protocol-permission-set-name-registry-reading.test.ts`.
35023507
*/
35033508
private packageHeldSecurityCatalogNames(): Array<{ catalogType: SecurityCatalogType; name: string; packageIds: string[] }> {
35043509
const held: Array<{ catalogType: SecurityCatalogType; name: string; packageIds: string[] }> = [];

0 commit comments

Comments
 (0)