Skip to content

Commit 76bd58f

Browse files
fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) (#20853)
Fixes #20761 Clause-②: yes (widening) ## What this does Stage 2 of #20761, under the maintainer's ruling B + A recorded in `5904938166`. The platform now trusts only a fact it holds itself about which flows are packaged. For flows, "packaged" means exactly "loaded by the loader from a managed package". 1. **The engine reads the loader's set, not the body (ruling point 1).** The automation engine no longer classifies a flow by the provenance stamps its own definition carries. Every classification reads one reader over the loader's set: the ADR-0126 §7.3 subflow guards in both directions, the arming gate, the activation door, and the package an activation row is attributed to. The automation plugin attaches that reader. It asks the metadata protocol at question time, with nothing cached at boot, so it follows the registry across install, upgrade and reload exactly as the locked-base verdict does. The body's stamps are kept for display only. 2. **One shared rule on every flow write door (ruling point 2).** It lives in `@objectstack/metadata-protocol`, beside the #20679 locked-base verdict. It is asked by the automation create door, the update door, the clone door and the `/meta` flow write. - A name the loader's set holds is refused as a locked base. That is #20679's `packagedBaseRefusal`, reused, not re-implemented. So a round trip of a shipped flow is refused. - Any other name, with a body whose stamps would classify it as code-shipped, is refused loudly: `422 INVALID_METADATA`, a code already registered to this package in the ADR-0112 ledger. No new code is minted. Nothing is written or registered. - A tenant row's own stamps, echoed on a round trip, agree with the server's fact and are a no-op (see "Round trips" below for the one case this had to measure). 3. **Clone (ruling point 3).** The copy still drops the base's whole protection envelope. `flow-clone.ts` already did that, and the envelope drop is pinned in `automation-flow-clone.test.ts`. The copy is now also judged by the same rule and **saved as a tenant row** through the metadata protocol's own save, env-wide. It reads back on the metadata door and survives a cold boot. The engine registers the copy first and the store saves it second, and a save that fails withdraws the registration, so no clone is reported that would not survive. 4. **Scope (ruling point 5).** `/meta`'s handling of every other metadata type is unchanged: the rule answers nothing for them. The two server-stated rewrites of stored rows (stored-metadata migration, package duplication) are not authoring doors and keep their old handling. ## Declared cross-lane touch The card is `domain:cli`, and its entry is the automation doors in `packages/runtime`. The claim (`5909772878`) declares two cross-lane surfaces, and this PR stays inside them: - `domain:engine`: `metadata-protocol` (`packages/metadata-protocol/src/**`). The shared rule and the loader's-set read, both new public members, and `/meta`'s flow write applying the rule. - `domain:services`: `service-automation` (`packages/services/service-automation/src/**`). The engine's classification reads the loader's set, and the plugin wiring hands it over. Nothing under `packages/spec/**`, `packages/objectql/**` or `packages/metadata-core/**` is touched. ## The seam | role | file | symbol | |:--|:--|:--| | loader's-set read | `packages/metadata-protocol/src/protocol.ts` | `ObjectStackProtocolImplementation.packagedArtifactOwner` | | the one authoring rule | `packages/metadata-protocol/src/protocol.ts` | `ObjectStackProtocolImplementation.tenantAuthoredWriteRefusal` (the lock branch calls `packagedBaseRefusal`) | | `/meta` applies it | `packages/metadata-protocol/src/protocol.ts` | `saveMetaItem`, before the stamps are stripped | | automation doors apply it | `packages/runtime/src/domains/automation.ts` | `refuseUnauthoredFlowWrite` (`POST /`, `PUT /:name`, clone) | | clone saved as a tenant row | `packages/runtime/src/domains/automation.ts` | clone arm → `protocol.saveMetaItem` | | engine reads the set | `packages/services/service-automation/src/engine.ts` | `setPackagedFlowSource`, `packagedFlowOwner` (every former body-stamp site) | | wiring | `packages/services/service-automation/src/plugin.ts` | `packagedFlowReader` | ## Pins (ruling point 4) | pin | where | |:--|:--| | a disagreeing assertion is refused on create and on update (`422 INVALID_METADATA`), nothing registered or written | runtime `automation-tenant-authored-write.test.ts`; dogfood | | a round trip of a shipped flow is refused as a locked base (`403 NOT_OVERRIDABLE`) on both doors | runtime; metadata-protocol `protocol.tenant-authored-write.test.ts`; dogfood | | a clone of a shipped flow is saved as a tenant row: it reads back on the metadata door and survives a cold boot on the same database file | runtime (the save and its rollback); dogfood (read-back and cold boot) | | a round trip of a customer flow is accepted unchanged, on both doors | runtime; metadata-protocol; dogfood | | the §7.3 guards and the toggle door treat a customer flow as customer-authored, whatever its body's stamps say | service-automation `packaged-flow-source.test.ts`; dogfood | | no activation row is attributed to a package from an authoring path | service-automation (a row is attributed to the package the set names, never the one the stamps name); dogfood (ledger read, no row) | | `/meta`'s flow write applies the same rule | metadata-protocol; dogfood | | control: `/meta` on another type keeps its old handling | metadata-protocol; dogfood | At least one pin runs through the real HTTP composition: `packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts` boots the showcase through `bootStack` with a database file, and cold-boots it once. **Ablation, run once and not kept.** The five fix sources were reverted to the merge base on the working tree, and the three packages were rebuilt. Each marker was proved absent from `dist/` with `scripts/ablation-dist-preflight.mjs --absent`. The dogfood file then read **8 failed / 5 passed of 13**. The five that stay green both ways are the preservation pins: the two locked-base refusals (#20679's), the two `/meta` round trips, and the other-type control. After the restore, `git status --porcelain` was empty, the markers were present in `dist/` again, and the file read **13 / 13**. A first attempt left `@objectstack/service-automation`'s build red: its barrel still exported the new type. That meant `@objectstack/runtime`'s `dist/` was never rebuilt, so the first reading was void. It was redone with the barrel reverted too. ## Round trips (the triage direction `5903721942`) - **Studio: NOT MEASURED.** The Studio source is in `objectui`, which is not in this container. What Studio receives was measured on the showcase boot: the served document on both read doors. - A customer flow created through the automation door is served with no provenance stamps. - A customer flow stored through `/meta` is served with none either. - A shipped flow is served with its package's stamps. - **A customer flow stored bound to a package is served with that binding surfaced as a package stamp and no tenant marker.** The canonical classifier alone reads that as code-shipped, so a literal "stamps classify it as code-shipped ⇒ refuse" rule would have broken this legitimate round trip (measured: 422 before the edge was fixed). The rule therefore also asks the server's own fact behind the stamp: the package the stored row of that name is bound to, or the base the write itself names. That round trip is pinned on both doors, including across a cold boot. - **CLI:** `os meta register` sends a file's contents verbatim to `PUT /meta/:type/:name` (`packages/cli/src/commands/meta/register.ts:59-76`), and `os meta get` prints the served document. So a get → register round trip is the `/meta` round trip above: a customer flow is accepted, a package-bound customer flow is accepted, and a shipped flow is refused as a locked base. The CLI adds no stamps of its own. ## Verification (at `f0de8fe69`, after merging `origin/main` `30839063b`) - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 192 files passed, 3 skipped; 2813 tests passed, 19 skipped. - `pnpm --filter @objectstack/service-automation exec vitest run`: 158 / 158 files, 1984 / 1984 tests. - `pnpm --filter @objectstack/runtime exec vitest run`: 298 / 298 files, 4961 passed, 1 skipped. - dogfood: `flow-provenance-server-held`, `packaged-flow-write-door-parity`, `automation-flow-clone-door`, `automation-toggle-tenant-scope` and `packaged-activation-ledger-reach` passed, 42 / 42. - `typecheck`: `metadata-protocol`, `service-automation` (with `check:test-typecheck`), `runtime` and `dogfood`, all exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: all 69 derived families were run, each with its exit code recorded. `--ran` reconciles 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. - `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone. It exited 0 after deepening, as it prescribes. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET: eight unrelated packages had no `dist/`. It exited 0 after they were built. - **Lint, as a proven narrowing, not a full `pnpm lint`:** 1. Population: every changed TypeScript file sits under `packages/**`, which the `packages/**/*.{ts,…}` and `**/*.{ts,…}` blocks of `eslint.config.mjs` lint. 2. `eslint --no-inline-config --format json` over the 23 changed files: 23 files, 0 errors, 0 warnings. 3. Invariance: the config never enables type-aware linting (`eslint.config.mjs:327-328`), so this diff cannot move a verdict on any untouched file. ## Acceptance notes - **The automation create and update doors still persist nothing (not built here, as the claim allowed).** Measured on the showcase boot with a database file: - a flow created through the create door answers `200` and reads `404` after a cold boot; - an update through the update door, on a flow stored through `/meta`, answers `200`, and the stored definition wins after a cold boot. The clone's save does not cover these doors for free: making them durable changes both doors' contract. Named in the report; no carrier. - **A `/meta` flow save naming a package id no package has answers `200`** on the showcase's host-config topology. Measured, out of scope. Named in the report. - **The loader's set is the registry's view, for both the lock and the engine.** They read one source, so they cannot disagree. A dev-time artifact reload that adds a code flow is not re-registered into that view until restart (source reading, not measured). That is the same for the #20679 lock. - The engine used to count a flow stored bound to a package as packaged at boot, because its served definition carries that binding as a package stamp (see "Round trips"). It now reads the loader's set, so that misclassification is gone too. --- _Generated by [Claude Code](https://claude.ai/code/session_016SuKjJJSq2iLTm4Z2PbAmY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b531c7b commit 76bd58f

24 files changed

Lines changed: 1399 additions & 70 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(automation): which flows are packaged is the package loader's fact, never the flow definition's own, and every flow written through an authoring door is authored in the deployment (#20761)
8+
9+
Clause-②: yes (widening)
10+
11+
A flow counts as packaged only when a managed package's loader registered it (ADR-0126 §2, ADR-0131 D6). Before this change, a flow definition written through an authoring door could carry a code package's provenance, and the automation engine then treated that flow as the package's.
12+
13+
- **The automation engine reads the loader's set.** The ADR-0126 §7.3 subflow guards, the arming gate, the activation switch and the package an activation row names now come from the packages the loader registered. The provenance a flow definition carries is kept for display only. `AutomationEngine` gains `setPackagedFlowSource(reader)` and `packagedFlowOwner(name)`, and the package exports the `PackagedFlowSource` type. `AutomationServicePlugin` attaches the reader for you: it asks the metadata protocol when the engine needs the answer. An engine with no reader attached treats no flow as packaged.
14+
- **One authoring rule for flows.** `ObjectStackProtocolImplementation` gains two methods. `packagedArtifactOwner({ type, name })` names the package whose loader registered an item. `tenantAuthoredWriteRefusal({ type, name, item, packageId? })` is the rule every flow write door asks: the automation create, update and clone doors, and the metadata door's flow write.
15+
- A write to a name a package ships is refused as a locked base. The answer is `packagedBaseRefusal`'s own (`403 NOT_OVERRIDABLE`), so sending a shipped flow's definition back is refused.
16+
- A definition that claims a code package's provenance for a name no package ships is refused with `422 INVALID_METADATA`, and nothing is written. Before, the automation doors kept the claim and the metadata door removed it without saying so.
17+
- A customer flow's definition sent back as it was read is accepted as before. That includes a stored flow bound to one of your own packages, whose read carries that binding.
18+
- `packagedBaseRefusal` also takes an optional `packageId`, the base a save names.
19+
- **The metadata door's other types are unchanged.** Only flows are judged by this rule. Migrating stored rows and duplicating a package are not affected either.
20+
- **A clone is saved.** `POST /automation/:name/clone` now writes its copy as a stored flow of the deployment, through the metadata protocol's save, with no package provenance. The copy reads back on the metadata door and is still there after a restart. Before, it lived only in the running engine and was gone after a restart. If the save fails, the clone is withdrawn and the failure is returned.
21+
22+
**If a write of yours is now refused with `422 INVALID_METADATA`:** remove the package provenance from the flow definition and send it again. To customize a packaged flow, clone it under a new name.

‎packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise<unknown>) {
210210
}
211211

212212
describe('#9157 — the population, re-derived from the code rather than from the card', () => {
213-
it('every `/meta` request-boundary verb with a required `type` calls the fold — all fourteen', () => {
213+
it('every `/meta` request-boundary verb with a required `type` calls the fold — all sixteen', () => {
214214
// ⭐ The card hand-listed "nine fold, three do not". Hand-listed sets of
215215
// this shape have shipped short before, so the set is DERIVED here and
216216
// the derivation is the pin: a tenth verb arriving unfolded turns this
@@ -280,12 +280,16 @@ describe('#9157 — the population, re-derived from the code rather than from th
280280
// in-process caller hands it a type spelling too.
281281
'getMetaItemsForExecution',
282282
'historyMetaItem',
283+
// [#20761] The loader's-set read and the one authoring rule every
284+
// flow write door asks (below) — both take the type a caller names.
285+
'packagedArtifactOwner',
283286
// [#20679] The locked-base verdict a second write door asks — it
284287
// takes the type a caller names, so it folds at the producer too.
285288
'packagedBaseRefusal',
286289
'publishMetaItem',
287290
'rollbackMetaItem',
288291
'saveMetaItem',
292+
'tenantAuthoredWriteRefusal',
289293
]);
290294
});
291295

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20761, ADR-0126 §2, ADR-0131 D6] `tenantAuthoredWriteRefusal` — the ONE
5+
* authoring rule every flow write door asks (the automation create, update and
6+
* clone doors, and `/meta`), and `packagedArtifactOwner` — the loader's-set read
7+
* it, the locked-base verdict and the automation engine's classification share.
8+
*
9+
* The matrix (the ruling recorded on the card, points 2 and 5):
10+
* - a name the loader's set holds is a locked base — `packagedBaseRefusal`'s
11+
* own answer, reused (so a shipped flow's round trip is refused);
12+
* - any other name, with stamps that would classify the body as code-shipped,
13+
* is refused `INVALID_METADATA` / 422 — unless the stamps agree with the
14+
* server's fact: the package a stored row of that name is bound to, or the
15+
* base the write itself names;
16+
* - everything else is admitted, and every type but `flow` is untouched.
17+
*
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.
23+
*/
24+
import { afterEach, describe, expect, it, vi } from 'vitest';
25+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
26+
import { ObjectStackProtocolImplementation } from './protocol.js';
27+
import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
28+
29+
const PACKAGE_ID = 'com.example.pkg';
30+
31+
const flowBody = (name: string, extra: Record<string, unknown> = {}) => ({
32+
name,
33+
label: name,
34+
type: 'autolaunched',
35+
nodes: [
36+
{ id: 'start', type: 'start', label: 'Start' },
37+
{ id: 'end', type: 'end', label: 'End' },
38+
],
39+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
40+
...extra,
41+
});
42+
43+
/** What the loader registered: one packaged flow and one packaged view. */
44+
const ARTIFACTS = new Map<string, Map<string, unknown>>([
45+
['flow', new Map([['pkg_flow', flowBody('pkg_flow', { _packageId: PACKAGE_ID, _provenance: 'package' })]])],
46+
['view', new Map([['pkg_view', { name: 'pkg_view', _packageId: PACKAGE_ID, _provenance: 'package' }]])],
47+
]);
48+
49+
/** The stamps a caller would send to claim the real package's provenance. */
50+
const ASSERTED = { _packageId: PACKAGE_ID, _provenance: 'package' };
51+
52+
interface StoredRow { type: string; name: string; package_id: string | null }
53+
54+
function protocolWith(opts: { rows?: StoredRow[]; findOne?: () => Promise<unknown>; environmentId?: string } = {}) {
55+
const rows = opts.rows ?? [];
56+
const insert = vi.fn(async () => ({ id: 'never' }));
57+
const engine = {
58+
registry: { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) },
59+
findOne: opts.findOne ?? (async (table: string, query: { where: Record<string, unknown> }) => {
60+
assertEngineFindOnePredicate(table, query);
61+
if (table !== 'sys_metadata') return null;
62+
return rows.find((r) => Object.entries(query.where).every(([k, v]) => (r as any)[k] === v)) ?? null;
63+
}),
64+
insert,
65+
};
66+
const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map(), opts.environmentId);
67+
return { protocol, insert };
68+
}
69+
70+
const shape = (e: any) => (e ? { code: e.code, status: e.status } : null);
71+
72+
afterEach(() => {
73+
delete process.env.OS_METADATA_WRITABLE;
74+
ObjectStackProtocolImplementation.resetEnvWritableCache();
75+
resetEnvWritableMetadataTypes();
76+
});
77+
78+
describe('packagedArtifactOwner — the loader\'s set, read', () => {
79+
it('names the package that ships a flow, and nothing for a name no package ships', () => {
80+
const { protocol } = protocolWith();
81+
expect(protocol.packagedArtifactOwner({ type: 'flow', name: 'pkg_flow' })).toBe(PACKAGE_ID);
82+
expect(protocol.packagedArtifactOwner({ type: 'flows', name: 'pkg_flow' })).toBe(PACKAGE_ID);
83+
expect(protocol.packagedArtifactOwner({ type: 'flow', name: 'customer_flow' })).toBeUndefined();
84+
});
85+
});
86+
87+
describe('tenantAuthoredWriteRefusal — every flow written through an authoring door is tenant-authored', () => {
88+
it('a name the loader\'s set holds is a locked base: the round trip of a shipped flow is refused with the lock\'s own answer', async () => {
89+
for (const environmentId of [undefined, 'env_1']) {
90+
const { protocol } = protocolWith({ environmentId });
91+
const served = ARTIFACTS.get('flow')!.get('pkg_flow');
92+
93+
const refusal: any = await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: served });
94+
const lock: any = protocol.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' });
95+
96+
expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
97+
expect(refusal.message).toBe(lock.message);
98+
}
99+
});
100+
101+
it('with the operator hatch open the lock admits the write, and the body\'s stamps decide nothing', async () => {
102+
process.env.OS_METADATA_WRITABLE = 'flow';
103+
ObjectStackProtocolImplementation.resetEnvWritableCache();
104+
const { protocol } = protocolWith();
105+
expect(await protocol.tenantAuthoredWriteRefusal({ type: 'flow', name: 'pkg_flow', item: flowBody('pkg_flow', ASSERTED) })).toBeNull();
106+
});
107+
108+
it('a body claiming a package\'s provenance for a name no package ships is refused INVALID_METADATA / 422', async () => {
109+
const { protocol } = protocolWith();
110+
const refusal: any = await protocol.tenantAuthoredWriteRefusal({
111+
type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED),
112+
});
113+
expect(shape(refusal)).toEqual({ code: 'INVALID_METADATA', status: 422 });
114+
// A stamp with no provenance key beside it still classifies as code-shipped.
115+
expect(shape(await protocol.tenantAuthoredWriteRefusal({
116+
type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', { _packageId: 'app.anything' }),
117+
}))).toEqual({ code: 'INVALID_METADATA', status: 422 });
118+
});
119+
120+
it('folds the type at the producer — a plural spelling cannot address around the rule', async () => {
121+
const { protocol } = protocolWith();
122+
expect(shape(await protocol.tenantAuthoredWriteRefusal({
123+
type: 'flows', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED),
124+
}))).toEqual({ code: 'INVALID_METADATA', status: 422 });
125+
});
126+
127+
it('a tenant row\'s own stamps are a no-op: tenant provenance, the stored-row sentinel, and no stamps at all', async () => {
128+
const { protocol } = protocolWith();
129+
for (const stamps of [{ _packageId: 'app.crm', _provenance: 'org' }, { _packageId: 'sys_metadata' }, {}]) {
130+
expect(await protocol.tenantAuthoredWriteRefusal({
131+
type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', stamps),
132+
})).toBeNull();
133+
}
134+
});
135+
136+
it('a package stamp that echoes the binding of the stored row of that name agrees with the server, and is a no-op', async () => {
137+
const { protocol } = protocolWith({ rows: [{ type: 'flow', name: 'bound_flow', package_id: 'com.tenant.base' }] });
138+
expect(await protocol.tenantAuthoredWriteRefusal({
139+
type: 'flow', name: 'bound_flow', item: flowBody('bound_flow', { _packageId: 'com.tenant.base' }),
140+
})).toBeNull();
141+
// …and one that names ANOTHER package disagrees with it.
142+
expect(shape(await protocol.tenantAuthoredWriteRefusal({
143+
type: 'flow', name: 'bound_flow', item: flowBody('bound_flow', ASSERTED),
144+
}))).toEqual({ code: 'INVALID_METADATA', status: 422 });
145+
});
146+
147+
it('a package stamp naming the base the write itself names agrees with that write', async () => {
148+
const { protocol } = protocolWith();
149+
expect(await protocol.tenantAuthoredWriteRefusal({
150+
type: 'flow', name: 'new_flow', item: flowBody('new_flow', { _packageId: 'com.tenant.base' }), packageId: 'com.tenant.base',
151+
})).toBeNull();
152+
expect(shape(await protocol.tenantAuthoredWriteRefusal({
153+
type: 'flow', name: 'new_flow', item: flowBody('new_flow', ASSERTED), packageId: 'com.tenant.base',
154+
}))).toEqual({ code: 'INVALID_METADATA', status: 422 });
155+
});
156+
157+
it('a store that cannot be read is re-raised, never turned into a verdict; an unprovisioned one holds no row', async () => {
158+
const down = protocolWith({ findOne: async () => { throw new Error('connect ECONNREFUSED'); } });
159+
await expect(down.protocol.tenantAuthoredWriteRefusal({
160+
type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED),
161+
})).rejects.toMatchObject({ status: 503 });
162+
163+
const missing = protocolWith({ findOne: async () => { throw new Error('no such table: sys_metadata'); } });
164+
expect(shape(await missing.protocol.tenantAuthoredWriteRefusal({
165+
type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED),
166+
}))).toEqual({ code: 'INVALID_METADATA', status: 422 });
167+
});
168+
169+
it('every other metadata type is untouched — the same stamps on a view are not this rule\'s to judge', async () => {
170+
const { protocol } = protocolWith();
171+
expect(await protocol.tenantAuthoredWriteRefusal({ type: 'view', name: 'customer_view', item: { name: 'customer_view', ...ASSERTED } })).toBeNull();
172+
expect(await protocol.tenantAuthoredWriteRefusal({ type: 'view', name: 'pkg_view', item: { name: 'pkg_view', ...ASSERTED } })).toBeNull();
173+
});
174+
});
175+
176+
describe('saveMetaItem applies the rule to a flow, before anything is written', () => {
177+
it('refuses the assertion with the rule\'s own envelope and writes nothing', async () => {
178+
for (const environmentId of [undefined, 'env_1']) {
179+
const { protocol, insert } = protocolWith({ environmentId });
180+
const thrown: any = await protocol.saveMetaItem({
181+
type: 'flow', name: 'customer_flow', item: flowBody('customer_flow', ASSERTED),
182+
}).then(() => undefined, (e) => e);
183+
184+
expect(shape(thrown)).toEqual({ code: 'INVALID_METADATA', status: 422 });
185+
expect(insert).not.toHaveBeenCalled();
186+
}
187+
});
188+
189+
it('asks the rule on an authoring save, and not on the two server-stated rewrites of stored rows', async () => {
190+
const ask = (request: Record<string, unknown>) => {
191+
const { protocol } = protocolWith();
192+
const spy = vi.spyOn(protocol, 'tenantAuthoredWriteRefusal');
193+
return protocol.saveMetaItem({ type: 'flow', name: 'customer_flow', item: flowBody('customer_flow'), ...request } as never)
194+
.then(() => spy.mock.calls.length, () => spy.mock.calls.length);
195+
};
196+
expect(await ask({})).toBe(1);
197+
expect(await ask({ writeFace: 'meta-envelope' })).toBe(1);
198+
expect(await ask({ source: 'migrate-stored' })).toBe(0);
199+
expect(await ask({ writeFace: 'package-duplicate' })).toBe(0);
200+
});
201+
});

0 commit comments

Comments
 (0)