Skip to content

Commit 2541bab

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23427-row-declarative-update-rules
2 parents 0c8c54f + 4e0838d commit 2541bab

8 files changed

Lines changed: 923 additions & 40 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
fix(spec,lint)!: an `objectExtensions` entry that leaves a `controlled_by_parent` object with anything but one required `master_detail` is refused, as the same object declared directly already was (#23210)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered cbp-extension-merged-master-refused -->
11+
12+
**BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
ADR-0055 §1: a `controlled_by_parent` object derives its master from exactly one `master_detail` field declared `required: true`. `ObjectSchema` refuses an object that declares anything else, and so does the security-posture lint. Neither looked at the object the runtime actually runs: at boot the registry merges every `objectExtensions` entry onto its target, field by field, so an extension could add a second required `master_detail`, or demote the one master. `defineStack`, `os validate` and the lint passed the stack, and the runtime then derived no master: every read of the object denied, every write refused with `422 INVALID_METADATA`.
15+
16+
**What is refused, and where.**
17+
18+
- **`@objectstack/spec`.** `ObjectStackDefinitionSchema` (so `defineStack`, `os validate`, `os build`, the lint scorer and an artifact load) judges the rule on the merged field set of every `controlled_by_parent` object that an `objectExtensions` entry of the same stack extends. In a multi-package artifact (`packages[]`), an extension in any package judges an object any package declares. The merge is read as the registry computes it: fields by name, a later writer replacing the whole field, extensions in ascending `priority`. The issue sits at the `fields` of the extension that wrote the deciding field (`objectExtensions.0.fields`, or `packages.0.manifest.objectExtensions.0.fields`), with the message the same object gets when declared directly. `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it.
19+
- **`@objectstack/lint`.** `security-controlled-by-parent-ambiguous-relation` and `security-controlled-by-parent-no-relation` judge the same merged shape and report it at the same extension, in the words of the declared object's finding.
20+
21+
No export, signature or error code is added or removed.
22+
23+
## FROM → TO
24+
25+
| stack | FROM | TO |
26+
|:--|:--|:--|
27+
| `objects: [{ name: 'cbpx_line', sharingModel: 'controlled_by_parent', fields: { header: { type: 'master_detail', reference: 'cbpx_header', required: true } } }, …]`, `objectExtensions: [{ extend: 'cbpx_line', fields: { other: { type: 'master_detail', reference: 'cbpx_other', required: true } } }]` | accepted; at runtime the object derived no master and failed closed | refused at `objectExtensions.0.fields`: "Object 'cbpx_line' declares sharingModel 'controlled_by_parent' with 2 required `master_detail` fields (`header`, `other`)…" |
28+
| the same object, extended with `fields: { header: { type: 'master_detail', reference: 'cbpx_header', required: false } }` | accepted; no master at runtime | refused at `objectExtensions.0.fields`: "…but no required `master_detail` field…" |
29+
| an artifact built before this change that carries either shape | loaded; the object failed closed | refused at load; rebuild it from the corrected source |
30+
31+
**Remedy, in one line.** In the extension, drop the second master or declare it `type: 'lookup'`, and never redeclare the object's master with `required: false` or another type.
32+
33+
**Unchanged.**
34+
35+
- An extension adding any non-master field (a `lookup` to another parent included) to a `controlled_by_parent` object.
36+
- An extension whose target the stack does not declare: another package's object, or the platform's. It is not judged, and the runtime's fail-closed refusal remains the backstop there.
37+
- An object already refused as declared is reported once, at the object.
38+
- In a multi-package artifact, a target on which two packages write one field at the same `priority`: the winner is the order the artifact loader registers them in, which the parse does not compute, so it is not judged.
39+
- Stored `sys_metadata` rows: an extension is merged only when a stack or an artifact registers, so no stored row carries the shape and none is rewritten.
40+
- The step-18 entry `cbp-extension-merged-master-refused` carries the prescription for `os migrate meta`. It is asked only of a stack that declares `objectExtensions`.
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23210 · ADR-0055 §1] `security-controlled-by-parent-ambiguous-relation`
5+
* and `security-controlled-by-parent-no-relation`, judged on the MERGED field
6+
* set of a `controlled_by_parent` object that an `objectExtensions` entry of
7+
* the same stack extends (`extensionMergedCbpFindings`).
8+
*
9+
* The stack schema refuses the same merged shapes
10+
* (`applyControlledByParentExtensionGate`, `@objectstack/spec`); this is the
11+
* door an unparsed intake meets. Every refusal below is pinned by rule id,
12+
* path and message — the message compared with the finding the same object
13+
* gets when it is declared directly — and every silence beside a twin that
14+
* differs in the one variable under test and is reported. The last block
15+
* holds the two doors to one verdict per shape.
16+
*/
17+
import { describe, it, expect } from 'vitest';
18+
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
19+
import {
20+
validateSecurityPosture,
21+
SECURITY_CBP_AMBIGUOUS_RELATION,
22+
SECURITY_CBP_NO_RELATION,
23+
} from './validate-security-posture.js';
24+
25+
const HEADER = { type: 'master_detail', reference: 'cbpx_header', required: true };
26+
const OTHER = { type: 'master_detail', reference: 'cbpx_other', required: true };
27+
28+
const OBJECTS = (lineFields: Record<string, unknown> = { header: HEADER }, sharingModel = 'controlled_by_parent') => [
29+
{ name: 'cbpx_header', sharingModel: 'private', fields: { title: { type: 'text' } } },
30+
{ name: 'cbpx_other', sharingModel: 'private', fields: { title: { type: 'text' } } },
31+
{ name: 'cbpx_line', sharingModel, fields: lineFields },
32+
];
33+
34+
const extending = (fields: Record<string, unknown>, extend = 'cbpx_line') => ({
35+
objects: OBJECTS(),
36+
objectExtensions: [{ extend, fields }],
37+
});
38+
39+
/** The card's stack: one declared master, a second one added by an extension. */
40+
const CARD_STACK = extending({ other: OTHER });
41+
42+
const cbpFindings = (stack: Record<string, unknown>) =>
43+
validateSecurityPosture(stack).filter(
44+
(f) => f.rule === SECURITY_CBP_AMBIGUOUS_RELATION || f.rule === SECURITY_CBP_NO_RELATION,
45+
);
46+
47+
describe('[#23210] validateSecurityPosture judges a controlled_by_parent object on its merged field set', () => {
48+
it('the card\'s stack: ambiguous-relation at the extension\'s fields, in the words of the declared control', () => {
49+
const findings = cbpFindings(CARD_STACK);
50+
expect(findings).toHaveLength(1);
51+
expect(findings[0]).toMatchObject({
52+
severity: 'error',
53+
rule: SECURITY_CBP_AMBIGUOUS_RELATION,
54+
where: 'object "cbpx_line"',
55+
path: 'objectExtensions[0].fields',
56+
});
57+
58+
const control = cbpFindings({ objects: OBJECTS({ header: HEADER, other: OTHER }) });
59+
expect(control.map((f) => [f.rule, f.path])).toEqual([[SECURITY_CBP_AMBIGUOUS_RELATION, 'objects[2].fields']]);
60+
expect(findings[0].message).toBe(control[0].message);
61+
expect(findings[0].hint).toBe(control[0].hint);
62+
expect(findings[0].message).toContain('"header" → cbpx_header, "other" → cbpx_other');
63+
});
64+
65+
it('the single-object control is unchanged, and its declared base alone is clean', () => {
66+
expect(cbpFindings({ objects: OBJECTS({ header: HEADER, other: OTHER }) }).map((f) => f.path)).toEqual([
67+
'objects[2].fields',
68+
]);
69+
expect(cbpFindings({ objects: OBJECTS() })).toEqual([]);
70+
});
71+
72+
it('an extension adding a non-master field is silent; its master-adding twin is reported', () => {
73+
expect(cbpFindings(extending({ other: { type: 'text' } }))).toEqual([]);
74+
expect(cbpFindings(extending({ other: { type: 'lookup', reference: 'cbpx_other', required: true } }))).toEqual([]);
75+
expect(cbpFindings(extending({ other: OTHER })).map((f) => f.path)).toEqual(['objectExtensions[0].fields']);
76+
});
77+
78+
it('an extension that demotes the one master is no-relation at the extension, in the words of the declared control', () => {
79+
for (const demoted of [
80+
{ type: 'master_detail', reference: 'cbpx_header', required: false },
81+
{ type: 'text' },
82+
{ type: 'lookup', reference: 'cbpx_header', required: true },
83+
]) {
84+
const findings = cbpFindings(extending({ header: demoted }));
85+
expect(findings.map((f) => [f.rule, f.path]), JSON.stringify(demoted)).toEqual([
86+
[SECURITY_CBP_NO_RELATION, 'objectExtensions[0].fields'],
87+
]);
88+
const control = cbpFindings({ objects: OBJECTS({ header: demoted }) });
89+
expect(control.map((f) => f.path)).toEqual(['objects[2].sharingModel']);
90+
expect(findings[0].message).toBe(control[0].message);
91+
}
92+
});
93+
94+
it('an extension whose target the stack does not declare is unjudged; the visible twin is reported', () => {
95+
expect(cbpFindings(extending({ a: OTHER, b: HEADER }, 'crm_line'))).toEqual([]);
96+
expect(cbpFindings(extending({ other: OTHER }, 'cbpx__cbpx_line'))).toEqual([]);
97+
expect(cbpFindings(extending({ other: OTHER }, 'cbpx_line'))).toHaveLength(1);
98+
});
99+
100+
it('a target already reported as declared is reported once, at the object', () => {
101+
const stack = {
102+
objects: OBJECTS({ header: HEADER, other: OTHER }),
103+
objectExtensions: [{ extend: 'cbpx_line', fields: { note: { type: 'text' } } }],
104+
};
105+
expect(cbpFindings(stack).map((f) => f.path)).toEqual(['objects[2].fields']);
106+
});
107+
108+
it('reads the array field form of an extension too', () => {
109+
const stack = { objects: OBJECTS(), objectExtensions: [{ extend: 'cbpx_line', fields: [{ name: 'other', ...OTHER }] }] };
110+
expect(cbpFindings(stack).map((f) => f.path)).toEqual(['objectExtensions[0].fields']);
111+
});
112+
113+
it('across the packages of one artifact: an extension in one body judges another body\'s object', () => {
114+
const body = (id: string, extra: Record<string, unknown>) => ({ manifest: { id, name: id, version: '1.0.0', type: 'app', ...extra } });
115+
const stack = (field: unknown) => ({
116+
packages: [
117+
body('com.acme.ext', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: field } }] }),
118+
body('com.acme.core', { objects: OBJECTS() }),
119+
],
120+
});
121+
expect(cbpFindings(stack(OTHER)).map((f) => [f.rule, f.path])).toEqual([
122+
[SECURITY_CBP_AMBIGUOUS_RELATION, 'packages[0].manifest.objectExtensions[0].fields'],
123+
]);
124+
expect(cbpFindings(stack({ type: 'text' }))).toEqual([]);
125+
});
126+
});
127+
128+
// ── The two doors, one verdict per shape ───────────────────────────────────
129+
//
130+
// The stack schema and this rule each read the merge (`@objectstack/lint`
131+
// imports no runtime and the schema's reader is internal to the spec), so the
132+
// table below holds them to the same answer on the same stacks: the path of
133+
// every refusal, schema-side dotted and lint-side bracketed.
134+
describe('[#23210] the parse and the lint agree on the merged shape', () => {
135+
const body = (id: string, extra: Record<string, unknown>) => ({ manifest: { id, name: id, version: '1.0.0', type: 'app', ...extra } });
136+
137+
it.each<[string, Record<string, unknown>, string[]]>([
138+
['the card\'s stack', CARD_STACK, ['objectExtensions.0.fields']],
139+
['a non-master field', extending({ other: { type: 'text' } }), []],
140+
['a demoted master', extending({ header: { type: 'master_detail', reference: 'cbpx_header', required: false } }), ['objectExtensions.0.fields']],
141+
['an unseen target', extending({ other: OTHER }, 'crm_line'), []],
142+
['a non-controlled target', { ...CARD_STACK, objects: OBJECTS({ header: HEADER }, 'private') }, []],
143+
[
144+
'a cross-package extension',
145+
{
146+
packages: [
147+
body('com.acme.ext', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: OTHER } }] }),
148+
body('com.acme.core', { objects: OBJECTS() }),
149+
],
150+
},
151+
['packages.0.manifest.objectExtensions.0.fields'],
152+
],
153+
[
154+
'a later writer restoring one master',
155+
{
156+
packages: [
157+
body('com.acme.adder', { objectExtensions: [{ extend: 'cbpx_line', priority: 200, fields: { other: OTHER } }] }),
158+
body('com.acme.fixer', { objectExtensions: [{ extend: 'cbpx_line', priority: 300, fields: { other: { type: 'text' } } }] }),
159+
body('com.acme.core', { objects: OBJECTS() }),
160+
],
161+
},
162+
[],
163+
],
164+
[
165+
'two packages tied on one key',
166+
{
167+
packages: [
168+
body('com.acme.adder', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: OTHER } }] }),
169+
body('com.acme.fixer', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: { type: 'text' } } }] }),
170+
body('com.acme.core', { objects: OBJECTS() }),
171+
],
172+
},
173+
[],
174+
],
175+
])('%s', (_label, stack, refusedAt) => {
176+
const parsed = ObjectStackDefinitionSchema.safeParse(stack);
177+
const parseIssues = parsed.success
178+
? []
179+
: parsed.error.issues
180+
.filter((i) => /declares sharingModel 'controlled_by_parent'/.test(i.message))
181+
.map((i) => i.path.join('.'));
182+
expect(parseIssues).toEqual(refusedAt);
183+
// An accepted shape parses WHOLE, so a silence is the rule's and not a
184+
// refinement skipped behind some other refusal.
185+
if (refusedAt.length === 0) expect(parsed.success).toBe(true);
186+
187+
const lintPaths = cbpFindings(stack).map((f) => f.path.replace(/\[(\d+)\]/g, '.$1'));
188+
expect(lintPaths).toEqual(refusedAt);
189+
});
190+
});

‎packages/lint/src/validate-security-posture.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1463,6 +1463,12 @@ const NOT_SCHEMA_RECEIVERS: Record<string, string> = {
14631463
// scanned where they happen (in `object-graph.ts`, guarded by its own tests),
14641464
// not here; this rule never touches an authored field record directly.
14651465
surface: "a `GraphObject` — `object-graph.ts`'s resolved per-object surface, not an authored one.",
1466+
// [#23210] The merged-shape pass folds each `objectExtensions` entry into a
1467+
// `VisibleExtension` ({ carrier, path, priority, fields }) BEFORE it reads
1468+
// one; the authored reads behind it are scanned on `ext` and `f`.
1469+
visible: "a `VisibleExtension` — this file's own fold of one extension entry, not an authored surface.",
1470+
left: 'a `VisibleExtension`, the left operand of the fold-order comparator.',
1471+
right: 'a `VisibleExtension`, the right operand of the fold-order comparator.',
14661472
};
14671473

14681474
const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [
@@ -1472,7 +1478,11 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
14721478
// started passing the stack's own capability declarations to the predicate
14731479
// as `AnchorBindingContext.declaredCapabilities` — a declared token is the
14741480
// app's own gate, not a platform system permission.
1475-
expected: ['apps', 'books', 'capabilities', 'data', 'objects', 'permissions', 'positions'],
1481+
// [#23210] `objectExtensions` and `packages` joined when the
1482+
// controlled-by-parent rules started judging an object on its MERGED
1483+
// field set: the extensions a stack (or each package body of an artifact)
1484+
// declares are folded onto the objects it declares.
1485+
expected: ['apps', 'books', 'capabilities', 'data', 'objectExtensions', 'objects', 'packages', 'permissions', 'positions'],
14761486
declaredBy: 'ObjectStackSchema',
14771487
keys: () => Object.keys(ObjectStackSchema.shape),
14781488
},
@@ -1558,6 +1568,28 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
15581568
declaredBy: 'ObjectStackSchema.data[]',
15591569
keys: () => shapeKeysOf(ObjectStackSchema.shape.data),
15601570
},
1571+
// [#23210] The merged-shape pass of the controlled-by-parent rules: an
1572+
// artifact entry (`packages[]`), its assembled body, and one
1573+
// `objectExtensions` entry — each read for the keys the registry's merge
1574+
// reads, and no other.
1575+
{
1576+
receiver: 'entry',
1577+
expected: ['manifest'],
1578+
declaredBy: 'ObjectStackSchema.packages[]',
1579+
keys: () => shapeKeysOf(ObjectStackSchema.shape.packages),
1580+
},
1581+
{
1582+
receiver: 'body',
1583+
expected: ['objectExtensions', 'objects'],
1584+
declaredBy: 'ObjectStackSchema.packages[].manifest',
1585+
keys: () => shapeKeysOf(shapeOf(ObjectStackSchema.shape.packages).manifest),
1586+
},
1587+
{
1588+
receiver: 'ext',
1589+
expected: ['extend', 'fields', 'priority'],
1590+
declaredBy: 'ObjectStackSchema.objectExtensions[]',
1591+
keys: () => shapeKeysOf(ObjectStackSchema.shape.objectExtensions),
1592+
},
15611593
// [#18535] The ADR-0066 D1 capability declarations the anchor rule reads: it
15621594
// wants their NAMES and hands the declarations themselves to the predicate,
15631595
// which reads `name` and ignores every other field — so nothing is
@@ -1612,6 +1644,9 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te
16121644
'candidates', // #23100: `resolveCbpRelation`'s candidate list — `.length`, a JS property.
16131645
'cbpCandidates', // #23100: one object's required master_detail list — `.length` / `.map`, JS methods.
16141646
'declaredCapabilities', // #18535: the stack's own capability list — `.length`, a JS property.
1647+
// #23210: the merged-shape pass's own collections — JS Map / Set / Array methods.
1648+
'bodies', 'entries', 'targets', 'owners', 'extensions', 'byCarrier', 'writers', 'tiedWriters',
1649+
'merged', 'baseFields', 'blamed',
16151650
]);
16161651
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
16171652
});

0 commit comments

Comments
 (0)