From 7dc85201fc4f1b5a029ffb28731d993c35f4c36a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:57:32 +0000 Subject: [PATCH 1/5] fix(metadata-protocol): a row bound to one package is not registered under a name another package ships The registry's bare slot answers every package's read of a name ahead of that package's own entry. A stored row bound to one package was hydrated there, so the by-name read naming another package that ships the name served the row's body under that package's envelope, on an unscoped kernel after a save and on either kernel after a cold boot. The hydrator now skips that registration (the shape the expansion registration already takes), and the delete's heal does not re-register a package-bound baseline under such a name. The reads answer the row from its row. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../protocol.org-scoped-write-refused.test.ts | 151 +++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 105 ++++++++++-- 2 files changed, 245 insertions(+), 11 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 4a9f8dba8a6..23017a27fe0 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -1354,6 +1354,20 @@ describe('each package\'s copy of a view container expands into its own package\ return (packageId ? shippedAs.find((it) => it._packageId === packageId) : undefined) ?? shippedAs[0]; }, getPackagedObjectOwner: (name: string) => (owner && name === 'task' ? { packageId: owner, ownership: 'own' } : undefined), + // [#22057] The delete's heal, as `SchemaRegistry` holds it: the + // bare entry goes when a package's own entry is under it, and an + // entry that is no packaged artifact goes when nothing is. + removeRuntimeShadow: (type: string, name: string) => { + if (type !== 'view' || !entries.has(name)) return false; + if (!composite(name).some((it) => it._packageId && it._packageId !== 'sys_metadata')) return false; + entries.delete(name); + return true; + }, + removeOverlayEntry: (type: string, name: string) => { + if (type !== 'view' || !entries.has(name) || isCodeArtifactBody(entries.get(name))) return false; + entries.delete(name); + return true; + }, getObject: () => undefined, registerObject: () => {}, getPackage: () => undefined, @@ -1368,12 +1382,18 @@ describe('each package\'s copy of a view container expands into its own package\ ['an unscoped kernel (write-through hydrates the registry)', undefined], ] as const; - function harness(shipped: Array<[string, Record]>, environmentId: string | undefined, owner?: string) { + function harness( + shipped: Array<[string, Record]>, + environmentId: string | undefined, + owner?: string, + metadataService?: unknown, + ) { const { engine, rows } = makeStubEngine(); engine.registry = packagesRegistry(shipped, owner); const services = new Map([['tenancy', { defaultOrgId: async () => 'org_a' }]]); + if (metadataService !== undefined) services.set('metadata', metadataService); const protocol = new ObjectStackProtocolImplementation(engine, () => services, environmentId) as any; - return { protocol, rows }; + return { protocol, rows, registry: engine.registry }; } /** An organization overlay of the form, open, as a rollback restores it: the save check never judged it. */ @@ -1910,5 +1930,132 @@ describe('each package\'s copy of a view container expands into its own package\ }); } }); + + // [#22057] A stored row of a view item name two packages ship, bound to + // one of them. The hydration used to register it under the bare name, + // which the registry answers ahead of either package's own entry, so + // the by-name read naming the OTHER package found no row of its own, + // expanded nothing, and served this row's body under that package's + // envelope, while the list's slot for that package served its own item. + // A cold boot hydrates the stored rows through the same door on either + // kernel. Such a row is not registered: the reads answer it from its + // row, for its own package and for a read naming none, on every kernel. + describe('(j) a stored row bound to one package, of a name another package ships: the read naming each package answers its own item', () => { + const ORDERS = [[OTHER, COPYING], [COPYING, OTHER]] as const; + const KEYED_MEMBER = MEMBERS.find((m) => m.member === 'a keyed member'); + if (!KEYED_MEMBER) throw new Error('no keyed member'); + const [SHARED] = loaderNames(KEYED_MEMBER.body('x', true, SLUG)); + const ROW_TITLE = `Intake (${COPYING} row)`; + const envelopeOf = (v: any) => [titleOf(v), v?._packageId, v?._provenance]; + const shippedBy = (packages: readonly string[]) => packages.map((pkg): [string, Record] => + [pkg, { object: 'task', ...KEYED_MEMBER.body(SHIPPED_TITLE(pkg), true, pkg === COPYING ? SLUG : OTHER_SLUG) }]); + const rowOf = (name: string, title: string) => ({ name, label: title, object: 'task', viewKind: 'form', config: { title } }); + async function saveRow(protocol: any, name: string, packageId: string | undefined, title: string) { + expect((await protocol.saveMetaItem({ + type: 'view', name, item: rowOf(name, title), ...(packageId ? { packageId } : {}), + })).success).toBe(true); + } + /** A cold boot over the stored rows: a fresh registry of the shipped items, then the boot's hydration. */ + async function bootOver( + rows: Map, + shipped: Array<[string, Record]>, + environmentId: string | undefined, + ): Promise> { + const booted = harness(shipped, environmentId); + for (const [key, row] of rows) booted.rows.set(key, row); + expect((await booted.protocol.loadMetaFromDb()).errors, 'the boot hydrates every stored row').toBe(0); + return booted; + } + const STARTS = [['saved on the running kernel', false], ['after a cold boot over the stored row', true]] as const; + + for (const [kernel, environmentId] of KERNELS) { + for (const order of ORDERS) { + for (const [start, boot] of STARTS) { + const where = `${kernel}; ${order[0]} registered first; ${start}`; + const setUp = async () => { + const saved = harness(shippedBy(order), environmentId); + await saveRow(saved.protocol, SHARED, COPYING, ROW_TITLE); + return boot ? bootOver(saved.rows, shippedBy(order), environmentId) : saved; + }; + + it(`(a) ${where}: the read naming ${OTHER} serves ${OTHER}'s own item under ${OTHER}'s envelope`, async () => { + const { protocol, registry } = await setUp(); + const read = await protocol.getMetaItem({ type: 'view', name: SHARED, packageId: OTHER }); + expect(envelopeOf(read.item), `the read naming ${OTHER}`).toEqual([SHIPPED_TITLE(OTHER), OTHER, 'package']); + expect(read.packageId, 'the envelope the read reports').toBe(OTHER); + expect(await slotTitles(protocol, SHARED, OTHER), `${OTHER}'s slot in the env-wide list`).toEqual([SHIPPED_TITLE(OTHER)]); + // ADR-0048 §3.3: a registry read that carries its package id never mis-resolves. + expect(titleOf(registry.getItem('view', SHARED, OTHER)), `the registry read naming ${OTHER}`).toBe(SHIPPED_TITLE(OTHER)); + }); + + it(`(b) ${where}: the read naming ${COPYING}, the read naming no package and the list scoped to ${COPYING} serve the row`, async () => { + const { protocol } = await setUp(); + for (const packageId of [COPYING, undefined]) { + const named = packageId ?? 'no package'; + const read = await protocol.getMetaItem({ type: 'view', name: SHARED, ...(packageId ? { packageId } : {}) }); + expect(envelopeOf(read.item), `the read naming ${named}`).toEqual([ROW_TITLE, COPYING, 'package']); + expect(read.packageId, `the envelope the read naming ${named} reports`).toBe(COPYING); + } + const scoped: any = await protocol.getMetaItems({ type: 'view', packageId: COPYING }); + expect((scoped.items as any[]).filter((v) => v?.name === SHARED).map(envelopeOf), `the list scoped to ${COPYING}`) + .toEqual([[ROW_TITLE, COPYING, 'package']]); + expect(await slotTitles(protocol, SHARED, COPYING), `${COPYING}'s slot in the env-wide list`).toEqual([ROW_TITLE]); + }); + } + } + } + + // What stays registered under the bare name: a package-less row (the + // overlay of every package's item of the name, ADR-0005, standing in + // for each package, ADR-0048), and a row bound to a package of a name + // only that package ships, or no package ships. The unscoped kernel + // registers on save; a cold boot registers on either kernel. + const STAYS = [ + { row: 'a package-less row of a name one package ships', shipped: [OTHER], bound: undefined, name: SHARED, reads: [OTHER, undefined] }, + { row: 'a package-less row of a name two packages ship', shipped: [OTHER, COPYING], bound: undefined, name: SHARED, reads: [OTHER, COPYING, undefined] }, + { row: `a row bound to ${OTHER}, the one package that ships the name`, shipped: [OTHER], bound: OTHER, name: SHARED, reads: [OTHER, undefined] }, + { row: `a row bound to ${COPYING}, of a name no package ships`, shipped: [OTHER], bound: COPYING, name: 'task.own_form', reads: [COPYING, undefined] }, + ] as const; + for (const [kernel, environmentId] of KERNELS) { + for (const [start, boot] of STARTS) { + for (const stays of STAYS) { + it(`(c) ${kernel}; ${start}; ${stays.row}: served from the row, and registered under the bare name as before`, async () => { + const saved = harness(shippedBy(stays.shipped), environmentId); + await saveRow(saved.protocol, stays.name, stays.bound, ROW_TITLE); + const { protocol, registry } = boot ? await bootOver(saved.rows, shippedBy(stays.shipped), environmentId) : saved; + for (const packageId of stays.reads) { + const read = await protocol.getMetaItem({ type: 'view', name: stays.name, ...(packageId ? { packageId } : {}) }); + expect(titleOf(read.item), `the read naming ${packageId ?? 'no package'}`).toBe(ROW_TITLE); + } + const registers = environmentId === undefined || boot; + expect(titleOf(registry.getItem('view', stays.name)) === ROW_TITLE, 'the bare entry holds the row').toBe(registers); + }); + } + } + } + + // The delete's heal ({@link restoreArtifactRegistryView}): with no + // bare entry to drop, it re-registers the metadata service's answer + // for the name, read naming no package. Where that is one package's + // body under a name another package ships, it is not registered + // either. The service here answers that read alone, so the registry + // is the only thing the read naming the other package can reach. + for (const order of ORDERS) { + it(`(d) an unscoped kernel; ${order[0]} registered first: after the row is deleted, the heal registers no package's body under the name`, async () => { + const service = { + get: async (type: string, name: string, packageId?: string) => (type === 'view' && name === SHARED && packageId === undefined + ? { ...rowOf(SHARED, SHIPPED_TITLE(COPYING)), _packageId: COPYING, _provenance: 'package' } + : undefined), + }; + const { protocol, registry } = harness(shippedBy(order), undefined, undefined, service); + await saveRow(protocol, SHARED, COPYING, ROW_TITLE); + expect((await protocol.deleteMetaItem({ type: 'view', name: SHARED })).success).toBe(true); + + expect(titleOf(registry.getItem('view', SHARED, OTHER)), `the registry read naming ${OTHER}`).toBe(SHIPPED_TITLE(OTHER)); + const read = await protocol.getMetaItem({ type: 'view', name: SHARED, packageId: OTHER }); + expect(envelopeOf(read.item), `the read naming ${OTHER}`).toEqual([SHIPPED_TITLE(OTHER), OTHER, 'package']); + }); + } + }); }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 05c2a69909c..5e68cb45712 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1819,6 +1819,15 @@ function envelopePackageId(requestedPackageId: string | undefined, served: unkno return requestedPackageId ?? (served as { _packageId?: string } | null | undefined)?._packageId; } +/** + * [#22057] The package a body is bound to, when it is bound to one: a + * non-empty id that is not the `'sys_metadata'` sentinel a package-less row + * carries. `undefined` for a package-less body. + */ +function boundPackageOf(packageId: unknown): string | undefined { + return typeof packageId === 'string' && packageId !== '' && packageId !== 'sys_metadata' ? packageId : undefined; +} + /** * [#16702] ADR-0010 §3.3 — the three protection keys that are READ-SIDE * DERIVED, and therefore must never be persisted from a caller's body. @@ -18373,9 +18382,45 @@ export class ObjectStackProtocolImplementation implements * stays with the callers: that is a fact about the kernel this protocol * instance serves, not about the row in hand. * - * Returns whether anything was registered (org-scoped rows, bodies - * without a `name`, and registry doubles without `registerItem`, are - * no-ops). + * Returns whether the row is live in this registry's view: registered, or + * answered from its row as the section below states. Org-scoped rows, + * bodies without a `name`, and registry doubles without `registerItem` + * are no-ops and return `false`. The boot's page report reads it. + * + * ## [#22057] Never a row bound to one package, under a name another ships + * + * The registry keeps one bare slot per name, and `SchemaRegistry.getItem` + * answers it ahead of every package's own entry, whichever package the read + * names. ADR-0005 gives that slot to a package-less row, which overlays the + * item of the name in every package, so for such a row the precedence is + * right. A row bound to a package (ADR-0048) overlays that package's item + * alone. The registry has no slot of that package's to hold it, and under + * the bare name it answered every package's read of the name. Where only + * its own package ships the name, or no package does, nothing else answers + * the name, and it is registered as before. Where another package also + * ships it, a read naming that package used to get this row: the by-name + * read found no row of its own, expanded nothing, and served this row's + * body at the registry step, under the other package's envelope, while the + * list served that package's own item. That broke ADR-0048 §3.3, which + * says a read that carries its package id never mis-resolves. Measured on + * an unscoped kernel after a save, and on either kernel after a cold boot + * (`loadMetaFromDb` hydrates through this method on every kernel). + * + * Such a row is not registered, which is #21980's shape for an expansion + * ({@link hydrateExpandedViewItems}). The reads answer it from its row on + * every kernel ({@link findServedOverlayRow}): for its own package, and for + * a read naming no package. The list merges it into its package's slot. + * An aggregated container row still has its expansions judged one by one. + * A registry read naming no package keeps ADR-0048 §3.3's best effort, the + * first package's entry. The environment-scoped kernel's running answer + * was already this one, because it registers nothing on a save. + * + * Two things are registered as before. A package-less row overlays every + * package's item. And the reads decline the stored row of a shipped FLOW + * name or of a code-defined datasource name ({@link declinesStoredRow}). + * Each read's registry half declines its bare entry too, so no read is + * served it, and the automation boot pull reports the flow row as a + * shadowed contender, from that entry. * * ## [#9111] `type` is an ASSERTED input, not a silently-trusted one * @@ -18486,6 +18531,19 @@ export class ObjectStackProtocolImplementation implements if (!data || typeof data !== 'object' || !('name' in data)) return false; const registry: any = (this.engine as any)?.registry; if (!registry || typeof registry.registerItem !== 'function') return false; + // [#22057] See the header: a row bound to one package is not registered + // under a name another package ships. Its expansions are judged by + // their own names. + const bound = boundPackageOf(options.packageId); + const rowName = String((data as any).name); + if ( + bound !== undefined + && this.anotherPackageShips(type, rowName, bound) + && !this.declinesStoredRow(canonicalType, rowName) + ) { + this.hydrateExpandedViewItems(type, data, options, registry); + return true; + } const artifact = this.lookupArtifactItem(type, (data as any).name, options.packageId ?? undefined); // [#16702] Say what this row IS before the artifact envelope is grafted // on top of it. Every body reaching this hydrator came out of a @@ -18920,7 +18978,9 @@ export class ObjectStackProtocolImplementation implements * stored row, ahead of the registry ({@link resolveRowlessExpandedView}), * for its own package and for a read that names none, and the list expands * the row itself. A name that only the container's own package ships, or - * that no package ships, is registered as before. + * that no package ships, is registered as before. [#22057] The question is + * {@link anotherPackageShips}, the one the container row's own + * registration asks too. */ private hydrateExpandedViewItems( type: string, @@ -18931,14 +18991,30 @@ export class ObjectStackProtocolImplementation implements let shipping: ShippingPackages | undefined; for (const item of this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true })) { shipping ??= this.shippingPackagesOf(type); - const own = item._packageId; - const anotherShips = this.shippedArtifactsOf(type, String(item.name), shipping) - .some((artifact) => (artifact as { _packageId?: unknown })._packageId !== own); - if (anotherShips) continue; + if (this.anotherPackageShips(type, String(item.name), item._packageId, shipping)) continue; registry.registerItem(type, item, 'name' as any); } } + /** + * [#21980, #22057] True when a code package other than `own` ships an + * artifact named `name`: some artifact in {@link shippedArtifactsOf}, the + * one every package that can ship the name answers, has another + * `_packageId`. With `own` undefined, any shipped artifact is another + * package's. + * + * The registry's bare slot answers every package's read of the name, so + * each registration under a bare name asks this first: + * {@link hydrateExpandedViewItems} for an expansion, + * {@link hydrateOverlayIntoRegistry} for a row bound to a package, and the + * delete's heal ({@link restoreArtifactRegistryView}) for a package's body + * it would re-register. + */ + private anotherPackageShips(type: string, name: string, own: unknown, shipping?: ShippingPackages): boolean { + return this.shippedArtifactsOf(type, name, shipping) + .some((artifact) => (artifact as { _packageId?: unknown })._packageId !== own); + } + /** * [#4521] Write-through the SchemaRegistry after a mutation goes LIVE, so * a just-saved item is dispatchable — not merely listable. @@ -19064,6 +19140,13 @@ export class ObjectStackProtocolImplementation implements * MetadataService baseline (FilesystemLoader-sourced types) and * re-register it, preserving the historical refresh behaviour * for items the SchemaRegistry never held as artifacts. + * [#22057] Tier 1 also declines when there is no plain-key entry, + * which is what a row bound to one package leaves under a name + * another package ships ({@link hydrateOverlayIntoRegistry}). So a + * baseline bound to one package, under a name another package ships, + * is not re-registered here: under the bare name it would answer + * every package's read of the name. The walk stops there, as for any + * baseline found. * 3. [#5079] When NEITHER layer has anything, the deleted row was the * whole item — so the plain-key entry is retired too * ({@link SchemaRegistry.removeOverlayEntry}). @@ -19196,7 +19279,11 @@ export class ObjectStackProtocolImplementation implements const baseline = await this.readItemFromMetadataService(type, name); if (baseline.data !== undefined && baseline.data !== null) { if (this.environmentId === undefined) { - this.engine.registry.registerItem(type, baseline.data, 'name'); + // [#22057] See tier 2 in the header. + const bound = boundPackageOf((baseline.data as { _packageId?: unknown })._packageId); + if (bound === undefined || !this.anotherPackageShips(type, name, bound)) { + this.engine.registry.registerItem(type, baseline.data, 'name'); + } } return; } From 8a677785891cb841da40b353d2fc05857bbf4ba4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:00:27 +0000 Subject: [PATCH 2/5] test(metadata-protocol): every registerItem( call in protocol.ts carries a recorded disposition; changeset Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...57-bound-row-not-under-shared-bare-name.md | 12 ++ .../protocol.register-item-call-sites.test.ts | 155 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 .changeset/22057-bound-row-not-under-shared-bare-name.md create mode 100644 packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts diff --git a/.changeset/22057-bound-row-not-under-shared-bare-name.md b/.changeset/22057-bound-row-not-under-shared-bare-name.md new file mode 100644 index 00000000000..6384cbb79a1 --- /dev/null +++ b/.changeset/22057-bound-row-not-under-shared-bare-name.md @@ -0,0 +1,12 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +A by-name metadata read naming a package now serves that package's item when another package's stored row has the same name + +Clause-②: no + +- **What was wrong.** Two installed packages can ship an item of one name, and a stored `sys_metadata` row can be bound to one of them. The registry hydration registered that row under the item's bare name. The registry answers the bare name ahead of every package's own entry, whichever package the read names. So `getMetaItem` naming the OTHER package found no row of its own and served this row's body at its registry step, under the other package's envelope. That is the read behind `GET /api/v1/meta/TYPE/NAME?package=…`. Meanwhile the list's slot for that package served its own item. This was measured on an unscoped kernel after a save, and on either kernel after a cold boot, because `loadMetaFromDb` hydrates through the same door on every kernel. +- **What it does now.** A row bound to one package is not registered under a name another package ships. This is the shape the container-expansion registration already takes. The reads answer the row from the row itself: the read naming its own package, the read naming no package, and the list's slot for that package. The read naming the other package serves that package's own item and envelope. The delete's registry heal also stops re-registering a metadata-service baseline bound to one package under such a name. +- **Unchanged.** Three kinds of row still register under the bare name as before. A package-less stored row overlays every package's item of its name. A row of a name that only its own package ships, or that no package ships. A stored row the reads decline (a shipped flow name, or a code-defined datasource name). A registry read naming no package keeps its first-match best effort (ADR-0048 §3.3). The environment-scoped kernel's answer after a save is unchanged, because it registers nothing on a save. +- ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. The built entry declarations gain one `private` member name on `ObjectStackProtocolImplementation`. diff --git a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts new file mode 100644 index 00000000000..fac7b72a419 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22057 — every `registerItem(` call in `protocol.ts` carries a recorded + * disposition, so the closing card's classification cannot go stale when a + * registration is added, removed or re-scoped. + * + * `SchemaRegistry.registerItem(type, item, keyField)` with no package id + * writes the registry's bare slot for the name, and `SchemaRegistry.getItem` + * answers that slot ahead of every package's own entry, whichever package the + * read names. The question each row below answers is the card's: can this + * registration put a body bound to one package under a bare name that + * another package ships? Where it can, the registration first asks + * `anotherPackageShips` and skips the name (#21980's shape for an expansion), + * and the reads answer the body from its stored row. The behaviour is pinned + * in `protocol.org-scoped-write-refused.test.ts`, block (j). + * + * The population is the card's enumeration command, + * `git grep -n "registerItem(" -- packages/metadata-protocol/src/protocol.ts`: + * its matches are the calls below, mentions inside comments, and one + * `unregisterItem(`. Each call 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 LINES = SOURCE.split('\n'); +const CALLEE = 'registerItem('; + +/** 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*(?:<[^>]*>)?\(/; +/** A line of a comment: a block comment's body or opener, or a line comment. */ +const COMMENT_LINE = /^\s*(?:\*|\/\*|\/\/)/; + +interface Site { readonly member: string; readonly args: string } +interface Located extends Site { readonly line: number; readonly headerLine: number } + +/** The member a source offset sits in: the nearest member header above it. */ +function memberAt(offset: number): { name: string; line: number } { + for (let line = SOURCE.slice(0, offset).split('\n').length - 1; line >= 0; line--) { + const header = MEMBER_HEADER.exec(LINES[line]); + if (header) return { name: header[1], line }; + } + return { name: '(no member)', line: -1 }; +} + +/** Every `registerItem(` call on a code line: not `unregisterItem(`, not inside a comment. */ +function callsOf(): Located[] { + const calls: Located[] = []; + for (let at = SOURCE.indexOf(CALLEE); at !== -1; at = SOURCE.indexOf(CALLEE, at + 1)) { + if (/[A-Za-z_$]/.test(SOURCE[at - 1] ?? '')) continue; + const line = SOURCE.slice(0, at).split('\n').length - 1; + if (COMMENT_LINE.test(LINES[line])) continue; + 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(/,$/, ''); + const member = memberAt(at); + calls.push({ member: member.name, args, line, headerLine: member.line }); + } + return calls; +} + +type Disposition = 'no' | 'skips a name another package ships'; + +/** + * The recorded disposition of every call. `path`: the callers that reach it + * and the kernels it registers on. `why`: why it cannot put a body bound to + * one package under a name another package ships, or how it skips that name. + */ +const SITES: ReadonlyArray = [ + { + member: 'applyObjectRegistryMutation', args: 'request.type, request.item, \'name\'', disposition: 'no', + path: 'applyRegistryWriteThrough for `object` (a save, a publish, a rollback, a revert, a replica\'s mutation), on every kernel', + why: 'an object: the entry lands in the generic map, which no read serves an object from (getItem, listItems and ' + + 'getArtifactItem read the object contributors for `object`); an object has one code owner (ADR-0029 D3), ' + + 'and a row bound to another package is refused (D9.9, OBJECT_OVERLAY_PACKAGE_MISMATCH)', + }, + { + member: 'hydrateOverlayIntoRegistry', args: 'type, mergeArtifactProtection(stateTenantAuthorship(data), envelope), \'name\' as any', + disposition: 'skips a name another package ships', + path: 'the list\'s hydration and the write-through on an unscoped kernel; the boot\'s loadMetaFromDb on every kernel', + why: 'a stored row: a row bound to a package is not registered where another package ships its name, and the reads ' + + 'answer it from its row (a package-less row, and a row the reads decline, register as before)', + }, + { + member: 'hydrateExpandedViewItems', args: 'type, item, \'name\' as any', disposition: 'skips a name another package ships', + path: 'hydrateOverlayIntoRegistry, for each expansion of a stored view container', + why: 'an expansion: not registered where another package ships its name (#21980), and the by-name read answers it ' + + 'from its stored container row', + }, + { + member: 'restoreArtifactRegistryView', args: 'type, baseline.data, \'name\'', disposition: 'skips a name another package ships', + path: 'the delete\'s heal, tier 2 (deleteMetaItem, revertCommit\'s removal, a replica\'s removal), on an unscoped kernel', + why: 'the metadata service\'s baseline: a baseline bound to a package is not re-registered where another package ' + + 'ships the name; tier 1 finds no bare entry to drop for a row the hydration skipped', + }, +]; + +const keyOf = (site: Site) => `${site.member}: ${CALLEE}${site.args})`; + +describe('[#22057] every registerItem( in protocol.ts has a recorded disposition', () => { + const found = callsOf(); + + it('the calls 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 registration with no recorded disposition: classify it (can it put a body bound to one package under a ' + + 'bare name that another package ships?) and record it in SITES', + ).toEqual([]); + expect(recorded.filter((key) => !inSource.includes(key)), 'a recorded registration that is no longer in the source').toEqual([]); + expect(inSource, 'each call once').toEqual(recorded); + }); + + it('the population is the card\'s enumeration command: the calls, plus mentions in comments and unregisterItem(', () => { + // Non-vacuity: a scan that found nothing, or lost the members, cannot pass the test above by accident. + expect(found).toHaveLength(SITES.length); + expect(found.filter((site) => site.member === '(no member)')).toEqual([]); + const callLines = new Set(found.map((site) => site.line)); + const others = LINES.map((text, line) => ({ text, line })) + .filter(({ text, line }) => text.includes(CALLEE) && !callLines.has(line)); + expect( + others.filter(({ text }) => !COMMENT_LINE.test(text) && !text.includes(`un${CALLEE}`)).map(({ text }) => text.trim()), + 'a line the command prints that is neither a recorded call, a comment, nor unregisterItem(', + ).toEqual([]); + // Every other spelling of the method on a code line is the hydrator's capability probe. + const spelled = LINES.filter((text) => !COMMENT_LINE.test(text) && /(? !text.includes(CALLEE)); + expect(spelled.map((text) => text.trim())).toEqual(['if (!registry || typeof registry.registerItem !== \'function\') return false;']); + }); + + it('each registration that skips a name another package ships asks anotherPackageShips before it registers', () => { + for (const site of SITES.filter((s) => s.disposition === 'skips a name another package ships')) { + const call = found.find((f) => keyOf(f) === keyOf(site)); + expect(call, keyOf(site)).toBeDefined(); + const body = LINES.slice(call!.headerLine, call!.line).join('\n'); + expect(body, `${site.member}: the question asked ahead of its registration`).toContain('this.anotherPackageShips('); + } + }); + + it('the question is the one predicate, over every package that can ship the name', () => { + expect(SOURCE).toMatch( + /\n {4}private anotherPackageShips\(type: string, name: string, own: unknown, shipping\?: ShippingPackages\): boolean \{\n {8}return this\.shippedArtifactsOf\(type, name, shipping\)\n/, + ); + }); +}); From 45ffd806d846eafc747220fe32bf4a4bb0045ef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:12:36 +0000 Subject: [PATCH 3/5] test(metadata-protocol): a stored row of a shipped flow name bound to another package stays registered as the tenant row Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...protocol.flow-stored-row-shipped-name.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts b/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts index 92227b22344..0822b232b4c 100644 --- a/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts @@ -161,6 +161,22 @@ describe('[#20913] hydration registers a stored flow row as the tenant row it is expect(protocol.packagedArtifactOwner({ type: 'flow', name: SHIPPED })).toBe(PACKAGE_ID); }); + // [#22057] The hydration does not register a row bound to one package under + // a name another package ships. The reads decline a stored row of a shipped + // flow name, so that skip does not apply to it: it is registered as the + // tenant row it is, for the boot pull to report as a shadowed contender. + it('a stored row of a shipped flow name, bound to another package, is registered as the tenant row it is too', async () => { + const { protocol, registry } = harness([ + storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'), { package_id: 'com.example.other' }), + ]); + await protocol.getMetaItemsForExecution({ type: 'flow' }); + + const hydrated = registry.bare('flow', SHIPPED); + expect(hydrated?.label).toBe('STORED'); + expect(hydrated?._provenance).toBe('org'); + expect(isCodeArtifactBody(hydrated)).toBe(false); + }); + it('control: an overlay of a packaged type in the overlay regime keeps the artifact\'s envelope', async () => { const { protocol, registry } = harness([storedRow('view', 'pkg_view', { name: 'pkg_view', label: 'OVERLAY VIEW' })]); await protocol.getMetaItems({ type: 'view' }); From 274a7505166c98c4bf331d51b370ad6864f98230 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 09:38:43 +0000 Subject: [PATCH 4/5] fix(metadata-protocol): keep the bare-name skip and the heal guard to view Every other type registers as before: a row bound to one package, of a name two packages ship, keeps the bare entry with its own body and envelope (the boot-hydration pin in objectql). The page control pins that here, the enumeration records each skip as view only, and the flow case for the removed declined-row exception is dropped. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...tocol.flow-stored-row-shipped-name.test.ts | 16 --- .../protocol.org-scoped-write-refused.test.ts | 61 +++++++++++ .../protocol.register-item-call-sites.test.ts | 45 ++++++-- packages/metadata-protocol/src/protocol.ts | 101 +++++++++--------- 4 files changed, 145 insertions(+), 78 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts b/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts index 0822b232b4c..92227b22344 100644 --- a/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts @@ -161,22 +161,6 @@ describe('[#20913] hydration registers a stored flow row as the tenant row it is expect(protocol.packagedArtifactOwner({ type: 'flow', name: SHIPPED })).toBe(PACKAGE_ID); }); - // [#22057] The hydration does not register a row bound to one package under - // a name another package ships. The reads decline a stored row of a shipped - // flow name, so that skip does not apply to it: it is registered as the - // tenant row it is, for the boot pull to report as a shadowed contender. - it('a stored row of a shipped flow name, bound to another package, is registered as the tenant row it is too', async () => { - const { protocol, registry } = harness([ - storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'), { package_id: 'com.example.other' }), - ]); - await protocol.getMetaItemsForExecution({ type: 'flow' }); - - const hydrated = registry.bare('flow', SHIPPED); - expect(hydrated?.label).toBe('STORED'); - expect(hydrated?._provenance).toBe('org'); - expect(isCodeArtifactBody(hydrated)).toBe(false); - }); - it('control: an overlay of a packaged type in the overlay regime keeps the artifact\'s envelope', async () => { const { protocol, registry } = harness([storedRow('view', 'pkg_view', { name: 'pkg_view', label: 'OVERLAY VIEW' })]); await protocol.getMetaItems({ type: 'view' }); diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 23017a27fe0..6ac5a2dc1b0 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -1940,6 +1940,8 @@ describe('each package\'s copy of a view container expands into its own package\ // A cold boot hydrates the stored rows through the same door on either // kernel. Such a row is not registered: the reads answer it from its // row, for its own package and for a read naming none, on every kernel. + // ⛔ View only: a row of any other type keeps the bare entry (#4624), + // pinned as the control (e) below. describe('(j) a stored row bound to one package, of a name another package ships: the read naming each package answers its own item', () => { const ORDERS = [[OTHER, COPYING], [COPYING, OTHER]] as const; const KEYED_MEMBER = MEMBERS.find((m) => m.member === 'a keyed member'); @@ -2056,6 +2058,65 @@ describe('each package\'s copy of a view container expands into its own package\ expect(envelopeOf(read.item), `the read naming ${OTHER}`).toEqual([SHIPPED_TITLE(OTHER), OTHER, 'package']); }); } + + /** + * A registry double for any type, held as `SchemaRegistry` holds it: + * each package's entry under `:`, a hydrated row under + * the bare name, `getItem` bare slot first, and `getArtifactItem`'s + * package-scoped code-artifact lookup. + */ + function typedRegistry() { + const byType = new Map>>(); + const of = (type: string) => byType.get(type) ?? byType.set(type, new Map()).get(type)!; + const composite = (type: string, name: string) => [...of(type)].filter(([key]) => key.endsWith(`:${name}`)).map(([, it]) => it); + return { + registerItem: (type: string, item: Record, _keyField?: string, packageId?: string) => { + if (packageId) { + if (item._packageId === undefined) item._packageId = packageId; + if (item._provenance === undefined) item._provenance = 'package'; + } + of(type).set(packageId ? `${packageId}:${String(item.name)}` : String(item.name), item); + }, + bare: (type: string, name: string) => of(type).get(name), + listItems: (type: string, packageId?: string) => [...of(type).values()].filter((it) => !packageId || it._packageId === packageId), + getItem: (type: string, name: string, packageId?: string) => of(type).get(name) + ?? (packageId ? of(type).get(`${packageId}:${name}`) : undefined) ?? composite(type, name)[0], + getArtifactItem: (type: string, name: string, packageId?: string) => { + const shippedAs = composite(type, name).filter((it) => isCodeArtifactBody(it)); + return (packageId ? shippedAs.find((it) => it._packageId === packageId) : undefined) ?? shippedAs[0]; + }, + getObject: () => undefined, + registerObject: () => {}, + getPackage: () => undefined, + isPackageDisabled: () => false, + isObjectPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + } + + // ⛔ View only. A row of any other type, bound to one package, of a + // name two packages ship, holds the bare entry with its own body and + // its own package's envelope, as #4624 rules (ADR-0048; `objectql`'s + // `protocol-boot-hydration-scoped.test.ts`), through either hydration. + for (const order of ORDERS) { + it(`(e) control, an unscoped kernel; ${order[0]} registered first: a page row bound to ${COPYING}, of a name both ship, holds the bare entry with its own body and envelope`, async () => { + for (const hydration of ['the cold boot', 'the list read'] as const) { + const { engine, rows } = makeStubEngine(); + const registry = typedRegistry(); + for (const pkg of order) registry.registerItem('page', { name: 'home', label: `Home (shipped by ${pkg})` }, 'name', pkg); + engine.registry = registry; + rows.set(`page|home|__env__|active|${COPYING}`, { + id: 'r_page_home', type: 'page', name: 'home', organization_id: null, package_id: COPYING, state: 'active', + metadata: JSON.stringify({ name: 'home', label: ROW_TITLE }), + }); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), undefined) as any; + if (hydration === 'the cold boot') expect((await protocol.loadMetaFromDb()).errors, 'the boot hydrates the row').toBe(0); + else await protocol.getMetaItems({ type: 'page' }); + const bare = registry.bare('page', 'home'); + expect([bare?.label, bare?._packageId, bare?._provenance], `${hydration}: the bare entry`).toEqual([ROW_TITLE, COPYING, 'package']); + } + }); + } }); }); }); diff --git a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts index fac7b72a419..a0014a5c1bf 100644 --- a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts @@ -10,11 +10,16 @@ * answers that slot ahead of every package's own entry, whichever package the * read names. The question each row below answers is the card's: can this * registration put a body bound to one package under a bare name that - * another package ships? Where it can, the registration first asks + * another package ships? Where it can for a VIEW, the registration first asks * `anotherPackageShips` and skips the name (#21980's shape for an expansion), * and the reads answer the body from its stored row. The behaviour is pinned * in `protocol.org-scoped-write-refused.test.ts`, block (j). * + * ⛔ View only. For every other type a row bound to one package, of a name two + * packages ship, holds the bare entry with its own body and envelope, as + * #4624 rules (`objectql`'s `protocol-boot-hydration-scoped.test.ts`, + * ADR-0048); block (j)'s control (e) pins that here. + * * The population is the card's enumeration command, * `git grep -n "registerItem(" -- packages/metadata-protocol/src/protocol.ts`: * its matches are the calls below, mentions inside comments, and one @@ -68,14 +73,20 @@ function callsOf(): Located[] { return calls; } -type Disposition = 'no' | 'skips a name another package ships'; +type Disposition = 'no' | 'yes, view only: skips a name another package ships'; /** * The recorded disposition of every call. `path`: the callers that reach it * and the kernels it registers on. `why`: why it cannot put a body bound to * one package under a name another package ships, or how it skips that name. */ -const SITES: ReadonlyArray = [ +const SITES: ReadonlyArray = [ { member: 'applyObjectRegistryMutation', args: 'request.type, request.item, \'name\'', disposition: 'no', path: 'applyRegistryWriteThrough for `object` (a save, a publish, a rollback, a revert, a replica\'s mutation), on every kernel', @@ -85,22 +96,27 @@ const SITES: ReadonlyArray { - for (const site of SITES.filter((s) => s.disposition === 'skips a name another package ships')) { + for (const site of SITES.filter((s) => s.disposition !== 'no')) { const call = found.find((f) => keyOf(f) === keyOf(site)); expect(call, keyOf(site)).toBeDefined(); const body = LINES.slice(call!.headerLine, call!.line).join('\n'); @@ -147,6 +163,13 @@ describe('[#22057] every registerItem( in protocol.ts has a recorded disposition } }); + it('each skip is kept to view, so every other type registers as #4624 rules', () => { + for (const site of SITES.filter((s) => s.disposition !== 'no')) { + expect(site.viewOnlyBy, `${site.member}: the text that keeps it to view`).toBeDefined(); + expect(SOURCE.split(site.viewOnlyBy!).length - 1, `${site.member}: ${site.viewOnlyBy}`).toBe(1); + } + }); + it('the question is the one predicate, over every package that can ship the name', () => { expect(SOURCE).toMatch( /\n {4}private anotherPackageShips\(type: string, name: string, own: unknown, shipping\?: ShippingPackages\): boolean \{\n {8}return this\.shippedArtifactsOf\(type, name, shipping\)\n/, diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5e68cb45712..8de5265bea8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -18387,40 +18387,40 @@ export class ObjectStackProtocolImplementation implements * bodies without a `name`, and registry doubles without `registerItem` * are no-ops and return `false`. The boot's page report reads it. * - * ## [#22057] Never a row bound to one package, under a name another ships + * ## [#22057] A VIEW row bound to one package, never under a name another ships * * The registry keeps one bare slot per name, and `SchemaRegistry.getItem` * answers it ahead of every package's own entry, whichever package the read * names. ADR-0005 gives that slot to a package-less row, which overlays the - * item of the name in every package, so for such a row the precedence is - * right. A row bound to a package (ADR-0048) overlays that package's item - * alone. The registry has no slot of that package's to hold it, and under - * the bare name it answered every package's read of the name. Where only - * its own package ships the name, or no package does, nothing else answers - * the name, and it is registered as before. Where another package also - * ships it, a read naming that package used to get this row: the by-name - * read found no row of its own, expanded nothing, and served this row's - * body at the registry step, under the other package's envelope, while the - * list served that package's own item. That broke ADR-0048 §3.3, which - * says a read that carries its package id never mis-resolves. Measured on - * an unscoped kernel after a save, and on either kernel after a cold boot - * (`loadMetaFromDb` hydrates through this method on every kernel). - * - * Such a row is not registered, which is #21980's shape for an expansion - * ({@link hydrateExpandedViewItems}). The reads answer it from its row on - * every kernel ({@link findServedOverlayRow}): for its own package, and for - * a read naming no package. The list merges it into its package's slot. - * An aggregated container row still has its expansions judged one by one. - * A registry read naming no package keeps ADR-0048 §3.3's best effort, the - * first package's entry. The environment-scoped kernel's running answer - * was already this one, because it registers nothing on a save. - * - * Two things are registered as before. A package-less row overlays every - * package's item. And the reads decline the stored row of a shipped FLOW - * name or of a code-defined datasource name ({@link declinesStoredRow}). - * Each read's registry half declines its bare entry too, so no read is - * served it, and the automation boot pull reports the flow row as a - * shadowed contender, from that entry. + * item of the name in every package. A view row bound to a package + * (ADR-0048) overlays that package's view alone, and under the bare name it + * answered every package's read of the name. Where another package also + * ships the name, the by-name read naming that package found no row of its + * own, expanded nothing, and served this row's body at the registry step, + * under the other package's envelope, while the list served that package's + * own view. Measured on an unscoped kernel after a save, and on either + * kernel after a cold boot (`loadMetaFromDb` hydrates through this method on + * every kernel). + * + * Such a view row is not registered, which is #21980's shape for an + * expansion ({@link hydrateExpandedViewItems}), and for the same reason it + * is safe there: no reader outside this class reads a view's bare entry. + * The reads answer the row from its row on every kernel + * ({@link findServedOverlayRow}): for its own package, and for a read naming + * no package. The list merges it into its package's slot. An aggregated + * container row still has its expansions judged one by one. The + * environment-scoped kernel's running answer was already this one, because + * it registers nothing on a save. + * + * ⛔ `view` only, judged on the canonical type. Every other type registers + * as before: a row bound to one package, of a name two packages ship, holds + * the bare entry with its own body and its own package's envelope (#4624, + * ADR-0048, pinned in `objectql`'s `protocol-boot-hydration-scoped.test.ts`). + * Registry readers that name no package read those entries (the declared + * security-metadata bootstraps, the action router, the picklist read), and + * which body they should see for such a name is not ruled here. A + * package-less view row, and a view row of a name only its own package + * ships or no package ships, register as before too. * * ## [#9111] `type` is an ASSERTED input, not a silently-trusted one * @@ -18531,16 +18531,11 @@ export class ObjectStackProtocolImplementation implements if (!data || typeof data !== 'object' || !('name' in data)) return false; const registry: any = (this.engine as any)?.registry; if (!registry || typeof registry.registerItem !== 'function') return false; - // [#22057] See the header: a row bound to one package is not registered - // under a name another package ships. Its expansions are judged by - // their own names. - const bound = boundPackageOf(options.packageId); - const rowName = String((data as any).name); - if ( - bound !== undefined - && this.anotherPackageShips(type, rowName, bound) - && !this.declinesStoredRow(canonicalType, rowName) - ) { + // [#22057] See the header: a view row bound to one package is not + // registered under a name another package ships. Its expansions are + // judged by their own names. ⛔ View only. + const bound = canonicalType === 'view' ? boundPackageOf(options.packageId) : undefined; + if (bound !== undefined && this.anotherPackageShips(type, String((data as any).name), bound)) { this.hydrateExpandedViewItems(type, data, options, registry); return true; } @@ -19004,11 +18999,12 @@ export class ObjectStackProtocolImplementation implements * package's. * * The registry's bare slot answers every package's read of the name, so - * each registration under a bare name asks this first: + * each VIEW registration under a bare name asks this first: * {@link hydrateExpandedViewItems} for an expansion, - * {@link hydrateOverlayIntoRegistry} for a row bound to a package, and the - * delete's heal ({@link restoreArtifactRegistryView}) for a package's body - * it would re-register. + * {@link hydrateOverlayIntoRegistry} for a view row bound to a package, + * and the delete's heal ({@link restoreArtifactRegistryView}) for a + * package's view it would re-register. ⛔ Only views ask it: see the + * hydrator's header for why every other type does not. */ private anotherPackageShips(type: string, name: string, own: unknown, shipping?: ShippingPackages): boolean { return this.shippedArtifactsOf(type, name, shipping) @@ -19141,12 +19137,13 @@ export class ObjectStackProtocolImplementation implements * re-register it, preserving the historical refresh behaviour * for items the SchemaRegistry never held as artifacts. * [#22057] Tier 1 also declines when there is no plain-key entry, - * which is what a row bound to one package leaves under a name + * which is what a VIEW row bound to one package leaves under a name * another package ships ({@link hydrateOverlayIntoRegistry}). So a - * baseline bound to one package, under a name another package ships, - * is not re-registered here: under the bare name it would answer - * every package's read of the name. The walk stops there, as for any - * baseline found. + * view baseline bound to one package, under a name another package + * ships, is not re-registered here: under the bare name it would + * answer every package's read of the name. The walk stops there, as + * for any baseline found. ⛔ View only, as at the hydration: every + * other type re-registers as before. * 3. [#5079] When NEITHER layer has anything, the deleted row was the * whole item — so the plain-key entry is retired too * ({@link SchemaRegistry.removeOverlayEntry}). @@ -19279,8 +19276,10 @@ export class ObjectStackProtocolImplementation implements const baseline = await this.readItemFromMetadataService(type, name); if (baseline.data !== undefined && baseline.data !== null) { if (this.environmentId === undefined) { - // [#22057] See tier 2 in the header. - const bound = boundPackageOf((baseline.data as { _packageId?: unknown })._packageId); + // [#22057] See tier 2 in the header. ⛔ View only. + const bound = canonicalMetaType(type) === 'view' + ? boundPackageOf((baseline.data as { _packageId?: unknown })._packageId) + : undefined; if (bound === undefined || !this.anotherPackageShips(type, name, bound)) { this.engine.registry.registerItem(type, baseline.data, 'name'); } From 130b8bf9ec4351e2c47ce0526a03b624e144a1ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 09:39:02 +0000 Subject: [PATCH 5/5] docs(changeset): state the view scope of the bare-name skip Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/22057-bound-row-not-under-shared-bare-name.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/22057-bound-row-not-under-shared-bare-name.md b/.changeset/22057-bound-row-not-under-shared-bare-name.md index 6384cbb79a1..881697e0a7b 100644 --- a/.changeset/22057-bound-row-not-under-shared-bare-name.md +++ b/.changeset/22057-bound-row-not-under-shared-bare-name.md @@ -2,11 +2,11 @@ '@objectstack/metadata-protocol': patch --- -A by-name metadata read naming a package now serves that package's item when another package's stored row has the same name +A by-name view read naming a package now serves that package's view when another package's stored row has the same name Clause-②: no -- **What was wrong.** Two installed packages can ship an item of one name, and a stored `sys_metadata` row can be bound to one of them. The registry hydration registered that row under the item's bare name. The registry answers the bare name ahead of every package's own entry, whichever package the read names. So `getMetaItem` naming the OTHER package found no row of its own and served this row's body at its registry step, under the other package's envelope. That is the read behind `GET /api/v1/meta/TYPE/NAME?package=…`. Meanwhile the list's slot for that package served its own item. This was measured on an unscoped kernel after a save, and on either kernel after a cold boot, because `loadMetaFromDb` hydrates through the same door on every kernel. -- **What it does now.** A row bound to one package is not registered under a name another package ships. This is the shape the container-expansion registration already takes. The reads answer the row from the row itself: the read naming its own package, the read naming no package, and the list's slot for that package. The read naming the other package serves that package's own item and envelope. The delete's registry heal also stops re-registering a metadata-service baseline bound to one package under such a name. -- **Unchanged.** Three kinds of row still register under the bare name as before. A package-less stored row overlays every package's item of its name. A row of a name that only its own package ships, or that no package ships. A stored row the reads decline (a shipped flow name, or a code-defined datasource name). A registry read naming no package keeps its first-match best effort (ADR-0048 §3.3). The environment-scoped kernel's answer after a save is unchanged, because it registers nothing on a save. +- **What was wrong.** Two installed packages can ship a view of one name, and a stored `sys_metadata` view row can be bound to one of them. The registry hydration registered that row under the view's bare name. The registry answers the bare name ahead of every package's own entry, whichever package the read names. So `getMetaItem` naming the OTHER package found no row of its own and served this row's body at its registry step, under the other package's envelope. That is the read behind `GET /api/v1/meta/view/NAME?package=…`. Meanwhile the list's slot for that package served its own view. This was measured on an unscoped kernel after a save, and on either kernel after a cold boot, because `loadMetaFromDb` hydrates through the same door on every kernel. +- **What it does now.** A view row bound to one package is not registered under a name another package ships. This is the shape the view-container expansion registration already takes. The reads answer the row from the row itself: the read naming its own package, the read naming no package, and the list's slot for that package. The read naming the other package serves that package's own view and envelope. The delete's registry heal also stops re-registering a metadata-service view baseline bound to one package under such a name. +- **Scope: `view` only.** Every other type registers as before: a row bound to one package, of a name two packages ship, keeps the bare entry with its own body and its own package's envelope. A package-less view row, and a view row of a name that only its own package ships or that no package ships, also register as before. The environment-scoped kernel's answer after a save is unchanged, because it registers nothing on a save. - ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. The built entry declarations gain one `private` member name on `ObjectStackProtocolImplementation`.