Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): the metadata write refusal reports the package door — ITEM_LOCKED / WRITABLE_PACKAGE_REQUIRED are emitted where they apply - #8185
Conversation
…ot only the type's flags (#7682) `PUT /api/v1/meta/object/showcase_task` answered `403 NOT_OVERRIDABLE` identically whether `?package=` named a read-only or a writable package: `SysMetadataRepository.assertAllowed` discriminated on the metadata TYPE's registry flags and never read the base the caller named. `ITEM_LOCKED` and `WRITABLE_PACKAGE_REQUIRED` are both registered to this package in the error-code ledger and neither was ever emitted on this path. The refusal branch now reads the named base through the shared `isWritablePackage` predicate (imported from `package-writability.ts`, the same one `saveMetaItem`'s ADR-0070 D1 gate and the `/packages` lifecycle gate use): * `override-artifact` into a read-only base -> 403 ITEM_LOCKED, carrying ADR-0010's reserved `lockSource: 'package'` and the package id. "Pick a writable base" would be a false prescription here, so the code names the lock instead. * `runtime-only` into a read-only base -> 422 WRITABLE_PACKAGE_REQUIRED, the same code, status and prescription `saveMetaItem` already emits for exactly this condition, now also stated at the single persistence route. No ALLOW decision moves: every allow limb returns before this point, so an ADR-0005 overlay (which names the read-only package it customizes by construction) and the documented `OS_METADATA_WRITABLE` hatch behave exactly as before -- both pinned, the hatch because #8146 must be free to move it deliberately. A write naming no base keeps the previous codes verbatim, and the DELETE verb is untouched (#6960). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…TED, per the 2026-08-12 #8146 ruling An in-flight maintainer ruling landed on #7682 after this work started: #8146 is settled as option B — a hatch write into a read-only package should REFUSE, the Studio badge is telling the truth, and the hatch is type-level by its own shipped documentation. The dispatch's instruction to "preserve and pin" that behaviour is therefore superseded, and the ruling says explicitly not to land a pin asserting it as correct. No behaviour change here. The hatch limb was never touched by this PR (it returns before the new package door), so nothing to revert; what changes is the CLAIM the suite and the changeset were making about it. The case is relabelled as a characterization pin of today's answer and kept, deliberately, as the tripwire the #8146 fix must invert. Re-measured on current main at that ruling's request, since the original measurement was against two-week-old builds: it still reproduces end to end through saveMetaItem on the host-config topology — `success: true`, and the row lands with `package_id = com.example.showcase`, i.e. bound INTO the read-only package rather than as the per-org override the variable's documentation describes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t
…he gap as deliberate (#7682) PM ruling on PR #8185's patch round. The case asserted that a hatch write into a read-only package SUCCEEDS — green because the bug is present — and the 2026-08-12 ruling on #8146 said "don't merge it". A better label does not change what the assertion claims, so it goes. My tripwire argument for keeping it is answered: #8146 already names "the refusal plus a rejection pin asserting code and status" as its own deliverable, so the pin gets written against the FIXED behaviour, which is where it belongs. The opposite risk is real too — a deliberately-red pin gets "repaired" to green by someone who never read why it was there. The suite docblock now states that the hatch path is deliberately uncovered pending #8146, so the absence reads as a decision rather than an oversight, and carries the re-measurement: the row lands bound INTO the read-only package (package_id = com.example.showcase, organization_id = null) rather than as the per-org override the variable's own documentation describes. The changeset says the same. No source change: `assertAllowed` is correct as shipped, and both open questions were ruled in its favour (the ITEM_LOCKED / WRITABLE_PACKAGE_REQUIRED partition, and `Part of #7682`). The `permission` case is kept and moved beside its siblings — with no hatch set it asserts the refusal, which is the card's point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t
…kage-writability-refusal
…suite docblock (#7682) Formatting only, no assertion or source change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t
…e'] (#7682) CI's TypeScript Type Check went red on my push: the metadata-protocol DEBT entry records 63 raw tsc errors and `tsc --noEmit` reported 64. The +1 was mine and real: sys-metadata-repository.package-writability.test.ts(166,21): error TS2322: Type 'string' is not assignable to type '"object" | "view" | ... | "email_template"'. `putWith`'s `opts.type` was `string`; `MetaRef['type']` is a literal union. The suite ran green under vitest either way — this package has no `typecheck` script, so its type surface is only judged by the ledger in CI, which is exactly the gap that let a widened parameter through a local run. Fixed at the source, not in the ledger: DEBT is a ratchet that may only shrink, and raising an entry for a file added in the same PR would be the worst version of that. `tsc --noEmit` now reports 63 for the package, matching the recorded entry, with zero errors attributable to either file this PR touches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t
…kage-writability-refusal
Uh oh!
There was an error while loading. Please reload this page.
…d' comment #8185 wrote it while the OS_METADATA_WRITABLE gap was still open; this change closes that gap, so the comment now contradicts the suite docblock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
Part of #7682
Restores an invariant the error-code ledger already declared: the metadata write refusal now reports the package door when the caller named a read-only base, instead of always blaming the metadata type's registry flags.
The defect
PUT /api/v1/meta/object/showcase_taskanswered403 NOT_OVERRIDABLE("'object' is not allowOrgOverride in the registry") — the same code, status and sentence whether?package=pointed at a read-only package or a writable one. The refusal discriminated on the metadata type's registry flags and never read the base the caller named, so the two codes the ledger registers to@objectstack/metadata-protocolfor the package-writability condition —ITEM_LOCKEDandWRITABLE_PACKAGE_REQUIRED— were never emitted on this path at all. Declared, not enforced.Premise verification
Both of the dispatch's mechanism assumptions hold, and one deserves stating because it decides where the fix belongs:
sys-metadata-repository.tsassertAllowed(intent === 'runtime-only' ? 'NOT_CREATABLE' : 'NOT_OVERRIDABLE') — re-located by reading, as instructed.PutOptions.packageIdis the base?package=resolves to,this.engineis the same engine the protocol hands the repository, andisWritablePackage(packages/metadata-protocol/src/package-writability.ts) already reads exactly those two surfaces structurally. It is imported, never re-spelled — that shared-rule argument is the module's own stated reason to exist.saveMetaItem's own artifact-backed refusal sits behindenvironmentId !== undefined, and the showcase is assembled by the CLI's lightweight host-config path (new ObjectQLPlugin(), no environmentId).meta-object-owd-gate.test.tsstates the same reading independently ("SysMetadataRepository.assertAllowed()refuses anobjectoverlay of a PACKAGED item outright"), and the end-to-end block in the new suite proves it by drivingsaveMetaItemon that topology.The fix
A refused write that named a read-only base now says so, and which code depends on intent, because the two ledgered codes are not interchangeable:
override-artifact— an artifact backs the name and it ships from a package the deployment provides →403 ITEM_LOCKED, carryinglockSource: 'package'(ADR-0010's own reserved value for a lock the package layer asserts) and the package id.WRITABLE_PACKAGE_REQUIREDwould be a false prescription here: switching bases cannot help, because the artifact is code-shipped wherever the caller points, so an auto-retrying client would simply fail again. This is the server-side counterpart of the "Read-only" badge Studio already renders.runtime-only— no artifact under this name, so the caller is authoring a NEW item into a read-only base →422 WRITABLE_PACKAGE_REQUIREDwith the package id: the same code, status and prescriptionsaveMetaItemalready emits for exactly this condition (ADR-0070 D1), now also stated at the single persistence route, for callers that do not pass through that gate.That partition was PM-ruled (option A) on this PR's patch round, on the false-prescription argument above.
What deliberately does NOT move
No allow decision changes. Every allow limb returns before the new door, so this is the code selection inside the refusal branch, not a new gate. That is load-bearing rather than cautious:
saveMetaItemcreates — topromoteDraft/restoreVersion/revertCommit, which route throughputcarrying the row's own binding, i.e. it would break republishing and repair of legacy package-bound rows on surfaces nobody measured.Also unchanged: a write naming no base keeps
NOT_OVERRIDABLE/NOT_CREATABLEverbatim (isWritablePackage(null)isfalseby design — "no base resolved" — so reading it without the caller-named guard would re-code every ordinary env-local refusal in the product), and the DELETE verb is untouched (DeleteOptionsnames no package; #6960 moved that side on purpose and warns against symmetrising).OS_METADATA_WRITABLEis deliberately uncovered here (#8146)The hatch path is untouched by this change — structurally, its limb returns before the new door — and this PR ships no test of it, which the suite docblock records so the gap reads as a decision rather than an oversight.
An earlier revision of this branch carried a labelled characterization pin of today's behaviour. It was removed on PM ruling, and the reasoning is worth keeping: however well labelled, that case was green because the bug is present, and the 2026-08-12 ruling on #8146 (option B — the server should refuse; the badge is telling the truth) said plainly not to merge it. #8146 already names "the refusal plus a rejection pin asserting
codeandstatus" as its own deliverable, so the pin gets written against the fixed behaviour, which is where it belongs.New evidence carried to that decision, re-measured on current
mainat the ruling's request (the original measurement was against two-week-old builds). The hatch write still succeeds end to end throughsaveMetaItemon the host-config topology:{ "success": true, "seq": 1, "state": "active", "message": "Saved customization overlay (env-wide, state=active) — type=permission, name=showcase_contributor" }and the row lands
{ package_id: "com.example.showcase", organization_id: null }— i.e. bound into the read-only package, not the per-org override the variable's own documentation describes ("treats them asallowOrgOverride: true", a type-level unlock). Which of the two readings of option B follows from that is the maintainer's call on #8146; nothing here pre-empts it.Tests
New:
packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts(9 cases). Every refusal case pins bothcodeandstatus; the read-only/writable pair is asserted as a difference, so a suite that started answering the new codes on both sides would go red.Reverse verification, direction predicted before running: reverting the source and keeping the suite turned the discrimination cases red while leaving the preservation cases (the ADR-0005 overlay landing, the no-base codes, the delete codes) green — measured exactly so. The green ones are the point: they do not depend on this change, which is what makes them honest pins rather than artifacts of it.
Consumer sweep direction, stated so it can be reviewed: those three downstream packages were selected by hand as the ones that exercise this refusal (the full
...@objectstack/metadata-protocolprefix set is effectively the whole repo); the^...suffix filter was used only for building dependency closures. Two of the three initially failed to collect on unbuilt sibling dists in a fresh worktree — the stale-artefact trap, not this change — and are green after their closures were built.Gates:
check:cross-package-test-inputs,check:durability-log-level(both named byscripts/pm/dispatch-gates.mjsfor this diff), pluscheck:error-code-casing(new error codes),check:nul-bytes+ a widened control-byte self-scan, the spec error-code-ledger test, ESLint on both files, and the changeset gates. All green. Both codes were already registered to this package in the ledger, so nothing inpackages/specmoves.What this does not close
OS_METADATA_WRITABLEunlocks a write on a read-only package while Studio renders a "Read-only" badge — which one is telling the truth? #8146 — ruled option B, but the reading fork above is with the maintainer and its implementation is not in this PR.saveMetaItem's scoped-kernel branch refuses before the repository's package door ever runs #8184 (filed from this work) —saveMetaItem's scoped-kernel branch (environmentId !== undefined) refuses before the repository door ever runs, so on a cloud per-env kernel the same request still answers the undiscriminatedNOT_OVERRIDABLE. That second refusal point lives inprotocol.ts, which this dispatch forbade editing (two siblings, [finding]metadata-protocolinterpolates raw driver text into client-facing messages — three downstream sanitizers each have a hole because of it (option C of #8086) #8136 and finding: the list-query normalizer'swherearity note still says a repeated?filter=is refused byisFilterASTfailing #8003, are in that file concurrently). Not a regression — that branch answeredNOT_OVERRIDABLEbefore this PR too; this change makes the divergence visible. The new suite's finaldescribestates the boundary so it is not mistaken for coverage.Hence
Part of #7682rather thanFixes— PM-ruled on the same patch round.Generated by Claude Code