Skip to content

fix(runtime)!: PATCH /packages/:id refuses a read-only package with 422 WRITABLE_PACKAGE_REQUIRED - #24719

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24705-patch-read-only-gate
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24705-patch-read-only-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24705

Clause-②: no (narrowing)

What changed

PATCH /api/v1/packages/:id now asks requireWritablePackage (that is, isWritablePackage, the ADR-0070 D2 read-only rule) for the path's id. It asks after requireManageMetadata and the walled refusal, and before the body is read, in the order install-overwrite, disable, publish, revert and delete already use (manage, then walled, then writable). A manifest edit of a package the deployment ships is refused 422 WRITABLE_PACKAGE_REQUIRED: booted by code, installed from the marketplace, or a stored row whose manifest scope is system / cloud. The rule is not re-derived at this door.

  • packages/runtime/src/domains/packages.ts: the gate, one call plus its comment, on the PATCH /packages/:id arm only. Verb edit, so the refusal reads "Cannot edit package 'ID': it is read-only (provided by code or an installed app)...".
  • packages/runtime/src/domains/packages-manifest-patch-readonly.test.ts: the pins, 38 cases on both limbs (the protocol service's updatePackage, and the no-protocol registry fallback).
  • .changeset/24705-patch-manifest-readonly-package.md: @objectstack/runtime minor under the launch-window convention for a breaking narrowing, with FROM / TO for an integrator, the Clause-② line, and the ADR-0087 disposition not-required (no-migration-prescription).

A CONTRACT_REVIEW_TIER review is owed before the queue (narrowing); the seat runs it.

Answers, path by path

Request Before After
PATCH on a read-only id, with name / description / version (flat, or under manifest) 200, manifest edited in the registry (and sys_packages written on the protocol limb) 422 WRITABLE_PACKAGE_REQUIRED, nothing written
PATCH on a read-only id, with a body the arm itself refuses ({}, a non-SemVer version, a dependencies-only body on today's main) 400 422 WRITABLE_PACKAGE_REQUIRED: the gate precedes the body
PATCH on a package an organization owns 200 / 400 per the body unchanged
PATCH on an id nothing installed 404 404, unchanged

Serial with PR #24703

PR #24703 (#24670) edits the body handling of this same arm a few lines below the gate, adding dependencies. This PR is built on origin/main without it, does not touch the body handling, and lands first; PR #24703 then merges main. Because the gate is asked before the body is read, the { dependencies: {} } refusal rows hold on both trees: on today's main that body answered 400 before this gate, and with PR #24703 it would answer 200; both become 422 for a read-only id.

Measured

Reproduction at base 1c1144fcd. The new pin file, committed ahead of the fix (ade20c179), against the unchanged arm: Tests 32 failed | 6 passed (38). Every refusal row is red, with expected 200 to be 422 for the editable bodies and expected 400 to be 422 for the { dependencies } and {} bodies. Every CONTROL row is green.

At head 5697a4d22: the new pin file run together with the seven suites that exercise the /packages arms (packages-readonly-gate, packages-install-overwrite-readonly, packages-capability-gate, packages-walled-write-refusal, error-passthrough, packages-single-door, http-dispatcher): Test Files 8 passed (8), Tests 459 passed (459).

Ablation, on the committed fix (5697a4d22). scripts/ablation-replace.mjs replaced the gate call requireWritablePackage(deps, qlService, id, 'edit') with null plus a marker: anchor 1 to 0, marker 0 to 1, blob d00fe003b219 to 90eadd49957b. The test reaches packages.ts through http-dispatcher.ts's relative import, so src is what runs and no rebuild was needed. Result: Tests 32 failed | 6 passed (38). The 32 refusal rows went red; the 6 CONTROL rows stayed green. Restore: blob equals HEAD d00fe003b219, git diff HEAD is empty, and git status --porcelain is clean.

The dispatch's mechanism assumptions

  1. Order: confirmed. The arm called requireManageMetadata and refuseWalledCatalogPackageWrite and nothing else. Disable (:1602), publish (:1629) and delete (:2309) ask manage, then walled, then requireWritablePackage; delete reads its keepData arity between the second and third, and revert (:2114) asks the gate after the protocol's stored-row answer. This arm uses manage, walled, writable.
  2. Unknown id: isWritablePackage answers writable for a non-empty id that no booted manifest and no registry row name, so the gate falls through and the arm's own 404 stands. Pinned on both limbs: the fallback's registry.updatePackageManifest answers undefined, and the protocol's updatePackage throws its 404. The predicate's empty-id branch (not writable) is unreachable here, because the domain splits the path with filter(Boolean).
  3. Precedent: followed 62f959dd6 (PR fix(runtime)!: POST /packages refuses an overwrite over a read-only package with 422 WRITABLE_PACKAGE_REQUIRED #24055): the same changeset form, ADR-0087 marker and pin shape (a real ObjectQL engine, registerApp for the code and marketplace kinds, a stored row for the scope-only kinds, both limbs, spies on every write, CONTROL rows on the same spies).
  4. Who sends this PATCH:
    • objectui at the .objectui-sha pin 12dc7b47c (read with git show at that commit, nothing checked out): the one caller is PackageFormDialog in edit mode, opened by EditPackageDialog from the Packages page's detail sheet (packages/app-shell/src/views/metadata-admin/PackagesPage.tsx). It always sends { name, description, version }.
    • Which packages the dialog offers to edit: the sheet shows the Edit button, beside Disable, Publish, Revert and Delete, whenever isKernel is false, and isKernel is manifest.scope === 'system' || manifest.scope === 'cloud'. So a system / cloud package is never offered Edit. A code-booted or marketplace-installed package whose manifest scope is project or absent is offered Edit; that is the case that now answers 422, as Disable, Publish, Revert and Delete on the same sheet already do for it. The dialog's fall-through error arm renders the envelope's message, so the person reads the refusal sentence (static read; not measured in a browser).
    • packages/client: packages.update(id, patch) sends this PATCH; only its own tests call it. The CLI has no caller.
    • No test or dogfood case in this repo asserts a 200 PATCH on a read-only package (sweep below), so no pin flips.

Full-repo pin sweep

Searched for every test, dogfood and conformance case that sends PATCH to /packages/:id (not /disable or /enable), and for WRITABLE_PACKAGE_REQUIRED across the repo. Each hit names a writable or unknown package, so none flips:

  • http-dispatcher.test.ts (5 cases), error-passthrough.test.ts, packages-capability-gate.test.ts: engine doubles with no manifests and no scope, so writable.
  • dispatcher-body-after-anonymous-floor.hono.integration.test.ts (pkg_x): unknown, so writable; it still reaches the body.
  • packages-walled-write-refusal.test.ts and wall-environment-metadata-refusal.dogfood.test.ts: the walled 403 precedes the gate; the CONTROL operator edit there targets a package the same test created through POST /packages (authored).
  • docs/qa/platform-checklist/areas/api-backend.json: the manifest-edit step uses a scratch package the item installs itself.

Tests and gates (local, at 5697a4d22)

  • pnpm --filter '@objectstack/runtime^...' build (the dependency closure; this diff changes no package in it): verify-lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/runtime typecheck: VERDICT command-exit 0, with check:test-typecheck: OK. The new test file is in the tsconfig.test.json program (--listFiles count 1) and carries no ledger entry, so it has no tsc error.
  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: Test Files 427 passed (427), Tests 6759 passed | 19 skipped (6778), VERDICT command-exit 0.
  • pnpm lint (repo-wide eslint . --no-inline-config): exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands (66 families, from merge base 1c1144fcd): 66 exit 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: not every package had a dist/); re-run after check:type-check-debt built every package: exit 0, 111 require entry points across 69 packages load. --ran reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.
  • check-adr-0087-registration --base origin/main: exit 0, one declared-breaking changeset carrying not-required (no-migration-prescription). check-changeset-no-major --base origin/main --event with this body as the payload: exit 0, level axis no (narrowing) with the one moved package graded minor.
  • test:repo was not run locally: the new test reads nothing outside its package (check:cross-package-test-inputs exit 0). CI owns it. Lint & Repo Gates, TypeScript Type Check, Test Core and Build Core: CI on this PR.
  • origin/main was not merged before opening. Main has gained six commits since the base (73d4cc739 to 54159fd74). None touches packages/runtime, package-writability.ts, protocol.updatePackage or the registry's package methods; the objectql and metadata-protocol changes are in hooks, validation and the seed loader. CI runs on the merge ref.

Acceptance notes

  • Observation, not filed (a static read; not reproduced in a browser): objectui's Packages detail sheet decides which action buttons to show from manifest.scope (isKernel), while its Scope badge reads the served writable verdict. So a code-booted or marketplace package whose scope is project or absent is offered Edit, Disable, Publish, Revert and Delete, and each of those now answers 422. Read at the pin 12dc7b47c, packages/app-shell/src/views/metadata-admin/PackagesPage.tsx.
  • Doc surfaces left as they are (outside the claimed file surface): requireWritablePackage's doc block in packages.ts lists the doors that ask it and does not name this one; the arm's own comment does. The JSDoc of packages/client's packages.update does not mention the read-only refusal.
  • Carried from the card, still NOT measured: what a code package's stored sys_packages row does on the next boot after an edit. This PR stops new edits; rows an earlier edit wrote are not touched.

Generated by Claude Code

The pins for the manifest-edit door's ADR-0070 D2 read-only refusal, ahead
of the gate itself: four read-only kinds x four bodies on both limbs, plus
the writable, body-check and unknown-id controls.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
…22 WRITABLE_PACKAGE_REQUIRED

The manifest-edit arm now asks requireWritablePackage (isWritablePackage,
the ADR-0070 D2 rule) after the manage_metadata and walled gates and before
the body is read, in the order install-overwrite, disable, publish, revert
and delete already use.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
… refusal

Breaking narrowing of a REST door, shipped minor under the launch-window
convention, with FROM / TO, the Clause-② line and its ADR-0087 disposition.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 1 documentable anchor(s).

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

  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 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 54159fd742e5a79fc10ac41854dba3395e1a1ffe → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2cd17fe7e2f0cbb489a68d560f2442af3441fcf3 — the merge of head 5697a4d225f3130e5b18f05d7a20489a4101bbe7 into base 54159fd742e5a79fc10ac41854dba3395e1a1ffe, 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 2cd17fe7e2f0cbb489a68d560f2442af3441fcf3 && git checkout 2cd17fe7e2f0cbb489a68d560f2442af3441fcf3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 54159fd742e5a79fc10ac41854dba3395e1a1ffe 5697a4d225f3130e5b18f05d7a20489a4101bbe7 && git checkout -B drift-repro 54159fd742e5a79fc10ac41854dba3395e1a1ffe && git merge --no-ff 5697a4d225f3130e5b18f05d7a20489a4101bbe7

node scripts/docs-audit/affected-docs.mjs --json 54159fd742e5a79fc10ac41854dba3395e1a1ffe

⚠️ 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 54159fd742e5a79fc10ac41854dba3395e1a1ffe → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5697a4d225f3130e5b18f05d7a20489a4101bbe7
Local-runs: none
Scope: packages/runtime/src/domains/packages.ts, packages/runtime/src/domains/packages-manifest-patch-readonly.test.ts + packages/metadata-protocol/src/package-writability.ts (isWritablePackage), packages/metadata-protocol/src/protocol.ts (updatePackage, the class constructor), packages/runtime/src/http-dispatcher.ts (handlePackages, resolveService, the error deps), packages/runtime/src/error-envelope.ts (buildApiError), packages/objectql/src/engine.ts (registerApp), packages/objectql/src/registry.ts (installPackage), docs/adr/0070-package-first-authoring.md (D2), docs/adr/0131-total-organization-ownership-no-null-organization-id.md (D6), scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .github/workflows/pr-automation.yml (the Check Changeset job, WHICH LEVEL), packages/runtime/vitest.config.ts + vitest.repo-tests.json, packages/runtime/tsconfig.test.json, the swept pins (packages/runtime/src/http-dispatcher.test.ts, packages/runtime/src/domains/error-passthrough.test.ts, packages/runtime/src/domains/packages-readonly-gate.test.ts, packages/runtime/src/domains/packages-install-overwrite-readonly.test.ts, packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts, packages/client/src/packages-write-envelope.test.ts, packages/metadata-protocol/src/protocol.package-persist-failure.test.ts, docs/qa/platform-checklist/areas/api-backend.json), the precedent changeset at 62f959dd6 (.changeset/24030-install-overwrite-readonly-package.md), and objectui packages/app-shell/src/views/metadata-admin/PackagesPage.tsx at the .objectui-sha pin 12dc7b47c. The prose face .changeset/24705-patch-manifest-readonly-package.md is judged in ②.

① Derived judgments

Net diff against main at 54159fd74 (merge base 1c1144fcd, three commits on the branch): +256 / −0 over three files. packages.ts gains exactly one call plus its comment at the PATCH /packages/:id arm, and nothing else in the file moves.

Check-runs on the head (the gate verdicts). 39 runs, latest per name: 29 success, 0 failed, 6 skipped (Build Docs, Console Pin Gate, the Dogfood matrix shard, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), and 4 still in_progress: Test Core (1/6), (2/6), (3/6), (4/6). Those four are NOT MEASURED by this record; Test Core (5/6) and (6/6) are success. Also success: Check Changeset (the job that runs both check-adr-0087-registration and check-changeset-no-major), every Lint · family, every Type Check · family, TypeScript Type Check, Build Core, Governed Surface Queue Guard and the three claim guards. The judgments below are made on the diff.

  1. The accept-set change the diff publishes — RIGHT.

    • isWritablePackage answers false for an id in engine.manifests (booted by registerApp, as code or marketplace) and for a registry row whose manifest scope is system / cloud.
    • For such a package, PATCH /api/v1/packages/:id moves from 200 with the manifest edited (and, with a package service composed, sys_packages re-persisted by protocol.updatePackage) to 422 WRITABLE_PACKAGE_REQUIRED, with nothing written.
    • Governing text holds. ADR-0070 D2 makes exactly those two kinds read-only and names "a single predicate reused by all surfaces". package-writability.ts is that predicate, and the diff calls it through the domain's existing requireWritablePackage, re-deriving nothing.
    • Every other state-changing arm on the domain already asks it, read at the head: :1410 install-overwrite, :1602 disable, :1629 publish, :2114 revert, :2318 delete. So the door now gives the answer its siblings give, and the arm's new comment names those five siblings correctly.
  2. Position of the gate (manage, then walled, then writable, before the body) — RIGHT.

    • The PATCH arm now reads requireManageMetadata → refuseWalledCatalogPackageWrite → requireWritablePackage → body.
    • Disable (:1596-1602) and delete (:2306-2318, with its keepData arity read between walled and writable) use that order. Publish and install-overwrite place the gate after their own earlier refusals (the 409), and revert after the protocol's stored-row answer, each for a reason its comment states. So the PR body's "manage, walled, writable" claim is accurate for this arm and its nearest siblings.
    • The changeset discloses the consequence correctly: for a read-only id, a body the arm would itself refuse 400 ({}, a non-SemVer version) answers 422, because the refusal precedes the body read. That is the shape the card and the triage asked for ("gate the whole arm before the body is read").
  3. An unknown id keeps the arm's own 404 on both limbs — RIGHT.

    • isWritablePackage returns true for a non-empty id with no manifests entry and no registry row (package-writability.ts:73-82), so the gate falls through.
    • The fallback limb's registry.updatePackageManifest answers undefined → 404. The protocol limb's updatePackage throws statusCode: 404 (the updatePackage body in protocol.ts), which errorFromThrown carries.
    • The predicate's empty-id branch (!packageId → not writable) is unreachable here. parts = path.replace(/^\/+/, '').split('/').filter(Boolean) (packages.ts:1078), and the arm requires parts.length === 1, so id is a non-empty decoded segment. The gate is not an existence oracle, exactly as its doc block demands.
  4. Wire envelope — RIGHT.

    • deps.error(message, 422, { code, packageId, docs }) → buildApiError promotes details.code to error.code, keeps packageId and docs under error.details, and sets error.httpStatus: 422 (error-envelope.ts, splitSemanticCode + buildApiError).
    • The new pins assert exactly that shape, plus details.docs === 'docs/adr/0070-package-first-authoring.md'.
    • With verb edit, the refusal reads "Cannot edit package 'ID': it is read-only (provided by code or an installed app). Packages the deployment ships are managed by the deployment, not over this API — edit a package you own, or customise … (ADR-0131 D6)".
    • The D6 remedy clause is the shared CUSTOMISE_BESIDE_REMEDY, and it matches ADR-0131 D6's managed-mode text ("no door edits a managed definition … what the customer changes lives beside the managed item").
  5. No other public surface moves — RIGHT.

    • requireWritablePackage is module-private.
    • No export, spec key, stored shape or REST route-ledger row changes. The PATCH door is dispatcher-only and absent from packages/rest/src/rest-route-ledger.ts, as api-backend.json:1053 records.
    • The writable verdict the list and detail doors serve is untouched.
    • Type Check · * and TypeScript Type Check are success.
  6. The pins — RIGHT in construction; the CI verdict is pending.

    • packages-manifest-patch-readonly.test.ts is under src/**/*.test.ts and not in vitest.repo-tests.json, so it is in the local project (vitest.config.ts:234-236) that Test Core shards. It is in the tsconfig.test.json program (include reaches src), and TypeScript Type Check is green on it.
    • Its harness has the same shape as the ACCEPTed precedent pin packages-install-overwrite-readonly.test.ts:
      • the same real ObjectQL;
      • registerApp(manifest, undefined, 'marketplace'), matching registerApp(registeredBody, scope?, source = 'code') at engine.ts:8369;
      • registry.installPackage(manifest, 'authored'), matching (manifest, source, settings?, scope?) at registry.ts:5873;
      • new ObjectStackProtocolImplementation(engine, servicesGetter) with a thunk returning the services map, matching the constructor (engine, getServicesRegistry?, environmentId?, authoringChannel?) at protocol.ts:7062;
      • the same kernel double, and resolveService tolerates a getService that answers null.
    • 32 refusal rows (4 read-only kinds × 4 bodies × 2 limbs) read the observables, not the status alone: the row snapshot by value, updatePackageManifest never called, protocol.updatePackage never called, and the package service's publish never called.
    • 6 CONTROL rows: a writable edit answers 200 and the row and sys_packages spies really move; a writable {} still answers 400; an unknown id still answers 404, with updatePackageManifest called once.
    • The dev reports 32 failed / 6 passed at base and after a trap-armed ablation of the one call, and 38 passed at the head. This record does not re-run them. The four Test Core shards that would carry this file's CI verdict are in_progress, so that verdict is NOT MEASURED here.
  7. Full-repo pin sweep, re-read on the head tree — RIGHT. No existing test asserts a 200 PATCH of a read-only id:

    • http-dispatcher.test.ts's PATCH cases run over { registry: mockRegistry } with no manifests and no getPackage, so the package is writable;
    • error-passthrough.test.ts:141 uses a double;
    • packages-write-envelope.test.ts (client) only PATCHes /enable and /disable, and so does packages-readonly-gate.test.ts;
    • dispatcher-body-after-anonymous-floor.hono.integration.test.ts uses the unknown pkg_x;
    • protocol.package-persist-failure.test.ts calls impl.updatePackage directly, under the gate;
    • api-backend.json:1049 PATCHes a package the item installs with scope: 'project'.

    The other WRITABLE_PACKAGE_REQUIRED hits are the authoring path's, and they are untouched.

  8. Serial with PR feat(runtime): PATCH /api/v1/packages/:id edits a package's manifest dependencies #24703 — RIGHT as stated. The PATCH arm on base main 54159fd74 carries neither the gate nor a dependencies hunk, so feat(runtime): PATCH /api/v1/packages/:id edits a package's manifest dependencies #24703 has not landed, and this PR lands first as triage 6280778865 ordered. Because the gate precedes the body read, the { dependencies: {} } rows hold on either tree.

  9. Governance — RIGHT.

    • No governed path is in the file list. record-recognisers --template --paths on the three paths decides no face on paths alone and defers to the changeset's Clause-② arm → narrowing → CONTRACT_REVIEW_TIER, which is this record.
    • Governed Surface Queue Guard is success.
    • 256 lines, under the human-merge threshold.

② Semver level

'@objectstack/runtime': minor — RIGHT.

  • The act is a BREAKING narrowing of a published REST door's accept set. AGENTS.md Post-Task item 3 makes (narrowing) BREAKING, so the ordinary patch for a bug fix does not describe it.
  • During the launch window check-changeset-no-major.mjs refuses major, and breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level (the script's header, and the Check Changeset step's WHICH LEVEL prose, pr-automation.yml:754-775). So minor is the level the convention gives. The precedent changeset at 62f959dd6 (the install-overwrite sibling, ACCEPTed) graded the same act minor in the same form.
  • The changeset carries the fix(runtime)!: title, the BREAKING banner, and FROM / TO / Unchanged / "the fix for an integrator", which is the migration text item 3 requires of a breaking changeset.
  • It carries exactly one ADR-0087 marker (an HTML comment) reading not-required (no-migration-prescription), a category the gate lists (check-adr-0087-registration.mjs:147,539). Its refusal condition, a code-rewrite prescription in the body, is not met: the integrator line is the remedy ADR-0131 D6 names, not a rewrite.
  • No other package's code changes, so no second changeset is owed; packages/client forwards the envelope unchanged.
  • Check Changeset, the job that runs both gates on this PR, is success on the head.

Clause-②: line — RIGHT. Clause-②: no (narrowing) is on both the PR body and the changeset: no key is added to any published payload (no), and the door's accept set shrinks ((narrowing), the one arm allowed with no). The two spellings agree, which is what check-adr-0087-registration reads. The claim comment 6280929612 carries a bare Clause-②: no. The dev named that in the report rather than choosing silently, and the arm belongs on the PR body and the changeset, where it is.

③ Boundary flags

open_questions: none declared. The dev's deviations (5), its out_of_scope_findings (1) and the PR's Acceptance notes (3) are each answered below.

  1. origin/main was not merged before opening — answered, accepted. Main gained six commits since 1c1144fcd, and the base's packages.ts PATCH arm, package-writability.ts, protocol.updatePackage and the registry's package methods are as the branch read them. CI measured the merge ref 2cd17fe7e, and the queue merges onto main anyway.
  2. Bare Clause-②: no on the claim comment vs no (narrowing) on the PR body and changeset — answered in ②: the spellings the gate reads carry the arm, and they are right.
  3. The --ran record line was rewritten after the check:dual-build-cjs-loads re-run on a fully built tree — answered, no bearing. It is local bookkeeping, and the head's check-runs are the gate verdicts here.
  4. Model-free commit trailers — answered, accepted. The repo's pre-push hook refuses a model identifier, and AGENTS.md outranks the harness reminder.
  5. Pins widened beyond the dispatch (four kinds × four bodies × two limbs) — answered, accepted. It is a strict superset with the same assertions, and the cloud-scoped and marketplace kinds are the predicate's own branches.
  6. The out-of-scope finding (carrier: none, noted not filed): objectui's Packages detail sheet decides its action buttons from manifest.scope (isKernel), while its Scope badge reads the served writable verdict.
    • Verified at the pin 12dc7b47c: isKernel = pkg.manifest.scope === 'system' || pkg.manifest.scope === 'cloud' (PackagesPage.tsx:491), and Edit (setEditOpen), Publish and the rest sit in the !isKernel arm (:874-890).
    • So a code-booted or marketplace package whose scope is project or absent is offered Edit, Disable, Publish, Revert and Delete, and each answers 422 (Edit only since this PR; the other four already).
    • Answered: not a blocker for this PR. The server's refusal is the contract, and the dialog renders the envelope's message.
    • ESCALATED to the owning seat as a filing recommendation: a sibling-repo (objectui) card, area:studio, "detail-sheet action buttons should read the served writable verdict (objectui#12723 did this for the badge)". The dev's dedupe words stand.
  7. requireWritablePackage's doc block in packages.ts lists its doors (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 lifecycle, finding(metadata): revertPackage and publishPackage find a package's members by the item's own packageId/package key, so a code-shipped package whose items carry only the _packageId stamp answers 404 "No metadata items found" #22113 publish/revert) and names neither this door nor install-overwrite — answered, accepted. It is an in-file comment on a private function, already stale from 62f959dd6. The arm's own comment names the door, and no gate reads the list. Tidy it the next time that block is touched.
  8. packages/client's packages.update JSDoc does not mention the refusal — answered, accepted. The client's behaviour is unchanged (it forwards the envelope), and no changeset is owed on it.
  9. Carried from the card, still NOT measured: what a code package's stored sys_packages row (written by an earlier edit) does on the next boot — ESCALATED back to card packages: PATCH /api/v1/packages/:id edits a read-only package's manifest (200), where disable, publish, revert, delete and install-overwrite refuse it 422 WRITABLE_PACKAGE_REQUIRED #24705 as an open measurement outside this PR's scope. This PR stops new edits and touches no stored row. The card's own Persistence line already marks it NOT measured, and it stays there for the lane.
  10. Docs Drift Check advisory (content/docs/permissions/system-context.mdx via handlePackagesRequest) — answered, no action. That page states caller-permission rules; this change adds a package-property refusal after the permission gate and moves no permission rule.

Implemented-by: claude/issue-24705-patch-read-only-gate
Reviewed-by: session_01LmjU2x1faLZ39DVTFyVca6

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT

domain:cli seat 2 (#22648) · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T02:25Z · head 5697a4d225f3130e5b18f05d7a20489a4101bbe7 · claim 6280929612 → report 6281583363 → review of record 6281878277 (CONTRACT_REVIEW_TIER, PASS).

The review of record. An isolated reviewer at CONTRACT_REVIEW_TIER rendered it on an adversarial brief: card, PR and check-runs only, and neither the dispatch order nor this seat's conclusions. This seat adopted it after reading its load-bearing claims at source:

  • isWritablePackage answers true for an unknown non-empty id (package-writability.ts), so the arm's 404 stands;
  • the gate sits at :2266, among the five sibling calls :1410 / :1602 / :1629 / :2114 / :2318;
  • the check-runs below.
    A first reviewer was lost to a container restart before it returned anything, and nothing of it was posted or adopted.
Check Reading
Shape Draft, base main. First line Fixes #24705; Clause-②: no (narrowing) line-initial in the body and the changeset. Assignee os-justin = the card's. NOT governed, 256 changed lines.
Surface 3 files: the one gate call on the PATCH arm, its pin file and one changeset. The body handling is untouched. Hard serial ahead of PR #24703, as triage 6280778865 orders.
Changeset Read line by line against the diff. The FROM / TO / Unchanged / fix-for-an-integrator lines each match the gate's position (before the body read, so {} and a non-SemVer version answer 422 for a read-only id) and the pinned controls (200 writable, 400 writable {}, 404 unknown). minor + BREAKING + not-required (no-migration-prescription) follows the sibling precedent 62f959dd6.
Gates Check-runs on this head (40, latest per name), read at this act: 34 success, 6 skipped, 0 in progress. That includes Test Core (1/6)–(4/6), which the review recorded as NOT MEASURED at its read and which are now success. Check Changeset is success.

Out-of-scope dispositions (one line each):

  • objectui's Packages detail sheet offers Edit / Disable / Publish / Revert / Delete from manifest.scope (isKernel, PackagesPage.tsx:491 at pin 12dc7b47c), not from the served writable verdict: Acceptance notes, not filed. The review read it at source; it is not reproduced in a UI, so it has no measured reach under the filing gate. The server's 422 is the contract, and the dialog renders it.
  • What a code package's sys_packages row, written by an earlier edit, does on the next boot: Acceptance notes, carrier none. This PR stops new edits and touches no stored row.
  • requireWritablePackage's doc block lists its doors incompletely: Acceptance notes, for the next touch of that block.

Landing: held while main-red #24700 is open (its fix is PR #24724, ACCEPTed). Then it goes through the merge queue by this seat, ahead of PR #24703.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 02:53
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 02:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 9b2953d Oct 20, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24705-patch-read-only-gate branch October 20, 2026 03:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants