Skip to content

Commit 1e271aa

Browse files
committed
fix(metadata-protocol): the save check anchors each package's row on that package's env-wide definition
The organization-scoped save check judges a view overlay against the env-wide body of the row it is keyed by. That anchor is now resolved per package, the way the list read resolves each package's item: the package's own env-wide row, else the package-less env-wide row (which stands in for every package), else that package's artifact. It no longer falls back to one artifact per name, the first in registry order, and one package's stored row no longer stands for every package's artifact of the name. The "never under-closes" wording on the withdrawal judgement's docblock and on the public data collection page is narrowed to match what holds: a withdrawal of a name may over-close across packages; both checks read every package's env-wide definition, except that the anonymous form endpoints read one package's expansion of each form name when several packages' stored view containers expand it. Pin: with the withdrawing package not first in registry order, a package-less or package-bound org save that renames the form is refused; another package's env-wide row anchors that package only; controls: the save that keeps the form withdrawn saves, and a package-less env-wide row stands in for every package. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent 114ed63 commit 1e271aa

5 files changed

Lines changed: 111 additions & 15 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
The organization-scoped save check judges a view overlay against every package's environment-wide definition of its row
6+
7+
- An organization-scoped `view` save or publish in the organization the anonymous form endpoints read is refused when it would leave open a form the environment-wide definition withdraws. Its row anchor is now resolved per package, the way the list read resolves each package's item: each package's own environment-wide row, else the package-less environment-wide row (which stands in for every package), else that package's artifact. Before, with no environment-wide row stored, it judged only the first package's artifact in registry order, and a stored row of any one package hid every package's artifact of the name.
8+
- The known limit stated with the public-form withdrawal ("it may over-close, never under-close") is narrowed. A withdrawal of a view name still closes that name in every package, so it may over-close. Both checks read every package's environment-wide definition of the name, except at the anonymous endpoints when two packages each have an environment-wide copy of the same view container saved: there the endpoints read one package's copy of each form those containers expand, and can miss the other package's withdrawal of that form until it is withdrawn in each package's copy. The organization-scoped save check still judges both copies.

‎content/docs/ui/public-data-collection.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ A withdrawal is a kill switch across metadata layers. If the environment-wide de
6262

6363
**Which form a withdrawal closes.** Two checks apply the rule, and they match forms differently:
6464

65-
- **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view.
65+
- **Saving and publishing in an organization** judges the organization's copy against the stored environment-wide definition it overrides (the row its copy is keyed by, in each package that ships it). Inside that definition, a withdrawn form is the same form as the organization's when they share a place (`form`, the same `formViews` entry, or the view's own `config`) or a public link. A match on either is enough, so a renamed `formViews` key, a `form.name`, a move to another place, a renamed expanded item, and a new or re-cased slug all still count as the same form. A form that differs from every withdrawn form in both place and link is a different form, such as a sibling in the same view.
6666
- **The anonymous endpoints** judge each form by the name of the view item they serve. Beneath the organization's read they read the environment-wide view list, and a form is closed when the environment-wide item of the same name explicitly withdraws a form in the same place or under the same link.
6767
- **A different view is a different form.** A different view that uses the same public link (for example, another app's "contact us" form) neither closes this one nor is closed by it.
6868

6969
**Forms a package ships.** A package's form is part of the environment-wide definition, not a separate layer beneath it. A definition parsed by the stack schema (strict `defineStack`, the default) gets the schema's default `enabled: false`, so a shipped form that keeps its link without setting `enabled: true` counts as withdrawn and an organization's copy cannot open it. A definition loaded without that parse (`defineStack(..., { strict: false })` or a hand-built manifest) is judged as written: a switch it leaves out is absent, which is not a withdrawal, so set `enabled: false` explicitly to ship a form closed. The environment-wide definition is the administrator's switch: an environment-wide save may open a form that the package ships closed.
7070

71-
**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name. This may close more than was meant, but it never leaves a withdrawn form open. Per-package precision is tracked in #21934.
71+
**Known limit: packages and names.** A withdrawal of a view name closes that name in every package. When two packages each ship a view of the same name, one package's withdrawal also closes the other package's form of that name, so this may close more than was meant. Both checks read every package's environment-wide definition of the name, with one exception at the endpoints: when two packages each have an environment-wide copy of the same view container saved, the endpoints read only one package's copy of each form those containers expand, so they can miss the other package's withdrawal of that form. The organization-scoped save check still judges both copies. To close such a form at the endpoints, withdraw it in each package's environment-wide copy.
7272

7373
**Known limit.** The save check runs only when an organization's copy is saved or published. A copy that was already stored before the environment-wide withdrawal, or that a rollback or revert restores, is judged only by the endpoints, which match by served item name. If that copy keeps the form open under a different key or place than the environment-wide definition, the endpoints can still serve it. To close it, withdraw the form in that organization's copy too; the next organization-scoped save of a copy that keeps it open is refused.
7474

‎packages/metadata-core/src/anonymous-form-intake.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,14 @@ function anonymousFormExplicitWithdrawals(view: unknown): Array<{ slot: string;
322322
* and closes nothing. The package a body is bound to is NOT compared: a
323323
* withdrawal of a name closes that name's form in every package (a known
324324
* limit that fails closed: it may over-close another package's form of the
325-
* same name, never under-close). A layer with no body of the row, or whose body has no
326-
* explicit withdrawal, withdraws nothing, so a form published only in an
327-
* organization stays open there.
325+
* same name). It judges only the bodies the layer holds, so a caller closes a
326+
* name in every package only when its layer holds every package's body of the
327+
* name: the organization-scoped write door anchors one body per package, and
328+
* the env-wide view list the anonymous doors read holds one item per package
329+
* of a name, except that it holds one package's expansion of each name that
330+
* stored view containers of several packages expand. A layer with no body of
331+
* the row, or whose body has no explicit withdrawal, withdraws nothing, so a
332+
* form published only in an organization stays open there.
328333
*/
329334
export function anonymousFormIntakeWithdrawnIn(
330335
layer: ReadonlyArray<unknown>,

‎packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,3 +1205,72 @@ describe('a publish consults the lock of the package key it resolved', () => {
12051205
expect(draftsOf()).toEqual(['pkg_a']);
12061206
});
12071207
});
1208+
1209+
// The organization-scoped save check anchors the overlay on the env-wide
1210+
// definition of its row, one per package that holds the name: each package's
1211+
// own env-wide row, else the package-less env-wide row (which stands in for
1212+
// every package), else that package's artifact. So a package that withdraws
1213+
// the form is judged whatever its place in registry order, and another
1214+
// package's stored row anchors that package only.
1215+
describe('the save check anchors each package\'s row on that package\'s env-wide definition', () => {
1216+
const LINK = '/forms/walled-intake';
1217+
const open = { enabled: true, allowAnonymous: true, publicLink: LINK };
1218+
const container = (sharing: Record<string, unknown>, key = 'intake_form') => ({
1219+
name: 'task', object: 'task', formViews: { [key]: { sharing } },
1220+
});
1221+
// Registry order: package A (open) first, so a lookup that names no
1222+
// package answers A's artifact. Package B, which withdraws, is second.
1223+
const shippedA = { ...container(open), _packageId: 'pkg_a' };
1224+
const shippedB = { ...container({ ...open, allowAnonymous: false }), _packageId: 'pkg_b' };
1225+
// The overlay moves the form to another key, so only the row anchor matches it.
1226+
const renamedOpen = container(open, 'intake_v2');
1227+
1228+
function makeTwoPackageProtocol() {
1229+
const { engine, rows } = makeStubEngine();
1230+
engine.registry.listItems = (type: string) => (type === 'view' ? [shippedA, shippedB] : []);
1231+
engine.registry.getArtifactItem = (type: string, name: string, pkg?: string) => {
1232+
if (type !== 'view' || name !== 'task') return undefined;
1233+
return pkg === 'pkg_b' ? shippedB : shippedA;
1234+
};
1235+
const services = new Map<string, unknown>([['tenancy', { defaultOrgId: async () => 'org_a' }]]);
1236+
const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_prod') as any;
1237+
return { protocol, rows };
1238+
}
1239+
1240+
for (const [label, packageId] of [['a package-less', undefined], ['a package A-bound', 'pkg_a']] as const) {
1241+
it(`the withdrawing package is not first in registry order: ${label} org save that renames the form is refused`, async () => {
1242+
const { protocol, rows } = makeTwoPackageProtocol();
1243+
await expect(protocol.saveMetaItem({
1244+
type: 'view', name: 'task', item: renamedOpen, organizationId: 'org_a',
1245+
...(packageId ? { packageId } : {}),
1246+
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' });
1247+
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
1248+
});
1249+
}
1250+
1251+
it('another package\'s env-wide row anchors that package only: the org save is still judged against the withdrawing package', async () => {
1252+
const { protocol, rows } = makeTwoPackageProtocol();
1253+
expect((await protocol.saveMetaItem({
1254+
type: 'view', name: 'task', item: { ...container(open), label: 'Task (A, env-wide)' }, packageId: 'pkg_a',
1255+
})).success).toBe(true);
1256+
await expect(protocol.saveMetaItem({
1257+
type: 'view', name: 'task', item: renamedOpen, organizationId: 'org_a',
1258+
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403, organizationId: 'org_a' });
1259+
expect(orgRows(rows).filter((r) => r.org === 'org_a')).toEqual([]);
1260+
});
1261+
1262+
it('control: the same org save that keeps the form withdrawn saves', async () => {
1263+
const { protocol } = makeTwoPackageProtocol();
1264+
expect((await protocol.saveMetaItem({
1265+
type: 'view', name: 'task', item: container({ ...open, allowAnonymous: false }, 'intake_v2'), organizationId: 'org_a',
1266+
})).success).toBe(true);
1267+
});
1268+
1269+
it('control: a package-less env-wide row stands in for every package, so the org save it leaves open saves', async () => {
1270+
const { protocol } = makeTwoPackageProtocol();
1271+
expect((await protocol.saveMetaItem({ type: 'view', name: 'task', item: container(open) })).success).toBe(true);
1272+
expect((await protocol.saveMetaItem({
1273+
type: 'view', name: 'task', item: renamedOpen, organizationId: 'org_a',
1274+
})).success).toBe(true);
1275+
});
1276+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16070,7 +16070,9 @@ export class ObjectStackProtocolImplementation implements
1607016070
// by: the env-wide body of row `name`, as stored (a container is not
1607116071
// expanded, so a form moved to another key or slot, renamed through
1607216072
// `form.name`, or renamed by an expansion collision is still matched
16073-
// against the form it was, by slot or by slug).
16073+
// against the form it was, by slot or by slug). [#21934] One body per
16074+
// package that holds the name ({@link envWideRawViewRows}), so every
16075+
// package's withdrawal of it is judged, whatever the registry order.
1607416076
const envRows = (await this.envWideRawViewRows(args.type, args.name)).map((r) => ({ ...r, name: args.name }));
1607516077
if (envRows.length > 0) {
1607616078
const own = { ...raw, name: args.name };
@@ -16101,10 +16103,15 @@ export class ObjectStackProtocolImplementation implements
1610116103
}
1610216104

1610316105
/**
16104-
* The env-wide body of the `view` row `name`, as stored: the active
16105-
* env-wide `sys_metadata` row when there is one (the env-wide overlay is
16106-
* keyed by its own name, ADR-0005), else the code package's artifact of
16107-
* that name. Empty when neither exists.
16106+
* The env-wide bodies of the `view` row `name`, as stored, for every
16107+
* package that holds the name. [#21934] Resolved per package, the way the
16108+
* list read resolves each package's item (ADR-0048): the package's own
16109+
* active env-wide `sys_metadata` row (the env-wide overlay is keyed by its
16110+
* own name, ADR-0005), else the package-less env-wide row, which stands in
16111+
* for every package's row of the name, else that package's artifact of
16112+
* the name. So a stored row of one package anchors that package only, and
16113+
* every package that ships the name is judged on its own definition,
16114+
* whatever the registry order. Empty when no package holds the name.
1610816115
*
1610916116
* Read raw, never through the list read: that serves a container only as
1611016117
* its expansion, whose item names and slots the overlay author chooses,
@@ -16120,11 +16127,18 @@ export class ObjectStackProtocolImplementation implements
1612016127
}
1612116128
const stored = this.storedOverlayEntries({ type }, records)
1612216129
.filter((e) => e.name === name && e.organizationId === null)
16123-
.map((e) => e.data)
16124-
.filter((d): d is Record<string, unknown> => !!d && typeof d === 'object' && !Array.isArray(d));
16125-
if (stored.length > 0) return stored;
16126-
const artifact = this.lookupArtifactItem(type, name);
16127-
return artifact && typeof artifact === 'object' ? [artifact as Record<string, unknown>] : [];
16130+
.filter((e) => !!e.data && typeof e.data === 'object' && !Array.isArray(e.data));
16131+
const bodies = stored.map((e) => e.data as Record<string, unknown>);
16132+
const withOwnRow = new Set(stored.map((e) => e.packageId));
16133+
// The package-less row stands in for every package without a row of its own.
16134+
if (withOwnRow.has(undefined)) return bodies;
16135+
for (const artifact of this.shippedArtifactsOf(type, name)) {
16136+
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) continue;
16137+
const pkg = (artifact as { _packageId?: unknown })._packageId;
16138+
if (typeof pkg === 'string' && withOwnRow.has(pkg)) continue;
16139+
bodies.push(artifact as Record<string, unknown>);
16140+
}
16141+
return bodies;
1612816142
}
1612916143

1613016144
/**

0 commit comments

Comments
 (0)