Skip to content

Commit d4aaeaf

Browse files
fix(runtime): server-composed clone and re-flag bodies drop a stored protection block before the save door (#23188)
Fixes #23120 Clause-②: no ## What changes Two runtime doors re-save a body the server composes from a stored row. Both now drop a `protection` block before the body reaches `saveMetaItem`. This follows triage's decision on the card (comment 6136379484): one rule for both doors, no new write face, no `source`. - `packages/runtime/src/flow-clone.ts`: `FLOW_CLONE_DROPPED_KEYS` now names `'protection'`. It is spelled the way `PERMISSION_SET_CLONE_DROPPED_KEYS` spells it, because no spec export names the block on its own. The derived spreads are unchanged. - `packages/runtime/src/domains/packages.ts`: the publish-drafts visibility flip builds `{ ...app, _unpublished: false }` as before, then deletes `protection` from that copy only. The app object the read returned is not mutated. Neither door states a `source` or a write face, so every other save-door gate still judges both bodies. The block binds nothing: only the package loader translates it into a lock (`applyProtection`). Dropping it changes no lock and no verdict. ## Measured **`FLOW_CLONE_DROPPED_KEYS`.** Before, 9 keys: `_diagnostics`, `_draft` (from `METADATA_READ_DECORATIONS`) and `_lock`, `_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl` (from `Object.keys(MetadataProtectionFields)`). `protection` was in neither source, so the card's premise holds for the flow door. After: the same 9, plus `protection`. **The re-flag body.** Before: `{ ...app, _unpublished: false }`, where `app` is a row from `getMetaItems({ type: 'app', packageId })`. That read serves a stored row's block as stored (measured below). After: the same body without `protection`. **Where the block comes from.** `FlowSchema` declares `protection`, so `registerFlow`'s `FlowSchema.parse` keeps it on the flow the engine holds, and `getFlow` returns that object to the clone. A flow the package loader registered arrives already stripped by `applyProtection`. A flow bound from a row stored before the save door's refusal does not. **Reach, at the public doors, on a real showcase boot** (`bootShowcase` with a database file, through the in-process HTTP app). How a legacy row was made: the flow and the app were saved through the doors without a block, and the block was then written into the stored row's `metadata` JSON with the data engine under the system context. That is the only way such a row can exist now, because the door refuses the block. A cold boot on the same database file then bound the rows through the real path. The script was a one-off under `packages/qa/dogfood/test`, run twice and deleted. It is not part of this diff. | Step | Before: runtime dist built from source identical to `dc93781c19` for both files | After: runtime dist built at `ee0cbaa494` | |:--|:--|:--| | `PUT /meta/flow/NAME` with a block (why no new row can carry one) | 400 `VALIDATION_ERROR` | 400 `VALIDATION_ERROR` | | Stored legacy flow row and app row carry the block after the cold boot | yes, yes | yes, yes | | `GET /automation/NAME` (the engine-held flow) carries the block | yes | yes | | `POST /automation/LEGACY/clone` | **400 `VALIDATION_ERROR`**, `issues[0].path` is `protection`, 0 rows saved, the copy reads 404 | **200**, 1 row saved, no block, the copy reads 200 | | Control: `POST /automation/PLAIN/clone` | 200, row saved without a block | 200, row saved without a block | | `POST /packages/LEGACY_PKG/publish-drafts` | **200 with the save door's refusal under `unhideError`**. The row is still `_unpublished: true` and still carries the block | **200, `unhiddenApps` names the app**. The row is `_unpublished: false` with no block | | Control: `POST /packages/PLAIN_PKG/publish-drafts` | 200, `unhiddenApps` names the app, row `_unpublished: false` | the same | The clone's refusal message named a block on `reach23120_legacy_flow_copy`, a flow the admin was creating and had never sent a block for. ## Tests - New pins, one per door, each with a no-block control: - `packages/runtime/src/domains/automation-tenant-authored-write.test.ts`. A real protocol over a real `SchemaRegistry`, and the save spied, as the file's other cases do. The source is `FlowSchema.parse` output carrying a block, as the engine binds a stored tenant row. Asserted: 200; the body handed to `saveMetaItem` has no `protection`; nothing else changed; no `source` was stated. Control: a no-block source saves exactly `{ ...definition, name, label, status: 'draft' }`. - `packages/runtime/src/domains/packages-unpublished-reflag-protection.test.ts` (new). The publish-drafts door over a protocol double whose `getMetaItems` answers the stored rows. Its `objectql` service is a registry only, so it adds no engine double. Asserted: 200; `unhiddenApps`; no `unhideError`; the re-saved body equals the stored row minus `protection`, with `_unpublished: false`; the row the read returned is unmutated. Control: a no-block app re-saves `{ ...stored, _unpublished: false }` exactly. - Both spell `protection` literally rather than reading it from the constant, so removing either drop reds its pin. - Before the fix, at `fb9f991a30`/`477e4f530d` with the pins committed: each block pin red on `expected … to not have property "protection"`, each control green. - **Ablation**, from the committed fix (`ee0cbaa494`), inside `scripts/pm/os-verify-lock.sh`, through `scripts/ablation-replace.mjs` (WRAP mode) plus an outer `trap` that restores with `git checkout HEAD --` on the absolute path. The subjects are imported from source by relative path, so no `dist/` leg applies. Predicted before running: each leg reds exactly its own door's block pin, 1 failed / 9 passed. - Leg A, `'protection',` deleted from `FLOW_CLONE_DROPPED_KEYS`: anchor 1 to 0, blob `b2579ea10d` to `c551a850dc`. **1 failed / 9 passed**: the flow block pin red, both controls and the re-flag pin green. Restored: blob equals HEAD `b2579ea10d`, `git diff HEAD` empty. - Leg B, `delete item.protection;` deleted from the flip: anchor 1 to 0, blob `4c9541ee97` to `660bf46dd2`. **1 failed / 9 passed**: the re-flag block pin red, both controls and the flow pin green. Restored: blob equals HEAD `4c9541ee97`, `git diff HEAD` empty. - At `e3cb0b4deb` (this branch with `origin/main` `e540e7ffb9` merged, dependency closure rebuilt): - `pnpm --filter @objectstack/runtime test`: Test Files 364 passed (364); Tests 5178 passed, 19 skipped (5197). `VERDICT command-exit 0`. - `pnpm --filter @objectstack/runtime typecheck`: `VERDICT command-exit 0`. The test layer is held at its ledger, 27 files / 190 errors / 68 signatures. `tsc --listFiles -p tsconfig.test.json` puts both test files in the program (2 of 2, 1414 files), with 0 of the 190 errors in any file this PR touches. ## Gates At `e3cb0b4deb`, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 64 commands with no paths (5 paths vs merge base `e540e7ffb`). That is identical to the derivation over the claim's paths. Each command ran with its exit code captured before any pipe. - 63 exited 0 on the first pass. - `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3) for 8 packages with no `dist/`. Those were built (turbo, all 58 tasks from cache), and the rerun exited 0: "108 published require entry point(s) across 67 package(s) load". - `dispatch-gates --ran`: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN". **Lint, narrowed.** `eslint --no-inline-config --format json` over the 4 changed `.ts` files: 4 linted, 0 errors, 0 warnings, none ignored (`isPathIgnored` false). The changeset `.md` is outside eslint's population (`isPathIgnored` true). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as stated in the config itself), so this diff cannot move any untouched file's verdict. The full `pnpm lint` belongs to CI. ## Acceptance notes - Read, not measured: the other runtime `saveMetaItem` sites (`POST /automation`, `PUT /automation/:name`, the `/meta` dispatcher save) save the caller's own body. A refusal there names a block the caller sent, which is the remedy #23096's changeset documents. In `metadata-protocol`, the server's own re-saves either state a `source` or face (stored migration, duplication, unmanaged import) or, for commit revert/rollback and draft promotion, write rows without going through `saveMetaItem`. So no third server-composed door meets this refusal. Nothing filed. - A legacy row keeps its bytes until something rewrites it. `GET /meta/flow/NAME` and `GET /automation/NAME` still serve its block (measured above), and a Studio round trip is still refused with the documented remedy. A flip now rewrites an app's row without the block. A clone writes a new row without it and leaves its source row alone. - Release order: `.changeset/23096-save-door-protection.md` is in `main`'s unreleased stock, and this PR adds `.changeset/23120-server-composed-protection-drop.md` (`@objectstack/runtime` patch). If this lands before the next release is cut, both ship in one release, as triage requires. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8814211 commit d4aaeaf

5 files changed

Lines changed: 224 additions & 10 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): cloning a flow, and publishing a package's drafts, no longer fail on a `protection` block left in a row stored before the metadata save door refused it
6+
7+
Clause-②: no
8+
9+
Two runtime doors re-save a body the server builds from a stored row: the flow clone (`POST /api/v1/automation/:name/clone`), and the visibility flip of `POST /api/v1/packages/:id/publish-drafts`, which re-saves each of the package's apps still stored with `_unpublished: true`. The metadata save door now refuses a `protection` block with `400 VALIDATION_ERROR` (entry `23096-save-door-protection`). A row stored before that refusal can still carry a block, and both doors copied it into the body they saved.
10+
11+
**FROM.** A clone of such a flow answered `400 VALIDATION_ERROR` about a block the admin never sent. The flip of such an app was refused the same way: the publish still answered `200`, the app stayed `_unpublished`, and the refusal was reported only under `unhideError`.
12+
13+
**TO.** Both doors drop the `protection` block from the body they save, as the permission-set clone already does. The clone and the flip save, and the saved row carries no block. Nothing else in either body changes, and neither door states a `source`, so every other save-door check still applies. The block never bound anything: only a code package's loader translates `protection` into a lock, so dropping it changes no lock and no verdict.
14+
15+
**Unchanged.** A flow or an app with no block is saved exactly as before.

‎packages/runtime/src/domains/automation-tenant-authored-write.test.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
* - `POST /:name/clone` asks the same rule of the copy, registers it, and saves
1818
* it through the protocol's own save as a tenant row: no provenance on the
1919
* saved body, env-wide; a save that fails withdraws the registration and
20-
* relays the failure, so no clone is reported that would not survive.
20+
* relays the failure, so no clone is reported that would not survive;
21+
* - [#23120] a clone of a flow whose stored row still carries a `protection`
22+
* block saves a body without it (the save door refuses the block), and a
23+
* source with no block saves the body it always did.
2124
*
2225
* Harness as in `automation-packaged-base-lock.test.ts`: a REAL protocol over a
2326
* REAL `SchemaRegistry` answers every verdict, with the packaged flow
@@ -32,13 +35,18 @@ import { describe, it, expect, vi } from 'vitest';
3235
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
3336
import { SchemaRegistry } from '@objectstack/objectql';
3437
import { isCodeArtifactBody } from '@objectstack/metadata-core';
38+
import { FlowSchema } from '@objectstack/spec/automation';
3539

3640
import { HttpDispatcher } from '../http-dispatcher.js';
3741
import type { HttpProtocolContext } from '../http-dispatcher.js';
3842

3943
const PACKAGED = 'pkg_alert_flow';
4044
const PACKAGE_ID = 'com.example.pkg';
4145
const CUSTOMER = 'customer_flow';
46+
/** A tenant flow whose stored row predates the save door's `protection` refusal. */
47+
const LEGACY = 'legacy_locked_flow';
48+
/** The block such a row still carries: the save door now refuses it, and it never bound. */
49+
const LEGACY_BLOCK = { lock: 'full', reason: 'Stored before the metadata save door refused this block.' };
4250

4351
/** The stamps a caller would send to claim the real package's provenance. */
4452
const ASSERTED = { _packageId: PACKAGE_ID, _provenance: 'package' };
@@ -60,6 +68,9 @@ function boot() {
6068
const flows = new Map<string, unknown>([
6169
[PACKAGED, { ...definitionOf(PACKAGED), ...ASSERTED }],
6270
[CUSTOMER, definitionOf(CUSTOMER)],
71+
// As the engine binds a stored tenant row: `FlowSchema.parse` output,
72+
// and `FlowSchema` declares `protection`, so the block rides along.
73+
[LEGACY, FlowSchema.parse({ ...definitionOf(LEGACY), protection: LEGACY_BLOCK })],
6374
]);
6475
const registerFlow = vi.fn((name: string, definition: unknown) => {
6576
flows.set(name, definition);
@@ -178,3 +189,56 @@ describe('[#20761, ADR-0126 §7.1] the clone door saves its copy as a tenant row
178189
expect(h.held('my_alert_copy')).toBeUndefined();
179190
});
180191
});
192+
193+
/**
194+
* [#23120, ADR-0010 §3.5] The body the clone door saves is composed by the
195+
* server from a stored row, so it drops the `protection` block before it
196+
* reaches `saveMetaItem`. The save door refuses the block on an
197+
* environment-authored write (`400 VALIDATION_ERROR`); a row stored before
198+
* that refusal still carries one, and only the package loader ever translated
199+
* it into a lock, so dropping it changes no verdict.
200+
*
201+
* `protection` is spelled here, not read from `FLOW_CLONE_DROPPED_KEYS`: the
202+
* key the save door refuses is the contract this pins, so removing it from
203+
* that constant has to turn this red.
204+
*/
205+
describe('[#23120] a clone does not carry its source\'s stored `protection` block into the save', () => {
206+
const COPY = { name: 'legacy_locked_flow_copy', label: 'Legacy copy' };
207+
const MUTATED = ['name', 'label', 'status'];
208+
const without = (body: Record<string, unknown>, keys: readonly string[]) => {
209+
const copy = { ...body };
210+
for (const key of keys) delete copy[key];
211+
return copy;
212+
};
213+
214+
it('a clone of a flow whose stored row carries the block saves, and the body it saves carries none', async () => {
215+
const h = boot();
216+
const source = h.held(LEGACY) as Record<string, unknown>;
217+
// The fixture really carries the block, so this measures a drop and not an absence.
218+
expect(source.protection).toEqual(LEGACY_BLOCK);
219+
220+
const { response } = await h.dispatcher.handleAutomation(`/${LEGACY}/clone`, 'POST', COPY, AUTHOR(), undefined);
221+
222+
expect(statusOf(response)).toBe(200);
223+
expect(h.saveMetaItem).toHaveBeenCalledTimes(1);
224+
const saved = h.saveMetaItem.mock.calls[0]![0] as { type: string; name: string; item: Record<string, unknown>; source?: unknown };
225+
expect(saved).toMatchObject({ type: 'flow', name: COPY.name });
226+
expect(saved.item).not.toHaveProperty('protection');
227+
// No server-stated `source`: the clone stays an authoring write, judged by every gate.
228+
expect(saved.source).toBeUndefined();
229+
// Only the block went: everything else the source carries is saved.
230+
expect(without(saved.item, MUTATED)).toEqual(without(source, [...MUTATED, 'protection']));
231+
expect(h.registerFlow).toHaveBeenCalledWith(COPY.name, saved.item);
232+
expect(dataOf(response)?.flow).not.toHaveProperty('protection');
233+
});
234+
235+
it('control: a clone of a flow with no block saves the same body it always did', async () => {
236+
const h = boot();
237+
const { response } = await h.dispatcher.handleAutomation(`/${CUSTOMER}/clone`, 'POST', COPY, AUTHOR(), undefined);
238+
239+
expect(statusOf(response)).toBe(200);
240+
expect(h.saveMetaItem).toHaveBeenCalledTimes(1);
241+
const saved = h.saveMetaItem.mock.calls[0]![0] as { item: Record<string, unknown> };
242+
expect(saved.item).toEqual({ ...definitionOf(CUSTOMER), name: COPY.name, label: COPY.label, status: 'draft' });
243+
});
244+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23120, ADR-0010 §3.5] The ADR-0045 visibility flip of
5+
* `POST /packages/:id/publish-drafts` re-saves each `_unpublished` app of the
6+
* package through `saveMetaItem`, and the body it re-saves is composed by the
7+
* server from the stored row the app read returned. That body drops the
8+
* `protection` block before it reaches the save door.
9+
*
10+
* The save door refuses a `protection` block on an environment-authored write
11+
* with `400 VALIDATION_ERROR`, and the flip states no `source`, so it is one.
12+
* A row stored before that refusal can still carry a block, and the flip used
13+
* to copy it through: the save was refused, the app stayed `_unpublished`, and
14+
* the publish answered 200 with the refusal under `unhideError` — a message the
15+
* publisher cannot act on. The block binds nothing (only the package loader
16+
* translates it into a lock, through `applyProtection`), so dropping it
17+
* changes no verdict.
18+
*
19+
* Pinned at the flip itself: the protocol is a double whose `getMetaItems`
20+
* answers the stored rows and whose `saveMetaItem` records the body it is
21+
* handed. The save door's own refusal is pinned where it lives
22+
* (`metadata-protocol`'s `protocol.save-door-protection.test.ts`). The
23+
* `protection` key is spelled here, so removing the drop turns this red.
24+
*/
25+
26+
import { describe, expect, it, vi } from 'vitest';
27+
import { HttpDispatcher } from '../http-dispatcher.js';
28+
29+
const PACKAGE_ID = 'com.workspace';
30+
31+
/** The block a row stored before the save door's refusal still carries. */
32+
const LEGACY_BLOCK = { lock: 'full', reason: 'Stored before the metadata save door refused this block.' };
33+
34+
const PKG_ADMIN = () => ({
35+
request: {},
36+
executionContext: {
37+
userId: 'u_pkg_admin',
38+
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
39+
},
40+
}) as any;
41+
42+
/**
43+
* The publish-drafts door over a protocol double holding `apps` as stored
44+
* rows. `seedApplied` is answered so the route's own seed fallback stays out
45+
* of the way: this file reads the flip and nothing else.
46+
*/
47+
function makeDoor(apps: Array<Record<string, unknown>>) {
48+
const publishPackageDrafts = vi.fn().mockResolvedValue({
49+
success: true, publishedCount: 0, failedCount: 0, published: [], failed: [],
50+
seedApplied: { success: true, inserted: 0 },
51+
});
52+
const getMetaItems = vi.fn().mockResolvedValue({ items: apps });
53+
const saveMetaItem = vi.fn().mockResolvedValue({ success: true });
54+
// The `/packages` domain answers 503 without a registry, so `objectql` is a
55+
// registry and nothing else: the flip reads and writes through the protocol.
56+
const services: Record<string, unknown> = {
57+
protocol: { publishPackageDrafts, getMetaItems, saveMetaItem },
58+
objectql: { registry: { getAllPackages: () => [] } },
59+
};
60+
const kernel: any = {
61+
getService: (name: string) => (name in services ? Promise.resolve(services[name]) : null),
62+
context: { getService: () => null, trigger: vi.fn() },
63+
};
64+
return { dispatcher: new HttpDispatcher(kernel), getMetaItems, saveMetaItem };
65+
}
66+
67+
async function publishDrafts(apps: Array<Record<string, unknown>>) {
68+
const door = makeDoor(apps);
69+
const { response } = await door.dispatcher.handlePackages(
70+
`/${PACKAGE_ID}/publish-drafts`, 'POST', {}, {}, PKG_ADMIN(),
71+
);
72+
const saved = door.saveMetaItem.mock.calls.map(
73+
(call) => call[0] as { type: string; name: string; item: Record<string, unknown>; packageId?: unknown; source?: unknown },
74+
);
75+
return { response: response as any, saved, door };
76+
}
77+
78+
describe('[#23120] the publish-drafts visibility flip re-saves no stored `protection` block', () => {
79+
it('an app whose stored row carries the block flips, and the body it re-saves carries none', async () => {
80+
const stored = { name: 'legacy_app', label: 'Legacy App', hidden: false, _unpublished: true, protection: LEGACY_BLOCK };
81+
const { response, saved } = await publishDrafts([stored]);
82+
83+
expect(response?.status).toBe(200);
84+
const data = response?.body?.data ?? response?.body;
85+
expect(data?.unhiddenApps).toEqual(['legacy_app']);
86+
expect(data).not.toHaveProperty('unhideError');
87+
88+
expect(saved).toHaveLength(1);
89+
expect(saved[0]).toMatchObject({ type: 'app', name: 'legacy_app', packageId: PACKAGE_ID });
90+
expect(saved[0]!.item).not.toHaveProperty('protection');
91+
// No server-stated `source`: the flip stays a write every gate judges.
92+
expect(saved[0]!.source).toBeUndefined();
93+
// Only the block went, and the gate flipped.
94+
const expected: Record<string, unknown> = { ...stored, _unpublished: false };
95+
delete expected.protection;
96+
expect(saved[0]!.item).toEqual(expected);
97+
// The row the read answered is not mutated: it may be a cached object.
98+
expect(stored.protection).toEqual(LEGACY_BLOCK);
99+
});
100+
101+
it('control: an app with no block re-saves the same body it always did', async () => {
102+
const stored = { name: 'plain_app', label: 'Plain App', hidden: true, _unpublished: true };
103+
const { response, saved } = await publishDrafts([stored]);
104+
105+
expect(response?.status).toBe(200);
106+
expect((response?.body?.data ?? response?.body)?.unhiddenApps).toEqual(['plain_app']);
107+
expect(saved).toHaveLength(1);
108+
expect(saved[0]!.item).toEqual({ ...stored, _unpublished: false });
109+
});
110+
});

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,16 +1729,30 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
17291729
: Array.isArray((appsRes as any)?.items) ? (appsRes as any).items : [];
17301730
for (const app of apps) {
17311731
if (app && typeof app === 'object' && app._unpublished === true && typeof app.name === 'string') {
1732+
// `false`, not a delete: ADR-0045 §3 makes
1733+
// publish/unpublish symmetric ("unpublish =
1734+
// re-hide"), so the gate stays a two-state
1735+
// flag rather than a key whose absence has
1736+
// to be re-derived. Whatever `hidden` the
1737+
// app carries is copied through untouched.
1738+
const item: Record<string, unknown> = { ...app, _unpublished: false };
1739+
// [#23120, ADR-0010 §3.5] …but not a
1740+
// `protection` block. This body is the
1741+
// server's, built from the stored row, and
1742+
// a row stored before the metadata save door
1743+
// refused the block can still carry one: the
1744+
// save would be refused with
1745+
// `400 VALIDATION_ERROR` and the app left
1746+
// `_unpublished`. Only the package loader
1747+
// translates the block into a lock, so it
1748+
// never bound and dropping it changes no
1749+
// verdict. Dropped from this copy only — the
1750+
// row the read returned is not mutated.
1751+
delete item.protection;
17321752
await protocol.saveMetaItem({
17331753
type: 'app',
17341754
name: app.name,
1735-
// `false`, not a delete: ADR-0045 §3 makes
1736-
// publish/unpublish symmetric ("unpublish =
1737-
// re-hide"), so the gate stays a two-state
1738-
// flag rather than a key whose absence has
1739-
// to be re-derived. Whatever `hidden` the
1740-
// app carries is copied through untouched.
1741-
item: { ...app, _unpublished: false },
1755+
item,
17421756
packageId: id,
17431757
...(body?.actor ? { actor: body.actor } : {}),
17441758
});

‎packages/runtime/src/flow-clone.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ export const FLOW_CLONE_STATUS = 'draft' as const;
122122
export const FLOW_CLONE_MUTATED_FIELDS = ['name', 'label', 'status'] as const;
123123

124124
/**
125-
* Keys that must NOT survive onto a clone — the ADR-0010 protection envelope
126-
* plus the read-time decorations.
125+
* Keys that must NOT survive onto a clone — the ADR-0010 protection envelope,
126+
* the read-time decorations, and the package author's `protection` block.
127127
*
128128
* DERIVED from the spec, never restated: `MetadataProtectionFields` is the
129129
* declaration `FlowSchema` itself spreads, and `METADATA_READ_DECORATIONS` is
@@ -135,10 +135,21 @@ export const FLOW_CLONE_MUTATED_FIELDS = ['name', 'label', 'status'] as const;
135135
* The read decorations would usually be absent here — a clone reads its source
136136
* from the engine's flow map, not over `/meta` — but the strip costs nothing
137137
* and closes the case where a caller's source came through a served read.
138+
*
139+
* [#23120, ADR-0010 §3.5] `protection` is named, not derived, exactly as
140+
* `PERMISSION_SET_CLONE_DROPPED_KEYS` names it: no spec export lists the block
141+
* on its own. Only the package loader translates it into a lock
142+
* (`applyProtection`, ADR-0010 Phase 4.3), and the metadata save door refuses
143+
* it on an environment-authored write with `400 VALIDATION_ERROR`. A flow the
144+
* loader registered arrives with it already stripped, but a flow bound from a
145+
* row stored before that refusal still carries it — `FlowSchema` declares the
146+
* key, so the parse keeps it — and a copy that kept it would be refused over a
147+
* block the admin never sent. Dropping it changes no verdict: it never bound.
138148
*/
139149
export const FLOW_CLONE_DROPPED_KEYS: readonly string[] = Object.freeze([
140150
...METADATA_READ_DECORATIONS,
141151
...Object.keys(MetadataProtectionFields),
152+
'protection',
142153
]);
143154

144155
/**

0 commit comments

Comments
 (0)