Uh oh!
There was an error while loading. Please reload this page.
fix(app-shell): Studio package switcher reads the server's writable verdict, heuristic only as fallback (#7177) - #7331
Conversation
…erdict `GET /api/v1/packages` rows now carry a top-level `writable: boolean` computed server-side by `isWritablePackage` (ADR-0070 D2, objectstack#14375) — the same predicate the authoring and lifecycle gates enforce. `parsePackages` reads it when present and keeps `scope !== 'project'` only as the fallback for servers that predate the field. The heuristic is wrong for exactly one row: a `type: module` sub-package of a multi-package artifact (ADR-0130 D4) is served with no `scope` key, because the schema default is applied at parse time while the artifact load path hands the raw manifest body to `registerApp`. The heuristic reads it as a writable database base while the server refuses every write to it, and nothing in the raw row separates it from a scope-less Studio-created base that really is writable. Pins: scope-less rows in both verdict directions, `scope: 'project'` overridden in both directions, a non-boolean `writable` ignored, kernel packages hidden whatever verdict they carry, and a payload with no `writable` key anywhere producing output byte-identical to the pre-change capture. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…dio-picker-module-packages
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
hotlong
commented
Sep 2, 2026
PM 复审:PASS —— head |
| 断言 | 实测 |
|---|---|
| 服务端优先、缺失回落 | ✅ typeof p.writable === 'boolean' ? p.writable : scope !== 'project';回落表达式一字未改 |
| 非布尔不当裁定 | ✅ pin 4 用 'false' 字串在 scope:'project' 行上把"回落"与"强转"区分开(Boolean('false') 为 true 会在无 scope 行上巧合一致) |
| system/cloud 只管可见性 | ✅ 过滤保持在 writable 读取之前,注释改写为"visibility, not writability" |
| 逐位负向 pin | ✅ 期望值在未改动的树 ad3d4029 上采集后粘贴,不是重推 |
| 文档注释 | ✅ 把"缺 scope = 可写的 DB base"这条错误规则改写为"服务端是权威,启发式只是旧服务端回落,且对无 scope 的启动 module 行是错的",并写明机制(parse 时默认 vs 原始体) |
| 消融 | ✅ 预测先说:只有两条有区分度的 pin 红(1、3),其余与启发式同向的绿;实测 2 红 13 绿恰如预测;复原 blob = HEAD;无 dist 腿(相对路径导入)——声明正确 |
| 真实服务器 | ✅ app-todo:23 行全带 writable;com.example.todo 只读、duplicate 出的无 scope 键 base 可写;HMR console 指向该服务器渲染的是本分支代码 |
| 多包验证 | ⏸ 诚实尝试一次,defineStack 在作者态就拒(原文在正文);挂到 objectstack#14439 落地后由 PM 复跑(已记在该卡)。dev 顺带挖出第三道 parse 缝(metadata/plugin.ts:912),已并入 #14439 |
changeset @object-ui/app-shell: patch ✓;三个文件对受管面零命中(dev 跑过 check-governed-queue-guard --test,CI Governed Surface Queue Guard 亦绿)。
CI(ac9555b)
30 项全绿(含 Test 4 分片、Type Check、Lint、Build & E2E、Bundle Analysis、Governed Surface Queue Guard、Changeset 三项)。
收口
ready → squash auto-merge。落地即 Fixes #7177。多包 Studio 复跑与 .objectui-sha 提针由 PM 在 #14439 落地后跟进。
Generated by Claude Code
Fixes#7177
Part of objectstack-ai/objectstack#14122 — ADR-0130 Consequences row 6, the client half. Server half: objectstack-ai/objectstack#14375 (PR objectstack-ai/objectstack#14430).
Why
parsePackagesderived "writable" frommanifest.scopealone (scope !== 'project'). That is not the rule the server enforces.isWritablePackage(ADR-0070 D2) readsengine.manifestsfirst — a package booted from an artifact throughregisterAppis read-only whatever its scope says — and only then thesystem/cloudscopes. The two rules split on exactly the row ADR-0130 introduces:manifest.scopeengine.manifeststype: modulesub-package of a multi-package artifact (D4/D7 raw body)Nothing in the raw row separates those two; only the server's
engine.manifestsdoes. A client-side "missing scope means read-only" rule (the first fix proposed on the card, since withdrawn) would have flipped every Studio base read-only. So the verdict moved server-side and this PR consumes it.Measured on a live server (see Verification): the scope default is applied at PARSE time, while the artifact load path hands the RAW manifest body to
registerApp— so the served row has noscopekey at all, and the heuristic reads it as a writable database base.What changed
packages/app-shell/src/views/studio-design/packages-io.ts:parsePackagesuses the row's ownwritablewhen the server states one (typeof p.writable === 'boolean'), and falls back to the unchangedscope !== 'project'expression when the key is absent (older servers). A non-boolean value is not a verdict and falls back too.system/cloudhide filter is untouched, and now says in writing that it is about visibility, not writability.Changeset:
.changeset/7177-studio-switcher-server-writable-verdict.md(@object-ui/app-shell: patch).Pins
packages/app-shell/src/views/studio-design/packages-io.writableVerdict.test.ts— 5 tests:writable: falseon a scope-less row (the ADR-0130 module) is honoured, and the test asserts the row really carries no scope so the verdict cannot be leaking out of one.writable: trueon a scope-less row (a Studio base) is honoured.scope: 'project'is overridden in BOTH directions — serverfalsestays false, servertruewins over the heuristic.writable(the string"false") is ignored.Boolean('false')istrue, so a coercing read would have agreed with the fallback by accident on the scope-less row; thescope: 'project'row is what tells the two apart.Negative pin (same file): a payload with no
writablekey anywhere produces output deep-equal to the pre-change capture. The expected value was captured by runningparsePackagesagainst that payload on the UNTOUCHED tree atad3d4029abb949cb41815b6ce38d5e0ecad1486aand pasted in, never re-derived.Reverse verification — ablation, prediction stated first
Subject:
packages-io.tsrestored to its pre-change bytes from the pinned base commit, with the fix committed first so the restore leg has a real restore point.git hash-objectdiffers from the HEAD blob.Tests 2 failed | 13 passed (15), failing exactlyhonours writable:false on a scope-less rowandlets the server win over the heuristic in BOTH directions on scope:project.c94002e170b25bc7a2d40500e7adfef3a7328393equal to the HEAD blob,git diff HEADempty, 15/15 green again.No rebuild step applies: the pins import the subject by a relative specifier inside the same package, so nothing resolves through
dist.Verification at
dcef834pnpm exec vitest run packages/app-shell/src/views/studio-design/packages-io.writableVerdict.test.ts packages/app-shell/src/views/studio-design/packages-io.test.ts packages/app-shell/src/views/studio-design/packages-io.duplicateEnvelope.test.ts—Test Files 3 passed (3),Tests 23 passed (23), exit 0.pnpm exec vitest run packages/app-shell/src/views/studio-design/—Test Files 44 passed (44),Tests 236 passed (236), exit 0.pnpm --filter @object-ui/app-shell run type-check— exit 0 (dependency closure built first; the chainedtsconfig.test.jsonproject includessrc/**/*.test.ts, so the new pins are type-checked).--no-inline-config— 0 errors, 0 warnings. Narrowing declared: the repo-wide population iseslint .; the file count (2) is read from--format json;eslint.config.jsconfigures no type-aware linting (0 occurrences ofprojectService/parserOptions/project:/TypeChecked), so this diff cannot move any untouched file's verdict. CI runs the full farm regardless.node scripts/check-changeset-presence.mjs— exit 0, 1 changeset declared for 1 released package.pnpm check:control-bytes,check:vi-mock-specifiers,check:vi-mock-inherit,check:i18n-keys— all green.node scripts/check-governed-queue-guard.mjs --teston the three changed paths —NOT GOVERNED.Live server verification
Booted from objectstack commit
bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942— the head of PR objectstack-ai/objectstack#14430, which is not yet in objectstackmain(checked:withWritableVerdicthas 0 occurrences inorigin/main'spackages/runtime/src/domains/packages.ts). The squash content is identical.Single-package boot — the negative check
examples/app-todoon its own port with a freshfile:DB (the showcase app was tried first and is unusable for this: itsGET /api/v1/packagesanswers HTTP 500Converting circular structure to JSON … '_ObjectQL' … property 'engine', a pre-existing platform defect the PM is filing, unrelated to this change).GET /api/v1/packages: 23 rows, all 23 carryingwritable. The one row the switcher keeps:Identical to what the heuristic said, so the switcher is unchanged for existing single-package apps.
Then
POST /api/v1/packages/com.example.todo/duplicatecreated a Studio base, giving the pair that matters:The Studio switcher, driven through the objectui HMR console pointed at that server (
VITE_SERVER_URL/DEV_PROXY_TARGET, so this branch's code is what rendered — the vendored/_consolebundle is stale by construction), listsTodo Manageras Read-only andTodo Copy (writable base)as Writable. That second row is the one a client-side "missing scope means read-only" rule would have broken, and it is scope-less on the wire.Multi-package boot — BLOCKED
BLOCKED-BY objectstack-ai/objectstack#14439. The card's second acceptance line — switcher shows both packages, the module marked read-only, and the three Studio sections filtering by package — cannot be verified end to end yet, because no producer emits a
packages[]artifact. One honest attempt, a two-packagepackages[]config booted throughos devfrombd0ee2fb, refused at the producer door:ArtifactPackageEntrySchema.manifestis the AUTHORINGManifestSchema, whoseobjectsisz.array(z.string())— glob patterns — so object DEFINITIONS in a sub-package body are refused before compile finishes.A second, independent door refuses the same shape after compile, recorded here because it is a separate seam and objectstack-ai/objectstack#14439 will meet both:
MetadataPlugin._parseAndRegisterArtifacthard-parses the whole artifact withObjectStackDefinitionSchemabefore ADR-0130 D4's load path (which deliberately does not judge bodies) is ever reached, so an assembledpackages[i].manifest.objectsfails withexpected string, received objectandpackages[i].manifest.permissionsfails its union. Measured by parsing artifact variants directly: bodies withobjects+permissionsfail 4 ways; withpermissionsremoved, 2 ways; with both removed, the parse passes.flows,appsand the other collections are simply undeclared onManifestSchema, so they are stripped from the parsed copy and survive in the raw body — which is whyobjects(Data pillar) andpermissions(Access pillar) are precisely the two the D4 seam cannot carry today.This verification will be re-run against that card's
examples/app-multi-packagefixture once it lands. Nothing was worked around in objectui.Boundaries
No new authorable key, no spec change, no lenient alias. The fallback is version compatibility with servers that predate the field, not tolerance of a second dialect: the field is either a boolean or it is absent.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Generated by Claude Code