diff --git a/.changeset/23210-cbp-extension-merged-master.md b/.changeset/23210-cbp-extension-merged-master.md new file mode 100644 index 00000000000..a6fcffbf104 --- /dev/null +++ b/.changeset/23210-cbp-extension-merged-master.md @@ -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) + + + +**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`. diff --git a/packages/lint/src/validate-security-posture-extension-merge.test.ts b/packages/lint/src/validate-security-posture-extension-merge.test.ts new file mode 100644 index 00000000000..b8368d94026 --- /dev/null +++ b/packages/lint/src/validate-security-posture-extension-merge.test.ts @@ -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 = { 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, 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) => + 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) => ({ 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) => ({ manifest: { id, name: id, version: '1.0.0', type: 'app', ...extra } }); + + it.each<[string, Record, 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); + }); +}); diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index b0856b648dc..5fe7cec43b2 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -1480,6 +1480,12 @@ const NOT_SCHEMA_RECEIVERS: Record = { // 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[] }> = [ @@ -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), }, @@ -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 @@ -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([]); }); diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 95a4321e5f4..1b241d17dfd 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -120,7 +120,7 @@ import { referenceCarrierOf } from '@objectstack/spec/data'; import { describeAnchorForbiddenBits, type AnchorBindingContext } from '@objectstack/spec/security'; -import { indexObjectGraph, nearestName, recordsOf, type ObjectGraph } from './object-graph.js'; +import { indexObjectGraph, nearestName, packagesOf, recordsOf, type ObjectGraph } from './object-graph.js'; export const SECURITY_OWD_UNSET = 'security-owd-unset'; export const SECURITY_OWD_ALIAS = 'security-owd-alias'; @@ -406,6 +406,199 @@ function grantsObjectAccess(p: AnyRec): boolean { ); } +/** + * `security-controlled-by-parent-no-relation` for one object, at `path`. One + * site for the declared object and the merged one + * ({@link pushExtensionMergedCbpFindings}), so the two read in one wording. + */ +function pushCbpNoRelation(findings: SecurityFinding[], objName: string, path: string): void { + findings.push({ + severity: 'error', + rule: SECURITY_CBP_NO_RELATION, + where: `object "${objName}"`, + path, + // [#22161] One verdict sentence; the ADR-0055 rule it tested and why + // the object is unusable rather than locked down are this id's + // `os explain` entry. + message: + `declares sharingModel 'controlled_by_parent' but no required master_detail field to derive access ` + + `from, so every read is denied and every write refused (422 INVALID_METADATA)`, + // [#22161] One remedy line; that an object with no master decides its + // own baseline, and what each value means, is this id's `os explain` + // entry. + hint: + `Add the master relation, e.g. fields.parent: { type: 'master_detail', reference: '', ` + + `required: true }, or set its own sharingModel: 'private', 'public_read' or 'public_read_write'`, + }); +} + +/** + * `security-controlled-by-parent-ambiguous-relation` for one object, at + * `path`, naming its candidates. Shared like {@link pushCbpNoRelation}. + */ +function pushCbpAmbiguousRelation( + findings: SecurityFinding[], + objName: string, + path: string, + candidates: CbpRelation[], +): void { + const roster = candidates.map((cand) => `"${cand.field}" → ${cand.master}`).join(', '); + findings.push({ + severity: 'error', + rule: SECURITY_CBP_AMBIGUOUS_RELATION, + where: `object "${objName}"`, + path, + // [#22161] One verdict sentence; why exactly one master is the rule is + // this id's `os explain` entry. + message: + `declares ${candidates.length} required master_detail fields (${roster}), so no ` + + `controlled_by_parent master is derived: every read denied, every write refused`, + // [#22161] One remedy line. + hint: + `Keep master_detail on the ONE master this object's rows belong to, and declare every other parent ` + + `as a lookup field`, + }); +} + +/** One `objectExtensions` entry as the registry registers it — see {@link pushExtensionMergedCbpFindings}. */ +interface VisibleExtension { + /** The position of the package that ships it: registration order across packages. */ + carrier: number; + /** The entry's path, e.g. `objectExtensions[0]`. */ + path: string; + /** `ext.priority ?? 200`, the registry's default. */ + priority: number; + /** Its field records, name folded in (`recordsOf`). */ + fields: AnyRec[]; +} + +/** + * [#23210 · ADR-0055 §1] `security-controlled-by-parent-no-relation` and + * `security-controlled-by-parent-ambiguous-relation`, judged on the MERGED + * field set of a `controlled_by_parent` object that an `objectExtensions` + * entry of this stack extends. + * + * The registry folds every extension onto its target at boot, so an object + * that passes the per-object rules above as declared can run with two required + * master_detail fields, or none, and the runtime then derives no master and + * fails closed. The stack schema refuses the same merged shape + * (`applyControlledByParentExtensionGate`, `@objectstack/spec`'s + * `stack.zod.ts`); this is the door an UNPARSED intake meets — `os lint` on a + * raw config, a direct call — and the tests hold the two verdicts equal. + * + * The merge is read as the registry computes it (`mergeObjectDefinitions`, + * `SchemaRegistry.resolveObject`), and as the schema door reads it, since + * `@objectstack/lint` imports no runtime: the target's own declaration is the + * base and keeps its `sharingModel`; fields merge by name, a later writer + * replacing the whole field; extensions apply in ascending `priority`, equal + * priorities in registration order, one per package per target (the later + * entry wins). `extend` names its target verbatim. + * + * Visible targets, as the schema door has them: with no `packages[]`, the + * stack's `objects` extended by its `objectExtensions`; with `packages[]`, the + * objects every body declares (plus a top-level object no body declares) + * extended by every body's `objectExtensions` — the artifact is the + * co-ownership boundary, the reach `validate-translation-references.ts` + * already takes for extension FIELDS. Unjudged, as by the schema: a target + * the stack does not declare (the blind spot `validate-object-references.ts` + * states), a target two bodies declare, a target whose own declaration + * already breaks the rule (reported above, at its own path), and a target on + * which two packages write one field key at the priority that decides it. + * + * Each finding sits at the `fields` of an extension that wrote a key which is + * a `master_detail` in the declaration or in the merged set — the line that + * added the second master, or demoted the one. + */ +function pushExtensionMergedCbpFindings(findings: SecurityFinding[], stack: AnyRec): void { + const multi = Array.isArray(stack.packages); + const bodies: AnyRec[] = packagesOf(stack).map((entry) => + entry.manifest && typeof entry.manifest === 'object' && !Array.isArray(entry.manifest) + ? (entry.manifest as AnyRec) + : {}, + ); + + // Target name → its declaration, or `null` when two packages claim it. + const targets = new Map(); + const owners = new Map(); + const declare = (objects: AnyRec[], carrier: number) => { + for (const obj of objects) { + const name = typeof obj.name === 'string' && obj.name.length > 0 ? obj.name : undefined; + if (!name || targets.get(name) === null) continue; + const owner = owners.get(name); + if (owner !== undefined && owner !== carrier) { + targets.set(name, null); + continue; + } + owners.set(name, carrier); + targets.set(name, obj); + } + }; + if (!multi) { + declare(recordsOf(stack.objects), 0); + } else { + bodies.forEach((body, j) => declare(recordsOf(body.objects), j)); + declare(recordsOf(stack.objects).filter((obj) => typeof obj.name === 'string' && !owners.has(obj.name)), bodies.length); + } + + // Target name → its extensions, one per package. + const extensions = new Map>(); + const collect = (entries: unknown, carrier: number, at: string) => { + if (!Array.isArray(entries)) return; + entries.forEach((entry: unknown, i: number) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return; + const ext = entry as AnyRec; + if (typeof ext.extend !== 'string' || !targets.get(ext.extend)) return; + const byCarrier = extensions.get(ext.extend) ?? new Map(); + byCarrier.set(carrier, { + carrier, + path: `${at}objectExtensions[${i}]`, + priority: typeof ext.priority === 'number' ? ext.priority : 200, + fields: recordsOf(ext.fields), + }); + extensions.set(ext.extend, byCarrier); + }); + }; + if (!multi) collect(stack.objectExtensions, 0, ''); + else bodies.forEach((body, j) => collect(body.objectExtensions, j, `packages[${j}].manifest.`)); + + for (const [target, byCarrier] of extensions) { + const obj = targets.get(target); + if (!obj || owdOf(obj) !== 'controlled_by_parent') continue; + if (cbpMasterCandidates(obj).length !== 1) continue; + + const baseFields = new Map(recordsOf(obj.fields).map((f) => [String(f.name ?? '?'), f] as const)); + const merged = new Map(baseFields); + const ordered = [...byCarrier.values()].sort( + (left, right) => left.priority - right.priority || left.carrier - right.carrier, + ); + const writers = new Map(); + // Per key, how many packages wrote it at the priority of its last writer. + const tiedWriters = new Map(); + for (const visible of ordered) { + for (const f of visible.fields) { + const key = String(f.name ?? '?'); + const tied = writers.get(key)?.priority === visible.priority; + tiedWriters.set(key, tied ? (tiedWriters.get(key) ?? 1) + 1 : 1); + writers.set(key, visible); + merged.set(key, f); + } + } + if ([...tiedWriters.values()].some((count) => count > 1)) continue; + + const candidates = cbpMasterCandidates({ ...obj, fields: [...merged.values()] }); + if (candidates.length === 1) continue; + const blamed = new Set(); + for (const [key, visible] of writers) { + if (merged.get(key)?.type === 'master_detail' || baseFields.get(key)?.type === 'master_detail') blamed.add(visible); + } + for (const visible of ordered) { + if (!blamed.has(visible)) continue; + if (candidates.length === 0) pushCbpNoRelation(findings, target, `${visible.path}.fields`); + else pushCbpAmbiguousRelation(findings, target, `${visible.path}.fields`, candidates); + } + } +} + /** * Validate the security posture of a stack. Returns findings (empty = clean). * `error` findings gate the build in `os compile`; `info` is advisory. @@ -512,24 +705,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } // who may author it. const cbpCandidates = owd === 'controlled_by_parent' ? cbpMasterCandidates(obj) : undefined; if (cbpCandidates && cbpCandidates.length === 0) { - findings.push({ - severity: 'error', - rule: SECURITY_CBP_NO_RELATION, - where: `object "${objName}"`, - path: `${objPath}.sharingModel`, - // [#22161] One verdict sentence; the ADR-0055 rule it tested and why - // the object is unusable rather than locked down are this id's - // `os explain` entry. - message: - `declares sharingModel 'controlled_by_parent' but no required master_detail field to derive access ` + - `from, so every read is denied and every write refused (422 INVALID_METADATA)`, - // [#22161] One remedy line; that an object with no master decides its - // own baseline, and what each value means, is this id's `os explain` - // entry. - hint: - `Add the master relation, e.g. fields.parent: { type: 'master_detail', reference: '', ` + - `required: true }, or set its own sharingModel: 'private', 'public_read' or 'public_read_write'`, - }); + pushCbpNoRelation(findings, objName, `${objPath}.sharingModel`); } // ── [#14747 · #23100] controlled_by_parent with TWO OR MORE masters ─── @@ -548,22 +724,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } // not: the ambiguity is a property of the document, independent of who may // author it. if (cbpCandidates && cbpCandidates.length > 1) { - const roster = cbpCandidates.map((cand) => `"${cand.field}" → ${cand.master}`).join(', '); - findings.push({ - severity: 'error', - rule: SECURITY_CBP_AMBIGUOUS_RELATION, - where: `object "${objName}"`, - path: `${objPath}.fields`, - // [#22161] One verdict sentence; why exactly one master is the rule is - // this id's `os explain` entry. - message: - `declares ${cbpCandidates.length} required master_detail fields (${roster}), so no ` + - `controlled_by_parent master is derived: every read denied, every write refused`, - // [#22161] One remedy line. - hint: - `Keep master_detail on the ONE master this object's rows belong to, and declare every other parent ` + - `as a lookup field`, - }); + pushCbpAmbiguousRelation(findings, objName, `${objPath}.fields`, cbpCandidates); } // D11: external dial present on any object (system included) must obey @@ -600,6 +761,12 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } } } + // ── [#23210] the same two rules, on an object's MERGED field set ───── + // An `objectExtensions` entry of this stack merges into its target at boot, + // so an object that passes above as declared can run with two required + // master_detail fields, or none. See {@link pushExtensionMergedCbpFindings}. + pushExtensionMergedCbpFindings(findings, stack); + // [#16108] The object graph, for the FLS field-existence rule below. Built // ONCE here rather than per permission set: `indexObjectGraph` walks every // object's whole field map, and a stack with N sets would otherwise pay for diff --git a/packages/spec/src/migrations/entries/semantic/18.cbp-extension-merged-master-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cbp-extension-merged-master-refused.ts new file mode 100644 index 00000000000..c769ef1833f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cbp-extension-merged-master-refused.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0055 §1, judged on the object the runtime runs. An accept-set narrowing +// of the stack schema: an objectExtensions entry that leaves a +// controlled_by_parent object the same stack declares with anything but one +// required master_detail is refused, as the same object declared directly +// already was (cbp-master-relation-tiers-retired). No D2 conversion: which +// master stays is the owner's pick, and turning one into a lookup changes what +// deleting its master does. No stored row carries the shape — an extension is +// merged only when a stack or artifact registers — so the consequence at rest +// is an artifact built before 18, which its load now refuses. +export const entry: SemanticMigration = { + id: 'cbp-extension-merged-master-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'objectExtensions[].fields (also packages[].manifest.objectExtensions[].fields) merged into a ' + + 'sharingModel: controlled_by_parent object the same stack or artifact declares', + replacement: + 'The object as merged keeps exactly one master_detail field declared required: true. An extension of a ' + + 'controlled_by_parent object adds every other parent as a lookup field (drop the second master, or ' + + 'declare it type: \'lookup\'), and never redeclares the master field with required: false or another ' + + 'type. If a second parent must govern the rows, that is a different object design, decided by the ' + + 'owner of the object, not by an extension.', + reason: + 'The registry merges every objectExtensions entry onto its target at boot, a field replaced whole by name, ' + + 'so an object that passed its own parse could run with two required master_detail fields, or none. The ' + + 'runtime then derives no master: every read of the object is denied and every write refused with 422 ' + + 'INVALID_METADATA, while defineStack, objectstack validate and the security-posture lint said nothing, ' + + 'although they refused the same object declared directly. ObjectStackDefinitionSchema now judges the ' + + 'ADR-0055 §1 rule on the merged field set of every target the stack declares (in a multi-package ' + + 'artifact, any package\'s object), and refuses at the extension\'s fields with the message the declared ' + + 'object gets; the lint reports security-controlled-by-parent-ambiguous-relation or -no-relation at the ' + + 'same extension. A target the stack does not declare is not judged, so an extension of another ' + + 'package\'s object still meets the runtime\'s refusal instead. Not convertible: which master stays, and ' + + 'whether a parent becomes a lookup, are the owner\'s decisions.', + acceptanceCriteria: + 'defineStack, objectstack validate and objectstack build load the stack with no issue at an ' + + 'objectExtensions entry\'s fields of the form "declares sharingModel \'controlled_by_parent\' with N ' + + 'required master_detail fields" or "but no required master_detail field", and objectstack lint reports ' + + 'neither security-controlled-by-parent rule at an objectExtensions path. At runtime the security plugin ' + + 'logs no "resolves no master relation" line for the extended object, and a member who can read its ' + + 'master reads its rows. An artifact built before 18 that carries the shape is refused at load until it ' + + 'is rebuilt from the corrected source.', + relevantWhen: { kind: 'stack-declares', keys: ['objectExtensions'] }, +}; diff --git a/packages/spec/src/migrations/semantic-relevance.test.ts b/packages/spec/src/migrations/semantic-relevance.test.ts index c5e83960647..75f76088ce7 100644 --- a/packages/spec/src/migrations/semantic-relevance.test.ts +++ b/packages/spec/src/migrations/semantic-relevance.test.ts @@ -45,6 +45,7 @@ const EXPECTED_RELEVANCE: Readonly> = { '18:analytics-cube-public-default-visible-enforced': ['analyticsCubes'], '18:analytics-cube-single-granularity-default-enforced': ['analyticsCubes'], '18:api-endpoint-cache-ttl-unit-in-key': ['apis'], + '18:cbp-extension-merged-master-refused': ['objectExtensions'], '18:chart-config-aria-retired': ['dashboards', 'reports', 'pages'], '18:cube-join-sql-and-relationship-retired': ['analyticsCubes'], '18:cube-member-inner-name-retired': ['analyticsCubes'], @@ -82,11 +83,11 @@ describe('which entries carry a relevance question (ENUMERATION)', () => { expect(actual).toEqual(EXPECTED_RELEVANCE); }); - it('is 25 entries: 5 of protocol 17 and 20 of protocol 18', () => { + it('is 26 entries: 5 of protocol 17 and 21 of protocol 18', () => { const keys = Object.keys(EXPECTED_RELEVANCE); - expect(keys).toHaveLength(25); + expect(keys).toHaveLength(26); expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(5); - expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(20); + expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(21); }); it('the table names only registered entries (anti-vacuity for the comparison above)', () => { diff --git a/packages/spec/src/stack-cbp-extension-master.test.ts b/packages/spec/src/stack-cbp-extension-master.test.ts new file mode 100644 index 00000000000..096c56c02c8 --- /dev/null +++ b/packages/spec/src/stack-cbp-extension-master.test.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23210 · ADR-0055 §1] The stack door judges a `controlled_by_parent` + * object's master rule on its MERGED field set — the object as the registry + * runs it, with every `objectExtensions` entry of the same stack folded in + * (`applyControlledByParentExtensionGate`, `stack.zod.ts`). + * + * Before it, an extension adding a second required `master_detail` to a + * controlled object parsed clean, and the runtime then derived no master and + * failed closed, while the same merged object declared directly was refused. + * Each refusal below is pinned with the issue's `code`, path and message at the + * parse, and with the `STACK_SCHEMA_INVALID` / 422 envelope through + * `defineStack`; each accepted shape is pinned beside a twin that differs in + * the one variable under test and IS refused, so a silent pass cannot come from + * a refinement that never ran. + */ +import { describe, it, expect } from 'vitest'; +import { defineStack, ObjectStackDefinitionSchema } from './stack.zod'; + +type Issue = { code?: string; path?: PropertyKey[]; message?: string }; +type Envelope = Error & { code?: string; status?: number; issues?: Issue[] }; + +const MANIFEST = { id: 'com.acme.cbpx', name: 'cbpx', namespace: 'cbpx', version: '1.0.0', type: 'app' as const }; + +const HEADER = { type: 'master_detail', reference: 'cbpx_header', required: true }; +const OTHER = { type: 'master_detail', reference: 'cbpx_other', required: true }; + +const line = (fields: Record, sharingModel = 'controlled_by_parent') => ({ + name: 'cbpx_line', + sharingModel, + fields, +}); + +const OBJECTS = (lineFields: Record = { header: HEADER }, sharingModel?: string) => [ + { name: 'cbpx_header', sharingModel: 'private', fields: { title: { type: 'text' } } }, + { name: 'cbpx_other', sharingModel: 'private', fields: { title: { type: 'text' } } }, + line(lineFields, sharingModel), +]; + +/** The card's stack: one declared master, a second one added by an extension. */ +const CARD_STACK = { + manifest: MANIFEST, + objects: OBJECTS(), + objectExtensions: [{ extend: 'cbpx_line', fields: { other: OTHER } }], +}; + +/** The controlled-by-parent issues the parse raises, as `[path, message]`. */ +function cbpIssues(config: unknown): Array<[string, string]> { + const result = ObjectStackDefinitionSchema.safeParse(config); + if (result.success) return []; + return result.error.issues + .filter((i) => /declares sharingModel 'controlled_by_parent'/.test(i.message)) + .map((i) => [i.path.join('.'), i.message]); +} + +function refusal(config: unknown): Envelope | null { + try { + defineStack(config as never); + return null; + } catch (e) { + return e as Envelope; + } +} + +describe('[#23210] defineStack judges a controlled_by_parent object on its merged field set', () => { + it('the card\'s stack: refused at the extension\'s `fields`, in the words of the declared control', () => { + const issues = cbpIssues(CARD_STACK); + expect(issues.map(([path]) => path)).toEqual(['objectExtensions.0.fields']); + + // The control — the same merged object declared directly — is refused with + // the same message, at its own path. + const control = cbpIssues({ manifest: MANIFEST, objects: OBJECTS({ header: HEADER, other: OTHER }) }); + expect(control.map(([path]) => path)).toEqual(['objects.2.fields']); + expect(issues[0][1]).toBe(control[0][1]); + expect(issues[0][1]).toContain( + "Object 'cbpx_line' declares sharingModel 'controlled_by_parent' with 2 required `master_detail` fields (`header`, `other`)", + ); + + const result = ObjectStackDefinitionSchema.safeParse(CARD_STACK); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues.find((i) => i.path.join('.') === 'objectExtensions.0.fields'); + expect(issue).toMatchObject({ code: 'custom' }); + }); + + it('through defineStack: STACK_SCHEMA_INVALID / 422, carrying the located issue', () => { + const error = refusal(CARD_STACK); + expect(error, 'the stack must be refused').not.toBeNull(); + expect(error).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(error!.issues!.find((i) => i.path?.join('.') === 'objectExtensions.0.fields')).toMatchObject({ code: 'custom' }); + }); + + it('the single-object control is unchanged: one issue, at the object, none at an extension', () => { + expect(cbpIssues({ manifest: MANIFEST, objects: OBJECTS({ header: HEADER, other: OTHER }) }).map(([p]) => p)).toEqual([ + 'objects.2.fields', + ]); + // …and its declared base, alone, parses whole. + expect(ObjectStackDefinitionSchema.safeParse({ manifest: MANIFEST, objects: OBJECTS() }).success).toBe(true); + }); + + it('an extension adding a non-master field passes; its master-adding twin is refused', () => { + const withField = (field: unknown) => ({ + manifest: MANIFEST, + objects: OBJECTS(), + objectExtensions: [{ extend: 'cbpx_line', fields: { other: field } }], + }); + expect(ObjectStackDefinitionSchema.safeParse(withField({ type: 'text' })).success).toBe(true); + expect(ObjectStackDefinitionSchema.safeParse(withField({ type: 'lookup', reference: 'cbpx_other', required: true })).success).toBe(true); + expect(() => defineStack(withField({ type: 'text' }) as never)).not.toThrow(); + expect(cbpIssues(withField(OTHER)).map(([p]) => p)).toEqual(['objectExtensions.0.fields']); + }); + + it('an extension that demotes the one master is refused, 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 stack = { manifest: MANIFEST, objects: OBJECTS(), objectExtensions: [{ extend: 'cbpx_line', fields: { header: demoted } }] }; + const issues = cbpIssues(stack); + expect(issues.map(([p]) => p), JSON.stringify(demoted)).toEqual(['objectExtensions.0.fields']); + const control = cbpIssues({ manifest: MANIFEST, objects: OBJECTS({ header: demoted }) }); + expect(control.map(([p]) => p)).toEqual(['objects.2.sharingModel']); + expect(issues[0][1]).toBe(control[0][1]); + } + }); + + it('an extension whose target the stack does not declare is unjudged; the visible twin is refused', () => { + // Another package's object (or the platform's): nothing to merge into here. + const unseen = { manifest: MANIFEST, objects: OBJECTS(), objectExtensions: [{ extend: 'crm_line', fields: { a: OTHER, b: HEADER } }] }; + expect(ObjectStackDefinitionSchema.safeParse(unseen).success).toBe(true); + // The registry keys an extension by `extend` verbatim, so a prefixed + // spelling merges into no declared object and is not this object. + const prefixed = { ...CARD_STACK, objectExtensions: [{ extend: 'cbpx__cbpx_line', fields: { other: OTHER } }] }; + expect(ObjectStackDefinitionSchema.safeParse(prefixed).success).toBe(true); + expect(cbpIssues(CARD_STACK).map(([p]) => p)).toEqual(['objectExtensions.0.fields']); + }); + + it('a target whose sharingModel is not controlled_by_parent owes no master; its controlled twin is refused', () => { + expect(ObjectStackDefinitionSchema.safeParse({ ...CARD_STACK, objects: OBJECTS({ header: HEADER }, 'private') }).success).toBe(true); + expect(cbpIssues(CARD_STACK)).toHaveLength(1); + }); + + it('a target already refused as declared is reported once, at the object, not again at its extension', () => { + const stack = { + manifest: MANIFEST, + objects: OBJECTS({ header: HEADER, other: OTHER }), + objectExtensions: [{ extend: 'cbpx_line', fields: { note: { type: 'text' } } }], + }; + expect(cbpIssues(stack).map(([p]) => p)).toEqual(['objects.2.fields']); + }); + + it('only the extension that wrote a master-relevant key is located', () => { + // `note` is innocent; `other` is the second master. + const stack = { + manifest: MANIFEST, + objects: OBJECTS(), + objectExtensions: [{ extend: 'cbpx_line', fields: { note: { type: 'text' }, other: OTHER } }], + }; + expect(cbpIssues(stack).map(([p]) => p)).toEqual(['objectExtensions.0.fields']); + }); +}); + +describe('[#23210] across the packages of one artifact (ADR-0130 D4)', () => { + const body = (id: string, extra: Record) => ({ + manifest: { id, name: id, version: '1.0.0', type: 'app', ...extra }, + }); + + it('an extension in one package judges a target another package of the artifact declares', () => { + const stack = { + packages: [ + body('com.acme.ext', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: OTHER } }] }), + body('com.acme.core', { objects: OBJECTS() }), + ], + }; + expect(cbpIssues(stack).map(([p]) => p)).toEqual(['packages.0.manifest.objectExtensions.0.fields']); + // Twin: the same extension adding a plain field passes. + const plain = { + packages: [ + body('com.acme.ext', { objectExtensions: [{ extend: 'cbpx_line', fields: { other: { type: 'text' } } }] }), + body('com.acme.core', { objects: OBJECTS() }), + ], + }; + expect(ObjectStackDefinitionSchema.safeParse(plain).success).toBe(true); + }); + + it('priority decides the winner: a later writer that restores one master clears the refusal', () => { + const stack = (adderPriority: number, fixerPriority: number) => ({ + packages: [ + body('com.acme.adder', { objectExtensions: [{ extend: 'cbpx_line', priority: adderPriority, fields: { other: OTHER } }] }), + body('com.acme.fixer', { + objectExtensions: [{ extend: 'cbpx_line', priority: fixerPriority, fields: { other: { type: 'lookup', reference: 'cbpx_other' } } }], + }), + body('com.acme.core', { objects: OBJECTS() }), + ], + }); + // The lookup writes last: one master survives. + expect(ObjectStackDefinitionSchema.safeParse(stack(200, 300)).success).toBe(true); + // The master writes last: two masters, located at the package that wrote it. + expect(cbpIssues(stack(300, 200)).map(([p]) => p)).toEqual(['packages.0.manifest.objectExtensions.0.fields']); + }); + + it('two packages writing one key at one priority are left unjudged: registration order decides, not this parse', () => { + const tied = { + 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() }), + ], + }; + expect(ObjectStackDefinitionSchema.safeParse(tied).success).toBe(true); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 9f2602e9be7..ecc31d4e3de 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -35,6 +35,7 @@ import { lintUnknownAuthoringKeys, lintUnknownStackKeys } from './kernel/metadat // Data Protocol import { ObjectSchema, ObjectExtensionSchema } from './data/object.zod'; +import { controlledByParentMasterRefusal } from './data/controlled-by-parent-master'; import { InlineGridColumnSchema } from './data/field.zod'; import { SeedSchema } from './data/seed.zod'; @@ -260,6 +261,192 @@ function applyPermissionSetOwnerBindingGate( }); } +/** One `objectExtensions` entry as the registry registers it, with where it sits in the stack. */ +interface VisibleObjectExtension { + /** The position of the package that ships it: registration order across packages. */ + readonly carrier: number; + /** The entry's path in the stack, e.g. `['objectExtensions', 0]`. */ + readonly path: readonly (string | number)[]; + /** `ext.priority ?? 200`, the registry's own default (`engine.ts`, the `objectExtensions` loop). */ + readonly priority: number; + readonly fields: Record; +} + +/** + * [#23210 · ADR-0055 §1] The controlled-by-parent master rule, judged on the + * MERGED field set of an object that an `objectExtensions` entry of this stack + * extends — the BUILD door of the rule the per-object refinement + * (`refuseControlledByParentWithoutOneMaster`, `data/object.zod.ts`) applies + * to an object as it is declared. + * + * ## Why the per-object refinement is not enough + * + * The registry folds every extension onto its target at boot + * (`SchemaRegistry.resolveObject`, `objectql/src/registry.ts`), so an object + * that passes as declared can run with two required `master_detail` fields — + * or none — once an extension in the same stack is merged. The runtime then + * derives no master and fails closed (every read denied, every write refused), + * while `defineStack` and `os validate` had said nothing. The same merged + * object declared directly is refused here, with this message; so is the + * extension that produces it. + * + * ## The merge, read exactly as the registry computes it + * + * - The base is the target's own declaration; an extension carries no + * `sharingModel` (`ObjectExtensionSchema` is closed and the merge copies + * `fields`, `validations`, `indexes` and three labels only), so the merged + * object's `sharingModel` is the target's. + * - `fields` merge BY NAME, a later writer replacing the whole field + * (`mergeObjectDefinitions`: `{ ...base.fields, ...extension.fields }`). An + * extension can therefore demote the one master (`required: false`, another + * type) as well as add a second. + * - Extensions apply in ascending `priority`, equal priorities in + * registration order (a stable sort), and a package registers one extension + * per target: a later entry of the same package replaces an earlier one. + * + * ## Which targets this door can see — and the ones it leaves unjudged + * + * The registry keys an extension by its `extend` string verbatim + * (`computeFQN` is the identity), so an extension merges into the object whose + * `name` is exactly that string, and only that one. A target is visible when + * the stack declares it: + * + * - **no `packages[]`**: the stack is its one package — its top-level + * `objects`, extended by its top-level `objectExtensions`; + * - **`packages[]`** (ADR-0130 D4): the artifact is the co-ownership + * boundary, so an extension in any body judges a target declared in any + * body, or a top-level object no body declares (the residual the engine + * registers too). The top-level `objectExtensions` are not read there: the + * engine registers a multi-package stack's bodies and the residual's + * objects, never its top-level extensions. + * + * Left unjudged, on purpose, so that nothing here guesses at an object the + * stack cannot see: an `extend` naming no object of the stack (another + * package's, the platform's); a target two bodies declare (the registry + * refuses that ownership conflict on its own); a target whose own declaration + * already breaks the rule (the per-object refinement reports it there); and a + * target on which two packages write one field key at the `priority` that + * decides it, whose winner is the registration order the artifact loader + * computes (`resolveArtifactPackageOrder`, dependency-topological), not this + * parse. + * + * ## Where the issue sits + * + * At the `fields` of each extension that wrote the field that decides the + * verdict: the last writer of every key that is a `master_detail` in the + * target's declaration or in the merged set. That is the line that introduced + * the second master, or that demoted the one. The message is the per-object + * refinement's own ({@link controlledByParentMasterRefusal}), naming the + * target and the merged fields — one rule, one wording. + * + * On the SCHEMA, for the reason {@link applyApiEndpointGates} gives: + * `defineStack`, `os validate`, `os build`, the lint scorer and an artifact + * load all run this parse. + */ +function applyControlledByParentExtensionGate( + config: { objects?: unknown; objectExtensions?: unknown; packages?: unknown }, + ctx: z.RefinementCtx, +): void { + const asRecord = (value: unknown): Record | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + + const bodies = Array.isArray(config.packages) + ? config.packages.map((entry: unknown) => asRecord(asRecord(entry)?.manifest) ?? {}) + : undefined; + + // Target name → its declaration, or `null` when two packages claim it. + const targets = new Map | null>(); + const declare = (objects: unknown, carrier: number, owners: Map): void => { + if (!Array.isArray(objects)) return; + for (const obj of objects) { + const record = asRecord(obj); + const name = record?.name; + if (!record || typeof name !== 'string' || name.length === 0) continue; + const owner = owners.get(name); + if (targets.get(name) === null) continue; + if (owner !== undefined && owner !== carrier) { + targets.set(name, null); + continue; + } + owners.set(name, carrier); + // The same package declaring one name twice re-registers it: the last wins. + targets.set(name, record); + } + }; + const owners = new Map(); + if (bodies === undefined) { + declare(config.objects, 0, owners); + } else { + bodies.forEach((body, j) => declare(body.objects, j, owners)); + // The residual: a top-level object no body declares. + const residual = Array.isArray(config.objects) + ? config.objects.filter((obj: unknown) => { + const name = asRecord(obj)?.name; + return typeof name === 'string' && !owners.has(name); + }) + : []; + declare(residual, bodies.length, owners); + } + + // Target name → its extensions, one per package (the last entry wins). + const extensions = new Map>(); + const collect = (entries: unknown, carrier: number, at: readonly (string | number)[]): void => { + if (!Array.isArray(entries)) return; + entries.forEach((entry: unknown, i: number) => { + const record = asRecord(entry); + const target = record?.extend; + if (!record || typeof target !== 'string' || !targets.get(target)) return; + const byCarrier = extensions.get(target) ?? new Map(); + byCarrier.set(carrier, { + carrier, + path: [...at, 'objectExtensions', i], + priority: typeof record.priority === 'number' ? record.priority : 200, + fields: asRecord(record.fields) ?? {}, + }); + extensions.set(target, byCarrier); + }); + }; + if (bodies === undefined) collect(config.objectExtensions, 0, []); + else bodies.forEach((body, j) => collect(body.objectExtensions, j, ['packages', j, 'manifest'])); + + for (const [target, byCarrier] of extensions) { + const base = targets.get(target); + const baseFields = asRecord(base?.fields); + if (!base || !baseFields || base.sharingModel !== 'controlled_by_parent') continue; + if (controlledByParentMasterRefusal(target, base.sharingModel, baseFields) !== undefined) continue; + + const ordered = [...byCarrier.values()].sort((a, b) => a.priority - b.priority || a.carrier - b.carrier); + const writers = new Map(); + // Per key, how many packages wrote it at the priority of its last writer. + // Above one, the winner is registration order, which this parse does not compute. + const tiedWriters = new Map(); + const merged: Record = { ...baseFields }; + for (const extension of ordered) { + for (const [key, field] of Object.entries(extension.fields)) { + const previous = writers.get(key); + tiedWriters.set(key, previous?.priority === extension.priority ? (tiedWriters.get(key) ?? 1) + 1 : 1); + writers.set(key, extension); + merged[key] = field; + } + } + if ([...tiedWriters.values()].some((count) => count > 1)) continue; + + const refusal = controlledByParentMasterRefusal(target, base.sharingModel, merged); + if (refusal === undefined) continue; + const isMaster = (field: unknown): boolean => asRecord(field)?.type === 'master_detail'; + const blamed = new Set(); + for (const [key, extension] of writers) { + if (isMaster(merged[key]) || isMaster(baseFields[key])) blamed.add(extension); + } + for (const extension of ordered) { + if (!blamed.has(extension)) continue; + ctx.addIssue({ code: 'custom', path: [...extension.path, 'fields'], message: refusal.message }); + } + } +} + /** * One package entry as an AUTHOR writes it — manifest only (ADR-0130 D4). * @@ -1850,7 +2037,9 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ .describe('Assembled package bodies carried by this release artifact (ADR-0130 D4)'), ...STACK_DEFINITION_COLLECTIONS_SHAPE, -}).superRefine(applyApiEndpointGates).superRefine(applyPermissionSetOwnerBindingGate)); +}).superRefine(applyApiEndpointGates) + .superRefine(applyPermissionSetOwnerBindingGate) + .superRefine(applyControlledByParentExtensionGate)); export type ObjectStackDefinition = z.input; /** Post-parse shape of {@link ObjectStackDefinition} — defaults applied, transforms run (ADR-0122). */