Skip to content

Commit e00374a

Browse files
fix(metadata-protocol): an object draft's verdict names the namespace-prefix refusal its publish gives (#27444) (#27780)
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 #27444 Clause-②: no Metadata-protocol: none Skills-docs: none — no file under `skills/**` describes what a draft's `_diagnostics` carries; the namespace-prefix rule itself is unchanged. ## 维护者速读 - 改了什么:对象草稿的读取诊断(`_diagnostics`)现在会带上包命名空间前缀的拒绝项 `NAMESPACE_PREFIX`,与"全部发布"拒绝时的代码和原话一致。 - 为什么改:之前草稿缺前缀时一直显示"有效",直到发布整批时才被拒、连同其它草稿一起回滚,作者在编辑处看不到问题。 - 风险与回滚:只读诊断多一条错误;草稿保存、发布行为都不变。回滚即还原 `protocol-authoring-gate.ts` 的这一处调用。 - 先看:`packages/metadata-protocol/src/protocol-authoring-gate.ts`(`draftPublishVerdict` 末尾新增的前缀判定)、`protocol.ts`(宿主接线一行)、`protocol.draft-publish-verdict.test.ts`(新增两例)。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.draft-publish-verdict.test.ts`,预期 17/17 通过。 - 需要你做的:无,席位落地。 ## What changes - `draftPublishVerdict` (the draft read's publish verdict, folded into `_diagnostics` by `withPublishVerdict`) now asks, for an `object` draft, `objectNamespacePrefixViolation` — the one judge the publish pre-flight (`publishPackageDrafts`), the per-item publish (`promoteDraftForPublish`) and the direct publish-mode save already ask. One check, not a copy: the host is handed the class's own method (`protocol.ts`, host wiring only). - It judges against the package the draft is staged into (the draft row's `package_id`, the package "Publish all" promotes it under and passes as `request.packageId`). A draft bound to no package, or to a package that declares no namespace, gets nothing — the judge's own skip. - Finding shape: one `errors[]` entry with `code: 'NAMESPACE_PREFIX'`, `path: 'name'`, and `message` = the exact sentence publish-drafts refuses with (`Object 'qa_formula_pkg_r2' is missing the package namespace prefix. Rename it to 'r2_qa_formula_pkg_r2' (namespace = 'r2').`); `valid: false`. It is listed before the gate's own issues, the order the batch pre-flight reports it in. - Unchanged: the draft save is still ungated (ADR-0129 D2, #4463 D1) and answers 200 `state: "draft"`; publish-drafts and the per-item publish refuse exactly as before. ## Tests Two cases in `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts`, through the real save, draft read and publish-drafts doors (the #26707 harness): - an unprefixed object draft in a namespaced package saves 200 `state: draft`; publish-drafts refuses it `outcome: refused` / `NAMESPACE_PREFIX`; its draft read is `valid: false` and its `NAMESPACE_PREFIX` entry's message equals the publish-drafts `error` string, compared in the same test; - control: a prefixed object draft in the same package reads `{ valid: true }` and publishes. Red before the fix (`expected true to be false` on `diagnostics.valid`, 1 failed / 16 passed); green after. <details><summary>Ablation (scripts/ablation-replace.mjs)</summary> Mutation: the new call `? this.objectNamespacePrefixViolation(draft.name, draft.packageId)` replaced with `? null` in `protocol-authoring-gate.ts`. The test imports `./protocol.js` from `src/` (relative), so no `dist/` hop is involved. ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob c477eab -> ad467a0d1c65 × saves as a draft, and its read carries the NAMESPACE_PREFIX refusal publish-drafts gives 16ms AssertionError: expected true to be false // Object.is equality Tests 1 failed | 16 passed (17) ablation-replace: ok restored: blob == HEAD (c477eab) and `git diff HEAD` is empty ``` </details> ## Acceptance notes - The per-item publish skips sealed managed content before asking the prefix judge; the draft verdict follows the batch pre-flight instead, which has no such skip. Not measured whether a draft of a sealed managed object can exist at all; noted, not filed. - No UI change in this PR: the entry rides the same `_diagnostics.errors` the #26707 findings already ride. ## Verification Run on `dda53adf` (after merging `origin/main` once): <details><summary>Local gates</summary> - `pnpm --filter '@objectstack/metadata-protocol^...' build` :: exit 0 - `pnpm --filter @objectstack/metadata-protocol typecheck` :: exit 0 - `pnpm --filter @objectstack/metadata-protocol test` :: exit 0 — Test Files 280 passed, 4 skipped (284); Tests 21804 passed, 26 skipped (21830) - `eslint --no-inline-config packages/metadata-protocol` :: exit 0 - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 61 derived; 60 exit 0. `pnpm check:lean-entry-closure` first exited 3 (objectql `dist/` absent), re-run after `pnpm --filter @objectstack/objectql build` :: exit 0. `pnpm check:dual-build-cjs-loads` :: exit 3 — NOT MEASURED, PREREQUISITE NOT MET (needs every package's `dist/`; CI's `Lint & Repo Gates` runs it). - `--ran` reconciliation: 61 derived, 60 run, 1 NOT-MEASURED (the one above), 0 UNRUN. - Open PRs on the changed files: none of the 16 open PRs touches them, so no `merge-tree` probe applies. </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 86aaff3 commit e00374a

4 files changed

Lines changed: 92 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): an object draft's verdict names the package namespace-prefix refusal its publish gives (#27444)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
Skills-docs: none — no file under `skills/**` describes what a draft's `_diagnostics` carries; the namespace-prefix rule itself is unchanged.
10+
11+
A draft object saved into a package whose manifest declares a namespace, under a name without the `<namespace>_` prefix, read `_diagnostics: { valid: true }` until "Publish all" refused the whole batch with `NAMESPACE_PREFIX`. The draft read now asks the same judge the publish pre-flight and the per-item publish ask, on the package the draft is staged into, and folds its answer into `_diagnostics`: `valid: false` with an `errors[]` entry whose `code` is `NAMESPACE_PREFIX` and whose message is the sentence publish refuses with. The draft save itself is unchanged — it still stores and answers 200 `state: "draft"` — and publish refuses exactly as before.

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export interface AuthoringGateHost {
7474
externalBindingSaveRefusal: ObjectStackProtocolImplementation['externalBindingSaveRefusal'];
7575
foldStoredCollection: ObjectStackProtocolImplementation['foldStoredCollection'];
7676
getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo'];
77+
objectNamespacePrefixViolation: ObjectStackProtocolImplementation['objectNamespacePrefixViolation'];
7778
hierarchyResolverMounted: ObjectStackProtocolImplementation['hierarchyResolverMounted'];
7879
isArtifactBacked: ObjectStackProtocolImplementation['isArtifactBacked'];
7980
orgWallEnforced: ObjectStackProtocolImplementation['orgWallEnforced'];
@@ -461,7 +462,7 @@ export async function judgeRuntimeAuthoringRules(
461462

462463
/** The body of `ObjectStackProtocolImplementation#draftPublishVerdict`; the method's docblock there is its contract. */
463464
export async function draftPublishVerdict(
464-
this: RuntimeRulesHost & Pick<AuthoringGateHost, 'collectBatchPendingDeclarations' | 'credentialChannelFor' | 'getOverlayRepo'>,
465+
this: RuntimeRulesHost & Pick<AuthoringGateHost, 'collectBatchPendingDeclarations' | 'credentialChannelFor' | 'getOverlayRepo' | 'objectNamespacePrefixViolation'>,
465466
draft: { type: string; name: string; body: unknown; packageId: string | null },
466467
): Promise<RuntimeAuthoringIssue[]> {
467468
const singular = PLURAL_TO_SINGULAR[draft.type] ?? draft.type;
@@ -490,5 +491,22 @@ export async function draftPublishVerdict(
490491
? { restoredCredentialPaths: () => channel.heldPaths({ name: draft.name, state: 'draft', item: draft.body }) }
491492
: {}),
492493
});
493-
return (verdict.error as { issues?: RuntimeAuthoringIssue[] } | null)?.issues ?? [];
494+
const issues = (verdict.error as { issues?: RuntimeAuthoringIssue[] } | null)?.issues ?? [];
495+
// [#27444 · ADR-0129 D2] The namespace-prefix rule is not a gate rule: the
496+
// doors that make an object live under a package ask it on their own
497+
// (the publish pre-flight, the per-item publish), so the draft verdict
498+
// asks the same judge, on the package the draft is staged into — the one
499+
// "Publish all" promotes it under — and answers its code and sentence.
500+
const prefixViolation = singular === 'object'
501+
? this.objectNamespacePrefixViolation(draft.name, draft.packageId)
502+
: null;
503+
if (!prefixViolation) return issues;
504+
return [{
505+
rule: 'NAMESPACE_PREFIX',
506+
path: 'name',
507+
where: `object "${draft.name}"`,
508+
message: prefixViolation,
509+
hint: prefixViolation,
510+
severity: 'error',
511+
}, ...issues];
494512
}

‎packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,61 @@ describe('a draft reads the verdict its publish gives (#26707)', () => {
464464
});
465465
});
466466

467+
describe('[#27444] an object draft without its package namespace prefix', () => {
468+
// A package whose manifest DECLARES a namespace, beside the stub's
469+
// namespace-less base: the prefix rule judges only the former.
470+
const NS_PKG = 'qa.scratch.r2';
471+
const nsEngine = () => {
472+
const stub = makeStubEngine();
473+
const base = stub.engine.registry.getPackage;
474+
stub.engine.registry.getPackage = (id: string) =>
475+
id === NS_PKG ? { manifest: { id: NS_PKG, namespace: 'r2' } } : base(id);
476+
return stub;
477+
};
478+
const objectBody = (name: string) => ({
479+
name,
480+
label: 'Formula Pkg',
481+
sharingModel: 'private',
482+
fields: { name: { type: 'text', label: 'Name' } },
483+
});
484+
const stage = (protocol: ObjectStackProtocolImplementation, name: string) =>
485+
(protocol as any).saveMetaItem({ type: 'object', name, item: objectBody(name), packageId: NS_PKG, mode: 'draft' });
486+
const read = async (protocol: ObjectStackProtocolImplementation, name: string) =>
487+
((await protocol.getMetaItem({ type: 'object', name, state: 'draft', packageId: NS_PKG } as any)) as any)
488+
.item._diagnostics as { valid: boolean; errors?: Array<{ path: string; message: string; code?: string }> };
489+
490+
it('saves as a draft, and its read carries the NAMESPACE_PREFIX refusal publish-drafts gives', async () => {
491+
const { engine, rows } = nsEngine();
492+
const protocol = new ObjectStackProtocolImplementation(engine);
493+
494+
// The draft still saves (ADR-0129 D2: a draft is judged when it is promoted).
495+
expect(await stage(protocol, 'qa_formula_pkg_r2')).toMatchObject({ success: true, state: 'draft' });
496+
expect([...rows.values()].filter((r) => r.name === 'qa_formula_pkg_r2').map((r) => r.state)).toEqual(['draft']);
497+
498+
const diagnostics = await read(protocol, 'qa_formula_pkg_r2');
499+
500+
// The batch publish still refuses it, as before…
501+
const res: any = await protocol.publishPackageDrafts({ packageId: NS_PKG });
502+
expect(res).toMatchObject({ success: false, outcome: 'refused', publishedCount: 0 });
503+
const refused = res.failed.find((f: any) => f.name === 'qa_formula_pkg_r2');
504+
expect(refused?.code).toBe('NAMESPACE_PREFIX');
505+
506+
// …and the draft's verdict is that refusal: its code, its sentence.
507+
expect(diagnostics.valid).toBe(false);
508+
expect(diagnostics.errors?.filter((e) => e.code === 'NAMESPACE_PREFIX').map((e) => e.message))
509+
.toEqual([refused.error]);
510+
});
511+
512+
it('a prefixed draft object in the same package still reads valid:true', async () => {
513+
const { engine } = nsEngine();
514+
const protocol = new ObjectStackProtocolImplementation(engine);
515+
await stage(protocol, 'r2_formula_pkg');
516+
517+
expect(await read(protocol, 'r2_formula_pkg')).toEqual({ valid: true });
518+
expect(await protocol.publishPackageDrafts({ packageId: NS_PKG })).toMatchObject({ success: true, failedCount: 0 });
519+
});
520+
});
521+
467522
describe('[#27445] a stored dashboard carrying an undeclared filterBindings key', () => {
468523
const board = CASES.find((c) => c.rule === 'dashboard-filter-binding-unknown')!.item;
469524

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7218,6 +7218,11 @@ export class ObjectStackProtocolImplementation implements
72187218
* Computed on every draft read rather than stored at save: the verdict
72197219
* depends on the live universe and the package's other drafts, which a
72207220
* stored verdict would not see change.
7221+
*
7222+
* [#27444] An object draft is also told the namespace-prefix verdict
7223+
* ({@link objectNamespacePrefixViolation}, `NAMESPACE_PREFIX`), the one
7224+
* rule outside the gate that the promotion doors refuse a draft on — the
7225+
* same judge, on the draft row's package, never a copy of it.
72217226
*/
72227227
private draftPublishVerdict(draft: {
72237228
type: string; name: string; body: unknown; packageId: string | null;
@@ -7232,6 +7237,7 @@ export class ObjectStackProtocolImplementation implements
72327237
foldStoredCollection: (...a) => this.foldStoredCollection(...a),
72337238
getOverlayRepo: (...a) => this.getOverlayRepo(...a),
72347239
hierarchyResolverMounted: (...a) => this.hierarchyResolverMounted(...a),
7240+
objectNamespacePrefixViolation: (...a) => this.objectNamespacePrefixViolation(...a),
72357241
orgWallEnforced: (...a) => this.orgWallEnforced(...a),
72367242
resolveSduiManifest: (...a) => this.resolveSduiManifest(...a),
72377243
resolveWritePackageScope: (...a) => this.resolveWritePackageScope(...a),

0 commit comments

Comments
 (0)