Skip to content
35 changes: 35 additions & 0 deletions .changeset/views-tighten-assembled-channel.md
Original file line numberDiff line numberDiff line change
@@ -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.
38 changes: 36 additions & 2 deletions packages/lint/src/validate-view-containers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [
{
Expand All@@ -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', () => {
Expand Down
59 changes: 55 additions & 4 deletions packages/lint/src/validate-view-containers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -80,13 +93,51 @@ export function validateViewContainers(stack: Record<string, unknown>): 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;

Expand Down
109 changes: 109 additions & 0 deletions packages/objectql/src/engine-assembled-views-roundtrip.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<any>('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<any>('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<any>('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<any>('view') ?? []).find((v: any) => v?.name === 'account.hot');
expect(round.viewKind).toBe('list');
expect(round.config).toEqual(STANDALONE.config);
});
});
73 changes: 62 additions & 11 deletions packages/objectql/src/engine-nested-plugin-view-expansion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand All@@ -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', () => {
Expand Down
Loading
Loading