Skip to content
Merged
25 changes: 25 additions & 0 deletions .changeset/31680-presentation-ai-reads-by-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/objectql': patch
'@objectstack/metadata-protocol': patch
---

feat(objectql)!: the by-name reads of apps, pages, dashboards, reports, books, analytics cubes, tools, skills and agents consult `(package, name)` (ADR-0150 D3)

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) no metadata shape, schema, stored row or registered node type changes: the change is which item a bare name resolves to at read time, the stored metadata is read exactly as before, so there is nothing for `objectstack migrate meta` to rewrite -->

`SchemaRegistry.getApp`, `MetadataFacade.get` / `getEntry` / `exists` and the protocol's by-name read that names no package (`GET /api/v1/meta/TYPE/NAME`, and MCP `agent_prompt`, which reads it) now answer these nine types through the one package-scoped resolver. A package caller gets its own item, else the one holder in its declared dependencies (plus `sys`). A caller with no package gets the environment's own item, else the one package holder. A name with one holder, and a name with none, answer exactly as before.

**BREAKING** for three bare-name reads that were served a guess and are now refused with `409 RESOURCE_CONFLICT`, the message naming every holder:

1. A caller with no package reads a name that two packages both ship. For an app, page, dashboard, report, book or analytics cube that can happen today; for an agent, skill or tool only on a deployment that booted already holding the clash (a new install of a held name is refused). It was served whichever package registered first.
2. A package caller reads a name that only the environment's own item holds.
3. A package caller reads a name whose one holder is a package it does not declare as a dependency.

The fix is one line, and none of the four example apps in this repository needs it:

- FROM a bare read of a name two packages ship TO the read that names the package it means (`?package=<id>`).
- FROM a package caller reading the environment's own item TO the item shipped in the reading package, or in a package it depends on.
- FROM a package caller reading a package it does not depend on TO that package declared in the reader's `dependencies`.
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,30 @@ describe('agent_prompt — one reader with GET /meta/agent/NAME (the protocol)',
}
});

it('[#31680, ADR-0150 D3] a by-name read the package-scoped resolver refuses (409 RESOURCE_CONFLICT) travels naming every holder: never "not found", never the outage text', async () => {
// The protocol's package-less by-name read refuses a name more than one
// package holds, naming each (child E8). Read here as a miss it would tell
// the client the agent does not exist; read as the #6055 outage it would
// say nothing is known. It is neither: it travels as the refusal it is.
const refusal = Object.assign(
new Error('agent "pkg_helper" is held by com.acme.alpha and com.acme.beta: qualify the name with its package'),
{ code: 'RESOURCE_CONFLICT', status: 409, httpStatus: 409 },
);
const session = await connectWith(
metadataService({ agents: [] }),
protocol({ byName: async () => { throw refusal; } }),
);
try {
const got = session.client.getPrompt({ name: 'agent_prompt', arguments: { agentName: 'pkg_helper' } });
await expect(got).rejects.toThrow(/com\.acme\.alpha and com\.acme\.beta/);
await expect(got).rejects.not.toThrow(/not found|SERVICE_UNAVAILABLE/);
await expect(got).rejects.not.toThrow(PACKAGE_AGENT.instructions);
expect(session.log.warn).not.toHaveBeenCalledWith(expect.stringContaining('agent prompt refused'), expect.anything());
} finally {
await session.close();
}
});

it('a degraded roster verdict with the protocol present and no agent named keeps the prompt listed, naming no agent', async () => {
const session = await connectWith(
metadataService({
Expand Down
31 changes: 27 additions & 4 deletions packages/metadata-protocol/src/protocol-meta-item-reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
const wantedMember = bundleMemberDiscriminator(request.type, member);
const isWantedMember = (candidate: unknown): boolean =>
wantedMember === undefined || itemDiscriminator(request.type, candidate) === wantedMember;
// [ADR-0150 D3, #31680 child E8] A read that names no package is a caller
// with no package. For a type whose by-name readers consult
// `(package, name)` (the registry's `readsByPackage`), the registry's
// answer is the ONE package-scoped resolver's (`resolveItem`): the
// environment's own item, else the unique package holder. A name two
// packages hold is refused HERE, before any layer below is read, naming
// both (a thrown `409 RESOURCE_CONFLICT`); it served the first registered.
// Step 3 serves this answer in place of `getItem`'s, and nothing else
// below changes. A read that names a package (`?package=`) keeps
// `getItem`'s prefer-local read: that address is the exact qualifier of
// child G1 (ADR-0150 D5), not a caller.
const registry = host.engine?.registry;
const packagelessScopedRead: { item: unknown } | undefined =
!request.packageId && typeof registry?.readsByPackage === 'function' && registry.readsByPackage(request.type)
? { item: registry.resolveItem(request.type, request.name, null, member) }
: undefined;
let item: unknown;
// [ADR-0131 D6] Environment → code: the environment's stored row,
// else the code packages' definition. No organization's row is read —
Expand Down Expand Up @@ -566,10 +582,17 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
if (artifact !== undefined && isWantedMember(artifact)) item = artifact;
}
if (item === undefined) {
item = host.engine.registry.getItem(request.type, request.name, request.packageId, member);
if (item === undefined) {
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
if (alt) item = host.engine.registry.getItem(alt, request.name, request.packageId, member);
// [ADR-0150 D3, #31680] The package-scoped resolver's answer, read
// once above, for a caller with no package of a type it covers; no
// first-composite read and no plural-spelling retry beside it.
if (packagelessScopedRead) {
item = packagelessScopedRead.item;
} else {
item = host.engine.registry.getItem(request.type, request.name, request.packageId, member);
if (item === undefined) {
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
if (alt) item = host.engine.registry.getItem(alt, request.name, request.packageId, member);
}
}
// [#22877] A registry that does not narrow by member (a host
// double, an older registry) can answer another member of the
Expand Down
11 changes: 11 additions & 0 deletions packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ export type {
PackageScopedRefusalReason,
} from './package-scoped-resolver.js';

// [#31680, ADR-0150 D3] Child E8: the by-name readers of the presentation and
// AI types consult `(package, name)` through the resolver above — the types
// wired, the read every reader shares, and the coded refusal it throws.
export {
PACKAGE_SCOPED_READ_TYPES,
isPackageScopedReadType,
readPackageScopedItem,
PackageScopedNameRefusedError,
} from './package-scoped-read.js';
export type { PackageScopedReadType } from './package-scoped-read.js';

// [#31673, ADR-0150 D4] The action engine's `(package, objectName, name)` key:
// the declaration half — which package's action a bare name on an object means
// to a caller, resolved by the resolver above over the items that own that
Expand Down
38 changes: 30 additions & 8 deletions packages/objectql/src/metadata-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
canonicalMetadataServiceType,
} from '@objectstack/core';
import { SchemaRegistry } from './registry.js';
import { PackageScopedNameRefusedError } from './package-scoped-read.js';

/**
* Is this (already canonical) type the object metadata type?
Expand Down Expand Up @@ -225,25 +226,36 @@ export class MetadataFacade {
* path can only corrupt — so there is none, and no replacement envelope key
* either (any key chosen would just collide with the next authorable field).
*
* `currentPackageId` (ADR-0048) opts into package-scoped resolution: when two
* installed packages ship an item of the same `type`/`name`, the registry
* prefers the one owned by `currentPackageId` (composite key
* `${packageId}:${name}`) before falling back to first-match. Omit it for the
* legacy context-free lookup.
* `currentPackageId` (ADR-0048) is the caller's package. [ADR-0150 D3,
* #31680 child E8] For a type whose by-name readers consult
* `(package, name)` (`PACKAGE_SCOPED_READ_TYPES`) the read is the one
* package-scoped resolver's (`SchemaRegistry.resolveItem`): the caller's own
* item, else the unique holder in its declared closure; with no package, the
* environment's own item, else the unique package holder; a name that is
* ambiguous, or held only where the caller may not reach, is refused naming
* every candidate. Every other type keeps `getItem`'s prefer-local read.
*
* @throws {PackageScopedNameRefusedError} when the resolver refuses.
*/
async get(type: string, name: string, currentPackageId?: string): Promise<any> {
// [#7378 row 2] Read the store `register` wrote: the canonical type.
return this.registry.getItem(canonicalMetadataServiceType(type), name, currentPackageId);
return this.registry.resolveItem(canonicalMetadataServiceType(type), name, currentPackageId);
}

/**
* Get the raw stored entry, synchronously and without package-scoped
* resolution. ([#7519] Historically documented as "with metadata wrapper" —
* there is no wrapper; {@link get} returns the same stored document. This
* member survives as the sync, scope-free variant.)
*
* [ADR-0150 D3, #31680 child E8] Scope-free is a caller with no package: a
* type of `PACKAGE_SCOPED_READ_TYPES` resolves environment-wide through the
* one package-scoped resolver, and a name two packages hold is refused.
*
* @throws {PackageScopedNameRefusedError} when the resolver refuses.
*/
getEntry(type: string, name: string): any {
return this.registry.getItem(canonicalMetadataServiceType(type), name);
return this.registry.resolveItem(canonicalMetadataServiceType(type), name);
}

/**
Expand Down Expand Up @@ -285,9 +297,19 @@ export class MetadataFacade {

/**
* Check if a metadata item exists
*
* [ADR-0150 D3, #31680 child E8] Read as {@link getEntry} reads it. A name
* the package-scoped resolver refuses exists: its holders are a fact, and
* no item is served for it here.
*/
async exists(type: string, name: string): Promise<boolean> {
const item = this.registry.getItem(canonicalMetadataServiceType(type), name);
let item: unknown;
try {
item = this.registry.resolveItem(canonicalMetadataServiceType(type), name);
} catch (err) {
if (err instanceof PackageScopedNameRefusedError) return true;
throw err;
}
return item !== undefined && item !== null;
}

Expand Down
Loading
Loading