Skip to content

Commit 4e0838d

Browse files
fix(spec,lint)!: an objectExtensions entry that leaves a controlled_by_parent object without exactly one required master_detail is refused by defineStack and the posture lint (#23454)
Fixes #23210 Clause-②: no (narrowing) ## What changes ADR-0055 §1 says a `controlled_by_parent` object derives its master from exactly one `master_detail` field declared `required: true`. `ObjectSchema` and the security-posture lint refuse an object that declares anything else. Neither judged the object the runtime runs: at boot the registry folds every `objectExtensions` entry onto its target, so an extension could add a second required master, or demote the one. The stack passed `defineStack`, `os validate` and the lint, and the runtime then derived no master and failed closed. Both authoring doors now judge the **merged** field set when the stack declares the target: - **`@objectstack/spec`**: a new refinement, `applyControlledByParentExtensionGate`, chained on `ObjectStackDefinitionSchema` after `applyApiEndpointGates` and `applyPermissionSetOwnerBindingGate`. It calls the existing `controlledByParentMasterRefusal` (unchanged, still internal) on the merged fields, so the message is the declared object's own. `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying the issue. - **`@objectstack/lint`**: `validateSecurityPosture` gains a merged-shape pass that reports `security-controlled-by-parent-ambiguous-relation` or `-no-relation` at the extension. The two finding sites are now shared push helpers, so the merged and the declared finding are written once. - An ADR-0087 step-18 semantic entry, `cbp-extension-merged-master-refused`, with `relevantWhen: objectExtensions`, and a BREAKING changeset with the FROM → TO table and the remedy. No export, signature or error code is added or removed (`check:api-surface`: unchanged). Not touched: `packages/objectql/src/engine.ts` (the runtime merge and its fail-closed refusal stay the backstop) and `ObjectExtensionSchema`'s own accept set. ## The visibility reading implemented (H2 / H3) - **Name resolution (H2): exact `name` equality, nothing looser.** The registry keys an extension by its `extend` string verbatim (`computeFQN` is the identity), and `getObject` resolves a short name to the first key registered under it. Measured on the registry: `extend: 'cbpx__cbpx_line'` opens its own ownerless entry and never merges into `cbpx_line`. So a prefixed spelling is unjudged, and pinned so. - **`packages[]` (H3): the artifact is the visibility boundary.** With no `packages[]`, the stack's `objects` are extended by its `objectExtensions`. With `packages[]`, an extension in any body judges an object any body declares, or a top-level object no body declares (the residual the engine registers too). Top-level `objectExtensions` are not read there, because the engine registers a multi-package stack's bodies and the residual's objects, never its top-level extensions (`ObjectQLPlugin`'s `manifest.register`, `unclaimedTopLevelObjects`). Why not one-body-only: `composeStacks(…, { manifest: 'preserve' })` emits every definition inside `packages[]` only, so a per-body reading would leave the cross-package case, which is what `objectExtensions` is for, unjudged at `os validate` and `os build`. The lint's precedent is the same reach (`validate-translation-references.ts` and `validate-mapping-target-fields.ts` fold `packages[].manifest.objectExtensions`). - **Unjudged, on purpose:** a target the stack does not declare; a target two bodies declare (an ownership conflict the registry refuses on its own); a target already refused as declared (reported once, at the object); and a target on which two packages write one field key at the `priority` that decides it. In that last case the registry's winner is registration order (dependency-topological, `resolveArtifactPackageOrder` in `@objectstack/core`), which the spec cannot compute. Measured: at p200 vs p200 the later-registered package won. ## The merge, as measured on the registry (H1) A throwaway probe against `SchemaRegistry` on this branch (scratch, not committed): | case | merged `cbpx_line` | |:--|:--| | the card's extension adds `other` | `master_detail=[header:true, other:true]` | | extension redeclares `header` with `required: false` | `master_detail=[header:false]`: the whole field is replaced by name | | extension body carries `sharingModel: 'private'` | `sharingModel=controlled_by_parent`: not carried | | adder p200, fixer (`other: text`) p300 | one master (the fixer wins) | | adder p300, fixer p200 | two masters (the adder wins) | | adder p200, fixer p200 | one master (the later-registered fixer wins: a tie is registration order) | | one package, two entries for one target | the later entry replaces the earlier | | extender at p50, below the owner's 100 | still folded on top of the base | The judge computes exactly that: base fields, then extensions in ascending `priority` (equal priority in package order), one per package per target (last entry wins), each field replaced whole. ## Where the issue sits (H5) At the `fields` of each extension that wrote a key which is a `master_detail` in the declaration or in the merged set: `objectExtensions.0.fields`, or `packages.0.manifest.objectExtensions.0.fields`. That is the line that added the second master or demoted the one. The single-object control is unchanged: `objects.N.fields` for two masters, `objects.N.sharingModel` for none. The merged issue's message is byte-equal to the control's (pinned). ## The lint door (H4) `os validate` parses with `ObjectStackDefinitionSchema.safeParse` (`validate.ts`, step 2b) before the rule table runs, so with this change the card's stack stops at the parse and the lint never sees it there. The rule table's input is the parsed union stack, which keeps `objectExtensions` (a declared key). The lint pin still matters for the unparsed callers: `os lint` on a raw config, the lint scorer, and direct calls. The lint test file holds the two doors to one verdict per shape (8 shapes, parse paths equal to lint paths). ## Tests At `4daa50d71` (this branch with `main` `a7a135dc2` merged, after the seat's overlap notices for #23411 and #23412), unless noted. `main` has since moved to `05ab9ef89`, and none of those commits touches a file in this diff: - `@objectstack/spec`, unit layer (`vitest run --project local`): 675 files, 20487 passed, 1 todo. - `@objectstack/lint`, full: 139 files, 6718 passed. - `@objectstack/objectql`, unit layer (`--project local`): 415 files, 8087 passed. - `@objectstack/cli`, `--project unit`: 285 files, 4278 passed, 29 skipped. Two more files (`published-subpath-console.pin`, `published-subpath-hook-body.pin`) refuse to run without `packages/cli/dist`, so `cli` was built and they were re-run: 2 files, 29 passed. The `integration` layer is declared to CI (no spawn entry or integration file touched). - Typecheck: `@objectstack/spec` green at `f9744329d` (the merge moved no spec source); `@objectstack/lint` green at `4daa50d71`. New pins: `packages/spec/src/stack-cbp-extension-master.test.ts` (12) and `packages/lint/src/validate-security-posture-extension-merge.test.ts` (16). Each refusal asserts the issue's `code` and path (or the finding's rule id and path) and compares the message with the declared control. Each silence sits beside a twin that differs in one variable and is refused. The #5017 source-scan meta-tests in `validate-security-posture.test.ts` table the new reads (`stack.objectExtensions`, `stack.packages`, `entry.manifest`, `body.objects` / `body.objectExtensions`, `ext.extend` / `ext.fields` / `ext.priority`), each checked against the live schema shape. **Ablations** (committed state, through `scripts/ablation-replace.mjs`, which verifies the mutation and its restore on disk): - A1, spec: drop `.superRefine(applyControlledByParentExtensionGate)` (anchor 1 → 0, blob `ecc31d4e3de3` → `b0e64ef6c625`). The spec pin file goes 9 failed, 3 passed. The 3 that stay green are the ones that do not depend on the refinement: the control, the already-refused object, and the tie. Restored: blob equals HEAD, `git diff HEAD` empty. - A2, lint: delete the `pushExtensionMergedCbpFindings(findings, stack);` call (blob `1b241d17dfd0` → `5a4bafa4383f`). The lint pin file goes 9 failed, 7 passed (the 7 assert silence or the declared control). Restored, verified the same way. - Both subjects resolve through source (`./stack.zod`, `./validate-security-posture.js`), so no dist rebuild was owed. **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 91 families for this diff at `4daa50d71`. All 91 were run there with exit codes captured before any pipe, and `--ran` reconciles: 89 run (all exit 0), 2 NOT MEASURED. `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: it needs a full-workspace `pnpm build`, and only the `cli` closure was built. `check:type-check-debt` was not run: it is a whole-repo tsc census owed to CI's type-check job. These include `check-adr-0087-registration --base origin/main` (registered `cbp-extension-merged-master-refused`, new here), `check:api-surface` (unchanged), `check:authorable-surface`, `check:spec-changes`, `check:upgrade-guide`, `check:nul-bytes` and `check:spec-docblock-symbol-anchors`. Local-only families: `check:skill-frame-freshness` exit 0. `check:dev-prereqs` exit 1 is an unmet local precondition, not a verdict on this diff: 9 workspace packages outside the `cli` closure are unbuilt. `spec/dist` was built from this head's spec source. `pnpm --filter @objectstack/spec check:generated` reports all 14 artifacts current, so no generated projection moves. ## Stored metadata No stored `sys_metadata` row can carry the shape: an extension is merged only when a stack or an artifact registers, and `object_extension` is not a registered metadata type (`DEFAULT_METADATA_TYPE_REGISTRY` has no entry; the name appears only in the metadata plugin's artifact field map). An artifact built before 18 that carries the shape is refused at load (the artifact door and `EnvironmentArtifactSchema.metadata` parse `ObjectStackDefinitionSchema`). That is the same posture as the directly declared shape since `cbp-master-relation-tiers-retired`. The remedy is to rebuild from the corrected source. ## Consumer sweep `examples/**` and the spec, lint, CLI and other package fixtures: no `objectExtensions` entry declares a `master_detail` field. The showcase's one extension (`examples/app-showcase/src/data/extensions/account.extension.ts`, on `showcase_account`, which is not `controlled_by_parent`) adds `select`, `url` and `number` fields. Nothing to re-spell. ## Acceptance notes - `os explain security-controlled-by-parent-ambiguous-relation` (and `-no-relation`, `rule-explanations.ts`) still describes the declared object only. It now also fires for a merged one, and a sentence there would say so. It was left out of this diff's file surface. Carrier: none. - `validateSecurityPosture` now reads `stack.packages` through `packagesOf`, so a malformed (non-array) `packages` throws `INVALID_ARTIFACT_PACKAGES` from this rule too. That matches its sibling readers (`validate-translation-references.ts`, `validate-mapping-target-fields.ts`, `validate-object-references.ts`) and the schema, which refuses the shape first. - The merge reading is written twice, in the spec refinement and in the lint pass, because `controlledByParentMasterRefusal` and the fold are internal to `@objectstack/spec`, and exporting them would be a published widening. The parity table in the lint test holds them together. --- _Generated by [Claude Code](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 08ea7e3 commit 4e0838d

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)