From fbefd17341a7bc10454a2316cdbaff33f08cd77e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 18:57:55 +0000 Subject: [PATCH 1/5] fix(metadata-protocol): the write refusal reports the package door, not 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 Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t --- .../metadata-refusal-package-writability.md | 53 +++ ...ata-repository.package-writability.test.ts | 380 ++++++++++++++++++ .../src/sys-metadata-repository.ts | 144 ++++++- 3 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 .changeset/metadata-refusal-package-writability.md create mode 100644 packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts diff --git a/.changeset/metadata-refusal-package-writability.md b/.changeset/metadata-refusal-package-writability.md new file mode 100644 index 0000000000..76f727fd93 --- /dev/null +++ b/.changeset/metadata-refusal-package-writability.md @@ -0,0 +1,53 @@ +--- +"@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. In particular the documented +`OS_METADATA_WRITABLE` hatch is untouched: an env-hatch write into a read-only +package (`OS_METADATA_WRITABLE=permission` + a `permission` set belonging to a +read-only package) still succeeds, and is now pinned by a test — whether that +hatch or Studio's badge is the correct semantics is a separate maintainer +decision (#8146) and must move deliberately, not as a side effect of this fix. +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). + +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. diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts new file mode 100644 index 0000000000..419c076795 --- /dev/null +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -0,0 +1,380 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7682 — the metadata write refusal reports the PACKAGE door, not only the + * type's registry flags. + * + * ## What the QA run measured (objectstack-ai/objectstack#7637) + * + * `PUT /api/v1/meta/object/showcase_task` answered `403 NOT_OVERRIDABLE` + * ("'object' is not allowOrgOverride in the registry") — the SAME code, the + * same status and the same sentence whether `?package=` pointed at a + * **read-only** package or a **writable** one. `ITEM_LOCKED` and + * `WRITABLE_PACKAGE_REQUIRED` are both registered to + * `@objectstack/metadata-protocol` in the error-code ledger and neither was + * ever emitted on this path. Declared ≠ enforced: the refusal discriminated on + * the metadata TYPE and was blind to the base the caller named. + * + * The emitter is this repository, not the protocol's own gate, on exactly the + * topology the run used: `saveMetaItem`'s artifact-backed refusal sits behind + * `environmentId !== undefined`, and the flagship showcase is assembled by the + * CLI's lightweight host-config path (`new ObjectQLPlugin()`, no + * environmentId) — the same reading `meta-object-owd-gate.test.ts` states + * ("`SysMetadataRepository.assertAllowed()` refuses an `object` overlay of a + * PACKAGED item outright"). The end-to-end block at the bottom pins that + * routing as well as the codes, so a future change that moves the refusal back + * to the protocol shows up here rather than as a silent revert of this card. + * + * ## What each block is for + * + * - **the difference** — one PUT, two bases, two outcomes. That difference IS + * the defect; a suite that only pinned the new codes would stay green if + * the writable case started answering them too. + * - **no allow decision moves** — this is a code SELECTION inside the refusal + * branch. An ADR-0005 overlay names the read-only package it customizes by + * construction, so a package door that refused would close the overlay + * model; and `OS_METADATA_WRITABLE` must keep unlocking exactly what it + * unlocked before, because the hatch-vs-Studio-badge question is a separate + * maintainer decision (#8146) that this card must not pre-empt. + * - **delete is untouched** — #6960 moved the delete side deliberately and + * warns against symmetrising; `DeleteOptions` carries no `packageId`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / +// #5480 update), so the fake engine below cannot accept a call ObjectQL +// refuses. Imported from `@objectstack/metadata-core` and not from +// `@objectstack/objectql`: objectql DEPENDS ON this package, so that import +// would close a dependency cycle turbo rejects outright. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +interface Row { [k: string]: unknown } + +/** A booted code package — `registerApp` puts its manifest in `engine.manifests`. */ +const READ_ONLY_PKG = 'com.example.showcase'; +/** An installed/platform package — read-only by manifest SCOPE, not by boot. */ +const PLATFORM_PKG = 'platform.core'; +/** A bare ADR-0048 authoring workspace — no manifest anywhere, so writable. */ +const WRITABLE_PKG = 'com.acme.workspace'; + +/** + * Minimal engine fake, plus the two surfaces `isWritablePackage` reads. + * + * Both are structural and both are OPTIONAL on the real engine, which is why + * every pre-existing repository test keeps its verdict unchanged: an engine + * that declares neither answers "writable" for every id, so the type-door + * codes stand exactly as before. + */ +function makeFakeEngine() { + const rows = new Map(); + const historyRows: Row[] = []; + + const keyOf = (w: Record) => + `${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`; + + const findRow = (where: Record) => { + if (where.id !== undefined) { + for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r }; + return null; + } + const k = keyOf(where); + const r = rows.get(k); + return r ? { key: k, row: r } : null; + }; + + const matchesHistory = (h: Row, where: Record): boolean => + Object.entries(where).every(([k, v]) => v === undefined || h[k] === v); + + return { + rows, + historyRows, + // Booted code packages (the `registerApp` map). + manifests: new Map([[READ_ONLY_PKG, { id: READ_ONLY_PKG }]]), + registry: { + // Installed / platform packages, read-only by manifest scope. + getPackage: (id: string) => + id === PLATFORM_PKG ? { manifest: { id, scope: 'system' } } : undefined, + }, + async find(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') return historyRows.filter((h) => matchesHistory(h, opts.where)); + return Array.from(rows.values()); + }, + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null; + return findRow(opts.where)?.row ?? null; + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + const h: Row = { ...data }; + if (!h.id) h.id = `h_${historyRows.length + 1}`; + historyRows.push(h); + return { id: h.id as string }; + } + const k = keyOf(data); + const row: Row = { id: `r_${rows.size + 1}`, ...data }; + rows.set(k, row); + return { id: row.id as string }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) throw new Error('not found'); + rows.set(found.key, { ...found.row, ...data }); + return { id: found.row.id as string }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + }; +} + +const objectBody = { name: 'showcase_task', label: 'Task', fields: { name: { type: 'text', label: 'Name' } } }; + +/** `put` with everything but the base fixed, so each case differs in ONE way. */ +async function putWith( + repo: SysMetadataRepository, + opts: { type: string; name: string; intent: 'override-artifact' | 'runtime-only'; packageId?: string }, +): Promise { + return repo + .put( + { org: 'env', type: opts.type, name: opts.name }, + { ...objectBody, name: opts.name }, + { + parentVersion: null, + actor: null, + intent: opts.intent, + ...(opts.packageId !== undefined ? { packageId: opts.packageId } : {}), + }, + ) + .then(() => null, (e: unknown) => e); +} + +describe('#7682 — the refusal discriminates on package writability', () => { + let engine: ReturnType; + let repo: SysMetadataRepository; + + beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + resetEnvWritableMetadataTypes(); + ObjectStackProtocolImplementation.resetEnvWritableCache(); + engine = makeFakeEngine(); + repo = new SysMetadataRepository({ engine: engine as never, organizationId: null, orgLabel: 'env' }); + }); + + afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + resetEnvWritableMetadataTypes(); + ObjectStackProtocolImplementation.resetEnvWritableCache(); + }); + + // ── the difference, which is the whole defect ───────────────────────── + + describe('one PUT, two bases, two outcomes', () => { + it('a read-only base answers ITEM_LOCKED / 403 where a writable base answers NOT_OVERRIDABLE / 403', async () => { + const readOnly = await putWith(repo, { + type: 'object', name: 'showcase_task', intent: 'override-artifact', packageId: READ_ONLY_PKG, + }); + const writable = await putWith(repo, { + type: 'object', name: 'showcase_task', intent: 'override-artifact', packageId: WRITABLE_PKG, + }); + + // Both halves of the ADR-0112 envelope, on both sides — a message-only + // assertion cannot tell these two refusals apart, and before #7682 there + // was nothing to tell apart. + expect(readOnly).toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + expect(writable).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect((readOnly as { code: string }).code).not.toBe((writable as { code: string }).code); + + // Nothing persisted on either side: this is a refusal, not a report. + expect(Array.from(engine.rows.values())).toEqual([]); + }); + + it('read-only-by-SCOPE (installed/platform) is the same door as read-only-by-boot', async () => { + // `isWritablePackage` has two read-only signals and the door must read + // both, or "read-only" quietly means "booted from code" only. + const err = await putWith(repo, { + type: 'object', name: 'showcase_task', intent: 'override-artifact', packageId: PLATFORM_PKG, + }); + expect(err).toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + }); + + it('carries the package id and the ADR-0010 package lock source, so a consumer can say WHY', async () => { + const err = await putWith(repo, { + type: 'object', name: 'showcase_task', intent: 'override-artifact', packageId: READ_ONLY_PKG, + }) as { packageId?: string; lockSource?: string; lock?: unknown; message?: string }; + + expect(err.packageId).toBe(READ_ONLY_PKG); + // ADR-0010's reserved value for a lock the PACKAGE layer asserts. It is + // what separates this from the item-level `_lock` refusal… + expect(err.lockSource).toBe('package'); + // …which is also why no `lock` value is claimed: the item declares none. + expect(err.lock).toBeUndefined(); + expect(String(err.message)).toContain(READ_ONLY_PKG); + }); + + it('a runtime-only create names the base: WRITABLE_PACKAGE_REQUIRED / 422 vs NOT_CREATABLE / 403', async () => { + // `job` is code-only (no allowRuntimeCreate, no allowOrgOverride), so + // both bases are refused — and the codes still differ, because "you + // named a read-only base" and "this type has no create channel" are + // different facts. 422 + the wording mirror `saveMetaItem`'s ADR-0070 D1 + // emitter exactly: one condition, one vocabulary, two enforcement points. + const readOnly = await putWith(repo, { + type: 'job', name: 'nightly', intent: 'runtime-only', packageId: READ_ONLY_PKG, + }); + const writable = await putWith(repo, { + type: 'job', name: 'nightly', intent: 'runtime-only', packageId: WRITABLE_PKG, + }); + + expect(readOnly).toMatchObject({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422, packageId: READ_ONLY_PKG }); + expect(writable).toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); + }); + }); + + // ── nothing that was allowed becomes refused ────────────────────────── + + describe('no allow decision moves', () => { + it('an ADR-0005 overlay of a code-shipped item still lands (the naive gate would break this)', async () => { + // `view` is allowOrgOverride, and an overlay of a packaged view names + // the read-only package it customizes BY CONSTRUCTION. If the package + // door refused instead of choosing a code, the whole overlay model would + // close — this is the case that decides the shape of the fix. + const err = await putWith(repo, { + type: 'view', name: 'case_grid', intent: 'override-artifact', packageId: READ_ONLY_PKG, + }); + expect(err).toBeNull(); + expect(Array.from(engine.rows.values())).toHaveLength(1); + expect(Array.from(engine.rows.values())[0]).toMatchObject({ package_id: READ_ONLY_PKG }); + }); + + it('[ruling] OS_METADATA_WRITABLE still unlocks a write into a READ-ONLY package', async () => { + // The card's measured hatch case, at the layer that answers it: + // `OS_METADATA_WRITABLE=permission` + a `permission` set belonging to the + // read-only showcase package → the write SUCCEEDS. #7682's second half + // (does the hatch or Studio's "Read-only" badge win?) is filed as #8146 + // and is the maintainer's to rule; this pin is what keeps that ruling + // free to move the behaviour DELIBERATELY later, instead of it drifting + // as a side effect of the code-selection fix. + process.env.OS_METADATA_WRITABLE = 'permission'; + resetEnvWritableMetadataTypes(); + + const err = await putWith(repo, { + type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', packageId: READ_ONLY_PKG, + }); + + expect(err).toBeNull(); + expect(Array.from(engine.rows.values())).toHaveLength(1); + expect(Array.from(engine.rows.values())[0]).toMatchObject({ + type: 'permission', name: 'showcase_contributor', package_id: READ_ONLY_PKG, + }); + }); + + it('without the hatch, that same permission write is refused by the package door', async () => { + // The other side of the pin above: the hatch is doing the work, not an + // accident of `permission` being overlay-capable. + const err = await putWith(repo, { + type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', packageId: READ_ONLY_PKG, + }); + expect(err).toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + }); + + it('a write that names NO base keeps the type-door codes verbatim', async () => { + // `isWritablePackage(null)` is false by design ("no base resolved" is a + // refusal for the authoring path), so reading it here without the + // caller-named guard would re-code every ordinary env-local overlay + // refusal in the product. It does not. + const override = await putWith(repo, { + type: 'object', name: 'showcase_task', intent: 'override-artifact', + }); + const create = await putWith(repo, { type: 'job', name: 'nightly', intent: 'runtime-only' }); + + expect(override).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(create).toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); + }); + }); + + // ── the delete verb is deliberately not symmetrised (#6960) ─────────── + + it('delete keeps its own codes — DeleteOptions names no base', async () => { + await expect( + repo.delete( + { org: 'env', type: 'object', name: 'showcase_task' }, + { parentVersion: 'sha256:whatever', actor: null, intent: 'override-artifact' }, + ), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); +}); + +/** + * The card's reproduction, end to end, on the topology it was measured on. + * + * `environmentId` undefined = the CLI host-config assembler (the showcase and + * every self-hosted server shaped like it). `saveMetaItem`'s own artifact- + * backed refusal is behind `environmentId !== undefined`, so the write reaches + * `SysMetadataRepository.put` and the package door is what answers. + * + * ⚠️ On a SCOPED kernel (`environmentId` set) the protocol refuses first, with + * `NOT_OVERRIDABLE`, and this fix is not reachable — that second refusal point + * lives in `protocol.ts`, which this card is not authorised to edit. Filed + * separately; the asymmetry is stated here so the next reader measures it + * instead of assuming this suite covers both kernels. + */ +describe('#7682 — through saveMetaItem on the host-config topology', () => { + function boot() { + const engine = makeFakeEngine() as unknown as Record; + (engine as { registry: Record }).registry = { + ...(engine.registry as Record), + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: () => undefined, + // A hit here is what makes the name artifact-backed, i.e. an + // `override-artifact` intent — the card's `showcase_task`. + getArtifactItem: (type: string, name: string) => + type === 'object' && name === 'showcase_task' + ? { name, _packageId: READ_ONLY_PKG } + : undefined, + }; + const protocol = new ObjectStackProtocolImplementation( + engine as never, + () => new Map(), + undefined, // no environmentId — the host-config / showcase assembly + ) as unknown as { + saveMetaItem(req: Record): Promise; + }; + return { engine, protocol }; + } + + beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + resetEnvWritableMetadataTypes(); + ObjectStackProtocolImplementation.resetEnvWritableCache(); + }); + afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + resetEnvWritableMetadataTypes(); + ObjectStackProtocolImplementation.resetEnvWritableCache(); + }); + + it('PUT object/showcase_task answers ITEM_LOCKED for a read-only base and NOT_OVERRIDABLE for a writable one', async () => { + const { protocol } = boot(); + + const readOnly = await protocol + .saveMetaItem({ type: 'object', name: 'showcase_task', item: objectBody, packageId: READ_ONLY_PKG }) + .then(() => null, (e: unknown) => e); + const writable = await protocol + .saveMetaItem({ type: 'object', name: 'showcase_task', item: objectBody, packageId: WRITABLE_PKG }) + .then(() => null, (e: unknown) => e); + + expect(readOnly).toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + expect(writable).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + }, 30_000); +}); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 1adb61296f..ae83ba51e0 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -75,6 +75,11 @@ import type { import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import type { IObjectQLEngine } from '@objectstack/core'; +// [#7682] The read-only-package predicate, imported rather than re-spelled — +// the same function `saveMetaItem`'s ADR-0070 D1 gate and the `/packages` +// lifecycle gate read, so a third read-only signal added there reaches this +// door too (that shared-rule argument is the module's whole reason to exist). +import { isWritablePackage } from './package-writability.js'; /** * Overlay-row lifecycle state. @@ -394,7 +399,13 @@ export class SysMetadataRepository implements MetadataRepository { opts: PutOptions & { state?: OverlayState; opType?: ExtendedOperation }, ): Promise { this.assertOpen(); - this.assertAllowed(ref.type, opts.intent); + // [#7682] The base the caller NAMED is part of the authorization question, + // not just of the row key: `assertAllowed` reports the package door when + // that base is read-only. `opts.packageId` (not the `targetPackageId` + // resolution below) on purpose — `undefined` means "the caller named no + // base", which is the ordinary env-local overlay and must keep the + // type-door codes; `?? null` there is a ROW-KEY default, a different fact. + this.assertAllowed(ref.type, opts.intent, opts.packageId); const state: OverlayState = opts.state ?? 'active'; const body = (spec ?? {}) as Record; @@ -1051,8 +1062,75 @@ export class SysMetadataRepository implements MetadataRepository { * The env-var escape hatch (`OS_METADATA_WRITABLE`) still * applies to BOTH intents, so operators can opt into artifact * overrides at runtime for emergency fixes. + * + * ## [#7682] The package door — which fact the refusal reports + * + * `packageId` is the base the CALLER named (`?package=` → `PutOptions.packageId`), + * and until #7682 nothing on this path read it. Every refusal came out as + * `NOT_OVERRIDABLE` / `NOT_CREATABLE` — a verdict about the metadata TYPE's + * registry flags — so `PUT /api/v1/meta/object/showcase_task` answered + * identically whether `?package=` pointed at a read-only package or a + * writable one, and the two codes the ledger registers for the + * package-writability condition (`ITEM_LOCKED`, `WRITABLE_PACKAGE_REQUIRED`, + * both under `@objectstack/metadata-protocol`) were never emitted here at + * all. Declared ≠ enforced. + * + * What this reads, and what it deliberately does NOT: + * + * - **It changes no ALLOW decision.** Every allow limb above returns before + * this point, so a write that succeeds today still succeeds — including + * the ADR-0005 case that makes the naive "refuse writes into a read-only + * package" gate wrong: an org overlay of a code-shipped item *always* + * names the read-only package it customizes, and refusing that would + * close the whole overlay model. The env hatch (`OS_METADATA_WRITABLE`) + * returns two limbs above for the same reason — #7682's hatch-vs-Studio + * -badge half is a separate maintainer decision (#8146) and must not move + * as a side effect of this one. Pinned by the hatch case in + * `sys-metadata-repository.package-writability.test.ts`. + * - **It refuses nothing new.** This is the code-selection for writes that + * are ALREADY refused; the difference is which true fact the refusal + * reports. Making the package door an allow→deny gate here would extend + * ADR-0070 D1 (measured on `saveMetaItem` creates) to `promoteDraft` / + * `restoreVersion` / `revertCommit`, which route through {@link put} and + * carry the row's OWN binding — i.e. it would break republishing and + * repair of legacy package-bound rows, surfaces nobody measured. + * - **Which code, by intent.** The two ledgered codes are not + * interchangeable, and each is the one whose prescription is TRUE for its + * case: + * • `runtime-only` — no artifact backs this name, so the caller is + * authoring a NEW item into a read-only base. That is exactly + * ADR-0070 D1's condition, and `saveMetaItem` already emits + * `WRITABLE_PACKAGE_REQUIRED` / 422 for it; this is the same + * condition stated the same way at the ONE persistence route, for the + * callers that do not pass through that gate. One vocabulary, two + * enforcement points — the shape `package-writability.ts` exists for. + * • `override-artifact` — an artifact backs this name and it is + * provided by a package the deployment ships. "Pick a writable base" + * is FALSE here (the artifact would still be code-shipped), so the + * honest code is `ITEM_LOCKED`: the write is refused because its + * target is read-only, which is precisely what Studio's "Read-only" + * badge tells the user. ADR-0010 reserves `_lockSource: 'package'` + * for this layer; the error carries it so a consumer can tell a + * package-provenance lock from an item's own `_lock` (which is + * enforced separately, by `assertLockAllowsWrite` in the protocol, + * and still sets `lock`/`lockReason` — this one does not claim a + * `_lock` value the item never declared). + * - **An unnamed base is not "read-only".** `isWritablePackage(null)` is + * `false` by design (the authoring path treats "no base resolved" as a + * refusal), but a package-LESS write is the ordinary env-local overlay, + * not a write into a read-only package. So the door only opens when the + * caller actually named one; otherwise the type-door codes stand + * unchanged, which is what every existing caller and test sees. + * - **The DELETE half is untouched.** `DeleteOptions` carries no + * `packageId` and {@link assertDeleteAllowed} passes none, so removal + * keeps today's codes verbatim — #6960's ruling moved the delete side on + * purpose and warns against symmetrising either way. */ - private assertAllowed(type: string, intent: MetadataWriteIntent = 'override-artifact'): void { + private assertAllowed( + type: string, + intent: MetadataWriteIntent = 'override-artifact', + packageId?: string | null, + ): void { const singular = PLURAL_TO_SINGULAR[type] ?? type; const allowedByRegistry = OVERLAY_ALLOWED_TYPES.has(singular) || OVERLAY_ALLOWED_TYPES.has(type); if (allowedByRegistry) return; @@ -1079,6 +1157,16 @@ export class SysMetadataRepository implements MetadataRepository { const env = envWritableMetadataTypes(); if (env.has(singular) || env.has(type)) return; + // [#7682] The package door. Only reached once every allow limb above has + // declined, so it re-reports an existing refusal — see the TSDoc for why + // it is a code selection and not a gate. + const namedBase = typeof packageId === 'string' && packageId.length > 0; + if (namedBase && !isWritablePackage(this.engine, packageId)) { + throw intent === 'runtime-only' + ? SysMetadataRepository.readOnlyBaseCreateError(type, packageId as string) + : SysMetadataRepository.readOnlyBaseOverrideError(type, packageId as string); + } + const allowed = [ ...OVERLAY_ALLOWED_TYPES, ...envWritableMetadataTypes(), @@ -1097,6 +1185,58 @@ export class SysMetadataRepository implements MetadataRepository { throw err; } + /** + * [#7682] `runtime-only` into a read-only base — the ADR-0070 D1 condition, + * refused at the persistence route. + * + * Deliberately the SAME code, status and prescription as the + * `saveMetaItem` emitter (`protocol.ts`, "D1 (ADR-0070)"): one condition, + * one vocabulary, stated at both enforcement points rather than re-spelled + * differently at each. The message stays user-actionable because here the + * prescription is true — a writable base really is what this write needs. + */ + private static readOnlyBaseCreateError(type: string, packageId: string): Error { + const err: any = new Error( + `[writable_package_required] Cannot create ${type} in package '${packageId}': ` + + `that package is read-only (provided by code or an installed app), so it is not a writable base. ` + + `Switch to a writable package in the package selector, or create a new one, and retry.`, + ); + err.code = 'WRITABLE_PACKAGE_REQUIRED'; + err.status = 422; + err.packageId = packageId; + err.docs = 'docs/adr/0070-package-first-authoring.md'; + return err; + } + + /** + * [#7682] `override-artifact` against an item a read-only package provides. + * + * `ITEM_LOCKED` rather than `WRITABLE_PACKAGE_REQUIRED`: switching packages + * cannot help — the artifact is code-shipped wherever the caller points — + * so the refusal states the lock and prescribes the two things that DO move + * it (edit the source and redeploy, or open the documented operator hatch). + * `lockSource: 'package'` is ADR-0010's own reserved value for a lock the + * PACKAGE layer asserts, which is what makes this distinguishable from the + * item-level `_lock` refusal (`assertLockAllowsWrite`) that carries a `lock` + * value read off the item. This one claims no `_lock`, because the item + * declares none. + */ + private static readOnlyBaseOverrideError(type: string, packageId: string): Error { + const err: any = new Error( + `[item_locked] Cannot overlay '${type}' in package '${packageId}': that package is read-only ` + + `(provided by code or an installed app) and the type has no per-org overlay channel ` + + `(allowOrgOverride=false), so this item is locked against runtime edits. ` + + `Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE=${PLURAL_TO_SINGULAR[type] ?? type} ` + + `to grant a runtime escape hatch. See docs/adr/0010-metadata-protection-model.md.`, + ); + err.code = 'ITEM_LOCKED'; + err.status = 403; + err.lockSource = 'package'; + err.packageId = packageId; + err.docs = 'docs/adr/0010-metadata-protection-model.md'; + return err; + } + /** * [#6960] The DELETE half of {@link assertAllowed}, and the only place the * two verbs diverge. From cf8b72f2cd32400817166bd8242dfe155eb3b213 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:12:02 +0000 Subject: [PATCH 2/5] docs(metadata-protocol): mark the OS_METADATA_WRITABLE case as CONTESTED, per the 2026-08-12 #8146 ruling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t --- .../metadata-refusal-package-writability.md | 23 +++++---- ...ata-repository.package-writability.test.ts | 48 ++++++++++++++----- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/.changeset/metadata-refusal-package-writability.md b/.changeset/metadata-refusal-package-writability.md index 76f727fd93..90283203c9 100644 --- a/.changeset/metadata-refusal-package-writability.md +++ b/.changeset/metadata-refusal-package-writability.md @@ -35,15 +35,20 @@ refused write that named a read-only base says so: 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. In particular the documented -`OS_METADATA_WRITABLE` hatch is untouched: an env-hatch write into a read-only -package (`OS_METADATA_WRITABLE=permission` + a `permission` set belonging to a -read-only package) still succeeds, and is now pinned by a test — whether that -hatch or Studio's badge is the correct semantics is a separate maintainer -decision (#8146) and must move deliberately, not as a side effect of this fix. -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). +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. That is **not** an endorsement: the +maintainer ruling of 2026-08-12 on #8146 holds that a hatch write into a +read-only package should REFUSE, and the test covering it is labelled a +characterization pin of today's behaviour so the #8146 fix must invert it rather +than pass it silently. Re-measured on current `main` at that ruling's request: +it still reproduces, and the row lands bound INTO the read-only package +(`package_id = com.example.showcase`) rather than as the per-org override the +variable's own documentation describes. 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 diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index 419c076795..f3e0fe98e9 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -33,9 +33,11 @@ * - **no allow decision moves** — this is a code SELECTION inside the refusal * branch. An ADR-0005 overlay names the read-only package it customizes by * construction, so a package door that refused would close the overlay - * model; and `OS_METADATA_WRITABLE` must keep unlocking exactly what it - * unlocked before, because the hatch-vs-Studio-badge question is a separate - * maintainer decision (#8146) that this card must not pre-empt. + * model. `OS_METADATA_WRITABLE` is untouched for the same structural reason + * (its limb returns before the door), and its case here is a CHARACTERIZATION + * pin, not an endorsement: the 2026-08-12 ruling on #8146 says today's + * answer is a bug, and that case is named and documented so the fix must + * invert it rather than quietly pass it. See the case for the re-measurement. * - **delete is untouched** — #6960 moved the delete side deliberately and * warns against symmetrising; `DeleteOptions` carries no `packageId`. */ @@ -255,14 +257,32 @@ describe('#7682 — the refusal discriminates on package writability', () => { expect(Array.from(engine.rows.values())[0]).toMatchObject({ package_id: READ_ONLY_PKG }); }); - it('[ruling] OS_METADATA_WRITABLE still unlocks a write into a READ-ONLY package', async () => { - // The card's measured hatch case, at the layer that answers it: - // `OS_METADATA_WRITABLE=permission` + a `permission` set belonging to the - // read-only showcase package → the write SUCCEEDS. #7682's second half - // (does the hatch or Studio's "Read-only" badge win?) is filed as #8146 - // and is the maintainer's to rule; this pin is what keeps that ruling - // free to move the behaviour DELIBERATELY later, instead of it drifting - // as a side effect of the code-selection fix. + it('[CONTESTED — #8146 ruled this a BUG] OS_METADATA_WRITABLE currently unlocks a write into a READ-ONLY package', async () => { + // ⛔ NOT an assertion that this behaviour is correct. Read the name. + // + // This is a CHARACTERIZATION pin of what `main` does today, and the + // maintainer ruling of 2026-08-12 on #8146 (option B: "the server should + // refuse — the badge is telling the truth") says today's answer is a bug: + // the hatch is TYPE-level by its own shipped documentation + // (`content/docs/deployment/environment-variables.mdx`, `OS_METADATA_WRITABLE` + // — "treats them as `allowOrgOverride: true`"), so it has nothing to say + // about the package dimension. + // + // It is kept, rather than deleted, because it is the tripwire: whoever + // implements #8146 must INVERT this case, and a suite that simply went + // quiet about the hatch would let that land without anyone re-reading + // what the hatch is for. Re-measured on current `main` at the request of + // that ruling (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 documentation + // describes. + // + // #7682's own fix does not touch this path: the hatch limb returns + // before the package door, exactly as it did before. Which is the point + // — the change under test neither preserves nor moves this deliberately; + // it is orthogonal to it. process.env.OS_METADATA_WRITABLE = 'permission'; resetEnvWritableMetadataTypes(); @@ -278,8 +298,10 @@ describe('#7682 — the refusal discriminates on package writability', () => { }); it('without the hatch, that same permission write is refused by the package door', async () => { - // The other side of the pin above: the hatch is doing the work, not an - // accident of `permission` being overlay-capable. + // The other side of the case above: the hatch is doing the work, not an + // accident of `permission` being overlay-capable. This one IS an + // assertion of correctness — with no hatch, the read-only base is what + // the refusal names, which is #7682's whole point. const err = await putWith(repo, { type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', packageId: READ_ONLY_PKG, }); From 99aebfed5a1a36bc83bc6d2e64d5dbac29d49dc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:42:28 +0000 Subject: [PATCH 3/5] test(metadata-protocol): drop the OS_METADATA_WRITABLE case, record the gap as deliberate (#7682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t --- .../metadata-refusal-package-writability.md | 21 +++-- ...ata-repository.package-writability.test.ts | 87 +++++++------------ 2 files changed, 44 insertions(+), 64 deletions(-) diff --git a/.changeset/metadata-refusal-package-writability.md b/.changeset/metadata-refusal-package-writability.md index 90283203c9..3acbbeccd3 100644 --- a/.changeset/metadata-refusal-package-writability.md +++ b/.changeset/metadata-refusal-package-writability.md @@ -41,14 +41,19 @@ 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. That is **not** an endorsement: the -maintainer ruling of 2026-08-12 on #8146 holds that a hatch write into a -read-only package should REFUSE, and the test covering it is labelled a -characterization pin of today's behaviour so the #8146 fix must invert it rather -than pass it silently. Re-measured on current `main` at that ruling's request: -it still reproduces, and the row lands bound INTO the read-only package -(`package_id = com.example.showcase`) rather than as the per-org override the -variable's own documentation describes. +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 diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index f3e0fe98e9..165263bc8b 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -33,11 +33,26 @@ * - **no allow decision moves** — this is a code SELECTION inside the refusal * branch. An ADR-0005 overlay names the read-only package it customizes by * construction, so a package door that refused would close the overlay - * model. `OS_METADATA_WRITABLE` is untouched for the same structural reason - * (its limb returns before the door), and its case here is a CHARACTERIZATION - * pin, not an endorsement: the 2026-08-12 ruling on #8146 says today's - * answer is a bug, and that case is named and documented so the fix must - * invert it rather than quietly pass it. See the case for the re-measurement. + * model, and that case is pinned below. + * + * ## ⛔ `OS_METADATA_WRITABLE` is deliberately UNCOVERED here (#8146) + * + * The absence is a decision, not an oversight, so do not "complete" this suite + * by adding a case for it. A hatch write into a read-only package currently + * SUCCEEDS — re-measured on `main` while #7682 was in flight: `success: true`, + * with the row landing at `package_id = com.example.showcase`, i.e. bound INTO + * the read-only package rather than as the per-org override the variable's own + * documentation describes (`content/docs/deployment/environment-variables.mdx` + * — "treats them as `allowOrgOverride: true`", a TYPE-level unlock). The + * maintainer ruling of 2026-08-12 on #8146 holds that this should refuse, so + * any test of it here would be green *because the bug is present*, and that is + * a shape this repo does not merge (PM ruling, PR #8185 patch round). + * + * #7682 does not touch that path — the hatch limb returns before the new + * package door, exactly as it did before — and #8146 already names its own + * deliverable as "the refusal plus a rejection pin asserting `code` and + * `status`". That pin belongs to #8146, written against the fixed behaviour; + * nothing is lost by this suite staying silent until then. * - **delete is untouched** — #6960 moved the delete side deliberately and * warns against symmetrising; `DeleteOptions` carries no `packageId`. */ @@ -239,6 +254,17 @@ describe('#7682 — the refusal discriminates on package writability', () => { expect(readOnly).toMatchObject({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422, packageId: READ_ONLY_PKG }); expect(writable).toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); }); + + it('a permission set belonging to a read-only package is refused by the package door', async () => { + // The card's own hatch case, minus the hatch — see the suite docblock for + // why the hatch half is deliberately uncovered here. With no hatch set, + // the read-only base is what the refusal names, which is #7682's whole + // point, and `permission` is the type the QA run used. + const err = await putWith(repo, { + type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', packageId: READ_ONLY_PKG, + }); + expect(err).toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + }); }); // ── nothing that was allowed becomes refused ────────────────────────── @@ -257,57 +283,6 @@ describe('#7682 — the refusal discriminates on package writability', () => { expect(Array.from(engine.rows.values())[0]).toMatchObject({ package_id: READ_ONLY_PKG }); }); - it('[CONTESTED — #8146 ruled this a BUG] OS_METADATA_WRITABLE currently unlocks a write into a READ-ONLY package', async () => { - // ⛔ NOT an assertion that this behaviour is correct. Read the name. - // - // This is a CHARACTERIZATION pin of what `main` does today, and the - // maintainer ruling of 2026-08-12 on #8146 (option B: "the server should - // refuse — the badge is telling the truth") says today's answer is a bug: - // the hatch is TYPE-level by its own shipped documentation - // (`content/docs/deployment/environment-variables.mdx`, `OS_METADATA_WRITABLE` - // — "treats them as `allowOrgOverride: true`"), so it has nothing to say - // about the package dimension. - // - // It is kept, rather than deleted, because it is the tripwire: whoever - // implements #8146 must INVERT this case, and a suite that simply went - // quiet about the hatch would let that land without anyone re-reading - // what the hatch is for. Re-measured on current `main` at the request of - // that ruling (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 documentation - // describes. - // - // #7682's own fix does not touch this path: the hatch limb returns - // before the package door, exactly as it did before. Which is the point - // — the change under test neither preserves nor moves this deliberately; - // it is orthogonal to it. - process.env.OS_METADATA_WRITABLE = 'permission'; - resetEnvWritableMetadataTypes(); - - const err = await putWith(repo, { - type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', packageId: READ_ONLY_PKG, - }); - - expect(err).toBeNull(); - expect(Array.from(engine.rows.values())).toHaveLength(1); - expect(Array.from(engine.rows.values())[0]).toMatchObject({ - type: 'permission', name: 'showcase_contributor', package_id: READ_ONLY_PKG, - }); - }); - - it('without the hatch, that same permission write is refused by the package door', async () => { - // The other side of the case above: the hatch is doing the work, not an - // accident of `permission` being overlay-capable. This one IS an - // assertion of correctness — with no hatch, the read-only base is what - // the refusal names, which is #7682's whole point. - const err = await putWith(repo, { - type: 'permission', name: 'showcase_contributor', intent: 'override-artifact', packageId: READ_ONLY_PKG, - }); - expect(err).toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); - }); - it('a write that names NO base keeps the type-door codes verbatim', async () => { // `isWritablePackage(null)` is false by design ("no base resolved" is a // refusal for the authoring path), so reading it here without the From 1bccc1d93cff3ac48e30456e9eefa7f55a8248f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:48:03 +0000 Subject: [PATCH 4/5] docs(metadata-protocol): keep the delete bullet with its list in the suite docblock (#7682) Formatting only, no assertion or source change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t --- .../src/sys-metadata-repository.package-writability.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index 165263bc8b..a2315a85f9 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -34,6 +34,8 @@ * branch. An ADR-0005 overlay names the read-only package it customizes by * construction, so a package door that refused would close the overlay * model, and that case is pinned below. + * - **delete is untouched** — #6960 moved the delete side deliberately and + * warns against symmetrising; `DeleteOptions` carries no `packageId`. * * ## ⛔ `OS_METADATA_WRITABLE` is deliberately UNCOVERED here (#8146) * @@ -53,8 +55,6 @@ * deliverable as "the refusal plus a rejection pin asserting `code` and * `status`". That pin belongs to #8146, written against the fixed behaviour; * nothing is lost by this suite staying silent until then. - * - **delete is untouched** — #6960 moved the delete side deliberately and - * warns against symmetrising; `DeleteOptions` carries no `packageId`. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; From 5ca8d0554c1d995d5969e3d1ecd4688a2d3061c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 01:42:21 +0000 Subject: [PATCH 5/5] fix(metadata-protocol): type the test helper's `type` as MetaRef['type'] (#7682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AmES43BMDg4bPrxTdi5q7t --- ...adata-repository.package-writability.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts index a2315a85f9..bacfbad5e6 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts @@ -64,6 +64,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; // `@objectstack/objectql`: objectql DEPENDS ON this package, so that import // would close a dependency cycle turbo rejects outright. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import type { MetaRef } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; @@ -156,10 +157,22 @@ function makeFakeEngine() { const objectBody = { name: 'showcase_task', label: 'Task', fields: { name: { type: 'text', label: 'Name' } } }; -/** `put` with everything but the base fixed, so each case differs in ONE way. */ +/** + * `put` with everything but the base fixed, so each case differs in ONE way. + * + * `type` is `MetaRef['type']`, not `string`: that field is a literal union, and + * a widened `string` here is a real `tsc --noEmit` error even though `vitest` + * runs the file happily (this package's type surface is judged by the DEBT + * ledger in CI, never by the test run). + */ async function putWith( repo: SysMetadataRepository, - opts: { type: string; name: string; intent: 'override-artifact' | 'runtime-only'; packageId?: string }, + opts: { + type: MetaRef['type']; + name: string; + intent: 'override-artifact' | 'runtime-only'; + packageId?: string; + }, ): Promise { return repo .put(