From 3d5832f8ce50a4d0e81f9792b3d41419f3fa5a9f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 15:45:49 +0000 Subject: [PATCH 1/3] feat(spec): declare IMetadataService.loadManyKeyed beside its plural-read siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyed plural loader read shipped as a public member on MetadataManager with no declaration on the contract its two siblings loadMany? and loadDiagnosed? are declared on, so the one cross-package caller — the ObjectQL governance audit — narrowed the service slot with a local structural type written beside the call site. Declare the member on IMetadataService, delete the local type, and let the call site read the contract. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../15385-metadata-service-load-many-keyed.md | 26 +++++ .../kernel/contracts/metadata-service.mdx | 30 +++++ packages/objectql/src/plugin.ts | 30 +---- .../src/contracts/metadata-service.test.ts | 106 ++++++++++++++++++ .../spec/src/contracts/metadata-service.ts | 33 ++++++ 5 files changed, 198 insertions(+), 27 deletions(-) create mode 100644 .changeset/15385-metadata-service-load-many-keyed.md diff --git a/.changeset/15385-metadata-service-load-many-keyed.md b/.changeset/15385-metadata-service-load-many-keyed.md new file mode 100644 index 00000000000..2b4dd087585 --- /dev/null +++ b/.changeset/15385-metadata-service-load-many-keyed.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +--- + +`IMetadataService` declares `loadManyKeyed?` — the keyed plural loader read now sits on the contract beside its two declared siblings `loadMany?` and `loadDiagnosed?` (#15385). + +Clause-②: yes + +A verb family lives whole on the contract. `MetadataManager.loadManyKeyed(type)` shipped as a public member with no declaration on the interface its siblings are declared on, so the one cross-package caller — the ObjectQL governance audit — narrowed the service slot with a **local structural type** written beside the call site. That local type is deleted in the same change and the call site reads the contract. + +The vocabulary is not new: `loadManyKeyed`, and the `{ name, data }` item shape it answers with, are already published on `MetadataLoader`, which declares the same member as optional over its own loader-local options type. What this adds is the member's place on `IMetadataService`. + +```ts +loadManyKeyed?( + type: string, + options?: Record, +): Promise>; +``` + +**What it is for.** The key is a fact about the **store** — `register()`'s own `name` argument — and it travels *beside* `data`, never folded into it, so `data` stays byte-identical to what the unkeyed plural read would return and no consumer ever sees a synthesised `name`. An item whose stored body has no top-level `name` is legal and deliberate (an org customization container's identity is the object it targets), and such an item has no identity at all in a plural read keyed by `data.name` — it is dropped, silently. That is why this is a second member rather than a widened return type on the existing one. + +**What moves for consumers.** Nothing breaks. The member is **optional**, like `loadMany?` and `loadDiagnosed?` beside it, so every existing `IMetadataService` implementation still satisfies the contract unchanged and the `typeof … === 'function'` probe stays the way a caller asks for it. What changes is that a caller no longer has to declare the shape itself to stay typed: intersecting the slot with a hand-written structural type was the only way to reach the member without erasing the lookup to `any`, and that workaround is now unnecessary. `MetadataManager`, which already implements the member, needs no edit. + +This is the position `loadDiagnosed` was in before #4127 batch 4 declared it, and it is resolved the same way. Ruled in decision batch #123 item 5 (2026-09-12), maintainer verbatim: 「同意」. + +`content/docs/kernel/contracts/metadata-service.mdx` gains the member in the same change. diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 28444193126..8a3c961225f 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -51,6 +51,7 @@ export interface IMetadataService { load?(type: string, name: string, options?): Promise; loadDiagnosed?(type: string, name: string, options?): Promise<{ data: T | null; degraded: boolean; errors: string[] }>; + loadManyKeyed?(type: string, options?): Promise>; // Convenience accessors for UI metadata (optional) getView?(name: string): Promise; @@ -140,6 +141,35 @@ if (degraded) { } ``` +### loadManyKeyed + +Optional. Reads **every** item of a type through the registered loaders, each +one paired with the key its loader holds it under. + +The key is a fact about the **store**, not about the body — it is `register`'s +own `name` argument — and it travels *beside* `data`, never inside it. `data` is +exactly what the unkeyed `loadMany` would return for the same item, so no +consumer ever sees a synthesised `name`. + +That difference is the point. A stored body with no top-level `name` is legal +and deliberate — an org customization container's identity is the object it +targets, not a name of its own — and such an item has **no identity at all** in +a plural read keyed by `data.name`: it is dropped, silently. Reach for the keyed +read wherever the set you are assembling is addressed by key. + +```typescript +for (const { name, data } of (await metadataService.loadManyKeyed?.('action')) ?? []) { + // `name` is the key the store holds this item under — present even when + // `data` carries none of its own. +} +``` + + +Probe it like every optional member — +`typeof metadataService.loadManyKeyed === 'function'`. A metadata plane that +does not offer it is read as "no keyed read here", never as an empty set. + + ### list / listNames The plural reads' failure posture. Both read a **set** through the same diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 394e45e06f2..98afc50a9ca 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -55,27 +55,6 @@ interface ProtocolWithDbRestore { }>; } -/** - * [#14423] The keyed plural read the governance audit reads the metadata plane - * through — `MetadataManager.loadManyKeyed(type)`, structurally. - * - * Declared HERE, beside the one call site, rather than on `IMetadataService`: - * `packages/spec` is a contract surface owned by another lane, and widening it - * is its own decision with its own review. This is the same position - * `loadDiagnosed` was in before it was declared — the call site and the - * implementation agreed, and the contract was what nobody had written — and - * the same remedy applies when that lane takes it: delete this and read the - * contract. ⛔ Not `any`: intersecting the slot's real contract keeps the - * lookup typed (#4251), and the member stays optional so a plane that predates - * it type-checks and is simply read as "no keyed read here". - */ -type KeyedPluralMetadataRead = { - loadManyKeyed?( - type: string, - options?: Record, - ): Promise>; -}; - /** Type guard — checks whether the service exposes `loadMetaFromDb`. */ function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore { return ( @@ -2555,14 +2534,11 @@ export class ObjectQLPlugin implements Plugin { */ private async resolveGovernanceMetadataService( ctx: PluginContext, - ): Promise<(IMetadataService & KeyedPluralMetadataRead) | undefined> { + ): Promise { const scopeId = this.environmentId; if (scopeId && typeof ctx.getServiceScoped === 'function') { try { - const scoped = await ctx.getServiceScoped( - 'metadata', - scopeId, - ); + const scoped = await ctx.getServiceScoped('metadata', scopeId); if (scoped != null) return scoped; } catch { // Not resolvable under a scope on this host — nothing registered under @@ -2573,7 +2549,7 @@ export class ObjectQLPlugin implements Plugin { } } try { - return ctx.getService('metadata'); + return ctx.getService('metadata'); } catch { return undefined; } diff --git a/packages/spec/src/contracts/metadata-service.test.ts b/packages/spec/src/contracts/metadata-service.test.ts index 42f4403faa7..2784610769b 100644 --- a/packages/spec/src/contracts/metadata-service.test.ts +++ b/packages/spec/src/contracts/metadata-service.test.ts @@ -514,4 +514,110 @@ describe('Metadata Service Contract', () => { expect(params).toEqual({}); }); }); + + // ========================================== + // Keyed plural loader read (#15385 batch #123 item 5) + // ========================================== + + describe('loadManyKeyed (optional member)', () => { + /** A minimal base implementation with only the REQUIRED members. */ + const baseService = (): IMetadataService => ({ + register: async () => {}, + get: async () => undefined, + list: async () => [], + unregister: async () => {}, + exists: async () => false, + listNames: async () => [], + getObject: async () => undefined, + listObjects: async () => [], + }); + + /** + * A stored body with NO top-level `name` — the shape the whole member + * exists for. Its identity is the key the store holds it under, so keying + * the unkeyed plural read by `data.name` has nothing to key it by. + */ + const namelessBody = { label: 'Account overrides', fields: [] as unknown[] }; + + it('is optional — an implementation without it still satisfies the contract', () => { + const service = baseService(); + + // The optional-member convention: consumers probe before they call. + expect(typeof service.loadManyKeyed).toBe('undefined'); + expect(typeof (service as IMetadataService).loadManyKeyed === 'function').toBe(false); + }); + + it('is probeable with typeof === "function" when provided', () => { + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async () => [], + }; + + expect(typeof service.loadManyKeyed).toBe('function'); + }); + + it('answers (key, body) pairs, and an empty set for a type nothing holds', async () => { + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async (type: string) => + type === 'customization' ? [{ name: 'account', data: namelessBody }] : [], + }; + + const keyed = await service.loadManyKeyed!('customization'); + expect(keyed).toEqual([{ name: 'account', data: namelessBody }]); + + expect(await service.loadManyKeyed!('no_such_type')).toEqual([]); + }); + + it('carries the key BESIDE the body — nothing is folded into `data`', async () => { + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async () => [{ name: 'account', data: namelessBody }], + }; + + const keyed = await service.loadManyKeyed!('customization'); + + // The body is the same object the unkeyed read would have returned... + expect(keyed[0]!.data).toBe(namelessBody); + // ...so the key lives only on the pair, never synthesised into the body. + expect('name' in keyed[0]!.data).toBe(false); + expect(keyed[0]!.name).toBe('account'); + }); + + it('types the key as string and the body as T', async () => { + type Overrides = { label: string; fields: unknown[] }; + + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async () => [{ name: 'account', data: namelessBody }], + }; + + // Type-level shape assertion: these annotations only compile against the + // declared `Promise>`. + const keyed = await service.loadManyKeyed!('customization'); + const name: string = keyed[0]!.name; + const body: Overrides = keyed[0]!.data; + + expect(name).toBe('account'); + expect(body.label).toBe('Account overrides'); + }); + + it('takes the same engine-local options bag as its unkeyed sibling', async () => { + let seen: Record | undefined; + const service: IMetadataService = { + ...baseService(), + loadManyKeyed: async (_type: string, options?: Record) => { + seen = options; + return []; + }, + }; + + await service.loadManyKeyed!('customization', { patterns: ['*.json'] }); + expect(seen).toEqual({ patterns: ['*.json'] }); + + // `options` is optional — the one in-repo caller passes nothing. + await service.loadManyKeyed!('customization'); + expect(seen).toBeUndefined(); + }); + }); }); diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index eddcf910298..bd37483b171 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -674,6 +674,39 @@ export interface IMetadataService { */ loadMany?(type: string, options?: Record): Promise; + /** + * Load EVERY item of a type across all loaders, each paired with the KEY + * its loader holds it under — the keyed twin of {@link loadMany}. + * + * `name` is the STORE's key (`register()`'s `name` argument), carried + * BESIDE the body and never folded into it: `data` is exactly what + * {@link loadMany} would return for the same item, so no consumer ever sees + * a synthesised name and the register contract's `data.name` check keeps + * meaning what it means. That is the whole reason this is a second member + * rather than a widened {@link loadMany} return: an item whose stored body + * has no top-level `name` — a customization container, whose identity IS + * the target object — has no identity at all in the unkeyed read, and + * keying that read by `data.name` drops it. + * + * [#15385 batch #123 item 5] Declared alongside {@link loadMany} / + * {@link loadDiagnosed} — the position {@link loadDiagnosed} was in before + * #4127 batch 4, and resolved the same way. Implemented by + * `MetadataManager` since #14423 and reached by the ObjectQL governance + * audit through a local structural type beside its one call site, with the + * contract the only thing nobody had written. A verb family lives whole on + * the contract: the vocabulary is already published on `MetadataLoader` + * (which declares the same optional member over its own loader-local + * options type); what this adds is the member's place HERE. `options` is + * the manager's load-options bag, engine-local in shape — declared + * `Record` like {@link loadMany}'s. Optional like its + * siblings, so a plane that predates it type-checks and is simply read as + * "no keyed read here", `typeof … === 'function'` being the probe. + */ + loadManyKeyed?( + type: string, + options?: Record, + ): Promise>; + // ========================================== // Import / Export // ========================================== From 97a639c5b38634b45af7c94f4507e7737b91e4cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 16:20:21 +0000 Subject: [PATCH 2/3] fix(spec): the loadManyKeyed doubles are generic, as the declaration is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double holding one fixed body cannot satisfy a caller-chosen `T` — the three object-literal implementations were concrete and `check:test-typecheck` refused them (TS2322, a signature the shrink-only ledger does not record). Declare each double `` and hand the body back under it. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- packages/spec/src/contracts/metadata-service.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/contracts/metadata-service.test.ts b/packages/spec/src/contracts/metadata-service.test.ts index 2784610769b..3a327c377d9 100644 --- a/packages/spec/src/contracts/metadata-service.test.ts +++ b/packages/spec/src/contracts/metadata-service.test.ts @@ -559,8 +559,10 @@ describe('Metadata Service Contract', () => { it('answers (key, body) pairs, and an empty set for a type nothing holds', async () => { const service: IMetadataService = { ...baseService(), - loadManyKeyed: async (type: string) => - type === 'customization' ? [{ name: 'account', data: namelessBody }] : [], + // Generic, because the declaration is: a double holding one fixed body + // can only answer under the `T` its CALLER names. + loadManyKeyed: async (type: string) => + type === 'customization' ? [{ name: 'account', data: namelessBody as T }] : [], }; const keyed = await service.loadManyKeyed!('customization'); @@ -572,7 +574,7 @@ describe('Metadata Service Contract', () => { it('carries the key BESIDE the body — nothing is folded into `data`', async () => { const service: IMetadataService = { ...baseService(), - loadManyKeyed: async () => [{ name: 'account', data: namelessBody }], + loadManyKeyed: async () => [{ name: 'account', data: namelessBody as T }], }; const keyed = await service.loadManyKeyed!('customization'); @@ -589,7 +591,7 @@ describe('Metadata Service Contract', () => { const service: IMetadataService = { ...baseService(), - loadManyKeyed: async () => [{ name: 'account', data: namelessBody }], + loadManyKeyed: async () => [{ name: 'account', data: namelessBody as T }], }; // Type-level shape assertion: these annotations only compile against the From b96baa08f273ba99c0986c24f706b693c2061b6a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:22:16 +0000 Subject: [PATCH 3/3] docs(spec): name the live record, the real nameless-body case, and a probe-first example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections to the loadManyKeyed declaration and its page. The docblock cited the card that ordered the implementation, which has since been deleted from the board and no longer resolves; check:issue-citations refused it as allocated-but-absent. Cite the merged pull request that is the live record instead, and keep the deleted card's number in prose, without a leading hash, so it stays greppable without minting a reference that dangles. "a customization container" named nothing in the tree and collided with the ADR-0005 sys_metadata overlay, which is a different mechanism this very page documents. The real case is the aggregated defineView container, which the MetadataKeyedItem docblock already names; both carriers now say so. The page's example did an optional call and null-coalesced to an empty array, turning an absent member into an empty set — the opposite of what the callout beneath it says and of what the one real caller does. It now probes first and spells out why absence is not emptiness. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../kernel/contracts/metadata-service.mdx | 34 +++++++++++++++---- .../spec/src/contracts/metadata-service.ts | 17 ++++++---- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 8a3c961225f..86674fffe56 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -152,15 +152,35 @@ exactly what the unkeyed `loadMany` would return for the same item, so no consumer ever sees a synthesised `name`. That difference is the point. A stored body with no top-level `name` is legal -and deliberate — an org customization container's identity is the object it -targets, not a name of its own — and such an item has **no identity at all** in -a plural read keyed by `data.name`: it is dropped, silently. Reach for the keyed -read wherever the set you are assembling is addressed by key. +and deliberate — an aggregated `defineView` container has none **by design**, +because its identity is the object it targets — and such an item has **no +identity at all** in a plural read keyed by `data.name`: it is dropped, +silently. Reach for the keyed read wherever the set you are assembling is +addressed by key. + + +Not to be confused with the **org customization overlay**, which is ADR-0005's +`sys_metadata` mechanism described under "Overlay Management" below — a +different thing entirely. + + +Probe before you call, and treat an absent member as *absent*, never as an empty +result — this is the same distinction `loadDiagnosed` exists for, one member +over: ```typescript -for (const { name, data } of (await metadataService.loadManyKeyed?.('action')) ?? []) { - // `name` is the key the store holds this item under — present even when - // `data` carries none of its own. +const keyedRead = metadataService.loadManyKeyed; + +if (typeof keyedRead === 'function') { + for (const { name, data } of await keyedRead.call(metadataService, 'action')) { + // `name` is the key the store holds this item under — present even when + // `data` carries none of its own. + } +} else { + // This plane offers no keyed read. ⛔ Do NOT fall through to an empty set: + // "no keys available here" and "the store holds nothing" are different + // facts. Fall back to the unkeyed plural read, or record that keys could + // not be obtained. } ``` diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index bd37483b171..f46d51bd859 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -684,16 +684,21 @@ export interface IMetadataService { * a synthesised name and the register contract's `data.name` check keeps * meaning what it means. That is the whole reason this is a second member * rather than a widened {@link loadMany} return: an item whose stored body - * has no top-level `name` — a customization container, whose identity IS - * the target object — has no identity at all in the unkeyed read, and - * keying that read by `data.name` drops it. + * has no top-level `name` — an aggregated `defineView` container, which has + * none BY DESIGN because its identity is the object it targets — has no + * identity at all in the unkeyed read, and keying that read by `data.name` + * drops it. (⛔ Not the org customization overlay, which is ADR-0005's + * `sys_metadata` mechanism and a different thing entirely.) * * [#15385 batch #123 item 5] Declared alongside {@link loadMany} / * {@link loadDiagnosed} — the position {@link loadDiagnosed} was in before * #4127 batch 4, and resolved the same way. Implemented by - * `MetadataManager` since #14423 and reached by the ObjectQL governance - * audit through a local structural type beside its one call site, with the - * contract the only thing nobody had written. A verb family lives whole on + * `MetadataManager` in #15378 and reached by the ObjectQL governance audit + * through a local structural type beside its one call site, with the + * contract the only thing nobody had written. ⚠️ #15378 is the LIVE record + * for that landing: the card it was filed under — issue 14423, written + * here without a leading hash because it no longer resolves — has since + * been deleted from the board. A verb family lives whole on * the contract: the vocabulary is already published on `MetadataLoader` * (which declares the same optional member over its own loader-local * options type); what this adds is the member's place HERE. `options` is