Skip to content

Commit 003feae

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): the metadata write refusal stops depending on deployment topology (#8184) (#8353)
* fix(metadata-protocol): the scoped-kernel refusal consults the package door (#8184) `saveMetaItem`'s artifact-backed refusal sits behind `environmentId !== undefined` and threw before `SysMetadataRepository.assertAllowed` ever ran, so one request answered `403 ITEM_LOCKED` on a host-config kernel and the undiscriminated `403 NOT_OVERRIDABLE` on a project/cloud per-env one. The branch now consults the same `isWritablePackage` predicate and throws the repository's OWN emitter — one condition, one vocabulary, on every topology. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH * chore(changeset): scoped-kernel package door (#8184) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c519533 commit 003feae

4 files changed

Lines changed: 388 additions & 8 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): the metadata write refusal stops depending on deployment topology (#8184)
6+
7+
`PUT /api/v1/meta/object/showcase_task?package=READONLY_PKG` answered **two
8+
different machine-readable codes for one condition**, selected by the kernel's
9+
`environmentId` — a row-scoping key, not a topology declaration:
10+
11+
| kernel | answer |
12+
| --- | --- |
13+
| host-config / CLI lightweight assembler (`environmentId` undefined — the flagship showcase, self-hosted servers) | `403 ITEM_LOCKED`, `lockSource: 'package'` |
14+
| project / cloud per-environment kernel (`environmentId` set) | `403 NOT_OVERRIDABLE` — the package was never read |
15+
16+
`saveMetaItem` carries its own artifact-backed refusal behind
17+
`if (this.environmentId !== undefined)`, and it threw before
18+
`SysMetadataRepository.assertAllowed` — the topology-independent package door
19+
(#7682, then #8146's hatch ruling) — ever ran. So a client that learned to
20+
handle `ITEM_LOCKED` on a self-hosted deployment never saw it on a cloud one,
21+
and an operator reading `NOT_OVERRIDABLE` was told the type had no overlay
22+
channel when the real obstacle was the read-only base they had named.
23+
24+
Not a regression: that branch answered `NOT_OVERRIDABLE` before #8185 and
25+
#8320 too. Those cards made the divergence visible by fixing the other half.
26+
27+
**The scoped branch now consults the same `isWritablePackage` predicate and
28+
throws the repository's own emitter** — called, not copied — so the code, the
29+
status, `lockSource`, `packageId` and the sentence are byte-identical on both
30+
topologies, and neither door can drift when the other moves.
31+
32+
**Same limb ordering as the repository, because the ordering is the rule:**
33+
34+
- **Below every registry limb.** The branch is guarded by `!overlayAllowed`, so
35+
an `allowOrgOverride` type never reaches the door. An ADR-0005 org overlay of
36+
a code-shipped item *always* names the read-only package it customizes; a
37+
door one limb higher would close the overlay model outright.
38+
- **Above the hatch limb.** `isOverlayAllowed` folds `OS_METADATA_WRITABLE` in,
39+
so an open hatch takes the write past this branch to the repository door,
40+
which applies the same rule with its own hatch-aware remedy — the refusal
41+
never prescribes the step the caller already took. Both directions pinned.
42+
43+
**Narrow, exactly as the repository is.** Only a write that *names* a read-only
44+
base is re-coded; a package-less write keeps `NOT_OVERRIDABLE` verbatim, and a
45+
package-less hatch write still lands `{ package_id: null, organization_id: null }`
46+
env-wide and `{ package_id: null, organization_id: <org> }` under an org kernel.
47+
Refusing a hatch write that names no read-only base (the broad reading) would
48+
retire the hatch's only documented use and remains a maintainer decision plus a
49+
docs/ADR change.
50+
51+
The `runtime-only` create side needed no change: the ADR-0070 D1 gate further
52+
down `saveMetaItem` is already topology-independent and already answers
53+
`422 WRITABLE_PACKAGE_REQUIRED` on every kernel.

packages/metadata-protocol/src/protocol.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10434,6 +10434,74 @@ export class ObjectStackProtocolImplementation implements
1043410434
if (this.environmentId !== undefined) {
1043510435
const artifactBacked = this.isArtifactBacked(request.type, request.name);
1043610436
if (artifactBacked && !overlayAllowed) {
10437+
// [#8184] THE PACKAGE DOOR — the SECOND refusal point for one
10438+
// condition, and the reason this card exists.
10439+
//
10440+
// `SysMetadataRepository.assertAllowed` reads the base the
10441+
// caller NAMED and answers `ITEM_LOCKED` (`lockSource:
10442+
// 'package'`) when it is read-only (#7682, then #8146's
10443+
// hatch ruling). That door is topology-INDEPENDENT — and it
10444+
// was unreachable here, because this branch throws first on
10445+
// every kernel with an `environmentId`. So one request
10446+
// answered `ITEM_LOCKED` on a host-config / CLI-assembled
10447+
// kernel and the undiscriminated `NOT_OVERRIDABLE` on a
10448+
// project/cloud per-env one: the refusal VOCABULARY keyed off
10449+
// a row-scoping key, which is the #5086 / #6710 finding
10450+
// (see the block comment above) arriving on the error codes.
10451+
// A client that learns to handle `ITEM_LOCKED` on one
10452+
// deployment never saw it on the other.
10453+
//
10454+
// ⚠️ MIRRORED, NOT RE-INVENTED. Same predicate
10455+
// ({@link isWritablePackage}, the ADR-0070 rule in one
10456+
// place), same emitter — `readOnlyBaseOverrideError` is
10457+
// called, not copied — so the code, the status, the
10458+
// `lockSource`, the `packageId` and the sentence cannot drift
10459+
// between the two doors. Two independently-authored refusals
10460+
// for one condition is how `NOT_OVERRIDABLE`-everywhere
10461+
// started.
10462+
//
10463+
// THE LIMB ORDERING IS THE RULE, and it is the same ordering
10464+
// the repository states: BELOW every registry limb, ABOVE the
10465+
// hatch limb.
10466+
// • Below the registry limb — this whole branch is guarded
10467+
// by `!overlayAllowed`, so an `allowOrgOverride` type
10468+
// never reaches the door. That is ADR-0005: an org
10469+
// overlay of a code-shipped item ALWAYS names the
10470+
// read-only package it customizes, and a door one limb
10471+
// higher would close the overlay model outright. Pinned.
10472+
// • Above the hatch limb — `isOverlayAllowed` folds
10473+
// `OS_METADATA_WRITABLE` in, so an OPEN hatch takes the
10474+
// write past this branch entirely, down to the repository
10475+
// door, which applies the same rule with `hatchOpen:
10476+
// true` and its own remedy. The hatch therefore still
10477+
// never unlocks package writability on this topology
10478+
// either (#8146 NARROW), and both directions of that
10479+
// remedy selection are pinned in
10480+
// `sys-metadata-repository.package-writability.test.ts`.
10481+
// That is also why `hatchOpen` is passed as a literal
10482+
// `false` here rather than recomputed: reaching this line
10483+
// PROVES the hatch is closed, and a recomputed value
10484+
// would be dead code dressed as a decision.
10485+
//
10486+
// ⛔ NARROW, exactly as the repository is: only a write that
10487+
// NAMES a read-only base is re-coded. A package-less write
10488+
// keeps `NOT_OVERRIDABLE` verbatim. Refusing a hatch write
10489+
// that names NO read-only base (BROAD) retires the hatch's
10490+
// only documented use and needs a maintainer decision plus a
10491+
// docs/ADR change — never arrived at from here.
10492+
//
10493+
// `runtime-only` needs no limb here: this branch is guarded by
10494+
// `artifactBacked`, so the intent is always
10495+
// `override-artifact`. The create side of the door is the
10496+
// ADR-0070 D1 gate further down this method, which is already
10497+
// topology-independent and already answers
10498+
// `WRITABLE_PACKAGE_REQUIRED` / 422 on every kernel.
10499+
const namedBase = typeof request.packageId === 'string' && request.packageId.length > 0;
10500+
if (namedBase && !this.isWritablePackage(request.packageId)) {
10501+
throw SysMetadataRepository.readOnlyBaseOverrideError(
10502+
request.type, request.packageId as string, false,
10503+
);
10504+
}
1043710505
const err = new Error(
1043810506
`[not_overridable] Metadata item '${request.type}/${request.name}' is provided by a code package `
1043910507
+ `and the type has not opted into per-org overlay writes (allowOrgOverride=false). `

0 commit comments

Comments
 (0)