Skip to content
63 changes: 63 additions & 0 deletions .changeset/metadata-refusal-package-writability.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): the metadata write refusal reports the package door — `ITEM_LOCKED` / `WRITABLE_PACKAGE_REQUIRED` are emitted where they apply (#7682)

`PUT /api/v1/meta/object/showcase_task` answered `403 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 error-code
ledger registers to this package for the package-writability condition —
`ITEM_LOCKED` and `WRITABLE_PACKAGE_REQUIRED` — were never emitted on this path
at all. Declared, not enforced.

`SysMetadataRepository.assertAllowed` now reads the named base through the
shared `isWritablePackage` predicate (the same one `saveMetaItem`'s ADR-0070 D1
gate and the `/packages` lifecycle gate use — imported, not re-spelled), and a
refused write that named a read-only base says so:

- **`override-artifact`** (an artifact backs the name, and it ships from a
package the deployment provides) → `403 ITEM_LOCKED`, carrying
`lockSource: 'package'` — ADR-0010's own reserved value for a lock the package
layer asserts — plus the package id. `WRITABLE_PACKAGE_REQUIRED` would be the
wrong prescription here: switching bases cannot help, because the artifact is
code-shipped wherever the caller points. This is the server-side counterpart
of the "Read-only" badge Studio already renders.
- **`runtime-only`** (no artifact under this name — a NEW item authored into a
read-only base) → `422 WRITABLE_PACKAGE_REQUIRED` with the package id, the
same code, status and prescription `saveMetaItem` already emits for exactly
this condition. One vocabulary, now stated at the single persistence route as
well, so callers that do not pass through that gate cannot skip it.

**No allow decision moves.** Every write that succeeded before still succeeds:
this is the code selection inside the refusal branch, not a new gate. That
distinction is load-bearing rather than cautious — an ADR-0005 org overlay names
the read-only package it customizes *by construction*, so a package door that
refused would close the overlay model itself. Writes that name no base keep the
previous `NOT_OVERRIDABLE` / `NOT_CREATABLE` codes verbatim, and the DELETE verb
is unchanged (#6960 moved that side on purpose; `DeleteOptions` names no
package).

The `OS_METADATA_WRITABLE` hatch is likewise untouched — structurally, because
its limb returns before the new door — and is deliberately left **uncovered** by
this change's tests, which the suite docblock records so the gap reads as a
decision. The maintainer ruling of 2026-08-12 on #8146 holds that a hatch write
into a read-only package should REFUSE, so a test of today's answer would be
green *because the bug is present*; #8146 ships the refusal and its own
rejection pin (`code` + `status`) together.

Re-measured on current `main` while this was in flight, and carried here because
it is new evidence for that decision: the hatch write still succeeds, and 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 ("treats them as `allowOrgOverride: true`" — a
*type*-level unlock, which says nothing about the package dimension).

Reachability, stated so it is not mistaken for more than it is: this refusal is
what answers on the host-config topology (`environmentId` undefined — the CLI's
lightweight assembler, i.e. the flagship showcase and self-hosted servers shaped
like it), which is the topology the defect was measured on. On a scoped kernel
`saveMetaItem` refuses earlier, in `protocol.ts`, still with the undiscriminated
`NOT_OVERRIDABLE`; that second refusal point is filed separately.
Loading
Loading