Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/22024-no-package-read-wears-served-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/metadata-protocol': patch
---

A by-name metadata read that names no package now wears the package of the body it serves

Clause-②: no

- **What was wrong.** `getMetaItem` with no `packageId` (behind `GET /api/v1/meta/TYPE/NAME` when no package is named) merges the registry artifact's protection envelope, `_packageId`, `_packageVersion` and `_provenance`, over the body it serves. With no package named, that envelope was the first-registered package's. When two installed packages ship one name and the body served was the other package's, the answer carried that body under the wrong package. Two cases were measured. In the first, both packages ship a view container and one of them stores a copy of it: the read served the copy's view and named the first-registered package. In the second, a stored row of the name is bound to one package. The answer's top-level `packageId` / `provenance` / `packageVersion` fields, which are read off the served item, said the same wrong thing.
- **What it does now.** With no package named, the envelope is looked up at the package the served item is bound to. That is the stored row's package, the package of the container copy that expands the name, or the `_packageId` of the MetadataService or registry item. This is the rule the `GET /api/v1/meta/TYPE` list already applies to each item it serves, so the list and the by-name read now give one envelope for one served body.
- **Unchanged.** Which body the read serves. A read naming a package. The lock family and the `lock` / `editable` / `deletable` envelope, which still come from the item-lock resolution over the read's own address. A served item bound to no package (a package-less stored row or copy, or a registry entry with no package) keeps the package-less lookup it had, so for a name only one package ships, a tenant's package-less overlay still wears that package's envelope. The layered read (`/layers`) is not changed.
- ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently.
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #22024 — every `lookupArtifactItem(` in `protocol.ts` carries a recorded
* disposition, so the closing card's classification cannot go stale when a
* call is added, removed or re-scoped.
*
* `lookupArtifactItem(type, name, currentPackageId?)` answers the registry's
* artifact for a name, prefer-local to `currentPackageId` and else the first
* composite: the first-registered package's. A call that passes no package,
* or one that can be `undefined`, therefore answers the first-registered
* package's artifact for a name two packages ship. The question each row
* below answers is the card's: does that artifact's `_packageId` (its
* envelope) reach an answer whose body came from a different package?
*
* One row did: the by-name read's envelope merge in `getMetaItem`, which
* served one package's stored copy under the first-registered package's
* `_packageId`. It now looks its envelope up at the served item's own package
* (`envelopePackageId`, the list's rule); the behaviour is pinned in
* `protocol.org-scoped-write-refused.test.ts`, block (i).
*
* The population is the card's own enumeration command,
* `git grep -n 'lookupArtifactItem(' -- packages/metadata-protocol/src`
* (tests excluded), which is one file: every match is a call or the
* definition. Each is keyed by the class member it sits in and its argument
* text, which survive line moves and fail on any change of scope. A new call,
* a removed one or a re-scoped one turns this red until its row is recorded
* here with its disposition (and the card's table in the PR that adds it).
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.url)), 'utf8');
const CALLEE = 'lookupArtifactItem(';

/** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */
const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/;

interface Site { readonly member: string; readonly args: string }

/** Every `lookupArtifactItem(` in the source: the member it sits in and its argument text, whitespace-normalised. */
function sitesOf(source: string): Site[] {
const lines = source.split('\n');
const sites: Site[] = [];
for (let at = source.indexOf(CALLEE); at !== -1; at = source.indexOf(CALLEE, at + 1)) {
let end = at + CALLEE.length;
for (let depth = 1; depth > 0 && end < source.length; end++) {
if (source[end] === '(') depth++;
else if (source[end] === ')') depth--;
}
const args = source.slice(at + CALLEE.length, end - 1).replace(/\s+/g, ' ').trim().replace(/,$/, '');
let member = '(no member)';
for (let line = source.slice(0, at).split('\n').length - 1; line >= 0; line--) {
const header = MEMBER_HEADER.exec(lines[line]);
if (header) { member = header[1]; break; }
}
sites.push({ member, args });
}
return sites;
}

type Disposition = 'definition' | 'served-package envelope' | 'no';

/**
* The recorded disposition of every site. `noPackage`: it passes no package,
* or one that can be `undefined`. `why`: for a `no`, why its artifact's
* envelope never reaches an answer whose body came from another package.
*/
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string', noPackage: false, disposition: 'definition',
why: 'the definition itself' },
{ member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item)', noPackage: true, disposition: 'served-package envelope',
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule)' },
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId', noPackage: true, disposition: 'served-package envelope',
why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares' },
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' },
{ member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
why: 'the layered read\'s code LAYER: body and envelope are one artifact, and its provenance fields are read off that layer' },
{ member: 'packagedArtifactBase', args: 'type, name', noPackage: true, disposition: 'no',
why: 'the packaged base the translators compare a served body against: nothing of it is grafted onto the body' },
{ member: 'readCodeLayerForCarryForward', args: 'type, name, pkg', noPackage: true, disposition: 'no',
why: 'a save\'s credential carry-forward: a write, whose door strips the derived provenance keys before it persists' },
{ member: 'isArtifactBacked', args: 'type, name', noPackage: true, disposition: 'no',
why: 'a boolean predicate' },
{ member: 'isNestedArtifactField', args: '\'object\', name.slice(0, sep)', noPackage: true, disposition: 'no',
why: 'a boolean containment test over the one owner of an object' },
{ member: 'packagedArtifactOwner', args: 'folded.type, folded.name', noPackage: true, disposition: 'no',
why: 'a shipped-package check by name: it returns a package id, never an envelope on a served body' },
{ member: 'shippedArtifactsOf', args: 'type, name', noPackage: true, disposition: 'no',
why: 'the lock\'s artifact layer (the one item-lock resolution), never an envelope on a served body' },
{ member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
why: 'scoped to a named package, and kept only when it is that package\'s own' },
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined', noPackage: true, disposition: 'no',
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package' },
{ member: 'expandRuntimeViewContainer', args: 'type, String(item.name), ownPackageId', noPackage: false, disposition: 'no',
why: 'not called without a package, and kept only when it is the container\'s own package\'s' },
{ member: 'shippedViewContainerOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
why: 'returns before the lookup without a package, and keeps only that package\'s own container' },
{ member: 'runtimeViewContainerPackage', args: 'type, container.name', noPackage: true, disposition: 'no',
why: 'the package a package-less container row overlays: it becomes the expansion\'s own package, whose own artifact lends the envelope' },
{ member: 'isShippedByAnotherPackage', args: 'type, name', noPackage: true, disposition: 'no',
why: 'a boolean predicate' },
{ member: 'viewContainerNameCollisionRefusal', args: 'type, name', noPackage: true, disposition: 'no',
why: 'the save door\'s collision check: the package id names a shipper in a refusal, never an envelope on a served body' },
];

const keyOf = (site: Site) => `${site.member}: ${CALLEE}${site.args})`;

describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded disposition', () => {
const found = sitesOf(SOURCE);

it('the sites in the source are exactly the recorded ones', () => {
const recorded = SITES.map(keyOf).sort();
const inSource = found.map(keyOf).sort();
expect(
inSource.filter((key) => !recorded.includes(key)),
'a site with no recorded disposition: classify it (does its artifact\'s envelope reach an answer whose body '
+ 'came from another package?) and record it in SITES',
).toEqual([]);
expect(recorded.filter((key) => !inSource.includes(key)), 'a recorded site that is no longer in the source').toEqual([]);
expect(inSource, 'each site once').toEqual(recorded);
});

it('the population is the card\'s enumeration: one definition and every call, each in a member', () => {
// Non-vacuity: a scan that found nothing, or lost the members, cannot pass the test above by accident.
expect(found.filter((site) => site.member === 'lookupArtifactItem' && site.args.includes(': string'))).toHaveLength(1);
expect(found.filter((site) => site.member === '(no member)')).toEqual([]);
expect(found.length).toBe(SITES.length);
});

it('a site passing fewer than three arguments is recorded as passing no package', () => {
const topLevelArgs = (args: string) => {
let depth = 0;
let count = 1;
for (const c of args) {
if (c === '(') depth++;
else if (c === ')') depth--;
else if (c === ',' && depth === 0) count++;
}
return count;
};
expect(SITES.filter((site) => site.disposition !== 'definition' && topLevelArgs(site.args) < 3 && !site.noPackage).map(keyOf))
.toEqual([]);
});

it('both read doors take their envelope from one rule, the served item\'s own package when none is named', () => {
expect(SOURCE).toMatch(/\nfunction envelopePackageId\(requestedPackageId: string \| undefined, served: unknown\): string \| undefined \{/);
expect(SOURCE).toContain('const itemPackageId = envelopePackageId(packageId, it);');
expect(SITES.filter((site) => site.disposition === 'served-package envelope').map((site) => site.member).sort())
.toEqual(['getMetaItem', 'readFlattenedMetaItems']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1799,5 +1799,116 @@ describe('each package\'s copy of a view container expands into its own package\
}
}
});

// The body (h) pins is the copy's, and the read wears the envelope of
// the package whose copy it serves: the copy's own `_packageId`, as the
// env-wide list's slot of that body wears it, never the first-registered
// package's. The read naming each package keeps its own body and its
// own envelope. A stored row of the name bound to one package is served
// the same way. A package-less copy names no package: its read wears
// the envelope of a package whose list slot serves that same body.
describe('(i) the by-name read naming no package wears the envelope of the package whose body it serves', () => {
const ORDERS = [[OTHER, COPYING], [COPYING, OTHER]] as const;
const envelopeOf = (v: any) => [titleOf(v), v?._packageId, v?._provenance];
const shippedFor = (m: Member, order: readonly string[]) => order.map((pkg): [string, Record<string, unknown>] =>
[pkg, { object: 'task', ...m.body(SHIPPED_TITLE(pkg), true, pkg === COPYING ? SLUG : OTHER_SLUG) }]);
/** The env-wide list's [title, package, provenance] for every item it serves under `name`. */
async function listedEnvelopes(protocol: any, name: string): Promise<unknown[][]> {
const envWide: any = await protocol.getMetaItems({ type: 'view' });
return (envWide.items as any[]).filter((v) => v?.name === name).map(envelopeOf);
}

/**
* One store the read naming no package serves COPYING's body from, written on a fresh harness of one
* kernel. `named`: the reads naming each package are pinned on it too (the copies of a container,
* the measured case).
*/
interface Cell {
readonly label: string;
readonly named: boolean;
readonly setUp: (environmentId: string | undefined) => Promise<{ protocol: any; name: string }>;
}
const KEYED = MEMBERS.find((m) => m.member === 'a keyed member');
if (!KEYED) throw new Error('no keyed member');
const CELLS: readonly Cell[] = [
...MEMBERS.flatMap((m) => ([OTHER, undefined] as const).flatMap((owner) => ORDERS.map((order): Cell => {
const ownership = owner ? 'another package owns the object' : 'no code package owns the object';
const placement = PLACEMENTS.find((q) => q.m === m && q.owner === owner && q.ships);
if (!placement) throw new Error(`no placement for ${ownership}, ${m.member}`);
return {
label: `${m.member}; ${ownership}; ${order[0]} registered first; ${COPYING} stores a copy of the container`,
named: true,
setUp: async (environmentId) => {
const { protocol } = harness(shippedFor(m, order), environmentId, owner);
await saveCopyOf(protocol, placement, false);
return { protocol, name: loaderNames(m.body('x', true, SLUG))[0] };
},
};
}))),
...ORDERS.map((order): Cell => ({
label: `${KEYED.member}; ${order[0]} registered first; a stored row of the name, bound to ${COPYING}`,
named: false,
setUp: async (environmentId) => {
const [name] = loaderNames(KEYED.body('x', true, SLUG));
const { protocol } = harness(shippedFor(KEYED, order), environmentId);
expect((await protocol.saveMetaItem({
type: 'view', name, packageId: COPYING,
item: { name, label: COPY_TITLE, object: 'task', viewKind: 'form', config: { title: COPY_TITLE } },
})).success).toBe(true);
return { protocol, name };
},
})),
];

for (const cell of CELLS) {
it(`(a) ${cell.label}: the read naming no package serves that body under ${COPYING}'s envelope, on both kernels`, async () => {
for (const [kernel, environmentId] of KERNELS) {
const { protocol, name } = await cell.setUp(environmentId);
const read = await protocol.getMetaItem({ type: 'view', name });
expect(envelopeOf(read.item), `${kernel}: the item`).toEqual([COPY_TITLE, COPYING, 'package']);
expect(read.packageId, `${kernel}: the envelope the read reports`).toBe(COPYING);
}
});

if (cell.named) it(`(b) ${cell.label}: the read naming each package keeps its own body and its own envelope, on both kernels`, async () => {
for (const [kernel, environmentId] of KERNELS) {
const { protocol, name } = await cell.setUp(environmentId);
for (const pkg of [OTHER, COPYING]) {
const named = await protocol.getMetaItem({ type: 'view', name, packageId: pkg });
const own = pkg === COPYING ? COPY_TITLE : SHIPPED_TITLE(pkg);
expect(envelopeOf(named.item), `${kernel}: the read naming ${pkg}`).toEqual([own, pkg, 'package']);
expect(named.packageId, `${kernel}: the envelope the read naming ${pkg} reports`).toBe(pkg);
}
}
});

it(`(c) ${cell.label}: the env-wide list wears the same envelope for the body the read naming no package serves, on both kernels`, async () => {
for (const [kernel, environmentId] of KERNELS) {
const { protocol, name } = await cell.setUp(environmentId);
const read = await protocol.getMetaItem({ type: 'view', name });
expect(await listedEnvelopes(protocol, name), `${kernel}: the list's envelope of that body`)
.toContainEqual(envelopeOf(read.item));
}
});
}

for (const order of ORDERS) {
it(`control: a package-less copy stands in for both packages; ${order[0]} registered first: the read wears a package whose list slot serves it`, async () => {
const [name] = loaderNames(KEYED.body('x', true, SLUG));
for (const [kernel, environmentId] of KERNELS) {
const { protocol } = harness(shippedFor(KEYED, order), environmentId);
expect((await protocol.saveMetaItem({
type: 'view', name: 'task', item: { name: 'task', object: 'task', ...KEYED.body('Intake (env-wide copy)', true, SLUG) },
})).success).toBe(true);

const read = await protocol.getMetaItem({ type: 'view', name });
expect(titleOf(read.item), `${kernel}: the read naming no package`).toBe('Intake (env-wide copy)');
const listed = await listedEnvelopes(protocol, name);
expect(listed.map(([, pkg]) => pkg).sort(), `${kernel}: the list serves the copy in both packages' slots`).toEqual([OTHER, COPYING]);
expect(listed, `${kernel}: the env-wide list wears the same envelope for the same body`).toContainEqual(envelopeOf(read.item));
}
});
}
});
});
});
Loading
Loading