Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/23210-cbp-extension-merged-master.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

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)

Clause-②: no (narrowing)

<!-- adr-0087: registered cbp-extension-merged-master-refused -->

**BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes.

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`.

**What is refused, and where.**

- **`@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.
- **`@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.

No export, signature or error code is added or removed.

## FROM → TO

| stack | FROM | TO |
|:--|:--|:--|
| `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`)…" |
| 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…" |
| an artifact built before this change that carries either shape | loaded; the object failed closed | refused at load; rebuild it from the corrected source |

**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.

**Unchanged.**

- An extension adding any non-master field (a `lookup` to another parent included) to a `controlled_by_parent` object.
- 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.
- An object already refused as declared is reported once, at the object.
- 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.
- 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.
- 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`.
190 changes: 190 additions & 0 deletions packages/lint/src/validate-security-posture-extension-merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#23210 · ADR-0055 §1] `security-controlled-by-parent-ambiguous-relation`
* and `security-controlled-by-parent-no-relation`, judged on the MERGED field
* set of a `controlled_by_parent` object that an `objectExtensions` entry of
* the same stack extends (`extensionMergedCbpFindings`).
*
* The stack schema refuses the same merged shapes
* (`applyControlledByParentExtensionGate`, `@objectstack/spec`); this is the
* door an unparsed intake meets. Every refusal below is pinned by rule id,
* path and message — the message compared with the finding the same object
* gets when it is declared directly — and every silence beside a twin that
* differs in the one variable under test and is reported. The last block
* holds the two doors to one verdict per shape.
*/
import { describe, it, expect } from 'vitest';
import { ObjectStackDefinitionSchema } from '@objectstack/spec';
import {
validateSecurityPosture,
SECURITY_CBP_AMBIGUOUS_RELATION,
SECURITY_CBP_NO_RELATION,
} from './validate-security-posture.js';

const HEADER = { type: 'master_detail', reference: 'cbpx_header', required: true };
const OTHER = { type: 'master_detail', reference: 'cbpx_other', required: true };

const OBJECTS = (lineFields: Record<string, unknown> = { header: HEADER }, sharingModel = 'controlled_by_parent') => [
{ name: 'cbpx_header', sharingModel: 'private', fields: { title: { type: 'text' } } },
{ name: 'cbpx_other', sharingModel: 'private', fields: { title: { type: 'text' } } },
{ name: 'cbpx_line', sharingModel, fields: lineFields },
];

const extending = (fields: Record<string, unknown>, extend = 'cbpx_line') => ({
objects: OBJECTS(),
objectExtensions: [{ extend, fields }],
});

/** The card's stack: one declared master, a second one added by an extension. */
const CARD_STACK = extending({ other: OTHER });

const cbpFindings = (stack: Record<string, unknown>) =>
validateSecurityPosture(stack).filter(
(f) => f.rule === SECURITY_CBP_AMBIGUOUS_RELATION || f.rule === SECURITY_CBP_NO_RELATION,
);

describe('[#23210] validateSecurityPosture judges a controlled_by_parent object on its merged field set', () => {
it('the card\'s stack: ambiguous-relation at the extension\'s fields, in the words of the declared control', () => {
const findings = cbpFindings(CARD_STACK);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'error',
rule: SECURITY_CBP_AMBIGUOUS_RELATION,
where: 'object "cbpx_line"',
path: 'objectExtensions[0].fields',
});

const control = cbpFindings({ objects: OBJECTS({ header: HEADER, other: OTHER }) });
expect(control.map((f) => [f.rule, f.path])).toEqual([[SECURITY_CBP_AMBIGUOUS_RELATION, 'objects[2].fields']]);
expect(findings[0].message).toBe(control[0].message);
expect(findings[0].hint).toBe(control[0].hint);
expect(findings[0].message).toContain('"header" → cbpx_header, "other" → cbpx_other');
});

it('the single-object control is unchanged, and its declared base alone is clean', () => {
expect(cbpFindings({ objects: OBJECTS({ header: HEADER, other: OTHER }) }).map((f) => f.path)).toEqual([
'objects[2].fields',
]);
expect(cbpFindings({ objects: OBJECTS() })).toEqual([]);
});

it('an extension adding a non-master field is silent; its master-adding twin is reported', () => {
expect(cbpFindings(extending({ other: { type: 'text' } }))).toEqual([]);
expect(cbpFindings(extending({ other: { type: 'lookup', reference: 'cbpx_other', required: true } }))).toEqual([]);
expect(cbpFindings(extending({ other: OTHER })).map((f) => f.path)).toEqual(['objectExtensions[0].fields']);
});

it('an extension that demotes the one master is no-relation at the extension, in the words of the declared control', () => {
for (const demoted of [
{ type: 'master_detail', reference: 'cbpx_header', required: false },
{ type: 'text' },
{ type: 'lookup', reference: 'cbpx_header', required: true },
]) {
const findings = cbpFindings(extending({ header: demoted }));
expect(findings.map((f) => [f.rule, f.path]), JSON.stringify(demoted)).toEqual([
[SECURITY_CBP_NO_RELATION, 'objectExtensions[0].fields'],
]);
const control = cbpFindings({ objects: OBJECTS({ header: demoted }) });
expect(control.map((f) => f.path)).toEqual(['objects[2].sharingModel']);
expect(findings[0].message).toBe(control[0].message);
}
});

it('an extension whose target the stack does not declare is unjudged; the visible twin is reported', () => {
expect(cbpFindings(extending({ a: OTHER, b: HEADER }, 'crm_line'))).toEqual([]);
expect(cbpFindings(extending({ other: OTHER }, 'cbpx__cbpx_line'))).toEqual([]);
expect(cbpFindings(extending({ other: OTHER }, 'cbpx_line'))).toHaveLength(1);
});

it('a target already reported as declared is reported once, at the object', () => {
const stack = {
objects: OBJECTS({ header: HEADER, other: OTHER }),
objectExtensions: [{ extend: 'cbpx_line', fields: { note: { type: 'text' } } }],
};
expect(cbpFindings(stack).map((f) => f.path)).toEqual(['objects[2].fields']);
});

it('reads the array field form of an extension too', () => {
const stack = { objects: OBJECTS(), objectExtensions: [{ extend: 'cbpx_line', fields: [{ name: 'other', ...OTHER }] }] };
expect(cbpFindings(stack).map((f) => f.path)).toEqual(['objectExtensions[0].fields']);
});

it('across the packages of one artifact: an extension in one body judges another body\'s object', () => {
const body = (id: string, extra: Record<string, unknown>) => ({ manifest: { id, name: id, version: '1.0.0', type: 'app', ...extra } });
const stack = (field: unknown) => ({
packages: [
body('com.acme.ext', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: field } }] }),
body('com.acme.core', { objects: OBJECTS() }),
],
});
expect(cbpFindings(stack(OTHER)).map((f) => [f.rule, f.path])).toEqual([
[SECURITY_CBP_AMBIGUOUS_RELATION, 'packages[0].manifest.objectExtensions[0].fields'],
]);
expect(cbpFindings(stack({ type: 'text' }))).toEqual([]);
});
});

// ── The two doors, one verdict per shape ───────────────────────────────────
//
// The stack schema and this rule each read the merge (`@objectstack/lint`
// imports no runtime and the schema's reader is internal to the spec), so the
// table below holds them to the same answer on the same stacks: the path of
// every refusal, schema-side dotted and lint-side bracketed.
describe('[#23210] the parse and the lint agree on the merged shape', () => {
const body = (id: string, extra: Record<string, unknown>) => ({ manifest: { id, name: id, version: '1.0.0', type: 'app', ...extra } });

it.each<[string, Record<string, unknown>, string[]]>([
['the card\'s stack', CARD_STACK, ['objectExtensions.0.fields']],
['a non-master field', extending({ other: { type: 'text' } }), []],
['a demoted master', extending({ header: { type: 'master_detail', reference: 'cbpx_header', required: false } }), ['objectExtensions.0.fields']],
['an unseen target', extending({ other: OTHER }, 'crm_line'), []],
['a non-controlled target', { ...CARD_STACK, objects: OBJECTS({ header: HEADER }, 'private') }, []],
[
'a cross-package extension',
{
packages: [
body('com.acme.ext', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: OTHER } }] }),
body('com.acme.core', { objects: OBJECTS() }),
],
},
['packages.0.manifest.objectExtensions.0.fields'],
],
[
'a later writer restoring one master',
{
packages: [
body('com.acme.adder', { objectExtensions: [{ extend: 'cbpx_line', priority: 200, fields: { other: OTHER } }] }),
body('com.acme.fixer', { objectExtensions: [{ extend: 'cbpx_line', priority: 300, fields: { other: { type: 'text' } } }] }),
body('com.acme.core', { objects: OBJECTS() }),
],
},
[],
],
[
'two packages tied on one key',
{
packages: [
body('com.acme.adder', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: OTHER } }] }),
body('com.acme.fixer', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: { type: 'text' } } }] }),
body('com.acme.core', { objects: OBJECTS() }),
],
},
[],
],
])('%s', (_label, stack, refusedAt) => {
const parsed = ObjectStackDefinitionSchema.safeParse(stack);
const parseIssues = parsed.success
? []
: parsed.error.issues
.filter((i) => /declares sharingModel 'controlled_by_parent'/.test(i.message))
.map((i) => i.path.join('.'));
expect(parseIssues).toEqual(refusedAt);
// An accepted shape parses WHOLE, so a silence is the rule's and not a
// refinement skipped behind some other refusal.
if (refusedAt.length === 0) expect(parsed.success).toBe(true);

const lintPaths = cbpFindings(stack).map((f) => f.path.replace(/\[(\d+)\]/g, '.$1'));
expect(lintPaths).toEqual(refusedAt);
});
});
37 changes: 36 additions & 1 deletion packages/lint/src/validate-security-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,12 @@ const NOT_SCHEMA_RECEIVERS: Record<string, string> = {
// scanned where they happen (in `object-graph.ts`, guarded by its own tests),
// not here; this rule never touches an authored field record directly.
surface: "a `GraphObject` — `object-graph.ts`'s resolved per-object surface, not an authored one.",
// [#23210] The merged-shape pass folds each `objectExtensions` entry into a
// `VisibleExtension` ({ carrier, path, priority, fields }) BEFORE it reads
// one; the authored reads behind it are scanned on `ext` and `f`.
visible: "a `VisibleExtension` — this file's own fold of one extension entry, not an authored surface.",
left: 'a `VisibleExtension`, the left operand of the fold-order comparator.',
right: 'a `VisibleExtension`, the right operand of the fold-order comparator.',
};

const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [
Expand All @@ -1489,7 +1495,11 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
// started passing the stack's own capability declarations to the predicate
// as `AnchorBindingContext.declaredCapabilities` — a declared token is the
// app's own gate, not a platform system permission.
expected: ['apps', 'books', 'capabilities', 'data', 'objects', 'permissions', 'positions'],
// [#23210] `objectExtensions` and `packages` joined when the
// controlled-by-parent rules started judging an object on its MERGED
// field set: the extensions a stack (or each package body of an artifact)
// declares are folded onto the objects it declares.
expected: ['apps', 'books', 'capabilities', 'data', 'objectExtensions', 'objects', 'packages', 'permissions', 'positions'],
declaredBy: 'ObjectStackSchema',
keys: () => Object.keys(ObjectStackSchema.shape),
},
Expand Down Expand Up @@ -1575,6 +1585,28 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
declaredBy: 'ObjectStackSchema.data[]',
keys: () => shapeKeysOf(ObjectStackSchema.shape.data),
},
// [#23210] The merged-shape pass of the controlled-by-parent rules: an
// artifact entry (`packages[]`), its assembled body, and one
// `objectExtensions` entry — each read for the keys the registry's merge
// reads, and no other.
{
receiver: 'entry',
expected: ['manifest'],
declaredBy: 'ObjectStackSchema.packages[]',
keys: () => shapeKeysOf(ObjectStackSchema.shape.packages),
},
{
receiver: 'body',
expected: ['objectExtensions', 'objects'],
declaredBy: 'ObjectStackSchema.packages[].manifest',
keys: () => shapeKeysOf(shapeOf(ObjectStackSchema.shape.packages).manifest),
},
{
receiver: 'ext',
expected: ['extend', 'fields', 'priority'],
declaredBy: 'ObjectStackSchema.objectExtensions[]',
keys: () => shapeKeysOf(ObjectStackSchema.shape.objectExtensions),
},
// [#18535] The ADR-0066 D1 capability declarations the anchor rule reads: it
// wants their NAMES and hands the declarations themselves to the predicate,
// which reads `name` and ignores every other field — so nothing is
Expand Down Expand Up @@ -1629,6 +1661,9 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te
'candidates', // #23100: `resolveCbpRelation`'s candidate list — `.length`, a JS property.
'cbpCandidates', // #23100: one object's required master_detail list — `.length` / `.map`, JS methods.
'declaredCapabilities', // #18535: the stack's own capability list — `.length`, a JS property.
// #23210: the merged-shape pass's own collections — JS Map / Set / Array methods.
'bodies', 'entries', 'targets', 'owners', 'extensions', 'byCarrier', 'writers', 'tiedWriters',
'merged', 'baseFields', 'blamed',
]);
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
});
Expand Down
Loading
Loading