diff --git a/.changeset/28093-residual-top-level-items-register.md b/.changeset/28093-residual-top-level-items-register.md new file mode 100644 index 00000000000..80fd399da3a --- /dev/null +++ b/.changeset/28093-residual-top-level-items-register.md @@ -0,0 +1,12 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): every item a composed stack declares only on its top level, beside package bodies that do not declare it, registers in the engine at boot the way a package body's item of that type does + +Clause-②: yes (widening) +Metadata-protocol: none + +The engine's `manifest` service registered a multi-package stack's residual (top-level items no package body declares, ADR-0130 D4) objects, connectors and flows under the stack's own `manifest.id`, and no other type. A residual permission set, position, capability, sharing rule, queue, assignment rule, app, view, page, dashboard, report, action, hook, agent, tool, skill, API endpoint, webhook, email template, doc and every other collection a package body registers was held by the metadata service alone, so a reader that asks the engine registry answered as if it were not declared. Among those readers is the metadata save door's managed-content check: a residual top-level security rule was not sealed against an environment save the way the same rule declared in a package body is; it now is. + +Each such item now registers under the stack's manifest id and version, through the judges a body's item of that type passes: a view container is filed under its object and expanded into its views after the divergent-`name` judge, a `views:` entry that is not a container is refused as a body's is (422 `INVALID_METADATA`), a permission set whose `packageId` names another package is refused (ADR-0086 D3), an action is parsed against its schema, and the boot's later checks (the ownership-queue service check for queues and assignment rules, the bound-action target audit) now cover residual items too, so a residual queue on a composition without an ownership-queue service fails the boot with the prescription a body's does. Picklists stay metadata-only, as the residual objects rule already states. A top-level item a package body declares is still registered once, as that body's; stacks with no residual item, and single-package stacks, are unchanged. diff --git a/packages/objectql/src/manifest-service-residual-objects.test.ts b/packages/objectql/src/manifest-service-residual-objects.test.ts index 55e9f8cb83b..58604687144 100644 --- a/packages/objectql/src/manifest-service-residual-objects.test.ts +++ b/packages/objectql/src/manifest-service-residual-objects.test.ts @@ -37,9 +37,10 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; -import type { IMetadataService } from '@objectstack/spec/contracts'; +import { OWNERSHIP_QUEUE_SERVICE, type IMetadataService } from '@objectstack/spec/contracts'; import { ObjectQLPlugin } from './plugin.js'; import type { ObjectQL } from './engine.js'; +import { viewContainerNameRefusal } from './view-container-name-refusal.js'; type ManifestService = { register(m: unknown): void | Promise }; @@ -386,3 +387,158 @@ describe('ADR-0130 D4 — the manifest service registers the residual\'s flows', expect(flowOwners(engineOf(kernel))).toEqual([['acme_escalate', SERVICE_ID, '2.4.0']]); }); }); + +/** + * [ADR-0130 D4] EVERY other residual type a package body's collections take + * into the registry reaches it too, under the stack's manifest id and version: + * the engine registered a residual's objects, connectors and flows only, so a + * residual item of any other type was registered by the metadata door alone + * and every reader that asks the registry — the save door's managed-content + * seal among them — answered as if it were not declared. Two residual types + * keep a path of their own and are not in this population: objects (above) and + * picklists, which stay metadata-only for the reason the objects rule names. + */ +describe('ADR-0130 D4 — the manifest service registers every other residual type', () => { + /** The stack collection of each type, with a minimal named item. */ + const GENERIC: Record = { + apps: 'app', pages: 'page', dashboards: 'dashboard', reports: 'report', actions: 'action', + workflows: 'workflow', positions: 'position', permissions: 'permission', capabilities: 'capability', + sharingRules: 'sharing_rule', apis: 'api', webhooks: 'webhook', agents: 'agent', tools: 'tool', + skills: 'skill', ragPipelines: 'rag_pipeline', hooks: 'hook', mappings: 'mapping', + analyticsCubes: 'analytics_cube', emailTemplates: 'email_template', docs: 'doc', books: 'book', + }; + const itemOf = (type: string) => ({ name: `acme_top_${type}`, label: `Top ${type}` }); + /** The registry parses an action on registration, so the residual's action is a valid one too. */ + const ITEM_EXTRA: Record> = { actions: { type: 'url', target: 'https://example.com' } }; + const collectionsOf = (pick: (type: string) => boolean = () => true) => Object.fromEntries( + Object.entries(GENERIC).filter(([, type]) => pick(type)) + .map(([field, type]) => [field, [{ ...itemOf(type), ...ITEM_EXTRA[field] }]]), + ); + /** + * A package BODY is strictly parsed on registration, so the pins that put an + * item on a body take a schema-valid one: every security-domain type, and + * one of each other kind the body schema admits with a short item. + */ + const BODY_VALID: Record> = { + permissions: { objects: {} }, + positions: {}, + capabilities: {}, + sharingRules: { + type: 'criteria', object: 'acme_case', condition: "record.subject == 'x'", accessLevel: 'read', + sharedWith: { type: 'position', value: 'acme_top_position' }, + }, + apps: {}, + pages: {}, + actions: { type: 'url', target: 'https://example.com' }, + hooks: { object: 'acme_case', events: ['beforeInsert'] }, + agents: { role: 'Triage', instructions: 'Triage cases.' }, + webhooks: { object: 'acme_case', url: 'https://example.com/hook', triggers: ['create'] }, + emailTemplates: { subject: 'Subject', bodyHtml: '

Body

' }, + }; + const bodyCollections = () => Object.fromEntries( + Object.entries(BODY_VALID).map(([field, extra]) => [field, [{ ...itemOf(GENERIC[field]), ...extra }]]), + ); + const ownerOf = (ql: ObjectQL, type: string, name: string) => { + const item = ql.registry.getItem(type, name) as { _packageId?: string; _packageVersion?: string } | undefined; + return item ? [item._packageId, item._packageVersion] : undefined; + }; + const namesOf = (ql: ObjectQL, type: string) => + (ql.registry.listItems(type) as Array<{ name?: string }>).map((i) => i.name).filter((n) => n?.startsWith('acme_')).sort(); + + it.each(Object.values(GENERIC))('registers a residual %s under the stack\'s manifest id and version', async (type) => { + const { kernel } = await boot(registering('app', [residualStack(noteObj(), collectionsOf((t) => t === type))])); + expect(ownerOf(engineOf(kernel), type, `acme_top_${type}`)).toEqual([RELEASE_ID, '1.0.0']); + }); + + it('registers every one of them from one stack, and leaves the artifact\'s own entries as they arrived', async () => { + const collections = collectionsOf(); + const { kernel } = await boot(registering('app', [residualStack(noteObj(), collections)])); + const ql = engineOf(kernel); + for (const type of Object.values(GENERIC)) { + expect(ownerOf(ql, type, `acme_top_${type}`), type).toEqual([RELEASE_ID, '1.0.0']); + } + for (const [field] of Object.entries(GENERIC)) { + expect((collections[field] as Array<{ _packageId?: string }>)[0]._packageId, field).toBeUndefined(); + } + }); + + it('a residual view container registers under its object, expanded into its views, like a body\'s', async () => { + const container = { object: 'acme_note', list: { type: 'grid', columns: ['title'] } }; + const { kernel } = await boot(registering('app', [residualStack(noteObj(), { views: [container] })])); + const ql = engineOf(kernel); + const views = ql.registry.listItems('view') as Array<{ name?: string; _packageId?: string }>; + const noteViews = views.filter((v) => v.name === 'acme_note' || v.name?.startsWith('acme_note.')); + expect(noteViews.map((v) => v.name)).toContain('acme_note'); + expect(noteViews.length).toBeGreaterThan(1); + expect(new Set(noteViews.map((v) => v._packageId))).toEqual(new Set([RELEASE_ID])); + expect((container as { _packageId?: string })._packageId).toBeUndefined(); + }); + + it('a residual view container whose own `name` disagrees with its object is refused, in the body seam\'s words', async () => { + const container = { name: 'notes_grid', object: 'acme_note', list: { type: 'grid', columns: ['title'] } }; + const expected = viewContainerNameRefusal(container, 'manifest', RELEASE_ID); + expect(expected).toBeDefined(); + await expect(boot(registering('app', [residualStack(noteObj(), { views: [container] })]))) + .rejects.toThrow(expected!.message); + }); + + it('a residual view entry with no `name` is filed under its slot, as the body seam files it', async () => { + const entry = { object: 'acme_note', label: 'Notes' }; + const { kernel } = await boot(registering('app', [residualStack(noteObj(), { views: [entry] })])); + expect(ownerOf(engineOf(kernel), 'view', 'acme_note')).toEqual([RELEASE_ID, '1.0.0']); + expect((entry as { name?: string }).name).toBeUndefined(); + }); + + it.each([ + ['a flattened overlay with no `name`', { object: 'acme_note', type: 'grid', columns: ['title'] }, 'acme_note'], + ['a ViewItem', { name: 'acme_note_item', object: 'acme_note', viewKind: 'list', config: { type: 'grid' } }, 'acme_note_item'], + ])('a residual `views:` entry that is not a container (%s) is refused 422, in the body seam\'s words', async (_label, entry, name) => { + const refusal = await boot(registering('app', [residualStack(noteObj(), { views: [entry] })])).then(() => undefined, (e: unknown) => e); + expect(refusal).toMatchObject({ code: 'INVALID_METADATA', status: 422, httpStatus: 422 }); + expect((refusal as Error).message).toContain( + `Invalid \`views:\` entry '${name}' from manifest '${RELEASE_ID}': the stack \`views:\` collection carries view CONTAINERS only.`, + ); + }); + + it('a residual queue and assignment rule register when the ownership-queue service is composed', async () => { + const queues = { queues: [itemOf('queue')], assignmentRules: [itemOf('assignment_rule')] }; + const queueService: Plugin = { + name: 'test.ownership-queue', + init: async (ctx: PluginContext) => { ctx.registerService(OWNERSHIP_QUEUE_SERVICE, {}); }, + } as Plugin; + const { kernel } = await boot(queueService, registering('app', [residualStack(noteObj(), queues)])); + const ql = engineOf(kernel); + expect(ownerOf(ql, 'queue', 'acme_top_queue')).toEqual([RELEASE_ID, '1.0.0']); + expect(ownerOf(ql, 'assignment_rule', 'acme_top_assignment_rule')).toEqual([RELEASE_ID, '1.0.0']); + }); + + it('…and, like a body\'s, refuse the boot loudly when it is not', async () => { + const queues = { queues: [itemOf('queue')], assignmentRules: [itemOf('assignment_rule')] }; + await expect(boot(registering('app', [residualStack(noteObj(), queues)]))) + .rejects.toThrow(/declares queues \(1\) and assignmentRules \(1\), but no 'ownership-queue' service is registered/); + }); + + it('a residual permission set whose body `packageId` names another package is refused, as a body\'s is', async () => { + const set = { ...itemOf('permission'), objects: {}, packageId: OTHER_ID }; + await expect(boot(registering('app', [residualStack(noteObj(), { permissions: [set] })]))) + .rejects.toThrow(/Drop `packageId` from permission set 'acme_top_permission'/); + }); + + it('a top-level item a body repeats is the body\'s, registered once', async () => { + const { kernel } = await boot(registering('app', [residualStack(noteObj(), bodyCollections(), bodyCollections())])); + const ql = engineOf(kernel); + for (const type of Object.keys(BODY_VALID).map((field) => GENERIC[field])) { + expect(ownerOf(ql, type, `acme_top_${type}`), type).toEqual([SERVICE_ID, '2.4.0']); + expect(namesOf(ql, type), type).toEqual([`acme_top_${type}`]); + } + }); + + it('CONTROL: a stack with no residual item of these types registers exactly the bodies\' items', async () => { + const { kernel } = await boot(registering('app', [residualStack(noteObj(), {}, bodyCollections())])); + const ql = engineOf(kernel); + for (const type of Object.keys(BODY_VALID).map((field) => GENERIC[field])) { + expect(namesOf(ql, type), type).toEqual([`acme_top_${type}`]); + expect(ownerOf(ql, type, `acme_top_${type}`), type).toEqual([SERVICE_ID, '2.4.0']); + } + }); +}); diff --git a/packages/objectql/src/manifest-service-residual-security-seal.test.ts b/packages/objectql/src/manifest-service-residual-security-seal.test.ts new file mode 100644 index 00000000000..acb62124a9f --- /dev/null +++ b/packages/objectql/src/manifest-service-residual-security-seal.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0130 D4, ADR-0131 D6] A security rule a composed stack declares only on + * its TOP LEVEL, beside package bodies that do not declare it (the stack's + * residual), is managed content the way a package body's is: the metadata + * save door seals it against an environment save, and the declared rule keeps + * being the one served. + * + * What decides "a code package ships this" at that door is an entry a package + * registered in this engine's SchemaRegistry (`isArtifactBacked`). The + * `manifest` service registers each residual item there under the stack's own + * manifest id, as it registers a body's items under the body's id, so the two + * answer alike. `engine-security-catalog-package-door.test.ts` holds the same + * door for a body's items; this file holds it for the residual's. + * + * The population is read from the type registry, not written out: every + * `domain: 'security'` type with `allowEnvironmentOverlay: false`. The stack is + * booted through the `manifest` service, as `AppPlugin` hands a config boot's + * stack to it. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { OWNERSHIP_QUEUE_SERVICE } from '@objectstack/spec/contracts'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { singularToPlural } from '@objectstack/spec/shared'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +const RELEASE_ID = 'com.example.acme.release'; +const NAME = 'probe_item'; + +/** The pin's population, read off the registry: every non-overridable `security` type. */ +const SECURITY_NON_OVERRIDABLE = DEFAULT_METADATA_TYPE_REGISTRY + .filter((entry) => entry.domain === 'security' && entry.allowEnvironmentOverlay === false) + .map((entry) => entry.type); + +/** A schema-valid body per type; a type that joins the population without one fails the first case by name. */ +const BODIES: Record> = { + permission: { name: NAME, label: 'Probe', objects: {} }, + position: { name: NAME, label: 'Probe' }, + capability: { name: NAME, label: 'Probe' }, + sharing_rule: { + type: 'criteria', name: NAME, label: 'Probe', object: 'probe_object', + condition: "record.status == 'open'", accessLevel: 'read', + sharedWith: { type: 'position', value: 'probe_position' }, + }, + queue: { name: NAME, label: 'Probe', members: [{ type: 'position', value: 'probe_position' }] }, + assignment_rule: { + name: NAME, label: 'Probe', object: 'probe_object', + entries: [{ name: 'all', assignTo: { type: 'queue', value: 'probe_queue' } }], + }, +}; + +const sysMetadataObject: ServiceObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** In-memory driver, copied from `save-meta-response-conformance.test.ts`; equality-only WHERE. */ +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them (#7620). + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +/** + * A stack carrying `packages[]` whose top level declares `NAME` through each + * type's collection and whose one body declares none of them — what + * `AppPlugin.init` hands the `manifest` service. + */ +function residualStack(types: readonly string[]): Record { + const manifest = { id: RELEASE_ID, name: 'acme', namespace: 'acme', version: '1.0.0', type: 'app' }; + const stack: Record = { + manifest, + packages: [{ manifest: { id: 'com.example.acme.service', name: 'service', namespace: 'acme', version: '2.4.0', type: 'module' } }], + }; + for (const type of types) stack[singularToPlural(type)] = [{ ...BODIES[type] }]; + return { ...manifest, ...stack }; +} + +const kernels: ObjectKernel[] = []; +afterEach(async () => { + for (const kernel of kernels.splice(0)) await kernel.shutdown().catch(() => {}); +}); + +async function boot(types: readonly string[]) { + const engine = new ObjectQL(); + const { driver, stores } = makeMemoryDriver(); + engine.registerDriver(driver, true); + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin({ ql: engine, registerProtocol: false })); + await kernel.use({ + name: 'test.residual-security-stack', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + // The queue and assignment-rule boot check asks for the service, never calls it. + ctx.registerService(OWNERSHIP_QUEUE_SERVICE, {}); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test-package'); + } + await ctx.getService<{ register(m: unknown): Promise }>('manifest').register(residualStack(types)); + }, + } as Plugin); + await kernel.bootstrap(); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), undefined); + const storedRows = () => Array.from(stores.get('sys_metadata')?.values() ?? []); + return { engine, protocol, storedRows }; +} + +describe('[ADR-0130 D4, ADR-0131 D6] a residual top-level security rule is sealed like a body\'s', () => { + it('the population is read from the registry, and every member has a fixture', () => { + expect(SECURITY_NON_OVERRIDABLE).toEqual(expect.arrayContaining(['permission', 'position', 'capability'])); + for (const type of SECURITY_NON_OVERRIDABLE) { + expect(BODIES[type], `no fixture body for security type '${type}'`).toBeDefined(); + } + }); + + it.each(SECURITY_NON_OVERRIDABLE)('a residual %s is registered under the stack\'s manifest id', async (type) => { + const { engine } = await boot([type]); + const artifact = engine.registry.getArtifactItem(type, NAME); + expect(artifact?._packageId).toBe(RELEASE_ID); + expect(artifact?._packageVersion).toBe('1.0.0'); + }); + + it.each(SECURITY_NON_OVERRIDABLE)('a save over a residual %s is refused 403 NOT_OVERRIDABLE and stores nothing', async (type) => { + const { protocol, storedRows } = await boot([type]); + const err: any = await protocol + .saveMetaItem({ type, name: NAME, item: { ...BODIES[type], label: 'Environment fork' } }) + .then(() => undefined, (e: unknown) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(storedRows()).toHaveLength(0); + }); + + it('the by-name read after the refusal serves the declared permission set', async () => { + const { protocol } = await boot(['permission']); + await protocol + .saveMetaItem({ type: 'permission', name: NAME, item: { ...BODIES.permission, label: 'Environment fork' } }) + .catch(() => undefined); + const read: any = await protocol.getMetaItem({ type: 'permission', name: NAME }); + expect(read?.item?.label).toBe('Probe'); + }); + + it('CONTROL: a position the stack does not declare still saves (the allowRuntimeCreate tier)', async () => { + const { protocol, storedRows } = await boot(['position']); + const result: any = await protocol.saveMetaItem({ + type: 'position', name: 'env_only_position', item: { name: 'env_only_position', label: 'Env only' }, + }); + expect(result?.success).toBe(true); + expect(storedRows().map((r: any) => `${r.type}/${r.name}`)).toEqual(['position/env_only_position']); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 6e77ca8fcc8..6f18d97b867 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -11,8 +11,14 @@ import { resolveArtifactPackageEntries, artifactPackageId, readDeploymentOrgScop // [ADR-0130 D4] The residual rule's decision half — which top-level items of a // multi-package stack no body declares, and the id they take. See // `unclaimedTopLevelObjects` below. -import { unclaimedTopLevel } from '@objectstack/metadata'; -import { applyConversionsToStoredItem } from '@objectstack/spec'; +import { unclaimedTopLevel, type UnclaimedTopLevel } from '@objectstack/metadata'; +import { ASSEMBLED_VIEW_ITEMS_KEY, applyConversionsToStoredItem, expandViewContainer, isAggregatedViewContainer, isViewContainerShaped } from '@objectstack/spec'; +import { judgePermissionSetOwnerBinding } from '@objectstack/spec/security'; +// [ADR-0017] The one object derivation of a view container and the one +// divergent-`name` judge, called by the residual's view registration as by a +// body's (see `registerUnclaimedTopLevelItems`). +import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; +import { viewContainerNameRefusal } from './view-container-name-refusal.js'; import { StorageNameMapping } from '@objectstack/spec/system'; import { applyProtection } from '@objectstack/spec/shared'; // [#21777] The ONE "is this schema the remote's?" predicate, shared with `ObjectQL.syncSchemas`. @@ -137,13 +143,14 @@ function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore { * object was listed by `GET /meta/object` and answered `404` on * `/data/`, through both boot doors. * + * @param residual - The stack's residual, as `unclaimedTopLevel` answers it, + * read once per registration and shared with {@link unclaimedTopLevelItems}. * @returns `undefined` when the stack has no residual object — every stack * without `packages[]`, and every normally composed one. */ function unclaimedTopLevelObjects( - stack: unknown, + residual: UnclaimedTopLevel | undefined, ): { ownerId: string | undefined; ownerVersion: string | undefined; objects: ServiceObject[] } | undefined { - const residual = unclaimedTopLevel(stack); if (!residual) return undefined; const objects = residual.items .filter((entry) => entry.type === 'object') @@ -154,38 +161,50 @@ function unclaimedTopLevelObjects( } /** - * The items of ONE registry type in a multi-package stack's residual, and the - * id they are owned by — the residual rule's answer read the way - * {@link unclaimedTopLevelObjects} reads it, for the residual types a - * boot-time reader takes from the registry alone: + * The residual types the engine does NOT register through + * {@link unclaimedTopLevelItems}, each because it has a path of its own: * - * - `connector` (ADR-0015 D7): the connector credential lender and the - * ADR-0097 reconcile read `registry.listItems('connector')`, never the - * metadata service. A residual connector instance registered by the - * metadata door only was therefore invisible to both at boot — the instance - * did not materialize, and a datasource's `external.connector` borrow of it - * was refused as undeclared until a dev reload carried the artifact in — - * while the composed check (`composeStacks`) accepts that borrow, because - * the instance is declared. - * - `flow`: the automation plugin's boot pull (`service-automation`'s - * `start()`) reads `registry.listItems('flow')`, never the metadata service. - * A residual flow registered by the metadata door only was listed by `/meta` - * and never registered with the engine: `getFlow` answered `null`, a run - * answered "not found", and its trigger never armed. + * - `object`: {@link unclaimedTopLevelObjects}, through the registry's object + * verb, with the two classes it lists and does not serve; + * - `picklist`: kept as metadata only. A field naming a picklist only the + * residual declares is what the objects rule lists and does not serve, and + * a body's field naming one fails the boot's picklist audit — both loud, + * and both judged on that premise, so registering the residual's lists here + * would change which objects the data door serves; + * - `object_extension`: an extension has no slot of its own (it is keyed by + * `extend`, and its `name` spelling is a retired alias the schema refuses), + * so the residual rule never lists one. + */ +const RESIDUAL_TYPES_WITH_OWN_PATH: ReadonlySet = new Set(['object', 'picklist', 'object_extension']); + +/** + * Every OTHER item of a multi-package stack's residual, and the id they are + * owned by — the residual rule's answer read the way + * {@link unclaimedTopLevelObjects} reads it, for every type a package body's + * collections take into the registry (`registerApp`'s metadata collections). + * + * Why the engine needs them: a reader that asks the registry and not the + * metadata service sees a residual item only if this registers it. Measured: + * the engine registered a residual's objects, connectors (ADR-0015 D7: the + * connector credential lender and the ADR-0097 reconcile) and flows (the + * automation plugin's boot pull) and no other type, so every other residual + * item was registered by the metadata door alone. Among the readers that then + * answered "not declared": the metadata save door's managed-content seal + * (`isArtifactBacked`, ADR-0131 D6), which asks the registry whether a code + * package ships an item — so a residual security rule was not sealed the way + * the same rule declared in a package body is. * - * @returns `undefined` when the stack has no residual item of `type`. + * @returns `undefined` when the stack has no such residual item. */ -function unclaimedTopLevelItemsOfType( - stack: unknown, - type: 'connector' | 'flow', -): { type: 'connector' | 'flow'; ownerId: string | undefined; ownerVersion: string | undefined; items: Record[] } | undefined { - const residual = unclaimedTopLevel(stack); +function unclaimedTopLevelItems( + residual: UnclaimedTopLevel | undefined, +): { ownerId: string | undefined; ownerVersion: string | undefined; items: Array<{ type: string; item: Record }> } | undefined { if (!residual) return undefined; const items = residual.items - .filter((entry) => entry.type === type) - .map((entry) => entry.item as Record); + .filter((entry) => !RESIDUAL_TYPES_WITH_OWN_PATH.has(entry.type)) + .map((entry) => ({ type: entry.type, item: entry.item as Record })); return items.length > 0 - ? { type, ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, items } + ? { ownerId: residual.ownerId, ownerVersion: residual.ownerVersion, items } : undefined; } @@ -803,18 +822,17 @@ export class ObjectQLPlugin implements Plugin { : registeredArtifact; const entries = resolveArtifactPackageEntries(artifact); const ordered = entries.map((entry) => entry.manifest) as any[]; - // [ADR-0130 D4] The objects of the stack's RESIDUAL — top-level objects - // no package body declares — as the residual rule answers it. See - // {@link unclaimedTopLevelObjects}. - const residual = unclaimedTopLevelObjects(artifact); - // …and its CONNECTORS and FLOWS, which the credential lender and the - // automation plugin's boot pull read from the registry alone. See - // {@link unclaimedTopLevelItemsOfType}. - const residualItems = (['connector', 'flow'] as const) - .map((type) => unclaimedTopLevelItemsOfType(artifact, type)) - .filter((entry): entry is NonNullable => entry !== undefined); - - if (ordered.length === 0 && residual === undefined && residualItems.length === 0) { + // [ADR-0130 D4] The stack's RESIDUAL — top-level items no package body + // declares — as the residual rule answers it, read once: its objects + // (see {@link unclaimedTopLevelObjects})… + const residualAnswer = unclaimedTopLevel(artifact); + const residual = unclaimedTopLevelObjects(residualAnswer); + // …and every other item a body's collections take into the registry, + // which readers of the registry alone see only through this. See + // {@link unclaimedTopLevelItems}. + const residualItems = unclaimedTopLevelItems(residualAnswer); + + if (ordered.length === 0 && residual === undefined && residualItems === undefined) { // An artifact that declared `packages: []` registers nothing. Said out // loud rather than returning quietly: "the install did nothing" is not // a state anyone should have to infer from an absence. @@ -867,8 +885,12 @@ export class ObjectQLPlugin implements Plugin { if (this.boundActionTargetsSealed) { const declared = new Set(residual?.objects.map((o) => o.name) ?? []); for (const manifest of ordered) for (const name of collectManifestObjectNames(manifest)) declared.add(name); + // The residual's own actions are judged with the bodies': they + // register below, under the residual rule's id. + const residualActions = residualItems?.items.filter((entry) => entry.type === 'action').map((entry) => entry.item) ?? []; const unbound = ordered .flatMap((manifest) => collectManifestBoundActions(manifest, artifactPackageId(manifest))) + .concat(collectManifestBoundActions({ actions: residualActions }, residualItems?.ownerId)) .filter((bound) => !declared.has(bound.objectName) && ql.registry.getObject(bound.objectName) === undefined); const refusal = describeUnboundActionTargets(unbound); if (refusal) throw refusal; @@ -888,29 +910,19 @@ export class ObjectQLPlugin implements Plugin { // bodies (a composed stack keeps one member's manifest) that body's // record must not be replaced. if (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered); - // [ADR-0015 D7, ADR-0130 D4] The residual's connectors and flows, the - // same way and in the same place: after every body, under the residual - // rule's id, through the registry verb a body's own connectors and - // flows take (`registerApp`'s metadata collections). No body declares - // one of these names — that is what makes it residual — so none of - // them shadows a body's entry. Stamped as a residual object is: the - // version first, on a shallow copy, leaving the artifact's own entry as - // the registry leaves it. - for (const { type, ownerId, ownerVersion, items } of residualItems) { - for (const item of items) { - const stamped = ownerVersion === undefined - ? { ...item } - : applyProtection({ ...item } as any, { packageId: ownerId, packageVersion: ownerVersion }); - ql.registry.registerItem(type, stamped, 'name' as any, ownerId); - } - } + // [ADR-0015 D7, ADR-0130 D4] Every other residual item, the same way + // and in the same place: after every body, under the residual rule's + // id, through the registration a body's own item of that type takes. + // See {@link registerUnclaimedTopLevelItems}. + if (residualItems) this.registerUnclaimedTopLevelItems(ctx, residualItems); // [#24472] The boot reported every same-name pair it found // (`kernel:ready`, below); an artifact arriving later is judged once it // has registered, for the pairs naming one of its packages on either // side. Reported, never refused — nothing above is undone. if (this.boundActionTargetsSealed) { const arrived = new Set(scope.packageIds); - if (residual?.ownerId !== undefined) arrived.add(residual.ownerId); + const residualOwner = residual?.ownerId ?? residualItems?.ownerId; + if (residualOwner !== undefined) arrived.add(residualOwner); this.reportBoundActionCollisions(ctx, ql.registry[FIND_BOUND_ACTION_COLLISIONS](arrived)); } // Manifests registered AFTER start() (marketplace install / ledger @@ -3013,6 +3025,94 @@ export class ObjectQLPlugin implements Plugin { ); } + /** + * [ADR-0015 D7, ADR-0130 D4] Register every residual item that is not an + * object ({@link unclaimedTopLevelItems}) in the engine, the way + * `registerApp`'s metadata collections register a package body's item of + * that type: under the residual rule's id, with the stack's version stamped + * beside it, after the judges that seam runs first. A residual item and a + * body's then answer every reader of the registry alike, and refuse alike. + * + * - A `view` container is filed under the object it binds to and expanded + * into its views (ADR-0017), once the one divergent-`name` judge + * (`viewContainerNameRefusal`) has passed it — the expansion and the + * object derivation are `@objectstack/spec`'s and `@objectstack/metadata`'s, + * the copies the body seam and the metadata door call. + * - A permission set whose body `packageId` disagrees with the residual's id + * is refused by the one owner-binding judge (ADR-0086 D3, + * `judgePermissionSetOwnerBinding`), in the envelope the body seam's + * refusal of the same set carries. + * - Any other `view` entry takes the body seam's two lines: one with no + * `name` is filed under its slot, and one that is not container-shaped + * (`isViewContainerShaped`) is refused 422 `INVALID_METADATA` in the seam's + * words, which this copies (the seam lives in `engine.ts`). + * - Every other item is one `registerItem` under its name. + * + * No body declares one of these names — that is what makes it residual — so + * none of them shadows a body's entry. Each is stamped on a shallow copy, + * leaving the artifact's own entry as it arrived. What judges a body's item + * AFTER it registers judges the residual's too: the ownership-queue service + * check and the bound-action target audit at `kernel:ready`. + */ + private registerUnclaimedTopLevelItems( + ctx: PluginContext, + residual: { ownerId: string | undefined; ownerVersion: string | undefined; items: Array<{ type: string; item: Record }> }, + ): void { + const ql = this.ql; + if (!ql) return; + const { ownerId, ownerVersion } = residual; + const stamp = (item: Record): Record => ownerVersion === undefined + ? { ...item } + : applyProtection({ ...item } as any, { packageId: ownerId, packageVersion: ownerVersion }); + for (const { type, item } of residual.items) { + if (type === 'view' && isAggregatedViewContainer(item)) { + const nameRefusal = viewContainerNameRefusal(item, 'manifest', ownerId); + if (nameRefusal) throw nameRefusal; + const object = deriveViewContainerObject(item); + if (!object) continue; + const container = stamp({ ...item, name: object }); + ql.registry.registerItem('view', container, 'name' as any, ownerId); + for (const view of expandViewContainer(object, container)) { + for (const w of view._diagnostics?.warnings ?? []) { + ctx.logger.warn(`View expansion warning for '${view.name}': ${w.message}`, { from: ownerId }); + } + ql.registry.registerItem('view', stamp(view as unknown as Record), 'name' as any, ownerId); + } + continue; + } + if (type === 'permission') { + const refusal = judgePermissionSetOwnerBinding(item, String(item.name), ownerId, 'register'); + if (refusal) { + throw Object.assign(new Error(refusal.message), { + code: 'VALIDATION_ERROR', + status: 400, + httpStatus: 400, + issues: [{ path: 'packageId', message: refusal.issueMessage, code: 'custom' }], + }); + } + } + // The body seam's two `views:` lines, in its words: a non-container entry + // with no `name` is filed under the slot the residual rule gave it, and an + // entry that is not container-shaped (`viewKind`/`config`/flattened + // overlay keys) is refused — `views:` carries containers only (#5320). + const name = (item.name as string | undefined) || (type === 'view' ? deriveViewContainerObject(item) : undefined); + const toRegister = item.name === name ? item : { ...item, name }; + if (type === 'view' && !isViewContainerShaped(toRegister)) { + throw Object.assign(new Error( + `Invalid \`views:\` entry '${name}' from manifest '${ownerId}': the stack ` + + '`views:` collection carries view CONTAINERS only. `viewKind`/`config`/inline view ' + + 'config belong to a single VIEW, not to the container — wrap it: ' + + '`defineView({ list: { type, data, columns, … } })`, or name it — ' + + '`defineView({ listViews: { my_view: { … } } })`. A machine-assembled manifest ' + + `(package export, environment artifact) carries non-container view artifacts under ` + + `\`${ASSEMBLED_VIEW_ITEMS_KEY}:\` instead — re-export the package with a runtime that ` + + 'writes that channel.', + ), { code: 'INVALID_METADATA', status: 422, httpStatus: 422 }); + } + ql.registry.registerItem(type, stamp(toRegister), 'name' as any, ownerId); + } + } + /** * [ADR-0130 D5] Bridge each package of one artifact, in the SAME * dependency-topological order they registered in.