From 282ba121986e35fde1a53862c772f538e9522c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 01:14:23 +0000 Subject: [PATCH 1/2] fix(objectql): register jobs / emailTemplates / tools / skills from a nested plugin too (#7049) `engine.ts` reaches the ADR-0010 provenance-stamping seam (`registerItem` -> `applyProtection`) from two entry points -- a manifest and a nested plugin -- and each carried its OWN copy of the collection list. The copies had drifted by four collections: `jobs`, `emailTemplates`, `tools` and `skills` registered from a manifest and NOT from a nested plugin, so a package shipping any of them via `manifest.plugins[]` registered nothing and stamped no provenance -- no refusal, no diagnostic. Hand-adding the four names was the available alternative and is exactly what #5870 did for `capabilities`; it is what left these four undiffed. Measured the two loops against each other instead: they differ in which object they read, which package id they stamp (both resolve to the same parent package), a per-key `debug` line, and the manifest seam's aggregated-view expansion plus its warn-on-nameless-item. All four are loop-BODY differences; none is a reason for the seams to enumerate different collections. So the enumeration is hoisted to one module-scope `METADATA_ARRAY_KEYS` both seams read, and the divergence is unrepresentable rather than merely unnoticed. `check:stack-collection-maps` now pins one ObjectQL enumeration instead of two, and the waiver row recording the divergence is removed in the same change -- #6242's ratchet handshake: 8 enumerations / 19 waiver rows -> 7 / 16. Tests pin each of the four registering AND carrying `_packageId` / `_provenance` from a nested plugin, plus the seams' agreement as a property over every comparable collection rather than over the four that happened to diverge. Refs: #7049, #7032, #6242, #5870, #4509, ADR-0010. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CXFj4BYpeUHyTX4eqirbeQ --- .../nested-plugin-collection-registration.md | 37 +++ .../src/engine-capability-provenance.test.ts | 28 ++- .../engine-nested-plugin-collections.test.ts | 220 ++++++++++++++++++ packages/objectql/src/engine.ts | 133 ++++++----- scripts/check-stack-collection-maps.mjs | 73 +++--- 5 files changed, 386 insertions(+), 105 deletions(-) create mode 100644 .changeset/nested-plugin-collection-registration.md create mode 100644 packages/objectql/src/engine-nested-plugin-collections.test.ts diff --git a/.changeset/nested-plugin-collection-registration.md b/.changeset/nested-plugin-collection-registration.md new file mode 100644 index 0000000000..a1ce99efad --- /dev/null +++ b/.changeset/nested-plugin-collection-registration.md @@ -0,0 +1,37 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): a nested plugin registers `jobs` / `emailTemplates` / `tools` / `skills` — four collections it silently dropped + +**This changes boot behaviour for packages that already ship today.** A package +whose artifacts arrive through a nested plugin (`manifest.plugins[]`) and that +declares any of `jobs`, `emailTemplates`, `tools` or `skills` previously +registered **nothing** for those collections: no refusal, no diagnostic, no +ADR-0010 provenance stamp. After this change the same package registers them, +stamped to the parent package — so `/meta/job`, `/meta/email_template`, +`/meta/tool` and `/meta/skill` begin answering for it, the email plugin's +`sys_email_template` materializer (#4509) begins seeing its templates, and the +AI protocol begins resolving its tools and skills. Anything that has been +compensating for the silence — a duplicate declaration hoisted to the top-level +manifest, a hand-seeded `sys_email_template` row — will now find the collection +already registered. + +`engine.ts` reaches the provenance-stamping seam (`registerItem` → +`applyProtection`, the only place `_packageId` / `_provenance` are written) from +two entry points, and each carried its **own copy** of the collection list. The +copies had drifted by exactly those four. `capabilities` hit the same divergence +and was patched into the second copy by hand (#5870) without the rest of the two +lists being diffed, which is how these four survived it. + +So the copies are gone rather than reconciled: both entry points now read one +module-scope `METADATA_ARRAY_KEYS`. The two loops were measured against each +other first — they differ in which object they read, which package id they stamp +(both resolve to the same parent package), a per-key `debug` line, and the +manifest seam's aggregated-view expansion and warn-on-nameless-item. Every one +of those is a loop-body difference; none is a reason for the two seams to +enumerate different collections. `check:stack-collection-maps` correspondingly +pins one ObjectQL enumeration instead of two, and its waiver row recording the +divergence is removed with the divergence (#6242's ratchet handshake). + +Refs: #7049, #6242, #5870, #4509, ADR-0010. diff --git a/packages/objectql/src/engine-capability-provenance.test.ts b/packages/objectql/src/engine-capability-provenance.test.ts index 0cae1e32e0..6631087a80 100644 --- a/packages/objectql/src/engine-capability-provenance.test.ts +++ b/packages/objectql/src/engine-capability-provenance.test.ts @@ -8,11 +8,18 @@ * path: `ObjectQL.registerApp()` decomposes a manifest's metadata arrays and * calls `SchemaRegistry.registerItem(type, item, 'name', packageId)`, which runs * `applyProtection(item, { packageId })` and stamps `_packageId` / - * `_provenance`. The key list that drives that decomposition - * (`metadataArrayKeys`, twice in `engine.ts`: the manifest seam and the nested - * `registerPlugin` seam) carried every other Security-Protocol collection — - * `permissions`, `sharingRules`, `roles`, `profiles`, `policies` — but not - * `capabilities`. + * `_provenance`. The key list that drives that decomposition — at the time, + * `metadataArrayKeys`, declared TWICE in `engine.ts`, once per seam: the + * manifest seam and the nested `registerPlugin` seam — carried every other + * Security-Protocol collection — `permissions`, `sharingRules`, `roles`, + * `profiles`, `policies` — but not `capabilities`. + * + * The two copies are one `METADATA_ARRAY_KEYS` since #7049, which found four + * MORE collections (`jobs`, `emailTemplates`, `tools`, `skills`) that this + * fix's one-name-at-a-time shape had left diverged. The seams' agreement is + * pinned as a property in `engine-nested-plugin-collections.test.ts`; the + * nested-seam case below stays because `capabilities` is the collection whose + * absence had a NAMED downstream consequence. * * Consequence before the fix: `plugin-security`'s `bootstrapDeclaredCapabilities` * resolves the owner as `cap._packageId ?? cap.packageId` and reads its input @@ -110,10 +117,13 @@ describe('registerApp — declared capabilities carry registry provenance (#5870 }); it('stamps capabilities declared by a NESTED plugin too (the second seam)', () => { - // `engine.ts` carries `metadataArrayKeys` twice — the manifest seam and the - // `registerPlugin` seam reached via `manifest.plugins[]`. A fix applied to - // only one leaves a package's nested plugin declaring capabilities that - // still never get stamped, which is the same defect one level down. + // `engine.ts` reaches this seam from two entry points — the manifest seam + // and the `registerPlugin` seam reached via `manifest.plugins[]`. A fix + // applied to only one leaves a package's nested plugin declaring + // capabilities that still never get stamped, which is the same defect one + // level down. (Since #7049 both entry points read ONE collection list, so + // this can no longer be half-fixed; the assertion stays as the regression + // pin for the collection that first exposed it.) const engine = new ObjectQL(); engine.registerApp({ id: PKG, diff --git a/packages/objectql/src/engine-nested-plugin-collections.test.ts b/packages/objectql/src/engine-nested-plugin-collections.test.ts new file mode 100644 index 0000000000..668cda4516 --- /dev/null +++ b/packages/objectql/src/engine-nested-plugin-collections.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7049] A nested plugin registers the SAME collections a manifest does. + * + * `engine.ts` reaches the ADR-0010 provenance seam — `registerItem` → + * `applyProtection`, the only place `_packageId` / `_provenance` are stamped — + * from two entry points: a manifest (`registerApp()`) and a nested plugin + * (`registerPlugin()`, reached via `manifest.plugins[]`). Each used to carry its + * OWN copy of the collection list, and the two copies had drifted: + * + * `jobs`, `emailTemplates`, `tools`, `skills` — registered from a manifest, + * NOT registered from a nested plugin. + * + * A package shipping any of the four from a nested plugin therefore registered + * NOTHING: no refusal, no diagnostic, no provenance stamp. The collection simply + * was not in the registry after boot, and every reader of it — `/meta/job`, + * `/meta/email_template`, the AI protocol's tool/skill resolution, the email + * plugin's `sys_email_template` materializer (#4509) — answered empty for a + * package that had declared it. + * + * `capabilities` hit this exact divergence and was patched into the nested copy + * BY HAND (#5870); nobody then diffed the rest of the two lists, which is how + * the remaining four survived. So the fix is not four more names: the two copies + * are now ONE `METADATA_ARRAY_KEYS` both seams read, and the last suite below + * pins that property directly — a future divergence has to get past a test that + * compares the two seams' output rather than past a reviewer diffing two lists. + * + * Refs: #7049, #6242 (the enumeration sweep), #5870 (the `capabilities` + * precedent), PR #7032 (the measurement + the gate waiver row this removes), + * ADR-0010 (provenance envelope). + */ + +import { describe, it, expect } from 'vitest'; +import { pluralToSingular } from '@objectstack/spec/shared'; +import { ObjectQL } from './engine'; + +const PKG = 'com.acme.billing'; + +/** + * The four collections whose two-copy divergence this card closes, each with the + * singular registry type its readers ask for. Both halves matter: membership in + * the shared key list registers the item, and the plural→singular mapping is + * what decides whether it lands in the store anything reads (the same pairing + * `engine-capability-provenance.test.ts` pins for the security collections). + */ +const DIVERGED_COLLECTIONS: ReadonlyArray = [ + ['jobs', 'job'], + ['emailTemplates', 'email_template'], + ['tools', 'tool'], + ['skills', 'skill'], +]; + +/** One authored item per collection, with NO author-side package attribution. */ +function itemFor(plural: string): Record { + switch (plural) { + case 'jobs': + return { name: 'nightly_invoice', label: 'Nightly Invoice', schedule: '0 2 * * *' }; + case 'emailTemplates': + return { name: 'invoice_ready', subject: 'Your invoice is ready', body: 'Hello {{name}}' }; + case 'tools': + return { name: 'refund_lookup', label: 'Refund Lookup', description: 'Find a refund by id.' }; + case 'skills': + return { name: 'dunning', label: 'Dunning', description: 'Chase overdue invoices.' }; + default: + throw new Error(`no fixture for '${plural}'`); + } +} + +/** A manifest whose artifacts arrive ONLY through a nested plugin. */ +function manifestWithNestedPlugin(collections: readonly string[]) { + const plugin: Record = { name: 'billing-nested' }; + for (const plural of collections) plugin[plural] = [itemFor(plural)]; + return { id: PKG, name: 'billing', plugins: [plugin] }; +} + +/** The same artifacts declared directly on the manifest — the reference path. */ +function manifestDirect(collections: readonly string[]) { + const manifest: Record = { id: PKG, name: 'billing' }; + for (const plural of collections) manifest[plural] = [itemFor(plural)]; + return manifest; +} + +function registeredNames(engine: ObjectQL, type: string): string[] { + return (engine.registry.listItems(type) ?? []) + .map((i: any) => i?.content ?? i) + .filter(Boolean) + .map((i: any) => i.name); +} + +function registeredItem(engine: ObjectQL, type: string): any { + return (engine.registry.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean)[0]; +} + +describe('registerPlugin — the four collections a nested plugin used to drop (#7049)', () => { + for (const [plural, singular] of DIVERGED_COLLECTIONS) { + it(`registers \`${plural}\` shipped by a nested plugin under '${singular}'`, () => { + const engine = new ObjectQL(); + engine.registerApp(manifestWithNestedPlugin([plural])); + + // Before #7049 this list was EMPTY — the silent no-registration. + expect(registeredNames(engine, singular)).toEqual([itemFor(plural).name]); + }); + + it(`stamps ADR-0010 provenance on a nested-plugin \`${plural}\` item`, () => { + const engine = new ObjectQL(); + engine.registerApp(manifestWithNestedPlugin([plural])); + + const item = registeredItem(engine, singular); + expect(item, `nothing registered under '${singular}'`).toBeDefined(); + // A nested plugin contributes UNDER its parent package's ownership: the + // parent already claimed the namespace, so `ownerId` is the parent id. + expect(item._packageId, `'${singular}' reached the registry unstamped`).toBe(PKG); + expect(item._provenance).toBe('package'); + // …and the stamp really is the registry's: nothing authored it. + expect(item.packageId).toBeUndefined(); + }); + } + + it('registers all four at once, exactly as a manifest does', () => { + const plurals = DIVERGED_COLLECTIONS.map(([p]) => p); + + const nested = new ObjectQL(); + nested.registerApp(manifestWithNestedPlugin(plurals)); + const direct = new ObjectQL(); + direct.registerApp(manifestDirect(plurals)); + + for (const [plural, singular] of DIVERGED_COLLECTIONS) { + expect(registeredNames(nested, singular), `'${plural}' missing from the nested seam`) + .toEqual(registeredNames(direct, singular)); + expect(registeredItem(nested, singular)._packageId) + .toBe(registeredItem(direct, singular)._packageId); + } + }); + + it('maps each collection to the singular type its readers ask for', () => { + // Membership in the key list is only half the contract: an item registered + // under the wrong singular lands in a store nobody reads. + for (const [plural, singular] of DIVERGED_COLLECTIONS) { + expect(pluralToSingular(plural), `${plural} must register as '${singular}'`).toBe(singular); + } + }); +}); + +describe('the two registration seams enumerate ONE collection list (#7049)', () => { + /** + * The root cause this card names is not the four missing names — it is that + * the two seams had two hand-maintained lists and nothing compared them. This + * suite is that comparison, stated as a property over EVERY collection the + * engine registers rather than over the four that happened to diverge: ship + * one item of a collection through a manifest and the same item through a + * nested plugin, and the two registries must agree. A key added to one seam + * only cannot pass it, whatever the two lists look like. + * + * The collections excluded below are excluded for measured reasons, not to + * make the test pass — see each entry. + */ + const NOT_COMPARABLE: ReadonlyArray = [ + // `views` has no top-level `name` and the manifest seam additionally expands + // an aggregated container into per-view items (ADR-0017); that expansion is + // a LOOP-BODY difference between the seams, not an enumeration difference, + // and closing it changes what a nested plugin serves — its own card, filed + // rather than folded in here. + ['views', 'manifest seam additionally expands aggregated containers (ADR-0017)'], + // Retired kinds the loop still iterates; the schema rejects the keys long + // before either seam runs, so a fixture cannot exercise them (the gate + // carries them as an `extra` waiver row for the same reason). + ['workflows', 'ADR-0019 retired'], + ['approvals', 'ADR-0020 retired'], + ['roles', 'ADR-0090 retired'], + ['profiles', 'ADR-0088 retired'], + ['policies', 'ADR-0088 retired'], + ['ragPipelines', 'not declared by ObjectStackDefinitionSchema'], + ]; + + const excluded = new Set(NOT_COMPARABLE.map(([k]) => k)); + + /** + * Read the shared enumeration back off the engine by probing it: for each + * candidate collection, does a one-item manifest register anything? This asks + * the running engine rather than importing a constant, so the test measures + * behaviour and not the same literal the implementation reads. + */ + const CANDIDATES = [ + 'actions', 'pages', 'dashboards', 'reports', 'datasets', 'themes', + 'flows', 'webhooks', 'jobs', + 'permissions', 'capabilities', 'sharingRules', + 'agents', 'tools', 'skills', 'apis', + 'hooks', 'mappings', 'analyticsCubes', 'connectors', + 'emailTemplates', 'docs', 'books', + ].filter((k) => !excluded.has(k)); + + it.each(CANDIDATES)('registers `%s` identically from a manifest and from a nested plugin', (plural) => { + const singular = pluralToSingular(plural); + const item = { name: `probe_${plural}`, label: `Probe ${plural}` }; + + const direct = new ObjectQL(); + direct.registerApp({ id: PKG, name: 'billing', [plural]: [item] }); + const nested = new ObjectQL(); + nested.registerApp({ id: PKG, name: 'billing', plugins: [{ name: 'p', [plural]: [item] }] }); + + const fromManifest = registeredNames(direct, singular); + const fromPlugin = registeredNames(nested, singular); + + // The property, in both directions: whatever one seam registers, the other + // registers. A collection added to one list only fails here. + expect(fromPlugin, `'${plural}' diverges between the two registration seams`).toEqual(fromManifest); + if (fromManifest.length > 0) { + expect(registeredItem(nested, singular)._packageId).toBe(PKG); + expect(registeredItem(nested, singular)._provenance).toBe('package'); + } + }); + + it('records why each excluded collection is not comparable, rather than dropping it silently', () => { + for (const [, why] of NOT_COMPARABLE) expect(why.length).toBeGreaterThan(0); + expect(NOT_COMPARABLE.map(([k]) => k)).toEqual([ + 'views', 'workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines', + ]); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 246bd10c13..878713284c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1020,6 +1020,73 @@ export type EngineMiddleware = ( next: () => Promise ) => Promise; +/** + * The stack collections the engine decomposes into individual registry items — + * ONE list, read by BOTH registration seams (the manifest seam in + * `registerApp()` and the nested-plugin seam in `registerPlugin()`). + * + * ## Why this is one constant and not two lists (#7049) + * + * It used to be two: `const metadataArrayKeys = [...]` declared separately + * inside each loop. They drifted, invisibly, because nothing compared them — + * `jobs`, `emailTemplates`, `tools` and `skills` were registered from a + * manifest and NOT from a nested plugin, so a package shipping any of the four + * from a nested plugin registered nothing and stamped no ADR-0010 provenance: + * no refusal, no diagnostic. `capabilities` hit the SAME divergence and was + * patched into the nested copy by hand (#5870) without anyone diffing the rest + * of the two lists, which is how the remaining four survived it. + * + * The two loops were measured against each other before this was merged. They + * differ in four ways — which object they read (`manifest` vs `plugin`), which + * package id they stamp (both resolve to the SAME parent package: a nested + * plugin contributes under its parent's ownership), a per-key `debug` line, and + * the manifest seam's aggregated-view expansion plus its warn-on-nameless-item. + * Every one of those lives in the loop BODY. Not one of them is a reason for + * the two seams to enumerate different collections, so the enumeration is + * shared and the divergence is now unrepresentable rather than merely unnoticed. + * + * `check:stack-collection-maps` pins this list against + * `ObjectStackDefinitionSchema` in both directions (#6242); it used to pin the + * two copies as two sites and carry a waiver row recording their divergence. + * That row is gone with the divergence. + */ +const METADATA_ARRAY_KEYS = [ + // UI Protocol + 'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes', + // Automation Protocol + 'flows', 'workflows', 'approvals', 'webhooks', + 'jobs', + // Security Protocol — `capabilities` is here for the same reason as + // `permissions` (#5870, #4967 Part 2): the ONLY seam that stamps + // ADR-0010 provenance is `registerItem` → `applyProtection`, so a + // collection missing from this list reaches no registry with a + // `_packageId`. `bootstrapDeclaredCapabilities` resolves the owning + // package as `cap._packageId ?? cap.packageId`; while `capabilities` + // sat outside this list the first half could never be satisfied and + // `readDeclared(ql, 'capability')` returned nothing, which made the + // author-side `packageId` — documented as the FALLBACK — mandatory, + // and its omission a silent, unenforced authorization declaration. + 'roles', 'permissions', 'capabilities', 'profiles', 'sharingRules', 'policies', + // AI Protocol + 'agents', 'tools', 'skills', 'ragPipelines', + // API Protocol + 'apis', + // Data Extensions + 'hooks', 'mappings', 'analyticsCubes', + // Integration Protocol + 'connectors', + // System Protocol — outbound mail templates. Registered here so the + // email plugin's materializer can read them back into + // `sys_email_template` (#4509); without this key an authored + // `emailTemplates:` entry never reached the registry at all, which is + // the far end of the disconnect the bridge closes. + 'emailTemplates', + // System Protocol — package documentation (ADR-0046); inert data + 'docs', + // Documentation navigation spine (ADR-0046 §6) + 'books', +] as const; + /** * Derive the registry key for a metadata item. * @@ -3006,44 +3073,12 @@ export class ObjectQL implements IObjectQLEngine { }); } - // 5. Register all other metadata types generically - const metadataArrayKeys = [ - // UI Protocol - 'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes', - // Automation Protocol - 'flows', 'workflows', 'approvals', 'webhooks', - 'jobs', - // Security Protocol — `capabilities` is here for the same reason as - // `permissions` (#5870, #4967 Part 2): the ONLY seam that stamps - // ADR-0010 provenance is `registerItem` → `applyProtection`, so a - // collection missing from this list reaches no registry with a - // `_packageId`. `bootstrapDeclaredCapabilities` resolves the owning - // package as `cap._packageId ?? cap.packageId`; while `capabilities` - // sat outside this list the first half could never be satisfied and - // `readDeclared(ql, 'capability')` returned nothing, which made the - // author-side `packageId` — documented as the FALLBACK — mandatory, - // and its omission a silent, unenforced authorization declaration. - 'roles', 'permissions', 'capabilities', 'profiles', 'sharingRules', 'policies', - // AI Protocol - 'agents', 'tools', 'skills', 'ragPipelines', - // API Protocol - 'apis', - // Data Extensions - 'hooks', 'mappings', 'analyticsCubes', - // Integration Protocol - 'connectors', - // System Protocol — outbound mail templates. Registered here so the - // email plugin's materializer can read them back into - // `sys_email_template` (#4509); without this key an authored - // `emailTemplates:` entry never reached the registry at all, which is - // the far end of the disconnect the bridge closes. - 'emailTemplates', - // System Protocol — package documentation (ADR-0046); inert data - 'docs', - // Documentation navigation spine (ADR-0046 §6) - 'books', - ]; - for (const key of metadataArrayKeys) { + // 5. Register all other metadata types generically. + // The collection list is `METADATA_ARRAY_KEYS` (module scope) and is + // SHARED with the nested-plugin seam in `registerPlugin()` — see the + // constant's docblock for why the two seams may not enumerate + // different collections (#7049). + for (const key of METADATA_ARRAY_KEYS) { const items = (manifest as any)[key]; if (Array.isArray(items) && items.length > 0) { this.logger.debug(`Registering ${key} from manifest`, { id, count: items.length }); @@ -3185,20 +3220,14 @@ export class ObjectQL implements IObjectQLEngine { } } - // Register metadata arrays (actions, views, triggers, etc.) - const metadataArrayKeys = [ - 'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes', - 'flows', 'workflows', 'approvals', 'webhooks', - // `capabilities` per #5870 — same stamping seam, one level down: a - // nested plugin's declarations must carry the parent package's - // provenance too, or the same declared-≠-enforced hole reopens for - // packages that ship their capabilities from a nested plugin. - 'roles', 'permissions', 'capabilities', 'profiles', 'sharingRules', 'policies', - 'agents', 'ragPipelines', 'apis', - 'hooks', 'mappings', 'analyticsCubes', 'connectors', - 'docs', 'books', - ]; - for (const key of metadataArrayKeys) { + // Register metadata arrays (actions, views, triggers, etc.) from the SAME + // list the manifest seam uses — same stamping seam, one level down: a + // nested plugin's declarations must carry the parent package's ADR-0010 + // provenance too, or the declared-≠-enforced hole reopens for packages + // that ship a collection from a nested plugin (`capabilities` #5870; + // `jobs` / `emailTemplates` / `tools` / `skills` #7049, which is why the + // list is no longer copied here to be patched one name at a time). + for (const key of METADATA_ARRAY_KEYS) { const items = (plugin as any)[key]; if (Array.isArray(items) && items.length > 0) { for (const item of items) { diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index f2fbda7938..45900887f5 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -12,13 +12,19 @@ // // `ObjectStackDefinitionSchema` (`packages/spec/src/stack.zod.ts`) decides which // collections a stack may declare. SEVEN other places re-enumerate that same set -// by hand -- eight enumerations in all, because ObjectQL declares its list twice: -// the map-format field list, the plural->singular map, the artifact category -// enum, the ObjectQL registration loops (x2), the artifact-ingest field map, the -// runtime's app-payload probe and the showcase coverage manifest -- and until -// this gate NOTHING compared any of them to the schema or to each other. +// by hand: the map-format field list, the plural->singular map, the artifact +// category enum, the ObjectQL registration seam, the artifact-ingest field map, +// the runtime's app-payload probe and the showcase coverage manifest -- and +// until this gate NOTHING compared any of them to the schema or to each other. // They drifted independently and invisibly: // +// (Eight, when this gate was written: ObjectQL declared its list TWICE, once per +// registration seam, and the two copies had drifted four collections apart -- +// recorded here as a waiver row until #7049 hoisted the single +// `METADATA_ARRAY_KEYS` both seams now read. A divergence between two copies is +// the one deviation no reading of either copy alone produces, which is the +// argument for this gate in one line.) +// // - `PLURAL_TO_SINGULAR` carries `ragPipelines`, which the schema does not // declare. // - `metadataArrayKeys` still lists `workflows` / `approvals` / `roles` / @@ -76,7 +82,9 @@ const repoRoot = resolve(here, '..'); * Slice the balanced `{...}` / `[...]` body that follows `anchor` in `source` * (the anchor's last character must be the opening bracket). Returns `null` when * the anchor is absent. `from` lets a caller walk repeated occurrences of one - * anchor -- ObjectQL declares `metadataArrayKeys` twice. + * anchor -- no SITE needs it since #7049 collapsed ObjectQL's two copies into + * one, but the next site that re-declares an anchor will, and the self-test + * keeps the behaviour pinned. */ export function sliceBody(source, anchor, from = 0) { const at = source.indexOf(anchor, from); @@ -417,11 +425,23 @@ const SITES = [ ], }, { - id: 'metadataArrayKeys (manifest)', + // ONE site, not two, since #7049. `engine.ts` used to declare this list + // twice -- once inside the manifest registration loop and once inside + // `registerPlugin` -- and the two copies had drifted: `jobs`, + // `emailTemplates`, `tools` and `skills` registered from a manifest and NOT + // from a nested plugin, so a package shipping them from a nested plugin + // registered nothing and stamped no ADR-0010 provenance. That divergence + // was recorded here as a waiver row on the nested copy; #7049 closed it by + // hoisting ONE `METADATA_ARRAY_KEYS` both seams read, so the row is gone + // with the thing it recorded and the gate now pins the single enumeration + // that remains. (Hand-adding the four names to the second copy was the + // available alternative and is what #5870 did for `capabilities`; it is + // what left the other four undiffed.) + id: 'METADATA_ARRAY_KEYS', file: 'packages/objectql/src/engine.ts', - what: 'collections the engine registers from a manifest (the ADR-0010 provenance seam)', + what: 'collections the engine registers from a manifest AND from a nested plugin (the ADR-0010 provenance seam)', extract: (src) => { - const b = sliceBody(src, 'const metadataArrayKeys = ['); + const b = sliceBody(src, 'const METADATA_ARRAY_KEYS = ['); return b && stringArrayItems(b.body); }, waivers: [ @@ -454,41 +474,6 @@ const SITES = [ }, ], }, - { - id: 'metadataArrayKeys (nested plugin)', - file: 'packages/objectql/src/engine.ts', - what: 'the same registration loop one level down, for a nested plugin', - extract: (src) => { - const first = sliceBody(src, 'const metadataArrayKeys = ['); - if (!first) return null; - const b = sliceBody(src, 'const metadataArrayKeys = [', first.end); - return b && stringArrayItems(b.body); - }, - waivers: [ - { - direction: 'extra', - keys: ['workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines'], - reason: 'the same six retired kinds as the manifest loop above — the two copies drift in lockstep (#6242 row 3).', - }, - { - direction: 'missing', - keys: ['objects', 'objectExtensions', 'apps', 'translations', 'datasourceMapping', 'datasources', 'data', 'positions'], - reason: 'same seam split as the manifest loop above.', - }, - { - direction: 'missing', - keys: ['jobs', 'emailTemplates', 'tools', 'skills'], - reason: - 'DIVERGENCE BETWEEN THE TWO COPIES — the most valuable row in this table, and one no reading of ' - + 'either list alone produces. These four ARE registered from a manifest and are NOT registered ' - + 'from a nested plugin, so a package that ships them from a nested plugin registers nothing and ' - + 'stamps no ADR-0010 provenance. `capabilities` was added to this copy for exactly that reason ' - + '(#5870) after the hole was found on the manifest copy; nobody then asked what else the two ' - + 'lists disagreed about. Recorded rather than fixed here: closing it changes what a nested plugin ' - + 'registers at boot — `engine-core` behaviour owing its own verification (#6242 row 3).', - }, - ], - }, { id: 'ARTIFACT_FIELD_TO_TYPE', file: 'packages/metadata/src/plugin.ts', From ba762bba3e105de765a856ab913e04aa30fba613 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 01:33:16 +0000 Subject: [PATCH 2/2] docs(objectql): name the filed card for the views-expansion seam difference (#7163) The `views` exclusion in the seam-agreement sweep is out of scope for #7049 by measurement, not by convenience: one aggregated container registers ['account', 'account.all_accounts', 'account.form'] from a manifest and ['account'] from a nested plugin. Filed as #7163; recording the issue number and the measured values so the exclusion is falsifiable from the test file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CXFj4BYpeUHyTX4eqirbeQ --- .../objectql/src/engine-nested-plugin-collections.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/engine-nested-plugin-collections.test.ts b/packages/objectql/src/engine-nested-plugin-collections.test.ts index 668cda4516..1db531dd2a 100644 --- a/packages/objectql/src/engine-nested-plugin-collections.test.ts +++ b/packages/objectql/src/engine-nested-plugin-collections.test.ts @@ -159,9 +159,11 @@ describe('the two registration seams enumerate ONE collection list (#7049)', () // `views` has no top-level `name` and the manifest seam additionally expands // an aggregated container into per-view items (ADR-0017); that expansion is // a LOOP-BODY difference between the seams, not an enumeration difference, - // and closing it changes what a nested plugin serves — its own card, filed - // rather than folded in here. - ['views', 'manifest seam additionally expands aggregated containers (ADR-0017)'], + // and closing it changes what a nested plugin serves. Measured while closing + // this card and filed as #7163 rather than folded in: one container + // registers `['account', 'account.all_accounts', 'account.form']` from a + // manifest and `['account']` from a nested plugin. + ['views', 'manifest seam additionally expands aggregated containers (ADR-0017) — #7163'], // Retired kinds the loop still iterates; the schema rejects the keys long // before either seam runs, so a fixture cannot exercise them (the gate // carries them as an `extra` waiver row for the same reason).