Skip to content

fix(types): chain the spec's objectNavTargetExclusivity in NavigationItemSchema (objectui#8563) - #9060

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8563-nav-target-exclusivity
Sep 10, 2026
Merged

fix(types): chain the spec's objectNavTargetExclusivity in NavigationItemSchema (objectui#8563)#9060
os-warren merged 1 commit into
mainfrom
claude/issue-8563-nav-target-exclusivity

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #8563

NavigationItemSchema is hand-written rather than derived from a spec .shape, so nothing carried the spec's own checks across it. An object nav entry declaring both filters and recordId — or both runAction and recordId — parsed clean at this door and was refused by @objectstack/spec at publish. This chains the spec's exported objectNavTargetExclusivity on the type: 'object' arm, so the two doors agree.

The floor was bisected, not assumed

The dispatch flagged that 17.4.0 was not established as the first version carrying the export: a 17.3.0 probe had returned undefined for the symbol and for its positive control checkListViewPageMount, which reads as a broken instrument rather than a negative.

Re-derived here against each published 17.x version's own tarball, never workspace resolution. The instrument was rebuilt first, because the original one had two faults: it grepped the CJS require branch while importing the ESM one, and a plain grep measures PRESENCE, not EXPORTEDNESS.

version objectNavTargetExclusivity in ESM namespace checkListViewPageMount ObjectNavItemSchema (control) zzzNotAThing (control)
17.0.0 undefined undefined function undefined
17.1.0 undefined undefined function undefined
17.2.0 undefined undefined function undefined
17.3.0 undefined undefined function undefined
17.4.0 function (arity 2) function function undefined

The dispatch's 17.3.0 reading was a true negative, not a broken instrument. checkListViewPageMount is simply another symbol that first shipped as an export in 17.4.0, so it fails for the same reason as the subject — it cannot serve as a control for versions below it. ObjectNavItemSchema is a control that fires at every version in the range, and with it in place all four readings below 17.4.0 are interpretable negatives.

Also worth recording, because it is what made the naive instrument lie: objectNavTargetExclusivity is present in the bundle from 17.0.0 (4 textual occurrences — it was defined and chained internally all along). 16862 added the export keyword. A grep for the name is green four versions before the symbol is importable.

Independent third instrument, from a different direction — check:spec-floors fetches the declared floor from the registry and reads its dist/ui/index.d.mts export surface. Reverting the floor to ^17.3.0 produces:

@object-ui/types  [floor-too-low]  packages/types/dist/zod/app.zod.js references
`objectNavTargetExclusivity` from @objectstack/spec/ui, which @objectstack/spec@17.3.0
does not export
@object-ui/types  ->  "@objectstack/spec": "^17.4.0"

The gate names ^17.4.0 itself. Floor moved ^17.3.0 -> ^17.4.0; the lockfile was regenerated by pnpm install and the diff is exactly one generated specifier line.

The two asymmetries are preserved, re-derived from the installed rule

Read out of the installed 17.4.0 implementation body, not from the card's prose or the dispatch's:

if (item.filters && (item.recordId || item.viewName))  ->  issue at ["filters"]
if (item.runAction && item.recordId)                   ->  issue at ["runAction"]

So recordId + viewName is tolerated (no clause pairs them), and runAction is refused with recordId only — it composes with viewName or filters. Six negative controls pin exactly that, plus a vacuity guard asserting the rule itself raises nothing for any of them, so the comparison below cannot pass as empty-equals-empty.

Chained, not copied — and that is asserted, not just intended

A local copy of the rule body passes every case the day it is written and drifts the day the spec's own rule moves, which is this same defect one layer down. Two assertions are about identity rather than behaviour:

  • the door's issues are compared byte for byte against the exported function driven directly, so a reworded copy fails even when it refuses the same set;
  • the mirror's source is parsed, so a local re-declaration of the name fails even if it produced identical bytes.

Ablation (committed first, restored to a byte-identical tree, both legs verified by blob hash). Deleting the chained call: 6 failed / 9 passed — the three refusal cases, the nested-children case, the byte-identity comparison and the AST check all go red, while the six negative controls and the vacuity guard stay green. Restored, then re-run: 15/15 green.

The precedence sentence had no referent

Precedence: recordId -> filters -> viewName was copied from a spec docblock the spec itself corrected. No precedence resolves these combinations — they are refused, so an author following the sentence got a rejection. Corrected in the filters .describe() (the card's item 3) and in two adjacent sites the same change falsifies:

  • the runAction .describe() in the same schema object, which said the combination was ignored when it is now refused;
  • the NavigationItem interface doc in packages/types/src/app.ts, both the filters and runAction members.

packages/types/src/app.ts is outside the file surface the dispatch named, so it was taken as a bounded in-place fix and is called out here rather than done quietly: same defect class, comment-only, no gate surface added, and verified held by none of the 9 open PRs.

One gate had to be extended, honestly

imported-defaults-8317.test.ts enforces that every @objectstack/spec value read in a mirror is wrapped in stripImportedDefaults, and it is a source census that "can see an import written TOMORROW". A chained refinement is a third kind of read its two declared exceptions did not cover, and it failed on exactly the two assertions predicted before the run.

It is extended rather than worked around. Routing the import through a local re-export would have made the census blind, which is the opposite of the point. The new REFINEMENT_EXCEPTIONS entry is keyed by binding name, holds the binding itself, and a new assertion requires each entry to be a real function carrying no Zod internals — so a schema cannot reach the list merely by being listed in it. The test's own docblock invited this ("adding a fourth is an edit to this list").

navigation-spec-parity.test.ts's docblock said the spec's superRefine exclusivity rule was deliberately not modelled. This change falsifies half of that sentence, so it is amended: the union convergence is still not modelled, the object arm's exclusivity rule now is.

Verification

  • pnpm --filter @object-ui/types test171 files, 3379 tests, all passed
  • pnpm --filter @object-ui/types type-check — rc 0 (tsc --noEmit + examples + test projects)
  • eslint . in packages/types, counts read from --format json: 241 files selected by eslint itself, 0 errors, 277 warnings. The one warning on app.zod.ts is pre-existing — the same no-explicit-any on the same z.ZodType<any> declaration, line-shifted; confirmed by linting the base revision of the file, which reports the identical single warning. No type-aware linting is configured (project appears 0 times in eslint.config.js), so this diff cannot move the verdict of any file it does not touch.
  • Consumer sweep for the accept-set narrowing — the suites that actually parse navigation (studio-design/navSurface, StudioDesignSurface.navItemInspector, console preview-samples-spec-valid and registry-inputs-spec-parity, plugin-gantt/readme-navigation-example, react offline-nav-performance-spec-parity, metadata-admin/inspectors/nav-target): 7 files, 296 tests, all passed. resolveHref and the nav-target inspector take NavigationItem as a TYPE and never parse, so the narrowing does not reach them.
  • Gates green: check:spec-symbols, check:control-bytes, check:phantom-deps, check:unused-deps, check:lockfile-integrity (VERDICT clean), check:new-line-citations, check:entry-guard, check:bash32-floor, check:esm-specifiers, check:pre-install-import-graph, changeset:check and check-changeset-presence.
  • check-governed-queue-guard --test over all 8 changed paths: NOT GOVERNED, so no maintainer brief is owed on this PR.
  • NOT MEASURED, both prerequisite failures rather than verdicts, and both CI-owned: check:spec-floors reports 17 no-artifact findings because only packages/types is built locally — @object-ui/types itself was judged and clean, and its workflow builds the whole workspace first (it is nightly + release-path, not a per-PR gate). check:readme-exports self-declares a COLLAPSED population ("35 unbuilt"); its workflow builds ./packages/* first, and this diff adds no export and no README.

Changeset is minor, from this repo's own precedent read by content: AGENTS.md §版本号策略 and check-changeset-no-major.mjs's header both state that objectui's own breaking changes ship as minor with the break spelled out in the body, because the 39-package fixed group tracks @objectstack's major. packages/types/CHANGELOG.md 17.5.0 carries the matching precedent — an accept-set narrowing (formats no longer admitting 'pdf') shipped as a minor on the same reasoning.

Held out of this PR

skills/objectui/guides/app-composition.md and docs/adr/0055-parameterized-bare-data-surface.md still teach the refused precedence. Reported as objectui#9059 rather than edited: skills/** is a published skill package that pulls in three more gates plus line-budget accounting, and amending an ADR is a maintainer's call, not a rider on a schema PR.

Verification notes

Both shared checkouts were left untouched and clean. /home/user/objectui HEAD d4733f27e and /home/user/objectstack HEAD eabdd66f4, both git status --porcelain empty at start and at finish. All work was done in a fresh clone.


Generated by Claude Code

…ItemSchema (objectui#8563)

`NavigationItemSchema` is hand-written rather than derived from a spec `.shape`,
so nothing carried the spec's own checks across it: an object nav entry
declaring both `filters` and `recordId` — or both `runAction` and `recordId` —
parsed clean at this door and was refused by `@objectstack/spec` at publish.

The rule is CHAINED, not restated. A local copy passes every case the day it is
written and drifts the day the spec's own rule moves, which is this same defect
one layer down. The pin compares this door's issues byte for byte against the
exported function driven directly, and parses the mirror's source to refuse a
local re-declaration of the name.

The two asymmetries the spec keeps on purpose are preserved and pinned by six
negative controls: `recordId` + `viewName` stays tolerated, and `runAction`
composes with `viewName` or `filters` — it is refused with `recordId` only.

Two `.describe()` strings and the `NavigationItem` interface doc taught a
`Precedence: recordId -> filters -> viewName` that resolves nothing, since the
combination is refused; they now match the spec's own describe.

The `@objectstack/spec` floor moves `^17.3.0` -> `^17.4.0`: 17.4.0 is the first
published version that EXPORTS the rule, bisected across the published 17.x line
against each version's own tarball rather than workspace resolution.

`imported-defaults-8317.test.ts` gains a third declared exception kind — a
chained spec refinement, which is a function with no Zod graph to strip and no
accept set to differentiate — with a guard that each entry really is a function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.1 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-PeWB9QBA.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.20KB 114.67KB
core (index.js) 8.28KB 3.31KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 207.56KB 57.44KB
fields (index.js) 247.01KB 62.29KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.50KB 19.97KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.09KB 34.58KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.44KB 65.17KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.07KB 14.32KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Contract review

Reviewed head: f5f70bab4f70848343829499c5106905d96f2973 — PR #9060, card #8563, round 1, 8 changed files, 1 commit. Head re-read immediately before posting: unchanged. PR is draft, base main, first body line Fixes #8563, label needs:contract-review; card carries Clause-②: yes in its Claim:.

Charter read at objectstack origin/main 2662a084e71c6df748337b0e08d7877d57c6b8bc (fresh sparse clone): references/contract-review.md in full, landing-operations.md, true-green.md, platform-readings.md, SKILL.md §复核 and §入队与落地.

Tier: CONTRACT_REVIEW_TIER = 'claude-fable-5-1' read from scripts/pm/dispatch-gates.mjs at that sha. This review subagent's transcript, located by grepping a unique marker rather than by filename, carries harness-stamped per-message model fields 88/88 claude-fable-5-1 at the last read before posting; no other stamp present.

Inputs: the card and every comment on it (body, Claim:, the seat's correction, the os-dev-report), and the PR body, diff, commit, comments and check runs. Nothing from the dispatch order or the dispatching seat's conclusions. Every statement in the PR body, the report and the diff's prose was treated as a claim to falsify; measurements below are my own, on a fresh clone of the PR head and on isolated installs of each published @objectstack/spec 17.x tarball.

① Derived judgments — accept-set and public-surface changes, one by one

  1. Accept-set narrowing on the type: 'object' armfilters+recordId, filters+viewName, runAction+recordId now refused. Measured: a 13-row matrix through objectui's NavigationItemSchema at head against the spec's own NavigationItemSchema from the installed 17.4.0 tarball — identical on all 13 rows (7 ACCEPT, 6 REFUSE with identical code@path, including filters: {}+recordId and the triple filters+recordId+runAction). The same rows through the spec at 17.3.0 give the same protocol verdicts: the protocol already refused these; objectui was the wider door. Direction under the maintainer's rule (「以 objectstack 协议为准」, narrower-than-protocol forbidden): objectui moves from wider to equal, not narrower — no row where the door refuses and the protocol accepts. The standalone ObjectNavItemSchema accepts all 13 (no object-level rule), consistent with the spec seat's note; the mirror's counterpart is the union, which is what was compared. Correct.
  2. The two asymmetries. Rule body read out of dist/ui/index.mjs at 17.4.0: exactly two clauses — filters && (recordId || viewName) raises at filters; runAction && recordId raises at runAction. recordId+viewName, runAction+filters, runAction+viewName ACCEPT at both doors; the pin's vacuity guard is real (the rule raises on every positive row driven directly). No over-refusal, no under-refusal. Correct.
  3. Mount scope. The door calls the rule only when item.type === 'object'; the 17.4.0 bundle mounts it once, on the object arm. Non-object items carrying these keys: door ACCEPT vs spec REFUSE unrecognized_keys — the pre-existing flat-shape-vs-union gap, wider direction, untouched by this PR and still documented as not modelled. Correct scope.
  4. Reach through nested children (z.lazy): pinned; ablation A below flips the nested case. Correct.
  5. Chain, not copy. Import is from @objectstack/spec/ui; the door's issue {code, path, message} bytes equal the installed-tarball rule driven directly (resolved path .pnpm/@objectstack+spec@17.4.0…/dist/ui). Ablations on my clone, each restored to a byte-identical tree (blob hash equal, porcelain 0) before the next: (A) call deleted, import kept → 6 failed / 9 passed — the three refusal rows, the nested case, the byte-identity comparison and the AST check; the six controls and the vacuity guard stay green. (B) same-name verbatim local copy replacing the import → 1 failed (AST: import missing, local declaration present). (C) hostile shape: import kept but unused, a verbatim copy under a different name is what the door calls → 1 failed (AST calls > 0: "imports the rule but never calls it"). The pin fails for every copy shape tried. Correct.
  6. Dependency floor ^17.3.0^17.4.0. Re-derived on my own instrument. Published stable 17.x line: 17.0.0, 17.1.0, 17.2.0, 17.3.0, 17.4.0 (latest = 17.4.0; the seven 17.0.0-rc.* pre-releases are admitted by neither caret). Each installed into its own directory and probed against its own tarball. Controls chosen by me: NavigationAreaSchema and AppSchema — both already imported by this very mirror at the PR base, so they are known to fire in the tested region — plus NavigationItemSchema; each reads as a live binding in both the ESM (index.mjs) and CJS (index.js) namespaces and appears in every version's .d.mts export list; negative control qqqDefinitelyNotExported is undefined everywhere. Subject: undefined in both namespaces and absent from both .d.mts and .d.ts export lists at 17.0.0–17.3.0; function of length 2 in both namespaces and both export lists at 17.4.0. Both failure directions checked: the name occurs textually 4× in index.mjs at 17.0.0–17.3.0 (declared as var objectNavTargetExclusivity = (item, ctx) => and already mounted by .superRefine at 17.3.0), so a symbol grep reads green four versions early; the dispatch's control checkListViewPageMount is undefined at 17.0.0–17.3.0 and function only at 17.4.0, so it cannot fire below the subject's own release — the seat's "broken instrument" reading was two true negatives and the dev's correction stands. Third instrument: check:spec-floors with packages/types built reports no finding for @object-ui/types at ^17.4.0; with the floor reverted to ^17.3.0 it emits [floor-too-low] … references objectNavTargetExclusivity from @objectstack/spec/ui, which @objectstack/spec@17.3.0 does not export and prescribes ^17.4.0. Lockfile diff is one specifier: line; resolved version unchanged at 17.4.0. 17.4.0 is the floor. Correct.
  7. Describe and doc text. filters describe now Mutually exclusive with recordId/viewName. — byte-equal to the spec's own describe at 17.4.0 (already so at 17.3.0). The runAction describe and the NavigationItem interface doc now state refusal-with-recordId and composition with viewName/filters, matching the rule's two clauses. The mirror contains 0 Precedence: recordId occurrences (pinned). Correct.
  8. Public surface. No new export, no new key, no type change (NavigationItemSchema remains the lazy z.ZodType of any); the census now declares one more value read. No widening tell. Correct.
  9. Edits beyond the named file surface. (a) packages/types/src/app.ts doc comment — same defect class as card item 3, named on the card by the spec seat's evidence comment before dispatch, comment-only, not on the dispatch's stay-out list, and held by none of the 14 open PRs (each PR's file list read; the dev said 9 — there are 14 now; result unchanged). In scope. (b) imported-defaults-8317.test.ts REFINEMENT_EXCEPTIONS — ablation (D), the new filter removed: exactly the 2 assertions the dev predicted fail, so the census genuinely sees this read and the exception is load-bearing, not decorative. The invariant it enforces — no spec schema VALUE crosses a mirror unstripped — is not weakened: an entry must be a live function carrying no _zod internals and must match a real read, and a refinement has no Zod graph and no default to strip; the alternative (a local re-export) would have blinded the census. In scope. Non-binding residual: the exemption is keyed by the mirror's local binding name, so it follows an alias rather than the spec export — the same keying class as the existing vocabulary exceptions, exploitable only by a deliberate aliasing import, not a drift path. (c) navigation-spec-parity.test.ts docblock — would be false after this change. In scope.
  10. Consumer sweep for the narrowing. Parse population: no non-test call site parses NavigationItemSchema or AppComponentSchema directly; the schema reaches runtime through AnyComponentSchemasafeValidateSchema (objectui validate) and the studio/console suites. A multi-line scan of every .ts/.tsx/.json/.yaml/.md outside tests, changelogs and node_modules for an object literal combining recordId with filters or runAction: 0 documents (the hits are the new pin, the mirror itself, two resolveHref tests, the nav-target inspector test, and a react-pages.md table about ObjectForm props — none is a navigation document). resolveHref (packages/layout) and nav-target.ts (app-shell inspector) take NavigationItem as a type and never parse; the inspector derives ONE landing mode and clears the other target fields on switch, so it cannot author a refused pair; the renderer's precedence fallback keeps pre-guard stored documents rendering and is not a second rejection point. On my clone at head: full @object-ui/types suite 171 files / 3379 tests pass, type-check rc 0, the three touched files 138/138. No renderer or resolver depends on a combination the schema now refuses. Accounted for.

② Semver grading against the changeset

Changeset declares "@object-ui/types": minor with the break spelled out. Precedent read by content: AGENTS.md §版本号策略 — objectui's own breaking changes ship as minor, major reserved for the one synchronized bump that follows objectstack across its major, enforced by scripts/check-changeset-no-major.mjs (header read); packages/types/CHANGELOG.md 17.5.0 lists the exportOptions 'pdf' retirement under Minor Changes. The dependency-floor raise is minor-class as well. @object-ui/types sits in the 40-entry fixed group. Grade minor — consistent with the changeset.

③ Dev flags, open_questions, out_of_scope_findings

Landing readings (recorded, not gates of this record)

Check runs on the head: total_count 36 = returned array length 36, all completed — 33 success + 3 skipped (dependabot, Test (coverage), Test (coverage shard …)), 0 failed / cancelled / in progress. mergeable_state: clean, draft, 1 commit. origin/main has moved past the PR's merge-base (a650bb357f27bc54) — a sync concern for the landing window, unrelated to the contract.

Not measured

  • Not run here: eslint, check:readme-exports, check-clause2-carriers.mjs --pair 9060, check-widening-tells.mjs (declaration is yes, so it does not apply), any browser/dogfood path, and suites of packages other than @object-ui/types (the green CI shards on this head cover those).
  • Not judged: the dev's serving tier — the commit's Co-Authored-By trailer is a signature constant and non-evidential under the charter.
  • Not judged: the flat-shape-vs-union convergence gap (pre-existing, outside the card).

Independence pair

Implemented-by: branch claude/issue-8563-nav-target-exclusivity (mode:subagent dev — recorded by branch; a subagent carries no session of its own)
Reviewed-by: session_01Jmxdo7bmeqCQHLSfmLVX9w (spec seat; ruling produced by its context-isolated review subagent)

Same-session test, applied literally to the charter text (mode:subagent dev 记其分支…;两者同 session ⇒ 报 SELF-REVIEW): the Implemented-by line carries a branch, not a session id, so the two lines cannot name the same session and the SELF-REVIEW condition does not fire. Stated for the record rather than hidden: both the dev subagent and this review subagent were dispatched from session_01Jmxdo7bmeqCQHLSfmLVX9w (the Claim: and the PR footer both name it). Under the charter, independence for this configuration rests on the context-isolation clause — this subagent received the card, the existing rulings and the PR body only, not the dispatch order or the seat's conclusions — and on the transcript tier check above. Signature trailers were used as evidence in neither direction.

VERDICT: PASS — no binding finding. Non-binding observations for successors: (i) the census exemption is keyed by local binding name; (ii) NavigationRenderer.tsx:562 still names the precedence outside this surface (#9059).


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — carriers cleared, landing

Clause-② contract review: VERDICT: PASS, no binding finding — record 5625847021 (#9060 (comment)), judged on head f5f70bab4f70848343829499c5106905d96f2973, re-read immediately before the reviewer posted and still the head at this write.

Reviewer ran at CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (dispatch-gates.mjs:10507), transcript-verified three times — 25/25, 88/88 before posting, 95/95 after — one distinct value. Charter read at objectstack origin/main 2662a084e71c…, and it declined to reuse an older charter copy already sitting in its scratchpad. I re-fetched to 6e3462df47865ebca358d662f71e22ee036b457b before clearing: .claude/skills/pm-dispatch and scripts/pm byte-unchanged, with a full-tree control showing 7 other files did change.

Independence — NOT self-review:

Implemented-by: branch `claude/issue-8563-nav-target-exclusivity` (mode:subagent dev — recorded by branch; a subagent carries no session of its own)
Reviewed-by: `session_01Jmxdo7bmeqCQHLSfmLVX9w` (spec seat; ruling produced by its context-isolated review subagent)

Sixth reviewer today to reach this from the charter text, and it stated openly in the record that both subagents came from the same dispatching session, resting independence on the context-isolation clause (:33-34) and the transcript tier check, with :56 disposing of the trailer.

Landing pre-checks — all measured just now

check reading
① in-seat clause-② review PASS on record, 同形, ceiling tier ✅ record 5625847021 on the head
check-clause2-carriers.mjs --pair 9060 rc 0 — both carriers agree
check-widening-tells.mjs --declaration yes --files rc 0 (a yes already routes to review)
③ every check on the head, ⛔ not a required subset 36 check runs, 33 success + 3 skipped, 0 failed, 0 pending; total_count 36 = array length 36
governed surface --pr objectstack-ai/objectui#9060 → 0 of 8 paths hit the register ⇒ NOT governed
mergeability mergeable: true, mergeable_state: clean

⚠️ This PR touches a generated artifact (pnpm-lock.yaml), so landing-operations A applies in principle. Recorded rather than waved through: the change is one specifier: line (^17.3.0^17.4.0) with the resolved version unchanged at 17.4.0; the merge is clean against current main; and the four other open PRs touching the lockfile (#8941, #8940, #8939, #7058 — dependency bumps) are not in the queue, so no serial relay is owed. ⭐ The dev also established that scripts/pm/os-regen-merge.sh does not exist in objectui — it is objectstack's, written for a merge driver and gen:schema anchor this repo does not have; objectui routes the lockfile as pnpm-lock.yaml merge=pnpm-merge, a contributor-local driver with zero CI consumers. My dispatch brief said the landing seat would run that script; it was wrong, and this is the correction.

⭐ The floor bisection, and the reviewer confirming my error precisely

I warned in the Claim: comment that 17.4.0 was not established as the first version carrying objectNavTargetExclusivity, because my 17.3.0 probe returned undefined for the symbol and for its control. The dev refuted that; the reviewer re-derived it independently and chose its controls by the right criterion:

"My controls: NavigationAreaSchema and AppSchema — both already imported by this very mirror at the PR base under ^17.3.0, so they are known to fire in the region tested."

That is exactly the rule I failed to apply. My control checkListViewPageMount is itself undefined at 17.0.0–17.3.0, so it could never fire below the release under test, and my reading was two true negatives. Both directions confirmed a third time: the name is textually present 4× from 17.0.0 and already mounted by .superRefine at 17.3.0 — so a grep is green four versions early — while only the export lands at 17.4.0. Third instrument: check:spec-floors reverted to ^17.3.0 emits [floor-too-low] and prescribes ^17.4.0 by name. Floor = 17.4.0.

Two non-binding observations carried forward, ⛔ not fixed here

  • The census exemption is keyed by the mirror's local binding name; ablation D showed the census does see the read and the invariant holds, but the alias keying is a residual.
  • packages/layout/src/NavigationRenderer.tsx:562 still names the refused precedence — outside this PR's surface, successor objectui#9059.

Also corrected by measurement: the dev reported 9 open PRs holding files; the reviewer measured 14. The conclusion (the out-of-surface edit is held by none of them) is unchanged.

Both carriers stripped in this same act; PR flipped ready and enqueued. ⛔ Base is not hand-merged — the queue rebuilds on current main itself, which is also what resolves the merge-base drift the reviewer flagged (a650bb357f27bc54).


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 10, 2026 21:46
@os-warren
os-warren added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 8b532cb Sep 10, 2026
38 checks passed
@os-warren
os-warren deleted the claude/issue-8563-nav-target-exclusivity branch September 10, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chain spec's exported objectNavTargetExclusivity in the hand-written NavigationItemSchema superRefine (objectui half of objectstack#16714)

1 participant