Split out of #7544 (fixed in #7909) by the domain:metadata seat. #7909 removed the one dead entry; this is the general gap it sat on, and it is a contract-face change ⇒ domain:spec.
Symptom
A Setup/app nav entry whose destination object cannot serve the request is shipped to the client in the /meta payload anyway. The user sees a menu item that, when clicked, cannot work — and today the console renders that failure as a generic empty state, so it reads as "you have no records" rather than "this page cannot work" (that half is an objectui surface — see the companion card linked below).
Root cause — measured on origin/main, twice, independently
filterAppForUser (packages/rest/src/rest-server.ts:2976-3056) is the server-side nav filter. It gates:
Its own docblock names what it does not gate:
NOT gated here: visible (CEL) at any level, and requiresObject — both are still evaluated client-side only. That asymmetry is deliberate and pinned in rest.test.ts.
Nothing in nav filtering consults enable.apiEnabled at all. Two measurements, run separately by the #7544 dev and by the dispatching PM:
| Probe | Result |
|---|
apiEnabled in rest-server.ts | 9 occurrences — apiAccessDenialFromEnable (:1665), the enforceApiAccess docblock (:2468-2505), the bulk path (:10128, :10177, :10423) |
…of those, inside filterAppForUser (:2976-3056) | 0 |
requiresObject in rest-server.ts | 1 — the docblock line at :3011 saying it is not gated there |
So requiresObject, the key that looks like the right tool, never reaches the server-side filter.
⭐ Why this is not "just add a gate"
Two independent conditions decide whether a destination can serve, and neither is expressible on a nav entry today:
enable.apiEnabled: false → OBJECT_API_DISABLED (404). apiAccessDenialFromEnable (:1658) is a pure function of the object's enable block — it takes no user, no permissions and no context. The denial is identical for every persona, platform admin included.- an
apiMethods whitelist without the needed operation → OBJECT_API_METHOD_NOT_ALLOWED (405), same pure-function property.
⚠️A requiredPermissions gate cannot prune either, and this is the load-bearing point: they are independent conditions, so no combination of permissions on the entry prunes an entry whose object is API-disabled. #7544 shipped exactly that combination for a year and it read as correct to reviewers — the in-code comment on the removed entry claimed a non-admin "403s server-side", which implies an admin could list. None could.
⇒ Re-pointing a dead entry at a requiresObject gate would not have pruned it either. Deletion was the only repair available to #7544, not merely the one chosen.
The sibling defect shares this root
The Account app served whole to a member denied every backing object (same QA sweep as #7544) uses requiresObject and still leaves the server with no pruning signal — because requiresObjectis the client-only key. Both halves are one missing server-side declaration. Anyone closing this should check whether that sibling closes with it.
Scope — this is a fork, rule it before dispatching
⛔ Do not leave a dev to pick; the readings produce different contract faces:
- (a) Make
requiresObject server-side — reuse the existing key, and have filterAppForUser resolve the named object and consult enable. Cheapest to declare, but it changes the meaning of an existing spec key and the docblock says the client-only asymmetry is deliberate and pinned in rest.test.ts — so that pin is a decision to overturn in the open, not a test to update quietly. - (b) A new declaration (e.g.
requiresServableObject / an enable-aware variant) — leaves requiresObject alone, costs a new spec property (ADR-0087 registration required). - (c) Derive it, declare nothing —
type: 'object' entries already name their objectName; the filter could consult enable for every such entry with no new key at all. Smallest contract face; ⚠️ but it is implicit behaviour, and it prunes entries whose authors never asked for pruning.
Whoever dispatches this should rule with a falsifiable premise, or escalate with the menu.
Control — a fix must not over-prune
nav_api_keys → sys_api_key rides the same machinery and has no enable restriction: it must survive any pruning change. packages/platform-objects/src/platform-objects.test.ts (landed in #7909, describe('object entries can actually serve a list (#7544)')) already asserts both directions through the same derivation source the REST gate uses (resolveEffectiveApiMethods / isApiOperationAllowed, #3391) — reuse it rather than writing a second, drifting copy of the gate order.
⚠️sys_jwks's enable block is ⛔ out of scope. It is API-disabled because its rows are private JWT signing-key material; #7909 pinned that it stays apiEnabled: false / apiMethods: [] / access.default: 'private'. Opening a read path onto it is a credential disclosure, not a fix.
Evidence trail
Split out of #7544 (fixed in #7909) by the
domain:metadataseat. #7909 removed the one dead entry; this is the general gap it sat on, and it is a contract-face change ⇒domain:spec.Symptom
A Setup/app nav entry whose destination object cannot serve the request is shipped to the client in the
/metapayload anyway. The user sees a menu item that, when clicked, cannot work — and today the console renders that failure as a generic empty state, so it reads as "you have no records" rather than "this page cannot work" (that half is anobjectuisurface — see the companion card linked below).Root cause — measured on
origin/main, twice, independentlyfilterAppForUser(packages/rest/src/rest-server.ts:2976-3056) is the server-side nav filter. It gates:_unpublished(the machine-managed key, ⛔ nothidden)requiredPermissionsrequiresService(ADR-0057 D10)filterNavnever drops a group DECLARED withchildren: []— Setup renders an inert 「Approvals」 group on a runtime without plugin-approvals #7380)Its own docblock names what it does not gate:
Nothing in nav filtering consults
enable.apiEnabledat all. Two measurements, run separately by the #7544 dev and by the dispatching PM:apiEnabledinrest-server.tsapiAccessDenialFromEnable(:1665), theenforceApiAccessdocblock (:2468-2505), the bulk path (:10128,:10177,:10423)filterAppForUser(:2976-3056)requiresObjectinrest-server.ts:3011saying it is not gated thereSo
requiresObject, the key that looks like the right tool, never reaches the server-side filter.⭐ Why this is not "just add a gate"
Two independent conditions decide whether a destination can serve, and neither is expressible on a nav entry today:
enable.apiEnabled: false→OBJECT_API_DISABLED(404).apiAccessDenialFromEnable(:1658) is a pure function of the object'senableblock — it takes no user, no permissions and no context. The denial is identical for every persona, platform admin included.apiMethodswhitelist without the needed operation →OBJECT_API_METHOD_NOT_ALLOWED(405), same pure-function property.requiredPermissionsgate cannot prune either, and this is the load-bearing point: they are independent conditions, so no combination of permissions on the entry prunes an entry whose object is API-disabled. #7544 shipped exactly that combination for a year and it read as correct to reviewers — the in-code comment on the removed entry claimed a non-admin "403s server-side", which implies an admin could list. None could.⇒ Re-pointing a dead entry at a
requiresObjectgate would not have pruned it either. Deletion was the only repair available to #7544, not merely the one chosen.The sibling defect shares this root
The Account app served whole to a member denied every backing object (same QA sweep as #7544) uses
requiresObjectand still leaves the server with no pruning signal — becauserequiresObjectis the client-only key. Both halves are one missing server-side declaration. Anyone closing this should check whether that sibling closes with it.Scope — this is a fork, rule it before dispatching
⛔ Do not leave a dev to pick; the readings produce different contract faces:
requiresObjectserver-side — reuse the existing key, and havefilterAppForUserresolve the named object and consultenable. Cheapest to declare, but it changes the meaning of an existing spec key and the docblock says the client-only asymmetry is deliberate and pinned inrest.test.ts— so that pin is a decision to overturn in the open, not a test to update quietly.requiresServableObject/ anenable-aware variant) — leavesrequiresObjectalone, costs a new spec property (ADR-0087 registration required).type: 'object'entries already name theirobjectName; the filter could consultenablefor every such entry with no new key at all. Smallest contract face;Whoever dispatches this should rule with a falsifiable premise, or escalate with the menu.
Control — a fix must not over-prune
nav_api_keys→sys_api_keyrides the same machinery and has noenablerestriction: it must survive any pruning change.packages/platform-objects/src/platform-objects.test.ts(landed in #7909,describe('object entries can actually serve a list (#7544)')) already asserts both directions through the same derivation source the REST gate uses (resolveEffectiveApiMethods/isApiOperationAllowed, #3391) — reuse it rather than writing a second, drifting copy of the gate order.sys_jwks'senableblock is ⛔ out of scope. It is API-disabled because its rows are private JWT signing-key material; #7909 pinned that it staysapiEnabled: false/apiMethods: []/access.default: 'private'. Opening a read path onto it is a credential disclosure, not a fix.Evidence trail
sys_jwksis API-disabled and arequiredPermissionsgate cannot prune it #7544 — the reported instance (QA run QA run · platform-core (FULL area) · a86db175 · 2026-08-11 · 3 PASS / 1 PARTIAL / 7 FAIL #7514), and the dev report with the full measurement tableapiMethodsfails CLOSEDrequiresService, the existing precedent for a server-side nav gatefilterNavnever drops a group DECLARED withchildren: []— Setup renders an inert 「Approvals」 group on a runtime without plugin-approvals #7380 — empty-group collapse