Repository navigation
Nav gating cannot express "prune when the destination cannot serve" — enable.apiEnabled is never consulted by filterAppForUser #7912
Description
Activity
huangyiirene commented
on Aug 12, 2026 CollaboratorAuthorMore actionsMoved
pm:queue→needs-user-decisionby the spec seat (sessionsession_0123k4cam2jEAkPmbJeoaY3r), under the standing lane-triage authorization. The card's own scope section forbids leaving the pick to a dev ("⛔ Do not leave a dev to pick"), and each route is a contract action on the manual floor: (a) re-means a published key whose client-only asymmetry is deliberately pinned inrest.test.ts; (b) mints a new authorable key (permanent ADR-0087 vocabulary); (c) adds implicit server-side behavior to a published surface.Four-lens analysis:
- Platform long-term coherence — servability is already fully declared on the object (
enable.apiEnabled,apiMethods). Routes (a) and (b) declare it a second time on the nav entry, creating a two-source truth that can drift (entry says servable, object says not). Route (c) derives from the single existing source — no vocabulary growth — at the cost of one implicit rule in a filter whose gates are today all declared. - Measured business pull — real, measured twice: Setup › Advanced › Signing Keys (JWKS) is a dead nav entry —
sys_jwksis API-disabled and arequiredPermissionsgate cannot prune it #7544's dead entry shipped for a year and read as correct to reviewers (its in-code comment claimed a fallback that never existed), and the Account-app sibling still has no pruning signal. The pruning has pull; a new opt-in key does not — no author has asked to ship an entry whose destination cannot serve. - AI-agent error-resistance — (c) is structurally strongest: the footgun class disappears without asking authors (or AI authors) to remember a key. Its weakness is silence — an author whose object is accidentally API-disabled sees the entry vanish with no signal. If (c) is ruled, it should carry a loud companion: a publish-time diagnostic naming the pruned entry and the
enable/apiMethodscondition that pruned it (same principle as the authoring-validation-not-persisted: a flat view body is accepted, published and reported valid, then expands to nothing — the write door judges by the wire union, not the strict ViewSchema #7741 ruling: no silent dead rows — and no silent repairs either). - Startup scope discipline — (c) declares nothing and maintains nothing new. (b) is a permanent key for a derivable fact — the worst shape. (a) is cheap to type but spends its budget overturning a pinned deliberate decision.
Recommendation: (c) —
filterAppForUserconsults servability for everytype: 'object'entry through the existing #3391 derivation source (resolveEffectiveApiMethods/isApiOperationAllowed— reuse the #7909 invariant test's gate order, not a second copy), plus the publish-time diagnostic above. Controls from the card stand:nav_api_keys→sys_api_keymust survive any pruning change;sys_jwksstaysapiEnabled: falseand ⛔ out of scope. Note (c) leavesrequiresObject's client-only pin untouched — no pin needs overturning.Routing note if ruled (c): the implementation lands in
packages/rest(filterAppForUser) ⇒ likely re-routedomain:clifor the code, with this seat holding the contract statement (the docblock's "NOT gated here" list changes meaning). Awaiting your ruling — the card dispatches the moment one lands.
Generated by Claude Code
- Platform long-term coherence — servability is already fully declared on the object (
Maintainer ruling — 2026-08-12 (live PM chat,
session_01GxKQfv3k8b6a2d2QrZU411; maintainer sam, verbatim: 「接受你的全部建议。」 accepting the decision-box analysis in full). Moved out of the decision box →pm:queue.Ruling: option (c) — derive it, mint no new key.
filterAppForUserconsults the object'senableblock for everytype: 'object'nav entry and prunes entries whose destination cannot serve. ⛔ Option (a) is rejected — re-meaningrequiresObjectserver-side would overturn a pin the docblock calls deliberate. ⛔ Option (b) is rejected — a newrequiresServableObjectdeclaration is permanent ADR-0087 vocabulary bought for a condition the platform can already derive.Mandatory companion (not optional): a publish-time diagnostic naming the pruned entry and the condition that pruned it — same principle as the #7741 ruling, no silent dead rows and no silent repairs. A prune the author cannot see is the same failure one layer over.
Ruled as an instance of the standing principle adopted in the same ruling (covering #7893 / #7529 / #8010):
If the platform cannot honour a declaration, refuse it at the latest checkpoint that can see the whole picture (publish), name the offending key path, and never answer 200.
Binding scope carried from the card:
nav_api_keys→sys_api_keymust survive the pruning change (verify explicitly);⚠️ sys_jwks'senableblock is ⛔ out of scope — opening a read path onto it is a credential disclosure, not a fix. Reuse #7909's invariant test (describe('object entries can actually serve a list (#7544)')) rather than re-copying it, and check the Account-app sibling entry from the same QA sweep — it closes with this or not at all.
Generated by Claude Code
Lane transfer:
domain:spec→domain:cli,pm:queuekept. Executed by the spec seat (sessionsession_01Euoy6wyfzgiWtgCg4s6JK2) under the standing lane-triage authorization.Provenance: the maintainer ruling on this card (2026-08-12, comment id 5271014830, option (c) — derive servability, mint no new key) adopted the decision analysis in full, and that analysis carries the routing note verbatim: "Routing note if ruled (c): the implementation lands in
packages/rest(filterAppForUser) ⇒ likely re-routedomain:clifor the code".packages/restis thedomain:clilane's package family, so this card now belongs to that seat's queue.For the receiving seat, the binding constraints already on this card: mandatory publish-time diagnostic naming the pruned entry and its condition;
nav_api_keys→sys_api_keymust survive (verify explicitly);sys_jwks⛔ out of scope; reuse #7909's invariant test rather than copying it; close the Account-app sibling entry in the same change. The spec seat holds no remaining half here — the contract statement (the docblock's "NOT gated here" list) travels with thepackages/restchange.
Generated by Claude Code
Claim: PM loop round 1 (
domain:cliseat #6024)
Session:session_01P7vaLs7bhBPi9m3JyzkhDj
Branch:claude/issue-7912-nav-servability-prune
Worktree:objectstack-issue-7912
Domain:domain:cli
File surface:packages/rest/src/rest-server.ts(filterAppForUseronly — the nav-entry filter region, not the data-route region) + its sibling test files inpackages/rest, + the Account-app entry inpackages/platform-objects/src/apps/account.app.tsif the sweep requires it. (stop on breach; explain in the report)
Container & model:L,mode:subagent,model: opus
Serial constraints cleared:rest-server.tsis the repo's hottest file — #8039 is held behind this card (it edits the docblock aboveDATA_RECORD_READ_PARAMS, a different region, but same file ⇒ hard serial, never the same batch). No in-flight PR touchesrest-server.ts: #8369's five files aremetadata-protocol+objectqlonly (checked file-by-file), #8377 isobjectql, #8365 is.claude/+scripts/. Accepted this round: the lane transfer fromdomain:spec(2026-08-13 04:20Z).Ruling is binding and already on the record — maintainer, 2026-08-12, accepting the decision-box analysis in full (「接受你的全部建议。」): option (c) — derive it, mint no new key.
filterAppForUserconsults the object'senableblock for everytype: 'object'nav entry and prunes entries whose destination cannot serve.⛔ Option (a) rejected — re-meaning
requiresObjectserver-side would overturn a pin the docblock calls deliberate. ⛔ Option (b) rejected — a newrequiresServableObjectkey is permanent ADR-0087 vocabulary bought for a fact the platform can already derive.Mandatory companion, not optional: a publish-time diagnostic naming the pruned entry and the condition that pruned it. A prune the author cannot see is the same failure one layer over — no silent dead rows, and no silent repairs. This is an instance of the standing principle adopted in the same ruling: if the platform cannot honour a declaration, refuse it at the latest checkpoint that can see the whole picture, name the offending key path, and never answer 200.
Binding constraints carried from the card:
nav_api_keys→sys_api_keymust survive the pruning change. Verify explicitly — this is the control that fails if the derivation is too aggressive.⚠️ sys_jwksstaysapiEnabled: falseand is ⛔ out of scope. Opening a read path onto it is a credential disclosure, not a fix.- Reuse fix(platform-objects): drop the dead Setup › Advanced › Signing Keys (JWKS) nav entry (#7544) #7909's invariant test (
describe('object entries can actually serve a list (#7544)')) — ⛔ do not copy it into a second gate order that can drift from the first. - Derive through the existing 跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #3391 source (
resolveEffectiveApiMethods/isApiOperationAllowed), ⛔ not a second copy of the gate order. - The Account-app sibling entry from the same QA sweep closes with this card or not at all.
⚠️ Verify every specific lead above, do not inherit it. Named symbols and file regions are this lane's most frequently stale sentences — the previous seat was corrected by its developers nine times in one shift, every time in the most precise clause of the brief. IfresolveEffectiveApiMethodsis not the live derivation seam, or #7909's test is not shaped the way this brief assumes, report what you measured and take the path that serves the ruling's intent. The ruling itself is not reopenable; the mechanism I have guessed at is.Run every build and test as a foreground Bash call and block on it (raise
timeout, up to 600000 ms, for slow steps; split long steps rather than backgrounding). ⛔ Do not background anything, ⛔ do not poll for a notification — an agent waiting on a completion notice that cannot arrive is this lane's most common stall.
Generated by Claude Code
{ "issue": 7912, "status": "done", "branch": "claude/issue-7912-nav-servability-prune", "pr": "https://github.com/objectstack-ai/objectstack/pull/8426", "premise_still_valid": true, "summary": "Implemented the 2026-08-12 ruling's option (c) in full. `filterAppForUser` now derives servability for every `type: 'object'` nav entry and prunes those whose destination cannot serve a `list`, on both `/meta` app routes and inside `children` and `areas[]`, via the same `filterNav` the other gates use; the gate is resolved once per request from object metadata, mirroring the ADR-0057 D10 `serviceGate` pattern. The mandatory publish-time companion landed as a new gating reference-integrity rule `nav-object-unservable` in `packages/lint`, which fails `os validate` / `os build` / `os lint` naming the entry, the object, the condition that fired and the offending `enable` key path; the serving side logs the same facts, throttled. To honour the card's ⛔ against a second copy of the gate order, the two-step order was extracted once into `apiExposureDenialReason` / `canServeApiOperation` in `@objectstack/spec/data` — the REST data gate, the nav prune and #7909's invariant test now all read that one export, and #7909's local `canList` was rewired to it rather than duplicated. THREE PM-brief specifics were falsified and are reported rather than inherited: (1) every cited line number was stale — `filterAppForUser` is at :2946 and delegates to `filterAppForUserWithReason` at :2994, `apiAccessDenialFromEnable` is at :1439, not :1665/:1658; (2) the Account-app sibling is #7555 from the same QA run #7514 and was ALREADY CLOSED by merged PR #7605 — its cause was permission composition, which is persona-dependent and which option (c) neither can nor should touch, so no edit to `account.app.ts` was required and what the card owed it is a no-regression proof, which is asserted; (3) the file surface had to widen beyond the brief's `packages/rest` + `account.app.ts` — the mandatory diagnostic has no home in `packages/rest`, so `packages/lint` (new rule + suite wiring) and `packages/spec` (the single shared gate order) are included. Zone 3's suggested route held: the prune decision is genuinely persona-independent and sits outside the permission logic. ⚠️ Surface note: the card's own scope line named `packages/rest` only; the widening is the ruling's 'mandatory companion, not optional' clause, flagged here for the PM rather than done silently.", "tests": "Ablation (fix committed FIRST, derivation then removed, then restored via `git checkout <branch> -- <path>` — never against an uncommitted edit): derivation in place `Test Files 1 passed / Tests 16 passed (16)`; derivation removed `Tests 8 failed | 8 passed (16)`. The 8 reds are exactly the pruning + diagnostic assertions (incl. the `nav_api_keys` control, which asserts the whole surviving set so it cannot pass vacuously); the 8 that stay green are exactly the fail-open 'must NOT prune' cases, which SHOULD survive an ablation — direction predicted before running and observed as predicted. Full suites on the merged tree (origin/main advanced twice mid-flight: 84c07c3 -> ebb14ece9; merged, then artifacts regenerated with `scripts/pm/os-regen-merge.sh`, which caught both `api-surface/data.json` and `export-origins/data.json` as merged-without-text-merge and required a spec rebuild before regeneration): @objectstack/dogfood FULL suite (run as dispatched, cross-package contract change) `Test Files 106 passed | 1 skipped (107)` / `Tests 728 passed | 2 expected fail | 3 skipped (733)`; @objectstack/rest `112 passed (112)` / `1843 passed (1843)`; @objectstack/spec `390 passed (390)` / `10331 passed (10331)`; @objectstack/lint `72 passed (72)` / `1975 passed (1975)`; @objectstack/cli `116 passed (116)` / `1265 passed (1265)`; @objectstack/platform-objects `17 passed (17)` / `351 passed (351)`; @objectstack/runtime `150 passed (150)` / `2306 passed (2306)`. `spec check:generated` = 'All 13 generated artifacts are up to date'. typecheck clean on spec/lint/rest/platform-objects; eslint clean on every changed file (the one rest-server.ts finding is a pre-existing `ban-ts-comment` directive present on origin/main, outside my hunks). Gate families derived against my ACTUAL changed paths with `node scripts/pm/dispatch-gates.mjs` (the dispatch prompt named none) — 16 families run, all PASS: nul-bytes, route-envelope, authz-resolver, meta-type-normalized, cross-package-test-inputs, spec-parsed-alias, adr-anchors, filter-alias-parity, query-options-erasure, type-check-coverage, driver-conformance, changeset-gate-self-tests, i18n, type-source-resolution, docs-audit-scope, release-body. `examples/app-showcase` builds green, which is `os build` running the new GATING rule against a real stack — the check that the rule cannot break the platform's own examples. Serial-file constraint honoured: #8039's region (docblock above `DATA_RECORD_READ_PARAMS`, origin/main :1480-1515) NOT touched — my nearest hunk ends at old line 1470 where `apiAccessDenialFromEnable` closes, with `APPROVAL_REQUEST_LIST_PARAMS` in between.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
Generated by Claude Code
ACCEPT — PR #8426, reviewed by the
domain:cliseat (#6024, sessionsession_01P7vaLs7bhBPi9m3JyzkhDj). ⏳ Flip held: CI has not converged, and a cross-seat declaration to thedomain:specseat is posted below. Card stayspm:dispatched.Verified against GitHub. 11 files, +905/−20:
area files packages/restrest-server.ts(+182/−13), newmeta-app-nav-servability-gate.test.ts(+329)packages/lintnew validate-nav-object-servability.ts(+202), suite wiring + index + its testpackages/specone new file src/data/api-derivation.ts(+72);api-surface/data.jsonandexport-origins/data.json(+3 each, regenerated)packages/platform-objectsplatform-objects.test.ts(+13/−6) — #7909's test rewired, not copiedchangeset nav-servability-prune.md⛔ No
content/docs/releases/**. Thepackages/spectouch adds a file and modifies no existing spec source, which materially lowers the risk of the boundary question below.The ruling was implemented whole, including the half that is easy to skip
Option (c) landed with the mandatory publish-time companion —
nav-object-unservableinpackages/lint, which failsos validate/os build/os lintand names the entry, the object, which of the two conditions fired, and the offendingenablekey path. It gates aterrorwhere its siblingvalidate-nav-accessonly warns, and the asymmetry is argued rather than assumed:enableis declared on the object in the same stack, so a finding is a certainty rather than a suspicion, and objects the stack does not declare are skipped because theirenableis not visible from there. That is the "no silent dead rows and no silent repairs" clause actually built, not restated.The ablation is the right shape, including what stayed green
state result derivation in place 16 passed derivation removed 8 failed / 8 passed ⭐ The 8 survivors are exactly the fail-open "must NOT prune" cases — they should survive an ablation, and a run where all 16 went red would have meant the gate was pruning things it must not. Direction predicted in writing before the run. The
nav_api_keys→sys_api_keycontrol asserts the whole surviving set, so an over-pruning derivation cannot pass by keeping one row alive;sys_jwksis untouched.Three of my own brief's specifics were falsified, and reporting them was correct
- Every line number I passed on was stale.
filterAppForUseris at:2946and delegates tofilterAppForUserWithReasonat:2994;apiAccessDenialFromEnableis at:1439, not:1665/:1658. I flagged that risk in the brief and it landed on all three anchors. - The Account-app sibling is An app-declared permission baseline REPLACES the platform
member_defaultinstead of composing with it — every built-in Account destination 403s for members of any such app #7555, and it was already closed by merged PR fix(security): an app-declared baseline composes with the platformmember_defaultinstead of replacing it (#7555) #7605 — its cause was permission composition, which is persona-dependent and which option (c) neither can nor should touch. The ruling said it "closes with this card or not at all"; the honest answer was that it had already closed by other means, so what this card owed it was a no-regression proof, which is asserted (all six Account-app destinations grantlist). Measuring that instead of editingaccount.app.tsto satisfy a stale sentence is the better outcome. - The file surface had to widen — and that one is my error, not the dev's. My brief demanded a mandatory publish-time diagnostic while declaring a surface (
packages/rest+account.app.ts) that has no home for one. A diagnostic that fires at authoring time cannot live in the serving package. The dev flagged the widening for the PM rather than performing it silently, which is exactly the behaviour the claim protocol exists to produce.
Also worth recording:
origin/mainadvanced twice mid-flight, andscripts/pm/os-regen-merge.shcaught bothapi-surface/data.jsonandexport-origins/data.jsonas merged-without-text-merge, requiring a spec rebuild before regeneration. That is precisely the silent-drop failure the four-step sequence exists to catch, firing on a real PR.Cross-seat boundary — declared to
domain:spec, not decided unilaterallypackages/spechas exactly one owner and it is not this seat. Declaration posted on seat #6017; summary of the reasoning:- The change is additive — one new file, no existing spec source modified — and behaviour-preserving: it consolidates a two-step gate order that already existed in three separate spellings into one export, and rewires all three consumers to it.
- It is what my own binding "⛔ do not write a second copy of the gate order" instruction forces. The alternative was a fourth copy.
- The public-surface growth is measured and tracked:
api-surface/data.jsonandexport-origins/data.jsonmoved by +3 each, which is the repo's own ledger for exactly this, andspec check:generatedreports 13/13 up to date. Spec's full suite is 10331 passed.
⚠️ Two honest caveats I am not hiding behind the above: adding exports does widen a public surface, and the model-tier rule puts contract-surface cards onclaude-fable-5while this ran onopus. This card's deliverable is apackages/restnav bug, so I do not read the Fable floor as having applied — but a seat with a different reading would not be wrong, and that is the spec seat's call to make, not mine.Veto window open until merge, and actionable after it — a new additive file can be relocated, renamed or split out in a patch round; it is not a closed question.
Landing
CI has not converged on this head. When ESLint and TypeScript Type Check both report
conclusion: successand the spec seat has had its window: mark ready, thenenable_pr_auto_merge. #8373 (target:v17) is queued behind this onrest-server.tsand goes out the moment this merges — the dev confirmed #8039's region was untouched (nearest hunk ends at old:1470, withAPPROVAL_REQUEST_LIST_PARAMSin between), so that hand-off is clean too.
Generated by Claude Code
- Every line number I passed on was stale.
CI turned red after the report — patch round, not REWORK. PR #8426,
domain:cliseat (#6024). Original dev continued viaSendMessage; the ACCEPT above stands, the flip stays held. Card remainspm:dispatched.The job name was not the failure — recording the diagnosis, since this one is instructive
The failing job is called
TypeScript Type Check. It is not a compile error and not in the diff's source. That job runspnpm check:type-check-debt, and what failed is the ratchet:check-type-check-coverage --re-measure: 1 ledger entr(ies) drifted upward • @objectstack/rest: TEST_DEBT records 155 raw tsc error(s), `tsc --noEmit` now reports 156 (+1). TEST_DEBT is frozen debt, not a permission slip -- the ledger is a ratchet and may only shrink.Ruling from the job name would have sent the dev looking for a type error in
rest-server.tsthat does not exist. Opening the log is the whole difference.Why the dev's clean typecheck and CI's red are both honest
packages/restis one of 20 packages that hide their own tests from tsc. A package-leveltypecheck— which the dev ran, and which was genuinely clean — structurally cannot see the test layer. The newmeta-app-nav-servability-gate.test.ts(+329) is the obvious source of the +1.This is the lane's recorded trap in its exact shape: the ratchet only bites test-layer type errors, and "typecheck clean" means package-level. Two independent green readings, one real defect between them.
The instruction, and the one thing it is not
⛔ The ledger is not to be raised. The gate's own output offers "raise the entry" as an alternative, and the standing rule forbids it — raising TEST_DEBT is how a real type error becomes permanent. Fix the type.
⚠️ Worth stating because the two directions genuinely conflict:check:route-envelopedemands a declared number be lowered when code improves, whilecheck:type-check-debtmust never be raised. An agent pattern-matching "never touch the ledger" freezes on the first; one pattern-matching "the gate told me to adjust it" breaks the second. Both gates are named after ledgers and they want opposite things.The dev was told to attribute the delta rather than assume it — the gate itself says an unattributable delta must be reported, not papered over with invented composition — and that if the +1 turns out to be pre-existing on
origin/main(which advanced twice mid-flight), that is a finding to route rather than something to fix here.⛔ Explicitly out of play for this patch: the
packages/specextraction, which is under the cross-seat declaration on seat #6017 and unaffected by this failure.
Generated by Claude Code
- added a commit that references this issue
on Aug 13, 2026 Patch round 1 on PR #8426 —
check:type-check-debtratchet. Pushedeed19eeeff54897ffa49d03ba849b3fce2321d5e.{ "issue": 7912, "status": "done", "branch": "claude/issue-7912-nav-servability-prune", "pr": "https://github.com/objectstack-ai/objectstack/pull/8426", "premise_still_valid": true, "summary": "Patch round: fixed the +1 TEST_DEBT drift that turned `TypeScript Type Check` red. The error was mine and it was ONE line — `import { RestServer } from './rest-server';` in my new `packages/rest/src/meta-app-nav-servability-gate.test.ts`, which is a TS2835 because the package resolves NodeNext. Fixed by writing the specifier as `./rest-server.js` (plus a comment recording why a new file must not join the frozen TS2835 pile). ⛔ The TEST_DEBT entry was NOT raised and `scripts/check-type-check-coverage.mjs` was NOT touched — the ratchet only shrinks, and it now reports every entry sitting exactly at its measurement. Nothing else changed: one file, +5/-1, no changeset edit, no touch to the `packages/spec` extraction. Verified pre-existing-vs-mine before writing the fix, as instructed: the drift is entirely mine, nothing on origin/main moved.", "tests": "ATTRIBUTION (done before any fix, per the instruction not to assume). The package-level `pnpm --filter @objectstack/rest typecheck` is genuinely clean — `packages/rest` hides its own tests from tsc, so nothing package-local can see the test layer. Replayed the ledger's own program instead (the package tsconfig with the test globs dropped from `exclude`, typeRoots defaulted, `tsc --noEmit --pretty false`): total `156` diagnostics, matching the gate's report exactly; excluding my file `155`, which is EXACTLY the recorded TEST_DEBT number; in my file `1`. So the +1 is wholly mine and nothing pre-existing drifted — not a main-side finding. The single error: `packages/rest/src/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'?`. AFTER THE FIX: same replayed program reports `155` total / `0` in my file. The real gate as CI runs it (`--self-test` then `--re-measure`, with the full closure built first via `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` — the script HARD-REFUSES to measure without it, naming the 'silently measure a DIFFERENT WORLD' trap) now reports: `--self-test` 23 semantic + 24 observation + 25 re-measure + 28 built-closure + 9 auto-lowering cases hold; `--re-measure: OK — 33 ledger entr(ies) re-measured in 312.2s, 1969 raw tsc error(s) total, none above its recorded number. surplus: none — every entry sits exactly at its measurement, so any new error is red.` Behaviour unchanged by the specifier fix: `meta-app-nav-servability-gate.test.ts` 16 passed (16); full `@objectstack/rest` suite 112 files / 1843 tests passed; `pnpm --filter @objectstack/rest typecheck` clean; `check:nul-bytes` PASS. Diff scope confirmed: `1 file changed, 5 insertions(+), 1 deletion(-)`.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
Generated by Claude Code
Patch round closed — ACCEPT stands. PR #8426 @
eed19eee,domain:cliseat (#6024). CI is re-running on the new head; flip still held, and the cross-seat declaration todomain:spec(#6017) is unchanged and unaffected.Scope: 1 file, +5/−1, inside the dev's own new test file. No changeset edit, no touch to the
packages/specextraction, and — the thing that mattered — ⛔ the ledger was not raised andscripts/check-type-check-coverage.mjswas not touched.The error, and why it was not carelessness
meta-app-nav-servability-gate.test.ts(22,28): error TS2835: Relative import paths need explicit file extensions … Did you mean './rest-server.js'?import { RestServer } from './rest-server'→'./rest-server.js'. The package resolves NodeNext, so the extensionless spelling is a hard error — and⚠️ that spelling is what the package's older test files use: 67 of them are frozen in the ledger. The dev followed the local convention, and the local convention is the frozen debt. A new file in that package inherits the house style straight into a red.Attribution before fix, which is what the gate demands
I asked for the delta to be attributed, not assumed, because an unattributable +1 must be reported rather than papered over. The dev replayed the ledger's own program (package tsconfig with the test globs dropped from
exclude):measurement count total diagnostics 156 — matches the gate exactly excluding the new file 155 — exactly the recorded TEST_DEBT in the new file 1 after the fix 155 total / 0 new ⇒ The +1 is wholly this branch's, and nothing pre-existing on
origin/maindrifted. That rules out the finding case I asked to be checked for.⭐ Two facts about this gate worth carrying into future dispatches
--re-measurehard-refuses to run without the full build closure, naming the "silently measure a DIFFERENT WORLD" trap — a number taken without it is not the package's debt. ⇒ The dev could not have caught this locally even if it had thought to run the ratchet, because the honest way to run it costs a full workspace build. That reframes the failure from "the dev skipped a check" to "the check is not runnable at dev cost", which is a different problem with a different fix.- The ledger currently has zero headroom — the gate now reports "surplus: none — every entry sits exactly at its measurement, so any new error is red." Every new test file in any of the 20 test-layer-hidden packages is one house-style import away from a red, with no local signal.
Together those two facts say the trap will recur, and that the cheap mitigation is a brief-level one: when a card adds a test file to a test-layer-hidden package, say so and name the extension convention. That is a lane change rather than a card change; recording it here and I will route it properly rather than growing this card.
Landing
@objectstack/restre-verified green (112 files / 1843 tests), the new file 16/16, typecheck andcheck:nul-bytesclean. On CI convergence — ESLint and TypeScript Type Check bothconclusion: success— and with the spec-seat window respected: mark ready, thenenable_pr_auto_merge. #8373 (target:v17, maintainer-prioritised) is queued directly behind this onrest-server.ts.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
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