From e749daedbc0347027c69de2fc26c5a9c34ee6cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 22:43:59 +0000 Subject: [PATCH 1/7] fix(metadata-protocol): the save door refuses a protection block instead of storing it inert ADR-0010 states one door for the package author's protection block: the loader translates it at load time (Phase 4.3), and an environment-authored item takes no package lock (section 3.5). The save door stored the block verbatim, so the served item read protection.lock 'full' while every edit and delete succeeded. It now refuses the block with a located 400 VALIDATION_ERROR on every type that declares it, and the protection TSDoc says so. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 86 +++++++++++++++++++ packages/spec/src/ai/agent.zod.ts | 8 +- packages/spec/src/ai/skill.zod.ts | 8 +- packages/spec/src/ai/tool.zod.ts | 8 +- packages/spec/src/automation/flow.zod.ts | 8 +- packages/spec/src/automation/webhook.zod.ts | 11 ++- packages/spec/src/data/object.zod.ts | 8 +- packages/spec/src/identity/position.zod.ts | 11 ++- packages/spec/src/security/permission.zod.ts | 11 ++- .../spec/src/system/email-template.zod.ts | 8 +- packages/spec/src/ui/app.zod.ts | 8 +- packages/spec/src/ui/dashboard.zod.ts | 8 +- packages/spec/src/ui/dataset.zod.ts | 7 +- packages/spec/src/ui/report.zod.ts | 8 +- packages/spec/src/ui/view.zod.ts | 13 +-- 15 files changed, 164 insertions(+), 47 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5f29462d845..266505e2e51 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -937,6 +937,79 @@ 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 writer through this door is judged, as for + * {@link runtimeHookWithoutBodyRefusal}: the REST and dispatcher saves, in + * draft and in publish mode, and the two server-stated re-savers + * (`migrateStoredMetadata`, `duplicatePackage`), which record this refusal as + * the row's failure. The unmanaged import strips a block it can parse before + * the body reaches this door (`stripUnmanagedImportLock`). A row stored before + * this rule 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; +} + /** * 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 +20618,19 @@ 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. See {@link protectionBlockRefusal}. + { + const protectionRefusal = protectionBlockRefusal(singularType, request.item, request.name); + if (protectionRefusal) throw protectionRefusal; + } + // 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/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 f4ab57651b1..3e756b7013a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5050,9 +5050,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( @@ -5270,8 +5272,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.', From 1e6bf4fe48a29f0eae1a6b71c8ac96693aa56f42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 22:47:04 +0000 Subject: [PATCH 2/7] test(metadata-protocol): pin the save door's protection refusal with the card's probes and its controls Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/protocol.save-door-protection.test.ts | 287 ++++++++++++++++++ packages/spec/src/shared/protection.zod.ts | 6 + 2 files changed, 293 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol.save-door-protection.test.ts 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..f8061cd6ef6 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.save-door-protection.test.ts @@ -0,0 +1,287 @@ +// 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 a server-stated re-saver meet the same + * refusal; + * - an overlay of a packaged item that declares no lock meets it too; + * - 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 a server-stated re-save (`migrate-stored`), which records it as the row\'s failure', async () => { + const { protocol, metadataWrites } = makeProtocol(); + const err = await refusalOf(() => protocol.saveMetaItem({ + type: app.type, name: app.name, item: app.body({ protection: LOCK }), source: 'migrate-stored', force: true, + })); + 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('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/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. From 38efcb3f9517f642b856509d52861cb5269c90ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 23:10:32 +0000 Subject: [PATCH 3/7] chore(changeset): the save door's protection refusal, a narrowing shipped minor Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/23096-save-door-protection.md | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/23096-save-door-protection.md diff --git a/.changeset/23096-save-door-protection.md b/.changeset/23096-save-door-protection.md new file mode 100644 index 00000000000..921ed779816 --- /dev/null +++ b/.changeset/23096-save-door-protection.md @@ -0,0 +1,26 @@ +--- +'@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. + +**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 writer through the door**: the REST and dispatcher saves, and the server-stated re-savers (`os migrate meta --stored --apply` and package duplication), which report the refusal as that row's failure. The unmanaged install already drops the block before it saves a copy. +- **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. From 7aeef8b82508f89210c706896f94866c4953c341 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 23:36:37 +0000 Subject: [PATCH 4/7] chore: regenerate the protection reference page and teach the pinned double ledger the new test's engine double Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- content/docs/references/shared/protection.mdx | 6 ++++++ scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 21 insertions(+) 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/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", From edad2a33fdcda822e5c8e739d7f6837901e61f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 23:40:16 +0000 Subject: [PATCH 5/7] fix(metadata-protocol): the server-stated rewrites strip a stored protection block instead of failing A row stored before the refusal may carry the block, and the stored migration, the flow credential move and package duplication rewrite such rows. They now drop the block, exactly as they drop the derived provenance stamps, instead of failing the rewrite over a key that never bound. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/23096-save-door-protection.md | 3 +- .../src/protocol.save-door-protection.test.ts | 36 +++++++++++++-- packages/metadata-protocol/src/protocol.ts | 46 +++++++++++++++---- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/.changeset/23096-save-door-protection.md b/.changeset/23096-save-door-protection.md index 921ed779816..c5e414251ab 100644 --- a/.changeset/23096-save-door-protection.md +++ b/.changeset/23096-save-door-protection.md @@ -18,7 +18,8 @@ Clause-②: no (narrowing) **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 writer through the door**: the REST and dispatcher saves, and the server-stated re-savers (`os migrate meta --stored --apply` and package duplication), which report the refusal as that row's failure. The unmanaged install already drops the block before it saves a copy. +- **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. diff --git a/packages/metadata-protocol/src/protocol.save-door-protection.test.ts b/packages/metadata-protocol/src/protocol.save-door-protection.test.ts index f8061cd6ef6..1b71f9dc699 100644 --- a/packages/metadata-protocol/src/protocol.save-door-protection.test.ts +++ b/packages/metadata-protocol/src/protocol.save-door-protection.test.ts @@ -15,9 +15,13 @@ * * - the `app` and `dashboard` probes are refused with a located * `400 VALIDATION_ERROR`, and no row is stored; - * - draft mode, a malformed block and a server-stated re-saver meet the same - * refusal; + * - 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 @@ -229,10 +233,11 @@ describe('the save door refuses a `protection` block — every way it arrives', expect(metadataWrites()).toEqual([]); }); - it('on a server-stated re-save (`migrate-stored`), which records it as the row\'s failure', async () => { + 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 }), source: 'migrate-stored', force: true, + type: app.type, name: app.name, item: app.body({ protection: { lock: 'sealed', reason: 'r' } }), + writeFace: 'package-unmanaged-import', })); expectProtectionRefusal(err); expect(metadataWrites()).toEqual([]); @@ -253,6 +258,29 @@ describe('the save door refuses a `protection` block — every way it arrives', }); }); +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 = 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); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 266505e2e51..94ad6a645ed 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -963,13 +963,19 @@ const PROTECTION_BLOCK_KEY = 'protection'; * question). On any other type the schema's own verdict stands: an * unrecognized key, refused by the parse. * - * Every writer through this door is judged, as for - * {@link runtimeHookWithoutBodyRefusal}: the REST and dispatcher saves, in - * draft and in publish mode, and the two server-stated re-savers - * (`migrateStoredMetadata`, `duplicatePackage`), which record this refusal as - * the row's failure. The unmanaged import strips a block it can parse before - * the body reaches this door (`stripUnmanagedImportLock`). A row stored before - * this rule keeps its bytes. + * 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 @@ -1010,6 +1016,24 @@ function protectionBlockRefusal( 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. @@ -20625,10 +20649,14 @@ export class ObjectStackProtocolImplementation implements // 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. See {@link protectionBlockRefusal}. - { + // 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 From f8644fa68538befef3b1c7dc382b933b891c4e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 23:41:56 +0000 Subject: [PATCH 6/7] test(metadata-protocol): type the rewrite case's body as a record Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../metadata-protocol/src/protocol.save-door-protection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.save-door-protection.test.ts b/packages/metadata-protocol/src/protocol.save-door-protection.test.ts index 1b71f9dc699..021bba5a114 100644 --- a/packages/metadata-protocol/src/protocol.save-door-protection.test.ts +++ b/packages/metadata-protocol/src/protocol.save-door-protection.test.ts @@ -268,7 +268,7 @@ describe('the server-stated rewrites strip the block instead — a row stored be for (const rewrite of rewrites) { it(`${rewrite.face}: the rewrite lands, and the row carries no \`protection\``, async () => { const { protocol, stored } = makeProtocol(); - const item = app.body({ protection: LOCK }); + 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'); From 94c3c852b947fe96f73ba6dcbde4e19ece968751 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:49:05 +0000 Subject: [PATCH 7/7] docs(metadata-protocol): the save door's protection refusal supersedes the unmanaged-lock-strip note's 422 on declaring types The changeset gains the supersession sentence in the house form, and stripUnmanagedImportLock's docblock no longer says such a body is refused as before. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/23096-save-door-protection.md | 2 ++ .../metadata-protocol/src/package-unmanaged-install.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/23096-save-door-protection.md b/.changeset/23096-save-door-protection.md index c5e414251ab..cb2f3f45f7c 100644 --- a/.changeset/23096-save-door-protection.md +++ b/.changeset/23096-save-door-protection.md @@ -13,6 +13,8 @@ Clause-②: no (narrowing) `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. 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