Skip to content

Commit 5464db8

Browse files
committed
wip(spec): step-18 entry and changeset for the merged-shape refusal
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
1 parent f458ec6 commit 5464db8

3 files changed

Lines changed: 92 additions & 3 deletions

File tree

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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// ADR-0055 §1, judged on the object the runtime runs. An accept-set narrowing
6+
// of the stack schema: an objectExtensions entry that leaves a
7+
// controlled_by_parent object the same stack declares with anything but one
8+
// required master_detail is refused, as the same object declared directly
9+
// already was (cbp-master-relation-tiers-retired). No D2 conversion: which
10+
// master stays is the owner's pick, and turning one into a lookup changes what
11+
// deleting its master does. No stored row carries the shape — an extension is
12+
// merged only when a stack or artifact registers — so the consequence at rest
13+
// is an artifact built before 18, which its load now refuses.
14+
export const entry: SemanticMigration = {
15+
id: 'cbp-extension-merged-master-refused',
16+
// No backticks in `surface` — build-upgrade-guide renders it inside a code
17+
// span already, and a nested backtick would close it.
18+
surface:
19+
'objectExtensions[].fields (also packages[].manifest.objectExtensions[].fields) merged into a '
20+
+ 'sharingModel: controlled_by_parent object the same stack or artifact declares',
21+
replacement:
22+
'The object as merged keeps exactly one master_detail field declared required: true. An extension of a '
23+
+ 'controlled_by_parent object adds every other parent as a lookup field (drop the second master, or '
24+
+ 'declare it type: \'lookup\'), and never redeclares the master field with required: false or another '
25+
+ 'type. If a second parent must govern the rows, that is a different object design, decided by the '
26+
+ 'owner of the object, not by an extension.',
27+
reason:
28+
'The registry merges every objectExtensions entry onto its target at boot, a field replaced whole by name, '
29+
+ 'so an object that passed its own parse could run with two required master_detail fields, or none. The '
30+
+ 'runtime then derives no master: every read of the object is denied and every write refused with 422 '
31+
+ 'INVALID_METADATA, while defineStack, objectstack validate and the security-posture lint said nothing, '
32+
+ 'although they refused the same object declared directly. ObjectStackDefinitionSchema now judges the '
33+
+ 'ADR-0055 §1 rule on the merged field set of every target the stack declares (in a multi-package '
34+
+ 'artifact, any package\'s object), and refuses at the extension\'s fields with the message the declared '
35+
+ 'object gets; the lint reports security-controlled-by-parent-ambiguous-relation or -no-relation at the '
36+
+ 'same extension. A target the stack does not declare is not judged, so an extension of another '
37+
+ 'package\'s object still meets the runtime\'s refusal instead. Not convertible: which master stays, and '
38+
+ 'whether a parent becomes a lookup, are the owner\'s decisions.',
39+
acceptanceCriteria:
40+
'defineStack, objectstack validate and objectstack build load the stack with no issue at an '
41+
+ 'objectExtensions entry\'s fields of the form "declares sharingModel \'controlled_by_parent\' with N '
42+
+ 'required master_detail fields" or "but no required master_detail field", and objectstack lint reports '
43+
+ 'neither security-controlled-by-parent rule at an objectExtensions path. At runtime the security plugin '
44+
+ 'logs no "resolves no master relation" line for the extended object, and a member who can read its '
45+
+ 'master reads its rows. An artifact built before 18 that carries the shape is refused at load until it '
46+
+ 'is rebuilt from the corrected source.',
47+
relevantWhen: { kind: 'stack-declares', keys: ['objectExtensions'] },
48+
};

‎packages/spec/src/migrations/semantic-relevance.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const EXPECTED_RELEVANCE: Readonly<Record<string, readonly string[]>> = {
4545
'18:analytics-cube-public-default-visible-enforced': ['analyticsCubes'],
4646
'18:analytics-cube-single-granularity-default-enforced': ['analyticsCubes'],
4747
'18:api-endpoint-cache-ttl-unit-in-key': ['apis'],
48+
'18:cbp-extension-merged-master-refused': ['objectExtensions'],
4849
'18:chart-config-aria-retired': ['dashboards', 'reports', 'pages'],
4950
'18:cube-join-sql-and-relationship-retired': ['analyticsCubes'],
5051
'18:cube-member-inner-name-retired': ['analyticsCubes'],
@@ -82,11 +83,11 @@ describe('which entries carry a relevance question (ENUMERATION)', () => {
8283
expect(actual).toEqual(EXPECTED_RELEVANCE);
8384
});
8485

85-
it('is 25 entries: 5 of protocol 17 and 20 of protocol 18', () => {
86+
it('is 26 entries: 5 of protocol 17 and 21 of protocol 18', () => {
8687
const keys = Object.keys(EXPECTED_RELEVANCE);
87-
expect(keys).toHaveLength(25);
88+
expect(keys).toHaveLength(26);
8889
expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(5);
89-
expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(20);
90+
expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(21);
9091
});
9192

9293
it('the table names only registered entries (anti-vacuity for the comparison above)', () => {

0 commit comments

Comments
 (0)