Skip to content

fix(rest,lint,spec): prune nav entries whose destination object cannot serve, and refuse them at authoring time (#7912) - #8426

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-7912-nav-servability-prune
Aug 13, 2026
Merged

fix(rest,lint,spec): prune nav entries whose destination object cannot serve, and refuse them at authoring time (#7912)#8426
os-zhuang merged 5 commits into
mainfrom
claude/issue-7912-nav-servability-prune

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#7912

A type: 'object' navigation entry pointing at an object that cannot answer a list was served in the /meta payload anyway. The user saw a menu item that could not work, and the console rendered the failure as a generic empty state — so it read as "you have no records" rather than "this page cannot work".

Implements the maintainer ruling of 2026-08-12 (「接受你的全部建议。」) — option (c): derive it, mint no new key, plus its mandatory publish-time companion.

The gap, re-measured on the merged tree

The card's line numbers were stale (rest-server.ts is 11k lines and moves daily). Anchored on symbols instead:

SymbolCard saidActually
filterAppForUser:2976-3056:2946, delegating to filterAppForUserWithReason at :2994 — the real implementation
apiAccessDenialFromEnable:1665 / :1658:1439
requiresObject in nav filteringdocblock onlyconfirmed — one mention, the "NOT gated here" line

The mechanism the card guessed at held: apiExposureDenialReason's two steps are a pure function of enable — no user, no permissions, no context — so the prune decision really is persona-independent and sits outside the permission logic entirely.

What landed

1. The prune (packages/rest).filterAppForUser gates type: 'object' entries on whether the destination can serve a list, on both /meta app routes, inside children and inside areas[] — through the same filterNav the other gates use, so there is no second implementation to drift. The gate is resolved once per request from object metadata, mirroring the ADR-0057 D10 serviceGate pattern rather than re-reading metadata per entry.

Fail-open in three distinct cases, each deliberate: unreadable metadata resolves no gate at all (a cold start must not empty a healthy sidebar — the #3545 trade); an object absent from metadata is served (no declared policy to enforce, #3770); an object with no enable block is served.

2. The mandatory companion (packages/lint). A new reference-integrity rule nav-object-unservablefailsos validate / os build / os lint, naming the entry, the object, which of the two conditions fired and the offending enable key path. It gates (error) where its sibling validate-nav-access only warns, and the asymmetry is principled: enable is declared on the object in the same stack, so a finding is a certainty, not a suspicion. Objects the stack does not declare are skipped — their enable is not visible from there. The serving side logs the same facts, throttled per app|entry|object|reason.

3. One gate order, three consumers (packages/spec). The two-step order — apiEnabled first and independently, the whitelist second — is now apiExposureDenialReason / canServeApiOperation in @objectstack/spec/data, beside the resolveEffectiveApiMethods / isApiOperationAllowed primitives it composes. There were already three spellings of it; the REST data gate, the nav prune and #7909's invariant test now all read the one export. Per the card's ⛔, #7909's test was reused, not copied — its local canList became a call to the shared function.

Verification

Ablation (the assertion is not vacuous). Fix committed first, then the derivation removed, then restored from the commit — never against an uncommitted edit:

StateResult
Derivation in place16 passed (16)
Derivation removed8 failed / 8 passed

The 8 reds are exactly the pruning and diagnostic assertions; the 8 that stay green are exactly the fail-open "must NOT prune" cases, which should survive an ablation. Predicted before running, and that is the direction observed.

⭐ The control holds.nav_api_keyssys_api_key survives, asserted as the whole surviving set so an over-pruning derivation cannot pass by keeping one row alive. sys_jwks stays apiEnabled: false and is untouched.

Full suites on the merged tree (origin/main advanced twice mid-flight; merged, then artifacts regenerated via scripts/pm/os-regen-merge.sh):

SuiteResult
@objectstack/dogfood (full, as dispatched)728 passed, 2 expected fail, 3 skipped
@objectstack/rest1843 passed (112 files)
@objectstack/spec10331 passed (390 files)
@objectstack/lint1975 passed (72 files)
@objectstack/cli1265 passed (116 files)
@objectstack/platform-objects351 passed
@objectstack/runtime2306 passed
spec check:generated13/13 up to date
typecheck (4 packages) + eslint (changed files)clean
16 derived gate familiesall pass

examples/app-showcase builds green, which is os build running the new gating rule against a real stack.

Deliberately unchanged

  • requiresObject keeps its client-only pin. It asks whether an object is registered; this gate asks whether a registered object's enable lets it answer. Option (a) was rejected and nothing here re-means the key — an entry whose object this layer cannot find is served, not pruned. Pinned by test.
  • visible (CEL) stays client-side only.
  • sys_jwks is out of scope and untouched.

The Account-app sibling closes — as already-closed

The ruling said it "closes with this card or not at all". Measured: the sibling is #7555 (same QA run #7514), and it was already closed by merged PR #7605. Its cause was permission composition — an app-declared baseline replacing the platform member_default — which option (c) neither can nor should touch, since servability is persona-independent by construction. What this card owed it is proof of no regression, and that is asserted: all six Account-app object destinations grant list, so the derived gate leaves the app exactly as it found it. No edit to account.app.ts was required.

Serial-file note

#8039's region (the docblock above DATA_RECORD_READ_PARAMS) was not touched. My nearest hunk ends where apiAccessDenialFromEnable closes, two declarations earlier.


Generated by Claude Code

…t serve, and refuse them at authoring time (#7912)
A `type: 'object'` nav entry pointing at an object that cannot answer a list
was served in the `/meta` payload anyway. Two independent conditions make a
destination unservable — `enable.apiEnabled: false` (404) and an
`enable.apiMethods` whitelist without `list` (405) — and both are pure
functions of the object's own `enable` block, so the destination is dead for
every persona. That is why no `requiredPermissions` gate on the entry could
ever prune it.
Per the maintainer ruling of 2026-08-12 (option (c)): derive the fact, mint no
new key.
- `filterAppForUser` consults the destination's `enable` for every
`type: 'object'` entry, on both `/meta` app routes and inside `children` and
`areas[]`. Fail-open on unknown objects and unreadable metadata, so
`requiresObject`'s client-only pin and #3770 both stand.
- The mandatory companion: `os validate` / `os build` / `os lint` now fail with
`nav-object-unservable`, naming the entry, the object, the offending `enable`
key path and which condition fired. The serving side logs the same facts.
- The two-step gate order is declared once as `apiExposureDenialReason` /
`canServeApiOperation` in `@objectstack/spec/data`. The REST data gate, the
nav prune and #7909's invariant test all read that one export instead of
re-spelling it — the third spelling is what this extraction removes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
…igin/main (#7912)
Both artifacts were merged without a text merge: this branch and #8342 each
added exports to `@objectstack/spec/data`, so neither side's baseline covered
the union. Regenerated from the merged tree, after rebuilding dist — the gate
reads the BUILT dist, and on a stale one it reports phantom removals that the
generator would then write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 1:17pm

Request Review

@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/rest, @objectstack/spec.

108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/lint, packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via packages/lint, @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/authentication.mdx(via @objectstack/rest)
  • content/docs/permissions/authorization.mdx(via @objectstack/lint, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/rest, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/rest, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/lint, @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…js extension (#7912)
`check:type-check-debt` went red: @objectstack/rest's TEST_DEBT records 155 raw
tsc errors and the re-measure reported 156 (+1).
Attributed, not assumed. The package-level `typecheck` is genuinely clean —
`packages/rest` is one of the 20 packages whose tsconfig hides its own tests from
tsc, so nothing local can see the test layer. Replaying the ledger's own program
(the package tsconfig with the test globs dropped from `exclude`) gives 156, of
which exactly ONE is in the new file and exactly 155 are not — matching the
recorded number, so nothing pre-existing on main drifted:
meta-app-nav-servability-gate.test.ts(22,28): error TS2835: Relative import
paths need explicit file extensions in ECMAScript imports when
'--moduleResolution' is 'node16' or 'nodenext'. Did you mean './rest-server.js'?
The package resolves NodeNext, so the extensionless spelling the older test
files use is a TS2835 — 67 of them are frozen in the ledger. The fix is the
extension, so a new file does not add the 68th.
⛔ The TEST_DEBT entry was NOT raised, and `scripts/check-type-check-coverage.mjs`
was not touched. The ledger is a ratchet: `--re-measure` now reports "none above
its recorded number / surplus: none — every entry sits exactly at its
measurement".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 14:19
@os-zhuang
os-zhuang added this pull request to the merge queueAug 13, 2026
Merged via the queue into main with commit 6b7129aAug 13, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7912-nav-servability-prune branch August 13, 2026 14:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nav gating cannot express "prune when the destination cannot serve" — enable.apiEnabled is never consulted by filterAppForUser

2 participants

@os-zhuang@claude