Skip to content

Commit 89be40c

Browse files
Jack Qclaude
andauthored
feat(objectql,runtime,lint): tighten views: to the container-only contract; assembled manifests travel non-container view artifacts in viewItems: (#8572)
* feat(spec): declare the viewItems channel for non-container view artifacts in assembled manifests (#5320) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL * feat(spec): export the shared container classifier, ADR-0122 Parsed alias, root re-exports, INVALID_METADATA provenance row (#5320) - 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL * merge origin/main (os-regen artifacts taken from main; regeneration follows) * chore(spec): regenerate api-surface/export-origins after merging origin/main (#5320) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL * feat(objectql,runtime,lint): views: tighten + viewItems ingestion + export partition + lint alignment (#5320) - 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec74646 commit 89be40c

7 files changed

Lines changed: 396 additions & 18 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/lint": patch
5+
---
6+
7+
feat(objectql,runtime,lint): tighten `views:` to the declared container-only contract; assembled manifests travel non-container view artifacts in `viewItems:` (#5320, #8070)
8+
9+
The registration loop (`registerApp` / nested-plugin seam) used to register
10+
EVERY `views:` entry as type `view` — wider than the stack schema, which has
11+
always declared containers only. The three gates now agree (#5320, ruled
12+
2026-08-12):
13+
14+
- **objectql**: a non-container `views:` entry (ViewItem record, flattened
15+
overlay, inline config) is REFUSED with the ADR-0112 envelope
16+
(`INVALID_METADATA` / 422) and the wrap-it prescription. The declared entry
17+
for machine-assembled non-container artifacts is the new `viewItems:`
18+
channel: each entry is validated against `AssembledViewArtifactSchema` and
19+
the parsed body registers — declared = enforced in both directions.
20+
- **runtime**: `GET /packages/:id/export` partitions view artifacts
21+
(`partitionAssembledViewArtifacts`): containers travel in `views:`, expanded
22+
items the container re-derives exactly are folded away, and standalone
23+
ViewItems / overlays / edited expansions travel in `viewItems:`. The
24+
export→import round trip that previously depended on the undeclared wider
25+
acceptance now survives end to end through the declared channels.
26+
- **lint**: the pre-parse `view-container-shape` rule reaches the same
27+
verdicts — a `viewKind`-bearing `views:` entry is an error with the wrap-it
28+
prescription (it previously skipped them as "registered as-is"), and a
29+
hand-authored `viewItems:` is flagged machine-assembled-only.
30+
31+
Migration: a manifest assembled by an OLDER runtime (an export product carrying
32+
expanded `viewKind` items inside `views:`) is refused on import with the
33+
prescription — re-export the package with a runtime that writes the
34+
`viewItems:` channel. Authored stacks are unaffected: `defineStack` already
35+
refused every shape the loop now refuses.

packages/lint/src/validate-view-containers.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ describe('validateViewContainers (defineView container shape guardrail)', () =>
2121
expect(findings).toHaveLength(0);
2222
});
2323

24-
it('passes an independent ViewItem (viewKind discriminator)', () => {
24+
// [#5320] Inverted from "passes an independent ViewItem": the loader's
25+
// as-is registration of ViewItems from `views:` was the undeclared wider
26+
// acceptance this card removed, and the pre-parse door now reaches the same
27+
// verdict the schema and the registration loop enforce.
28+
it('flags an independent ViewItem in `views:` with the wrap-it prescription (#5320)', () => {
2529
const findings = validateViewContainers({
2630
views: [
2731
{
@@ -32,7 +36,37 @@ describe('validateViewContainers (defineView container shape guardrail)', () =>
3236
},
3337
],
3438
});
35-
expect(findings).toHaveLength(0);
39+
expect(findings).toHaveLength(1);
40+
expect(findings[0]).toMatchObject({
41+
severity: 'error',
42+
rule: VIEW_CONTAINER_SHAPE,
43+
path: 'views[0]',
44+
});
45+
expect(findings[0].where).toContain('task.pipeline');
46+
expect(findings[0].message).toContain('containers only');
47+
expect(findings[0].hint).toContain('defineView');
48+
expect(findings[0].hint).toContain('metadata door');
49+
});
50+
51+
it('flags a hand-authored `viewItems:` as machine-assembled-only (#5320)', () => {
52+
const findings = validateViewContainers({
53+
viewItems: [
54+
{
55+
name: 'task.pipeline',
56+
object: 'task',
57+
viewKind: 'list',
58+
config: { type: 'kanban', columns: ['title'] },
59+
},
60+
],
61+
});
62+
expect(findings).toHaveLength(1);
63+
expect(findings[0]).toMatchObject({
64+
severity: 'error',
65+
rule: VIEW_CONTAINER_SHAPE,
66+
path: 'viewItems',
67+
});
68+
expect(findings[0].message).toContain('machine-assembled');
69+
expect(findings[0].hint).toContain('metadata door');
3670
});
3771

3872
it('flags a flat list-view object with the wrap-it hint', () => {

packages/lint/src/validate-view-containers.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,21 @@
3232
// `os validate` stops at the schema step), `defineStack(x, { strict: false })`,
3333
// and direct API callers.
3434
//
35-
// Independent ViewItems (`viewKind` + `config`) are legal `views: []` entries
36-
// (the loader registers them as-is) and are not flagged.
35+
// ## Independent ViewItems are NOT legal `views: []` entries any more (#5320)
36+
//
37+
// This header used to say a ViewItem (`viewKind` + `config`) "is registered
38+
// as-is by the loader" and skip it. That was a description of the runtime
39+
// loop's UNDECLARED wider acceptance — the exact "runtime wider than schema"
40+
// hole #5320 records — not of the declared contract, which was always
41+
// container-only (`stack.zod.ts`, `z.array(ViewSchema)`). The 2026-08-12 fork
42+
// ruling tightened the loop to the declared contract, so this rule's verdict
43+
// aligns: a `viewKind`-bearing entry in `views:` is now an ERROR with the same
44+
// wrap-it prescription the schema and the loop carry. Standalone views are
45+
// authored through the metadata door; runtime-ASSEMBLED manifests carry
46+
// non-container view artifacts under the machine-only `viewItems:` channel
47+
// (`ui/assembled-views.zod.ts`), which this rule flags when hand-authored —
48+
// the schema refuses it too, but `os lint` never parses, so the pre-parse
49+
// door needs its own voice.
3750

3851
export type ViewContainerSeverity = 'error' | 'warning';
3952

@@ -80,13 +93,51 @@ export function validateViewContainers(stack: Record<string, unknown>): ViewCont
8093
const out: ViewContainerFinding[] = [];
8194
if (!stack || typeof stack !== 'object') return out;
8295

96+
// [#5320] `viewItems:` is the machine-assembled channel, never an authoring
97+
// surface — the stack schema types it `never`, and this pre-parse door says
98+
// the same thing to `os lint` callers the parse never reaches.
99+
const viewItems = (stack as AnyRec).viewItems;
100+
if (viewItems != null && asEntries(viewItems).length > 0) {
101+
out.push({
102+
severity: 'error',
103+
rule: VIEW_CONTAINER_SHAPE,
104+
where: 'viewItems',
105+
path: 'viewItems',
106+
message:
107+
'`viewItems` is the machine-assembled channel for non-container view artifacts in '
108+
+ 'runtime-assembled manifests (package export, environment artifacts) — it is not an '
109+
+ 'authoring surface.',
110+
hint: 'Author views as defineView containers in `views:`; author a standalone view through '
111+
+ 'the metadata door (Studio / `PUT /api/v1/meta/view`), not in stack source.',
112+
});
113+
}
114+
83115
for (const { key, value } of asEntries((stack as AnyRec).views)) {
84116
// Non-object entries are the schema step's problem, not this rule's.
85117
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
86118
const rec = value as AnyRec;
87119

88-
// Independent ViewItem (`viewKind` discriminator) — registered as-is.
89-
if (rec.viewKind != null) continue;
120+
// [#5320] Independent ViewItem (`viewKind` discriminator) in `views:` —
121+
// refused by the schema AND (since the tighten) by the registration loop;
122+
// this rule now reaches the same verdict pre-parse, prescription included.
123+
if (rec.viewKind != null) {
124+
const label = typeof rec.name === 'string' ? ` ("${rec.name}")` : '';
125+
out.push({
126+
severity: 'error',
127+
rule: VIEW_CONTAINER_SHAPE,
128+
where: `views${key}${label}`,
129+
path: `views${key}`,
130+
message:
131+
'A ViewItem record is not a view container: the stack `views:` collection carries '
132+
+ 'containers only — `viewKind` belongs to a single VIEW, not to the container. The '
133+
+ 'registration loop refuses this entry (#5320).',
134+
hint: 'Wrap it in a defineView container: defineView({ list: { type, data, columns, ... }, '
135+
+ 'listViews: { ... } }) — or author the standalone view through the metadata door '
136+
+ '(Studio / `PUT /api/v1/meta/view`). Machine-assembled manifests carry it under '
137+
+ '`viewItems:`.',
138+
});
139+
continue;
140+
}
90141

91142
if (containerViewCount(rec) > 0) continue;
92143

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#5320/#8070] The export→import round trip survives END TO END through the
5+
* declared channels — the fork's acceptance probe, inverted.
6+
*
7+
* The 2026-08-12 fork measured (by execution) that the platform's own package
8+
* export emitted `views:` entries the stack vocabulary refuses — 2 of 3 entries
9+
* in the minimal single-container case — and the round trip survived only
10+
* through the registration loop's undeclared wider acceptance. With the ruling
11+
* landed (B vocabulary + A's re-aggregation + the tighten), the SAME flows must
12+
* survive through the declared channels instead:
13+
*
14+
* register → read back (what `GET /packages/:id/export` reads) → partition
15+
* (`partitionAssembledViewArtifacts`, the assembler's half) → re-import
16+
* through `registerApp` → every view artifact is registered again.
17+
*
18+
* This is the executed probe, not a grep: it runs the real registration loop
19+
* on both ends and the real partition in the middle.
20+
*/
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { partitionAssembledViewArtifacts } from '@objectstack/spec';
24+
import { ObjectQL } from './engine';
25+
26+
const PKG = 'com.acme.sales';
27+
28+
/** Minimal schema-valid container — the fork probe's fixture: default list +
29+
* default form → dual-read registers 3 registry items. */
30+
function accountContainer() {
31+
return {
32+
name: 'account',
33+
object: 'account',
34+
list: { type: 'grid', data: { provider: 'object', object: 'account' }, columns: [{ field: 'name' }] },
35+
form: { type: 'simple', data: { provider: 'object', object: 'account' }, sections: [{ label: 'Info', fields: [{ field: 'name' }] }] },
36+
};
37+
}
38+
39+
/** A tenant-authored standalone ViewItem — legal branch 1 of the `view`
40+
* metadata vocabulary; has NO container to re-aggregate from. */
41+
const STANDALONE = {
42+
name: 'account.hot',
43+
object: 'account',
44+
viewKind: 'list',
45+
config: { type: 'grid', columns: [{ field: 'name' }] },
46+
};
47+
48+
/** What the export path's `clean()` does: strip provenance decorations. */
49+
function clean(item: Record<string, unknown>): Record<string, unknown> {
50+
const out: Record<string, unknown> = {};
51+
for (const [k, v] of Object.entries(item)) {
52+
if (k.startsWith('_')) continue;
53+
out[k] = v;
54+
}
55+
return out;
56+
}
57+
58+
function viewNames(engine: ObjectQL): string[] {
59+
return (engine.registry.listItems<any>('view') ?? []).filter(Boolean).map((v: any) => v.name).sort();
60+
}
61+
62+
describe('export→import round trip through the declared channels (#5320/#8070)', () => {
63+
it('the minimal single-container package survives end to end — all entries land', () => {
64+
// ── source environment ──
65+
const source = new ObjectQL();
66+
source.registerApp({ id: PKG, name: 'sales', views: [accountContainer()] });
67+
// Tenant authors a standalone ViewItem through the metadata door.
68+
source.registry.registerItem('view', { ...STANDALONE }, 'name' as any, PKG);
69+
70+
const sourceNames = viewNames(source);
71+
expect(sourceNames).toEqual(['account', 'account.default', 'account.form', 'account.hot']);
72+
73+
// ── export assembly (what assemblePackageManifest now does for views) ──
74+
const stored = (source.registry.listItems<any>('view') ?? []).filter(Boolean).map(clean);
75+
const { views, viewItems, folded } = partitionAssembledViewArtifacts(stored);
76+
77+
// Predicted directions, stated before running (fork discipline):
78+
// the container travels; its 2 expanded items FOLD (the import side
79+
// re-derives them); the standalone travels in viewItems.
80+
expect(views.map((v) => v.name)).toEqual(['account']);
81+
expect(folded.sort()).toEqual(['account.default', 'account.form']);
82+
expect(viewItems.map((v) => v.name)).toEqual(['account.hot']);
83+
84+
// ── import into a fresh environment ──
85+
const target = new ObjectQL();
86+
target.registerApp({ id: PKG, name: 'sales', views, viewItems });
87+
88+
// END TO END: every view artifact of the source is registered in the target.
89+
expect(viewNames(target)).toEqual(sourceNames);
90+
});
91+
92+
it('a tenant-authored standalone ViewItem survives export→import alone', () => {
93+
const source = new ObjectQL();
94+
source.registerApp({ id: PKG, name: 'sales' });
95+
source.registry.registerItem('view', { ...STANDALONE }, 'name' as any, PKG);
96+
97+
const stored = (source.registry.listItems<any>('view') ?? []).filter(Boolean).map(clean);
98+
const { views, viewItems } = partitionAssembledViewArtifacts(stored);
99+
expect(views).toEqual([]);
100+
expect(viewItems.map((v) => v.name)).toEqual(['account.hot']);
101+
102+
const target = new ObjectQL();
103+
target.registerApp({ id: PKG, name: 'sales', viewItems });
104+
expect(viewNames(target)).toEqual(['account.hot']);
105+
const round = (target.registry.listItems<any>('view') ?? []).find((v: any) => v?.name === 'account.hot');
106+
expect(round.viewKind).toBe('list');
107+
expect(round.config).toEqual(STANDALONE.config);
108+
});
109+
});

packages/objectql/src/engine-nested-plugin-view-expansion.test.ts

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -202,13 +202,18 @@ describe('the expanded per-view identities a nested plugin now produces (#7163)'
202202
});
203203
});
204204

205-
describe('control — a NON-aggregated view is unchanged by this card (#7163)', () => {
205+
describe('a NON-container `views:` entry is REFUSED by both seams (#5320)', () => {
206206
/**
207-
* The fix is scoped by `isAggregatedViewContainer`, which is false for an
208-
* already-independent `ViewItem` (it carries `viewKind`). Such a view must
209-
* register exactly once, under its own name, through BOTH seams — no
210-
* expansion, no new keys. This is what says the change is additive and only
211-
* on the container shape.
207+
* [#5320] REPLACED WHOLESALE, per the fork ruling's fixture disposition.
208+
* The block this replaces was #7163's control: it PINNED that a standalone
209+
* ViewItem in `views:` "registers as-is, through both seams" — i.e. it
210+
* pinned exactly the undeclared runtime-wider acceptance this card removes
211+
* (the stack vocabulary was always container-only, `stack.zod.ts:views`).
212+
* Keeping it would have kept a green assertion over a deleted behaviour;
213+
* loosening it would have judged nothing. It is now the rejection pin:
214+
* both seams refuse the entry with the ADR-0112 envelope (`code` + `status`)
215+
* and the wrap-it prescription, and the declared travel route for
216+
* machine-assembled non-container artifacts is the `viewItems:` channel.
212217
*/
213218
const viewItem = {
214219
name: 'account.hot',
@@ -217,12 +222,58 @@ describe('control — a NON-aggregated view is unchanged by this card (#7163)',
217222
config: { type: 'grid', columns: [{ field: 'name' }] },
218223
};
219224

220-
it('registers a standalone ViewItem identically from both seams, with no expansion', () => {
221-
const direct = boot({ id: PKG, name: 'sales', views: [viewItem] });
222-
const nested = boot({ id: PKG, name: 'sales', plugins: [{ name: 'p', views: [viewItem] }] });
225+
/** Envelope-first assertion: `code` AND `status`, never a bare toThrow. */
226+
function expectRefusal(manifest: unknown) {
227+
let thrown: (Error & { code?: string; status?: number }) | undefined;
228+
try {
229+
boot(manifest);
230+
} catch (e) {
231+
thrown = e as Error & { code?: string; status?: number };
232+
}
233+
expect(thrown, 'registration must refuse, not accept').toBeTruthy();
234+
expect(thrown!.code).toBe('INVALID_METADATA');
235+
expect(thrown!.status).toBe(422);
236+
expect(thrown!.message).toMatch(/containers only/i);
237+
expect(thrown!.message).toContain('defineView');
238+
return thrown!;
239+
}
240+
241+
it('refuses a standalone ViewItem in `views:` from the manifest seam, envelope + prescription', () => {
242+
const err = expectRefusal({ id: PKG, name: 'sales', views: [viewItem] });
243+
// The refusal names the entry, so the author fixes the right view.
244+
expect(err.message).toContain('account.hot');
245+
});
246+
247+
it('refuses identically from the nested-plugin seam (one body, one verdict — #7163 kept)', () => {
248+
expectRefusal({ id: PKG, name: 'sales', plugins: [{ name: 'p', views: [viewItem] }] });
249+
});
223250

224-
expect(viewNames(nested)).toEqual(['account.hot']);
225-
expect(viewNames(nested)).toEqual(viewNames(direct));
251+
it('refuses a flattened overlay in `views:` too (inline config, no container slot)', () => {
252+
expectRefusal({
253+
id: PKG,
254+
name: 'sales',
255+
views: [{ name: 'account.default', object: 'account', viewKind: 'list', type: 'grid', columns: [{ field: 'name' }] }],
256+
});
257+
});
258+
259+
it('accepts the SAME artifact through the declared `viewItems:` channel', () => {
260+
const engine = boot({ id: PKG, name: 'sales', viewItems: [viewItem] });
261+
expect(viewNames(engine)).toEqual(['account.hot']);
262+
const stored = viewItems(engine).find((v: any) => v.name === 'account.hot');
263+
expect(stored.viewKind).toBe('list');
264+
expect(stored.object).toBe('account');
265+
});
266+
267+
it('refuses an undeclared bag in `viewItems:` with the envelope (strict channel, no passthrough)', () => {
268+
let thrown: (Error & { code?: string; status?: number }) | undefined;
269+
try {
270+
boot({ id: PKG, name: 'sales', viewItems: [{ name: 'account.junk', nope: 1 }] });
271+
} catch (e) {
272+
thrown = e as Error & { code?: string; status?: number };
273+
}
274+
expect(thrown, 'the viewItems channel must refuse an undeclared bag').toBeTruthy();
275+
expect(thrown!.code).toBe('INVALID_METADATA');
276+
expect(thrown!.status).toBe(422);
226277
});
227278

228279
it('leaves a container-free manifest with no view items at all', () => {

0 commit comments

Comments
 (0)