From 5c9b75dcd8ee934a4f7271a96fc3d7720d601380 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:31:46 +0000 Subject: [PATCH 1/5] feat(spec): declare the viewItems channel for non-container view artifacts in assembled manifests (#5320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option B of the 2026-08-12 fork ruling on #5320: runtime-assembled manifests (package export, environment artifacts) get a DECLARED home for the view artifacts the authored stack vocabulary refuses — expanded viewKind items, tenant-authored standalone ViewItems, flattened overlays. - AssembledViewArtifactSchema: the non-container branches of the view metadata vocabulary, built from VIEW_METADATA_MEMBERS minus container (no drift). - partitionAssembledViewArtifacts: producer-side re-aggregation (A's mechanical half) — containers travel in views:, exactly-derivable expanded items fold away, everything else travels in viewItems:. - ObjectStackDefinition.viewItems: declared always-refusing (machine-assembled only) so a hand-authored viewItems fails tsc and parse with the prescription instead of being silently stripped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .changeset/assembled-views-vocabulary.md | 30 +++ packages/spec/api-surface/ui.json | 5 + packages/spec/export-origins/ui.json | 5 + packages/spec/src/stack.zod.ts | 36 +++ packages/spec/src/ui/assembled-views.test.ts | 189 ++++++++++++++++ packages/spec/src/ui/assembled-views.zod.ts | 224 +++++++++++++++++++ packages/spec/src/ui/index.ts | 3 + 7 files changed, 492 insertions(+) create mode 100644 .changeset/assembled-views-vocabulary.md create mode 100644 packages/spec/src/ui/assembled-views.test.ts create mode 100644 packages/spec/src/ui/assembled-views.zod.ts diff --git a/.changeset/assembled-views-vocabulary.md b/.changeset/assembled-views-vocabulary.md new file mode 100644 index 0000000000..8c682612ed --- /dev/null +++ b/.changeset/assembled-views-vocabulary.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare the `viewItems:` channel — a portable home for non-container view artifacts in runtime-assembled manifests (#5320, #8070) + +Runtime-assembled manifests (`GET /packages/:id/export`, environment artifact +bundles) carry view artifacts the authored stack vocabulary refuses: expanded +`viewKind` items (the ADR-0017 dual-read keeps them registered beside their +container), tenant-authored standalone ViewItems, and flattened overlays. +Measured on #5320, 2 of 3 exported entries in the minimal single-container case +were stack-schema-refused yet metadata-door-legal — the export→import round trip +worked only through the runtime registration loop's undeclared wider acceptance. + +Per the 2026-08-12 fork ruling (option B plus A's mechanical half), the bridge +is now declared instead of silent: + +- **`AssembledViewArtifactSchema`** (`ui/assembled-views.zod.ts`) — one entry of + an assembled manifest's `viewItems:` collection: the non-container branches of + the `view` metadata vocabulary, built from `VIEW_METADATA_MEMBERS` minus + `container` so the two doors cannot drift. Strictly schema'd; no passthrough. +- **`partitionAssembledViewArtifacts`** — the producer-side re-aggregation + shared by every manifest assembler: containers travel in `views:`; expanded + items a travelling container re-derives exactly are folded away (reported in + `folded`); everything else — standalone items, overlays, items whose stored + body diverged from their container — travels in `viewItems:`. +- **`ObjectStackDefinition.viewItems`** is declared as an always-refusing key: + hand-authoring it in `defineStack` source fails `tsc` (input type `never`) and + parse, with the prescription (author containers in `views:`; author standalone + views through the metadata door). The channel is machine-assembled by design. diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 50d002b23e..7e343df25c 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -5,6 +5,7 @@ "ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS (const)", "AIChatWindowProps (const)", + "ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action (type)", "ActionAi (type)", "ActionAiParsed (type)", @@ -42,6 +43,9 @@ "AppearanceConfigSchema (const)", "AriaProps (type)", "AriaPropsSchema (const)", + "AssembledViewArtifact (type)", + "AssembledViewArtifactSchema (const)", + "AssembledViewPartition (interface)", "BorderRadius (type)", "BorderRadiusSchema (const)", "BreakpointColumnMap (type)", @@ -412,6 +416,7 @@ "normalizeFilterOperator (function)", "normalizeInlineAction (function)", "pageForm (const)", + "partitionAssembledViewArtifacts (function)", "reactBlockTagFor (function)", "reportForm (const)", "reportSelectionOrder (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index cbb13402d7..0de4cbd73a 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -5,6 +5,7 @@ "ACTION_LOCATIONS": "src/ui/action.zod.ts#ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS": "src/ui/action-params.zod.ts#ACTION_PARAM_BUILTIN_KEYS (const)", "AIChatWindowProps": "src/ui/component.zod.ts#AIChatWindowProps (const)", + "ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action": "src/ui/action.zod.ts#Action (type)", "ActionAi": "src/ui/action.zod.ts#ActionAi (type)", "ActionAiParsed": "src/ui/action.zod.ts#ActionAiParsed (type)", @@ -42,6 +43,9 @@ "AppearanceConfigSchema": "src/ui/view.zod.ts#AppearanceConfigSchema (const)", "AriaProps": "src/ui/i18n.zod.ts#AriaProps (type)", "AriaPropsSchema": "src/ui/i18n.zod.ts#AriaPropsSchema (const)", + "AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)", + "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", + "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", "BorderRadius": "src/ui/theme.zod.ts#BorderRadius (type)", "BorderRadiusSchema": "src/ui/theme.zod.ts#BorderRadiusSchema (const)", "BreakpointColumnMap": "src/ui/responsive.zod.ts#BreakpointColumnMap (type)", @@ -412,6 +416,7 @@ "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", "normalizeInlineAction": "src/ui/action.zod.ts#normalizeInlineAction (function)", "pageForm": "src/ui/page.form.ts#pageForm (const)", + "partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)", "reactBlockTagFor": "src/ui/react-blocks.ts#reactBlockTagFor (function)", "reportForm": "src/ui/report.form.ts#reportForm (const)", "reportSelectionOrder": "src/ui/report.zod.ts#reportSelectionOrder (function)", diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 090dd89acc..edec6820f0 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -249,6 +249,37 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ // LayoutDispatcher, NavigationBuilder or ThemeProvider ever consumed it). // Author external-user UI with apps/views + positions & permission sets. views: z.array(ViewSchema).optional().describe('List Views'), + /** + * [#5320] MACHINE-ASSEMBLED ONLY — not an authoring surface. + * + * Runtime-assembled manifests (`GET /packages/:id/export`, environment + * artifact bundles) carry their non-container view artifacts — tenant- + * authored standalone ViewItems, flattened overlays, expanded items a + * travelling container cannot re-derive — under this key, judged by + * `AssembledViewArtifactSchema` (`ui/assembled-views.zod.ts`). Authored + * stack sources never write it: views are authored as `defineView` + * containers in `views:`, and a standalone view is authored through the + * metadata door (Studio / `PUT /api/v1/meta/view`), not in stack source. + * + * Declared here as an always-refusing key — the `retiredKey()` mechanism, + * applied to a key that is machine-only rather than removed — for the same + * reason the tombstones exist (#3855): this schema is not `.strict()`, so an + * UNDECLARED `viewItems:` would be silently stripped and the author would + * learn nothing. Declared-refusing, the mistake fails `tsc` (input type + * `never`) and the parse refusal carries the prescription itself. + */ + viewItems: z.never({ + error: () => + '`viewItems` is the machine-assembled channel for non-container view artifacts in ' + + 'runtime-assembled manifests (package export, environment artifacts) — it is not an ' + + 'authoring surface. Author views as `defineView` containers in `views:`; author a ' + + 'standalone view through the metadata door (Studio / `PUT /api/v1/meta/view`) instead ' + + 'of stack source.', + }).optional().describe( + '[MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest ' + + '(standalone ViewItems, flattened overlays) — written by package export and artifact ' + + 'factories, refused in authored stack sources (#5320).', + ), pages: z.array(PageSchema).optional().describe('Custom Pages'), dashboards: z.array(DashboardSchema).optional().describe('Dashboards'), reports: z.array(ReportSchema).optional().describe('Analytics Reports'), @@ -1674,6 +1705,11 @@ const COMPOSE_KEY_DISPOSITIONS: Record { + it('accepts an expanded viewKind item (what the ADR-0017 dual-read exports)', () => { + const [expanded] = expandViewContainer('account', CONTAINER); + expect(expanded.viewKind).toBe('list'); + const r = AssembledViewArtifactSchema.safeParse(expanded); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + + it('accepts a tenant-authored standalone ViewItem (vocabulary branch 1)', () => { + const r = AssembledViewArtifactSchema.safeParse(STANDALONE_ITEM); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + + it('accepts a flattened list overlay (vocabulary branch 3)', () => { + const r = AssembledViewArtifactSchema.safeParse({ + name: 'account.default', + object: 'account', + viewKind: 'list', + type: 'grid', + columns: ['name'], + isPinned: true, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + + it('accepts a flattened form overlay (vocabulary branch 4)', () => { + const r = AssembledViewArtifactSchema.safeParse({ + name: 'account.form', + object: 'account', + viewKind: 'form', + type: 'simple', + sections: [{ label: 'Details', fields: [{ field: 'name' }] }], + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); + + it('refuses a container — containers travel in `views:`, not `viewItems:`', () => { + expect(AssembledViewArtifactSchema.safeParse(CONTAINER).success).toBe(false); + }); + + it('refuses a bag that speaks no view vocabulary', () => { + expect(AssembledViewArtifactSchema.safeParse({ nope: 1 }).success).toBe(false); + }); +}); + +describe('partitionAssembledViewArtifacts — re-aggregation where a container exists', () => { + it('folds the minimal single-container export set to the container alone', () => { + // What the registry holds after registering CONTAINER: the container under + // the bare object key AND its expanded items (dual-read) — the exact 3-entry + // set the fork's probe read back from `getMetaItems('view', pkg)`. + const expanded = expandViewContainer('account', CONTAINER); + expect(expanded.map((v) => v.name).sort()).toEqual(['account.default', 'account.form']); + + const { views, viewItems, folded } = partitionAssembledViewArtifacts([ + CONTAINER, + ...(expanded as unknown as Record[]), + ]); + expect(views).toEqual([CONTAINER]); + expect(viewItems).toEqual([]); + expect(folded.sort()).toEqual(['account.default', 'account.form']); + }); + + it('keeps a standalone ViewItem — no container to re-aggregate from', () => { + const { views, viewItems, folded } = partitionAssembledViewArtifacts([ + CONTAINER, + STANDALONE_ITEM as unknown as Record, + ]); + expect(views).toEqual([CONTAINER]); + expect(viewItems).toEqual([STANDALONE_ITEM]); + expect(folded).toEqual([]); + }); + + it('keeps an expanded item whose stored body DIVERGED from its container', () => { + // A tenant edited the expanded item after registration: folding it away + // would silently revert the edit on the next import. It must travel. + const [expandedList] = expandViewContainer('account', CONTAINER); + const edited = { + ...(expandedList as unknown as Record), + config: { type: 'grid', columns: ['name', 'industry'], data: DATA }, + }; + const { viewItems, folded } = partitionAssembledViewArtifacts([CONTAINER, edited]); + expect(viewItems).toEqual([edited]); + expect(folded).toEqual([]); + }); + + it('keeps a personalised expanded item (round-trip keys are payload, not noise)', () => { + const [expandedList] = expandViewContainer('account', CONTAINER); + const pinned = { ...(expandedList as unknown as Record), isPinned: true }; + const { viewItems } = partitionAssembledViewArtifacts([CONTAINER, pinned]); + expect(viewItems).toEqual([pinned]); + }); + + it('keeps a flattened overlay', () => { + const overlay = { + name: 'account.default', + object: 'account', + viewKind: 'list', + type: 'grid', + columns: ['name'], + isPinned: true, + }; + const { viewItems } = partitionAssembledViewArtifacts([overlay]); + expect(viewItems).toEqual([overlay]); + }); + + it('classes an EMPTY container as a container, not a viewItem', () => { + // Schema-legal ({} with identity only), registers nothing on expansion — + // but it is container-shaped and must not be smuggled into `viewItems:`, + // where the strict artifact union would refuse it. + const empty = { name: 'account', object: 'account' }; + const { views, viewItems } = partitionAssembledViewArtifacts([empty]); + expect(views).toEqual([empty]); + expect(viewItems).toEqual([]); + }); +}); + +describe('authored `viewItems:` is refused loudly (machine-assembled only)', () => { + it('names the channel and the prescription in the refusal', () => { + const r = ObjectStackDefinitionSchema.safeParse({ + manifest: { id: 'com.test.app', name: 'test', version: '1.0.0', type: 'app' }, + [ASSEMBLED_VIEW_ITEMS_KEY]: [STANDALONE_ITEM], + }); + expect(r.success).toBe(false); + const issue = (r as { error: { issues: Array<{ path: unknown[]; message: string }> } }).error + .issues.find((i) => i.path[0] === ASSEMBLED_VIEW_ITEMS_KEY); + expect(issue, 'refusal must be located at the `viewItems` key').toBeTruthy(); + expect(issue!.message).toContain('machine-assembled'); + expect(issue!.message).toContain('defineView'); + expect(issue!.message).toContain('metadata door'); + }); + + it('does not disturb a well-formed authored stack (containers in `views:`)', () => { + const r = ObjectStackDefinitionSchema.safeParse({ + manifest: { id: 'com.test.app', name: 'test', version: '1.0.0', type: 'app' }, + views: [CONTAINER], + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/assembled-views.zod.ts b/packages/spec/src/ui/assembled-views.zod.ts new file mode 100644 index 0000000000..a70a4691a0 --- /dev/null +++ b/packages/spec/src/ui/assembled-views.zod.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5320/#8070] The declared, portable home for NON-container view artifacts in + * runtime-ASSEMBLED manifests — the `viewItems:` channel. + * + * ## The gap this closes (measured on #5320, ruled 2026-08-12) + * + * Two view vocabularies are declared: the stack manifest's `views:` collection + * accepts CONTAINERS only (`stack.zod.ts`, `z.array(ViewSchema)`), while the + * `view` METADATA door accepts four branches ({@link ViewMetadataSchema} — + * standalone ViewItem records, containers, and the two flattened overlays). + * Manifests the platform assembles at runtime FROM metadata-door artifacts — + * `GET /packages/:id/export` and the cloud env-artifact boot bundle — therefore + * carry entries the stack vocabulary refuses: expanded `viewKind` items (the + * ADR-0017 dual-read keeps them registered beside their container), tenant- + * authored standalone ViewItems, and flattened overlays. Measured by execution, + * 2 of 3 exported entries in the minimal single-container case are refused by + * the stack schema; the round trip worked only through the registration loop's + * UNDECLARED wider acceptance — exactly the runtime-wider hole #5320 records. + * + * The 2026-08-12 fork ruling (option B plus A's mechanical half) closes it by + * DECLARING the bridge instead of keeping it silent: + * + * 1. **This module (B):** an assembled manifest carries its non-container view + * artifacts under the {@link ASSEMBLED_VIEW_ITEMS_KEY} collection, each + * entry judged by {@link AssembledViewArtifactSchema} — strictly schema'd, + * no passthrough bags. + * 2. **Producers (A's mechanical half):** export/artifact factories fold + * expanded items back into their containers where a container exists + * ({@link partitionAssembledViewArtifacts}); only items no container can + * re-derive travel in `viewItems:`. + * 3. **The runtime loop then tightens** `views:` to the declared container-only + * contract, refusing loudly with the wrap-it prescription. + * + * ## `viewItems:` is MACHINE-ASSEMBLED, not a second authoring spelling + * + * The decision is refuse-loudly, documented here and enforced in + * `stack.zod.ts`: an author writing `viewItems:` in `defineStack` source gets a + * named rejection with the prescription (author containers in `views:`; + * standalone views are authored through the metadata door), and `z.input` types + * the key `never` so `tsc` refuses first. Rationale, on the three ruling axes: + * no measured authoring pull exists for pre-expanded items in stack source + * (every in-tree stack authors containers); a hand-written expansion would be a + * SECOND spelling of the container it duplicates, drifting from it silently — + * the exact class of AI-authoring mistake strict schemas exist to prevent; and + * the 2026-08-12 11:15Z ruling forbids widening the authored stack surface. + * + * ## Why the producer helper lives in `packages/spec` + * + * Same precedent as {@link expandViewContainer} (this directory): logic that + * two independent codebases must agree on byte-for-byte belongs beside the + * schema it serves, so the producers cannot drift. The partition is the exact + * inverse of the expansion the registration loop performs — deciding "is this + * item re-derivable from its container?" requires running THE expansion, not a + * re-implementation of it. + */ + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { + VIEW_METADATA_BRANCHES, + VIEW_METADATA_MEMBERS, + expandViewContainer, + isAggregatedViewContainer, +} from './view.zod'; + +/** + * The manifest key runtime-assembled manifests carry non-container view + * artifacts under. Declared as a constant so producers (package export, the + * artifact factories) and the consumer (the ObjectQL registration loop) name + * one key instead of four string literals. + */ +export const ASSEMBLED_VIEW_ITEMS_KEY = 'viewItems' as const; + +/** + * One entry of an assembled manifest's `viewItems:` collection — the + * NON-container branches of the `view` metadata vocabulary, and nothing else. + * + * Built by mapping {@link VIEW_METADATA_BRANCHES} minus `container` over + * {@link VIEW_METADATA_MEMBERS}, so this union cannot drift from the metadata + * door's own members: an artifact legal at the metadata door (standalone + * ViewItem, flattened list/form overlay) is legal here BY CONSTRUCTION, and a + * container is not — containers travel in `views:`, where the stack vocabulary + * has always put them. The members are the WIRE variants (`.strip()`), so + * Studio round-trip keys survive and undeclared bags do not. + */ +export const AssembledViewArtifactSchema = lazySchema(() => { + const members = VIEW_METADATA_BRANCHES + .filter((branch) => branch !== 'container') + .map((branch) => VIEW_METADATA_MEMBERS[branch]); + return z.union(members as unknown as readonly [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]) + .describe( + 'A non-container view artifact travelling in a runtime-assembled manifest\'s `viewItems:` ' + + 'collection: a ViewItem record (`viewKind` + `config`), or a flattened list/form overlay. ' + + 'Machine-assembled (package export, environment artifacts) — authored stack sources carry ' + + 'containers in `views:` instead.', + ); +}); + +/** One assembled `viewItems:` entry (input shape). */ +export type AssembledViewArtifact = z.input; +/** Post-parse shape of {@link AssembledViewArtifact} — defaults applied, transforms run (ADR-0122). */ +export type AssembledViewArtifactParsed = z.infer; + +/** Result of {@link partitionAssembledViewArtifacts}. */ +export interface AssembledViewPartition { + /** Container documents — the assembled manifest's `views:` collection. */ + views: Record[]; + /** Non-container artifacts no exported container re-derives — `viewItems:`. */ + viewItems: Record[]; + /** + * Names of expanded items DROPPED because an exported container re-derives + * them byte-for-byte (the re-aggregation): the import side's expansion of + * that container reproduces each, so carrying them too would register every + * view twice and fork the container's authority over its own views. + */ + folded: string[]; +} + +/** Keys the expansion/registration machinery stamps that carry no authored + * content — excluded from the derivability comparison on both sides. */ +const MECHANICAL_KEYS = new Set(['order', 'scope', '_diagnostics']); + +/** Deep structural equality; object keys unordered, arrays ordered. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((v, i) => deepEqual(v, b[i])); + } + if (a && b && typeof a === 'object' && typeof b === 'object') { + const ka = Object.keys(a as object); + const kb = Object.keys(b as object); + if (ka.length !== kb.length) return false; + return ka.every((k) => + Object.prototype.hasOwnProperty.call(b, k) + && deepEqual((a as Record)[k], (b as Record)[k]), + ); + } + return false; +} + +/** The comparable projection of an item: mechanical keys dropped, `isDefault` + * normalised (absent ≡ `false` — the expansion stamps it only on winners). */ +function comparable(item: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(item)) { + if (MECHANICAL_KEYS.has(k) || v === undefined) continue; + out[k] = v; + } + if (!out.isDefault) delete out.isDefault; + return out; +} + +/** + * True for a view artifact of CONTAINER shape — including the empty container + * (`{ name }`, schema-legal, registers nothing). {@link isAggregatedViewContainer} + * answers "does this container carry views to expand?", which is false for an + * empty container; classification for travel needs the shape question instead: + * anything that is not a ViewItem record (`viewKind`/`config`) and carries no + * inline view-config fingerprint is a container document. + */ +function isContainerShaped(item: Record): boolean { + if (isAggregatedViewContainer(item)) return true; + if (item.viewKind != null || item.config != null) return false; + // Flattened overlays put single-view config keys at the top level. + return !['type', 'columns', 'sections', 'data', 'filter', 'sort'].some((k) => k in item); +} + +/** + * Partition one package's view artifacts for manifest assembly — A's + * mechanical half of the #5320 fork ruling, shared by every producer. + * + * Containers go to `views:`. Every other artifact is judged individually + * against the containers travelling WITH it: an expanded item that the import + * side's own expansion of its container will reproduce EXACTLY (same name, + * same authored payload — mechanical `order`/`scope`/`_diagnostics` excluded, + * personalisation keys like `isPinned` included, so a personalised or edited + * item is never folded away) is dropped as derivable and reported in `folded`; + * everything else — tenant-authored standalone ViewItems, flattened overlays, + * expanded items whose stored body has since diverged from their container — + * travels in `viewItems:`. + * + * Pure over its input: items are not mutated, and the caller owns any + * provenance stripping (`_`-prefixed keys) before partitioning. + */ +export function partitionAssembledViewArtifacts( + items: readonly Record[], +): AssembledViewPartition { + const views: Record[] = []; + const rest: Record[] = []; + for (const item of items) { + if (item && typeof item === 'object' && isContainerShaped(item)) views.push(item); + else rest.push(item); + } + + // Expand each travelling container ONCE, exactly as the registration loop + // will on import (same helper, same base name), and index by expanded name. + const derivable = new Map>(); + for (const container of views) { + const base = typeof container.name === 'string' && container.name + ? container.name + : typeof container.object === 'string' ? container.object : undefined; + if (!base) continue; + for (const expanded of expandViewContainer(base, container)) { + derivable.set(expanded.name, comparable(expanded as unknown as Record)); + } + } + + const viewItems: Record[] = []; + const folded: string[] = []; + for (const item of rest) { + const name = item && typeof item === 'object' && typeof item.name === 'string' ? item.name : undefined; + const twin = name ? derivable.get(name) : undefined; + if (twin && deepEqual(comparable(item), twin)) { + folded.push(name as string); + continue; + } + viewItems.push(item); + } + + return { views, viewItems, folded }; +} diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 633e84b375..51bc61bf76 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -21,6 +21,9 @@ export * from './responsive.zod'; export * from './app.zod'; export * from './bulk-action.zod'; export * from './view.zod'; +// [#5320] The assembled-manifest channel for non-container view artifacts — +// the `viewItems:` vocabulary and its producer-side partition helper. +export * from './assembled-views.zod'; export * from './dashboard.zod'; export * from './report.zod'; export * from './dataset.zod'; From 20f39baf883bce722b23bb7828bf0003fd6df5c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:38:30 +0000 Subject: [PATCH 2/5] feat(spec): export the shared container classifier, ADR-0122 Parsed alias, root re-exports, INVALID_METADATA provenance row (#5320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isViewContainerShaped: ONE classifier for producers (partition) and the consumer (the registration loop's views: tighten) — two classifiers would let an assembler emit an entry the importer refuses. - AssembledViewArtifactParsed (ADR-0122 alias convention). - Root index re-exports the assembled-views vocabulary (the engine imports from the package root). - ERROR_CODE_LEDGER: INVALID_METADATA provenance row under @objectstack/objectql (third emitter — the views: tighten and the viewItems: channel refuse 422). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- packages/spec/api-surface/root.json | 7 +++++++ packages/spec/api-surface/ui.json | 2 ++ packages/spec/export-origins/root.json | 7 +++++++ packages/spec/export-origins/ui.json | 2 ++ .../spec/src/api/error-code-ledger.zod.ts | 6 ++++++ packages/spec/src/index.ts | 10 ++++++++++ packages/spec/src/ui/assembled-views.zod.ts | 19 ++++++++++++++----- 7 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 5b1d1cdba1..a32100f87b 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -4,9 +4,14 @@ "exports": [ "ADMIN_FULL_ACCESS (const)", "ALL_CONVERSIONS (const)", + "ASSEMBLED_VIEW_ITEMS_KEY (const)", "AUDIENCE_ANCHOR_POSITIONS (const)", "Agent (type)", "ApplyConversionsOptions (interface)", + "AssembledViewArtifact (type)", + "AssembledViewArtifactParsed (type)", + "AssembledViewArtifactSchema (const)", + "AssembledViewPartition (interface)", "AuthoringKeySurface (type)", "BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES (const)", @@ -166,6 +171,7 @@ "formatZodIssue (function)", "isAggregatedViewContainer (function)", "isKnownPlatformCapability (function)", + "isViewContainerShaped (function)", "lintUnknownAuthoringKeys (function)", "lintUnknownKeysAgainstSchema (function)", "lintUnknownStackKeys (function)", @@ -175,6 +181,7 @@ "normalizePluginMetadata (function)", "normalizeStackInput (function)", "objectStackErrorMap (const)", + "partitionAssembledViewArtifacts (function)", "safeParsePretty (function)", "suggestFieldType (function)", "tmpl (function)" diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 7e343df25c..622af79287 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -44,6 +44,7 @@ "AriaProps (type)", "AriaPropsSchema (const)", "AssembledViewArtifact (type)", + "AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema (const)", "AssembledViewPartition (interface)", "BorderRadius (type)", @@ -413,6 +414,7 @@ "isActionParamValuePresent (function)", "isAggregatedViewContainer (function)", "isRecordContextBlockType (function)", + "isViewContainerShaped (function)", "normalizeFilterOperator (function)", "normalizeInlineAction (function)", "pageForm (const)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index f19f74d0ae..f256024404 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -4,9 +4,14 @@ "exports": { "ADMIN_FULL_ACCESS": "src/identity/eval-user.zod.ts#ADMIN_FULL_ACCESS (const)", "ALL_CONVERSIONS": "src/conversions/registry.ts#ALL_CONVERSIONS (const)", + "ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)", "AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)", "Agent": "src/ai/agent.zod.ts#Agent (type)", "ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)", + "AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)", + "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", + "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", + "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", "AuthoringKeySurface": "src/data/authoring-key-lint.ts#AuthoringKeySurface (type)", "BUILTIN_IDENTITY_METADATA": "src/identity/eval-user.zod.ts#BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES": "src/identity/eval-user.zod.ts#BUILTIN_IDENTITY_NAMES (const)", @@ -166,6 +171,7 @@ "formatZodIssue": "src/shared/error-map.zod.ts#formatZodIssue (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", "isKnownPlatformCapability": "src/kernel/platform-capabilities.ts#isKnownPlatformCapability (function)", + "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "lintUnknownAuthoringKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownAuthoringKeys (function)", "lintUnknownKeysAgainstSchema": "src/kernel/metadata-authoring-lint.ts#lintUnknownKeysAgainstSchema (function)", "lintUnknownStackKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownStackKeys (function)", @@ -175,6 +181,7 @@ "normalizePluginMetadata": "src/shared/metadata-collection.zod.ts#normalizePluginMetadata (function)", "normalizeStackInput": "src/shared/metadata-collection.zod.ts#normalizeStackInput (function)", "objectStackErrorMap": "src/shared/error-map.zod.ts#objectStackErrorMap (const)", + "partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)", "safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)", "suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)", "tmpl": "src/shared/expression.zod.ts#tmpl (function)" diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 0de4cbd73a..7caa4ae5d0 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -44,6 +44,7 @@ "AriaProps": "src/ui/i18n.zod.ts#AriaProps (type)", "AriaPropsSchema": "src/ui/i18n.zod.ts#AriaPropsSchema (const)", "AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)", + "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", "BorderRadius": "src/ui/theme.zod.ts#BorderRadius (type)", @@ -413,6 +414,7 @@ "isActionParamValuePresent": "src/ui/action-params.zod.ts#isActionParamValuePresent (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", + "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", "normalizeInlineAction": "src/ui/action.zod.ts#normalizeInlineAction (function)", "pageForm": "src/ui/page.form.ts#pageForm (const)", diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index c91aa1baea..99b0cf1962 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -344,6 +344,12 @@ export const ERROR_CODE_LEDGER = { 'ERR_BULK_RESULT_MISMATCH', 'ERR_DATASOURCE_UNAVAILABLE', 'ERR_DRIVER_CONNECT', + // [#5320] Third EMITTER of the code (metadata-protocol and plugin-security + // already register it) — the registration loop's `views:` tighten refuses a + // non-container entry, and the `viewItems:` channel refuses an entry the + // assembled vocabulary rejects, both 422/INVALID_METADATA. Provenance, not + // identity, per this file's header. + 'INVALID_METADATA', 'ERR_READONLY_FIELD_REJECTED', // strictReadonlyWrites: the write would strip caller-supplied fields, so it was refused (#5126; since #6437 that covers the primary_key strip too — one code, `drops` carries the per-reason breakdown) 'ERR_SUMMARY_RECOMPUTE', 'VALIDATION_FAILED', diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index b9cf1a5901..2329a483e6 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -68,6 +68,16 @@ export * from './stack.zod'; // DX Helper Functions (re-exported for convenience) export { defineView, defineForm, defineViewItem, isAggregatedViewContainer, expandViewContainer, expandViewContainerWithDiagnostics } from './ui/view.zod'; +// [#5320] The assembled-manifest view channel: the `viewItems:` vocabulary, its +// producer-side partition, and the shared container classifier the registration +// loop's `views:` tighten judges by. +export { + ASSEMBLED_VIEW_ITEMS_KEY, + AssembledViewArtifactSchema, + isViewContainerShaped, + partitionAssembledViewArtifacts, +} from './ui/assembled-views.zod'; +export type { AssembledViewArtifact, AssembledViewArtifactParsed, AssembledViewPartition } from './ui/assembled-views.zod'; export type { ExpandedViewItem, ViewKeyCollision, ExpandViewResult } from './ui/view.zod'; export { defineApp } from './ui/app.zod'; export { defineFlow } from './automation/flow.zod'; diff --git a/packages/spec/src/ui/assembled-views.zod.ts b/packages/spec/src/ui/assembled-views.zod.ts index a70a4691a0..1f79dbd99e 100644 --- a/packages/spec/src/ui/assembled-views.zod.ts +++ b/packages/spec/src/ui/assembled-views.zod.ts @@ -160,12 +160,21 @@ function comparable(item: Record): Record { * empty container; classification for travel needs the shape question instead: * anything that is not a ViewItem record (`viewKind`/`config`) and carries no * inline view-config fingerprint is a container document. + * + * EXPORTED because the producers and the consumer must run ONE classifier: the + * partition below routes by it on assembly, and the ObjectQL registration + * loop's `views:` tighten (#5320 step 3) refuses by it on ingestion. Two + * classifiers would let an assembler emit an entry the importer refuses — + * re-opening, one level down, the exact producer/consumer disagreement this + * module exists to close. */ -function isContainerShaped(item: Record): boolean { - if (isAggregatedViewContainer(item)) return true; - if (item.viewKind != null || item.config != null) return false; +export function isViewContainerShaped(item: unknown): boolean { + if (!item || typeof item !== 'object' || Array.isArray(item)) return false; + const rec = item as Record; + if (isAggregatedViewContainer(rec)) return true; + if (rec.viewKind != null || rec.config != null) return false; // Flattened overlays put single-view config keys at the top level. - return !['type', 'columns', 'sections', 'data', 'filter', 'sort'].some((k) => k in item); + return !['type', 'columns', 'sections', 'data', 'filter', 'sort'].some((k) => k in rec); } /** @@ -191,7 +200,7 @@ export function partitionAssembledViewArtifacts( const views: Record[] = []; const rest: Record[] = []; for (const item of items) { - if (item && typeof item === 'object' && isContainerShaped(item)) views.push(item); + if (isViewContainerShaped(item)) views.push(item); else rest.push(item); } From bd6dd7653680c0466f49ec6d52e9a8b2160fbebc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:38:32 +0000 Subject: [PATCH 3/5] merge origin/main (os-regen artifacts taken from main; regeneration follows) --- packages/spec/api-surface/root.json | 7 ------- packages/spec/api-surface/ui.json | 7 ------- packages/spec/export-origins/root.json | 7 ------- packages/spec/export-origins/ui.json | 7 ------- 4 files changed, 28 deletions(-) diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index a32100f87b..5b1d1cdba1 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -4,14 +4,9 @@ "exports": [ "ADMIN_FULL_ACCESS (const)", "ALL_CONVERSIONS (const)", - "ASSEMBLED_VIEW_ITEMS_KEY (const)", "AUDIENCE_ANCHOR_POSITIONS (const)", "Agent (type)", "ApplyConversionsOptions (interface)", - "AssembledViewArtifact (type)", - "AssembledViewArtifactParsed (type)", - "AssembledViewArtifactSchema (const)", - "AssembledViewPartition (interface)", "AuthoringKeySurface (type)", "BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES (const)", @@ -171,7 +166,6 @@ "formatZodIssue (function)", "isAggregatedViewContainer (function)", "isKnownPlatformCapability (function)", - "isViewContainerShaped (function)", "lintUnknownAuthoringKeys (function)", "lintUnknownKeysAgainstSchema (function)", "lintUnknownStackKeys (function)", @@ -181,7 +175,6 @@ "normalizePluginMetadata (function)", "normalizeStackInput (function)", "objectStackErrorMap (const)", - "partitionAssembledViewArtifacts (function)", "safeParsePretty (function)", "suggestFieldType (function)", "tmpl (function)" diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 622af79287..50d002b23e 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -5,7 +5,6 @@ "ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS (const)", "AIChatWindowProps (const)", - "ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action (type)", "ActionAi (type)", "ActionAiParsed (type)", @@ -43,10 +42,6 @@ "AppearanceConfigSchema (const)", "AriaProps (type)", "AriaPropsSchema (const)", - "AssembledViewArtifact (type)", - "AssembledViewArtifactParsed (type)", - "AssembledViewArtifactSchema (const)", - "AssembledViewPartition (interface)", "BorderRadius (type)", "BorderRadiusSchema (const)", "BreakpointColumnMap (type)", @@ -414,11 +409,9 @@ "isActionParamValuePresent (function)", "isAggregatedViewContainer (function)", "isRecordContextBlockType (function)", - "isViewContainerShaped (function)", "normalizeFilterOperator (function)", "normalizeInlineAction (function)", "pageForm (const)", - "partitionAssembledViewArtifacts (function)", "reactBlockTagFor (function)", "reportForm (const)", "reportSelectionOrder (function)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index f256024404..f19f74d0ae 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -4,14 +4,9 @@ "exports": { "ADMIN_FULL_ACCESS": "src/identity/eval-user.zod.ts#ADMIN_FULL_ACCESS (const)", "ALL_CONVERSIONS": "src/conversions/registry.ts#ALL_CONVERSIONS (const)", - "ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)", "AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)", "Agent": "src/ai/agent.zod.ts#Agent (type)", "ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)", - "AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)", - "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", - "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", - "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", "AuthoringKeySurface": "src/data/authoring-key-lint.ts#AuthoringKeySurface (type)", "BUILTIN_IDENTITY_METADATA": "src/identity/eval-user.zod.ts#BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES": "src/identity/eval-user.zod.ts#BUILTIN_IDENTITY_NAMES (const)", @@ -171,7 +166,6 @@ "formatZodIssue": "src/shared/error-map.zod.ts#formatZodIssue (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", "isKnownPlatformCapability": "src/kernel/platform-capabilities.ts#isKnownPlatformCapability (function)", - "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "lintUnknownAuthoringKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownAuthoringKeys (function)", "lintUnknownKeysAgainstSchema": "src/kernel/metadata-authoring-lint.ts#lintUnknownKeysAgainstSchema (function)", "lintUnknownStackKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownStackKeys (function)", @@ -181,7 +175,6 @@ "normalizePluginMetadata": "src/shared/metadata-collection.zod.ts#normalizePluginMetadata (function)", "normalizeStackInput": "src/shared/metadata-collection.zod.ts#normalizeStackInput (function)", "objectStackErrorMap": "src/shared/error-map.zod.ts#objectStackErrorMap (const)", - "partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)", "safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)", "suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)", "tmpl": "src/shared/expression.zod.ts#tmpl (function)" diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 7caa4ae5d0..cbb13402d7 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -5,7 +5,6 @@ "ACTION_LOCATIONS": "src/ui/action.zod.ts#ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS": "src/ui/action-params.zod.ts#ACTION_PARAM_BUILTIN_KEYS (const)", "AIChatWindowProps": "src/ui/component.zod.ts#AIChatWindowProps (const)", - "ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action": "src/ui/action.zod.ts#Action (type)", "ActionAi": "src/ui/action.zod.ts#ActionAi (type)", "ActionAiParsed": "src/ui/action.zod.ts#ActionAiParsed (type)", @@ -43,10 +42,6 @@ "AppearanceConfigSchema": "src/ui/view.zod.ts#AppearanceConfigSchema (const)", "AriaProps": "src/ui/i18n.zod.ts#AriaProps (type)", "AriaPropsSchema": "src/ui/i18n.zod.ts#AriaPropsSchema (const)", - "AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)", - "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", - "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", - "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", "BorderRadius": "src/ui/theme.zod.ts#BorderRadius (type)", "BorderRadiusSchema": "src/ui/theme.zod.ts#BorderRadiusSchema (const)", "BreakpointColumnMap": "src/ui/responsive.zod.ts#BreakpointColumnMap (type)", @@ -414,11 +409,9 @@ "isActionParamValuePresent": "src/ui/action-params.zod.ts#isActionParamValuePresent (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", - "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", "normalizeInlineAction": "src/ui/action.zod.ts#normalizeInlineAction (function)", "pageForm": "src/ui/page.form.ts#pageForm (const)", - "partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)", "reactBlockTagFor": "src/ui/react-blocks.ts#reactBlockTagFor (function)", "reportForm": "src/ui/report.form.ts#reportForm (const)", "reportSelectionOrder": "src/ui/report.zod.ts#reportSelectionOrder (function)", From 81f5fbf1747cd13375d450623e8414174140314f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:42:00 +0000 Subject: [PATCH 4/5] chore(spec): regenerate api-surface/export-origins after merging origin/main (#5320) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- packages/spec/api-surface/root.json | 7 +++++++ packages/spec/api-surface/ui.json | 7 +++++++ packages/spec/export-origins/root.json | 7 +++++++ packages/spec/export-origins/ui.json | 7 +++++++ 4 files changed, 28 insertions(+) diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 5b1d1cdba1..a32100f87b 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -4,9 +4,14 @@ "exports": [ "ADMIN_FULL_ACCESS (const)", "ALL_CONVERSIONS (const)", + "ASSEMBLED_VIEW_ITEMS_KEY (const)", "AUDIENCE_ANCHOR_POSITIONS (const)", "Agent (type)", "ApplyConversionsOptions (interface)", + "AssembledViewArtifact (type)", + "AssembledViewArtifactParsed (type)", + "AssembledViewArtifactSchema (const)", + "AssembledViewPartition (interface)", "AuthoringKeySurface (type)", "BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES (const)", @@ -166,6 +171,7 @@ "formatZodIssue (function)", "isAggregatedViewContainer (function)", "isKnownPlatformCapability (function)", + "isViewContainerShaped (function)", "lintUnknownAuthoringKeys (function)", "lintUnknownKeysAgainstSchema (function)", "lintUnknownStackKeys (function)", @@ -175,6 +181,7 @@ "normalizePluginMetadata (function)", "normalizeStackInput (function)", "objectStackErrorMap (const)", + "partitionAssembledViewArtifacts (function)", "safeParsePretty (function)", "suggestFieldType (function)", "tmpl (function)" diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 50d002b23e..622af79287 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -5,6 +5,7 @@ "ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS (const)", "AIChatWindowProps (const)", + "ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action (type)", "ActionAi (type)", "ActionAiParsed (type)", @@ -42,6 +43,10 @@ "AppearanceConfigSchema (const)", "AriaProps (type)", "AriaPropsSchema (const)", + "AssembledViewArtifact (type)", + "AssembledViewArtifactParsed (type)", + "AssembledViewArtifactSchema (const)", + "AssembledViewPartition (interface)", "BorderRadius (type)", "BorderRadiusSchema (const)", "BreakpointColumnMap (type)", @@ -409,9 +414,11 @@ "isActionParamValuePresent (function)", "isAggregatedViewContainer (function)", "isRecordContextBlockType (function)", + "isViewContainerShaped (function)", "normalizeFilterOperator (function)", "normalizeInlineAction (function)", "pageForm (const)", + "partitionAssembledViewArtifacts (function)", "reactBlockTagFor (function)", "reportForm (const)", "reportSelectionOrder (function)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index f19f74d0ae..f256024404 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -4,9 +4,14 @@ "exports": { "ADMIN_FULL_ACCESS": "src/identity/eval-user.zod.ts#ADMIN_FULL_ACCESS (const)", "ALL_CONVERSIONS": "src/conversions/registry.ts#ALL_CONVERSIONS (const)", + "ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)", "AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)", "Agent": "src/ai/agent.zod.ts#Agent (type)", "ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)", + "AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)", + "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", + "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", + "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", "AuthoringKeySurface": "src/data/authoring-key-lint.ts#AuthoringKeySurface (type)", "BUILTIN_IDENTITY_METADATA": "src/identity/eval-user.zod.ts#BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES": "src/identity/eval-user.zod.ts#BUILTIN_IDENTITY_NAMES (const)", @@ -166,6 +171,7 @@ "formatZodIssue": "src/shared/error-map.zod.ts#formatZodIssue (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", "isKnownPlatformCapability": "src/kernel/platform-capabilities.ts#isKnownPlatformCapability (function)", + "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "lintUnknownAuthoringKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownAuthoringKeys (function)", "lintUnknownKeysAgainstSchema": "src/kernel/metadata-authoring-lint.ts#lintUnknownKeysAgainstSchema (function)", "lintUnknownStackKeys": "src/kernel/metadata-authoring-lint.ts#lintUnknownStackKeys (function)", @@ -175,6 +181,7 @@ "normalizePluginMetadata": "src/shared/metadata-collection.zod.ts#normalizePluginMetadata (function)", "normalizeStackInput": "src/shared/metadata-collection.zod.ts#normalizeStackInput (function)", "objectStackErrorMap": "src/shared/error-map.zod.ts#objectStackErrorMap (const)", + "partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)", "safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)", "suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)", "tmpl": "src/shared/expression.zod.ts#tmpl (function)" diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index cbb13402d7..7caa4ae5d0 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -5,6 +5,7 @@ "ACTION_LOCATIONS": "src/ui/action.zod.ts#ACTION_LOCATIONS (const)", "ACTION_PARAM_BUILTIN_KEYS": "src/ui/action-params.zod.ts#ACTION_PARAM_BUILTIN_KEYS (const)", "AIChatWindowProps": "src/ui/component.zod.ts#AIChatWindowProps (const)", + "ASSEMBLED_VIEW_ITEMS_KEY": "src/ui/assembled-views.zod.ts#ASSEMBLED_VIEW_ITEMS_KEY (const)", "Action": "src/ui/action.zod.ts#Action (type)", "ActionAi": "src/ui/action.zod.ts#ActionAi (type)", "ActionAiParsed": "src/ui/action.zod.ts#ActionAiParsed (type)", @@ -42,6 +43,10 @@ "AppearanceConfigSchema": "src/ui/view.zod.ts#AppearanceConfigSchema (const)", "AriaProps": "src/ui/i18n.zod.ts#AriaProps (type)", "AriaPropsSchema": "src/ui/i18n.zod.ts#AriaPropsSchema (const)", + "AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)", + "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", + "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", + "AssembledViewPartition": "src/ui/assembled-views.zod.ts#AssembledViewPartition (interface)", "BorderRadius": "src/ui/theme.zod.ts#BorderRadius (type)", "BorderRadiusSchema": "src/ui/theme.zod.ts#BorderRadiusSchema (const)", "BreakpointColumnMap": "src/ui/responsive.zod.ts#BreakpointColumnMap (type)", @@ -409,9 +414,11 @@ "isActionParamValuePresent": "src/ui/action-params.zod.ts#isActionParamValuePresent (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", + "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", "normalizeInlineAction": "src/ui/action.zod.ts#normalizeInlineAction (function)", "pageForm": "src/ui/page.form.ts#pageForm (const)", + "partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)", "reactBlockTagFor": "src/ui/react-blocks.ts#reactBlockTagFor (function)", "reportForm": "src/ui/report.form.ts#reportForm (const)", "reportSelectionOrder": "src/ui/report.zod.ts#reportSelectionOrder (function)", From 6f07d2cb153fff74d97260eb5d0c4d64ed5aea10 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 21:51:52 +0000 Subject: [PATCH 5/5] feat(objectql,runtime,lint): views: tighten + viewItems ingestion + export partition + lint alignment (#5320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine.ts registerMetadataCollections: non-container views: entries refused (INVALID_METADATA/422, wrap-it prescription); viewItems: entries validated against AssembledViewArtifactSchema and registered parsed (both seams, one body — #7163 kept). - runtime assemblePackageManifest: views partitioned via partitionAssembledViewArtifacts — containers in views:, derivable expansions folded, the rest in viewItems:. - lint validate-view-containers: viewKind entries in views: now error with the wrap-it prescription; hand-authored viewItems: flagged machine-assembled-only. - engine-nested-plugin-view-expansion.test.ts: the #7163 control block replaced WHOLESALE with the rejection pin (code+status), per the ruled fixture disposition; new engine-assembled-views-roundtrip.test.ts runs the fork's acceptance probes inverted (round trip end-to-end through the new channel). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .changeset/views-tighten-assembled-channel.md | 35 ++++++ .../lint/src/validate-view-containers.test.ts | 38 +++++- packages/lint/src/validate-view-containers.ts | 59 +++++++++- .../engine-assembled-views-roundtrip.test.ts | 109 ++++++++++++++++++ ...ngine-nested-plugin-view-expansion.test.ts | 73 ++++++++++-- packages/objectql/src/engine.ts | 79 ++++++++++++- packages/runtime/src/domains/packages.ts | 21 ++++ 7 files changed, 396 insertions(+), 18 deletions(-) create mode 100644 .changeset/views-tighten-assembled-channel.md create mode 100644 packages/objectql/src/engine-assembled-views-roundtrip.test.ts diff --git a/.changeset/views-tighten-assembled-channel.md b/.changeset/views-tighten-assembled-channel.md new file mode 100644 index 0000000000..07ac95b077 --- /dev/null +++ b/.changeset/views-tighten-assembled-channel.md @@ -0,0 +1,35 @@ +--- +"@objectstack/objectql": minor +"@objectstack/runtime": minor +"@objectstack/lint": patch +--- + +feat(objectql,runtime,lint): tighten `views:` to the declared container-only contract; assembled manifests travel non-container view artifacts in `viewItems:` (#5320, #8070) + +The registration loop (`registerApp` / nested-plugin seam) used to register +EVERY `views:` entry as type `view` — wider than the stack schema, which has +always declared containers only. The three gates now agree (#5320, ruled +2026-08-12): + +- **objectql**: a non-container `views:` entry (ViewItem record, flattened + overlay, inline config) is REFUSED with the ADR-0112 envelope + (`INVALID_METADATA` / 422) and the wrap-it prescription. The declared entry + for machine-assembled non-container artifacts is the new `viewItems:` + channel: each entry is validated against `AssembledViewArtifactSchema` and + the parsed body registers — declared = enforced in both directions. +- **runtime**: `GET /packages/:id/export` partitions view artifacts + (`partitionAssembledViewArtifacts`): containers travel in `views:`, expanded + items the container re-derives exactly are folded away, and standalone + ViewItems / overlays / edited expansions travel in `viewItems:`. The + export→import round trip that previously depended on the undeclared wider + acceptance now survives end to end through the declared channels. +- **lint**: the pre-parse `view-container-shape` rule reaches the same + verdicts — a `viewKind`-bearing `views:` entry is an error with the wrap-it + prescription (it previously skipped them as "registered as-is"), and a + hand-authored `viewItems:` is flagged machine-assembled-only. + +Migration: a manifest assembled by an OLDER runtime (an export product carrying +expanded `viewKind` items inside `views:`) is refused on import with the +prescription — re-export the package with a runtime that writes the +`viewItems:` channel. Authored stacks are unaffected: `defineStack` already +refused every shape the loop now refuses. diff --git a/packages/lint/src/validate-view-containers.test.ts b/packages/lint/src/validate-view-containers.test.ts index 1fc4f073cc..afc7cc8fb8 100644 --- a/packages/lint/src/validate-view-containers.test.ts +++ b/packages/lint/src/validate-view-containers.test.ts @@ -21,7 +21,11 @@ describe('validateViewContainers (defineView container shape guardrail)', () => expect(findings).toHaveLength(0); }); - it('passes an independent ViewItem (viewKind discriminator)', () => { + // [#5320] Inverted from "passes an independent ViewItem": the loader's + // as-is registration of ViewItems from `views:` was the undeclared wider + // acceptance this card removed, and the pre-parse door now reaches the same + // verdict the schema and the registration loop enforce. + it('flags an independent ViewItem in `views:` with the wrap-it prescription (#5320)', () => { const findings = validateViewContainers({ views: [ { @@ -32,7 +36,37 @@ describe('validateViewContainers (defineView container shape guardrail)', () => }, ], }); - expect(findings).toHaveLength(0); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + path: 'views[0]', + }); + expect(findings[0].where).toContain('task.pipeline'); + expect(findings[0].message).toContain('containers only'); + expect(findings[0].hint).toContain('defineView'); + expect(findings[0].hint).toContain('metadata door'); + }); + + it('flags a hand-authored `viewItems:` as machine-assembled-only (#5320)', () => { + const findings = validateViewContainers({ + viewItems: [ + { + name: 'task.pipeline', + object: 'task', + viewKind: 'list', + config: { type: 'kanban', columns: ['title'] }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + path: 'viewItems', + }); + expect(findings[0].message).toContain('machine-assembled'); + expect(findings[0].hint).toContain('metadata door'); }); it('flags a flat list-view object with the wrap-it hint', () => { diff --git a/packages/lint/src/validate-view-containers.ts b/packages/lint/src/validate-view-containers.ts index a60ffd18db..2094da1e80 100644 --- a/packages/lint/src/validate-view-containers.ts +++ b/packages/lint/src/validate-view-containers.ts @@ -32,8 +32,21 @@ // `os validate` stops at the schema step), `defineStack(x, { strict: false })`, // and direct API callers. // -// Independent ViewItems (`viewKind` + `config`) are legal `views: []` entries -// (the loader registers them as-is) and are not flagged. +// ## Independent ViewItems are NOT legal `views: []` entries any more (#5320) +// +// This header used to say a ViewItem (`viewKind` + `config`) "is registered +// as-is by the loader" and skip it. That was a description of the runtime +// loop's UNDECLARED wider acceptance — the exact "runtime wider than schema" +// hole #5320 records — not of the declared contract, which was always +// container-only (`stack.zod.ts`, `z.array(ViewSchema)`). The 2026-08-12 fork +// ruling tightened the loop to the declared contract, so this rule's verdict +// aligns: a `viewKind`-bearing entry in `views:` is now an ERROR with the same +// wrap-it prescription the schema and the loop carry. Standalone views are +// authored through the metadata door; runtime-ASSEMBLED manifests carry +// non-container view artifacts under the machine-only `viewItems:` channel +// (`ui/assembled-views.zod.ts`), which this rule flags when hand-authored — +// the schema refuses it too, but `os lint` never parses, so the pre-parse +// door needs its own voice. export type ViewContainerSeverity = 'error' | 'warning'; @@ -80,13 +93,51 @@ export function validateViewContainers(stack: Record): ViewCont const out: ViewContainerFinding[] = []; if (!stack || typeof stack !== 'object') return out; + // [#5320] `viewItems:` is the machine-assembled channel, never an authoring + // surface — the stack schema types it `never`, and this pre-parse door says + // the same thing to `os lint` callers the parse never reaches. + const viewItems = (stack as AnyRec).viewItems; + if (viewItems != null && asEntries(viewItems).length > 0) { + out.push({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + where: 'viewItems', + path: 'viewItems', + message: + '`viewItems` is the machine-assembled channel for non-container view artifacts in ' + + 'runtime-assembled manifests (package export, environment artifacts) — it is not an ' + + 'authoring surface.', + hint: 'Author views as defineView containers in `views:`; author a standalone view through ' + + 'the metadata door (Studio / `PUT /api/v1/meta/view`), not in stack source.', + }); + } + for (const { key, value } of asEntries((stack as AnyRec).views)) { // Non-object entries are the schema step's problem, not this rule's. if (!value || typeof value !== 'object' || Array.isArray(value)) continue; const rec = value as AnyRec; - // Independent ViewItem (`viewKind` discriminator) — registered as-is. - if (rec.viewKind != null) continue; + // [#5320] Independent ViewItem (`viewKind` discriminator) in `views:` — + // refused by the schema AND (since the tighten) by the registration loop; + // this rule now reaches the same verdict pre-parse, prescription included. + if (rec.viewKind != null) { + const label = typeof rec.name === 'string' ? ` ("${rec.name}")` : ''; + out.push({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + where: `views${key}${label}`, + path: `views${key}`, + message: + 'A ViewItem record is not a view container: the stack `views:` collection carries ' + + 'containers only — `viewKind` belongs to a single VIEW, not to the container. The ' + + 'registration loop refuses this entry (#5320).', + hint: 'Wrap it in a defineView container: defineView({ list: { type, data, columns, ... }, ' + + 'listViews: { ... } }) — or author the standalone view through the metadata door ' + + '(Studio / `PUT /api/v1/meta/view`). Machine-assembled manifests carry it under ' + + '`viewItems:`.', + }); + continue; + } if (containerViewCount(rec) > 0) continue; diff --git a/packages/objectql/src/engine-assembled-views-roundtrip.test.ts b/packages/objectql/src/engine-assembled-views-roundtrip.test.ts new file mode 100644 index 0000000000..d6df1bc676 --- /dev/null +++ b/packages/objectql/src/engine-assembled-views-roundtrip.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5320/#8070] The export→import round trip survives END TO END through the + * declared channels — the fork's acceptance probe, inverted. + * + * The 2026-08-12 fork measured (by execution) that the platform's own package + * export emitted `views:` entries the stack vocabulary refuses — 2 of 3 entries + * in the minimal single-container case — and the round trip survived only + * through the registration loop's undeclared wider acceptance. With the ruling + * landed (B vocabulary + A's re-aggregation + the tighten), the SAME flows must + * survive through the declared channels instead: + * + * register → read back (what `GET /packages/:id/export` reads) → partition + * (`partitionAssembledViewArtifacts`, the assembler's half) → re-import + * through `registerApp` → every view artifact is registered again. + * + * This is the executed probe, not a grep: it runs the real registration loop + * on both ends and the real partition in the middle. + */ + +import { describe, it, expect } from 'vitest'; +import { partitionAssembledViewArtifacts } from '@objectstack/spec'; +import { ObjectQL } from './engine'; + +const PKG = 'com.acme.sales'; + +/** Minimal schema-valid container — the fork probe's fixture: default list + + * default form → dual-read registers 3 registry items. */ +function accountContainer() { + return { + name: 'account', + object: 'account', + list: { type: 'grid', data: { provider: 'object', object: 'account' }, columns: [{ field: 'name' }] }, + form: { type: 'simple', data: { provider: 'object', object: 'account' }, sections: [{ label: 'Info', fields: [{ field: 'name' }] }] }, + }; +} + +/** A tenant-authored standalone ViewItem — legal branch 1 of the `view` + * metadata vocabulary; has NO container to re-aggregate from. */ +const STANDALONE = { + name: 'account.hot', + object: 'account', + viewKind: 'list', + config: { type: 'grid', columns: [{ field: 'name' }] }, +}; + +/** What the export path's `clean()` does: strip provenance decorations. */ +function clean(item: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(item)) { + if (k.startsWith('_')) continue; + out[k] = v; + } + return out; +} + +function viewNames(engine: ObjectQL): string[] { + return (engine.registry.listItems('view') ?? []).filter(Boolean).map((v: any) => v.name).sort(); +} + +describe('export→import round trip through the declared channels (#5320/#8070)', () => { + it('the minimal single-container package survives end to end — all entries land', () => { + // ── source environment ── + const source = new ObjectQL(); + source.registerApp({ id: PKG, name: 'sales', views: [accountContainer()] }); + // Tenant authors a standalone ViewItem through the metadata door. + source.registry.registerItem('view', { ...STANDALONE }, 'name' as any, PKG); + + const sourceNames = viewNames(source); + expect(sourceNames).toEqual(['account', 'account.default', 'account.form', 'account.hot']); + + // ── export assembly (what assemblePackageManifest now does for views) ── + const stored = (source.registry.listItems('view') ?? []).filter(Boolean).map(clean); + const { views, viewItems, folded } = partitionAssembledViewArtifacts(stored); + + // Predicted directions, stated before running (fork discipline): + // the container travels; its 2 expanded items FOLD (the import side + // re-derives them); the standalone travels in viewItems. + expect(views.map((v) => v.name)).toEqual(['account']); + expect(folded.sort()).toEqual(['account.default', 'account.form']); + expect(viewItems.map((v) => v.name)).toEqual(['account.hot']); + + // ── import into a fresh environment ── + const target = new ObjectQL(); + target.registerApp({ id: PKG, name: 'sales', views, viewItems }); + + // END TO END: every view artifact of the source is registered in the target. + expect(viewNames(target)).toEqual(sourceNames); + }); + + it('a tenant-authored standalone ViewItem survives export→import alone', () => { + const source = new ObjectQL(); + source.registerApp({ id: PKG, name: 'sales' }); + source.registry.registerItem('view', { ...STANDALONE }, 'name' as any, PKG); + + const stored = (source.registry.listItems('view') ?? []).filter(Boolean).map(clean); + const { views, viewItems } = partitionAssembledViewArtifacts(stored); + expect(views).toEqual([]); + expect(viewItems.map((v) => v.name)).toEqual(['account.hot']); + + const target = new ObjectQL(); + target.registerApp({ id: PKG, name: 'sales', viewItems }); + expect(viewNames(target)).toEqual(['account.hot']); + const round = (target.registry.listItems('view') ?? []).find((v: any) => v?.name === 'account.hot'); + expect(round.viewKind).toBe('list'); + expect(round.config).toEqual(STANDALONE.config); + }); +}); diff --git a/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts b/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts index dcacbb4b7e..91d0d3dace 100644 --- a/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts +++ b/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts @@ -202,13 +202,18 @@ describe('the expanded per-view identities a nested plugin now produces (#7163)' }); }); -describe('control — a NON-aggregated view is unchanged by this card (#7163)', () => { +describe('a NON-container `views:` entry is REFUSED by both seams (#5320)', () => { /** - * The fix is scoped by `isAggregatedViewContainer`, which is false for an - * already-independent `ViewItem` (it carries `viewKind`). Such a view must - * register exactly once, under its own name, through BOTH seams — no - * expansion, no new keys. This is what says the change is additive and only - * on the container shape. + * [#5320] REPLACED WHOLESALE, per the fork ruling's fixture disposition. + * The block this replaces was #7163's control: it PINNED that a standalone + * ViewItem in `views:` "registers as-is, through both seams" — i.e. it + * pinned exactly the undeclared runtime-wider acceptance this card removes + * (the stack vocabulary was always container-only, `stack.zod.ts:views`). + * Keeping it would have kept a green assertion over a deleted behaviour; + * loosening it would have judged nothing. It is now the rejection pin: + * both seams refuse the entry with the ADR-0112 envelope (`code` + `status`) + * and the wrap-it prescription, and the declared travel route for + * machine-assembled non-container artifacts is the `viewItems:` channel. */ const viewItem = { name: 'account.hot', @@ -217,12 +222,58 @@ describe('control — a NON-aggregated view is unchanged by this card (#7163)', config: { type: 'grid', columns: [{ field: 'name' }] }, }; - it('registers a standalone ViewItem identically from both seams, with no expansion', () => { - const direct = boot({ id: PKG, name: 'sales', views: [viewItem] }); - const nested = boot({ id: PKG, name: 'sales', plugins: [{ name: 'p', views: [viewItem] }] }); + /** Envelope-first assertion: `code` AND `status`, never a bare toThrow. */ + function expectRefusal(manifest: unknown) { + let thrown: (Error & { code?: string; status?: number }) | undefined; + try { + boot(manifest); + } catch (e) { + thrown = e as Error & { code?: string; status?: number }; + } + expect(thrown, 'registration must refuse, not accept').toBeTruthy(); + expect(thrown!.code).toBe('INVALID_METADATA'); + expect(thrown!.status).toBe(422); + expect(thrown!.message).toMatch(/containers only/i); + expect(thrown!.message).toContain('defineView'); + return thrown!; + } + + it('refuses a standalone ViewItem in `views:` from the manifest seam, envelope + prescription', () => { + const err = expectRefusal({ id: PKG, name: 'sales', views: [viewItem] }); + // The refusal names the entry, so the author fixes the right view. + expect(err.message).toContain('account.hot'); + }); + + it('refuses identically from the nested-plugin seam (one body, one verdict — #7163 kept)', () => { + expectRefusal({ id: PKG, name: 'sales', plugins: [{ name: 'p', views: [viewItem] }] }); + }); - expect(viewNames(nested)).toEqual(['account.hot']); - expect(viewNames(nested)).toEqual(viewNames(direct)); + it('refuses a flattened overlay in `views:` too (inline config, no container slot)', () => { + expectRefusal({ + id: PKG, + name: 'sales', + views: [{ name: 'account.default', object: 'account', viewKind: 'list', type: 'grid', columns: [{ field: 'name' }] }], + }); + }); + + it('accepts the SAME artifact through the declared `viewItems:` channel', () => { + const engine = boot({ id: PKG, name: 'sales', viewItems: [viewItem] }); + expect(viewNames(engine)).toEqual(['account.hot']); + const stored = viewItems(engine).find((v: any) => v.name === 'account.hot'); + expect(stored.viewKind).toBe('list'); + expect(stored.object).toBe('account'); + }); + + it('refuses an undeclared bag in `viewItems:` with the envelope (strict channel, no passthrough)', () => { + let thrown: (Error & { code?: string; status?: number }) | undefined; + try { + boot({ id: PKG, name: 'sales', viewItems: [{ name: 'account.junk', nope: 1 }] }); + } catch (e) { + thrown = e as Error & { code?: string; status?: number }; + } + expect(thrown, 'the viewItems channel must refuse an undeclared bag').toBeTruthy(); + expect(thrown!.code).toBe('INVALID_METADATA'); + expect(thrown!.status).toBe(422); }); it('leaves a container-free manifest with no view items at all', () => { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index ec77b7f842..9b653fea49 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -146,7 +146,16 @@ import { expandSearchToFilter } from './search-filter.js'; import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js'; import { ExpressionEngine } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; -import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; +import { + isAggregatedViewContainer, + expandViewContainer, + // [#5320] The assembled-manifest view channel: `views:` carries containers + // only (judged by the SAME classifier the producers partition with), and + // non-container view artifacts enter through the declared `viewItems:` key. + ASSEMBLED_VIEW_ITEMS_KEY, + AssembledViewArtifactSchema, + isViewContainerShaped, +} from '@objectstack/spec'; import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; @@ -3958,6 +3967,34 @@ export class ObjectQL implements IObjectQLEngine { continue; } const toRegister = item.name === itemName ? item : { ...item, name: itemName }; + // [#5320] The `views:` tighten — containers ONLY, the contract the + // stack schema has always declared (`stack.zod.ts`, + // `z.array(ViewSchema)`). This loop used to register EVERY entry + // as type `view` — the "runtime wider than schema" hole #5320 + // records: a ViewItem or flattened overlay that `defineStack` + // refuses registered here in silence. Judged by the SAME + // classifier the manifest assemblers partition with + // (`isViewContainerShaped`), so a produced manifest cannot carry + // a `views:` entry this seam refuses. Deliberately a SHAPE-CLASS + // judgement, not a full `ViewSchema.parse`: which class an entry + // belongs to is this seam's contract; whether a container's + // internals are well-formed stays the authoring/publish doors' + // job (defineStack, `os validate`, the metadata door). + if (key === 'views' && !isViewContainerShaped(toRegister)) { + const err: Error & { code?: string; status?: number } = new Error( + `Invalid \`views:\` entry '${itemName}' from ${sourceLabel} '${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.', + ); + err.code = 'INVALID_METADATA'; + err.status = 422; + throw err; + } this._registry.registerItem(pluralToSingular(key), toRegister, 'name' as any, ownerId); // "Object has-many View" (ADR-0017): a `defineView` document // aggregates an object's views. Register the container under the @@ -3975,6 +4012,46 @@ export class ObjectQL implements IObjectQLEngine { } } } + + // [#5320] The `viewItems:` channel — the declared entry for the + // NON-container view artifacts a runtime-ASSEMBLED manifest carries + // (tenant-authored standalone ViewItems, flattened overlays, expanded + // items a travelling container cannot re-derive). Written by the manifest + // assemblers (`partitionAssembledViewArtifacts` — package export, the + // artifact factories); refused at the authoring door (`defineStack` types + // the key `never`), so only machine-assembled manifests legitimately + // reach here carrying it. Strictly schema'd: each entry is judged by + // `AssembledViewArtifactSchema` and the PARSED body is what registers, so + // an undeclared bag neither passes nor rides through — declared = + // enforced, in both directions. + const assembledItems = (source as any)?.[ASSEMBLED_VIEW_ITEMS_KEY]; + if (Array.isArray(assembledItems) && assembledItems.length > 0) { + this.logger.debug(`Registering ${ASSEMBLED_VIEW_ITEMS_KEY} from ${sourceLabel}`, { id: ownerId, count: assembledItems.length }); + for (const item of assembledItems) { + const parsed = AssembledViewArtifactSchema.safeParse(item); + if (!parsed.success) { + const itemName = resolveMetadataItemName('views', item) ?? '(unnamed)'; + const err: Error & { code?: string; status?: number } = new Error( + `Invalid \`${ASSEMBLED_VIEW_ITEMS_KEY}:\` entry '${itemName}' from ${sourceLabel} '${ownerId}': ` + + 'the assembled-manifest channel carries non-container view artifacts only — a ViewItem ' + + 'record (`viewKind` + `config`) or a flattened list/form overlay ' + + '(`AssembledViewArtifactSchema`, @objectstack/spec). A view CONTAINER travels in `views:`. ' + + `First issue: ${parsed.error.issues[0]?.message ?? 'no issue detail'}`, + ); + err.code = 'INVALID_METADATA'; + err.status = 422; + throw err; + } + const body = parsed.data as Record; + const itemName = resolveMetadataItemName('views', body); + if (!itemName) { + this.logger.warn('Skipping viewItems entry without a derivable name', { id: ownerId }); + continue; + } + const toRegister = body.name === itemName ? body : { ...body, name: itemName }; + this._registry.registerItem('view', toRegister, 'name' as any, ownerId); + } + } } /** diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 7451a141f5..e508171893 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -11,6 +11,13 @@ import { CoreServiceName } from '@objectstack/spec/system'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +// [#5320] A's mechanical half of the fork ruling: on export, view artifacts +// are PARTITIONED — containers travel in `views:`, expanded items a travelling +// container re-derives exactly are folded away (the import side's own +// expansion recreates them), and everything else (tenant-authored standalone +// ViewItems, flattened overlays, edited expanded items) travels under the +// declared `viewItems:` channel the registration loop ingests. +import { ASSEMBLED_VIEW_ITEMS_KEY, partitionAssembledViewArtifacts } from '@objectstack/spec/ui'; import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; @@ -953,6 +960,20 @@ context: HttpProtocolContext, continue; } if (items.length === 0) continue; + // [#5320] `views` is partitioned rather than dumped: the registry's + // ADR-0017 dual-read returns the container AND its expanded per-view + // items, and the stack `views:` vocabulary (container-only) refuses the + // expanded ones. Containers go to `views:`; expanded items the + // container re-derives exactly are dropped (`folded` — the importing + // loop's own expansion recreates them); the rest — standalone + // ViewItems, overlays, edited expansions — go to `viewItems:`. + if (plural === 'views') { + const { views, viewItems } = partitionAssembledViewArtifacts(items.map(clean)); + if (views.length > 0) manifest[plural] = views; + if (viewItems.length > 0) manifest[ASSEMBLED_VIEW_ITEMS_KEY] = viewItems; + total += views.length + viewItems.length; + continue; + } manifest[plural] = items.map(clean); total += items.length; }