diff --git a/.changeset/23096-save-door-protection.md b/.changeset/23096-save-door-protection.md new file mode 100644 index 00000000000..cb2f3f45f7c --- /dev/null +++ b/.changeset/23096-save-door-protection.md @@ -0,0 +1,29 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': patch +--- + +fix(metadata-protocol)!: the metadata save door refuses a `protection` block with a located `400 VALIDATION_ERROR` instead of storing a lock that binds nothing (ADR-0010 §3.5, Phase 4.3) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`protection` is the package author's lock declaration. ADR-0010 states one door for it: a code package's loader translates it into the item's private lock envelope when the package loads, and strips it (Phase 4.3). An item saved through the metadata API is authored in the environment, and ADR-0010 §3.5 states that such an item takes no package lock. + +**This entry supersedes one sentence of the entry `15213-unmanaged-lock-strip`, in this same release.** That entry says a malformed `protection` block is not stripped and "the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back". What holds now: on a type whose schema declares `protection`, an item the unmanaged install hands on with its block still in it (a malformed block, or a block beside a `_lock*` value its declaration refuses) meets this entry's door refusal, `400 VALIDATION_ERROR`, and the install is still rolled back. An item carrying a refused `_lock*` value and no block, and a block on a type that does not declare one (`action`, `hook`, `page`), still answer `422 INVALID_METADATA`. Where the two entries disagree, this one is current. + +**FROM.** `PUT /api/v1/meta/{type}/{name}` with a `protection` block answered `200` and stored the block verbatim. The item was then served with `protection.lock: 'full'` while it read `lock: 'none'`, `editable: true` and `deletable: true`, and a second save and a delete both answered `200`. Measured on the showcase for an `app` and a `dashboard`. An author, or an AI, reading that item was told it was protected when anyone holding the authoring capability could change or delete it. + +**TO.** The same save answers `400 VALIDATION_ERROR` and writes nothing. The message names the block and the door that does honour it, and the envelope's `issues[]` carries `protection` as the path, so the Studio designer can highlight it. + +- **Every type that declares the block**, in draft and in publish mode, for a new item and for an overlay of a packaged one. A malformed block is refused the same way, because the block is not accepted at this door at all. On a type whose schema does not declare `protection`, the schema's own `422 INVALID_METADATA` answers, as before. +- **Every authoring write**: the REST and dispatcher saves and the domain doors that save through them. The unmanaged install already drops a block it can read before it saves a copy, and a malformed one is refused here. +- **Server-side rewrites strip it.** A row stored before this change may carry the block. When the server itself rewrites such a row (`os migrate meta --stored --apply`, the stored flow credential move, package duplication), it drops the block instead of failing the rewrite: the block never bound, so nothing changes but the stored bytes. +- **Unchanged.** A body without `protection` saves as before. A packaged item's lock still binds: its loader translated the block at load time, and a save against it still answers `403 ITEM_LOCKED`, or `403 NOT_OVERRIDABLE` on a type with no environment overlay, before this refusal is asked. A row stored before this change keeps its bytes. +- **Package export.** `GET /packages/:id/export` copies a writable base's rows into a manifest, so a block saved into a base row rode into that manifest, where the package loader translates it on install. A block can no longer be saved into a base row. Declare it in the manifest you install instead. +- **Remedy.** Delete the `protection` block from the body you send to the metadata API. To lock an item, declare `protection` on it in a code package's source. + +`@objectstack/spec`: the `protection` TSDoc on every type that mounts it (app, view, dashboard, report, dataset, object, flow, webhook, agent, tool, skill, position, permission set, email template) now says that only the package loader translates the block, and that the metadata API's save door refuses it. diff --git a/content/docs/references/shared/protection.mdx b/content/docs/references/shared/protection.mdx index 86d2dbe1d19..67447f814fa 100644 --- a/content/docs/references/shared/protection.mdx +++ b/content/docs/references/shared/protection.mdx @@ -44,6 +44,12 @@ The loader (`metadata/plugin.ts` + `objectql/registry.ts`) calls the underscored fields directly — they are an implementation detail. +That loader is the block's only door (ADR-0010 Phase 4.3). The +metadata API's save door (`PUT /api/v1/meta/{type}/{name}`) writes +environment-authored items, which take no package lock (ADR-0010 +§3.5), so it does not translate the block: it refuses it with +`400 VALIDATION_ERROR` and stores nothing. + See also: - ADR-0010 §3.7 — Future work → now implemented. - `kernel/metadata-protection.zod.ts` — the runtime envelope. diff --git a/packages/metadata-protocol/src/package-unmanaged-install.ts b/packages/metadata-protocol/src/package-unmanaged-install.ts index 64c7eb5e87b..7c4d299da77 100644 --- a/packages/metadata-protocol/src/package-unmanaged-install.ts +++ b/packages/metadata-protocol/src/package-unmanaged-install.ts @@ -194,9 +194,14 @@ export interface DeclaredValueJudge { * no lock, so nothing is reported for it, nor for an item that carries no lock. * * Handed on UNTOUCHED, with no report, so the save door judges the body as the - * package declared it and refuses it as before: + * package declared it and refuses it: * - a lock key whose value its declaration refuses (`_lock: 'locked'`, a - * reason that is not a string, a malformed `protection` block); + * reason that is not a string, a malformed `protection` block). A refused + * `_lock*` value fails the door's parse, `422 INVALID_METADATA`. On a type + * that declares `protection`, a body still carrying its block (a malformed + * block, or one beside a refused `_lock*` value) meets the door's + * `protection` refusal first, `400 VALIDATION_ERROR` + * (`protectionBlockRefusal` in `protocol.ts`); * and left in place: * - a `protection` block on a type whose schema does not declare one * (`protectionDeclaration` absent) — the door refuses it as an diff --git a/packages/metadata-protocol/src/protocol.save-door-protection.test.ts b/packages/metadata-protocol/src/protocol.save-door-protection.test.ts new file mode 100644 index 00000000000..021bba5a114 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.save-door-protection.test.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0010 §3.5 and Phase 4.3] The save door refuses a `protection` block. + * + * `protection` is the package author's lock declaration. ADR-0010 states one + * door for it: a code package's loader translates it into the private `_lock` + * envelope at load time and strips it (`applyProtection`). An item the save + * door writes is authored in the environment, and ADR-0010 §3.5 states that + * such an item takes no package lock. The door used to store the block + * verbatim, so the item was served with `protection.lock: 'full'` while it read + * `lock: 'none'` and every edit and delete succeeded. + * + * The pins: + * + * - the `app` and `dashboard` probes are refused with a located + * `400 VALIDATION_ERROR`, and no row is stored; + * - draft mode, a malformed block and the unmanaged import's face meet the + * same refusal; + * - an overlay of a packaged item that declares no lock meets it too; + * - the server-stated rewrites of stored rows (`source: 'migrate-stored'`, + * `writeFace: 'package-duplicate'`) strip the block instead, so the row they + * write carries none and the rewrite is not failed over a key that never + * bound; + * - CONTROL: a body without `protection` saves as before, its row carries no + * `protection`, and the item stays editable and deletable; + * - CONTROL: the packaged item's `protection` still binds — the artifact the + * loader translated refuses an overlay save with `403 ITEM_LOCKED`, which + * still answers before this refusal; + * - CONTROL: on a type whose schema does not declare `protection`, the parse's + * own `422 INVALID_METADATA` answers, as before. + * + * Refusals are asserted as the envelope an HTTP boundary answers with + * (`resolveThrownHttpError`), never as a bare throw. + * + * The engine below is a stub: this package cannot import `@objectstack/objectql` + * (that package depends on this one). It keeps real rows, so the REAL save door + * runs against it, and its write verbs route through the producer's own + * dispatch predicates. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { applyProtection } from '@objectstack/spec/shared'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { ObjectStackProtocolImplementation, overlaySchemaDeclaredKey } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +type Row = Record; + +const PACKAGE_ID = 'com.example.dash'; +const LOCK = { lock: 'full', reason: 'probe: locked by hand' } as const; + +/** Equality and `null`; any other operator is refused, so a matcher that ignored one cannot prove anything. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [key, want] of Object.entries(where)) { + if (want === undefined) continue; + if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`); + if (want !== null && typeof want === 'object') throw new Error(`stub engine: unsupported operator on ${key}`); + if ((row[key] ?? null) !== want) return false; + } + return true; +} + +/** + * A stub engine that keeps rows, and a registry holding what a code package's + * loader registered: each artifact as `applyProtection` leaves it. + */ +function makeProtocol(artifacts: Array<{ type: string; item: Record }> = []) { + const tables = new Map(); + const table = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + let nextId = 0; + const writes: Array<{ op: string; table: string }> = []; + const shipped = (type: string, name: string) => artifacts + .filter((a) => a.type === type && a.item.name === name) + .map((a) => a.item); + const engine: any = { + async find(t: string, q?: { where?: Record; limit?: number }) { + const rows = table(t).filter((r) => matches(r, q?.where)); + // The caller's bound, after the filter, by presence — as the engine applies it. + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async findOne(t: string, q: { where: Record }) { + assertEngineFindOnePredicate(t, q); + return table(t).find((r) => matches(r, q.where)) ?? null; + }, + async insert(t: string, data: Row) { + writes.push({ op: 'insert', table: t }); + nextId += 1; + const row = { id: `r_${nextId}`, ...data }; + table(t).push(row); + return { ...row }; + }, + async update(t: string, data: Row, q?: Record) { + assertEngineUpdateDispatch(data, q); + writes.push({ op: 'update', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const hit = table(t).filter((r) => matches(r, where)); + for (const r of hit) Object.assign(r, data); + return { id: hit[0]?.id ?? null }; + }, + async delete(t: string, q?: Record) { + assertEngineDeleteDispatch(q); + writes.push({ op: 'delete', table: t }); + const where = (q as { where?: Record } | undefined)?.where; + const keep = table(t).filter((r) => !matches(r, where)); + const deleted = table(t).length - keep.length; + tables.set(t, keep); + return { deleted }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + getArtifactItem: (type: string, name: string) => shipped(type, name).find((item) => isCodeArtifactBody(item)), + getItem: (type: string, name: string) => shipped(type, name)[0], + listItems: (type: string) => artifacts.filter((a) => a.type === type).map((a) => a.item), + getObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + const metadataWrites = () => writes.filter((w) => w.table === 'sys_metadata'); + const stored = (type: string, name: string) => table('sys_metadata').filter((r) => r.type === type && r.name === name); + return { protocol, table, writes, metadataWrites, stored }; +} + +/** A dashboard a code package ships, as its loader registers it: `protection` translated and stripped. */ +function packagedDashboard(name: string, protection?: { lock: string; reason: string }): { type: string; item: Record } { + const item: Record = { name, label: `Packaged ${name}`, widgets: [], ...(protection ? { protection } : {}) }; + applyProtection(item, { packageId: PACKAGE_ID, packageVersion: '1.0.0' }); + return { type: 'dashboard', item }; +} + +/** The card's probes: the hand-authored bodies of the measured `PUT`s. */ +const PROBES = [ + { type: 'app', name: 'probe_locked_app', body: (extra: Record = {}) => ({ name: 'probe_locked_app', label: 'Probe Locked', ...extra }) }, + { type: 'dashboard', name: 'probe_locked_dash', body: (extra: Record = {}) => ({ name: 'probe_locked_dash', label: 'Probe Locked Dash', widgets: [], ...extra }) }, +] as const; + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the call to be refused, but it resolved'); +} + +/** The refusal's envelope, and its location: the key named in the message and as the one issue's path. */ +function expectProtectionRefusal(err: any): void { + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(400); + expect(envelope.code).toBe('VALIDATION_ERROR'); + expect(err.issues).toEqual([expect.objectContaining({ path: 'protection' })]); + expect(err.message).toContain('Remove the `protection` block'); + // It names the door ADR-0010 does state, and no private envelope key. + expect(err.message).toContain("code package's source"); + expect(err.message).not.toMatch(/`_lock/); +} + +const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_METADATA_WRITABLE'] as const; +const saved: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; + for (const k of ENV_KEYS) delete process.env[k]; + resetEnvWritableMetadataTypes(); +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + resetEnvWritableMetadataTypes(); +}); + +describe('the save door refuses a `protection` block — the card\'s probes', () => { + for (const probe of PROBES) { + it(`${probe.type}: refused with a located 400 VALIDATION_ERROR, and no row is stored`, async () => { + const { protocol, metadataWrites, stored } = makeProtocol(); + expect(overlaySchemaDeclaredKey(probe.type, 'protection')).toBeDefined(); + const err = await refusalOf(() => protocol.saveMetaItem({ + type: probe.type, name: probe.name, item: probe.body({ protection: LOCK }), + })); + expectProtectionRefusal(err); + expect(err.message).toContain(`${probe.type} '${probe.name}'`); + expect(stored(probe.type, probe.name)).toEqual([]); + expect(metadataWrites()).toEqual([]); + }); + + it(`${probe.type}: CONTROL — the same body without \`protection\` saves, its row carries none, and the item stays editable and deletable`, async () => { + const { protocol, stored } = makeProtocol(); + const first = await protocol.saveMetaItem({ type: probe.type, name: probe.name, item: probe.body() }); + expect(first.success).toBe(true); + const [row] = stored(probe.type, probe.name).filter((r) => r.state === 'active'); + const body = JSON.parse(row!.metadata); + expect(body).not.toHaveProperty('protection'); + expect(body.label).toBe(probe.body().label); + const second = await protocol.saveMetaItem({ + type: probe.type, name: probe.name, item: probe.body({ label: 'Edited' }), + }); + expect(second.success).toBe(true); + const removed = await protocol.deleteMetaItem({ type: probe.type, name: probe.name }); + expect(removed.success).toBe(true); + }); + } +}); + +describe('the save door refuses a `protection` block — every way it arrives', () => { + const app = PROBES[0]; + + it('in draft mode', async () => { + const { protocol, metadataWrites } = makeProtocol(); + const err = await refusalOf(() => protocol.saveMetaItem({ + type: app.type, name: app.name, item: app.body({ protection: LOCK }), mode: 'draft', + })); + expectProtectionRefusal(err); + expect(metadataWrites()).toEqual([]); + }); + + it('malformed: the block is refused by this refusal, not judged by the parse first', async () => { + const { protocol, metadataWrites } = makeProtocol(); + for (const protection of [{ lock: 'sealed', reason: 'r' }, { lock: 'full' }, null]) { + const err = await refusalOf(() => protocol.saveMetaItem({ + type: app.type, name: app.name, item: app.body({ protection }), + })); + expectProtectionRefusal(err); + } + expect(metadataWrites()).toEqual([]); + }); + + it('on the unmanaged import\'s face, which hands a block it cannot parse to this door to be refused', async () => { + const { protocol, metadataWrites } = makeProtocol(); + const err = await refusalOf(() => protocol.saveMetaItem({ + type: app.type, name: app.name, item: app.body({ protection: { lock: 'sealed', reason: 'r' } }), + writeFace: 'package-unmanaged-import', + })); + expectProtectionRefusal(err); + expect(metadataWrites()).toEqual([]); + }); + + it('on an overlay of a packaged item that declares no lock', async () => { + const { protocol, metadataWrites } = makeProtocol([packagedDashboard('pkg_open_dash')]); + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'dashboard', name: 'pkg_open_dash', item: { name: 'pkg_open_dash', label: 'Overlay', widgets: [], protection: LOCK }, + })); + expectProtectionRefusal(err); + expect(metadataWrites()).toEqual([]); + // CONTROL: the same overlay without the block saves. + const ok = await protocol.saveMetaItem({ + type: 'dashboard', name: 'pkg_open_dash', item: { name: 'pkg_open_dash', label: 'Overlay', widgets: [] }, + }); + expect(ok.success).toBe(true); + }); +}); + +describe('the server-stated rewrites strip the block instead — a row stored before the refusal may carry one', () => { + const app = PROBES[0]; + const rewrites = [ + { face: '`source: \'migrate-stored\'` (the stored migration, the flow credential move)', extra: { source: 'migrate-stored', force: true } }, + { face: '`writeFace: \'package-duplicate\'`', extra: { writeFace: 'package-duplicate' } }, + ] as const; + + for (const rewrite of rewrites) { + it(`${rewrite.face}: the rewrite lands, and the row carries no \`protection\``, async () => { + const { protocol, stored } = makeProtocol(); + const item: Record = app.body({ protection: LOCK }); + const result = await protocol.saveMetaItem({ type: app.type, name: app.name, item, ...rewrite.extra }); + expect(result.success).toBe(true); + const [row] = stored(app.type, app.name).filter((r) => r.state === 'active'); + const body = JSON.parse(row!.metadata); + expect(body).not.toHaveProperty('protection'); + expect(body.label).toBe(app.body().label); + // The caller's own body is never mutated. + expect(item.protection).toEqual(LOCK); + }); + } +}); + +describe('CONTROLS — what the refusal leaves as it was', () => { + it('the packaged item\'s `protection` still binds: the loader translated it, and an overlay save is refused 403 ITEM_LOCKED', async () => { + const artifact = packagedDashboard('pkg_locked_dash', LOCK); + // The loader's translation: the block became the private envelope and was stripped. + expect(artifact.item).not.toHaveProperty('protection'); + expect(artifact.item).toMatchObject({ _lock: 'full', _lockSource: 'package', _packageId: PACKAGE_ID }); + const { protocol, stored } = makeProtocol([artifact]); + for (const extra of [{}, { protection: LOCK }]) { + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'dashboard', name: 'pkg_locked_dash', item: { name: 'pkg_locked_dash', label: 'Overlay', widgets: [], ...extra }, + })); + // The lock gate answers first, with or without a block in the body. + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(403); + expect(envelope.code).toBe('ITEM_LOCKED'); + } + expect(stored('dashboard', 'pkg_locked_dash')).toEqual([]); + }); + + it('a type whose schema does not declare `protection` keeps the parse\'s own 422 INVALID_METADATA', async () => { + expect(overlaySchemaDeclaredKey('page', 'protection')).toBeUndefined(); + const { protocol, metadataWrites } = makeProtocol(); + const err = await refusalOf(() => protocol.saveMetaItem({ + type: 'page', name: 'probe_page', item: { name: 'probe_page', label: 'Page', type: 'app', regions: [], protection: LOCK }, + })); + const envelope = resolveThrownHttpError(err); + expect(envelope.status).toBe(422); + expect(envelope.code).toBe('INVALID_METADATA'); + expect(err.message).toContain('protection'); + expect(metadataWrites()).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5f29462d845..94ad6a645ed 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -937,6 +937,103 @@ function runtimeHookWithoutBodyRefusal( return err; } +/** The author-facing lock block (ADR-0010 Phase 4.3), as the spec mounts it on a metadata item. */ +const PROTECTION_BLOCK_KEY = 'protection'; + +/** + * [ADR-0010 §3.5 and Phase 4.3] The save door's refusal of a `protection` + * block — the package author's lock declaration, which this door cannot + * honour. + * + * ADR-0010 states one door for the block. Phase 4.3 calls it the public + * author surface "that translates into the private `_lock` envelope at load + * time": the loader "calls `applyProtection(item, { packageId })` to translate + * the block … and strips the public `protection` block so it never leaks into + * the overlay row". An item this door writes is authored in the environment, + * and §3.5 states what a lock means for one: "Tenant `org` artifacts ignore + * L3 / L4 entirely (the tenant owns them in full, can also delete them)". So + * the block has no translation here, and translating it anyway would open a + * lock door ADR-0010 does not state. Stored verbatim, which is what this door + * did before, the block was served back as `protection.lock: 'full'` while the + * item read `lock: 'none'` and every edit and delete succeeded: a body telling + * its reader the item is protected when it is not. + * + * Asked only on a type whose schema declares the block, read structurally by + * {@link overlaySchemaDeclaredKey} (the unmanaged import asks the same + * question). On any other type the schema's own verdict stands: an + * unrecognized key, refused by the parse. + * + * Every AUTHORING write is refused: the REST and dispatcher saves, in draft + * and in publish mode, the domain doors that save through this one, and the + * unmanaged import, which strips a block it can parse before the body reaches + * this door (`stripUnmanagedImportLock`) and hands a malformed one on to be + * refused here. The server-stated rewrites of rows the store already holds — + * a `source`-stating re-save (`migrateStoredMetadata`, the flow credential + * move) and `duplicatePackage` — strip the block instead, through + * {@link withoutProtectionBlock}, exactly as they strip the derived provenance + * stamps (`tenantAuthoredWriteRefusal` draws the same line): a row stored + * before this rule may carry one, the block never bound, so removing it + * changes no verdict, and failing the rewrite would fail a stored migration or + * a credential move over a key that does nothing. A row nothing rewrites keeps + * its bytes. + * + * `VALIDATION_ERROR` / 400, the envelope of the door's other content refusals + * (`savedItemNameRefusal`, the hook refusal above). No code is minted. It is + * located twice: the message names the key, and `issues[]` carries it as a + * path, the shape the Studio designer keys on. Every present value is refused, + * a malformed block included, because the block is not accepted here at all: + * judging its value first would ask an author to repair a block they then have + * to delete. The message prescribes first and explains second, because a 4xx + * message crosses the REST boundary bounded at 500 characters with its TAIL + * truncated. + * + * ⛔ The prescription names the package loader's door, never a `_lock*` key: + * those are the runtime's private envelope, never authored + * (`shared/protection.zod.ts`). + */ +function protectionBlockRefusal( + type: string, + item: unknown, + saveName: string, +): (Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined { + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + if (!Object.prototype.hasOwnProperty.call(item, PROTECTION_BLOCK_KEY)) return undefined; + if ((item as Record)[PROTECTION_BLOCK_KEY] === undefined) return undefined; + if (overlaySchemaDeclaredKey(type, PROTECTION_BLOCK_KEY) === undefined) return undefined; + const err = new Error( + `Remove the \`protection\` block from ${type} '${saveName}' and send it again. Nothing was written. ` + + 'This door does not translate the block, so it would lock nothing: an item saved through the metadata API ' + + 'is authored in this environment (ADR-0010 §3.5). To lock an item, declare `protection` on it in a code ' + + "package's source, where the package loader translates it into the item's lock (ADR-0010 Phase 4.3).", + ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.issues = [{ + path: PROTECTION_BLOCK_KEY, + message: "Not accepted through the metadata API: only a code package's loader translates `protection` into a lock.", + code: 'custom', + }]; + return err; +} + +/** + * [ADR-0010 §3.5] The server-stated rewrites' half of + * {@link protectionBlockRefusal}: the same body without its `protection` block, + * on a type whose schema declares one. On any other type the body passes as + * it is, for the parse to judge. + * + * Returns the SAME reference when there is nothing to strip, and a copy + * otherwise, so a caller's own body is never mutated. + */ +function withoutProtectionBlock(type: string, item: unknown): unknown { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + if (!Object.prototype.hasOwnProperty.call(item, PROTECTION_BLOCK_KEY)) return item; + if (overlaySchemaDeclaredKey(type, PROTECTION_BLOCK_KEY) === undefined) return item; + const rest = { ...(item as Record) }; + delete rest[PROTECTION_BLOCK_KEY]; + return rest; +} + /** * One entry of the `422 INVALID_METADATA` envelope's `issues[]` — the shape * Studio's designer keys on to highlight the offending form control. @@ -20545,6 +20642,23 @@ export class ObjectStackProtocolImplementation implements } } + // [ADR-0010 §3.5, Phase 4.3] A `protection` block is refused, on every + // type that declares one: only a code package's loader translates it + // into a lock, and this door writes environment-authored items, so it + // was stored verbatim and served as a lock that bound nothing. Asked + // after the authorization refusals above (a locked item still answers + // `ITEM_LOCKED`, a sealed one `NOT_OVERRIDABLE`) and before every gate + // that reads or reshapes the body, so the block is judged as authored + // and a refused save costs no read. The server-stated rewrites — the + // same two the flow authoring rule above exempts — strip it instead. + // See {@link protectionBlockRefusal}. + if (request.source === undefined && request.writeFace !== 'package-duplicate') { + const protectionRefusal = protectionBlockRefusal(singularType, request.item, request.name); + if (protectionRefusal) throw protectionRefusal; + } else { + request.item = withoutProtectionBlock(singularType, request.item); + } + // Phase 3a-destructive: for `object` writes, diff against the // current schema and 409 if the change would drop data — unless the // caller has acknowledged the risk with `force: true`. The admin UI diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index 5be4658dfe1..d1d8b9badd9 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -591,9 +591,11 @@ export const AgentSchema = lazySchema(() => strictObject({ structuredOutput: StructuredOutputConfigSchema.optional().describe('Structured output contract for the agent\'s final answer (JSON format, schema, retries, fallback format, transform steps), enforced on every final answer by the cloud AI runtime; the open framework edition does not run agents.'), /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index f00557fe64e..f989ac415de 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -410,9 +410,11 @@ export const SkillSchema = lazySchema(() => strictObject({ active: z.boolean().default(true).describe('Whether the skill is enabled'), /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index 0ea318366b7..9923f029ced 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -212,9 +212,11 @@ export const ToolSchema = lazySchema(() => strictObject({ // authored sources. /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 5063f322562..56d0a536899 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1278,9 +1278,11 @@ export const FlowSchema = lazySchema(() => strictObject( }).optional().describe("Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node."), /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index 55846752d5a..7adb9e9fbd6 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -208,10 +208,13 @@ export const WebhookSchema = lazySchema(() => strictObject({ description: z.string().optional().describe('Webhook description'), /** - * ADR-0010 §3.7 — Package-level protection envelope. Package authors declare - * lock policy here; the loader translates it into the private `_lock` - * envelope at registration time and strips this block before persistence. - * See `shared/protection.zod.ts`. + * ADR-0010 §3.7 — Package-level protection envelope. Package + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See + * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( 'Package author protection block — lock policy for this webhook.', diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 7ea6048e3c3..b03120d9eff 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2734,9 +2734,11 @@ const ObjectSchemaBase = strictObject( /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/identity/position.zod.ts b/packages/spec/src/identity/position.zod.ts index 2bbb605188f..6a75395d2b9 100644 --- a/packages/spec/src/identity/position.zod.ts +++ b/packages/spec/src/identity/position.zod.ts @@ -134,10 +134,13 @@ export const PositionSchema = lazySchema(() => strictObject( ), /** - * ADR-0010 §3.7 — Package-level protection envelope. Package authors declare - * lock policy here; the loader translates it into the private `_lock` - * envelope at registration time and strips this block before persistence. - * See `shared/protection.zod.ts`. + * ADR-0010 §3.7 — Package-level protection envelope. Package + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See + * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( 'Package author protection block — lock policy for this position.', diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index fbc81c70005..39d9891774b 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -945,10 +945,13 @@ export const PermissionSetSchema = lazySchema(() => strictObject( .describe('[ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist)'), /** - * ADR-0010 §3.7 — Package-level protection envelope. Package authors declare - * lock policy here; the loader translates it into the private `_lock` - * envelope at registration time and strips this block before persistence. - * See `shared/protection.zod.ts`. + * ADR-0010 §3.7 — Package-level protection envelope. Package + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See + * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( 'Package author protection block — lock policy for this permission set.', diff --git a/packages/spec/src/shared/protection.zod.ts b/packages/spec/src/shared/protection.zod.ts index c99a8f94a2d..882049a1adb 100644 --- a/packages/spec/src/shared/protection.zod.ts +++ b/packages/spec/src/shared/protection.zod.ts @@ -39,6 +39,12 @@ * the underscored fields directly — they are an implementation * detail. * + * That loader is the block's only door (ADR-0010 Phase 4.3). The + * metadata API's save door (`PUT /api/v1/meta/{type}/{name}`) writes + * environment-authored items, which take no package lock (ADR-0010 + * §3.5), so it does not translate the block: it refuses it with + * `400 VALIDATION_ERROR` and stores nothing. + * * See also: * - ADR-0010 §3.7 — Future work → now implemented. * - `kernel/metadata-protection.zod.ts` — the runtime envelope. diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index 35d8a54e826..d72fca56e99 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -228,9 +228,11 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ description: z.string().optional(), /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index f58eba7c688..05e1e68ce2b 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1713,9 +1713,11 @@ export const AppSchema = lazySchema(() => strictObject( /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 6e7bebcde6c..022cbcae7cf 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -1824,9 +1824,11 @@ export const DashboardSchema = lazySchema(() => strictObject({ ), /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 59fdd9aaeda..6697ab075b9 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -531,9 +531,10 @@ export const DatasetSchema = lazySchema(() => strictObject({ measures: z.array(DatasetMeasureSchema).describe('Aggregatable values'), /** - * ADR-0010 — package-author protection envelope; the loader translates this - * into the private `_lock` envelope at registration and strips it before - * persistence. + * ADR-0010 — package-author protection envelope, declared in a code package's + * source; the loader translates this into the private `_lock` envelope at + * registration and strips it. The metadata API's save door does not translate + * it and refuses it (`400 VALIDATION_ERROR`), so that door never stores it. */ protection: ProtectionSchema.optional().describe( 'Package author protection block — lock policy for this dataset.', diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 78dad298cd6..24f27098402 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -516,9 +516,11 @@ export const ReportSchema = lazySchema(() => strictObject({ blocks: z.array(JoinedReportBlockSchema).optional().describe('Sub-reports for type=joined'), /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index a83aa7c7968..de3902d3b33 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5143,9 +5143,11 @@ export const ViewSchema = lazySchema(() => strictObject({ formViews: z.record(z.string(), FormViewSchema).optional().describe('Additional named form views'), /** * ADR-0010 §3.7 — Package-level protection envelope. Package - * authors declare lock policy here; the loader translates it - * into the private `_lock` envelope at registration time and - * strips this block before persistence. See + * authors declare lock policy here, in a code package's source; + * the loader translates it into the private `_lock` envelope at + * registration time and strips this block. The metadata API's + * save door does not translate it and refuses it (`400 + * VALIDATION_ERROR`), so that door never stores it. See * `shared/protection.zod.ts`. */ protection: ProtectionSchema.optional().describe( @@ -5363,8 +5365,9 @@ function viewItemBaseShape() { owner: retiredKey(VIEW_ITEM_OWNER_RETIRED), hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED), /** - * Package author protection block — same envelope as {@link ViewSchema}; - * the loader translates it into the private `_lock` envelope. + * Package author protection block — same envelope as {@link ViewSchema}: + * the loader translates it into the private `_lock` envelope, and the + * metadata API's save door refuses it. */ protection: ProtectionSchema.optional().describe( 'Package author protection block — lock policy for this view.', diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5eb11eee029..8708798420d 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1391,6 +1391,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.save-door-protection.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.save-door-protection.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.save-door-protection.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts", "verb": "delete",