Uh oh!
There was an error while loading. Please reload this page.
feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430
Conversation
…rver's own writable verdict (#14375) ADR-0130 Consequences row 6, server half. Both read doors now stamp every package row with `writable: boolean`, computed by the SAME predicate the authoring and lifecycle gates enforce (`isWritablePackage`, ADR-0070 D2), so a client no longer derives it from `manifest.scope` alone — a rule that is not the server's, and that cannot tell a scope-less module booted from a multi-package artifact (read-only, in `engine.manifests`) from a scope-less Studio-created base (writable). - runtime `handlePackagesRequest`: list and detail decorate a spread copy of each registry record (`withWritableVerdict`). - metadata-protocol `getMetaItems({ type: 'package' })`: the producer the REST `GET /packages` door spreads its registry half from decorates the same records the same way; REST itself has no runtime dependency on metadata-protocol and computes nothing. - Pins: the four shapes the predicate distinguishes on both doors, the additive/no-mutation negative, and the REST spread-order carry. - `content/docs/permissions/system-context.mdx`: one line-number re-anchor from `check-system-context-census --fix`; no prose changed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 95e58e27bd66a2c8f7aed614f9cf1dc740477555 && git checkout 95e58e27bd66a2c8f7aed614f9cf1dc740477555
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96b627d135826253981cfa01d74b2832ccdea194 bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 && git checkout -B drift-repro 96b627d135826253981cfa01d74b2832ccdea194 && git merge --no-ff bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942
node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194
|
hotlong
commented
Sep 2, 2026
自审:PASS —— head |
| 断言 | 实测 |
|---|---|
| 同一个谓词 | ✅ 两处都调 isWritablePackage(runtime 直接 import;protocol 经已有的私有封装 protocol.ts:12657),无第二份"哪些包只读"的拼写 |
| spread 副本、不落库 | ✅ 两处都是 { ...row, writable };pin 5b 断言 registry 记录调用后不带 writable 键 |
| 包键与 registry 一致 | ✅ manifest.id 优先、id 回落,与 registry.getPackage(id) / manifests.has(id) 的键同源 |
| REST 门只搬运不计算 | ✅ package-routes.ts 未改;rest 对 metadata-protocol 仍无运行时依赖;摊的顺序有 pin |
| 不泄漏到其他类型 | ✅ getMetaItems({type:'app'}) 无 writable 键(pin) |
| 消融 | ✅ 两腿各红 5 / 3,恰为 manifests 分支家族;scope 与 DB base 的 pin 保持绿;复原按字节 + dist preflight --absent |
真实进程
app-todo:23 行全带布尔 writable;代码包与 22 个系统插件 false;复制出的 无 scope 键 base 为 true;detail 门同样 true、键集只多 writable。这正是卡面第 4 态在真实进程里的样子——客户端"缺 scope 即只读"会把它翻错。
showcase 的 500 是既有缺陷(origin/main6aea1f5 逐字复现,dist 不含本 PR 任何改动),已立 #14442;本 PR 一个 spread 副本既不能制造也不能消除循环引用。
文档漂移 advisory:十页零欠账
十页全部经 getMetaItems 符号或由它桥接的 /forms/:slug 路由锚命中;逐页 grep type:'package' | GET /packages | InstalledPackage | writable | packages.list | manifest.scope:唯一相关命中是 api/client-sdk.mdx:333 一行 client.packages.list() 示例,不枚举字段;concepts/metadata-lifecycle.mdx 讲的是 overlay 的"可写层",与包行字段无关。一页 release-owned 只读不碰。
CI(bd0ee2fb)
六个必需上下文按名全绿:Lint & Repo Gates(06:56)、TypeScript Type Check、Test Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build Core、Temporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。
收口
draft → ready,arm auto-merge。落地即关 #14375;#14439 的夹具在其后追加两行 writable:false 的多包断言。
Generated by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
#14430 landed) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half
Why
Studio's package switcher derives "writable" client-side from
manifest.scopealone (scope !== 'project', objectuipackages-io.ts:42). That is not the rule this server enforces.isWritablePackage(ADR-0070 D2,packages/metadata-protocol/src/package-writability.ts:73) 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 (raw body, D7)Nothing in the raw row tells those two apart;
engine.manifestsdoes, and only the server holds it. A client-side "missing scope → read-only" rule would have flipped every Studio base read-only — and the real boot below shows exactly such a base (com.acme.dupbase, noscopekey,writable: true). So the server says it, with the same predicate its authoring (saveMetaItem) and lifecycle (DELETE/disable) gates use — #8146's ruling ("one answer to is this package writable?") applied to the read door.What changed
Two doors serve
GET /packages, and they read the same registry records through different paths (packages/client/src/index.ts:1736-1744records the fork):handlePackagesRequest— list and detail:withWritableVerdict(qlService, row)on a spread copy of eachregistry.getAllPackages()/getPackage(id)record. Measured onos dev, this is the door that answers/api/v1/packagesin a running process.ObjectStackProtocolImplementation.getMetaItems({ type: 'package' })— the producer the RESTGET /packagesdoor spreads its registry half from (packages/rest/src/package-routes.ts:672). Thepackagebranch stampswritable: this.isWritablePackage(id)on a spread copy, the way theappbranch already applies nav contributions. REST itself computes nothing: it has no runtime dependency onmetadata-protocol(deliberate,package-routes.ts:41), and the verdict has one definition.Both are copies: the registry's own records are never mutated (pinned) and the verdict is never stored — it is a property of the running engine, recomputed per read.
Stated boundaries. A REST row with no registry presence (durable-only) carries no verdict, and the REST detail door's database-first row does not either — the registry item is the only carrier. Changing the detail door's read order would touch the refusal ordering #11376 pinned and is not this card.
InstalledPackageSchema/ the client'sInstalledPackagebinding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), andwritableis an additive row key, not a stored property.Pins
Runtime (
packages-writable-verdict.test.ts, realSchemaRegistry+manifestsmap through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:scope: 'project'→falsefalse— the test also asserts the row really has no scope, so the verdict cannot be coming from itsystem/cloud→falsetrue— the row a client-side rule would have brokenwritabledeep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key;totalunchanged;?type=filter carries it; detail door pins 2 and 4; unknown id still 404; anapplisting gains nowritable(no leak across types)REST (
package-list-writable-carry.test.ts, 4 tests): the spread order is the one place that door could lose the field — a durable row spread over a registry item leaves the verdict standing; a durable-only row carries none; nothing else about the merged rows changed.Reverse verification — ablation, both legs rebuilt
Subject:
isWritablePackage'sengine.manifestslimb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves@objectstack/metadata-protocolthroughdist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).?type=, detail pin 2); protocol 3 failed / 4 passed (pins 1, 2, 2+4). Pins 3, 4 and the negatives stayed green in both — exactly the predicted split.022c5982…equal to HEAD (restored bytrap, verified bygit hash-object), rebuilt, marker count indist0, preflight--absent✓ on the committed tree, both files green again (12/12, 7/7).Real boot (card acceptance), on this head
bd0ee2fb, full@objectstack/*buildexamples/app-todoviaos dev --seed-admin -p 4376 -d file:…, signed in throughPOST /api/v1/auth/sign-in/email:GET /api/v1/packageswritablecom.example.todo(booted app,scope: 'project')writable: false— pin 1 livescope: 'system')writable: false— pin 3 livePOST /api/v1/packages/com.example.todo/duplicate→com.acme.dupbasescopekey andwritable: true— pin 4 live; this is the row a scope-only rule would have flipped read-onlyGET /api/v1/packages/com.acme.dupbasemanifest, status, enabled, installedAt, updatedAt, writable— additiveTwo things this boot surfaced, stated rather than hidden:
examples/app-showcaseanswers HTTP 500 "Converting circular structure to JSON" onGET /api/v1/packages,/api/v1/packages/:idand/api/v1/meta/package. Reproduced verbatim onorigin/main6aea1f55from a separate worktree whosedistcarries none of this PR (withWritableVerdictcount 0), so it is pre-existing: the showcase stack declares plugin INSTANCES (plugins: [new …Plugin()]),AppPluginregisters{ ...bundle.manifest, ...bundle }, and the package record therefore holds objects that referenceengineafter boot. Filed asGET /api/v1/packages//api/v1/packages/:id//api/v1/meta/package全部 500「Converting circular structure to JSON」—— 栈里带插件实例(showcase)时,包记录存的是含实例的原始摊平 bundle #14442 with the repro and the source anchors; a spread copy cannot introduce or remove a cycle, and this PR does not touch that record. The evidence above usesapp-todo, which carries no instances.type: 'module', shared namespace) boot the card asked for cannot be produced yet: no path compiles apackages[]artifact today (packages/clihas zero handling of it; ArtifactPackageEntrySchema's body half cannot describe the payload the load path actually registers #14242 records the same). That producer is feat(cli+spec): 从 N 个软件包的作者态编出一个packages[]发布物 ——os build认识项目级composeStacks('preserve'),装配后的包体有自己的声明(#14242 取 B) #14439, whoseexamples/app-multi-packagefixture assertswritable: falseon both rows once this PR is inmain— the assertion lives there by design rather than being faked here.Verification at
bd0ee2fbpnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck— green (rest'scheck:test-typecheckincluded).check-system-context-censuswent red on pure line rot (the helper insertion movedpackages.ts:246 → :276); repaired with its own--fix: one anchor rewritten incontent/docs/permissions/system-context.mdx, no prose changed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat this head: 62 gates, 58 green, 0 findings. Two needed a second run to be measured —check:skill-examples(red only until@objectstack/client-react's closure was built; green after) andcheck:slot-lookup(ENOENT on a.examples-buildtemp file that my concurrentskill-examplesre-run deleted mid-lint; green alone). The remaining 4 are prerequisite-unmet, NOT MEASURED, and none relates to this diff:check-test-completeness(needs a savedturbo run testlog),pm/check-half-states(no GitHub route from this seat),check:dual-build-cjs-loadsandcheck:type-check-debt(need a full-repo build). CI measures all four. Exit codes captured before any pipe.Lint & Repo Gates,TypeScript Type Check,Test Core(rollup + 6 shards),Dogfood Regression Gate(rollup + 3 shards),Build Core,Temporal Conformance (live PG + MySQL).Clause-②
Expected no: an additive read-only key on two read doors; no accept/reject surface moves, no refusal added or removed, no schema changed.
needs:contract-reviewnot attached.🤖 Generated with Claude Code
https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m