feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field
Sep 2, 2026
Merged

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage)#14430
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half

Why

Studio's package switcher derives "writable" client-side from manifest.scope alone (scope !== 'project', objectui packages-io.ts:42). That is not the rule this server enforces. isWritablePackage (ADR-0070 D2, packages/metadata-protocol/src/package-writability.ts:73) reads engine.manifests first — a package booted from an artifact through registerApp is read-only whatever its scope says — and only then the system / cloud scopes. The two rules split on exactly the row ADR-0130 introduces:

rowmanifest.scopein engine.manifestsserverclient heuristic
type: module sub-package of a multi-package artifact (raw body, D7)absentyesread-onlywritable ❌
Studio-created database baseabsentnowritablewritable

Nothing in the raw row tells those two apart; engine.manifests does, 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, no scope key, 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-1744 records the fork):

  • runtime dispatcherhandlePackagesRequest — list and detail: withWritableVerdict(qlService, row) on a spread copy of each registry.getAllPackages() / getPackage(id) record. Measured on os dev, this is the door that answers /api/v1/packages in a running process.
  • metadata-protocolObjectStackProtocolImplementation.getMetaItems({ type: 'package' }) — the producer the REST GET /packages door spreads its registry half from (packages/rest/src/package-routes.ts:672). The package branch stamps writable: this.isWritablePackage(id) on a spread copy, the way the app branch already applies nav contributions. REST itself computes nothing: it has no runtime dependency on metadata-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's InstalledPackage binding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), and writable is an additive row key, not a stored property.

Pins

Runtime (packages-writable-verdict.test.ts, real SchemaRegistry + manifests map through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:

  1. booted code package, scope: 'project'false
  2. booted scope-less module (the ADR-0130 row) → false — the test also asserts the row really has no scope, so the verdict cannot be coming from it
  3. system / cloudfalse
  4. scope-less database base, installed never booted → true — the row a client-side rule would have broken
  5. negative: every served row minus writable deep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key; total unchanged; ?type= filter carries it; detail door pins 2 and 4; unknown id still 404; an app listing gains no writable (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's engine.manifests limb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves @objectstack/metadata-protocol through dist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).

  • Mutate leg — predicted: the manifests-limb family red, scope and DB-base pins green. Observed: runtime 5 failed / 7 passed (pins 1, 2, 2+4, ?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.
  • Restore leg — source blob 022c5982… equal to HEAD (restored by trap, verified by git hash-object), rebuilt, marker count in dist 0, preflight --absent ✓ on the committed tree, both files green again (12/12, 7/7).

Real boot (card acceptance), on this head bd0ee2fb, full @objectstack/* build

examples/app-todo via os dev --seed-admin -p 4376 -d file:…, signed in through POST /api/v1/auth/sign-in/email:

stepresult
GET /api/v1/packages200 · 23 rows, every row carries a boolean writable
com.example.todo (booted app, scope: 'project')writable: false — pin 1 live
22 platform plugins (scope: 'system')writable: false — pin 3 live
POST /api/v1/packages/com.example.todo/duplicatecom.acme.dupbaserow has no scope key and writable: true — pin 4 live; this is the row a scope-only rule would have flipped read-only
GET /api/v1/packages/com.acme.dupbase200 · keys manifest, status, enabled, installedAt, updatedAt, writable — additive

Two things this boot surfaced, stated rather than hidden:

Verification at bd0ee2fb

  • New tests: runtime 12/12, metadata-protocol 7/7, rest 4/4.
  • Full suites, dependency closure built first: metadata-protocol 155 files / 2132 tests, rest 169 / 2811, runtime 208 / 3073 — all passing (2 files / 10 tests skipped in metadata-protocol are the suite's own pre-existing skips).
  • pnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck — green (rest's check:test-typecheck included).
  • check-system-context-census went red on pure line rot (the helper insertion moved packages.ts:246 → :276); repaired with its own --fix: one anchor rewritten in content/docs/permissions/system-context.mdx, no prose changed.
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 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) and check:slot-lookup (ENOENT on a .examples-build temp file that my concurrent skill-examples re-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 saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (need a full-repo build). CI measures all four. Exit codes captured before any pipe.
  • CI on this head: all 37 checks green, the six required contexts by name — 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-review not attached.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

…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
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/concepts/metadata-lifecycle.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/forms.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/views.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via /book/:name/tree (route, bridged from symbol getMetaItems — its registrar handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194packageMentionDocs.

Which tree this was computed on

This run read content/docs from 95e58e27bd66a2c8f7aed614f9cf1dc740477555 — the merge of head bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 into base 96b627d135826253981cfa01d74b2832ccdea194, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96b627d135826253981cfa01d74b2832ccdea194 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

自审:PASS —— head bd0ee2fb,无需修正

独立性声明(先说清,不藏)

对树核实

断言实测
同一个谓词✅ 两处都调 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 未改;restmetadata-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 CheckTest Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build CoreTemporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。

收口

draft → ready,arm auto-merge。落地即关 #14375#14439 的夹具在其后追加两行 writable:false 的多包断言。


Generated by Claude Code

@hotlong
hotlong enabled auto-merge September 2, 2026 07:10
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 63f3b43Sep 2, 2026
42 checks passed
@hotlong
hotlong deleted the claude/issue-14375-packages-writable-field branch September 2, 2026 07:59
hotlong pushed a commit that referenced this pull request Sep 2, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field
Sep 2, 2026
Merged

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage)#14430
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half

Why

Studio's package switcher derives "writable" client-side from manifest.scope alone (scope !== 'project', objectui packages-io.ts:42). That is not the rule this server enforces. isWritablePackage (ADR-0070 D2, packages/metadata-protocol/src/package-writability.ts:73) reads engine.manifests first — a package booted from an artifact through registerApp is read-only whatever its scope says — and only then the system / cloud scopes. The two rules split on exactly the row ADR-0130 introduces:

rowmanifest.scopein engine.manifestsserverclient heuristic
type: module sub-package of a multi-package artifact (raw body, D7)absentyesread-onlywritable ❌
Studio-created database baseabsentnowritablewritable

Nothing in the raw row tells those two apart; engine.manifests does, 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, no scope key, 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-1744 records the fork):

  • runtime dispatcherhandlePackagesRequest — list and detail: withWritableVerdict(qlService, row) on a spread copy of each registry.getAllPackages() / getPackage(id) record. Measured on os dev, this is the door that answers /api/v1/packages in a running process.
  • metadata-protocolObjectStackProtocolImplementation.getMetaItems({ type: 'package' }) — the producer the REST GET /packages door spreads its registry half from (packages/rest/src/package-routes.ts:672). The package branch stamps writable: this.isWritablePackage(id) on a spread copy, the way the app branch already applies nav contributions. REST itself computes nothing: it has no runtime dependency on metadata-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's InstalledPackage binding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), and writable is an additive row key, not a stored property.

Pins

Runtime (packages-writable-verdict.test.ts, real SchemaRegistry + manifests map through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:

  1. booted code package, scope: 'project'false
  2. booted scope-less module (the ADR-0130 row) → false — the test also asserts the row really has no scope, so the verdict cannot be coming from it
  3. system / cloudfalse
  4. scope-less database base, installed never booted → true — the row a client-side rule would have broken
  5. negative: every served row minus writable deep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key; total unchanged; ?type= filter carries it; detail door pins 2 and 4; unknown id still 404; an app listing gains no writable (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's engine.manifests limb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves @objectstack/metadata-protocol through dist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).

  • Mutate leg — predicted: the manifests-limb family red, scope and DB-base pins green. Observed: runtime 5 failed / 7 passed (pins 1, 2, 2+4, ?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.
  • Restore leg — source blob 022c5982… equal to HEAD (restored by trap, verified by git hash-object), rebuilt, marker count in dist 0, preflight --absent ✓ on the committed tree, both files green again (12/12, 7/7).

Real boot (card acceptance), on this head bd0ee2fb, full @objectstack/* build

examples/app-todo via os dev --seed-admin -p 4376 -d file:…, signed in through POST /api/v1/auth/sign-in/email:

stepresult
GET /api/v1/packages200 · 23 rows, every row carries a boolean writable
com.example.todo (booted app, scope: 'project')writable: false — pin 1 live
22 platform plugins (scope: 'system')writable: false — pin 3 live
POST /api/v1/packages/com.example.todo/duplicatecom.acme.dupbaserow has no scope key and writable: true — pin 4 live; this is the row a scope-only rule would have flipped read-only
GET /api/v1/packages/com.acme.dupbase200 · keys manifest, status, enabled, installedAt, updatedAt, writable — additive

Two things this boot surfaced, stated rather than hidden:

Verification at bd0ee2fb

  • New tests: runtime 12/12, metadata-protocol 7/7, rest 4/4.
  • Full suites, dependency closure built first: metadata-protocol 155 files / 2132 tests, rest 169 / 2811, runtime 208 / 3073 — all passing (2 files / 10 tests skipped in metadata-protocol are the suite's own pre-existing skips).
  • pnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck — green (rest's check:test-typecheck included).
  • check-system-context-census went red on pure line rot (the helper insertion moved packages.ts:246 → :276); repaired with its own --fix: one anchor rewritten in content/docs/permissions/system-context.mdx, no prose changed.
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 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) and check:slot-lookup (ENOENT on a .examples-build temp file that my concurrent skill-examples re-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 saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (need a full-repo build). CI measures all four. Exit codes captured before any pipe.
  • CI on this head: all 37 checks green, the six required contexts by name — 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-review not attached.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

…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
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/concepts/metadata-lifecycle.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/forms.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/views.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via /book/:name/tree (route, bridged from symbol getMetaItems — its registrar handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194packageMentionDocs.

Which tree this was computed on

This run read content/docs from 95e58e27bd66a2c8f7aed614f9cf1dc740477555 — the merge of head bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 into base 96b627d135826253981cfa01d74b2832ccdea194, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96b627d135826253981cfa01d74b2832ccdea194 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

自审:PASS —— head bd0ee2fb,无需修正

独立性声明(先说清,不藏)

对树核实

断言实测
同一个谓词✅ 两处都调 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 未改;restmetadata-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 CheckTest Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build CoreTemporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。

收口

draft → ready,arm auto-merge。落地即关 #14375#14439 的夹具在其后追加两行 writable:false 的多包断言。


Generated by Claude Code

@hotlong
hotlong enabled auto-merge September 2, 2026 07:10
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 63f3b43Sep 2, 2026
42 checks passed
@hotlong
hotlong deleted the claude/issue-14375-packages-writable-field branch September 2, 2026 07:59
hotlong pushed a commit that referenced this pull request Sep 2, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field
Sep 2, 2026
Merged

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage)#14430
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half

Why

Studio's package switcher derives "writable" client-side from manifest.scope alone (scope !== 'project', objectui packages-io.ts:42). That is not the rule this server enforces. isWritablePackage (ADR-0070 D2, packages/metadata-protocol/src/package-writability.ts:73) reads engine.manifests first — a package booted from an artifact through registerApp is read-only whatever its scope says — and only then the system / cloud scopes. The two rules split on exactly the row ADR-0130 introduces:

rowmanifest.scopein engine.manifestsserverclient heuristic
type: module sub-package of a multi-package artifact (raw body, D7)absentyesread-onlywritable ❌
Studio-created database baseabsentnowritablewritable

Nothing in the raw row tells those two apart; engine.manifests does, 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, no scope key, 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-1744 records the fork):

  • runtime dispatcherhandlePackagesRequest — list and detail: withWritableVerdict(qlService, row) on a spread copy of each registry.getAllPackages() / getPackage(id) record. Measured on os dev, this is the door that answers /api/v1/packages in a running process.
  • metadata-protocolObjectStackProtocolImplementation.getMetaItems({ type: 'package' }) — the producer the REST GET /packages door spreads its registry half from (packages/rest/src/package-routes.ts:672). The package branch stamps writable: this.isWritablePackage(id) on a spread copy, the way the app branch already applies nav contributions. REST itself computes nothing: it has no runtime dependency on metadata-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's InstalledPackage binding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), and writable is an additive row key, not a stored property.

Pins

Runtime (packages-writable-verdict.test.ts, real SchemaRegistry + manifests map through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:

  1. booted code package, scope: 'project'false
  2. booted scope-less module (the ADR-0130 row) → false — the test also asserts the row really has no scope, so the verdict cannot be coming from it
  3. system / cloudfalse
  4. scope-less database base, installed never booted → true — the row a client-side rule would have broken
  5. negative: every served row minus writable deep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key; total unchanged; ?type= filter carries it; detail door pins 2 and 4; unknown id still 404; an app listing gains no writable (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's engine.manifests limb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves @objectstack/metadata-protocol through dist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).

  • Mutate leg — predicted: the manifests-limb family red, scope and DB-base pins green. Observed: runtime 5 failed / 7 passed (pins 1, 2, 2+4, ?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.
  • Restore leg — source blob 022c5982… equal to HEAD (restored by trap, verified by git hash-object), rebuilt, marker count in dist 0, preflight --absent ✓ on the committed tree, both files green again (12/12, 7/7).

Real boot (card acceptance), on this head bd0ee2fb, full @objectstack/* build

examples/app-todo via os dev --seed-admin -p 4376 -d file:…, signed in through POST /api/v1/auth/sign-in/email:

stepresult
GET /api/v1/packages200 · 23 rows, every row carries a boolean writable
com.example.todo (booted app, scope: 'project')writable: false — pin 1 live
22 platform plugins (scope: 'system')writable: false — pin 3 live
POST /api/v1/packages/com.example.todo/duplicatecom.acme.dupbaserow has no scope key and writable: true — pin 4 live; this is the row a scope-only rule would have flipped read-only
GET /api/v1/packages/com.acme.dupbase200 · keys manifest, status, enabled, installedAt, updatedAt, writable — additive

Two things this boot surfaced, stated rather than hidden:

Verification at bd0ee2fb

  • New tests: runtime 12/12, metadata-protocol 7/7, rest 4/4.
  • Full suites, dependency closure built first: metadata-protocol 155 files / 2132 tests, rest 169 / 2811, runtime 208 / 3073 — all passing (2 files / 10 tests skipped in metadata-protocol are the suite's own pre-existing skips).
  • pnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck — green (rest's check:test-typecheck included).
  • check-system-context-census went red on pure line rot (the helper insertion moved packages.ts:246 → :276); repaired with its own --fix: one anchor rewritten in content/docs/permissions/system-context.mdx, no prose changed.
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 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) and check:slot-lookup (ENOENT on a .examples-build temp file that my concurrent skill-examples re-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 saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (need a full-repo build). CI measures all four. Exit codes captured before any pipe.
  • CI on this head: all 37 checks green, the six required contexts by name — 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-review not attached.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

…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
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/concepts/metadata-lifecycle.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/forms.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/views.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via /book/:name/tree (route, bridged from symbol getMetaItems — its registrar handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194packageMentionDocs.

Which tree this was computed on

This run read content/docs from 95e58e27bd66a2c8f7aed614f9cf1dc740477555 — the merge of head bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 into base 96b627d135826253981cfa01d74b2832ccdea194, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96b627d135826253981cfa01d74b2832ccdea194 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

自审:PASS —— head bd0ee2fb,无需修正

独立性声明(先说清,不藏)

对树核实

断言实测
同一个谓词✅ 两处都调 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 未改;restmetadata-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 CheckTest Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build CoreTemporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。

收口

draft → ready,arm auto-merge。落地即关 #14375#14439 的夹具在其后追加两行 writable:false 的多包断言。


Generated by Claude Code

@hotlong
hotlong enabled auto-merge September 2, 2026 07:10
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 63f3b43Sep 2, 2026
42 checks passed
@hotlong
hotlong deleted the claude/issue-14375-packages-writable-field branch September 2, 2026 07:59
hotlong pushed a commit that referenced this pull request Sep 2, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field
Sep 2, 2026
Merged

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage)#14430
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half

Why

Studio's package switcher derives "writable" client-side from manifest.scope alone (scope !== 'project', objectui packages-io.ts:42). That is not the rule this server enforces. isWritablePackage (ADR-0070 D2, packages/metadata-protocol/src/package-writability.ts:73) reads engine.manifests first — a package booted from an artifact through registerApp is read-only whatever its scope says — and only then the system / cloud scopes. The two rules split on exactly the row ADR-0130 introduces:

rowmanifest.scopein engine.manifestsserverclient heuristic
type: module sub-package of a multi-package artifact (raw body, D7)absentyesread-onlywritable ❌
Studio-created database baseabsentnowritablewritable

Nothing in the raw row tells those two apart; engine.manifests does, 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, no scope key, 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-1744 records the fork):

  • runtime dispatcherhandlePackagesRequest — list and detail: withWritableVerdict(qlService, row) on a spread copy of each registry.getAllPackages() / getPackage(id) record. Measured on os dev, this is the door that answers /api/v1/packages in a running process.
  • metadata-protocolObjectStackProtocolImplementation.getMetaItems({ type: 'package' }) — the producer the REST GET /packages door spreads its registry half from (packages/rest/src/package-routes.ts:672). The package branch stamps writable: this.isWritablePackage(id) on a spread copy, the way the app branch already applies nav contributions. REST itself computes nothing: it has no runtime dependency on metadata-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's InstalledPackage binding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), and writable is an additive row key, not a stored property.

Pins

Runtime (packages-writable-verdict.test.ts, real SchemaRegistry + manifests map through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:

  1. booted code package, scope: 'project'false
  2. booted scope-less module (the ADR-0130 row) → false — the test also asserts the row really has no scope, so the verdict cannot be coming from it
  3. system / cloudfalse
  4. scope-less database base, installed never booted → true — the row a client-side rule would have broken
  5. negative: every served row minus writable deep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key; total unchanged; ?type= filter carries it; detail door pins 2 and 4; unknown id still 404; an app listing gains no writable (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's engine.manifests limb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves @objectstack/metadata-protocol through dist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).

  • Mutate leg — predicted: the manifests-limb family red, scope and DB-base pins green. Observed: runtime 5 failed / 7 passed (pins 1, 2, 2+4, ?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.
  • Restore leg — source blob 022c5982… equal to HEAD (restored by trap, verified by git hash-object), rebuilt, marker count in dist 0, preflight --absent ✓ on the committed tree, both files green again (12/12, 7/7).

Real boot (card acceptance), on this head bd0ee2fb, full @objectstack/* build

examples/app-todo via os dev --seed-admin -p 4376 -d file:…, signed in through POST /api/v1/auth/sign-in/email:

stepresult
GET /api/v1/packages200 · 23 rows, every row carries a boolean writable
com.example.todo (booted app, scope: 'project')writable: false — pin 1 live
22 platform plugins (scope: 'system')writable: false — pin 3 live
POST /api/v1/packages/com.example.todo/duplicatecom.acme.dupbaserow has no scope key and writable: true — pin 4 live; this is the row a scope-only rule would have flipped read-only
GET /api/v1/packages/com.acme.dupbase200 · keys manifest, status, enabled, installedAt, updatedAt, writable — additive

Two things this boot surfaced, stated rather than hidden:

Verification at bd0ee2fb

  • New tests: runtime 12/12, metadata-protocol 7/7, rest 4/4.
  • Full suites, dependency closure built first: metadata-protocol 155 files / 2132 tests, rest 169 / 2811, runtime 208 / 3073 — all passing (2 files / 10 tests skipped in metadata-protocol are the suite's own pre-existing skips).
  • pnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck — green (rest's check:test-typecheck included).
  • check-system-context-census went red on pure line rot (the helper insertion moved packages.ts:246 → :276); repaired with its own --fix: one anchor rewritten in content/docs/permissions/system-context.mdx, no prose changed.
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 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) and check:slot-lookup (ENOENT on a .examples-build temp file that my concurrent skill-examples re-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 saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (need a full-repo build). CI measures all four. Exit codes captured before any pipe.
  • CI on this head: all 37 checks green, the six required contexts by name — 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-review not attached.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

…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
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/concepts/metadata-lifecycle.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/forms.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/views.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via /book/:name/tree (route, bridged from symbol getMetaItems — its registrar handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194packageMentionDocs.

Which tree this was computed on

This run read content/docs from 95e58e27bd66a2c8f7aed614f9cf1dc740477555 — the merge of head bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 into base 96b627d135826253981cfa01d74b2832ccdea194, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96b627d135826253981cfa01d74b2832ccdea194 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

自审:PASS —— head bd0ee2fb,无需修正

独立性声明(先说清,不藏)

对树核实

断言实测
同一个谓词✅ 两处都调 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 未改;restmetadata-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 CheckTest Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build CoreTemporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。

收口

draft → ready,arm auto-merge。落地即关 #14375#14439 的夹具在其后追加两行 writable:false 的多包断言。


Generated by Claude Code

@hotlong
hotlong enabled auto-merge September 2, 2026 07:10
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 63f3b43Sep 2, 2026
42 checks passed
@hotlong
hotlong deleted the claude/issue-14375-packages-writable-field branch September 2, 2026 07:59
hotlong pushed a commit that referenced this pull request Sep 2, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field
Sep 2, 2026
Merged

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage)#14430
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half

Why

Studio's package switcher derives "writable" client-side from manifest.scope alone (scope !== 'project', objectui packages-io.ts:42). That is not the rule this server enforces. isWritablePackage (ADR-0070 D2, packages/metadata-protocol/src/package-writability.ts:73) reads engine.manifests first — a package booted from an artifact through registerApp is read-only whatever its scope says — and only then the system / cloud scopes. The two rules split on exactly the row ADR-0130 introduces:

rowmanifest.scopein engine.manifestsserverclient heuristic
type: module sub-package of a multi-package artifact (raw body, D7)absentyesread-onlywritable ❌
Studio-created database baseabsentnowritablewritable

Nothing in the raw row tells those two apart; engine.manifests does, 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, no scope key, 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-1744 records the fork):

  • runtime dispatcherhandlePackagesRequest — list and detail: withWritableVerdict(qlService, row) on a spread copy of each registry.getAllPackages() / getPackage(id) record. Measured on os dev, this is the door that answers /api/v1/packages in a running process.
  • metadata-protocolObjectStackProtocolImplementation.getMetaItems({ type: 'package' }) — the producer the REST GET /packages door spreads its registry half from (packages/rest/src/package-routes.ts:672). The package branch stamps writable: this.isWritablePackage(id) on a spread copy, the way the app branch already applies nav contributions. REST itself computes nothing: it has no runtime dependency on metadata-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's InstalledPackage binding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), and writable is an additive row key, not a stored property.

Pins

Runtime (packages-writable-verdict.test.ts, real SchemaRegistry + manifests map through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:

  1. booted code package, scope: 'project'false
  2. booted scope-less module (the ADR-0130 row) → false — the test also asserts the row really has no scope, so the verdict cannot be coming from it
  3. system / cloudfalse
  4. scope-less database base, installed never booted → true — the row a client-side rule would have broken
  5. negative: every served row minus writable deep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key; total unchanged; ?type= filter carries it; detail door pins 2 and 4; unknown id still 404; an app listing gains no writable (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's engine.manifests limb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves @objectstack/metadata-protocol through dist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).

  • Mutate leg — predicted: the manifests-limb family red, scope and DB-base pins green. Observed: runtime 5 failed / 7 passed (pins 1, 2, 2+4, ?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.
  • Restore leg — source blob 022c5982… equal to HEAD (restored by trap, verified by git hash-object), rebuilt, marker count in dist 0, preflight --absent ✓ on the committed tree, both files green again (12/12, 7/7).

Real boot (card acceptance), on this head bd0ee2fb, full @objectstack/* build

examples/app-todo via os dev --seed-admin -p 4376 -d file:…, signed in through POST /api/v1/auth/sign-in/email:

stepresult
GET /api/v1/packages200 · 23 rows, every row carries a boolean writable
com.example.todo (booted app, scope: 'project')writable: false — pin 1 live
22 platform plugins (scope: 'system')writable: false — pin 3 live
POST /api/v1/packages/com.example.todo/duplicatecom.acme.dupbaserow has no scope key and writable: true — pin 4 live; this is the row a scope-only rule would have flipped read-only
GET /api/v1/packages/com.acme.dupbase200 · keys manifest, status, enabled, installedAt, updatedAt, writable — additive

Two things this boot surfaced, stated rather than hidden:

Verification at bd0ee2fb

  • New tests: runtime 12/12, metadata-protocol 7/7, rest 4/4.
  • Full suites, dependency closure built first: metadata-protocol 155 files / 2132 tests, rest 169 / 2811, runtime 208 / 3073 — all passing (2 files / 10 tests skipped in metadata-protocol are the suite's own pre-existing skips).
  • pnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck — green (rest's check:test-typecheck included).
  • check-system-context-census went red on pure line rot (the helper insertion moved packages.ts:246 → :276); repaired with its own --fix: one anchor rewritten in content/docs/permissions/system-context.mdx, no prose changed.
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 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) and check:slot-lookup (ENOENT on a .examples-build temp file that my concurrent skill-examples re-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 saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (need a full-repo build). CI measures all four. Exit codes captured before any pipe.
  • CI on this head: all 37 checks green, the six required contexts by name — 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-review not attached.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

…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
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/concepts/metadata-lifecycle.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/forms.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/views.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via /book/:name/tree (route, bridged from symbol getMetaItems — its registrar handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194packageMentionDocs.

Which tree this was computed on

This run read content/docs from 95e58e27bd66a2c8f7aed614f9cf1dc740477555 — the merge of head bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 into base 96b627d135826253981cfa01d74b2832ccdea194, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96b627d135826253981cfa01d74b2832ccdea194 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

自审:PASS —— head bd0ee2fb,无需修正

独立性声明(先说清,不藏)

对树核实

断言实测
同一个谓词✅ 两处都调 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 未改;restmetadata-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 CheckTest Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build CoreTemporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。

收口

draft → ready,arm auto-merge。落地即关 #14375#14439 的夹具在其后追加两行 writable:false 的多包断言。


Generated by Claude Code

@hotlong
hotlong enabled auto-merge September 2, 2026 07:10
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 63f3b43Sep 2, 2026
42 checks passed
@hotlong
hotlong deleted the claude/issue-14375-packages-writable-field branch September 2, 2026 07:59
hotlong pushed a commit that referenced this pull request Sep 2, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field
Sep 2, 2026
Merged

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage)#14430
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half

Why

Studio's package switcher derives "writable" client-side from manifest.scope alone (scope !== 'project', objectui packages-io.ts:42). That is not the rule this server enforces. isWritablePackage (ADR-0070 D2, packages/metadata-protocol/src/package-writability.ts:73) reads engine.manifests first — a package booted from an artifact through registerApp is read-only whatever its scope says — and only then the system / cloud scopes. The two rules split on exactly the row ADR-0130 introduces:

rowmanifest.scopein engine.manifestsserverclient heuristic
type: module sub-package of a multi-package artifact (raw body, D7)absentyesread-onlywritable ❌
Studio-created database baseabsentnowritablewritable

Nothing in the raw row tells those two apart; engine.manifests does, 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, no scope key, 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-1744 records the fork):

  • runtime dispatcherhandlePackagesRequest — list and detail: withWritableVerdict(qlService, row) on a spread copy of each registry.getAllPackages() / getPackage(id) record. Measured on os dev, this is the door that answers /api/v1/packages in a running process.
  • metadata-protocolObjectStackProtocolImplementation.getMetaItems({ type: 'package' }) — the producer the REST GET /packages door spreads its registry half from (packages/rest/src/package-routes.ts:672). The package branch stamps writable: this.isWritablePackage(id) on a spread copy, the way the app branch already applies nav contributions. REST itself computes nothing: it has no runtime dependency on metadata-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's InstalledPackage binding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), and writable is an additive row key, not a stored property.

Pins

Runtime (packages-writable-verdict.test.ts, real SchemaRegistry + manifests map through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:

  1. booted code package, scope: 'project'false
  2. booted scope-less module (the ADR-0130 row) → false — the test also asserts the row really has no scope, so the verdict cannot be coming from it
  3. system / cloudfalse
  4. scope-less database base, installed never booted → true — the row a client-side rule would have broken
  5. negative: every served row minus writable deep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key; total unchanged; ?type= filter carries it; detail door pins 2 and 4; unknown id still 404; an app listing gains no writable (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's engine.manifests limb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves @objectstack/metadata-protocol through dist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).

  • Mutate leg — predicted: the manifests-limb family red, scope and DB-base pins green. Observed: runtime 5 failed / 7 passed (pins 1, 2, 2+4, ?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.
  • Restore leg — source blob 022c5982… equal to HEAD (restored by trap, verified by git hash-object), rebuilt, marker count in dist 0, preflight --absent ✓ on the committed tree, both files green again (12/12, 7/7).

Real boot (card acceptance), on this head bd0ee2fb, full @objectstack/* build

examples/app-todo via os dev --seed-admin -p 4376 -d file:…, signed in through POST /api/v1/auth/sign-in/email:

stepresult
GET /api/v1/packages200 · 23 rows, every row carries a boolean writable
com.example.todo (booted app, scope: 'project')writable: false — pin 1 live
22 platform plugins (scope: 'system')writable: false — pin 3 live
POST /api/v1/packages/com.example.todo/duplicatecom.acme.dupbaserow has no scope key and writable: true — pin 4 live; this is the row a scope-only rule would have flipped read-only
GET /api/v1/packages/com.acme.dupbase200 · keys manifest, status, enabled, installedAt, updatedAt, writable — additive

Two things this boot surfaced, stated rather than hidden:

Verification at bd0ee2fb

  • New tests: runtime 12/12, metadata-protocol 7/7, rest 4/4.
  • Full suites, dependency closure built first: metadata-protocol 155 files / 2132 tests, rest 169 / 2811, runtime 208 / 3073 — all passing (2 files / 10 tests skipped in metadata-protocol are the suite's own pre-existing skips).
  • pnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck — green (rest's check:test-typecheck included).
  • check-system-context-census went red on pure line rot (the helper insertion moved packages.ts:246 → :276); repaired with its own --fix: one anchor rewritten in content/docs/permissions/system-context.mdx, no prose changed.
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 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) and check:slot-lookup (ENOENT on a .examples-build temp file that my concurrent skill-examples re-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 saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (need a full-repo build). CI measures all four. Exit codes captured before any pipe.
  • CI on this head: all 37 checks green, the six required contexts by name — 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-review not attached.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

…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
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/concepts/metadata-lifecycle.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/forms.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/views.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via /book/:name/tree (route, bridged from symbol getMetaItems — its registrar handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194packageMentionDocs.

Which tree this was computed on

This run read content/docs from 95e58e27bd66a2c8f7aed614f9cf1dc740477555 — the merge of head bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 into base 96b627d135826253981cfa01d74b2832ccdea194, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96b627d135826253981cfa01d74b2832ccdea194 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

自审:PASS —— head bd0ee2fb,无需修正

独立性声明(先说清,不藏)

对树核实

断言实测
同一个谓词✅ 两处都调 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 未改;restmetadata-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 CheckTest Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build CoreTemporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。

收口

draft → ready,arm auto-merge。落地即关 #14375#14439 的夹具在其后追加两行 writable:false 的多包断言。


Generated by Claude Code

@hotlong
hotlong enabled auto-merge September 2, 2026 07:10
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 63f3b43Sep 2, 2026
42 checks passed
@hotlong
hotlong deleted the claude/issue-14375-packages-writable-field branch September 2, 2026 07:59
hotlong pushed a commit that referenced this pull request Sep 2, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field
Sep 2, 2026
Merged

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage)#14430
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half

Why

Studio's package switcher derives "writable" client-side from manifest.scope alone (scope !== 'project', objectui packages-io.ts:42). That is not the rule this server enforces. isWritablePackage (ADR-0070 D2, packages/metadata-protocol/src/package-writability.ts:73) reads engine.manifests first — a package booted from an artifact through registerApp is read-only whatever its scope says — and only then the system / cloud scopes. The two rules split on exactly the row ADR-0130 introduces:

rowmanifest.scopein engine.manifestsserverclient heuristic
type: module sub-package of a multi-package artifact (raw body, D7)absentyesread-onlywritable ❌
Studio-created database baseabsentnowritablewritable

Nothing in the raw row tells those two apart; engine.manifests does, 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, no scope key, 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-1744 records the fork):

  • runtime dispatcherhandlePackagesRequest — list and detail: withWritableVerdict(qlService, row) on a spread copy of each registry.getAllPackages() / getPackage(id) record. Measured on os dev, this is the door that answers /api/v1/packages in a running process.
  • metadata-protocolObjectStackProtocolImplementation.getMetaItems({ type: 'package' }) — the producer the REST GET /packages door spreads its registry half from (packages/rest/src/package-routes.ts:672). The package branch stamps writable: this.isWritablePackage(id) on a spread copy, the way the app branch already applies nav contributions. REST itself computes nothing: it has no runtime dependency on metadata-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's InstalledPackage binding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), and writable is an additive row key, not a stored property.

Pins

Runtime (packages-writable-verdict.test.ts, real SchemaRegistry + manifests map through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:

  1. booted code package, scope: 'project'false
  2. booted scope-less module (the ADR-0130 row) → false — the test also asserts the row really has no scope, so the verdict cannot be coming from it
  3. system / cloudfalse
  4. scope-less database base, installed never booted → true — the row a client-side rule would have broken
  5. negative: every served row minus writable deep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key; total unchanged; ?type= filter carries it; detail door pins 2 and 4; unknown id still 404; an app listing gains no writable (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's engine.manifests limb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves @objectstack/metadata-protocol through dist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).

  • Mutate leg — predicted: the manifests-limb family red, scope and DB-base pins green. Observed: runtime 5 failed / 7 passed (pins 1, 2, 2+4, ?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.
  • Restore leg — source blob 022c5982… equal to HEAD (restored by trap, verified by git hash-object), rebuilt, marker count in dist 0, preflight --absent ✓ on the committed tree, both files green again (12/12, 7/7).

Real boot (card acceptance), on this head bd0ee2fb, full @objectstack/* build

examples/app-todo via os dev --seed-admin -p 4376 -d file:…, signed in through POST /api/v1/auth/sign-in/email:

stepresult
GET /api/v1/packages200 · 23 rows, every row carries a boolean writable
com.example.todo (booted app, scope: 'project')writable: false — pin 1 live
22 platform plugins (scope: 'system')writable: false — pin 3 live
POST /api/v1/packages/com.example.todo/duplicatecom.acme.dupbaserow has no scope key and writable: true — pin 4 live; this is the row a scope-only rule would have flipped read-only
GET /api/v1/packages/com.acme.dupbase200 · keys manifest, status, enabled, installedAt, updatedAt, writable — additive

Two things this boot surfaced, stated rather than hidden:

Verification at bd0ee2fb

  • New tests: runtime 12/12, metadata-protocol 7/7, rest 4/4.
  • Full suites, dependency closure built first: metadata-protocol 155 files / 2132 tests, rest 169 / 2811, runtime 208 / 3073 — all passing (2 files / 10 tests skipped in metadata-protocol are the suite's own pre-existing skips).
  • pnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck — green (rest's check:test-typecheck included).
  • check-system-context-census went red on pure line rot (the helper insertion moved packages.ts:246 → :276); repaired with its own --fix: one anchor rewritten in content/docs/permissions/system-context.mdx, no prose changed.
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 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) and check:slot-lookup (ENOENT on a .examples-build temp file that my concurrent skill-examples re-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 saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (need a full-repo build). CI measures all four. Exit codes captured before any pipe.
  • CI on this head: all 37 checks green, the six required contexts by name — 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-review not attached.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

…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
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/concepts/metadata-lifecycle.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/forms.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/views.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via /book/:name/tree (route, bridged from symbol getMetaItems — its registrar handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194packageMentionDocs.

Which tree this was computed on

This run read content/docs from 95e58e27bd66a2c8f7aed614f9cf1dc740477555 — the merge of head bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 into base 96b627d135826253981cfa01d74b2832ccdea194, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96b627d135826253981cfa01d74b2832ccdea194 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

自审:PASS —— head bd0ee2fb,无需修正

独立性声明(先说清,不藏)

对树核实

断言实测
同一个谓词✅ 两处都调 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 未改;restmetadata-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 CheckTest Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build CoreTemporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。

收口

draft → ready,arm auto-merge。落地即关 #14375#14439 的夹具在其后追加两行 writable:false 的多包断言。


Generated by Claude Code

@hotlong
hotlong enabled auto-merge September 2, 2026 07:10
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 63f3b43Sep 2, 2026
42 checks passed
@hotlong
hotlong deleted the claude/issue-14375-packages-writable-field branch September 2, 2026 07:59
hotlong pushed a commit that referenced this pull request Sep 2, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) - #14430

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field
Sep 2, 2026
Merged

feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage)#14430
hotlong merged 1 commit into
mainfrom
claude/issue-14375-packages-writable-field

Conversation

@hotlong

@hotlonghotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14375
Part of #14122 · ADR-0130 Consequences row 6 — the server half

Why

Studio's package switcher derives "writable" client-side from manifest.scope alone (scope !== 'project', objectui packages-io.ts:42). That is not the rule this server enforces. isWritablePackage (ADR-0070 D2, packages/metadata-protocol/src/package-writability.ts:73) reads engine.manifests first — a package booted from an artifact through registerApp is read-only whatever its scope says — and only then the system / cloud scopes. The two rules split on exactly the row ADR-0130 introduces:

rowmanifest.scopein engine.manifestsserverclient heuristic
type: module sub-package of a multi-package artifact (raw body, D7)absentyesread-onlywritable ❌
Studio-created database baseabsentnowritablewritable

Nothing in the raw row tells those two apart; engine.manifests does, 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, no scope key, 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-1744 records the fork):

  • runtime dispatcherhandlePackagesRequest — list and detail: withWritableVerdict(qlService, row) on a spread copy of each registry.getAllPackages() / getPackage(id) record. Measured on os dev, this is the door that answers /api/v1/packages in a running process.
  • metadata-protocolObjectStackProtocolImplementation.getMetaItems({ type: 'package' }) — the producer the REST GET /packages door spreads its registry half from (packages/rest/src/package-routes.ts:672). The package branch stamps writable: this.isWritablePackage(id) on a spread copy, the way the app branch already applies nav contributions. REST itself computes nothing: it has no runtime dependency on metadata-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's InstalledPackage binding are untouched: the row type is a real fork across the two surfaces (#11925 / #12034), and writable is an additive row key, not a stored property.

Pins

Runtime (packages-writable-verdict.test.ts, real SchemaRegistry + manifests map through the real dispatcher, 12 tests) and protocol (get-meta-items-package-writable.test.ts, 7 tests), numbered as on the card:

  1. booted code package, scope: 'project'false
  2. booted scope-less module (the ADR-0130 row) → false — the test also asserts the row really has no scope, so the verdict cannot be coming from it
  3. system / cloudfalse
  4. scope-less database base, installed never booted → true — the row a client-side rule would have broken
  5. negative: every served row minus writable deep-equals the registry's own record (built from the pre-change shape, not re-derived), and the registry records never gain the key; total unchanged; ?type= filter carries it; detail door pins 2 and 4; unknown id still 404; an app listing gains no writable (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's engine.manifests limb made inert (if (false && …)) with a marker that survives bundling. The runtime suite resolves @objectstack/metadata-protocol through dist, so each leg rebuilt the package and proved the artifact state before reading results (scripts/ablation-dist-preflight.mjs).

  • Mutate leg — predicted: the manifests-limb family red, scope and DB-base pins green. Observed: runtime 5 failed / 7 passed (pins 1, 2, 2+4, ?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.
  • Restore leg — source blob 022c5982… equal to HEAD (restored by trap, verified by git hash-object), rebuilt, marker count in dist 0, preflight --absent ✓ on the committed tree, both files green again (12/12, 7/7).

Real boot (card acceptance), on this head bd0ee2fb, full @objectstack/* build

examples/app-todo via os dev --seed-admin -p 4376 -d file:…, signed in through POST /api/v1/auth/sign-in/email:

stepresult
GET /api/v1/packages200 · 23 rows, every row carries a boolean writable
com.example.todo (booted app, scope: 'project')writable: false — pin 1 live
22 platform plugins (scope: 'system')writable: false — pin 3 live
POST /api/v1/packages/com.example.todo/duplicatecom.acme.dupbaserow has no scope key and writable: true — pin 4 live; this is the row a scope-only rule would have flipped read-only
GET /api/v1/packages/com.acme.dupbase200 · keys manifest, status, enabled, installedAt, updatedAt, writable — additive

Two things this boot surfaced, stated rather than hidden:

Verification at bd0ee2fb

  • New tests: runtime 12/12, metadata-protocol 7/7, rest 4/4.
  • Full suites, dependency closure built first: metadata-protocol 155 files / 2132 tests, rest 169 / 2811, runtime 208 / 3073 — all passing (2 files / 10 tests skipped in metadata-protocol are the suite's own pre-existing skips).
  • pnpm --filter @objectstack/runtime --filter @objectstack/metadata-protocol --filter @objectstack/rest run typecheck — green (rest's check:test-typecheck included).
  • check-system-context-census went red on pure line rot (the helper insertion moved packages.ts:246 → :276); repaired with its own --fix: one anchor rewritten in content/docs/permissions/system-context.mdx, no prose changed.
  • Gate union from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 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) and check:slot-lookup (ENOENT on a .examples-build temp file that my concurrent skill-examples re-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 saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:dual-build-cjs-loads and check:type-check-debt (need a full-repo build). CI measures all four. Exit codes captured before any pipe.
  • CI on this head: all 37 checks green, the six required contexts by name — 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-review not attached.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

…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
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime, touching 8 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/concepts/metadata-lifecycle.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx(via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/protocol/objectui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/actions.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/forms.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))
  • content/docs/ui/views.mdx(via /forms/:slug (route, bridged from symbol getMetaItems — its registrar handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via /book/:name/tree (route, bridged from symbol getMetaItems — its registrar handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 96b627d135826253981cfa01d74b2832ccdea194packageMentionDocs.

Which tree this was computed on

This run read content/docs from 95e58e27bd66a2c8f7aed614f9cf1dc740477555 — the merge of head bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 into base 96b627d135826253981cfa01d74b2832ccdea194, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96b627d135826253981cfa01d74b2832ccdea194 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

自审:PASS —— head bd0ee2fb,无需修正

独立性声明(先说清,不藏)

对树核实

断言实测
同一个谓词✅ 两处都调 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 未改;restmetadata-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 CheckTest Core(汇总 + 6 分片)、Dogfood Regression Gate(汇总 + 3 分片)、Build CoreTemporal Conformance (live PG + MySQL)。37 项零失败。7 个文件对 GOVERNED_SURFACES 零命中。

收口

draft → ready,arm auto-merge。落地即关 #14375#14439 的夹具在其后追加两行 writable:false 的多包断言。


Generated by Claude Code

@hotlong
hotlong enabled auto-merge September 2, 2026 07:10
@hotlong
hotlong added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 63f3b43Sep 2, 2026
42 checks passed
@hotlong
hotlong deleted the claude/issue-14375-packages-writable-field branch September 2, 2026 07:59
hotlong pushed a commit that referenced this pull request Sep 2, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude