You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
The ADR-0070 read-only gate is missing on package lifecycle: PATCH /packages/:id/disable and DELETE /packages/:id both answer 200 on platform packages, removing them from the running deployment #7560
Platform packages are supposed to be read-only under ADR-0070. On the package lifecycle routes they are not:
PATCH /packages/<id>/disable on a platform package → 200.
DELETE /packages/<id> on a platform package → 200, and the code package is actually removed from the registry listing.
Reproduced on two platform packages.
Expected: both operations refuse a platform package with the ADR-0070 read-only refusal, the way other protected-resource writes do.
Blast radius, stated precisely: the packages come back after a restart — they are code-loaded, so nothing is permanently destroyed — but the running deployment loses them until then. A single API call takes platform functionality out of a live deployment with no way back short of a restart.
Root cause
Not located by the report. What it establishes is that the refusal is simply absent on these two routes: they execute the lifecycle operation and answer success rather than declining a protected package. The package domain lives in packages/runtime/src/domains/packages.ts (per #7033's census), which is why this is filed domain:cli.
Not covered by the recent /packages authorization work
#7033 ("/packages 域整体没有授权判据") was closed on 2026-08-09 by PR #7083, which added caller authorization to the whole domain — writes require manage_metadata, reads require the D4 read set, plus a domain-wide anonymous gate. That fix landed before this run's framework commit was cut, and it is on a different axis: it decides who may call the route. This card is about what the route may do once the caller is allowed — an authorized admin can still disable and delete a platform package. So this is neither a duplicate of #7033 nor a regression of it; the read-only gate is a second, missing check on the same routes.
Reproduction
Boot the platform and list /packages; pick a code-loaded platform package (repeated with a second one).
PATCH /packages/<id>/disable → 200.
DELETE /packages/<id> → 200; re-list /packages — the package is gone from the registry listing of the running process.
Restart — the package is back, confirming it is code-loaded and the loss is confined to the running deployment.
Prioritised as the most destructive card in the queue. It carries no security label, but a single authorized API call takes platform functionality out of a live deployment with no recovery short of a restart. That outranks the rest of the current queue on blast radius.
Scope: PATCH /packages/:id/disable and DELETE /packages/:id must refuse a platform package with the ADR-0070 read-only refusal, in the same shape other protected-resource writes use. ⛔ Do not invent a new refusal vocabulary — find how ADR-0070 is already enforced elsewhere and match it.
Accept bar: both operations refused for a platform package and still working for a non-platform one — a fix that refuses everything is not a fix. Prove the tests can fail.
packages/runtime/src/domains/packages.ts — the /packages domain dispatcher body, exactly where #7033's census put it. The two lifecycle routes ran their mutation unconditionally once the caller cleared requireManageMetadata:
PATCH /:id/disable → registry.disablePackage(id) then setPackageDisabled(...)
DELETE /:id → registry.uninstallPackage(id) then protocol.deletePackage(...)
Neither consulted ADR-0070's read-only rule. That rule existed — as a private method isWritablePackage on ObjectStackProtocolImplementation (packages/metadata-protocol/src/protocol.ts:8305), reachable only from the metadata authoring path. So saveMetaItem refused to author one field into a platform package while DELETE removed the whole package from the running deployment.
⛔ packages/rest/src/rest-server.ts is NOT in the diff — the lifecycle routes are not wired there. No collision with #7523 / #7525 / #7526.
One correction to the card's blast radius
The card says the packages come back after a restart. True for DELETE. Not true for disable: setPackageDisabled (packages/runtime/src/package-state-store.ts:70) persists to <OS_HOME>/package-state/<env>.json, and SchemaRegistry replays it at boot through initialDisabledPackageIds (packages/objectql/src/registry.ts:2202). A disabled platform package stayed disabled across restarts — the disable half was the more durable of the two defects, not the lesser one. The card's "no way back short of a restart" understates it: for disable, a restart was not a way back either.
The distinction, held
The caller gate is byte-untouched. In every new test the caller holds manage_metadata and is refused anyway. The new gate is deliberately not caller-sensitive — no isSystem bypass, unlike requireManageMetadata — because read-only is a property of the package. Pinned directly: "is NOT the #7033 caller gate: the admin is authorized (no 401/403) and still refused".
Precedent found — no new vocabulary invented
ADR-0070 is already enforced on the authoring axis, and this reuses it verbatim:
The predicate moved to packages/metadata-protocol/src/package-writability.ts and is exported; protocol.ts now delegates to it. Both callers reference one definition — a second hand-kept copy is precisely the drift that produced this defect. Ledger: WRITABLE_PACKAGE_REQUIRED registered as a second emitter under @objectstack/runtime (the ledger header explicitly provides for this: "listed once per emitting package — provenance, not identity").
Both read-only signals covered: booted code package (engine.manifests — the repro's "code-loaded") and platform-delivered manifest scope ∈ {system, cloud}.
Accept bar
Requirement
Evidence
disable refused for platform pkg
3 cases (system / cloud / code-loaded), enabled still true, status not disabled
DELETE refused for platform pkg
3 cases, registry.getAllPackages() unchanged
the card's observable
listing asserted, not the status code — tests drive a real SchemaRegistry, not vi.fn() doubles
Mutation table — every new test proven able to fail
Applied to the merged tree, suite run (packages-readonly-gate + pre-existing packages-capability-gate, 91 tests), reverted. Predicate mutations needed a metadata-protocol rebuild — @objectstack/runtime resolves the built dist, so the first pass of those four was a silent no-op and was re-run.
#
Mutation
Result
Red
M1
DELETE gate removed (pre-fix)
6 / 85
3 DELETE cases, isSystem, envelope, verb
M2
disable gate removed (pre-fix)
5 / 86
3 disable cases, axis, verb
M3
predicate always false — outage shape
8 / 83
writable disable+delete, both 404s, +4 pre-existing #7033 tests
M4
code-loaded signal dropped
2 / 89
both code-loaded cases only
M5
manifest-scope signal dropped
8 / 83
both system + both cloud, isSystem, axis, envelope, verb
M6
DELETE gate moved afteruninstallPackage
6 / 85
the listing assertions — delete-first-refuse-second
M7
disable gate moved afterdisablePackage
3 / 88
the enabled/status assertions
M8
refusal restated as PACKAGE_READ_ONLY
7 / 84
6 refusal cases + envelope shape
M9
unknown id treated as read-only
2 / 89
both 404 cases only
M10
blanket caller bypass re-added
10 / 81
6 refusal cases, isSystem, axis, envelope, verb
M6 is the one that matters for the card's instruction: it keeps the 422 and still fails, because the package left the listing. Status-only assertions would have passed it. M3 is the accept-bar one: refusing everything breaks four of #7033's own allow-path tests too.
This adds a gate inside an already-reached handler; it touches no mounting, no registrar, no route matching. #7563's fix (mounting the REST package registrar so POST /packages/publish stops being absorbed by /packages/:id) is orthogonal. One note for whoever takes it: if that mount lands, /packages/publish will parse as parts.length === 1 here too — worth confirming the literal publish segment can't reach DELETE/PATCH handling for a package named publish.
Follow-up worth a card (deliberately NOT done — out of scope)
PATCH /packages/:id (manifest edit: name / description / version) mutates a platform package's manifest with the same absent gate — registry.updatePackageManifest rewrites the shipped manifest in the running registry. Not fixed here because this card scopes to disable + delete, and I'd rather the PM decide than widen a destructive-path PR. The fix is one line: the same requireWritablePackage(deps, qlService, id, 'edit') call. POST /:id/publish-drafts, /discard-drafts, /rollback and /adopt-orphans are arguably the same family and deserve a look under one card rather than piecemeal.
The dev corrected this card's blast-radius claim with a measurement, and the correction makes the defect worse than filed. The card said the packages come back after a restart. That is true for DELETE — they are code-loaded — but not for disable: setPackageDisabled persists the choice to <OS_HOME>/package-state/<env>.json, which SchemaRegistry replays at boot, so a disabled platform package stayed disabled across restarts. The "confined to the running deployment, recoverable by restart" framing — which I repeated in the claim comment — was only half right, and the half that was wrong is the persistent half. Recorded here so the card's own history does not preserve the understatement.
No new vocabulary, as required: the refusal is ADR-0070's existing 422 / WRITABLE_PACKAGE_REQUIRED — the same code saveMetaItem already throws when asked to author into a read-only package. The dev found the precedent rather than inventing a shape.
Deliberately not caller-sensitive, with a stated reason: no isSystem bypass, unlike the write gate, because read-only is a property of the package, not of who is asking. Internal teardown calls registry.uninstallPackage directly and never passes through a gate, so nothing legitimate is broken.
The accept bar was met on the observable, not the status code: the new suite drives a realSchemaRegistry and asserts the registry listing in both directions — packages an org owns still disable, re-enable and delete exactly as before. A status-code-only assertion would not have caught the original harm, which was the package vanishing from the listing.
Activity
os-help commentedon Aug 11, 2026
Claim — PM loop
domain:cli, sessionsession_0158ZQo7LiHSxGWpYKuPq1wu(os-help seat, #6024), wave 5 (backfilling the slot #7450 freed).Prioritised as the most destructive card in the queue. It carries no
securitylabel, but a single authorized API call takes platform functionality out of a live deployment with no recovery short of a restart. That outranks the rest of the current queue on blast radius.claude/issue-7560-package-lifecycle-readonly-gatePATCH /packages/:id/disableandDELETE /packages/:idmust refuse a platform package with the ADR-0070 read-only refusal, in the same shape other protected-resource writes use. ⛔ Do not invent a new refusal vocabulary — find how ADR-0070 is already enforced elsewhere and match it./packages域整体没有授权判据 ——discard-drafts(破坏性)、export、GET /packages零能力调用方均实测 200,#7023 只是其中一条 #7033 / PR feat(runtime,rest): /packages 域补齐授权门 —— 写要 manage_metadata、读要 D4 读集、全域匿名门,两 transport 各一份 (#7033) (#7023) #7083 added caller authorization to the whole/packagesdomain (writes needmanage_metadata, reads the D4 set, plus an anonymous gate). That is who may call. This card is what the route may do once the caller is allowed — an authorized admin can still disable and delete a platform package. Neither a duplicate nor a regression of finding:/packages域整体没有授权判据 ——discard-drafts(破坏性)、export、GET /packages零能力调用方均实测 200,#7023 只是其中一条 #7033; it is a second, missing check on the same routes. ⛔ Do not "fix" it by tightening the caller gate.packages/runtime/src/domains/packages.ts(per finding:/packages域整体没有授权判据 ——discard-drafts(破坏性)、export、GET /packages零能力调用方均实测 200,#7023 只是其中一条 #7033's census) and/orpackages/rest/src/package-routes.ts+ tests.packages/rest/src/rest-server.ts(SECURITY:DELETE /api/v1/reports/:idanswers 500 for another owner's report but 204 for a nonexistent id — an enumeration oracle over report ids #7523 reports-delete, [rest] Hook refusals carrying an explicitstatusCodeare not mapped by/api/v1/data— they leak as HTTP 500INTERNAL_ERRORwith no located guidance #7525 error mapping, Three ledgered /meta routes are never mounted and die in the/meta/:typecatch-all — the route audit can't see this class because it treats the ledger as ground truth for what's mounted #7526/metamounting). Your surface should not be that file at all; if you find the lifecycle routes are wired there, report back before editing rather than colliding.POST /api/v1/packages/publishanswers a misleading 405 (Allowed: DELETE, GET, HEAD, PATCH) — the request is absorbed by/packages/:idbecause the REST package registrar is not mounted on showcase #7563 (the REST package registrar not mounted on showcase, soPOST /packages/publishis absorbed by/packages/:id). Same route family, different defect — leave it.claude-opus-5·mode:cloud.Generated by Claude Code
os-help commentedon Aug 11, 2026
os-dev-report — #7560 · branch
claude/issue-7560-package-lifecycle-readonly-gate· draft PR #7599Status:
doneRoot cause
packages/runtime/src/domains/packages.ts— the/packagesdomain dispatcher body, exactly where #7033's census put it. The two lifecycle routes ran their mutation unconditionally once the caller clearedrequireManageMetadata:PATCH /:id/disable→registry.disablePackage(id)thensetPackageDisabled(...)DELETE /:id→registry.uninstallPackage(id)thenprotocol.deletePackage(...)Neither consulted ADR-0070's read-only rule. That rule existed — as a private method
isWritablePackageonObjectStackProtocolImplementation(packages/metadata-protocol/src/protocol.ts:8305), reachable only from the metadata authoring path. SosaveMetaItemrefused to author one field into a platform package whileDELETEremoved the whole package from the running deployment.⛔
packages/rest/src/rest-server.tsis NOT in the diff — the lifecycle routes are not wired there. No collision with #7523 / #7525 / #7526.One correction to the card's blast radius
The card says the packages come back after a restart. True for
DELETE. Not true fordisable:setPackageDisabled(packages/runtime/src/package-state-store.ts:70) persists to<OS_HOME>/package-state/<env>.json, andSchemaRegistryreplays it at boot throughinitialDisabledPackageIds(packages/objectql/src/registry.ts:2202). A disabled platform package stayed disabled across restarts — the disable half was the more durable of the two defects, not the lesser one. The card's "no way back short of a restart" understates it: fordisable, a restart was not a way back either.The distinction, held
The caller gate is byte-untouched. In every new test the caller holds
manage_metadataand is refused anyway. The new gate is deliberately not caller-sensitive — noisSystembypass, unlikerequireManageMetadata— because read-only is a property of the package. Pinned directly: "is NOT the #7033 caller gate: the admin is authorized (no 401/403) and still refused".Precedent found — no new vocabulary invented
ADR-0070 is already enforced on the authoring axis, and this reuses it verbatim:
isWritablePackage(private, protocol.ts)422422WRITABLE_PACKAGE_REQUIREDWRITABLE_PACKAGE_REQUIREDpackageId,docs: docs/adr/0070-package-first-authoring.mdThe predicate moved to
packages/metadata-protocol/src/package-writability.tsand is exported;protocol.tsnow delegates to it. Both callers reference one definition — a second hand-kept copy is precisely the drift that produced this defect. Ledger:WRITABLE_PACKAGE_REQUIREDregistered as a second emitter under@objectstack/runtime(the ledger header explicitly provides for this: "listed once per emitting package — provenance, not identity").Both read-only signals covered: booted code package (
engine.manifests— the repro's "code-loaded") and platform-delivered manifestscope∈ {system,cloud}.Accept bar
enabledstilltrue,statusnotdisabledregistry.getAllPackages()unchangedSchemaRegistry, notvi.fn()doublesdisabled), re-enables, and deletes (leaves listing); read-only siblings untouched404, not422Mutation table — every new test proven able to fail
Applied to the merged tree, suite run (
packages-readonly-gate+ pre-existingpackages-capability-gate, 91 tests), reverted. Predicate mutations needed ametadata-protocolrebuild —@objectstack/runtimeresolves the builtdist, so the first pass of those four was a silent no-op and was re-run.DELETEgate removed (pre-fix)disablegate removed (pre-fix)false— outage shapescopesignal droppedDELETEgate moved afteruninstallPackagedisablegate moved afterdisablePackageenabled/statusassertionsPACKAGE_READ_ONLYM6 is the one that matters for the card's instruction: it keeps the 422 and still fails, because the package left the listing. Status-only assertions would have passed it. M3 is the accept-bar one: refusing everything breaks four of #7033's own allow-path tests too.
Files
packages/metadata-protocol/src/package-writability.ts(new) — exportedisWritablePackagepackages/metadata-protocol/src/protocol.ts— private method delegatespackages/metadata-protocol/src/index.ts— exportpackages/runtime/src/domains/packages.ts—requireWritablePackage+ both call sitespackages/spec/src/api/error-code-ledger.zod.ts— second emitter registeredpackages/runtime/src/domains/packages-readonly-gate.test.ts(new, 15 tests).changeset/packages-lifecycle-readonly-gate.mdGates
pnpm lint✓ ·pnpm typecheck✓ (spec, runtime;metadata-protocolhas notypecheckscript) · runtime 2005 ✓ · spec 9805 ✓ · metadata-protocol 1051 ✓ · objectql 3153 ✓ · rest 1344 ✓ · changeset ✓ · nocontent/docs/releases/**edits ✓#7563 — neither helped nor hindered
This adds a gate inside an already-reached handler; it touches no mounting, no registrar, no route matching. #7563's fix (mounting the REST package registrar so
POST /packages/publishstops being absorbed by/packages/:id) is orthogonal. One note for whoever takes it: if that mount lands,/packages/publishwill parse asparts.length === 1here too — worth confirming the literalpublishsegment can't reachDELETE/PATCHhandling for a package namedpublish.Follow-up worth a card (deliberately NOT done — out of scope)
PATCH /packages/:id(manifest edit: name / description / version) mutates a platform package's manifest with the same absent gate —registry.updatePackageManifestrewrites the shipped manifest in the running registry. Not fixed here because this card scopes to disable + delete, and I'd rather the PM decide than widen a destructive-path PR. The fix is one line: the samerequireWritablePackage(deps, qlService, id, 'edit')call.POST /:id/publish-drafts,/discard-drafts,/rollbackand/adopt-orphansare arguably the same family and deserve a look under one card rather than piecemeal.Generated by Claude Code
os-help commentedon Aug 11, 2026
ACCEPT — PR #7599, head
5a75743f5.The dev corrected this card's blast-radius claim with a measurement, and the correction makes the defect worse than filed. The card said the packages come back after a restart. That is true for
DELETE— they are code-loaded — but not fordisable:setPackageDisabledpersists the choice to<OS_HOME>/package-state/<env>.json, whichSchemaRegistryreplays at boot, so a disabled platform package stayed disabled across restarts. The "confined to the running deployment, recoverable by restart" framing — which I repeated in the claim comment — was only half right, and the half that was wrong is the persistent half. Recorded here so the card's own history does not preserve the understatement./packages域整体没有授权判据 ——discard-drafts(破坏性)、export、GET /packages零能力调用方均实测 200,#7023 只是其中一条 #7033 / PR feat(runtime,rest): /packages 域补齐授权门 —— 写要 manage_metadata、读要 D4 读集、全域匿名门,两 transport 各一份 (#7033) (#7023) #7083 gave the domain caller authorization (who may call); this is the second check on the same routes (what the route may do once the caller is allowed). The caller gate is untouched — verified by diff. ⛔ It was not "fixed" by tightening feat(runtime,rest): /packages 域补齐授权门 —— 写要 manage_metadata、读要 D4 读集、全域匿名门,两 transport 各一份 (#7033) (#7023) #7083, which was the failure mode I flagged.WRITABLE_PACKAGE_REQUIRED— the same codesaveMetaItemalready throws when asked to author into a read-only package. The dev found the precedent rather than inventing a shape.ObjectStackProtocolImplementation's private method into@objectstack/metadata-protocol's exportedisWritablePackage(engine, id), now referenced by both callers. That is the@objectstack/runtimere-declaresPermissionDeniedError"structurally identical" to plugin-security's, with nothing enforcing the identity #7270 lesson applied unprompted — a rule spelled twice is a rule that will diverge. Both signals covered: a booted code package (engine.manifests) and asystem/cloudmanifest scope.isSystembypass, unlike the write gate, because read-only is a property of the package, not of who is asking. Internal teardown callsregistry.uninstallPackagedirectly and never passes through a gate, so nothing legitimate is broken.SchemaRegistryand asserts the registry listing in both directions — packages an org owns still disable, re-enable and delete exactly as before. A status-code-only assertion would not have caught the original harm, which was the package vanishing from the listing.packages/rest/src/rest-server.tsuntouched — verified by diff, so [rest] Hook refusals carrying an explicitstatusCodeare not mapped by/api/v1/data— they leak as HTTP 500INTERNAL_ERRORwith no located guidance #7525 / Three ledgered /meta routes are never mounted and die in the/meta/:typecatch-all — the route audit can't see this class because it treats the ledger as ground truth for what's mounted #7526 / Public lookup route resolves the picker target from legacy field spellings only — omittedpublicPicker.objectanswers 500 against a canonical object schema #7486 are unaffected.success, TypeScript Type Checksuccess, Check Changesetsuccess, Spec property livenesssuccess, all Test Core / Dogfood / Temporal shardssuccess— 26 checks, zero failures.Ready flipped, auto-merge armed.
Generated by Claude Code
fix(runtime): refuse to disable or delete a read-only package (object…