Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/scoped-kernel-package-door.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): the metadata write refusal stops depending on deployment topology (#8184)

`PUT /api/v1/meta/object/showcase_task?package=READONLY_PKG` answered **two
different machine-readable codes for one condition**, selected by the kernel's
`environmentId` — a row-scoping key, not a topology declaration:

| kernel | answer |
| --- | --- |
| host-config / CLI lightweight assembler (`environmentId` undefined — the flagship showcase, self-hosted servers) | `403 ITEM_LOCKED`, `lockSource: 'package'` |
| project / cloud per-environment kernel (`environmentId` set) | `403 NOT_OVERRIDABLE` — the package was never read |

`saveMetaItem` carries its own artifact-backed refusal behind
`if (this.environmentId !== undefined)`, and it threw before
`SysMetadataRepository.assertAllowed` — the topology-independent package door
(#7682, then #8146's hatch ruling) — ever ran. So a client that learned to
handle `ITEM_LOCKED` on a self-hosted deployment never saw it on a cloud one,
and an operator reading `NOT_OVERRIDABLE` was told the type had no overlay
channel when the real obstacle was the read-only base they had named.

Not a regression: that branch answered `NOT_OVERRIDABLE` before #8185 and
#8320 too. Those cards made the divergence visible by fixing the other half.

**The scoped branch now consults the same `isWritablePackage` predicate and
throws the repository's own emitter** — called, not copied — so the code, the
status, `lockSource`, `packageId` and the sentence are byte-identical on both
topologies, and neither door can drift when the other moves.

**Same limb ordering as the repository, because the ordering is the rule:**

- **Below every registry limb.** The branch is guarded by `!overlayAllowed`, so
an `allowOrgOverride` type never reaches the door. An ADR-0005 org overlay of
a code-shipped item *always* names the read-only package it customizes; a
door one limb higher would close the overlay model outright.
- **Above the hatch limb.** `isOverlayAllowed` folds `OS_METADATA_WRITABLE` in,
so an open hatch takes the write past this branch to the repository door,
which applies the same rule with its own hatch-aware remedy — the refusal
never prescribes the step the caller already took. Both directions pinned.

**Narrow, exactly as the repository is.** Only a write that *names* a read-only
base is re-coded; a package-less write keeps `NOT_OVERRIDABLE` verbatim, and a
package-less hatch write still lands `{ package_id: null, organization_id: null }`
env-wide and `{ package_id: null, organization_id: <org> }` under an org kernel.
Refusing a hatch write that names no read-only base (the broad reading) would
retire the hatch's only documented use and remains a maintainer decision plus a
docs/ADR change.

The `runtime-only` create side needed no change: the ADR-0070 D1 gate further
down `saveMetaItem` is already topology-independent and already answers
`422 WRITABLE_PACKAGE_REQUIRED` on every kernel.
68 changes: 68 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10434,6 +10434,74 @@ export class ObjectStackProtocolImplementation implements
if (this.environmentId !== undefined) {
const artifactBacked = this.isArtifactBacked(request.type, request.name);
if (artifactBacked && !overlayAllowed) {
// [#8184] THE PACKAGE DOOR — the SECOND refusal point for one
// condition, and the reason this card exists.
//
// `SysMetadataRepository.assertAllowed` reads the base the
// caller NAMED and answers `ITEM_LOCKED` (`lockSource:
// 'package'`) when it is read-only (#7682, then #8146's
// hatch ruling). That door is topology-INDEPENDENT — and it
// was unreachable here, because this branch throws first on
// every kernel with an `environmentId`. So one request
// answered `ITEM_LOCKED` on a host-config / CLI-assembled
// kernel and the undiscriminated `NOT_OVERRIDABLE` on a
// project/cloud per-env one: the refusal VOCABULARY keyed off
// a row-scoping key, which is the #5086 / #6710 finding
// (see the block comment above) arriving on the error codes.
// A client that learns to handle `ITEM_LOCKED` on one
// deployment never saw it on the other.
//
// ⚠️ MIRRORED, NOT RE-INVENTED. Same predicate
// ({@link isWritablePackage}, the ADR-0070 rule in one
// place), same emitter — `readOnlyBaseOverrideError` is
// called, not copied — so the code, the status, the
// `lockSource`, the `packageId` and the sentence cannot drift
// between the two doors. Two independently-authored refusals
// for one condition is how `NOT_OVERRIDABLE`-everywhere
// started.
//
// THE LIMB ORDERING IS THE RULE, and it is the same ordering
// the repository states: BELOW every registry limb, ABOVE the
// hatch limb.
// • Below the registry limb — this whole branch is guarded
// by `!overlayAllowed`, so an `allowOrgOverride` type
// never reaches the door. That is ADR-0005: an org
// overlay of a code-shipped item ALWAYS names the
// read-only package it customizes, and a door one limb
// higher would close the overlay model outright. Pinned.
// • Above the hatch limb — `isOverlayAllowed` folds
// `OS_METADATA_WRITABLE` in, so an OPEN hatch takes the
// write past this branch entirely, down to the repository
// door, which applies the same rule with `hatchOpen:
// true` and its own remedy. The hatch therefore still
// never unlocks package writability on this topology
// either (#8146 NARROW), and both directions of that
// remedy selection are pinned in
// `sys-metadata-repository.package-writability.test.ts`.
// That is also why `hatchOpen` is passed as a literal
// `false` here rather than recomputed: reaching this line
// PROVES the hatch is closed, and a recomputed value
// would be dead code dressed as a decision.
//
// ⛔ NARROW, exactly as the repository is: only a write that
// NAMES a read-only base is re-coded. A package-less write
// keeps `NOT_OVERRIDABLE` verbatim. Refusing a hatch write
// that names NO read-only base (BROAD) retires the hatch's
// only documented use and needs a maintainer decision plus a
// docs/ADR change — never arrived at from here.
//
// `runtime-only` needs no limb here: this branch is guarded by
// `artifactBacked`, so the intent is always
// `override-artifact`. The create side of the door is the
// ADR-0070 D1 gate further down this method, which is already
// topology-independent and already answers
// `WRITABLE_PACKAGE_REQUIRED` / 422 on every kernel.
const namedBase = typeof request.packageId === 'string' && request.packageId.length > 0;
if (namedBase && !this.isWritablePackage(request.packageId)) {
throw SysMetadataRepository.readOnlyBaseOverrideError(
request.type, request.packageId as string, false,
);
}
const err = new Error(
`[not_overridable] Metadata item '${request.type}/${request.name}' is provided by a code package `
+ `and the type has not opted into per-org overlay writes (allowOrgOverride=false). `
Expand Down
Loading
Loading