diff --git a/.changeset/inline-view-arms-require-object-binding.md b/.changeset/inline-view-arms-require-object-binding.md new file mode 100644 index 0000000000..9c8ab176cc --- /dev/null +++ b/.changeset/inline-view-arms-require-object-binding.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": minor +--- + +The runtime metadata write door for `view` now refuses an inline view config that carries no object binding. The two flattened-overlay members of `ViewMetadataSchema` require `object` and `viewKind` — the pair every object-bound read path (`GET /meta/view?object=`, the view switcher) matches stored rows on — so a flat body like `{ name, type: 'grid', columns: [...] }` is rejected at save time (draft and active alike) with located guidance naming the missing binding and the `defineView({ list: { … } })` wrap remedy, instead of being stored, reported valid, and served by nothing. Console personalization PUTs are unaffected: the write path inherits the binding from the registry entry the overlay shadows before validation. Union membership is unchanged — four arms, same order, same JSON-Schema `anyOf` face; the inline arms' required set is the only change. diff --git a/packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts b/packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts index ddd48515b6..23b9701b73 100644 --- a/packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts +++ b/packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts @@ -189,8 +189,14 @@ describe('#5599 a stored `view` that is not a view is no longer badged valid', ( it('a legitimately-lean overlay is still valid — no collateral badge', () => { // The precondition asks "is this a view at all", never "is it complete". - expect(computeMetadataDiagnostics('view', { isPinned: true })).toEqual({ valid: true }); - expect(computeMetadataDiagnostics('view', { hidden: true })).toEqual({ valid: true }); + // [#7741] "lean" now still carries the object binding: the write path + // inherits `object`/`viewKind` from the shadowed entry (#2555), so a + // stored lean overlay of a REAL view looks exactly like this. + expect(computeMetadataDiagnostics('view', { isPinned: true, object: 'task', viewKind: 'list' })).toEqual({ valid: true }); + expect(computeMetadataDiagnostics('view', { hidden: true, object: 'task', viewKind: 'list' })).toEqual({ valid: true }); + // …while a stored row with NO binding is a row no object-bound read + // path can serve — the #7741 dead row — and is badged invalid now. + expect(computeMetadataDiagnostics('view', { isPinned: true })?.valid).toBe(false); }); it('a stored row of pure identity is no longer valid either', () => { diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts index 4bae6b052e..b6a6ef2f60 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts @@ -195,6 +195,7 @@ const viewBody = (name: string) => ({ name, label: 'Project Tasks', object: 'proj_task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'title', label: 'Title' }], }); diff --git a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts index 82ce72627e..bce30b73bd 100644 --- a/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts +++ b/packages/metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts @@ -198,6 +198,7 @@ const VIEW = { name: 'probe_view', label: 'Probe', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }; diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index c4163409d2..0ac95183e7 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -330,6 +330,7 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { name: 'rc3_probe_view', label: 'Probe', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }, }); @@ -452,6 +453,7 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { name: 'rc3_receipt_view', label: 'Receipt', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }, }, diff --git a/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts b/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts index f5dd92dc49..c4221c14bd 100644 --- a/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts @@ -178,6 +178,7 @@ const VIEW = { name: 'overdue_grid', label: 'Overdue', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }; diff --git a/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts b/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts index 7e702c9247..d49115f125 100644 --- a/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts @@ -201,7 +201,8 @@ const save = (p: ObjectStackProtocolImplementation) => p.saveMetaItem({ type: 'view', name: 'v1', - item: { name: 'v1', label: 'Edited', type: 'grid', columns: ['name'] }, + // [#7741] carries the object binding the inline arm now requires. + item: { name: 'v1', label: 'Edited', type: 'grid', columns: ['name'], object: 'task', viewKind: 'list' }, } as any); const remove = (p: ObjectStackProtocolImplementation) => diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 9141f58dae..26afe865dd 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -266,6 +266,7 @@ const VIEW = { name: 'org_grid', label: 'Org grid', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'title', label: 'Title' }], }; diff --git a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts index 5ab263c193..d291aba810 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -390,7 +390,8 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { type: 'view', name: 'case_grid', organizationId: 'org_alpha', - item: { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'] }, + // [#7741] carries the object binding the inline arm now requires. + item: { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'], object: 'case', viewKind: 'list' }, }); expect(spy).not.toHaveBeenCalled(); }); diff --git a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts index 86f9fc0e12..f4258276b5 100644 --- a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts +++ b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts @@ -144,6 +144,7 @@ const VIEW = { name: 'rc5_probe_view', label: 'Probe', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }; diff --git a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts index e30b15b0fd..ef62a454fc 100644 --- a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts +++ b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts @@ -165,7 +165,8 @@ describe('#5364 saveMetaItem 422 expands union branches', () => { const { protocol, rows } = makeProtocol(); const result = await save(protocol, { - name: 'task_list', object: 'task', type: 'grid', label: 'Tasks', + // [#7741] carries the object binding the inline arm now requires. + name: 'task_list', object: 'task', viewKind: 'list', type: 'grid', label: 'Tasks', columns: [{ field: 'title' }], }); diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 2c6494307b..05b4a2fe70 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -50,6 +50,7 @@ const validView = { name: 'case_grid', label: 'Cases', object: 'case', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [ { field: 'name', label: 'Name' }, { field: 'status', label: 'Status' }, @@ -401,6 +402,7 @@ describe('canonical hash stability (PR-10b backfill precondition)', () => { it('hashSpec: stable across key reorder for a view payload', () => { const reordered = { columns: validView.columns, + viewKind: validView.viewKind, // [#7741] rides with the binding pair object: validView.object, label: validView.label, name: validView.name, diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts index ba16dd8a7b..dfdb67236f 100644 --- a/packages/objectql/src/protocol-commit-history.test.ts +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -397,6 +397,7 @@ function makeRealRepoHarness(seedCommits: any[] = [], opts: { controlPlane?: boo */ const gridBody = (label: string) => ({ name: 'myapp_case_grid', type: 'grid', label, columns: ['id', 'title'], + object: 'case', viewKind: 'list', // [#7741] the inline arm requires the object binding pair }); /** v1 authored in the package workspace, then the edit the commit recorded. */ diff --git a/packages/objectql/src/protocol-lock-enforcement.test.ts b/packages/objectql/src/protocol-lock-enforcement.test.ts index 4da5da96ae..faf17ebce3 100644 --- a/packages/objectql/src/protocol-lock-enforcement.test.ts +++ b/packages/objectql/src/protocol-lock-enforcement.test.ts @@ -60,6 +60,7 @@ function seedLockedArtifact( name, label: name, object: 'case', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], _packageId: '@objectstack/test-fixture', _packageVersion: '1.0.0', @@ -74,6 +75,7 @@ const validView = { name: 'case_grid', label: 'Cases', object: 'case', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }; diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index d3dc7313de..a4beaca165 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -26,6 +26,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { name: 'test_grid', type: 'grid', label: 'Test Grid', + object: 'test_task', // [#7741] the inline arm requires the object binding pair + viewKind: 'list', columns: ['id', 'title'], }; @@ -420,6 +422,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { name: 'all_leads', label: 'All Leads', type: 'grid', + object: 'lead', // [#7741] the inline arm requires the object binding pair + viewKind: 'list', data: { provider: 'object', object: 'lead' }, columns: ['first_name', 'last_name'], }; @@ -1707,7 +1711,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { it('artifact-backed view (allowOrgOverride:true) still overlays cleanly', async () => { // Regression: types that DO allow overlays must keep working // even when the item is artifact-backed. - const viewBase = { name: 'case_grid', type: 'grid' as const, object: 'case', columns: [{ field: 'name' }] }; + // [#7741] the inline arm requires the object binding pair. + const viewBase = { name: 'case_grid', type: 'grid' as const, object: 'case', viewKind: 'list' as const, columns: [{ field: 'name' }] }; registry.registerItem('view', viewBase, 'name' as any, 'crm-plugin'); mockEngine.findOne.mockResolvedValue(null); diff --git a/packages/objectql/src/protocol-object-overlay-layer.test.ts b/packages/objectql/src/protocol-object-overlay-layer.test.ts index 50358931af..c15bfee983 100644 --- a/packages/objectql/src/protocol-object-overlay-layer.test.ts +++ b/packages/objectql/src/protocol-object-overlay-layer.test.ts @@ -575,7 +575,7 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a const { protocol } = makeSession(); const res = await protocol.saveMetaItem({ type: 'view', name: 'shared_grid', packageId: OTHER_PKG, - item: { name: 'shared_grid', type: 'grid', columns: ['name'] }, + item: { name: 'shared_grid', type: 'grid', columns: ['name'], object: 'task', viewKind: 'list' }, // [#7741] the inline arm requires the object binding pair }); expect(res.success).toBe(true); }); diff --git a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts index 9a617b0166..82729a8ed7 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -114,6 +114,7 @@ const viewBody = (name: string, label: string) => ({ name, label, object: 'showcase_task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }); diff --git a/packages/objectql/src/protocol-publish-rollback.test.ts b/packages/objectql/src/protocol-publish-rollback.test.ts index b14e78d0ac..7eb6e51ffb 100644 --- a/packages/objectql/src/protocol-publish-rollback.test.ts +++ b/packages/objectql/src/protocol-publish-rollback.test.ts @@ -163,6 +163,7 @@ function makeStubEngine() { describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const sampleBody = (label: string) => ({ name: 'case_grid', type: 'grid', label, columns: ['id', 'title'], + object: 'case', viewKind: 'list', // [#7741] the inline arm requires the object binding pair }); it('saveMetaItem with mode=draft creates a draft row, active read sees published', async () => { @@ -325,11 +326,11 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ type: 'view', name: 'case_grid', organizationId: 'org_alpha', - item: { name: 'case_grid', type: 'grid', label: 'A', columns: ['id'] }, + item: { name: 'case_grid', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, // [#7741] the inline arm requires the object binding pair }); await protocol.saveMetaItem({ type: 'view', name: 'case_grid', organizationId: 'org_alpha', - item: { name: 'case_grid', type: 'grid', label: 'B', columns: ['id', 'title'], extra: 1 }, + item: { name: 'case_grid', type: 'grid', label: 'B', columns: ['id', 'title'], extra: 1, object: 'case', viewKind: 'list' }, // [#7741] the inline arm requires the object binding pair }); const diff = await protocol.diffMetaItem({ type: 'view', name: 'case_grid', organizationId: 'org_alpha', diff --git a/packages/objectql/src/protocol-recorded-by-null.test.ts b/packages/objectql/src/protocol-recorded-by-null.test.ts index 2d6804e031..0f6acf66ff 100644 --- a/packages/objectql/src/protocol-recorded-by-null.test.ts +++ b/packages/objectql/src/protocol-recorded-by-null.test.ts @@ -159,7 +159,7 @@ function makeStubDriver() { return { driver, stores }; } -const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'] }); +const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair describe('#4556 — protocol write paths store NULL, not the sentinel string', () => { let engine: ObjectQL; diff --git a/packages/objectql/src/protocol-revert-org-scope.test.ts b/packages/objectql/src/protocol-revert-org-scope.test.ts index 530c85fee2..e6b10cfc3b 100644 --- a/packages/objectql/src/protocol-revert-org-scope.test.ts +++ b/packages/objectql/src/protocol-revert-org-scope.test.ts @@ -186,7 +186,7 @@ function makeStubDriver() { const PKG = 'app.revertscope'; const ORG = 'org_x'; -const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'] }); +const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair describe('#7559 — the revert reads the history row under the key the writer stored it with', () => { let engine: ObjectQL; diff --git a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts index e4775b257e..2248fd4435 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path-real-engine.test.ts @@ -128,7 +128,7 @@ function makeStubDriver() { } function viewBody(label: string) { - return { name: 'cases', type: 'grid', label, columns: ['id'] }; + return { name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }; // [#7741] the inline arm requires the object binding pair } describe('saveMetaItem — repository write path against real ObjectQL (PR-10d.4)', () => { diff --git a/packages/objectql/src/protocol-save-meta-repo-path.test.ts b/packages/objectql/src/protocol-save-meta-repo-path.test.ts index 2008a5e917..acdf1e3c82 100644 --- a/packages/objectql/src/protocol-save-meta-repo-path.test.ts +++ b/packages/objectql/src/protocol-save-meta-repo-path.test.ts @@ -104,7 +104,7 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { type: 'view', name: 'case_grid', organizationId: 'org_alpha', - item: { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'] }, + item: { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'], object: 'case', viewKind: 'list' }, }); expect(result.success).toBe(true); expect((result as any).seq).toBeGreaterThan(0); @@ -116,7 +116,7 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { it('repository path writes the checksum and surfaces seq', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - const body = { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'] }; + const body = { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'], object: 'case', viewKind: 'list' }; const result = await protocol.saveMetaItem({ type: 'view', name: 'case_grid', @@ -134,11 +134,11 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const protocol = new ObjectStackProtocolImplementation(engine); const r1 = await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', - item: { name: 'view_one', type: 'grid', label: 'A', columns: ['id'] }, + item: { name: 'view_one', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, }); const r2 = await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', - item: { name: 'view_one', type: 'grid', label: 'B', columns: ['id'] }, + item: { name: 'view_one', type: 'grid', label: 'B', columns: ['id'], object: 'case', viewKind: 'list' }, }); expect((r1 as any).seq).toBe(1); expect((r2 as any).seq).toBe(2); @@ -152,13 +152,13 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { // First write establishes a HEAD. await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', - item: { name: 'view_one', type: 'grid', label: 'A', columns: ['id'] }, + item: { name: 'view_one', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }, }); // Second write with an explicit stale parentVersion → conflict. await expect( protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', - item: { name: 'view_one', type: 'grid', label: 'B', columns: ['id'] }, + item: { name: 'view_one', type: 'grid', label: 'B', columns: ['id'], object: 'case', viewKind: 'list' }, parentVersion: 'sha256:notTheCurrentHead', }), ).rejects.toMatchObject({ @@ -170,7 +170,7 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { it('repository path no-ops when body is identical (idempotent put)', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - const body = { name: 'view_one', type: 'grid', label: 'A', columns: ['id'] }; + const body = { name: 'view_one', type: 'grid', label: 'A', columns: ['id'], object: 'case', viewKind: 'list' }; const r1 = await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org', item: body, }); @@ -189,11 +189,11 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { const protocol = new ObjectStackProtocolImplementation(engine); await protocol.saveMetaItem({ type: 'view', name: 'v', - item: { name: 'view_one', type: 'grid', label: 'env-wide', columns: ['id'] }, + item: { name: 'view_one', type: 'grid', label: 'env-wide', columns: ['id'], object: 'case', viewKind: 'list' }, }); await protocol.saveMetaItem({ type: 'view', name: 'v', organizationId: 'org_alpha', - item: { name: 'view_one', type: 'grid', label: 'org_alpha', columns: ['id'] }, + item: { name: 'view_one', type: 'grid', label: 'org_alpha', columns: ['id'], object: 'case', viewKind: 'list' }, }); // Two rows: one with organization_id=null, one with org_alpha. expect(rows.size).toBe(2); @@ -210,7 +210,7 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { type: 'views', name: 'case_grid', organizationId: 'org', - item: { name: 'case_grid', type: 'grid', label: 'OK', columns: ['id'] }, + item: { name: 'case_grid', type: 'grid', label: 'OK', columns: ['id'], object: 'case', viewKind: 'list' }, }); expect(result.success).toBe(true); const row = Array.from(rows.values())[0]; @@ -255,7 +255,7 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { type: 'view', name: 'cases', organizationId: 'org_x', - item: { name: 'cases', type: 'grid', label: 'Original', columns: ['id'] }, + item: { name: 'cases', type: 'grid', label: 'Original', columns: ['id'], object: 'case', viewKind: 'list' }, }); const beforeBody = (Array.from(rows.values())[0] as any).metadata; @@ -265,7 +265,7 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { type: 'view', name: 'cases', organizationId: 'org_x', - item: { name: 'cases', type: 'grid', label: 'Mutated (should not land)', columns: ['id'] }, + item: { name: 'cases', type: 'grid', label: 'Mutated (should not land)', columns: ['id'], object: 'case', viewKind: 'list' }, parentVersion: 'sha256:stale', }), ).rejects.toMatchObject({ code: 'METADATA_CONFLICT', status: 409 }); @@ -378,7 +378,7 @@ describe('saveMetaItem — repository write path (post PR-10d.6)', () => { organizationId: 'org_alpha', packageId: 'app.objectstack.hotcrm', mode: 'draft', - item: { name: 'case_grid', type: 'grid', label: 'Cases (org overlay)', columns: ['id', 'title'] }, + item: { name: 'case_grid', type: 'grid', label: 'Cases (org overlay)', columns: ['id', 'title'], object: 'case', viewKind: 'list' }, }); const create = inserts.find((d) => d.type === 'view' && d.name === 'case_grid'); expect(create).toBeTruthy(); diff --git a/packages/objectql/src/protocol-view-identity-overlay.test.ts b/packages/objectql/src/protocol-view-identity-overlay.test.ts index 1cc66c4793..e8c39eee25 100644 --- a/packages/objectql/src/protocol-view-identity-overlay.test.ts +++ b/packages/objectql/src/protocol-view-identity-overlay.test.ts @@ -217,19 +217,39 @@ describe('view overlay identity (#2555)', () => { expect(Array.from(rows.values()).some((r) => r.type === 'view')).toBe(true); }); - it('write path stays a plain name-stamp when the registry has no entry to inherit from', async () => { + it('[#7741] write path REFUSES the adhoc PUT when the registry has no entry to inherit from', async () => { + // ⚠️ Deliberate inversion (ruled 2026-08-12, direction B). This test + // used to pin `success: true` for exactly this call — a raw config PUT + // on a name no registry entry shadows, persisted as a plain name-stamp + // with no identity. QA run #7695 measured what that row is: stored, + // badged `valid: true`, and served by NO object-bound read path (the + // switcher filters `v.viewKind && v.object`). The inline arms now + // require the binding pair, and with no baseline to inherit it from, + // the save 422s with the located guidance instead of minting the dead + // row. A shadowING adhoc PUT (baseline present) still saves — that is + // the first test in this describe. const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - const result = await protocol.saveMetaItem({ - type: 'view', - name: 'adhoc.view', - item: { ...personalization }, - }); - expect(result.success).toBe(true); - const row = Array.from(rows.values()).find((r) => r.type === 'view'); - const persisted = JSON.parse(row!.metadata); - expect(persisted.name).toBe('adhoc.view'); - expect('viewKind' in persisted).toBe(false); + let caught: any; + try { + await protocol.saveMetaItem({ + type: 'view', + name: 'adhoc.view', + item: { ...personalization }, + }); + } catch (e) { + caught = e; + } + expect(caught).toBeTruthy(); + // The ADR-0112 envelope, not a bare throw. + expect(caught.code).toBe('INVALID_METADATA'); + expect(caught.status).toBe(422); + // The located prescription reaches the 422's issues. + const issues = JSON.stringify(caught.issues ?? []); + expect(issues).toContain('names no `object`'); + expect(issues).toContain('defineView({ list: { type, data, columns,'); + // …and nothing was stored. + expect(Array.from(rows.values()).filter((r) => r.type === 'view')).toHaveLength(0); }); // ── #5599 — the write path's spec gate was bypassable by ANY body ──────── diff --git a/packages/objectql/src/publish-meta-response-conformance.test.ts b/packages/objectql/src/publish-meta-response-conformance.test.ts index 72f38bb022..6fe8d819b0 100644 --- a/packages/objectql/src/publish-meta-response-conformance.test.ts +++ b/packages/objectql/src/publish-meta-response-conformance.test.ts @@ -147,7 +147,7 @@ async function makeProtocol() { return new ObjectStackProtocolImplementation(engine); } -const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'] }); +const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair /** * A `seed` body, which is what makes `seedApplied` appear on the response. It diff --git a/packages/objectql/src/save-meta-response-conformance.test.ts b/packages/objectql/src/save-meta-response-conformance.test.ts index 59f69402c0..0325e6510a 100644 --- a/packages/objectql/src/save-meta-response-conformance.test.ts +++ b/packages/objectql/src/save-meta-response-conformance.test.ts @@ -152,7 +152,7 @@ async function makeProtocol() { return new ObjectStackProtocolImplementation(engine); } -const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'] }); +const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' }); // [#7741] the inline arm requires the object binding pair /** Keys the producer emitted that the schema refused to carry through. */ function strippedKeys(raw: Record): string[] { diff --git a/packages/rest/src/meta-write-actor-identity.test.ts b/packages/rest/src/meta-write-actor-identity.test.ts index 692fa014a5..8084cdd26c 100644 --- a/packages/rest/src/meta-write-actor-identity.test.ts +++ b/packages/rest/src/meta-write-actor-identity.test.ts @@ -116,6 +116,7 @@ const VIEW = (name: string) => ({ name, label: 'Actor probe', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }); diff --git a/packages/rest/src/rest-meta-save-receipt-envelope.test.ts b/packages/rest/src/rest-meta-save-receipt-envelope.test.ts index c45499a09f..26507925c5 100644 --- a/packages/rest/src/rest-meta-save-receipt-envelope.test.ts +++ b/packages/rest/src/rest-meta-save-receipt-envelope.test.ts @@ -106,6 +106,7 @@ const VIEW = (name: string) => ({ name, label: 'Probe', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }); diff --git a/packages/runtime/src/meta-field-overlay-lock.test.ts b/packages/runtime/src/meta-field-overlay-lock.test.ts index 9d287e2545..117c958f13 100644 --- a/packages/runtime/src/meta-field-overlay-lock.test.ts +++ b/packages/runtime/src/meta-field-overlay-lock.test.ts @@ -118,6 +118,7 @@ const VIEW = { name: 'showcase_task.in_progress', label: 'In Progress', object: 'showcase_task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'title', label: 'Title' }], _packageId: 'com.example.showcase', }; diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 961b19af9c..820ce253d8 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -274,6 +274,7 @@ const VIEW = { name: 'overdue_grid', label: 'Overdue', object: 'task', + viewKind: 'list', // [#7741] the inline arm requires the object binding pair columns: [{ field: 'name', label: 'Name' }], }; diff --git a/packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts b/packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts index ffac5d857a..6d67572269 100644 --- a/packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts +++ b/packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts @@ -142,6 +142,8 @@ const viewBody = (name: string, label = name, object = 'anything') => ({ name, label, type: 'grid', + object, // [#7741] the inline arm requires the object binding pair + viewKind: 'list', data: { provider: 'object', object }, columns: ['id'], }); diff --git a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts index cd40861e3b..d582f35119 100644 --- a/packages/runtime/src/package-list-commits-org-scope.integration.test.ts +++ b/packages/runtime/src/package-list-commits-org-scope.integration.test.ts @@ -143,6 +143,8 @@ const viewBody = (name: string, label: string) => ({ name, label, type: 'grid', + object: 'anything', // [#7741] the inline arm requires the object binding pair + viewKind: 'list', data: { provider: 'object', object: 'anything' }, columns: ['id'], }); diff --git a/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts b/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts index bc33752883..0f19de1851 100644 --- a/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts +++ b/packages/runtime/src/package-revert-commit-org-scope.integration.test.ts @@ -159,6 +159,8 @@ const viewBody = (name: string) => ({ name, label: name, type: 'grid', + object: 'anything', // [#7741] the inline arm requires the object binding pair + viewKind: 'list', data: { provider: 'object', object: 'anything' }, columns: ['id'], }); diff --git a/packages/runtime/src/package-uninstall-org-scope.integration.test.ts b/packages/runtime/src/package-uninstall-org-scope.integration.test.ts index d818719958..7c565a4eb9 100644 --- a/packages/runtime/src/package-uninstall-org-scope.integration.test.ts +++ b/packages/runtime/src/package-uninstall-org-scope.integration.test.ts @@ -138,6 +138,8 @@ const viewBody = (name: string) => ({ name, label: name, type: 'grid', + object: 'anything', // [#7741] the inline arm requires the object binding pair + viewKind: 'list', data: { provider: 'object', object: 'anything' }, columns: ['id'], }); diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index fbedd81f13..71fdcc93fc 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -99,6 +99,11 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // standalone ViewItem record, flattened personalization overlay). The bare // container `ViewSchema` strip-parsed ViewItem/personalization bodies to `{}`, // making save-time 422 validation and read-time diagnostics a no-op for them. + // [#7741] The flattened overlay arms REQUIRE the `object` + `viewKind` + // binding (ruled 2026-08-12, draft and active alike): an inline config that + // cannot say which object it attaches to would be stored, badged valid, and + // served by no read path. Every consumer of this entry — saveMetaItem's 422 + // gate and the read-time diagnostics badge — inherits that refusal here. view: ViewMetadataSchema, page: PageSchema, dashboard: DashboardSchema, diff --git a/packages/spec/src/ui/view-authoring-wire-split.test.ts b/packages/spec/src/ui/view-authoring-wire-split.test.ts index af725548b2..8cb59b25f0 100644 --- a/packages/spec/src/ui/view-authoring-wire-split.test.ts +++ b/packages/spec/src/ui/view-authoring-wire-split.test.ts @@ -278,8 +278,20 @@ describe('#5074 — the wire door accepts what the platform itself writes', () = // `updateView` PUTs `{ ...current, ...partial }`; when there is no stored // item to merge, `current` is empty and the body is the bare partial. // `isPinned`/`sortOrder` are declared on member 1, so they are vocabulary. - accept(ViewMetadataSchema, { isPinned: true }); - accept(ViewMetadataSchema, { sortOrder: 3 }); + // + // [#7741] "Reaches the union" is still the claim — but the UNION now + // refuses the baseline-less bare partial (no `object`/`viewKind` to inherit + // means the saved row could never be served), so the pin is split in two: + // the refusal must be the members' located binding guidance, never the + // precondition's "not a view"; and the same partial as the write path + // actually delivers it (identity inherited from the shadowed entry, + // #2555) parses clean. + for (const partial of [{ isPinned: true }, { sortOrder: 3 }]) { + const msg = reject(ViewMetadataSchema, partial); + expect(msg).not.toContain('Not a `view` body'); + expect(msg).toContain('names no `object`'); + accept(ViewMetadataSchema, { ...partial, object: 'showcase_task', viewKind: 'list' }); + } }); it('#5599 — …but a body speaking NO view key is stopped before any member runs', () => { diff --git a/packages/spec/src/ui/view-inline-object-binding.test.ts b/packages/spec/src/ui/view-inline-object-binding.test.ts new file mode 100644 index 0000000000..617d498914 --- /dev/null +++ b/packages/spec/src/ui/view-inline-object-binding.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7741] The runtime write door refuses an inline view config that carries no + * object binding — maintainer ruling 2026-08-12, direction B. + * + * ## What QA run #7695 measured + * + * `PUT /api/v1/meta/view/` with `{ name, type: 'grid', columns: […], + * data: {…} }` — a single view's config written where the container belongs — + * returned 200 in BOTH draft and active mode, published clean, and read back + * with `_diagnostics: { valid: true }`. Meanwhile `expandViewContainer(...)` + * returned `[]` and `GET /meta/view?object=…` omitted the row: registered, + * reported valid, renders nothing. The same body handed to `defineView()` + * throws the located "Wrap it: `defineView({ list: { … } })`" guidance — the + * two doors disagreed in front of the same author. + * + * ## The ruled fix, and why the binding is a PAIR + * + * The inline (flattened overlay) arms of `ViewMetadataSchema` now REQUIRE + * `object` + `viewKind`. Both, because that pair is what the object-bound read + * paths actually filter on — measured, not assumed: + * `packages/rest/src/rest-server.ts` (`GET /meta/view?object=` → + * `v.viewKind && v.object === obj`) and + * `packages/metadata/src/metadata-manager.ts` (`getViewsByObject()`, same + * predicate). Requiring `object` alone would refuse the card's repro and then + * instruct the author into a SECOND dead row — bound by `object`, still + * invisible to the switcher for want of `viewKind`. + * + * ## Draft and active alike + * + * The ruling: 「draft 与 active 同样适用 …… 现在没有这个证据,不预留」. The pin + * lives at the schema layer on purpose: `getMetadataTypeSchema('view')` is the + * single entry `saveMetaItem` validates against for BOTH `mode: 'draft'` and + * `mode: 'publish'` saves (ADR-0005 §Validation), so there is no draft-shaped + * side door for the schema to miss. Whether some transport skips validation + * entirely is a transport question a spec test cannot see. + * + * ## Reverse verification — direction decided before it was run + * + * Expected direction: restoring the two fields to `.optional()` turns the + * card's repro body GREEN again through this same door (acceptance direction, + * the plain before/after) while `defineView` keeps throwing — re-opening + * exactly the two-door disagreement above. Observed on this branch (temporary + * `git checkout origin/main -- src/ui/view.zod.ts` after committing the fix): + * the repro parses `success: true` on the old schema, `success: false` on the + * new one; no inverted or count-shaped surprises. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { ViewMetadataSchema, diagnoseViewMetadata, defineView } from './view.zod'; + +/** The card's exact repro body (#7741 / QA run #7695), verbatim shape. */ +const REPRO = { + name: 'qa_dead_view', + type: 'grid', + columns: ['title', 'status'], + data: { provider: 'object', object: 'showcase_task' }, +}; + +/** The same inline config, properly bound. */ +const BOUND = { ...REPRO, object: 'showcase_task', viewKind: 'list' }; + +const door = () => getMetadataTypeSchema('view')!; + +function refusalText(body: unknown): string { + const r = door().safeParse(body); + expect(r.success, `expected the door to REFUSE ${JSON.stringify(body)}`).toBe(false); + return JSON.stringify((r as { error?: { issues?: unknown } }).error?.issues ?? []); +} + +describe('[#7741] the runtime write door refuses the unbound inline view config', () => { + it('REFUSES the card\'s exact repro body through getMetadataTypeSchema(\'view\')', () => { + const r = door().safeParse(REPRO); + expect(r.success).toBe(false); + }); + + it('the refusal is LOCATED: the claimed inline arm carries it at `object` / `viewKind`', () => { + const r = door().safeParse(REPRO); + expect(r.success).toBe(false); + if (r.success) return; + const root = r.error.issues[0] as unknown as { + code: string; + errors: Array>; + }; + // The union envelope other consumers key on (ADR-0112 at the issue level). + expect(root.code).toBe('invalid_union'); + expect(root.errors).toHaveLength(4); + // Branch 2 is the listOverlay the body claims (`type: 'grid'`); #7510's + // focusing mutes the other three, so the author reads ONLY the binding + // prescription, at the paths of the keys to add. + const claimed = root.errors[2]!; + expect(claimed.map((i) => i.path)).toEqual([['object'], ['viewKind']]); + for (const index of [0, 1, 3]) { + expect(root.errors[index]).toHaveLength(1); + expect(root.errors[index]![0]!.message).toContain('this body reads as `listOverlay`'); + } + }); + + it('…naming the offending shape and both halves of the remedy (bind, record, or wrap)', () => { + const text = refusalText(REPRO); + // The offending shape, by name. + expect(text).toContain('inline view config'); + // The binding remedy, with the measured reason a binding is required. + expect(text).toContain('names no `object`'); + expect(text).toContain('GET /meta/view?object='); + expect(text).toContain('add `object:'); + // The record alternative. + expect(text).toContain('{ name, object, viewKind, config: { … } }'); + // The wrap remedy — the SAME prose family `defineView` throws for this + // body (the ruling: the write door reuses the build path's guidance). + expect(text).toContain('Wrap it: `defineView({ list: { type, data, columns, … } })`'); + expect(text).toContain('defineView({ listViews: { my_view: { … } } })'); + }); + + it('the guidance is the same prose family the build door throws for the repro', () => { + // The contrast the card measured in step 5 of its reproduction: same body, + // `defineView` door. Both doors now speak the wrap remedy. + expect(() => defineView(REPRO as never)).toThrow(/defineView\(\{ list: \{ type, data, columns/); + }); + + it('a HALF binding is refused too — `object` alone or `viewKind` alone', () => { + // `object` without `viewKind` is precisely the second dead row the pair + // requirement exists to prevent (invisible to the switcher's + // `v.viewKind && v.object === obj` filter). + expect(refusalText({ ...REPRO, object: 'showcase_task' })).toContain('names no `viewKind`'); + expect(refusalText({ ...REPRO, viewKind: 'list' })).toContain('names no `object`'); + }); + + it('the form-family inline arm refuses the same way', () => { + const text = refusalText({ name: 'qa_dead_form', type: 'wizard' }); + expect(text).toContain('names no `object`'); + }); + + it('diagnoseViewMetadata names the inline branch, so consumers render the binding guidance', () => { + const d = diagnoseViewMetadata(REPRO); + expect(d.success).toBe(false); + if (d.success) return; + expect(d.branch).toBe('listOverlay'); + expect(d.issues.map((i) => i.path)).toEqual([['object'], ['viewKind']]); + }); +}); + +describe('[#7741] what the door still accepts, byte for byte', () => { + it('ACCEPTS the properly bound inline body — and adds nothing to it', () => { + const r = door().safeParse(BOUND); + expect(r.success, JSON.stringify((r as { error?: { issues?: unknown } }).error?.issues)).toBe(true); + if (!r.success) return; + expect(r.data).toEqual(BOUND); + }); + + it('ACCEPTS the ViewItem record arm byte-identically', () => { + const record = { + name: 'showcase_task.all', + object: 'showcase_task', + viewKind: 'list', + config: { type: 'grid', columns: ['title'], data: { provider: 'object', object: 'showcase_task' } }, + }; + const r = door().safeParse(record); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data).toEqual(record); + }); + + it('ACCEPTS the container arm byte-identically — #6391\'s union membership is intact', () => { + const container = { + object: 'showcase_task', + list: { type: 'grid', columns: ['title'], data: { provider: 'object', object: 'showcase_task' } }, + }; + const r = door().safeParse(container); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data).toEqual(container); + }); + + it('ACCEPTS the post-normalize personalization PUT (identity inherited from the shadowed entry)', () => { + // What `normalizeViewMetadata` + `viewIdentityPatch` (#2555) actually hand + // this schema for a console column-sort PUT on a real view. + const r = door().safeParse({ + type: 'grid', + data: { provider: 'object', object: 'showcase_task' }, + columns: ['title'], + sort: [{ field: 'estimate_hours', order: 'desc' }], + name: 'showcase_task.default', + viewKind: 'list', + object: 'showcase_task', + label: 'All Tasks', + }); + expect(r.success).toBe(true); + }); +}); + +describe('[#7741] the emitted contract declares what it enforces', () => { + it('the inline arms\' JSON Schema marks `object` and `viewKind` required, in both io directions', () => { + // Studio's SchemaForm is generated from this — declared = enforced means + // an AI author is TOLD the binding is required before the 422 says so. + for (const io of ['output', 'input'] as const) { + const json = z.toJSONSchema(door(), { unrepresentable: 'any', io }) as { + anyOf?: Array<{ required?: string[] }>; + }; + expect(json.anyOf).toHaveLength(4); + for (const member of [json.anyOf![2]!, json.anyOf![3]!]) { + expect(member.required ?? []).toEqual(expect.arrayContaining(['object', 'viewKind'])); + } + } + }); + + it('ViewMetadataSchema and the kernel registry entry are one schema — both modes, one verdict', () => { + // `saveMetaItem` resolves BOTH draft and publish saves through + // `getMetadataTypeSchema('view')`; pinning the identity here is the + // schema-level half of "draft 与 active 同样适用". + expect(door()).toBe(ViewMetadataSchema); + expect(ViewMetadataSchema.safeParse(REPRO).success).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/view-metadata-schema.test.ts b/packages/spec/src/ui/view-metadata-schema.test.ts index 7a71edca24..2e552ae834 100644 --- a/packages/spec/src/ui/view-metadata-schema.test.ts +++ b/packages/spec/src/ui/view-metadata-schema.test.ts @@ -122,7 +122,15 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha expect(r.success).toBe(true); }); - it('accepts a raw list config with NO identity (adhoc PUT, no registry entry to inherit from)', () => { + it('REJECTS a raw list config with NO object binding — the #7741 dead row, with located guidance', () => { + // ⚠️ Deliberate inversion (#7741, ruled 2026-08-12, direction B). This + // test used to PIN acceptance of exactly this body ("adhoc PUT, no + // registry entry to inherit from") — and QA run #7695 measured what that + // acceptance produced: a stored row badged `valid: true` that expands to + // nothing and no object-bound read path can serve. The inline arms now + // REQUIRE `object` + `viewKind` (the pair `GET /meta/view?object=` and + // `getViewsByObject()` filter on), so the adhoc body is refused at the + // door with the same wrap guidance `defineView` gives. const r = ViewMetadataSchema.safeParse({ type: 'grid', data: { provider: 'object', object: 'showcase_task' }, @@ -130,7 +138,14 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha sort: [{ field: 'estimate_hours', order: 'desc' }], name: 'adhoc.view', }); - expect(r.success).toBe(true); + expect(r.success).toBe(false); + if (r.success) return; + const text = JSON.stringify(r.error.issues); + // The refusal is the MEMBERS' located guidance, not the identity + // precondition's "not a view" — the body genuinely speaks view keys. + expect(r.error.issues[0]!.code).toBe('invalid_union'); + expect(text).toContain('names no `object`'); + expect(text).toContain('Wrap it: `defineView({ list: { type, data, columns, … } })`'); }); it('accepts a raw form config overlay', () => { @@ -236,24 +251,56 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha it('fails CLOSED, not open — it does not reject everything', () => { // Guards against the mirror-image defect: a precondition that rejects the // platform's own writes is strictly worse than the hole it closed. - expect(ViewMetadataSchema.safeParse({ type: 'simple' }).success).toBe(true); + // [#7741] The minimal accepted overlay now carries its binding pair. + expect(ViewMetadataSchema.safeParse({ type: 'simple', object: 'crm_lead', viewKind: 'form' }).success).toBe(true); }); // Every shape the platform itself writes carries a declared key, so the // precondition is inert on all of them. These are the acceptance inputs the // #5074 trace established for `updateView`'s `{ ...current, ...partial }`. + // + // [#7741] Re-judged fixture by fixture when the inline arms gained the + // `object` + `viewKind` binding requirement. The realistic write-path body + // is the POST-normalize one: `saveMetaItem` runs `normalizeViewMetadata` + // before validation and `viewIdentityPatch` (#2555) inherits + // `viewKind`/`object`/`label` from the registry entry the overlay shadows — + // an expanded ViewItem always carries both — so a console PUT on a REAL + // view arrives bound, exactly as spelled here. The precondition-inertness + // claim is asserted separately below on the bare, baseline-less shapes. it.each([ - ['a pin PUT with no stored item to merge', { isPinned: true }], - ['a switcher-reorder PUT', { sortOrder: 3 }], - ['a column-only overlay', { columns: ['name'] }], - ['a filter-only overlay', { filter: [{ field: 'name', operator: 'contains', value: 'x' }] }], - ['a hide PUT', { hidden: true }], - ['an order-only overlay', { order: 2 }], - ['a renamed view that still carries its config', { label: 'New name', columns: ['name'] }], + ['a pin PUT with no stored item to merge', { isPinned: true, object: 'crm_lead', viewKind: 'list' }], + ['a switcher-reorder PUT', { sortOrder: 3, object: 'crm_lead', viewKind: 'list' }], + ['a column-only overlay', { columns: ['name'], object: 'crm_lead', viewKind: 'list' }], + ['a filter-only overlay', { filter: [{ field: 'name', operator: 'contains', value: 'x' }], object: 'crm_lead', viewKind: 'form' }], + ['a hide PUT', { hidden: true, object: 'crm_lead', viewKind: 'form' }], + ['an order-only overlay', { order: 2, object: 'crm_lead', viewKind: 'form' }], + ['a renamed view that still carries its config', { label: 'New name', columns: ['name'], object: 'crm_lead', viewKind: 'list' }], ])('leaves %s alone', (_label, body) => { expect(ViewMetadataSchema.safeParse(body).success).toBe(true); }); + // [#7741] The same shapes with NO baseline to inherit identity from are now + // refused — that save would mint an unservable row badged `valid: true`, + // the exact receipt the ruling forbids. What this block pins is the + // REFUSAL'S OWNER: it is the union members' located binding guidance + // (`invalid_union`), never the identity precondition's "not a view" text — + // these bodies DO speak view vocabulary, so the precondition stays inert on + // them exactly as the block above always claimed. + it.each([ + ['a baseline-less pin PUT', { isPinned: true }], + ['a baseline-less reorder PUT', { sortOrder: 3 }], + ['a baseline-less column-only overlay', { columns: ['name'] }], + ['a baseline-less hide PUT', { hidden: true }], + ])('REFUSES %s at the members, not the precondition', (_label, body) => { + const r = ViewMetadataSchema.safeParse(body); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues[0]!.code).toBe('invalid_union'); + const text = JSON.stringify(r.error.issues); + expect(text).not.toContain('Not a `view` body'); + expect(text).toContain('names no `object`'); + }); + // ── identity is not shape ──────────────────────────────────────────────── // The subtraction that makes the precondition bite on the WRITE path. // `saveMetaItem` normalizes before it validates: `normalizeViewMetadata` @@ -293,8 +340,10 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha }); it('…but identity PLUS any real view key is fine — leanness is not the bar', () => { - expect(ViewMetadataSchema.safeParse({ name: 'v', object: 'o', hidden: true }).success).toBe(true); - expect(ViewMetadataSchema.safeParse({ name: 'v', viewKind: 'list', isPinned: true }).success).toBe(true); + // [#7741] "identity" here means the FULL binding pair: `object` alone or + // `viewKind` alone is half a binding and the members refuse it now. + expect(ViewMetadataSchema.safeParse({ name: 'v', object: 'o', viewKind: 'form', hidden: true }).success).toBe(true); + expect(ViewMetadataSchema.safeParse({ name: 'v', object: 'o', viewKind: 'list', isPinned: true }).success).toBe(true); }); it('leaves non-objects to the union — it judges objects only', () => { @@ -309,7 +358,9 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha it('derives its vocabulary from the members, so a new arm key is admitted automatically', () => { // Not a hand-written list: every top-level key any member declares counts. // `splitSize` is a FormView key nobody would think to allow-list by hand. - expect(ViewMetadataSchema.safeParse({ splitSize: 30 }).success).toBe(true); + // ([#7741] full acceptance additionally needs the binding pair; the + // vocabulary question this test asks is unchanged.) + expect(ViewMetadataSchema.safeParse({ splitSize: 30, object: 'crm_lead', viewKind: 'form' }).success).toBe(true); // …and a key that exists only NESTED (inside `config`) is not top-level // vocabulary, so it cannot smuggle a garbage body through. expect(ViewMetadataSchema.safeParse({ groupByField: 'stage' }).success).toBe(false); @@ -319,8 +370,9 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha // The ruling on #5599 kept every arm's `.strip()`: a body that speaks the // vocabulary still carries undeclared aux keys through without a 422. // This is the deliberate residue of the minimal fix, pinned so a later - // batch cannot mistake it for an oversight. - const r = ViewMetadataSchema.safeParse({ isPinned: true, someFutureStudioKey: 'x' }); + // batch cannot mistake it for an oversight. ([#7741] bound form — the + // openness under test is about UNKNOWN keys, not the binding pair.) + const r = ViewMetadataSchema.safeParse({ isPinned: true, someFutureStudioKey: 'x', object: 'crm_lead', viewKind: 'list' }); expect(r.success).toBe(true); }); }); diff --git a/packages/spec/src/ui/view-union-branch-focus.test.ts b/packages/spec/src/ui/view-union-branch-focus.test.ts index 8a4fc401b5..120693d79a 100644 --- a/packages/spec/src/ui/view-union-branch-focus.test.ts +++ b/packages/spec/src/ui/view-union-branch-focus.test.ts @@ -256,17 +256,28 @@ describe('[#7510] ⛔ the acceptance face did not move', () => { // through the same door. The refusals' top-level issue CODES are pinned with // them: focusing happens inside `errors`, so the envelope other consumers key // on is untouched. + // + // [#7741] One deliberate, RULED move since that measurement (2026-08-12, + // direction B): the flattened overlay arms require the `object` + `viewKind` + // binding, so the three formerly-unbound overlay/PUT entries here carry it + // now, and their unbound originals are pinned as REFUSED below. #7510's own + // claim — focusing never changes a verdict — is unaffected and still pinned + // by the rest of this corpus. const ACCEPTED: unknown[] = [ formItem({ field: 'owner', publicPicker: { displayFields: ['name'] } }), { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, { object: 'crm_lead', list: { type: 'grid', columns: ['name'] } }, { object: 'crm_lead', formViews: { my: { type: 'simple' } } }, - { type: 'grid', columns: ['name'], isDefault: true, order: 2 }, - { type: 'simple' }, - { isPinned: true }, + { type: 'grid', columns: ['name'], isDefault: true, order: 2, object: 'crm_lead', viewKind: 'list' }, + { type: 'simple', object: 'crm_lead', viewKind: 'form' }, + { isPinned: true, object: 'crm_lead', viewKind: 'list' }, ]; const REFUSED: Array<[unknown, string[]]> = [ + // [#7741] the unbound originals of the last three ACCEPTED entries. + [{ type: 'grid', columns: ['name'], isDefault: true, order: 2 }, ['invalid_union']], + [{ type: 'simple' }, ['invalid_union']], + [{ isPinned: true }, ['invalid_union']], [MISDIRECTED[0]![1], ['invalid_union']], [MISDIRECTED[3]![1], ['invalid_union']], [{ name: 'a.b', object: 'a', viewKind: 'chart', config: { type: 'grid', columns: ['name'] } }, ['invalid_union']], diff --git a/packages/spec/src/ui/view-union-diagnostics.test.ts b/packages/spec/src/ui/view-union-diagnostics.test.ts index 2a2c53cedd..7f6c06cb27 100644 --- a/packages/spec/src/ui/view-union-diagnostics.test.ts +++ b/packages/spec/src/ui/view-union-diagnostics.test.ts @@ -91,8 +91,11 @@ describe('[#6391] the union members are contractual, not positional', () => { // The route objectui had to reach for `errors[2]` to approximate. expect(VIEW_METADATA_MEMBERS.container.safeParse({ object: 'crm_lead', list: LIST_CFG }).success).toBe(true); expect(VIEW_METADATA_MEMBERS.container.safeParse({ object: 'crm_lead', listViews: {} }).success).toBe(false); - expect(VIEW_METADATA_MEMBERS.listOverlay.safeParse({ type: 'grid', columns: ['name'] }).success).toBe(true); - expect(VIEW_METADATA_MEMBERS.formOverlay.safeParse({ type: 'wizard' }).success).toBe(true); + // [#7741] The overlay arms require the `object` + `viewKind` binding. + expect(VIEW_METADATA_MEMBERS.listOverlay.safeParse({ type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'list' }).success).toBe(true); + expect(VIEW_METADATA_MEMBERS.listOverlay.safeParse({ type: 'grid', columns: ['name'] }).success).toBe(false); + expect(VIEW_METADATA_MEMBERS.formOverlay.safeParse({ type: 'wizard', object: 'crm_lead', viewKind: 'form' }).success).toBe(true); + expect(VIEW_METADATA_MEMBERS.formOverlay.safeParse({ type: 'wizard' }).success).toBe(false); }); }); @@ -130,7 +133,9 @@ describe('[#6391] diagnoseViewMetadata names the failing branch', () => { // `error.issues[0].errors[2]` — member position 2 — while the rendered // message came from the CONTAINER branch (fewest issues wins) and told the // author to wrap the body in `defineView`, which is not the defect. - const BAD_OVERLAY = { type: 'grid', columns: 'not-an-array' }; + // ([#7741] carries the binding pair so the ONE defect under test stays the + // broken `columns`, not a missing binding.) + const BAD_OVERLAY = { type: 'grid', columns: 'not-an-array', object: 'crm_lead', viewKind: 'list' }; it('reports the leaf issue under the branch that owns it', () => { const d = diagnoseViewMetadata(BAD_OVERLAY); @@ -229,18 +234,32 @@ describe('[#6391] diagnoseViewMetadata names the failing branch', () => { // ⛔ #7025's red line, asserted directly. A discriminated union changes error // SHAPE, not membership — so membership is what this block pins, in both // directions, on the corpus the before/after diff was measured over. -describe('[#7025] the acceptance face of ViewMetadataSchema did not move', () => { +// +// [#7741] The acceptance face DID move once since #7025, deliberately and by +// ruling (2026-08-12, direction B): the two flattened overlay arms now require +// the `object` + `viewKind` binding, so every unbound overlay/PUT entry in the +// measured corpus flipped from ACCEPTED to REFUSED. The corpus keeps both +// generations: the bound spelling (what the write path actually delivers after +// `normalizeViewMetadata` inherits identity, #2555) stays in ACCEPTED with its +// parse output re-measured, and the unbound original moves to REFUSED so the +// flip itself is pinned rather than silently absorbed. +describe('[#7025] the acceptance face of ViewMetadataSchema — as re-ruled by #7741', () => { const SECTION = { label: 'Main', collapsible: false, collapsed: false, columns: 1, fields: ['name'] }; + /** [#7741] The binding pair the write path inherits onto a shadowing overlay. */ + const BOUND_LIST = { object: 'crm_lead', viewKind: 'list' } as const; + const BOUND_FORM = { object: 'crm_lead', viewKind: 'form' } as const; /** - * Every entry is a verdict MEASURED on `origin/main` before the change and - * re-measured after — never a verdict predicted from reading the schema. Some - * of them are surprising, and they are in the table precisely because a - * surprising accept is the one a refactor silently loses: + * Every entry is a verdict MEASURED before and after — never a verdict + * predicted from reading the schema. Some of them are surprising, and they + * are in the table precisely because a surprising accept is the one a + * refactor silently loses: * - * - `overlay.badOperator` / `overlay.console*Id` ACCEPT as `{type:'simple'}` — - * the form overlay does not declare `filter`/`sort`, and `.strip()` drops - * them. Pre-existing, load-bearing for Studio's round-trip, and untouched. + * - `overlay.badOperator` / `overlay.console*Id` ACCEPT with the payload + * stripped to the binding + `{type:'simple'}` — the form overlay does not + * declare `filter`/`sort`, and `.strip()` drops them. Pre-existing, + * load-bearing for Studio's round-trip, and untouched by #7741 (openness + * is about unknown keys; the binding is about required ones). * - `overlay.list.min` (`{type:'grid'}` alone) REJECTS. * - `container.empty` REJECTS with `custom`, not `invalid_union`: when * exactly one branch is non-aborted zod's `handleUnionResults` returns THAT @@ -259,20 +278,25 @@ describe('[#7025] the acceptance face of ViewMetadataSchema did not move', () => ['container.listViews', { object: 'crm_lead', listViews: { my: LIST_CFG } }, { object: 'crm_lead', listViews: { my: LIST_CFG } }], ['container.formViews', { object: 'crm_lead', formViews: { my: FORM_CFG } }, { object: 'crm_lead', formViews: { my: { type: 'simple', sections: [SECTION] } } }], - ['overlay.list.columns', { columns: ['name', 'stage'] }, { type: 'grid', columns: ['name', 'stage'] }], - ['overlay.list.aux', { type: 'grid', columns: ['name'], isDefault: true, order: 2, hidden: false }, - { type: 'grid', columns: ['name'], isDefault: true, order: 2, hidden: false }], - ['overlay.form.min', { type: 'simple' }, { type: 'simple' }], - ['overlay.form.sections', { sections: [{ label: 'Main', fields: ['name'] }] }, { type: 'simple', sections: [SECTION] }], + ['overlay.list.columns', { columns: ['name', 'stage'], ...BOUND_LIST }, + { type: 'grid', columns: ['name', 'stage'], ...BOUND_LIST }], + ['overlay.list.aux', { type: 'grid', columns: ['name'], isDefault: true, order: 2, hidden: false, ...BOUND_LIST }, + { type: 'grid', columns: ['name'], isDefault: true, order: 2, hidden: false, ...BOUND_LIST }], + ['overlay.form.min', { type: 'simple', ...BOUND_FORM }, { type: 'simple', ...BOUND_FORM }], + ['overlay.form.sections', { sections: [{ label: 'Main', fields: ['name'] }], ...BOUND_FORM }, + { type: 'simple', sections: [SECTION], ...BOUND_FORM }], ['overlay.form.identity', { name: 'x', object: 'crm_lead', viewKind: 'form', type: 'wizard' }, { type: 'wizard', name: 'x', object: 'crm_lead', viewKind: 'form' }], - ['overlay.badOperator', { filter: [{ field: 'name', operator: 'sorta_equals', value: 'x' }] }, { type: 'simple' }], - ['overlay.consoleSortId', { sort: [{ id: 'row-1', field: 'name', order: 'asc' }] }, { type: 'simple' }], - ['overlay.consoleFilterId', { filter: [{ id: 'row-1', field: 'name', operator: '=', value: 'x' }] }, { type: 'simple' }], - ['put.isPinned', { isPinned: true }, { type: 'simple' }], - ['put.sortOrder', { sortOrder: 3 }, { type: 'simple' }], - ['put.hidden', { hidden: true }, { type: 'simple', hidden: true }], - ['put.pinAndOrder', { isPinned: true, sortOrder: 3 }, { type: 'simple' }], + ['overlay.badOperator', { filter: [{ field: 'name', operator: 'sorta_equals', value: 'x' }], ...BOUND_FORM }, + { type: 'simple', ...BOUND_FORM }], + ['overlay.consoleSortId', { sort: [{ id: 'row-1', field: 'name', order: 'asc' }], ...BOUND_FORM }, + { type: 'simple', ...BOUND_FORM }], + ['overlay.consoleFilterId', { filter: [{ id: 'row-1', field: 'name', operator: '=', value: 'x' }], ...BOUND_FORM }, + { type: 'simple', ...BOUND_FORM }], + ['put.isPinned', { isPinned: true, ...BOUND_LIST }, { type: 'simple', ...BOUND_LIST }], + ['put.sortOrder', { sortOrder: 3, ...BOUND_LIST }, { type: 'simple', ...BOUND_LIST }], + ['put.hidden', { hidden: true, ...BOUND_FORM }, { type: 'simple', hidden: true, ...BOUND_FORM }], + ['put.pinAndOrder', { isPinned: true, sortOrder: 3, ...BOUND_LIST }, { type: 'simple', ...BOUND_LIST }], ]; const REFUSED: Array<[string, unknown, string[]]> = [ @@ -288,6 +312,21 @@ describe('[#7025] the acceptance face of ViewMetadataSchema did not move', () => ['overlay.badType', { type: 'sideways' }, ['invalid_union']], ['overlay.badColumns', { type: 'grid', columns: 'not-an-array' }, ['invalid_union']], ['overlay.emptyState.badKey', { type: 'grid', emptyState: { title: 'None', notAnEmptyStateKey: 1 } }, ['invalid_union']], + // [#7741] The corpus's former unbound ACCEPTED entries, each re-measured: + // an overlay that names no `object`/`viewKind` would be stored as a row no + // object-bound read path can serve, so the members now refuse it with the + // located binding guidance (ruled 2026-08-12, direction B). + ['unbound.overlay.list.columns', { columns: ['name', 'stage'] }, ['invalid_union']], + ['unbound.overlay.list.aux', { type: 'grid', columns: ['name'], isDefault: true, order: 2, hidden: false }, ['invalid_union']], + ['unbound.overlay.form.min', { type: 'simple' }, ['invalid_union']], + ['unbound.overlay.form.sections', { sections: [{ label: 'Main', fields: ['name'] }] }, ['invalid_union']], + ['unbound.overlay.badOperator', { filter: [{ field: 'name', operator: 'sorta_equals', value: 'x' }] }, ['invalid_union']], + ['unbound.overlay.consoleSortId', { sort: [{ id: 'row-1', field: 'name', order: 'asc' }] }, ['invalid_union']], + ['unbound.overlay.consoleFilterId', { filter: [{ id: 'row-1', field: 'name', operator: '=', value: 'x' }] }, ['invalid_union']], + ['unbound.put.isPinned', { isPinned: true }, ['invalid_union']], + ['unbound.put.sortOrder', { sortOrder: 3 }, ['invalid_union']], + ['unbound.put.hidden', { hidden: true }, ['invalid_union']], + ['unbound.put.pinAndOrder', { isPinned: true, sortOrder: 3 }, ['invalid_union']], ['identity.nope', { nope: 1 }, ['custom']], ['identity.empty', {}, ['custom']], ['identity.idOnly', { id: 'x' }, ['custom']], diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 4bf728523e..5479430442 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -783,8 +783,10 @@ describe('FormViewSchema — the `groups` legacy alias folds onto `sections` (#6 }); it('ViewMetadataSchema — the FLATTENED form-overlay member (`.extend()` inherits the fold)', () => { + // [#7741] the overlay arm requires the `object` + `viewKind` binding. const parsed = ViewMetadataSchema.parse({ viewKind: 'form', + object: 'account', type: 'simple', groups: [S('Account')], }) as Record; diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 15e3ba6b42..8c2a67d5ce 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2478,6 +2478,19 @@ export const ObjectUserFiltersSchema = lazySchema(() => strictObject({ export const ObjectListViewSchema = lazySchema(() => ListViewSchema.omit({ userFilters: true }).extend({ userFilters: ObjectUserFiltersSchema.optional() })); +/** + * [#4001/#7741] The wrap remedy, ONE prose source for two doors: the container's + * unknown-key guidance (the build/authoring door below) and the inline overlay + * arms' missing-binding refusals (the runtime write door — + * {@link flattenedViewOverlayFields}). The ruling on #7741 (2026-08-12) requires + * the write door to reuse the build path's existing guidance — 「写门的拒绝复用 + * build 路径已有的 guidance」 — so the sentence is shared rather than forked into + * a second copy that would drift on the first rewording. + */ +const VIEW_WRAP_REMEDY = + 'Wrap it: `defineView({ list: { type, data, columns, … } })`, or name it — ' + + '`defineView({ listViews: { my_view: { … } } })`.'; + /** * Master View Schema * Can define multiple named views. @@ -2485,7 +2498,7 @@ export const ObjectListViewSchema = lazySchema(() => /** * View Container Schema * Aggregates all view definitions for a specific object or context. - * + * * @example * { * list: { type: "grid", columns: ["name"] }, @@ -2527,7 +2540,7 @@ export const ViewSchema = lazySchema(() => strictObject({ // entry is a claim about the schema, and claims need verifying like code. guidance: Object.fromEntries( ['type', 'columns', 'data', 'viewKind', 'filters', 'sort'] - .map((k) => [k, `\`${k}\` belongs to a single VIEW, not to the container. Wrap it: \`defineView({ list: { type, data, columns, … } })\`, or name it — \`defineView({ listViews: { my_view: { … } } })\`. The container's own keys are \`list\`, \`form\`, \`listViews\`, \`formViews\`.`]), + .map((k) => [k, `\`${k}\` belongs to a single VIEW, not to the container. ${VIEW_WRAP_REMEDY} The container's own keys are \`list\`, \`form\`, \`listViews\`, \`formViews\`.`]), ), }, { // Item identity. The container is a registered metadata item, so the door @@ -2925,20 +2938,93 @@ export function defineViewItem(config: z.input): ViewItem // above the object schemas #4001 closed: at union-MEMBER SELECTION, not at // any single member. The fix is a precondition, NOT a strictness flip on the // members — see {@link assertViewIdentity} and {@link viewMetadataVocabulary}. - -/** - * Optional identity + structural-guard fields layered onto the two "flattened - * runtime overlay" members. The `config`/`list`/`form`/`listViews`/`formViews` - * guards pin those keys to `undefined`: a body carrying any of them is a record - * or a container, not a flattened overlay, so it must be validated by the - * dedicated member instead of slipping through here with its real payload - * stripped away. +// +// [#7741] The residue that precondition deliberately let through — a body that +// DOES speak view vocabulary (`{ type: 'grid', columns: […] }`) but names no +// object to attach to — was the same defect one layer further in: accepted, +// persisted, badged valid, served by nothing. Closed at the members this time +// (ruled direction B, 2026-08-12): the two flattened overlay arms now REQUIRE +// `object` + `viewKind` — the exact pair the object-bound read paths filter on +// — with located refusals that reuse the container door's wrap guidance. The +// precondition's bar (speaks the vocabulary at all) and the members' bar +// (bound enough to be servable) are different questions and stay in their own +// stages; a baseline-shadowing personalization PUT arrives here already bound +// because `normalizeViewMetadata` inherits identity before validation (#2555). + +/** + * [#7741] The located refusals the two inline overlay arms give a body that + * arrives at the runtime write door without its object binding. + * + * Why these are REQUIRED rather than optional (maintainer ruling, 2026-08-12, + * direction B): every object-bound read path matches a stored view row on + * `object` AND `viewKind` — `GET /meta/view?object=` (rest-server.ts) and + * `getViewsByObject()` (metadata-manager.ts) both filter + * `v.viewKind && v.object === obj` — so an inline config missing either is + * stored, badged `valid: true`, and then served by nothing: `expandViewContainer` + * never sees it (it is not a container) and the switcher never lists it. The + * ruling: 「一个无法展开、无法被任何读路径服务的行,不允许被存储并盖上 + * `valid:true`」. Both fields, not `object` alone, because the pair is what the + * measured serving filter requires — a row bound by `object` but missing + * `viewKind` is exactly as invisible to the switcher as the card's repro. + * + * The platform's own personalization writes are unaffected: `saveMetaItem` + * normalizes before it validates, and `viewIdentityPatch` + * (`@objectstack/metadata-protocol`, #2555) inherits `viewKind`/`object`/`label` + * from the registry entry the overlay shadows — an expanded ViewItem always + * carries both — so a console pin/sort/hide PUT on a REAL view reaches this + * schema already bound. The body that arrives unbound is the one with no entry + * to inherit from: a new name authored inline, i.e. the dead row this closes. + * + * Draft and active alike, by the same ruling — the schema is mode-agnostic on + * purpose: 「draft 与 active 同样适用 …… 现在没有这个证据,不预留」. + */ +const INLINE_VIEW_OBJECT_REQUIRED = + 'This inline view config names no `object`, so the saved row could never be served: ' + + '`GET /meta/view?object=…` and the view switcher match stored views on `object` + `viewKind`, ' + + 'and a row missing them is registered yet renders nowhere. ' + + 'Bind it — add `object: ""` and `viewKind: "list" | "form"` — or save a ViewItem ' + + 'record (`{ name, object, viewKind, config: { … } }`), or make it a container. ' + + VIEW_WRAP_REMEDY; + +const INLINE_VIEW_KIND_REQUIRED = + 'This inline view config names no `viewKind`, so the saved row could never be served: ' + + '`GET /meta/view?object=…` and the view switcher match stored views on `object` + `viewKind`, ' + + 'and a row missing them is registered yet renders nowhere. ' + + 'Bind it — add `viewKind: "list" | "form"` alongside `object` — or save a ViewItem record ' + + '(`{ name, object, viewKind, config: { … } }`), or make it a container. ' + + VIEW_WRAP_REMEDY; + +/** + * Identity + structural-guard fields layered onto the two "flattened runtime + * overlay" members. The `config`/`list`/`form`/`listViews`/`formViews` guards + * pin those keys to `undefined`: a body carrying any of them is a record or a + * container, not a flattened overlay, so it must be validated by the dedicated + * member instead of slipping through here with its real payload stripped away. + * + * [#7741] `object` and `viewKind` are REQUIRED on these two members — see the + * refusal constants above for the measured reason and the ruling. The rest of + * the identity fields stay optional: they are display/round-trip state, not + * what any read path filters on. */ function flattenedViewOverlayFields() { return { name: z.string().optional().describe('Save name / qualified view id (stamped by the write path).'), - object: z.string().optional().describe('Bound object name (inherited from the shadowed entry — #2555).'), - viewKind: ViewKindSchema.optional().describe('View family (inherited from the shadowed entry — #2555).'), + object: z + .string({ error: (issue) => (issue.input === undefined ? INLINE_VIEW_OBJECT_REQUIRED : undefined) }) + .describe( + 'Bound object name — REQUIRED on an inline view config (#7741): the object-bound read paths ' + + '(`GET /meta/view?object=`, the view switcher) match on `object` + `viewKind`, so an unbound ' + + 'row can never be served. Inherited from the shadowed entry on personalization PUTs (#2555).', + ), + viewKind: z + .enum(ViewKindSchema.options, { + error: (issue) => (issue.input === undefined ? INLINE_VIEW_KIND_REQUIRED : undefined), + }) + .describe( + 'View family — REQUIRED on an inline view config (#7741): half of the `object` + `viewKind` ' + + 'pair the object-bound read paths match on. Inherited from the shadowed entry on ' + + 'personalization PUTs (#2555).', + ), label: I18nLabelSchema.optional().describe('Display label (inherited from the shadowed entry — #2555).'), isDefault: z.boolean().optional(), order: z.number().int().optional(), @@ -3066,7 +3152,11 @@ function speaksViewVocabulary(body: unknown): boolean { * whether the view is *complete* — which is what keeps it compatible with every * lean shape the platform round-trips. A pin PUT (`{ isPinned: true }`), a hide * PUT (`{ hidden: true }`), a reorder (`{ sortOrder: 3 }`) and a column-sort PUT - * all carry declared non-identity keys and are unaffected. + * all carry declared non-identity keys and are unaffected *by this precondition* + * ([#7741] the UNION may still refuse the baseline-less ones — an overlay with + * no shadowed entry to inherit `object`/`viewKind` from now fails the members' + * binding requirement, which is the ruled behaviour, and the refusal is the + * members' located guidance rather than this precondition's). * * "Complete" is deliberately NOT the bar, and the distinction is the whole * reason this is safe: `{ isPinned: true }` is not a renderable view either, but @@ -3203,6 +3293,10 @@ const ViewContainerWireSchema = lazySchema(() => * ledger names this as the trap to watch while batching: a response-side * extension of an authoring schema must strip back, or an upstream field * addition becomes a crash. + * + * [#7741] `object` + `viewKind` are required on this arm (and its form + * sibling) — see {@link flattenedViewOverlayFields} for the ruling and the + * measured serving filter that decides exactly this pair. */ const ListViewOverlayWireSchema = lazySchema(() => ListViewSchema.extend(flattenedViewOverlayFields()).strip(),