Skip to content

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

Closed

Description

@huangyiirene

Symptom

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

  1. Boot the platform and list /packages; pick a code-loaded platform package (repeated with a second one).
  2. PATCH /packages/<id>/disable → 200.
  3. DELETE /packages/<id> → 200; re-list /packages — the package is gone from the registry listing of the running process.
  4. Restart — the package is back, confirming it is code-loaded and the loss is confined to the running deployment.

Source

Extracted from the QA run #7514 (framework a86db17, console 09987b6).

Activity

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 11, 2026
  2. self-assigned this
    on Aug 11, 2026
  3. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    Claim — PM loop domain:cli, session session_0158ZQo7LiHSxGWpYKuPq1wu (os-help seat, #6024), wave 5 (backfilling the slot #7450 freed).

    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.


    Generated by Claude Code

  4. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    os-dev-report — #7560 · branch claude/issue-7560-package-lifecycle-readonly-gate · draft PR #7599

    Status: done

    Root cause

    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:

    Existing (authoring) New (lifecycle)
    predicate isWritablePackage (private, protocol.ts) same function, now exported
    status 422 422
    code WRITABLE_PACKAGE_REQUIRED WRITABLE_PACKAGE_REQUIRED
    details packageId, docs: docs/adr/0070-package-first-authoring.md same

    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
    still works for non-platform writable pkg disables (listing shows disabled), re-enables, and deletes (leaves listing); read-only siblings untouched
    not an existence oracle unknown id still 404, not 422

    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 after uninstallPackage 6 / 85 the listing assertions — delete-first-refuse-second
    M7 disable gate moved after disablePackage 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.

    Files

    • packages/metadata-protocol/src/package-writability.ts (new) — exported isWritablePackage
    • packages/metadata-protocol/src/protocol.ts — private method delegates
    • packages/metadata-protocol/src/index.ts — export
    • packages/runtime/src/domains/packages.ts — requireWritablePackage + both call sites
    • packages/spec/src/api/error-code-ledger.zod.ts — second emitter registered
    • packages/runtime/src/domains/packages-readonly-gate.test.ts (new, 15 tests)
    • .changeset/packages-lifecycle-readonly-gate.md

    Gates

    pnpm lint ✓ · pnpm typecheck ✓ (spec, runtime; metadata-protocol has no typecheck script) · runtime 2005 ✓ · spec 9805 ✓ · metadata-protocol 1051 ✓ · objectql 3153 ✓ · rest 1344 ✓ · changeset ✓ · no content/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/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.


    Generated by Claude Code

  5. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    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 for disable: setPackageDisabled persists the choice to &lt;OS_HOME&gt;/package-state/&lt;env&gt;.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.

    Ready flipped, auto-merge armed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions