Skip to content

Commit b1aee33

Browse files
fix(metadata-protocol)!: refuse a flow saved into a package this deployment has not installed (#20863) (#20959)
Fixes #20863 Clause-②: no (narrowing) ## What this does A flow saved through the metadata door (`PUT /api/v1/meta/flow/:name`) may name, as its base, the package it belongs to. When that id was a package this deployment has never installed, the door answered `200`, stored the flow live, and served the binding back: a flow bound to a package that does not exist. It now answers `422 WRITABLE_PACKAGE_REQUIRED`, and nothing is written, served or registered. - **One rule, extended in place.** The refusal lives in `tenantAuthoredWriteRefusal` (`packages/metadata-protocol/src/protocol.ts`), the one shared function every flow write door asks, which PR #20853 established. There is no second check. The rule gains a named-base arm between the locked-base lock and the provenance check. `saveMetaItem` already handed the rule the base it names, so its code is unchanged; only its comment at the hand-off says so. - **"Installed" is the set the metadata write path already resolves a base against.** The arm asks `resolveWritePackageScope`, the registry's package read that the runtime authoring gate uses for its package closure. It is not the loader's managed set alone: a tenant's own writable base, created through the package door and rehydrated from the package store at boot, is installed and ships no flow. No new registry read and no second list of packages. - **Whatever the definition carries.** Both branches that used to admit the save now refuse it: a definition with no provenance of its own, and one whose provenance names that same missing package. - **Order.** A shipped flow is still refused as a locked base first (#20679's check, reused). With the operator's writable-types hatch open, the lock admits a shipped flow, and a missing base is still refused: the hatch unlocks a type, never a binding. - **Scope.** `flow` only, as the #20761 ruling's rule 5 requires. Every other type keeps its old handling. The `/automation` create, update and clone doors name no base, so they do not move. The two server-stated rewrites (stored-metadata migration and package duplication) are not judged by the rule, as before. - **The stored-row sentinel is not a package.** A save naming it is a package-less save, admitted as before. ## Hypotheses from the dispatch, measured - **H1 held.** Measured on `origin/main` `31c39964fc` against the unmodified rule, with an environment id and without. A definition with no provenance returns `null` at the "body not code-shipped" branch. A definition whose provenance names the same missing id returns `null` at the "named base equals the stamp" branch. The ADR-0070 D1 gate further down then admits the save, because its writability predicate reads an unregistered id as a writable authoring workspace. At the door, the ablation's mutated leg below re-measured it on the showcase: `200`, state `active`. - **H2 held, with one qualification.** The reader exists: `resolveWritePackageScope` (registry `getPackage`), reused inside the rule. The qualification is that its `undefined` covers both "the registry does not hold this id" and "the registry cannot be read" (a registry with no package read at all, or one that throws). So on such a registry a named base is refused: the fail-closed direction, the same one the automation engine takes with no loader's-set reader. Every real composition's `SchemaRegistry` has the read. This is written down in the rule's docblock. - **H3: `WRITABLE_PACKAGE_REQUIRED` / 422.** Ledger row: `packages/spec/src/api/error-code-ledger.zod.ts:634`, in the `@objectstack/metadata-protocol` block (line 590). No code is minted. ADR-0070 D1 decided this code for exactly this condition: a runtime create whose resolved base is missing or read-only. Its remedy is the one this caller needs: choose or create a writable base, or name none. `INVALID_METADATA` (line 610, same block) was rejected because the definition may be perfectly valid; what is wrong is the base the request names, not a key in the body. The sentence is new, because the D1 emitter's sentence says "read-only", which is false for a package that does not exist. The refusal carries the refused id and the ADR-0070 docs pointer, as that emitter's does. - **H4 measured, one topology NOT MEASURED.** The in-repo environment kernel is the standalone stack (`createStandaloneStack`, environment id `env_local`). One-shot boot, not kept as a file, through the kernel's protocol service: both definition shapes answered `WRITABLE_PACKAGE_REQUIRED/422`, 0 metadata rows, registry item absent. The two controls saved `active`: no base named, and a base installed through `installPackage`. The unit pins also run every case with an environment id. The cloud's per-environment kernel manager is not in this repository: NOT MEASURED. - **H5 held and pinned.** Unit level: the store's insert and the registry's `registerItem` are never reached, for published and drafted saves, on both topologies. Door level: 0 `sys_metadata` rows under the name, the metadata read answers `404`, and the automation read answers `404` (the engine never armed it). ## Pins - `packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts`, a new describe block with 7 cases: the refusal across 5 definition shapes and both topologies, plus the plural type spelling; nothing written or registered on published and drafted saves; three controls (no base or the sentinel passes, an installed base passes, a shipped flow is a locked base first whatever base is named); the hatch case; and another type left untouched. The rule's registry double now serves the registry's package read. - **One door-level pin** on the showcase host-config boot, in the existing `packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts`: one `it`, so no second boot. Triage's door-level controls are the cases already in that file: a customer flow with no base saves, one in the tenant's installed base saves, and a shipped flow is refused as a locked base. - **Fixtures re-judged, because they saved flows into a package their registry never held.** - `protocol-publish-drafts-advisories`, `protocol-publish-drafts-closure` and `protocol.publish-item-rebind-announce` now declare their base installed, with no namespace and no dependencies. The prefix pre-flight and the closure are unchanged, and no assertion moves. - `protocol.package-closure-gate`'s "narrows nothing when the registry cannot produce the written package" pinned the very branch this change shuts for a flow save. It now reaches that state the way it still arises: a draft promoted after its package left the registry, beside the installed control, which reports. ## Evidence All runs are on HEAD `e201d770b2` unless noted. - metadata-protocol, full suite at `f051bba0e2`: 194 files passed and 3 skipped; 2886 tests passed and 19 skipped; exit 0. `typecheck`: exit 0. The one file changed after that, `protocol.tenant-authored-write.test.ts`, re-ran at `e201d770b2`: 19/19, and `typecheck` exit 0. - Consumers, because the wire answer changed: - objectql full suite: 349/349 files, 6812/6812 tests. - runtime full suite: 300/300 files, 5000 passed and 5 skipped. - rest full suite: 245/245 files, 4878 passed and 114 skipped. - These three ran at `eb25706394`, before the #20942 merge. - At `f051bba0e2`: - dogfood, 4 flow files (this pin's file, #20942's `flow-shipped-name-stored-row-boot`, the clone door and the durable doors): 32/32. - runtime, 6 automation and `/meta` files: 312/312. - objectql's publish-conformance file: 15/15. - `typecheck` exit 0 for objectql, including its test-layer check, and for dogfood. - **Red/green ablation of the new arm** at `e5914e3f2c`, through `scripts/ablation-replace.mjs`, whose restore is armed on exit: - The mutation made the arm's condition unsatisfiable. On disk: marker count 1, guard text count 0. Both dists that carry the arm were rebuilt, `@objectstack/metadata-protocol` and `@objectstack/rest` (rest bundles a copy). `ablation-dist-preflight` found the marker in both dists. - Mutated leg: the unit file read 3 failed, 16 passed of 19 (the three refusal cases). The dogfood file read 1 failed, 13 passed of 14: the new pin, answering `200` with state `active`. - Restore: the blob equals HEAD, `git diff HEAD` is empty, and `git status --porcelain` is empty. After rebuilding both, `--absent` passed for both dists. The files read 19/19 and 14/14. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands` derived 68 commands at `e201d770b2` from a tree that was not stale. All 68 were run, and every one exited 0. - `--ran` reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. - Two first readings were not measurements and were re-run: - `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (exit 3), because 8 packages outside this closure had no `dist/`. They were built, and it exited 0. - `check:engine-double-contract` refused an `update` double this PR had added to the rule's test. The double was dropped rather than growing the pinned ledger, and the gate exited 0. - `check:adr-0087-registration`: one declared-breaking changeset, disposition `not-required (no-migration-prescription)`. - `pnpm lint`: a proven narrowing, not a full run. - (1) Population: all 8 changed `.ts` files are under `packages/**`, which the `packages/**/*.{ts,tsx,mts,cts}` and `**/*.{ts,…}` blocks of `eslint.config.mjs` lint. - (2) `eslint --no-inline-config --format json` over those files: 8 files, 0 errors, 0 warnings, at `e201d770b2`. - (3) Invariance: the config never enables type-aware linting (`eslint.config.mjs` lines 326-328), so a verdict on an untouched file cannot move. - CI: not awaited. ## Changeset `.changeset/20863-orphan-package-binding-refused.md`: `@objectstack/metadata-protocol` `minor`, **BREAKING** under the launch-window convention, following PR #20907's shape. It carries one ADR-0087 marker and a line telling the caller what to send instead: an installed base, or no base. The dogfood package is private. The objectql change is a test file, which ships nothing. ## Declared deviations - **Outside the claim's file surface:** `packages/objectql/src/publish-package-drafts-response-conformance.test.ts`, +4 lines, in a separate commit (`eb25706394`) that can be dropped on its own. - Its harness staged flow drafts into a package its real registry never held, so any implementation of the ruling turns 5 of its cases red. It now installs that base, with no namespace. No assertion moves, and the file reads 15/15. - The claim did not name it. It is declared here, not taken silently. - The door-level pin went into the existing #20761 dogfood file rather than a new file, to avoid a second showcase boot in CI. - `main` was merged twice (no rebase). The second merge brought #20942 (`75519e1c0a`), which edits `protocol.ts` near this rule. Git merged it cleanly, and both changes are present. ## Acceptance notes (noted, not filed) - For every type other than `flow`, the metadata door still stores a row bound to a package id no installed package holds. The ADR-0070 writability predicate reads an unregistered id as a writable authoring workspace. This is kept deliberately: the #20761 ruling's rule 5 leaves other types unchanged. Carrier: none. - A host with no package store loses a runtime-created base from the registry at restart. That is an existing degradation, and `installPackage` states it loudly. After this change, a flow save naming such a lost base is refused rather than stored bound to it. Not measured. Carrier: none. - `@objectstack/rest`'s built `dist/` carries its own copy of this package's protocol code: the new sentence appears there, and rest lists `@objectstack/metadata-protocol` as a devDependency. Observed while scoping the ablation's rebuild. Not investigated further. Carrier: none. - objectql's publish-conformance harness calls a registry method that `SchemaRegistry` does not declare (0 hits in `registry.ts`), behind optional chaining, so those two calls do nothing. This is a reading, not measured. Carrier: none. - The `protocol.ts` ADR anchor does not mention the new named-base arm. It was not added, because the anchor file is outside the claim's surface. The rule's docblock cites ADR-0070 D1 and ADR-0126 §2. Carrier: none. ## NOT MEASURED - The cloud per-environment kernel manager: it is not in this repository. H4's in-repo environment kernel was measured, as above. - Studio's round trip: objectui is not in this container. The server-side refusal is what Studio receives. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ff5ca47 commit b1aee33

9 files changed

Lines changed: 266 additions & 31 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: a flow saved through the metadata door naming, as its base, a package this deployment has not installed is refused, instead of being stored bound to a package that does not exist (#20863)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which flow saves the metadata door accepts: a save that names a package no installed package holds is now refused. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. `PUT /api/v1/meta/flow/:name` answered `200` and stored the flow live when the save named, as the package the flow belongs to, a package id this deployment has never installed. It now answers `422 WRITABLE_PACKAGE_REQUIRED`, and nothing is written, served or registered.
12+
13+
**What changed.** The one authoring rule every flow write door asks (`tenantAuthoredWriteRefusal`) now also judges the base a save names. After the locked-base refusal and before the provenance check, a named base must be a package the registry holds as installed: a code package, an installed package, or a tenant's own writable base created through the package door. That is the same registry read the metadata write path already resolves a base against, so no second list of packages is kept. The refusal does not depend on what the definition carries: a save with no provenance of its own and a save whose provenance names that same missing package were both stored before, and both are refused now. It applies on a single-kernel host and on an environment kernel alike.
14+
15+
- The code is `WRITABLE_PACKAGE_REQUIRED` / `422`, the one ADR-0070 D1 already uses for a runtime create whose base is missing or read-only. The refusal names the package id the save sent.
16+
- **Unchanged:** a flow saved without naming a package; a flow saved into an installed package; the locked-base refusal of a shipped flow, which still answers first; every other metadata type, whose saves keep their old handling; the `/automation` create, update and clone doors, which name no package; and the server-stated rewrites of stored rows (the stored-metadata migration and package duplication), which the rule does not judge.
17+
18+
**What to send instead.** Save the flow into a package this deployment has installed (the package list shows them, and a base that does not exist yet is created first through the package door), or save the flow without naming a package.

‎packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,11 @@ function makeStubEngine() {
164164
registry: {
165165
registerItem: () => {},
166166
registerObject: () => {},
167-
// No declared package namespace → publishPackageDrafts skips the
167+
// The base the drafts are bound to is INSTALLED — a flow may be
168+
// saved only into a package the registry holds (#20863) — and its
169+
// manifest declares no namespace → publishPackageDrafts skips the
168170
// ADR-0028 prefix check (legacy-grandfathered path).
169-
getPackage: () => undefined,
171+
getPackage: (id: string) => (id === PKG ? { manifest: { id: PKG } } : undefined),
170172
},
171173
};
172174
return { engine, rows, historyRows };

‎packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,13 @@ function makeStubEngine(options?: { liveObjects?: unknown[]; livePermissions?: u
216216
if (type === 'permission') return options?.livePermissions ?? [liveReadonlySet];
217217
return [];
218218
},
219-
// No declared package namespace → the ADR-0028 prefix pre-flight is
220-
// skipped (legacy-grandfathered path), and `resolveWritePackageScope`
221-
// narrows nothing.
222-
getPackage: () => undefined,
219+
// The base the drafts are bound to is INSTALLED — a flow may be
220+
// saved only into a package the registry holds (#20863) — and its
221+
// manifest declares no namespace → the ADR-0028 prefix pre-flight is
222+
// skipped (legacy-grandfathered path); it declares no dependencies
223+
// and no live object carries a package stamp, so
224+
// `resolveWritePackageScope`'s closure removes nothing.
225+
getPackage: (id: string) => (id === PKG ? { manifest: { id: PKG } } : undefined),
223226
},
224227
};
225228
return { engine, rows, historyRows };

‎packages/metadata-protocol/src/protocol.package-closure-gate.test.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,12 @@ function makeStubEngine() {
164164
getPackage: (id: string) => packages[id],
165165
},
166166
};
167-
return { engine, rows };
167+
return { engine, rows, packages };
168168
}
169169

170170
function makeProtocol() {
171-
const { engine, rows } = makeStubEngine();
172-
return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any, rows };
171+
const { engine, rows, packages } = makeStubEngine();
172+
return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any, rows, packages };
173173
}
174174

175175
const save = (protocol: any, item: any, extra: Record<string, unknown> = {}) =>
@@ -238,13 +238,23 @@ describe('the write package closure reaches the gate (#9612)', () => {
238238
// Its `manifest.dependencies` is the ONLY declaration of what it may
239239
// reference; without it there is no bound, and an unbounded closure is
240240
// the whole collection.
241-
const { protocol } = makeProtocol();
241+
//
242+
// [#20863] A flow SAVE can no longer name a package the registry does
243+
// not hold — the one authoring rule refuses it before this gate runs —
244+
// so the state is reached the way it still arises: a draft stored into
245+
// an installed package, promoted after that package has left the
246+
// registry. The promotion names the draft's base, as a package publish
247+
// does; the same promotion with the package still installed is the
248+
// discriminating control.
249+
const promoted = async (uninstall: boolean) => {
250+
const { protocol, packages } = makeProtocol();
251+
await save(protocol, flowOn('crm_on_stranger', 'other_widget'), { packageId: OWN, mode: 'draft' });
252+
if (uninstall) delete packages[OWN];
253+
return protocol.publishMetaItem({ type: 'flow', name: 'crm_on_stranger', packageId: OWN });
254+
};
242255

243-
const result = await save(protocol, flowOn('crm_on_stranger', 'other_widget'), {
244-
packageId: 'com.never.installed',
245-
});
246-
247-
expect(triggerAdvisories(result)).toEqual([]);
256+
expect(triggerAdvisories(await promoted(false))).toEqual(['flow-trigger-unknown-object']);
257+
expect(triggerAdvisories(await promoted(true))).toEqual([]);
248258
});
249259

250260
it('narrows NOTHING for the `sys_metadata` overlay sentinel', async () => {

‎packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,11 @@ function makeStubEngine() {
158158
registry: {
159159
registerItem: () => {},
160160
registerObject: () => {},
161-
// No declared package namespace → the ADR-0028 prefix check is
161+
// The base the drafts are bound to is INSTALLED — a flow may be
162+
// saved only into a package the registry holds (#20863) — and its
163+
// manifest declares no namespace → the ADR-0028 prefix check is
162164
// skipped (legacy-grandfathered path).
163-
getPackage: () => undefined,
165+
getPackage: (id: string) => (id === PKG ? { manifest: { id: PKG } } : undefined),
164166
},
165167
};
166168
return { engine, rows, historyRows };

‎packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts‎

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,21 @@
1515
* base the write itself names;
1616
* - everything else is admitted, and every type but `flow` is untouched.
1717
*
18-
* The registry double serves only `getArtifactItem` — what the real
19-
* `SchemaRegistry` returns for an artifact a code package registered. The
20-
* engine double serves only `findOne` over `sys_metadata` rows (and records
21-
* `insert`, which a refused save must never reach). `@objectstack/objectql`
22-
* cannot be imported here: it depends on this package.
18+
* [#20863] …and a flow saved naming, as its base, a package no installed
19+
* package holds is refused `WRITABLE_PACKAGE_REQUIRED` / 422 (ADR-0070 D1: the
20+
* resolved target is missing) — whatever stamps its body carries, on both
21+
* topologies — before anything is written or registered. The controls: a flow
22+
* naming no package passes, a flow naming an installed package passes, and a
23+
* shipped flow is still refused as a locked base first.
24+
*
25+
* The registry double serves `getArtifactItem` — what the real
26+
* `SchemaRegistry` returns for an artifact a code package registered — and
27+
* `getPackage`, what it returns for an installed package (the read the `/meta`
28+
* write path resolves a base against); `registerItem` is recorded, and a
29+
* refused save must never reach it. The engine double serves only `findOne`
30+
* over `sys_metadata` rows (and records `insert`, which a refused save must
31+
* never reach either). `@objectstack/objectql` cannot be imported here: it
32+
* depends on this package.
2333
*/
2434
import { afterEach, describe, expect, it, vi } from 'vitest';
2535
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
@@ -49,13 +59,29 @@ const ARTIFACTS = new Map<string, Map<string, unknown>>([
4959
/** The stamps a caller would send to claim the real package's provenance. */
5060
const ASSERTED = { _packageId: PACKAGE_ID, _provenance: 'package' };
5161

62+
/** A tenant's own writable base — installed, so a save may name it. */
63+
const TENANT_BASE = 'com.tenant.base';
64+
/** A package id no installed package has. */
65+
const ORPHAN = 'com.nowhere.pkg';
66+
67+
/** What the registry holds as installed: the code package and the tenant's base. */
68+
const INSTALLED = new Map<string, unknown>([
69+
[PACKAGE_ID, { manifest: { id: PACKAGE_ID, scope: 'system' } }],
70+
[TENANT_BASE, { manifest: { id: TENANT_BASE, scope: 'project' } }],
71+
]);
72+
5273
interface StoredRow { type: string; name: string; package_id: string | null }
5374

5475
function protocolWith(opts: { rows?: StoredRow[]; findOne?: () => Promise<unknown>; environmentId?: string } = {}) {
5576
const rows = opts.rows ?? [];
5677
const insert = vi.fn(async () => ({ id: 'never' }));
78+
const registerItem = vi.fn();
5779
const engine = {
58-
registry: { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) },
80+
registry: {
81+
getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name),
82+
getPackage: (id: string) => INSTALLED.get(id),
83+
registerItem,
84+
},
5985
findOne: opts.findOne ?? (async (table: string, query: { where: Record<string, unknown> }) => {
6086
assertEngineFindOnePredicate(table, query);
6187
if (table !== 'sys_metadata') return null;
@@ -64,7 +90,7 @@ function protocolWith(opts: { rows?: StoredRow[]; findOne?: () => Promise<unknow
6490
insert,
6591
};
6692
const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map(), opts.environmentId);
67-
return { protocol, insert };
93+
return { protocol, insert, registerItem };
6894
}
6995

7096
const shape = (e: any) => (e ? { code: e.code, status: e.status } : null);
@@ -199,3 +225,87 @@ describe('saveMetaItem applies the rule to a flow, before anything is written',
199225
expect(await ask({ writeFace: 'package-duplicate' })).toBe(0);
200226
});
201227
});
228+
229+
describe('a flow saved naming, as its base, a package no installed package holds is refused', () => {
230+
const refusalFor = (protocol: ObjectStackProtocolImplementation, request: Record<string, unknown>) =>
231+
protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'orphan_flow', item: flowBody('orphan_flow'), ...request } as never);
232+
233+
it('WRITABLE_PACKAGE_REQUIRED / 422, whatever stamps the body carries, on both topologies', async () => {
234+
for (const environmentId of [undefined, 'env_1']) {
235+
const { protocol } = protocolWith({ environmentId });
236+
for (const stamps of [
237+
{},
238+
{ _packageId: ORPHAN },
239+
{ _packageId: ORPHAN, _provenance: 'package' },
240+
{ _packageId: ORPHAN, _provenance: 'org' },
241+
ASSERTED,
242+
]) {
243+
const refusal: any = await refusalFor(protocol, { item: flowBody('orphan_flow', stamps), packageId: ORPHAN });
244+
expect(shape(refusal), JSON.stringify({ environmentId, stamps })).toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 });
245+
expect(refusal.packageId).toBe(ORPHAN);
246+
}
247+
// The type is folded at the producer: a plural spelling cannot address around it.
248+
expect(shape(await refusalFor(protocol, { type: 'flows', packageId: ORPHAN })))
249+
.toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 });
250+
}
251+
});
252+
253+
it('saveMetaItem throws it before anything is written or registered — published or drafted, on both topologies', async () => {
254+
for (const environmentId of [undefined, 'env_1']) {
255+
for (const mode of [undefined, 'draft'] as const) {
256+
const { protocol, insert, registerItem } = protocolWith({ environmentId });
257+
const thrown: any = await protocol.saveMetaItem({
258+
type: 'flow', name: 'orphan_flow', item: flowBody('orphan_flow'), packageId: ORPHAN,
259+
...(mode ? { mode } : {}),
260+
}).then(() => undefined, (e) => e);
261+
262+
expect(shape(thrown), JSON.stringify({ environmentId, mode })).toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 });
263+
expect(insert).not.toHaveBeenCalled();
264+
expect(registerItem).not.toHaveBeenCalled();
265+
}
266+
}
267+
});
268+
269+
it('control: a flow naming no package passes this rule, and the stored-row sentinel names no package', async () => {
270+
const { protocol } = protocolWith();
271+
for (const packageId of [undefined, null, 'sys_metadata']) {
272+
expect(await refusalFor(protocol, { packageId }), String(packageId)).toBeNull();
273+
}
274+
});
275+
276+
it('control: a flow naming an installed package passes this rule, with or without its binding echoed', async () => {
277+
const { protocol } = protocolWith();
278+
expect(await refusalFor(protocol, { packageId: TENANT_BASE })).toBeNull();
279+
expect(await refusalFor(protocol, { item: flowBody('orphan_flow', { _packageId: TENANT_BASE }), packageId: TENANT_BASE })).toBeNull();
280+
// A read-only installed package is installed: whether it is a writable
281+
// base is the ADR-0070 D1 gate's question further down the save, never this rule's.
282+
expect(await refusalFor(protocol, { packageId: PACKAGE_ID })).toBeNull();
283+
});
284+
285+
it('control: a shipped flow is refused as a locked base first, whatever base the save names', async () => {
286+
for (const environmentId of [undefined, 'env_1']) {
287+
const { protocol } = protocolWith({ environmentId });
288+
const served = ARTIFACTS.get('flow')!.get('pkg_flow');
289+
for (const packageId of [ORPHAN, PACKAGE_ID]) {
290+
const refusal: any = await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId });
291+
expect(refusal?.status, packageId).toBe(403);
292+
expect(['NOT_OVERRIDABLE', 'ITEM_LOCKED']).toContain(refusal.code);
293+
}
294+
}
295+
});
296+
297+
it('with the operator hatch open a shipped flow passes the lock, and a base no installed package holds is still refused', async () => {
298+
process.env.OS_METADATA_WRITABLE = 'flow';
299+
ObjectStackProtocolImplementation.resetEnvWritableCache();
300+
const { protocol } = protocolWith();
301+
const served = ARTIFACTS.get('flow')!.get('pkg_flow');
302+
expect(shape(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId: ORPHAN })))
303+
.toEqual({ code: 'WRITABLE_PACKAGE_REQUIRED', status: 422 });
304+
expect(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served, packageId: PACKAGE_ID })).toBeNull();
305+
});
306+
307+
it('every other metadata type is untouched — a view naming the same base is not this rule\'s to judge', async () => {
308+
const { protocol } = protocolWith();
309+
expect(await protocol.tenantAuthoredWriteRefusal({ type: 'view', name: 'customer_view', item: { name: 'customer_view' }, packageId: ORPHAN })).toBeNull();
310+
});
311+
});

0 commit comments

Comments
 (0)