Skip to content

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

Description

@huangyiirene

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:

  1. 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.
  2. 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 requiresObject is 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

Activity

  1. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Moved pm:queue → needs-user-decision by the spec seat (session session_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 in rest.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:

    1. 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.
    2. Measured business pull — real, measured twice: Setup › Advanced › Signing Keys (JWKS) is a dead nav entry — sys_jwks is API-disabled and a requiredPermissions gate 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.
    3. 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/apiMethods condition 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).
    4. 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) — filterAppForUser consults servability for every type: '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_key must survive any pruning change; sys_jwks stays apiEnabled: false and ⛔ out of scope. Note (c) leaves requiresObject's client-only pin untouched — no pin needs overturning.

    Routing note if ruled (c): the implementation lands in packages/rest (filterAppForUser) ⇒ likely re-route domain:cli for 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

  2. added theissue type on Aug 12, 2026
  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    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. filterAppForUser consults the object's enable block for every type: 'object' nav entry and prunes entries whose destination cannot serve. ⛔ Option (a) is rejected — re-meaning requiresObject server-side would overturn a pin the docblock calls deliberate. ⛔ Option (b) is rejected — a new requiresServableObject declaration 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_key must survive the pruning change (verify explicitly); ⚠️ sys_jwks's enable block 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

  4. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Lane transfer: domain:spec → domain:cli, pm:queue kept. Executed by the spec seat (session session_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-route domain:cli for the code". packages/rest is the domain:cli lane'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_key must 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 the packages/rest change.


    Generated by Claude Code

  5. self-assigned this
    on Aug 13, 2026
  6. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1 (domain:cli seat #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 (filterAppForUser only — the nav-entry filter region, not the data-route region) + its sibling test files in packages/rest, + the Account-app entry in packages/platform-objects/src/apps/account.app.ts if the sweep requires it. (stop on breach; explain in the report)
    Container & model: L, mode:subagent, model: opus
    Serial constraints cleared: rest-server.ts is the repo's hottest file — #8039 is held behind this card (it edits the docblock above DATA_RECORD_READ_PARAMS, a different region, but same file ⇒ hard serial, never the same batch). No in-flight PR touches rest-server.ts: #8369's five files are metadata-protocol + objectql only (checked file-by-file), #8377 is objectql, #8365 is .claude/ + scripts/. Accepted this round: the lane transfer from domain: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. filterAppForUser consults the object's enable block for every type: 'object' nav entry and prunes entries whose destination cannot serve.

    ⛔ Option (a) rejected — re-meaning requiresObject server-side would overturn a pin the docblock calls deliberate. ⛔ Option (b) rejected — a new requiresServableObject key 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:

    ⚠️ 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. If resolveEffectiveApiMethods is 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

  7. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor
    {
      "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

  8. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #8426, reviewed by the domain:cli seat (#6024, session session_01P7vaLs7bhBPi9m3JyzkhDj). ⏳ Flip held: CI has not converged, and a cross-seat declaration to the domain:spec seat is posted below. Card stays pm:dispatched.

    Verified against GitHub. 11 files, +905/−20:

    area files
    packages/rest rest-server.ts (+182/−13), new meta-app-nav-servability-gate.test.ts (+329)
    packages/lint new validate-nav-object-servability.ts (+202), suite wiring + index + its test
    packages/spec one new file src/data/api-derivation.ts (+72); api-surface/data.json and export-origins/data.json (+3 each, regenerated)
    packages/platform-objects platform-objects.test.ts (+13/−6) — #7909's test rewired, not copied
    changeset nav-servability-prune.md

    ⛔ No content/docs/releases/**. The packages/spec touch 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-unservable in packages/lint, which fails os validate / os build / os lint and names the entry, the object, which of the two conditions fired, and the offending enable key path. It gates at error where its sibling validate-nav-access only warns, and the asymmetry is argued rather than assumed: enable is 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 their enable is 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_key control asserts the whole surviving set, so an over-pruning derivation cannot pass by keeping one row alive; sys_jwks is untouched.

    Three of my own brief's specifics were falsified, and reporting them was correct

    1. Every line number I passed on was stale. filterAppForUser is at :2946 and delegates to filterAppForUserWithReason at :2994; apiAccessDenialFromEnable is at :1439, not :1665/:1658. I flagged that risk in the brief and it landed on all three anchors.
    2. The Account-app sibling is An app-declared permission baseline REPLACES the platform member_default instead 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 platform member_default instead 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 grant list). Measuring that instead of editing account.app.ts to satisfy a stale sentence is the better outcome.
    3. 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/main advanced twice mid-flight, and scripts/pm/os-regen-merge.sh caught both api-surface/data.json and export-origins/data.json as 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 unilaterally

    packages/spec has 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.json and export-origins/data.json moved by +3 each, which is the repo's own ledger for exactly this, and spec check:generated reports 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 on claude-fable-5 while this ran on opus. This card's deliverable is a packages/rest nav 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: success and the spec seat has had its window: mark ready, then enable_pr_auto_merge. #8373 (target:v17) is queued behind this on rest-server.ts and goes out the moment this merges — the dev confirmed #8039's region was untouched (nearest hunk ends at old :1470, with APPROVAL_REQUEST_LIST_PARAMS in between), so that hand-off is clean too.


    Generated by Claude Code

  9. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    CI turned red after the report — patch round, not REWORK. PR #8426, domain:cli seat (#6024). Original dev continued via SendMessage; the ACCEPT above stands, the flip stays held. Card remains pm: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 runs pnpm 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.ts that does not exist. Opening the log is the whole difference.

    Why the dev's clean typecheck and CI's red are both honest

    packages/rest is one of 20 packages that hide their own tests from tsc. A package-level typecheck — which the dev ran, and which was genuinely clean — structurally cannot see the test layer. The new meta-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-envelope demands a declared number be lowered when code improves, while check:type-check-debt must 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/spec extraction, which is under the cross-seat declaration on seat #6017 and unaffected by this failure.


    Generated by Claude Code

  10. added a commit that references this issue on Aug 13, 2026
  11. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Patch round 1 on PR #8426 — check:type-check-debt ratchet. Pushed eed19eeeff54897ffa49d03ba849b3fce2321d5e.

    {
      "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

  12. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Patch round closed — ACCEPT stands. PR #8426 @ eed19eee, domain:cli seat (#6024). CI is re-running on the new head; flip still held, and the cross-seat declaration to domain: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/spec extraction, and — the thing that mattered — ⛔ the ledger was not raised and scripts/check-type-check-coverage.mjs was 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/main drifted. That rules out the finding case I asked to be checked for.

    ⭐ Two facts about this gate worth carrying into future dispatches

    1. --re-measure hard-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.
    2. 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/rest re-verified green (112 files / 1843 tests), the new file 16/16, typecheck and check:nul-bytes clean. On CI convergence — ESLint and TypeScript Type Check both conclusion: success — and with the spec-seat window respected: mark ready, then enable_pr_auto_merge. #8373 (target:v17, maintainer-prioritised) is queued directly behind this on rest-server.ts.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions