From 6b2d5a041fff1f164fba23c1f3baeaba655dc40a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 10:50:35 +0000 Subject: [PATCH 1/3] wip: export objectNavTargetExclusivity and fix the filters docblock Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .../export-object-nav-target-exclusivity.md | 9 + .../app-nav-target-exclusivity-export.test.ts | 293 ++++++++++++++++++ packages/spec/src/ui/app.zod.ts | 30 +- 3 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 .changeset/export-object-nav-target-exclusivity.md create mode 100644 packages/spec/src/ui/app-nav-target-exclusivity-export.test.ts diff --git a/.changeset/export-object-nav-target-exclusivity.md b/.changeset/export-object-nav-target-exclusivity.md new file mode 100644 index 0000000000..68d081edca --- /dev/null +++ b/.changeset/export-object-nav-target-exclusivity.md @@ -0,0 +1,9 @@ +--- +"@objectstack/spec": minor +--- + +`objectNavTargetExclusivity` — the object-level check on an object navigation item that refuses `filters` combined with `recordId` / `viewName`, and `runAction` combined with `recordId` — is now EXPORTED from `@objectstack/spec/ui`, one function per refinement in the same posture as the `check*` exports. A hand-written mirror of the object nav item chains this very function in its own `superRefine` instead of restating the rule from prose; a restatement is what drifts (objectui's mirror accepted `filters` + `recordId` while the spec refused it). + +**What moves for consumers: one new export.** No schema's accept set moves. `NavigationItemSchema` chains the check exactly where it did — its `type: 'object'` branch — and the exported `ObjectNavItemSchema` still does not chain it: which schema mounts the check is a separate question from whether a mirror can, and it is not decided here. The two deliberate asymmetries are unchanged and now pinned: `recordId` + `viewName` stays a tolerated legacy combination, and `runAction` is refused with `recordId` only (it still composes with `filters` / `viewName`). + +**Also corrected, in the same file:** the `filters` docblock stated a complete precedence order (`recordId` → `filters` → `viewName`) a few lines above saying the combination is unrepresentable, and the mirror copied that first half. The docblock now states only what the guard refuses, says in as many words that no precedence order is stated and why, and names the one legacy combination the guard tolerates. The `.describe()` strings — what reaches the generated references — are unchanged. diff --git a/packages/spec/src/ui/app-nav-target-exclusivity-export.test.ts b/packages/spec/src/ui/app-nav-target-exclusivity-export.test.ts new file mode 100644 index 0000000000..d7f79d387b --- /dev/null +++ b/packages/spec/src/ui/app-nav-target-exclusivity-export.test.ts @@ -0,0 +1,293 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16714] `objectNavTargetExclusivity` is EXPORTED, and the export IS the + * check the navigation door runs — the spec half of the objectui mirror gap. + * + * Why: objectui's `NavigationItemSchema` is hand-written (not `.shape`-derived, + * so the #16489 mechanism does not reach it) and its own `superRefine` checks + * only `id` / `label`. Its `filters` prose copied a precedence order from this + * module's docblock, so that door accepted `filters` + `recordId` while the + * spec's refused it. A mirror needs the rule as a function it can chain, and + * that function must be the schema's own — never a copy that can drift. + * + * What this file pins, and what each leg proves: + * + * 1. PARITY over the check's whole matrix — every distinct failure path and + * every accepting path — between the export called directly with a + * collecting ctx and `NavigationItemSchema`'s own parse. The accepting + * paths are the ruling's negative controls: `filters` alone, `recordId` + * alone, `viewName` alone and `recordId` + `viewName` all still pass, and + * so do `runAction` + `filters` / `viewName`. Those last five are the two + * deliberate asymmetries: an implementation that made every target field + * pairwise exclusive would turn the refusal legs green while refusing + * configurations that are legal today. + * 2. MOUNT — the `type: 'object'` branch of the union inside + * `NavigationItemSchema` carries exactly one `custom` check, and that + * check's issue vector over the matrix equals the export's. The exported + * `ObjectNavItemSchema` carries NO check and accepts every fixture, + * including the refused ones: exporting the function moved no accept set. + * A later ruling that mounts the guard there flips that pin deliberately. + * 3. ATTACHMENT BY IDENTIFIER — the module declares the export exactly once + * and chains it by name (`.superRefine(objectNavTargetExclusivity)`) + * exactly once, read from the source, so the door cannot be running an + * inline copy that merely agrees on this matrix. + * 4. BARREL identity — `./index` (what `@objectstack/spec/ui` ships) exports + * the very same function object, with the `(value, ctx)` arity. + * + * What it does NOT prove, stated so nobody reads it in: reference identity + * between the export and the check object the schema holds — zod 4 wraps the + * function handed to `superRefine` in a closure and keeps no handle to it. + * Legs 2 + 3 are the substitute, the same one `object-refinement-check-exports.test.ts` + * uses for the `check*` family. + * + * The fixtures are SHAPE-VALID on purpose (ids of two characters or more, + * every key declared) and the parse leg throws otherwise, so "both refuse" + * can never be true for the wrong reason. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { NavigationItemSchema, ObjectNavItemSchema, objectNavTargetExclusivity } from './app.zod'; +import * as ui from './index'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +interface IssueSig { + code: string; + path: string; + message: string; +} + +interface RawIssueLike { + code?: string; + path?: readonly PropertyKey[]; + message?: string; +} + +const sig = (i: RawIssueLike): IssueSig => ({ + code: String(i.code), + path: (i.path ?? []).map(String).join('.'), + message: String(i.message), +}); + +const sorted = (issues: IssueSig[]): IssueSig[] => + [...issues].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + +/** Leg (a): call the export directly with a collecting ctx. */ +function runExport(value: unknown): IssueSig[] { + const issues: RawIssueLike[] = []; + const ctx = { + value, + issues, + addIssue: (issue: string | RawIssueLike) => { + issues.push(typeof issue === 'string' ? { code: 'custom', message: issue } : { code: 'custom', ...issue }); + }, + }; + objectNavTargetExclusivity(value as never, ctx as unknown as z.RefinementCtx); + return sorted(issues.map(sig)); +} + +interface ZodCheckLike { + _zod: { + def: { check: string }; + check: (payload: { value: unknown; issues: RawIssueLike[] }) => unknown; + }; +} + +/** The check objects a schema actually holds — read through the lazySchema proxy where there is one. */ +function checksOf(schema: unknown): ZodCheckLike[] { + const def = (schema as { _zod: { def: { checks?: ZodCheckLike[] } } })._zod.def; + return def.checks ?? []; +} + +/** Leg (b): run ONE of the schema's own check objects in isolation. */ +function runCheckObject(check: ZodCheckLike, value: unknown): IssueSig[] { + const payload = { value, issues: [] as RawIssueLike[] }; + check._zod.check(payload); + return sorted(payload.issues.map(sig)); +} + +/** Leg (c): a schema's full parse, restricted to object-level (`custom`) issues. + * Throws when the fixture is not shape-valid — see the header. */ +function runParse(schema: { safeParse: (v: unknown) => z.ZodSafeParseResult }, value: unknown): IssueSig[] { + const r = schema.safeParse(value); + if (r.success) return []; + const foreign = r.error.issues.filter((i) => i.code !== 'custom'); + if (foreign.length > 0) { + throw new Error(`fixture is not shape-valid — the object-level check never ran: ${JSON.stringify(foreign)}`); + } + return sorted(r.error.issues.map((i) => sig(i as RawIssueLike))); +} + +/** + * The `type: 'object'` branch of the discriminated union `NavigationItemSchema` + * lazily resolves to — the one place this module chains the guard. Located by + * the branch's `type` literal, never by position. + */ +function objectBranchOf(schema: unknown): unknown { + const getter = (schema as { _zod: { def: { getter: () => unknown } } })._zod.def.getter; + const union = getter() as { _zod: { def: { type: string; options: unknown[] } } }; + expect(union._zod.def.type).toBe('union'); + const branches = union._zod.def.options.filter((opt) => { + const literal = (opt as { _zod: { def: { shape: { type: { _zod: { def: { values: unknown[] } } } } } } }) + ._zod.def.shape.type._zod.def.values; + return literal.includes('object'); + }); + expect(branches).toHaveLength(1); + return branches[0]; +} + +/** One issue vector per runner over the matrix — the equality's key. */ +const vectorOf = (run: (value: unknown) => IssueSig[], values: unknown[]): string => + JSON.stringify(values.map(run)); + +// --------------------------------------------------------------------------- +// Fixture matrix — one entry per distinct failure path, plus every accepting path +// --------------------------------------------------------------------------- + +interface Fixture { + label: string; + value: Record; + /** The paths the check is expected to refuse at; `[]` is an accepting path. */ + refusesAt: string[]; +} + +const NAV = { id: 'nav_tickets', label: 'Tickets', type: 'object', objectName: 'ticket' } as const; + +const fixtures: Fixture[] = [ + // Refusals — the guard's two rules, every distinct path. + { label: '`filters` + `recordId`', value: { ...NAV, filters: { status: 'open' }, recordId: '{current_user_id}' }, refusesAt: ['filters'] }, + { label: '`filters` + `viewName`', value: { ...NAV, filters: { status: 'open' }, viewName: 'by_status' }, refusesAt: ['filters'] }, + { + label: '`filters` + `recordId` + `viewName` (one issue, not two)', + value: { ...NAV, filters: { status: 'open' }, recordId: '{current_user_id}', viewName: 'all' }, + refusesAt: ['filters'], + }, + { label: '`runAction` + `recordId`', value: { ...NAV, runAction: 'create_ticket', recordId: '{current_user_id}' }, refusesAt: ['runAction'] }, + { + label: 'both rules at once — `filters` + `runAction` + `recordId`', + value: { ...NAV, filters: { status: 'open' }, runAction: 'create_ticket', recordId: '{current_user_id}' }, + refusesAt: ['filters', 'runAction'], + }, + // Negative controls (the ruling's item 4) — each target field alone. + { label: '`filters` alone', value: { ...NAV, filters: { owner_id: '{current_user_id}', status: 'open' } }, refusesAt: [] }, + { label: '`recordId` alone', value: { ...NAV, recordId: '{current_user_id}' }, refusesAt: [] }, + { label: '`viewName` alone', value: { ...NAV, viewName: 'all' }, refusesAt: [] }, + { label: 'no target field at all (the default view)', value: { ...NAV }, refusesAt: [] }, + // Asymmetry (i): the legacy combination stays tolerated. + { label: '`recordId` + `viewName` (tolerated legacy combination)', value: { ...NAV, recordId: '{current_user_id}', viewName: 'all' }, refusesAt: [] }, + // Asymmetry (ii): `runAction` is refused with `recordId` ONLY. + { label: '`runAction` alone', value: { ...NAV, runAction: 'create_ticket' }, refusesAt: [] }, + { label: '`runAction` + `filters`', value: { ...NAV, runAction: 'create_ticket', filters: { status: 'open' } }, refusesAt: [] }, + { label: '`runAction` + `viewName`', value: { ...NAV, runAction: 'create_ticket', viewName: 'all' }, refusesAt: [] }, +]; + +const matrix = fixtures.map((f) => f.value); +const refusing = fixtures.filter((f) => f.refusesAt.length > 0); +const accepting = fixtures.filter((f) => f.refusesAt.length === 0); + +// --------------------------------------------------------------------------- +// Leg 1 — parity on every fixture between the export and the door +// --------------------------------------------------------------------------- + +describe('objectNavTargetExclusivity — parity between the export and NavigationItemSchema', () => { + it.each(fixtures)('$label — the direct call refuses at exactly the declared paths', ({ value, refusesAt }) => { + const direct = runExport(value); + expect(direct.map((i) => i.path).sort()).toEqual([...refusesAt].sort()); + for (const issue of direct) expect(issue.code).toBe('custom'); + }); + + it.each(fixtures)('$label — the NavigationItemSchema parse and the direct call agree issue for issue', ({ value }) => { + expect(runParse(NavigationItemSchema, value)).toEqual(runExport(value)); + }); + + it('the matrix has both kinds of path, so agreement is not vacuous', () => { + expect(refusing.length).toBeGreaterThanOrEqual(5); + expect(accepting.length).toBeGreaterThanOrEqual(8); + }); + + it('the refusal message carries the fix, not only the verdict', () => { + const [filtersIssue] = runExport({ ...NAV, filters: { status: 'open' }, recordId: 'r_1' }); + expect(filtersIssue.message).toContain('pick ONE landing'); + const [runActionIssue] = runExport({ ...NAV, runAction: 'create_ticket', recordId: 'r_1' }); + expect(runActionIssue.message).toMatch(/runAction.*cannot be combined with.*recordId/s); + }); +}); + +// --------------------------------------------------------------------------- +// Leg 2 — the mount: where the check lives, and where it deliberately does not +// --------------------------------------------------------------------------- + +describe('the mount — the union branch carries the check, the exported ObjectNavItemSchema does not', () => { + it("the `type: 'object'` branch of NavigationItemSchema carries exactly one `custom` check", () => { + const checks = checksOf(objectBranchOf(NavigationItemSchema)); + expect(checks.map((c) => c._zod.def.check)).toEqual(['custom']); + }); + + it('that check is behaviourally identical to the export over the whole matrix', () => { + const [check] = checksOf(objectBranchOf(NavigationItemSchema)); + expect(vectorOf((v) => runCheckObject(check, v), matrix)).toBe(vectorOf(runExport, matrix)); + // …and the export is not a no-op on its own matrix (an all-empty vector + // would match any dead check). + expect(vectorOf(runExport, matrix)).not.toBe(vectorOf(() => [], matrix)); + }); + + it('ObjectNavItemSchema carries no object-level check — the export moved no accept set (#16714 ruling)', () => { + // Deliberate non-change: which schema mounts the check is a separate + // question from whether a mirror can chain it, and it is NOT decided by + // this export. A later ruling that mounts the guard on the exported + // schema flips this pin on purpose; until then every fixture on the + // matrix — the refused ones included — is accepted here. + expect(checksOf(ObjectNavItemSchema)).toEqual([]); + for (const { value } of fixtures) { + expect(ObjectNavItemSchema.safeParse(value).success).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// Leg 3 — attached by identifier, in the module that declares the schema +// --------------------------------------------------------------------------- + +describe('app.zod.ts attaches the export BY IDENTIFIER — no inline copy', () => { + const src = fs.readFileSync(path.join(HERE, 'app.zod.ts'), 'utf8'); + // An attachment is a CODE line that begins (after indentation) with + // `.superRefine(name)` — the door chains the check on its own line; a + // docblock naming the same spelling sits on a ` * ` line and is not counted. + const attachments = (name: string): number => + src.match(new RegExp(`^[ \\t]*\\.superRefine\\(${name}\\)`, 'gm'))?.length ?? 0; + // A NAME is only a sound key for that count if the module declares it exactly + // once — a second, shadowing binding would satisfy the count while the door + // chains a different function object. + const declarations = (name: string): number => + src.match(new RegExp(`^\\s*(export )?function ${name}\\b`, 'gm'))?.length ?? 0; + + it('declares `export function objectNavTargetExclusivity(` exactly once', () => { + expect(src).toContain('export function objectNavTargetExclusivity('); + expect(declarations('objectNavTargetExclusivity')).toBe(1); + }); + + it('chains it exactly once — on the union branch, by name', () => { + expect(attachments('objectNavTargetExclusivity')).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Leg 4 — the barrel ships the same function object +// --------------------------------------------------------------------------- + +describe('`./index` (the `@objectstack/spec/ui` surface) exports the same function object', () => { + it('objectNavTargetExclusivity — reference identity, and the `(value, ctx)` arity', () => { + expect((ui as Record).objectNavTargetExclusivity).toBe(objectNavTargetExclusivity); + expect(typeof objectNavTargetExclusivity).toBe('function'); + expect(objectNavTargetExclusivity.length).toBe(2); + }); +}); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index e901ac0934..ea9a5bff13 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -411,12 +411,17 @@ export const ObjectNavItemSchema = lazySchema(() => strictObject(navItemSurface( * parameterized slices (dashboard drill-throughs, "assigned to me" * links); a slice worth curating and reusing belongs in a named view * via `viewName`. Values support the same template variables as - * `recordId`. Precedence: `recordId` → `filters` → `viewName`. + * `recordId`. * * Mutually exclusive with `recordId` / `viewName` — enforced by - * {@link NavigationItemSchema} (see `objectNavTargetExclusivity`) so the - * ambiguous combination is unrepresentable rather than silently resolved - * by precedence. + * {@link objectNavTargetExclusivity}, chained on the `type: 'object'` + * branch of {@link NavigationItemSchema} — so the ambiguous combination is + * unrepresentable. There is deliberately NO precedence order stated here: + * without the guard a consumer would have to resolve the combination by + * picking one field and silently ignoring the rest, and a mirror that + * copies an ordering from this docblock instead of chaining the guard ends + * up accepting what this schema refuses (#16714). The guard's own docblock + * names the one legacy combination it tolerates (`recordId` + `viewName`). */ filters: z.record(z.string(), z.string()).optional().describe( 'URL filter conditions — targets the /:objectName/data bare surface via filter[]= params instead of a saved view. Values support template vars {current_user_id}, {current_org_id}. Mutually exclusive with recordId/viewName.', @@ -461,11 +466,22 @@ export const ObjectNavItemSchema = lazySchema(() => strictObject(navItemSurface( * the message. The legacy `recordId` + `viewName` combination stays * tolerated: it predates this guard and is documented as "viewName is * ignored when recordId is set". + * + * EXPORTED (#16714), one function per refinement, the same posture as the + * `check*` exports of #16489: a hand-written mirror of the object nav item + * chains this very function in its own `superRefine` instead of restating + * the rule from prose — a restatement is what drifts. Its one mount in this + * module is the `type: 'object'` branch of {@link NavigationItemSchema}; the + * exported {@link ObjectNavItemSchema} does NOT chain it, and exporting the + * function moves no accept set — which schema mounts the check is a separate + * question from whether a mirror can. The mount, the export and the two + * asymmetries below (`recordId` + `viewName` tolerated; `runAction` refused + * with `recordId` only) are pinned by `app-nav-target-exclusivity-export.test.ts`. */ -const objectNavTargetExclusivity = ( +export function objectNavTargetExclusivity( item: { filters?: unknown; recordId?: unknown; viewName?: unknown; runAction?: unknown }, ctx: z.RefinementCtx, -): void => { +): void { if (item.filters && (item.recordId || item.viewName)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -491,7 +507,7 @@ const objectNavTargetExclusivity = ( + '`runAction` to keep the record deep-link.', }); } -}; +} /** * 2. Dashboard Navigation Item From e7c15d138fbc631dc80c7a43a49a4a342f0140c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:05:19 +0000 Subject: [PATCH 2/3] wip: regenerate api-surface/export-origins for the new export; count the mid-line attachment Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + .../src/ui/app-nav-target-exclusivity-export.test.ts | 9 +++++---- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index a18527cee2..c76da0ed56 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -461,6 +461,7 @@ "listViewGroupKeyPredicate (function)", "normalizeFilterOperator (function)", "normalizeInlineAction (function)", + "objectNavTargetExclusivity (function)", "pageForm (const)", "partitionAssembledViewArtifacts (function)", "reactBlockTagFor (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 5448c21774..d7268bfd09 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -447,6 +447,7 @@ "listViewGroupKeyPredicate": "src/ui/view-grouping-query.ts#listViewGroupKeyPredicate (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", "normalizeInlineAction": "src/ui/action.zod.ts#normalizeInlineAction (function)", + "objectNavTargetExclusivity": "src/ui/app.zod.ts#objectNavTargetExclusivity (function)", "pageForm": "src/ui/page.form.ts#pageForm (const)", "partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)", "reactBlockTagFor": "src/ui/react-blocks.ts#reactBlockTagFor (function)", diff --git a/packages/spec/src/ui/app-nav-target-exclusivity-export.test.ts b/packages/spec/src/ui/app-nav-target-exclusivity-export.test.ts index d7f79d387b..88644544fb 100644 --- a/packages/spec/src/ui/app-nav-target-exclusivity-export.test.ts +++ b/packages/spec/src/ui/app-nav-target-exclusivity-export.test.ts @@ -259,11 +259,12 @@ describe('the mount — the union branch carries the check, the exported ObjectN describe('app.zod.ts attaches the export BY IDENTIFIER — no inline copy', () => { const src = fs.readFileSync(path.join(HERE, 'app.zod.ts'), 'utf8'); - // An attachment is a CODE line that begins (after indentation) with - // `.superRefine(name)` — the door chains the check on its own line; a - // docblock naming the same spelling sits on a ` * ` line and is not counted. + // An attachment is a CODE line carrying `.superRefine(name)` — here the + // door chains it mid-line (`}).strict().superRefine(name),`), so unlike the + // `check*` pin the match is not anchored at line start. Docblock (` * `) + // and `//` lines are excluded, so prose naming the same spelling is not counted. const attachments = (name: string): number => - src.match(new RegExp(`^[ \\t]*\\.superRefine\\(${name}\\)`, 'gm'))?.length ?? 0; + src.split(/\r?\n/).filter((line) => !/^[ \t]*(\*|\/\/)/.test(line) && line.includes(`.superRefine(${name})`)).length; // A NAME is only a sound key for that count if the module declares it exactly // once — a second, shadowing binding would satisfy the count while the door // chains a different function object. From bf41b3f60aec1a7f6edbd812790a781d860fb62b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 11:25:45 +0000 Subject: [PATCH 3/3] feat(spec): export objectNavTargetExclusivity; state no precedence order in the filters docblock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export the object-nav target-exclusivity check as a function so a hand-written mirror can chain the schema's own rule. NavigationItemSchema keeps its mount on the type: 'object' branch; ObjectNavItemSchema still carries no check — no accept set moves, the public surface gains one export. The filters docblock no longer states a complete precedence order above the sentence that declares the combination unrepresentable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x --- .changeset/export-object-nav-target-exclusivity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/export-object-nav-target-exclusivity.md b/.changeset/export-object-nav-target-exclusivity.md index 68d081edca..7ff2240360 100644 --- a/.changeset/export-object-nav-target-exclusivity.md +++ b/.changeset/export-object-nav-target-exclusivity.md @@ -2,7 +2,7 @@ "@objectstack/spec": minor --- -`objectNavTargetExclusivity` — the object-level check on an object navigation item that refuses `filters` combined with `recordId` / `viewName`, and `runAction` combined with `recordId` — is now EXPORTED from `@objectstack/spec/ui`, one function per refinement in the same posture as the `check*` exports. A hand-written mirror of the object nav item chains this very function in its own `superRefine` instead of restating the rule from prose; a restatement is what drifts (objectui's mirror accepted `filters` + `recordId` while the spec refused it). +`objectNavTargetExclusivity` — the object-level check on an object navigation item that refuses `filters` combined with `recordId` / `viewName`, and `runAction` combined with `recordId` — is now EXPORTED from `@objectstack/spec/ui`, one function per refinement in the same posture as the `check*` exports. A hand-written mirror of the object nav item chains this very function in its own `superRefine` instead of restating the rule from prose; a restatement is what drifts: objectui's hand-written mirror re-implements neither rule — its `superRefine` checks only `id` / `label`, and the file names no `filters` rule beyond the field's declaration (measured at the pinned `.objectui-sha` and at objectui `origin/main`). **What moves for consumers: one new export.** No schema's accept set moves. `NavigationItemSchema` chains the check exactly where it did — its `type: 'object'` branch — and the exported `ObjectNavItemSchema` still does not chain it: which schema mounts the check is a separate question from whether a mirror can, and it is not decided here. The two deliberate asymmetries are unchanged and now pinned: `recordId` + `viewName` stays a tolerated legacy combination, and `runAction` is refused with `recordId` only (it still composes with `filters` / `viewName`).