Skip to content

Commit 35afb15

Browse files
fix(metadata-protocol): a packaged item's save answers the package door before the checks that judge its body, on every kernel topology (#22338)
Fixes #22220 Clause-②: no ## What changes `saveMetaItem`'s package door (`refusePackagedBaseOverride`) is now asked on every kernel topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and before every check that reads the body or the store. It used to sit behind `environmentId !== undefined`. A host-config kernel (the CLI's assembler, the showcase's boot shape, `OS_MODE=off`) met the same predicate only at `SysMetadataRepository.assertAllowed`, the first statement of `repo.put`, which is the method's last act. So on that kernel every refusal in between answered first. - `packages/metadata-protocol/src/protocol.ts`: the `environmentId` wrapper around the door is removed. The `_lock` gate's guard `packagedBaseRefusal(...) === null` (its hand-written deferral to the door on host-config) always answered "no refusal" once the door runs first on every kernel, so it is removed. The invariant comment on that gate is extended, and the door's call site records why no acceptance set moves. Two TSDoc paragraphs that said the door is environment-only are corrected. - New pins: `packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts` (one table over both kernel topologies, `code` + `status` per row). - Three downstream test files pinned the old host-config ordering and are updated (below). - `scripts/engine-double-contract.pinned.json`: the new pin file's `findOne` double, recorded by `check-engine-double-contract.mjs --write`. - `.changeset/22220-package-door-before-gates.md`: `@objectstack/metadata-protocol` patch. ## Door table, live (base `4e4111ca05` vs head `9e763ec0bc`, both built and booted before the `main` merge) Seeded admin, default composition, `OS_METADATA_WRITABLE` unset. Bodies: **served** = the `GET` item; **gate-refused** = served plus one autonumber field whose format names a missing field (`autonumber-references-unknown-field`); **spec-refused** = served plus an undeclared top-level key (`unrecognized_keys`). Every cell is `status code`. `examples/app-crm`, `PUT /api/v1/meta/object/crm_account` (packaged under `com.example.crm`; `object` is `allowOrgOverride: false`). Environment kernel = `pnpm dev:crm -- --fresh` (`env_local`). Host-config kernel = the same stack under `OS_MODE=off` (the lightweight assembler, `environmentId` undefined; confirmed by the repository's sentence on the base row). | body, mode | env kernel, base | env kernel, head | host-config, base | host-config, head | |:--|:--|:--|:--|:--| | served, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | | served, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | | gate-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | gate-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | | spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | spec-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | `permission/crm_sales_user`, spec-refused, publish | not measured | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | `position/sales_rep`, spec-refused, publish | not measured | 403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE | | control: env-local `zz_local_obj`, gate-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | | control: env-local `zz_local_obj`, spec-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | At head the host-config 403 carries the environment kernel's sentence: the two `crm_account` gate-refused publish bodies compare byte-equal. At base the host-config 403s carried the repository's sentence (`'object' is not allowOrgOverride in the registry ...`), and a packaged `permission`'s plain publish carried plugin-security's lock sentence. The environment kernel's code path is unchanged by this diff, which is why its two unmeasured base cells are left unmeasured rather than inferred. The card's own composition, `examples/app-showcase` (`pnpm dev -- --fresh`, host-config), `PUT /api/v1/meta/object/showcase_task`: base answered 422 for gate-refused publish and for spec-refused publish and draft, 403 for the rest; head answers 403 `NOT_OVERRIDABLE` for all six rows, and the env-local controls answer 422 `INVALID_METADATA`. ## Door table, unit pins (both kernels; base = `protocol.ts` at the merge base, head = this branch) Measured through the real `saveMetaItem` over an engine double (the new pin file's harness). "gate reached" = `assertRuntimeAuthoringRules` was called. | request | env base | env head | host-config base | host-config head | |:--|:--|:--|:--|:--| | object, served body, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE, gate reached | 403 NOT_OVERRIDABLE | | object, gate-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA, gate reached | 403 NOT_OVERRIDABLE | | object, gate-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE, gate reached | 403 NOT_OVERRIDABLE | | object, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE | | object, spec-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE | | object, `?package=` naming its read-only package, gate-refused, publish | 403 ITEM_LOCKED | 403 ITEM_LOCKED | 422 INVALID_METADATA, gate reached | 403 ITEM_LOCKED | | object, fields dropped (destructive), publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 409 DESTRUCTIVE_CHANGE | 403 NOT_OVERRIDABLE | | position, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE | | permission, spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE | | control: env-local object, gate-refused, publish | 422, gate reached | 422, gate reached | 422, gate reached | 422, gate reached | | control: env-local object, spec-refused, publish | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | | control: packaged view (`allowOrgOverride: true`), spec-refused | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | Registry-wide (a packaged `pkg_TYPE` with a spec-refused body, publish, every type in `DEFAULT_METADATA_TYPE_REGISTRY`): at base the host-config kernel answered differently from the environment kernel for 15 types (`object` 409 `DESTRUCTIVE_CHANGE`; `hook`, `seed`, `mapping`, `page`, `app`, `action`, `dataset`, `datasource`, `doc`, `book`, `permission`, `position`, `tool`, `skill` 422 `INVALID_METADATA`). At head both kernels give the same envelope for every type, and every type the door governs (`allowOrgOverride: false`, `allowRuntimeCreate: true`) answers 403 `NOT_OVERRIDABLE`. The other 13 types answered alike on both kernels at base and at head. ## The window the door now precedes (M2) Refusals `saveMetaItem` could answer on a host-config kernel between the door's position and `repo.put`, for a packaged `allowOrgOverride: false` save: - the ADR-0010 `_lock` gate, 403 `ITEM_LOCKED` (already deferred to the door by hand; that guard is removed); - the ADR-0029 D9.9 package mismatch, 422 `OBJECT_OVERLAY_PACKAGE_MISMATCH`; - the destructive diff, 409 `DESTRUCTIVE_CHANGE` (measured above); - the layered-envelope refusal, 422 `INVALID_METADATA`; the save-name refusal, 400 `VALIDATION_ERROR`; - the flow conversion conflict, 409 `FLOW_CONVERSION_CONFLICT`; - the spec-conformance parse, 422 `INVALID_METADATA`, draft and publish (measured); - the stored-hook body refusal, 400 `VALIDATION_ERROR`; - the runtime authoring gate, 422 `INVALID_METADATA`, publish only (measured); - the domain plugins' authoring gates: plugin-security's permission-set lock (403 `NOT_OVERRIDABLE`, its own error class and sentence; measured live) and its object posture gate R1 (403, wire `code` `PERMISSION_DENIED`, `declaredCode` `owd_widening_forbidden`). The view-container collision refusal also sits there but judges only `view`, which allows overlays, so the door never precedes it. ADR-0070 D1 (`WRITABLE_PACKAGE_REQUIRED`) judges only `runtime-only` writes, which the door never refuses. ## Mechanism assumptions, as measured - **M1** reproduced at base on both compositions above. The authoring-gate body was built from what `main` refuses (`autonumber-references-unknown-field`); no pass-4 lift was needed. - **M2** the spec parse also answers 422 ahead of the door on host-config, in draft and publish mode; the full window is listed above. - **M3** the predicate is `refusePackagedBaseOverride`'s own, already topology-independent (`packagedBaseRefusal` asks it on every topology for the `/automation` doors). It and `assertAllowed` read the same registry-derived `allowOrgOverride` set, the same `OS_METADATA_WRITABLE` hatch and the same `isWritablePackage` (`package-writability.ts`), and both throw the same `readOnlyBaseOverrideError` for a named read-only base. They differ only in the fallback sentence for a type with no ADR-0126 regime row. - **M4** read `0b997ea447`. The door's input is `isArtifactBacked`, the same input the repository's intent uses, so stack-declared positions are refused by the door on both kernels (measured live for `position/sales_rep`). The `security`-domain types are `permission`, `position` and `capability`; `capability` is code-only and answers the code-only refusal ahead of the door, unchanged. - **M5** held: a draft is still not judged by the authoring gate; env-local bodies still answer 422 on both kernels; a packaged `view` and a packaged object with the hatch open are still judged by the gates. ## What does not move (Clause-② measurement, on this head) - **Accept sets.** The door refuses exactly when `artifactBacked && !isOverlayAllowed(type)`. `repo.put` runs `assertAllowed` with intent `override-artifact` whenever `artifactBacked`, and that refuses on the same predicate, on every topology. `saveMetaItem` has no success return before `repo.put` (the one `return` in that window is inside a closure). So a request the door refuses was refused before, and a request it admits meets the same checks as before. The unit tables above show every measured row refused at base and at head. - **Built entry declarations.** `dist/index.d.ts` and `dist/index.d.cts` of `@objectstack/metadata-protocol`, built from this head and from the merge base's `protocol.ts` (`fe98cc63a4`): the only differences are the two TSDoc paragraphs corrected above. No declaration line changes. - **Wire.** For a packaged `allowOrgOverride: false` save on a host-config kernel that one of the checks above refused, the answer is now 403 `NOT_OVERRIDABLE` (or 403 `ITEM_LOCKED` for a named read-only base) with the door's sentence, which is what an environment kernel already answered. ## Downstream pins that encoded the old host-config order Each relied on the host-config kernel skipping the door. Each now reaches the check it tests the way an environment kernel always had to. - `packages/objectql/src/protocol-destructive.test.ts`: saved a destructive edit of a packaged object with no `environmentId` "to bypass the overlay opt-in gate". It now opens `OS_METADATA_WRITABLE=object`, the one route by which a packaged object's write reaches the destructive diff. Expectations unchanged. - `packages/rest/src/meta-object-owd-gate.test.ts`: the lint-before-R1 order case and the two R1 refusals drove a packaged-object overlay with the hatch shut. They now open the hatch (the path R1's docblock names). Expectations unchanged. Two comments that said the door was environment-scoped are corrected. - `packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts`: the hatch-closed case pinned the lock's error class on host-config. With the hatch closed the door now answers first there, as on an environment kernel, so the case asserts the same `NOT_OVERRIDABLE` / 403 envelope and that the class is not the lock's. The hatch-open cases still pin the lock's class. ## Verification - Reverse verification: the new pin file run against the merge base's `protocol.ts` (swapped on disk, blob-verified, restored by trap to the HEAD blob): 23 failed / 26 passed of 49, every failure a host-config row or a registry row; at head 49/49. - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 222 files passed, 3 skipped; 28329 tests passed (at `9e763ec0bc`, before the `main` merge, which touched only the seed loader in this package). - At `81a42b1203`, after `git merge origin/main` and a rebuild: the new pin file 49/49; `objectql` `protocol-destructive.test.ts` 7/7; `plugin-security` `packaged-permission-set-lock-gate.test.ts` 7/7; `rest` `meta-object-owd-gate.test.ts` 14/14; the five `qa/dogfood` files that drive `saveMetaItem` 27/27. `typecheck` (with `check:test-typecheck` where the package has it) green for `metadata-protocol`, `objectql`, `plugin-security` and `rest`. - Before the merge: `objectql` full `--project local` (383 files passed; the 3 failures were `protocol-destructive.test.ts`, updated above), `plugin-security` full (181 files passed; the 1 failure was the lock-gate case updated above), the 35 `runtime` files and 24 `rest` files that call `saveMetaItem` (all green except the 3 `meta-object-owd-gate` cases updated above). - Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 78 commands on `81a42b1203`. 70 ran on `32d94c2d00` (the merge commit; the one later commit only adds the ledger row); the 8 that row adds, the changeset gates and the ratchet families re-ran on `81a42b1203`. 77 exited 0; `--ran` reconciles 78 derived, 77 run, 1 unrun. `check:engine-double-contract` first exited 1 for the missing ledger row and exits 0 with it recorded. `check:type-check-debt` (a repository-wide re-measure) hit a 400 s local timeout: **NOT MEASURED**, left to CI. - Lint, narrowed: ESLint's own config matches 5 of the 7 changed paths (the changeset and the JSON ledger answer "no matching configuration"); `--format json` reports 5 files, 0 errors, 0 warnings. This config enables no type-aware linting, so the diff cannot move a verdict on an untouched file. ## Serial notes - PR #22319 edits `saveMetaItem`'s flow canonicalization and spec-parse region; this diff stays out of those lines. - PR #22323 edits `protocol.ts` around the drafts listing and `sys-metadata-repository.ts`; no overlap with this diff. ## Acceptance notes - `packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts`'s `hostConfigDoor` docblock still says `saveMetaItem` skips its package door on host-config. The test measures `repo.put` directly and stays correct; only that sentence is now stale. Not edited here (outside the claimed file surface). - `packaged-base-regime.ts` and `sys-metadata-repository.ts` describe the protocol door as environment-scoped (incomplete now, not false). Not edited here, for the same reason. - ADR-0005 §"Whitelist enforcement" still says single-kernel deployments keep "any type writable". The repository's `assertAllowed` has refused these writes on those kernels since before this change; this diff moves no acceptance set, so it reverses no ADR decision. The ADR text is stale relative to shipped behaviour, not to this change. - `deleteMetaItem` keeps its own `environmentId`-scoped removal door; this card is about the save door only. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41d0d40 commit 35afb15

7 files changed

Lines changed: 461 additions & 61 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a save of a packaged item whose type allows no overlay answers `403 NOT_OVERRIDABLE` before any check that judges its body, on every kernel topology
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** `PUT /api/v1/meta/:type/:name` refuses an in-place write onto an item a code package ships when the type has no per-organization overlay channel (`allowOrgOverride: false`, for example `object`, `permission`, `position`). An environment-scoped kernel answered that refusal before it judged the body. A host-config kernel (the CLI's assembler, the showcase's boot shape, `OS_MODE=off`) answered it only at the repository write, after every other check. So on that kernel a publish of a packaged object whose body the authoring gate refuses answered `422 INVALID_METADATA` with findings the author could not land through this door, and a body the spec parse refuses answered `422` in draft and publish mode. After fixing the findings, the author got the `403`.
10+
- **What changes.** The package check now runs on every topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and every check that reads the body or the store. The same request now gets the same refusal on both kernels: `403 NOT_OVERRIDABLE`, or `403 ITEM_LOCKED` when the save names the read-only package, with the same sentence. On a host-config kernel that sentence replaces the repository's "is not allowOrgOverride in the registry" text for these saves.
11+
- **What does not change.** Every request refused before is still refused, and every request admitted before is still admitted. The repository refused the same writes on every topology, and it still does, for the doors that reach it without this check. An environment-local item, an item of a type that allows overlays, and a save with `OS_METADATA_WRITABLE` open for the type are judged by the same checks as before, and drafts are still not judged by the authoring gate.
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22220, #8184] The package door answers BEFORE the checks that judge the
5+
* body, on both kernel topologies — one request, one refusal.
6+
*
7+
* `saveMetaItem`'s package door (`refusePackagedBaseOverride`) refuses an
8+
* in-place write onto an item a code package ships, on a type with no per-org
9+
* overlay channel (`allowOrgOverride: false`). It used to be asked only on an
10+
* environment kernel (`environmentId !== undefined`); a host-config kernel —
11+
* the CLI's assembler, the showcase's boot shape — met the same predicate only
12+
* at the repository write (`SysMetadataRepository.assertAllowed`, the first
13+
* statement of `repo.put`), which is that method's last act. So on that kernel
14+
* every refusal in between answered first: a publish of a packaged object
15+
* whose body the runtime authoring gate refuses answered `422
16+
* INVALID_METADATA`, while an environment kernel answered `403
17+
* NOT_OVERRIDABLE` for the same request; a body the spec-conformance parse
18+
* refuses did the same in draft and publish mode. The author was told to fix
19+
* findings no write through this door could ever land.
20+
*
21+
* This file pins the request against BOTH kernels, row by row, asserting the
22+
* ADR-0112 envelope (`code` + `status`) per kernel:
23+
*
24+
* 1. a packaged `object`, `position` and `permission` — whatever the body —
25+
* answers the package door's refusal on both kernels, and the gate that
26+
* would have judged the body is never reached;
27+
* 2. controls, unchanged: an environment-local object with a gate-refused or
28+
* a spec-refused body still answers `422 INVALID_METADATA` on both
29+
* kernels; a packaged item of a type that allows overlays
30+
* (`allowOrgOverride: true`) and a packaged object with the
31+
* `OS_METADATA_WRITABLE` hatch open are still judged by the gates;
32+
* 3. every type in `DEFAULT_METADATA_TYPE_REGISTRY`: the same packaged
33+
* publish gives the same envelope on both kernels, and every type the
34+
* door governs answers it.
35+
*
36+
* Nothing in a refused row is persisted. `@objectstack/objectql` cannot be
37+
* imported here: it depends on this package.
38+
*/
39+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
40+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
41+
import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core';
42+
import { ObjectStackProtocolImplementation } from './protocol.js';
43+
import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
44+
45+
const PACKAGE_ID = 'com.example.pkg';
46+
const ENV_ID = 'env_1';
47+
48+
type Kernel = 'environment' | 'host-config';
49+
const KERNELS: ReadonlyArray<readonly [Kernel, string | undefined]> = [
50+
['environment', ENV_ID],
51+
['host-config', undefined],
52+
];
53+
54+
interface Envelope { code: unknown; status: unknown }
55+
56+
/** `field` artifacts are nested in their object, so the packaged field is the packaged object's own. */
57+
const packagedName = (type: string): string => (type === 'field' ? 'pkg_object.title' : `pkg_${type}`);
58+
59+
/** What a code package's loader registered: one artifact per registry type, package-stamped. */
60+
function packagedArtifacts(): Map<string, Map<string, Record<string, unknown>>> {
61+
const out = new Map<string, Map<string, Record<string, unknown>>>();
62+
for (const { type } of DEFAULT_METADATA_TYPE_REGISTRY) {
63+
if (type === 'field') continue; // shipped inside `pkg_object`, below
64+
const name = packagedName(type);
65+
out.set(type, new Map([[name, {
66+
name,
67+
label: name,
68+
...(type === 'object' ? { fields: { title: { type: 'text', label: 'Title' } } } : {}),
69+
_packageId: PACKAGE_ID,
70+
_provenance: 'package',
71+
}]]));
72+
}
73+
return out;
74+
}
75+
76+
/**
77+
* The engine double: the registry answers what the loader registered; the
78+
* store holds nothing; `insert` records every row it is handed, so a refused
79+
* row can be proven unpersisted. `manifests` holds the booted code package,
80+
* which makes it a read-only base (`isWritablePackage`).
81+
*/
82+
function harness(environmentId: string | undefined) {
83+
const artifacts = packagedArtifacts();
84+
const inserted: Array<{ table: string; data: Record<string, unknown> }> = [];
85+
const registry = {
86+
getArtifactItem(type: string, name: string) {
87+
const hit = artifacts.get(type)?.get(name);
88+
return hit && isCodeArtifactBody(hit) ? hit : undefined;
89+
},
90+
getItem(type: string, name: string) {
91+
return artifacts.get(type)?.get(name);
92+
},
93+
listItems(type: string) {
94+
return [...(artifacts.get(type)?.values() ?? [])];
95+
},
96+
getObject: () => undefined,
97+
registerObject: () => undefined,
98+
registerItem: () => undefined,
99+
getPackage: () => undefined,
100+
isPackageDisabled: () => false,
101+
applyNavContributions: (app: unknown) => app,
102+
};
103+
const engine: any = {
104+
async find(_table: string, opts?: { limit?: number }) {
105+
// `check:objectql-double-limit` — the caller's bound, applied after the (empty) filter.
106+
const matched: unknown[] = [];
107+
return opts?.limit === undefined ? matched : matched.slice(0, opts.limit);
108+
},
109+
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
110+
// `check:engine-double-contract` — refuses what the real engine refuses.
111+
assertEngineFindOnePredicate(table, opts);
112+
return null;
113+
},
114+
async insert(table: string, data: Record<string, unknown>) {
115+
inserted.push({ table, data });
116+
return { id: `r_${inserted.length}` };
117+
},
118+
manifests: new Map([[PACKAGE_ID, {}]]),
119+
registry,
120+
};
121+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId);
122+
const persisted = () => inserted.filter((r) => r.table === 'sys_metadata');
123+
return { protocol, persisted };
124+
}
125+
126+
/** A body the runtime authoring gate refuses and the spec parse accepts: the autonumber names a field the object lacks. */
127+
const gateRefusedObject = (name: string) => ({
128+
name,
129+
label: name,
130+
sharingModel: 'private',
131+
fields: {
132+
title: { type: 'text', label: 'Title' },
133+
task_no: { type: 'autonumber', label: 'Task No', autonumberFormat: '{plan_no}{000}' },
134+
},
135+
});
136+
137+
/** A body the spec-conformance parse refuses: an undeclared top-level key. */
138+
const specRefused = (body: Record<string, unknown>) => ({ ...body, zz_undeclared_key: 1 });
139+
140+
/** A body every check before the store accepts (the `sharingModel` keeps `security-owd-unset` quiet). */
141+
const servedObject = (name: string) => ({
142+
name,
143+
label: name,
144+
sharingModel: 'private',
145+
fields: { title: { type: 'text', label: 'Title' } },
146+
});
147+
148+
interface Probe {
149+
envelope: Envelope;
150+
issues: unknown;
151+
gateReached: boolean;
152+
persistedRows: number;
153+
}
154+
155+
async function probe(
156+
environmentId: string | undefined,
157+
request: { type: string; name: string; item: unknown; mode?: 'draft' | 'publish'; packageId?: string },
158+
): Promise<Probe> {
159+
const { protocol, persisted } = harness(environmentId);
160+
const gate = vi.spyOn(protocol as any, 'assertRuntimeAuthoringRules');
161+
try {
162+
const outcome = await protocol.saveMetaItem(request).then(
163+
() => null,
164+
(e: unknown) => e as Record<string, unknown>,
165+
);
166+
expect(outcome, `${request.type}/${request.name}: the save was ADMITTED`).not.toBeNull();
167+
return {
168+
envelope: { code: outcome!.code, status: outcome!.status },
169+
issues: outcome!.issues,
170+
gateReached: gate.mock.calls.length > 0,
171+
persistedRows: persisted().length,
172+
};
173+
} finally {
174+
gate.mockRestore();
175+
}
176+
}
177+
178+
const NOT_OVERRIDABLE: Envelope = { code: 'NOT_OVERRIDABLE', status: 403 };
179+
const ITEM_LOCKED: Envelope = { code: 'ITEM_LOCKED', status: 403 };
180+
const INVALID_METADATA: Envelope = { code: 'INVALID_METADATA', status: 422 };
181+
182+
let warn: ReturnType<typeof vi.spyOn>;
183+
beforeEach(() => {
184+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
185+
});
186+
afterEach(() => {
187+
warn.mockRestore();
188+
delete process.env.OS_METADATA_WRITABLE;
189+
ObjectStackProtocolImplementation.resetEnvWritableCache();
190+
resetEnvWritableMetadataTypes();
191+
});
192+
193+
describe('[#22220] a packaged publish answers the package door on both kernels', () => {
194+
interface Row {
195+
label: string;
196+
request: { type: string; name: string; item: unknown; mode?: 'draft' | 'publish'; packageId?: string };
197+
expected: Envelope;
198+
}
199+
const rows: Row[] = [
200+
{
201+
label: 'packaged object, the served body, publish',
202+
request: { type: 'object', name: 'pkg_object', item: servedObject('pkg_object') },
203+
expected: NOT_OVERRIDABLE,
204+
},
205+
{
206+
label: 'packaged object, a body the authoring gate refuses, publish',
207+
request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object') },
208+
expected: NOT_OVERRIDABLE,
209+
},
210+
{
211+
label: 'packaged object, a body the authoring gate refuses, draft (drafts stay ungated)',
212+
request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object'), mode: 'draft' },
213+
expected: NOT_OVERRIDABLE,
214+
},
215+
{
216+
label: 'packaged object, a body the spec parse refuses, publish',
217+
request: { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')) },
218+
expected: NOT_OVERRIDABLE,
219+
},
220+
{
221+
label: 'packaged object, a body the spec parse refuses, draft',
222+
request: { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')), mode: 'draft' },
223+
expected: NOT_OVERRIDABLE,
224+
},
225+
{
226+
label: 'packaged object named under its read-only package, a body the authoring gate refuses, publish',
227+
request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object'), packageId: PACKAGE_ID },
228+
expected: ITEM_LOCKED,
229+
},
230+
{
231+
label: 'packaged position (security domain), a body the spec parse refuses, publish',
232+
request: { type: 'position', name: 'pkg_position', item: specRefused({ name: 'pkg_position', label: 'Pkg' }) },
233+
expected: NOT_OVERRIDABLE,
234+
},
235+
{
236+
label: 'packaged permission (security domain), a body the spec parse refuses, publish',
237+
request: { type: 'permission', name: 'pkg_permission', item: specRefused({ name: 'pkg_permission', label: 'Pkg' }) },
238+
expected: NOT_OVERRIDABLE,
239+
},
240+
];
241+
242+
for (const row of rows) {
243+
for (const [kernel, environmentId] of KERNELS) {
244+
it(`${row.label} → ${row.expected.status} ${row.expected.code} (${kernel} kernel)`, async () => {
245+
const result = await probe(environmentId, row.request);
246+
expect(result.envelope).toEqual(row.expected);
247+
expect(result.gateReached, 'the door answers before the authoring gate').toBe(false);
248+
expect(result.persistedRows, 'a refusal persists nothing').toBe(0);
249+
});
250+
}
251+
}
252+
});
253+
254+
describe('[#22220] controls: the gates still judge what the door does not refuse', () => {
255+
it('an environment-local object with a body the authoring gate refuses → 422 INVALID_METADATA, gate reached (both kernels)', async () => {
256+
for (const [kernel, environmentId] of KERNELS) {
257+
const result = await probe(environmentId, { type: 'object', name: 'local_object', item: gateRefusedObject('local_object') });
258+
expect(result.envelope, kernel).toEqual(INVALID_METADATA);
259+
expect(result.gateReached, kernel).toBe(true);
260+
// The ONE finding, so the rest of the body is gate-clean: the packaged rows above are refused for the door alone.
261+
const rules = (result.issues as Array<{ rule?: string }>).map((i) => i.rule);
262+
expect(rules, kernel).toEqual(['autonumber-references-unknown-field']);
263+
expect(result.persistedRows, kernel).toBe(0);
264+
}
265+
});
266+
267+
it('an environment-local object with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => {
268+
for (const [kernel, environmentId] of KERNELS) {
269+
const result = await probe(environmentId, { type: 'object', name: 'local_object', item: specRefused(servedObject('local_object')) });
270+
expect(result.envelope, kernel).toEqual(INVALID_METADATA);
271+
const codes = (result.issues as Array<{ code?: string }>).map((i) => i.code);
272+
expect(codes, kernel).toContain('unrecognized_keys');
273+
expect(result.persistedRows, kernel).toBe(0);
274+
}
275+
});
276+
277+
it('a packaged item of a type that allows overlays (view) with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => {
278+
for (const [kernel, environmentId] of KERNELS) {
279+
const result = await probe(environmentId, {
280+
type: 'view', name: 'pkg_view', item: specRefused({ name: 'pkg_view', label: 'Pkg' }),
281+
});
282+
expect(result.envelope, kernel).toEqual(INVALID_METADATA);
283+
expect(result.persistedRows, kernel).toBe(0);
284+
}
285+
});
286+
287+
it('a packaged object with the OS_METADATA_WRITABLE hatch open and a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => {
288+
process.env.OS_METADATA_WRITABLE = 'object';
289+
ObjectStackProtocolImplementation.resetEnvWritableCache();
290+
resetEnvWritableMetadataTypes();
291+
for (const [kernel, environmentId] of KERNELS) {
292+
const result = await probe(environmentId, { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')) });
293+
expect(result.envelope, kernel).toEqual(INVALID_METADATA);
294+
expect(result.persistedRows, kernel).toBe(0);
295+
}
296+
});
297+
});
298+
299+
describe('[#22220] every registry type: one packaged publish, one envelope, on both kernels', () => {
300+
const governed = new Set(
301+
DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => !e.allowOrgOverride && e.allowRuntimeCreate).map((e) => e.type),
302+
);
303+
304+
for (const { type } of DEFAULT_METADATA_TYPE_REGISTRY) {
305+
it(`${type}${governed.has(type) ? ' (the door governs it)' : ''}`, async () => {
306+
const name = packagedName(type);
307+
const request = { type, name, item: specRefused({ name, label: name }) };
308+
const byKernel: Record<string, Envelope> = {};
309+
for (const [kernel, environmentId] of KERNELS) {
310+
const result = await probe(environmentId, request);
311+
byKernel[kernel] = result.envelope;
312+
expect(result.persistedRows, `${type} (${kernel})`).toBe(0);
313+
}
314+
expect(byKernel['host-config'], `${type}: the kernels disagree`).toEqual(byKernel.environment);
315+
if (governed.has(type)) expect(byKernel.environment, type).toEqual(NOT_OVERRIDABLE);
316+
});
317+
}
318+
319+
it('lit control: the door governs object, position and permission, and leaves view to the gates', () => {
320+
expect([...governed]).toEqual(expect.arrayContaining(['object', 'position', 'permission']));
321+
expect(governed.has('view')).toBe(false);
322+
});
323+
});

0 commit comments

Comments
 (0)