fix(cli): widen check:app-nav-i18n from one app to the declared platform-app population - #17972
Conversation
…orm-app population
The gate advertises "every id labelled in every locale" and was structurally
blind to a whole app. Measured, not inferred: it printed a byte-identical
`OK (10 contributor(s), 54 merged setup nav id(s), 4 locale(s), ...)` line both
before and after the Account app's contributed `nav_connect_agent` label landed.
Six sites narrowed it to `setup`, and only three were the obvious filters:
- the contribution filter, the app-shell filter, the merged-app lookup;
- the LOCALE-FILE lookup `data.apps.<app>.navigation` -- widening the first
three without this one collects `account` ids and then looks for their
labels under `apps.setup.navigation`;
- the build prerequisite, a package path hard-coded to `@objectstack/setup`;
- the CONTRIBUTORS roster, which booted no package registering the Account
shell, so `account` had no merged app to judge at all.
The population is now declared with the criterion that decides it: an app is
judged iff the ADR-0048 platform-app loop registers its shell by default AND at
least one package contributes navigation into it at runtime. That is why
`studio` (static nav, not default-loaded, already owned by
`app-nav-translation-parity.test.ts`) and `crm_app` (an example app with no
locale source here) are out.
The per-contributor "landed at least one nav id" invariant is applied PER APP.
A flat union across apps reads as the natural generalisation and is a softening:
`@objectstack/mcp` serves both apps, and a union keeps it passing on its
`account` id alone after its `setup` contribution silently stops -- the exact
"fewer ids means fewer checks" false green that invariant exists to catch.
`--self-test` carries the union as a negative control.
Every verdict, the refusal advisory and the pass line now name the app they are
actually about, and the pass line carries a per-app id count -- a collapsed
total would rebuild the very blindness this change removes.
The `setup` judgement is unchanged: 54 merged ids before, 54 after.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewPR #17972 · card #17891 · head
① ⭐ The finding that overturns BOTH earlier counts — a narrowing by absenceTriage named three narrowing sites. This seat's claim pre-check found a fourth (
Verified here: ⭐ ⛔ No grep of A sixth diagnostic string was found the same way: ② The population is now decided by a written criterion, ⛔ not by a listThe order's acceptance item 1 demanded the criterion, not just the sites. Delivered at ③ ⭐ The trap it guarded, which the order did not nameThe per-contributor “landed at least one nav id” invariant had to stay per app. A flat union across apps reads as the obvious generalisation and is a softening of the existing The delivery does not merely avoid it — it constructs the union as a negative control in ④ Two directions, with the one moving number declared rather than smoothed⭐ The Must-catch (relayed): delete one of #17759's four
⑤ Fences — measured⛔ No new translation key in any locale. ⛔ The existing ⑥ Declared gaps, ⛔ not smoothed⭐ The one worth repeating: Also declared: the integration tier (no spawn entry, no Verdict and what remainsPASS. The card asked for one gate's population to be widened; what landed is a gate that states the rule deciding its own population, names the app it is actually failing on, and pins the wrong generalisation as wrong.
⇒ This seat cannot re-run them —
Generated by Claude Code |
objectstack-ai#18142) Closes objectstack-ai#17648 `Clause-②: no` objectstack-ai#17646 delivered objectstack-ai#16746's ruling by adding a second `navigationContributions` entry into the **`account`** app — deliberately *not* by ungating Setup, which was measured to expose 14+ unrelated Setup surfaces. So a non-admin can now reach the Connect-an-Agent page, but at **none of the paths the three shipped texts named**. This edits the prose; nothing else moves. ## The Account path, measured (not invented) | fact | value | read from | |:--|:--|:--| | target app | `account` | `packages/mcp/src/connect-ui.ts` (the second contribution) | | group | `grp_account_developer`, label **Developer** | `packages/platform-objects/src/apps/account.app.ts`; label in `apps/translations/en.ts` | | item | `nav_connect_agent`, label **Connect an Agent** | `connect-ui.ts`; label in `en.ts` (all four locales, per objectstack-ai#17759) | | page | `connect_agent` | `CONNECT_AGENT_PAGE` in `connect-ui.ts` | | package id | `com.objectstack.account` | `packages/apps/account/src/index.ts`, wired at `packages/cli/src/commands/serve.ts` | | route shape | `/apps/:appName/page/:pageName` | objectui `packages/app-shell/src/console/AppContent.tsx` | | segment resolution | `_packageId` first, app `name` as alias | objectui `packages/app-shell/src/utils/appRoute.ts` — `matchAppBySegment` | | how a user gets in | avatar menu → **Profile** mounts the Account shell; Developer stays reachable from its sidebar | objectui `packages/app-shell/src/layout/AppHeader.tsx` | ⇒ `/_console/apps/com.objectstack.account/page/connect_agent`, symmetric with the Setup URL the page already carried. `packages/apps/account/src/index.ts` states the pair in as many words: *"`/apps/(packageId)` (alias `/apps/account`) resolves to exactly this app"*. Permissions, also measured: `SETUP_APP` declares `requiredPermissions: ['setup.access']` (`setup.app.ts:47`), and Setup's API-keys entry additionally requires `manage_platform_settings` (`setup-nav.contributions.ts:102`), while `ACCOUNT_APP` declares none. The Account app's own **API Keys** entry is the `mine` list view filtered `user_id == {current_user_id}` with the `revoke_api_key` row action — so the *revoke* fact survives the move rather than being dropped. ## The three sites **1. `packages/mcp/src/plugin.ts`** — the stdio refusal message (a runtime string, read exactly when the user is stuck). Found at **:384**, not the card's `:372` — the reading had rotted; located by content. - before: `mint an API key (Setup → Connect an Agent, or POST /api/v1/keys)` - after: `mint an API key on the Connect an Agent page (Account → Developer for any signed-in user; Setup → Connect an Agent for admins), or POST /api/v1/keys` **2. `packages/mcp/README.md:92`** (line unmoved) — same substitution, in the `OS_MCP_STDIO_API_KEY` paragraph, with the README's existing bold convention. **3. `content/docs/ai/connect-mcp.mdx`** — the "Headless: API keys" section (97–104, unmoved) rewritten as **one page, two doors**, each with its console URL and its permission; the revoke sentence now sends a user to **Account → Developer → API Keys** and labels the tenant-wide **Setup → Access Control → API Keys** list with the permission it needs. Bounded in-place fix in the same file and defect class: the `OS_MCP_SERVER_ENABLED=false` callout at **:14** also called it "the **Setup → Connect an Agent** page". It now says "the **Connect an Agent** page … along with both its Setup and Account navigation entries", which is what objectstack-ai#17646's own changeset measured (an opted-out deployment gets no page and neither entry). ## Reverse-read, both directions - **Made false:** objectstack-ai#17648's own measurement *"nothing in the docs names the Account path"*. Reproduced on `origin/main` before editing — `Account app` / `/_console/apps/account` / `grp_account_developer` over `content/docs/` = **3** hits (an authorization note, an objectui action target, a v17-0 release page), all unrelated; firing control on the same expression = **5**. That count is the card's, not shipped prose, and is history once this lands. - **Made true:** the mint instruction and the revoke instruction are now followable by a permissionless principal, and the refusal message is actionable for an operator who is not a platform admin. - **Zero results, reported:** no test pins the refusal-message text (0 hits; control — tests referencing `OS_MCP_STDIO_API_KEY` = 5 files). No pin test reads this page's prose (control — `scripts/docs-audit/handwritten-docs.json` lists the file, so the path is right). ## Verification Repo-wide, not narrowed: `pnpm lint` (`eslint . --no-inline-config`) **exit 0** in 74s at `680f338de4`. `pnpm --filter @objectstack/mcp build && typecheck && test` — **31 files, 333 tests passed**, under `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`). Dependency closure `pnpm --filter '@objectstack/mcp^...' build` — `VERDICT command-exit 0`. Gate families derived from the real change set with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` and reconciled with `--ran`: **83 derived, 81 run green, 2 NOT MEASURED, 0 unrun**. The two are `check:dual-build-cjs-loads` and `check:lean-entry-closure`, both **exit 3 / PREREQUISITE NOT MET** — they read built output across ~77 packages this worktree has not built. ⛔ Not read as passes; declared to CI's Build Core job. `check:skill-examples` also refused a prerequisite first; I built `@objectstack/client` + `@objectstack/client-react` and re-ran it to a real verdict (**258 prose examples type-check across 3 surfaces**). Control-character self-scan over the four touched files: clean, with a firing control (`grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'`). No ablation: the change is prose and one string literal — there is no guard to delete and no assertion whose failure mode could be proven by mutation. ## 验收备注 **A brief premise that is now FALSE, and worth the seat's attention.** The dispatch and the card both state that `packages/cli/scripts/check-app-nav-i18n.mjs` *"scopes itself to `APP_NAME = 'setup'` (`:109`) and skips every other contribution target (`:581`), so nothing judges the account-side entry"*. That was true when objectstack-ai#17648 was filed and is **superseded**: PR objectstack-ai#17972 (objectstack-ai#17891) widened it to a declared population, `APPS = [{ name: 'setup' }, { name: 'account' }]` at **:161-164**, with `APP_NAMES` at :166 and the self-test invariants at :575/:580. The file's own header now names `nav_connect_agent` / `grp_account_developer` / `connect-ui.ts` explicitly. The card's **conclusion** still holds, for a different reason than it gave: that gate judges **locale-bundle labels**, never English prose in docs, a README, or a thrown `Error`. Nothing machine-checks these three claims, so the prose edit was still the only remedy. **Out of scope, filed separately as objectstack-ai#18143** — four more shipped pages carry the identical defect but lie outside this card's declared file surface: `content/docs/ai/agents.mdx:55`, `content/docs/api/index.mdx:68`, `content/docs/getting-started/build-with-claude-code.mdx:435`, `content/docs/deployment/environment-variables.mdx:259`. The last two are direct mint instructions, the same shape as the three fixed here. **Noted, not filed:** `docs/adr/0101-mcp-stdio-principal-admission.md:104`, `docs/qa/platform-checklist/areas/ai.json:206` and two `.changeset/` files also name "Setup → Connect an Agent". All four are dated records — a ruling, a test checklist and shipped release history — so ⛔ not edited and ⛔ not filed. **Card candidate deliberately NOT built here:** a cheap way to make these claims machine-checkable would be to extend the docs-drift check from advisory to a real gate over "console path named in prose resolves to a registered app + page". Out of scope for a p1 prose fix; reported rather than built, per the brief. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17891
Clause-②: no
packages/cli/scripts/check-app-nav-i18n.mjsis named "every id labelled in every locale" and was structurally blind to an entire app. It now judges the declared platform-app population — Setup and Account — with the criterion that decides that population written down beside it.1. Acceptance item 1 — the population, declared before any edit
This is the third independent count. Triage named 3 sites plus the declaration; the claim comment's pre-check found 2 more. Re-derived here on
origin/mainbdb247d9e, over the 662-line file.APP_NAMEoccurs 10× (grep -c APP_NAME= 10), and the full census of narrowing is wider than the spelling.Behavioural — these decide what is measured
:109const APP_NAME = 'setup'— the declaration every filter below reads:581if (contribution?.app !== APP_NAME) continue— the contribution filter:590if (app?.name !== APP_NAME) continue— the app-shell filter:601engine.registry.getApp(APP_NAME)— the merged-app lookup:628data?.apps?.[APP_NAME]?.navigation— the locale-file lookup:474join(CLI_ROOT,'node_modules','@objectstack','setup','dist','index.mjs')— a hard-coded package probe, not spelledAPP_NAMEat all:173–:243CONTRIBUTORSroster itself — ten entries, none of which registers the Account app shell. Narrowing by absence: nosetupliteral, noAPP_NAME, and no grep of either finds it⭐ The roster is a real seventh site, not a refinement of the others. Widening
:581/:590/:601/:628/:474alone leavesengine.registry.getApp('account')returningundefined, so the gate reports "theaccountapp is not registered at all" instead of judging a single label. The shell ships in@objectstack/account(createAccountAppPlugin), whichos serveloads in the ADR-0048 platform-app loop (packages/cli/src/commands/serve.ts:4001) and whichpackages/clialready depends on.Diagnostic — these decide which app the gate reports on
:162·:614·:621·:652·:660are the fiveAPP_NAME-interpolated strings the claim pre-check listed. ⭐ There is a sixth neither count named::496, insidebuildPrerequisiteText— "This gate boots the real Setup composition". It is plain prose rather than an interpolation, so a grep forAPP_NAMEmisses it, and after widening it would have described a Setup-only run while refusing on a missing Account build. All six now name the app they are actually about.Checked and rejected as sites — stated so the next reader does not re-open them
:519SetupAppTranslations— a setup-named export that in fact carries every app:en.tsdeclaresapps.accountandapps.setupside by side. A naming hazard, not a narrowing; the locale source was already app-agnostic and:628's subscript was the whole of it. Renaming it would touchpackages/platform-objects/**, which is fenced (app-nav-translation-parity 的反方向断言只走静态声明 —— 贡献式导航条目在它眼里不存在,而天真地扩到 ACCOUNT_APP 会把一个正确的翻译键判成孤儿(从 #17891 拆出) #17930).:262·:290–:320— self-test fixtures named for Setup that exercisecollectNavIds/missingLabels, both of which take the tree and the locale subtree as arguments. Already app-agnostic.:364·:412— the refusal fixture pins that the advisory names the path it was handed, deliberately not a re-derived one.The criterion — stated, not defaulted
An app is judged by this gate iff both halves hold:
packages/cli/src/commands/serve.ts; andmanifest.navigationContributions[].app), so a static walk cannot see every label it renders.Today that resolves to exactly
setupandaccount. ⛔ "All apps" is not the criterion, and each half excludes something real:studiofails both. The loop deliberately does not load it ("@objectstack/studio is intentionally NOT default-loaded"), andSTUDIO_APPdeclares its whole navigation statically with nothing contributing into it —app-nav-translation-parity.test.tsalready owns that verdict by a static walk. Adding it here would be a second owner for one route and would drag in a package the composition root never boots.crm_appfails (a) and has no locale source here at all: it is anexamples/app andSetupAppTranslationscarries noapps.crm_appsubtree to compare against.accountsatisfied both halves all along. It was invisible anyway.2. Acceptance item 2 — both directions fire
Every exit code below was captured before any pipe (
cmd > log 2>&1; EXIT=$?).The mutation: delete #17759's
enAccountnav_connect_agententry frompackages/platform-objects/src/apps/translations/en.ts, rebuild@objectstack/platform-objects, and prove the deletion reached the artifact the gate consumes. It was reverted; the diff of this PR does not touch that file.nav_connect_agentoccurrences in the source3 → 1; unique marker1 → 0;git diff --stat= 1 file, 10 deletionsos-verify-lock … VERDICT command-exit 0 · held the lock 27sablation-dist-preflight … --absent→ exit 0, "marker absent from all 66 built files"apps.account.navigation — locale `en` has no label for 1 runtime-merged nav id(s): nav_connect_agent contributed by @objectstack/mcp (CONNECT_AGENT_UI_BUNDLE) — author's literal "Connect an Agent"cmpexit 0, sha25660a5f61d…on both. That line ischeck-app-nav-i18n: OK (10 contributor(s), 54 merged `setup` nav id(s), 4 locale(s), every id labelled in every locale).— the card's reading, reproduced here rather than quotedgit checkout HEAD --→ blobda6690b9…equals the HEAD blob;git diff HEADempty; whole-treegit status --porcelainemptyVERDICT command-exit 0 · held the lock 28s; preflight (present mode) exit 0, "marker present in 6 built files"; ⛔ not skipped — a marker left indist/would have kept the mutation live for every later run in this worktreeMust not over-fire, unmutated tree:
The
setupverdict is unchanged and its count is unmoved: 54 → 54, exit 0 → exit 0. The contributor count moves10 → 11because the Account shell is a new roster entry — that is the one number this change necessarily moves, and it is stated rather than smoothed.account: 12is the eleven idsACCOUNT_APPdeclares plus the one@objectstack/mcpcontributes.3. The trap in widening, and the guard against it
The per-contributor "landed at least one nav id" invariant is the anti-false-green half of this gate. Widening it into one flat union across apps reads as the obvious generalisation and is a softening:
@objectstack/mcpserves both apps, so a union keeps it passing on itsaccountid alone after itssetupcontribution has silently stopped — the exact "fewer ids means fewer checks" false green the invariant exists to catch, restated one app wider.So the invariant is applied per app: each
CONTRIBUTORSentry declares itsapps, andcontributorsWithNoNavIdsis called once per app overcontributionsForApp(...)— the original helper is untouched.--self-testcarries the union as a negative control: it constructs the union projection and requires it to be the spelling that goes green, so the pin cannot pass by tautology.New
--self-testcoverage, all with their own negative controls: the union softening; a verdict that names the wrongappssubtree; a pass line that cannot notice an app leaving the population; and three reachability pins tyingAPPS,CONTRIBUTORSand the shell packages together.4. Published surface — measured, not assumed
Widening added
contributionsForAppandsummaryTextas module exports of the script. Measured againstpackages/cli/package.json:filesis["dist","README.md","CHANGELOG.md"]andexportsnames only.,./console,./hook-bodyand./package.json.packages/cli/scripts/**is not shipped and is not an entry point, and the file already exportedcollectNavIds,missingLabelsandcontributorsWithNoNavIdsbefore this PR. ⇒ No export was added to a published entry point, so the re-declaration the dispatch order reserved to the seat is not triggered. The changeset is written because the dispatch order requires one for@objectstack/cli; by the published-surface test alone nothing shipped moves.5. Fences observed
packages/platform-objects/**is not in this diff (app-nav-translation-parity 的反方向断言只走静态声明 —— 贡献式导航条目在它眼里不存在,而天真地扩到 ACCOUNT_APP 会把一个正确的翻译键判成孤儿(从 #17891 拆出) #17930 owns mechanism two). It was mutated and restored for the ablation only, proven byte-identical by blob hash and a whole-treegit status.nav_connect_agententry in the Account app has no translation key in any locale, so the same page renders 「连接智能体」 under Setup and "Connect an Agent" under Account for the same user #17759 already supplied the correct form; this is the last step of the standard repair order..claude/**,content/docs/releases/**: untouched.setupjudgement is never narrowed, skipped or softened — see §2 and §3.Acceptance notes
SetupAppTranslationsis a setup-named export carrying every app's subtree. Noted, not filed: the rename lands inpackages/platform-objects/**, which this card is fenced out of, and it is a naming observation rather than a defect — nothing resolves wrongly because of it. Carrier: none today; app-nav-translation-parity 的反方向断言只走静态声明 —— 贡献式导航条目在它眼里不存在,而天真地扩到 ACCOUNT_APP 会把一个正确的翻译键判成孤儿(从 #17891 拆出) #17930 is the next PR in that file's neighbourhood if a maintainer wants it swept.account: one booted composition cannot distinguish a gated-off contribution from a dead key, so the reverse direction for the new app stays outside this gate exactly as it does for Setup.packages/cli(os lint'snaming/namespace-prefixreports a legitimate cross-package name reuse as an intra-package duplicate, contradicting the ADR-0048 §3.4 sentence in its own message #17821 insrc/commands/, [finding] vitest 的 --project 过滤器落空即静默成功 —— 点名一个 integration 文件跑 --project unit,报它是通过的文件、执行零个用例,并把它从文件计数里减掉 #17853 invitest.config.ts). No file overlap. The vitest tier population is derived at config load, so tier file counts may move under this branch; ⛔ not reconciled here.6. Verification record
Every exit code captured before any pipe. All readings taken in the dedicated worktree at
04216260f, branched fromorigin/mainbdb247d9e.pnpm --filter @objectstack/cli run check:app-nav-i18n(self-test and real run)pnpm --workspace-concurrency=2 --filter '@objectstack/cli^...' build, under the shared heavy-verify lock, slotdev-17891VERDICT command-exit 0 · held the lock 405s)pnpm build --concurrency=2, under the lockVERDICT command-exit 0 · held the lock 473s; 73/73 tasks)pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2, under the lockpnpm --filter @objectstack/cli typecheck, under the lockpnpm lint(eslint . --no-inline-config), whole repo, not narrowednode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, each run separately with its own exit code recordednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran …over that recordpnpm check:nul-bytesplus a hand sweep of both changed files for every non-tab/newline C0 byte and DELpnpm check:dual-build-cjs-loadsreturned exit 3 —PREREQUISITE NOT METon its first run: it reads built output for every package, and only the@objectstack/clidependency closure was built at that point. ⛔ That is not a red gate and was not recorded as one — nothing was measured. The whole repo was then built and the family re-run to a real verdict: exit 0, 104 published require entry points across 67 packages.NOT MEASURED — declared, not smoothed over
packages/cli'stypecheckdoes not reach the changed file.packages/cli/tsconfig.jsondeclaresinclude: ["src"]with noallowJs, sotsc --noEmitnever compilespackages/cli/scripts/check-app-nav-i18n.mjs. Its green says nothing about this diff. What does cover the file:node --check, the script's own--self-test(which thecheck:app-nav-i18nscript runs before the real pass), and the real run itself.packages/cli'sintegrationtier was not run locally. This diff touches no spawn entry point, nobin/, notest/helpers/serve-process.tsand no driver or kernel boot path, so by the tier rule onlyunitis owed here. CI'spnpm testruns both.dispatch-gatesnames every one of them, and CI is their authority.origin/mainmoved during this run (bdb247d9e→225197cdbat the time of the derivation). This branch is not rebased onto it; the merge is CI's and the queue's to validate.Generated by Claude Code