Skip to content

Commit c74aefe

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): thread packageId into both resolveDraftOrgScopeForPublish probes (#11156)
* fix(metadata-protocol): thread packageId into both resolveDraftOrgScopeForPublish probes (#11003) Maintainer ruling 2026-08-22, option A: the scope probes ask the promote's question. A package-stating per-item publish now resolves the org scope of the draft it named; the package-agnostic probe could match another package's row in the caller's org and answer 404 [no_draft] over a publishable env-wide draft. Accepted cost, on the record: a caller stating a package no longer discovers a no-package draft of the same (type, name). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y * test(metadata-protocol): stamp checksum on the direct-seeded package-less draft row The post-promotion drain is an optimistic-lock delete keyed on the row's checksum; a checksum-less hand-built row makes the drain classify the mismatch as the benign newer-draft race and survive, failing the remedy half of the accepted-cost pin. Stamp hashSpec(body) exactly as the repository's put does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2095040 commit c74aefe

3 files changed

Lines changed: 344 additions & 20 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': minor
3+
---
4+
5+
**BREAKING** (behavioral narrowing, maintainer-adjudicated): a per-item publish that states a package (`POST /api/v1/meta/:type/:name/publish?package=PKG_ID`) now resolves its draft's org scope package-exactly. Both probes in `resolveDraftOrgScopeForPublish` carry the stated `package_id`, so the scope probe asks the same question the promote does.
6+
7+
What this fixes: with two packages holding drafts for one `(type, name)` in different org scopes (the ADR-0048 coexistence), the package-agnostic scope probe could match another package's row in the caller's org, name that scope, and the package-exact promote then answered `404 [no_draft]` over a publishable draft sitting env-wide — the exact row the caller named.
8+
9+
What you may newly see: a publish that states `?package=` no longer discovers a draft of the same `(type, name)` authored with no package binding — it answers `404 [no_draft]`. That narrowing is the ruling, not a side effect: a mistyped package must fail loudly rather than silently publish some other package's draft. Remedy: if the draft you mean is the package-less one, retry the publish without the `?package=` query parameter; an unstated package keeps the historical match-any resolution.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) Request-time resolution semantics of one HTTP query parameter; no authored metadata key, spec surface, or export changes shape, so `objectstack migrate meta` has nothing to rewrite. -->

packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts

Lines changed: 281 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, expect, it } from 'vitest';
3+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
44
// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete /
55
// #5480 update), so the fake engine below cannot accept a call ObjectQL
66
// refuses. Imported from `@objectstack/metadata-core` and not from
77
// `@objectstack/objectql`: objectql DEPENDS ON this package, so that import
88
// would close a dependency cycle turbo rejects outright.
9-
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
9+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, hashSpec } from '@objectstack/metadata-core';
1010
import { ObjectStackProtocolImplementation } from './protocol.js';
1111

1212
/**
@@ -393,3 +393,282 @@ describe('publishMetaItem — the per-item door names a package too (#10350)', (
393393
expect(labelOf(active[0])).toBe('FROM_OTHER');
394394
});
395395
});
396+
397+
/**
398+
* [#11003] The ORG-SCOPE probes' half of the same ADR-0048 key — maintainer
399+
* ruling 2026-08-22, option A (recorded on the issue): the scope probes ask
400+
* the promote's question, i.e. `resolveDraftOrgScopeForPublish` threads the
401+
* stated `packageId` into BOTH of its `sys_metadata` probes.
402+
*
403+
* ## The defect these cases reproduce
404+
*
405+
* With two packages holding drafts for ONE `(type, name)` in DIFFERENT org
406+
* scopes, a package-stating publish resolved the wrong scope: probe 1 was
407+
* package-agnostic, matched the OTHER package's row in the caller's org,
408+
* named that org as the scope — and the promote (whose `whereFor` IS
409+
* package-exact since #8907/#10350) then found nothing there and answered
410+
* `404 [no_draft]` over a draft sitting env-wide, publishable, and named by
411+
* the caller.
412+
*
413+
* Unlike the #8907 cases above, NO insertion-order rigging is needed for the
414+
* wrong row to win: the two drafts live in different org partitions, so probe
415+
* 1's `organization_id` filter alone selects the foreign package's row — the
416+
* pre-fix failure is deterministic, not a driver-order coin toss.
417+
*
418+
* ## Why type `object` and the `OS_METADATA_WRITABLE` hatch
419+
*
420+
* The card's scenario is `(object, shared_ticket)`. `object` is
421+
* `allowOrgOverride: false` in the static registry, so an org-scoped object
422+
* draft exists only where the operator hatch (`OS_METADATA_WRITABLE=object`
423+
* — the Studio-side editing escape, #6190 R7) is open; with the hatch closed
424+
* the promote's own #6190 gate would answer `403 [not_overridable]` before
425+
* the probes' answer mattered, and the card's measured `404 [no_draft]`
426+
* could not be reproduced as filed. The hatch is scoped to this describe
427+
* (`beforeAll`/`afterAll` + cache reset), the same pattern
428+
* `protocol.org-scoped-write-refused.test.ts` R7 uses.
429+
*
430+
* ## Accepted cost, pinned on purpose
431+
*
432+
* The ruling's own words: a caller stating a package no longer discovers a
433+
* no-package draft of the same `(type, name)` — it 404s and the caller
434+
* retries without `?package=`; that narrowing is the ruling, not a side
435+
* effect. The last case pins BOTH halves of that sentence. The package-less
436+
* draft row is seeded by DIRECT `engine.insert`, not through
437+
* `saveMetaItem(mode:'draft')` — PR #11139 is changing how a package-less
438+
* draft save resolves its binding (inheriting the overlaid active row's
439+
* `package_id`), so a fixture seeded through that save path would stop
440+
* meaning "a package-less draft exists" the day it lands.
441+
*/
442+
describe('publishMetaItem — the scope probes ask the promote\'s question (#11003)', () => {
443+
beforeAll(() => {
444+
process.env.OS_METADATA_WRITABLE = 'object';
445+
ObjectStackProtocolImplementation.resetEnvWritableCache();
446+
});
447+
afterAll(() => {
448+
delete process.env.OS_METADATA_WRITABLE;
449+
ObjectStackProtocolImplementation.resetEnvWritableCache();
450+
});
451+
452+
/**
453+
* The card's coexistence arrangement: `app.other` holds the caller's-org
454+
* (`org1`) draft, `app.demo` holds the env-wide one. Distinct ADR-0048
455+
* rows — different `(org, package)` pairings, one `(type, name)`.
456+
*/
457+
async function seedCrossScopeDrafts(protocol: ObjectStackProtocolImplementation) {
458+
await protocol.saveMetaItem({
459+
type: 'object',
460+
name: 'shared_ticket',
461+
item: objectBody('shared_ticket', 'FROM_OTHER_ORG1'),
462+
packageId: 'app.other',
463+
organizationId: 'org1',
464+
mode: 'draft',
465+
});
466+
await protocol.saveMetaItem({
467+
type: 'object',
468+
name: 'shared_ticket',
469+
item: objectBody('shared_ticket', 'FROM_DEMO_ENV'),
470+
packageId: 'app.demo',
471+
mode: 'draft',
472+
});
473+
}
474+
475+
it('finds the draft the caller NAMED: publishing app.demo succeeds over app.other\'s same-org row', async () => {
476+
const { engine, rows } = makeStubEngine();
477+
const protocol = new ObjectStackProtocolImplementation(engine);
478+
479+
await seedCrossScopeDrafts(protocol);
480+
481+
// The ADR-0048 coexistence precondition, asserted so a future change
482+
// to the seeding cannot silently drain these cases' discriminating
483+
// power: two draft rows, the foreign package's in the CALLER'S org,
484+
// the named package's env-wide.
485+
const drafts = draftRowsOf(rows);
486+
expect(drafts.map((r) => [r.package_id, r.organization_id])).toEqual([
487+
['app.other', 'org1'],
488+
['app.demo', null],
489+
]);
490+
491+
// Pre-fix this REJECTED with `404 [no_draft]`: probe 1, package-
492+
// agnostic, matched app.other's org1 row and answered `org1`; the
493+
// package-exact promote then looked in org1 WITH
494+
// `package_id = 'app.demo'` and found nothing — while app.demo's
495+
// draft sat env-wide, publishable, and was the row the caller named.
496+
const res = await protocol.publishMetaItem({
497+
type: 'object',
498+
name: 'shared_ticket',
499+
packageId: 'app.demo',
500+
organizationId: 'org1',
501+
});
502+
503+
expect(res).toMatchObject({ success: true });
504+
const active = activeRowsOf(rows);
505+
expect(active).toHaveLength(1);
506+
expect(labelOf(active[0])).toBe('FROM_DEMO_ENV');
507+
});
508+
509+
it('lands env-wide: the caller\'s org row belongs to another package, and the promotion never touches that partition', async () => {
510+
const { engine, rows } = makeStubEngine();
511+
const protocol = new ObjectStackProtocolImplementation(engine);
512+
513+
await seedCrossScopeDrafts(protocol);
514+
await protocol.publishMetaItem({
515+
type: 'object',
516+
name: 'shared_ticket',
517+
packageId: 'app.demo',
518+
organizationId: 'org1',
519+
});
520+
521+
// The resolution's landing, pinned row-by-row: the probe fell through
522+
// to env-wide BECAUSE the caller's own org row belongs to another
523+
// package, so the active row is ENV-WIDE under the named package —
524+
// not an org1 row minted from a partition holding nothing of
525+
// app.demo's.
526+
const active = activeRowsOf(rows);
527+
expect(active).toHaveLength(1);
528+
expect(active[0].organization_id).toBeNull();
529+
expect(active[0].package_id).toBe('app.demo');
530+
// …and app.other's org1 draft is untouched — pending, undrained, in
531+
// its own partition. Pre-fix there was nothing to assert here: the
532+
// door had already refused.
533+
const drafts = draftRowsOf(rows);
534+
expect(drafts).toHaveLength(1);
535+
expect(drafts[0].package_id).toBe('app.other');
536+
expect(drafts[0].organization_id).toBe('org1');
537+
expect(labelOf(drafts[0])).toBe('FROM_OTHER_ORG1');
538+
});
539+
540+
it('still resolves the caller\'s own org when THAT is where the named package\'s draft lives (no overshoot)', async () => {
541+
const { engine, rows } = makeStubEngine();
542+
const protocol = new ObjectStackProtocolImplementation(engine);
543+
544+
// Mirrored arrangement: app.other env-wide (seeded FIRST, so any
545+
// regression back toward package-agnostic env probing has a wrong row
546+
// to find), app.demo in the caller's org.
547+
await protocol.saveMetaItem({
548+
type: 'object',
549+
name: 'shared_ticket',
550+
item: objectBody('shared_ticket', 'FROM_OTHER_ENV'),
551+
packageId: 'app.other',
552+
mode: 'draft',
553+
});
554+
await protocol.saveMetaItem({
555+
type: 'object',
556+
name: 'shared_ticket',
557+
item: objectBody('shared_ticket', 'FROM_DEMO_ORG1'),
558+
packageId: 'app.demo',
559+
organizationId: 'org1',
560+
mode: 'draft',
561+
});
562+
563+
const res = await protocol.publishMetaItem({
564+
type: 'object',
565+
name: 'shared_ticket',
566+
packageId: 'app.demo',
567+
organizationId: 'org1',
568+
});
569+
570+
// GREEN BEFORE THE FIX TOO, and stated so nobody reads a repro into
571+
// it: pre-fix probe 1 happened to answer `org1` because the only org1
572+
// row WAS app.demo's. What this case bounds is the fix itself — the
573+
// ADR-0005 precedence (own org shadows env-wide) must survive the
574+
// package narrowing, so a "package-exact means env-first/env-only"
575+
// mis-fix fails here loudly.
576+
expect(res).toMatchObject({ success: true });
577+
const active = activeRowsOf(rows);
578+
expect(active).toHaveLength(1);
579+
expect(active[0].organization_id).toBe('org1');
580+
expect(active[0].package_id).toBe('app.demo');
581+
expect(labelOf(active[0])).toBe('FROM_DEMO_ORG1');
582+
// app.other's env-wide draft: pending, undrained.
583+
const drafts = draftRowsOf(rows);
584+
expect(drafts).toHaveLength(1);
585+
expect(drafts[0].package_id).toBe('app.other');
586+
expect(drafts[0].organization_id).toBeNull();
587+
});
588+
589+
it('keeps the historical match-any probes when the caller states NO package (cross-scope fixture)', async () => {
590+
const { engine, rows } = makeStubEngine();
591+
const protocol = new ObjectStackProtocolImplementation(engine);
592+
593+
await seedCrossScopeDrafts(protocol);
594+
595+
// No `packageId` key at all: the probes stay package-agnostic, the
596+
// promote matches any package, and the ADR-0005 precedence picks the
597+
// caller's own org row — app.other's, whatever package it belongs to.
598+
// This is the same absent-key contract the #10350 case above pins
599+
// env-wide, exercised HERE because these probes only run for an
600+
// org-scoped caller (`requestOrgId === null` returns early).
601+
const res = await protocol.publishMetaItem({
602+
type: 'object',
603+
name: 'shared_ticket',
604+
organizationId: 'org1',
605+
});
606+
607+
expect(res).toMatchObject({ success: true });
608+
const active = activeRowsOf(rows);
609+
expect(active).toHaveLength(1);
610+
expect(active[0].organization_id).toBe('org1');
611+
expect(active[0].package_id).toBe('app.other');
612+
expect(labelOf(active[0])).toBe('FROM_OTHER_ORG1');
613+
});
614+
615+
it('accepted cost (the ruling, not a side effect): a package-stating caller 404s over a package-less draft, and retrying without ?package= publishes it', async () => {
616+
const { engine, rows } = makeStubEngine();
617+
const protocol = new ObjectStackProtocolImplementation(engine);
618+
619+
// Seeded by DIRECT insert — see the describe header for why this row
620+
// must not come from `saveMetaItem(mode:'draft')` while PR #11139 is
621+
// changing that path's binding resolution. The shape mirrors what the
622+
// repository's `put` writes for a package-less org draft — `checksum`
623+
// included: the post-promotion drain is an optimistic-lock delete
624+
// keyed on it, and a checksum-less row makes the drain read as the
625+
// benign "newer draft saved" race and survive (measured on this
626+
// fixture's first run).
627+
const noPackageBody = objectBody('shared_ticket', 'NO_PACKAGE');
628+
await engine.insert('sys_metadata', {
629+
type: 'object',
630+
name: 'shared_ticket',
631+
organization_id: 'org1',
632+
package_id: null,
633+
state: 'draft',
634+
metadata: JSON.stringify(noPackageBody),
635+
checksum: hashSpec(noPackageBody),
636+
version: 1,
637+
created_at: new Date().toISOString(),
638+
updated_at: new Date().toISOString(),
639+
});
640+
641+
// Half 1 — the narrowing: the caller stated `app.demo`, so neither
642+
// probe nor promote may discover the unbound row. ADR-0112 envelope,
643+
// not a bare `toThrow`. (No pre-fix red here, stated plainly: the
644+
// package-exact PROMOTE already answered `no_draft` for this
645+
// arrangement; what this pins is that the ruling's cost sentence
646+
// holds end-to-end and stays held.)
647+
await expect(
648+
protocol.publishMetaItem({
649+
type: 'object',
650+
name: 'shared_ticket',
651+
packageId: 'app.demo',
652+
organizationId: 'org1',
653+
}),
654+
).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 });
655+
// …and the refusal touched nothing: the package-less draft is intact.
656+
expect(draftRowsOf(rows)).toHaveLength(1);
657+
658+
// Half 2 — the documented remedy: retry WITHOUT `?package=`. The
659+
// absent key restores the match-any resolution and the unbound draft
660+
// publishes.
661+
const res = await protocol.publishMetaItem({
662+
type: 'object',
663+
name: 'shared_ticket',
664+
organizationId: 'org1',
665+
});
666+
expect(res).toMatchObject({ success: true });
667+
const active = activeRowsOf(rows);
668+
expect(active).toHaveLength(1);
669+
expect(active[0].package_id).toBeNull();
670+
expect(active[0].organization_id).toBe('org1');
671+
expect(labelOf(active[0])).toBe('NO_PACKAGE');
672+
expect(draftRowsOf(rows)).toHaveLength(0);
673+
});
674+
});

0 commit comments

Comments
 (0)