Skip to content

Commit ace0a53

Browse files
feat(lint)!: relationship/master-detail-required refuses the three unsafe master-reference shapes at error on a controlled_by_parent object (#22109)
Fixes #9139 Clause-②: no ## What this does `relationship/master-detail-required` (R2 in `packages/lint/src/data-model-rules.ts`) now has two tiers under one rule id, as the maintainer ruling of 2026-08-16 (Direction 1, scheduled for the v18 boundary) orders. - **On an object with `sharingModel: 'controlled_by_parent'`**, every `master_detail` field is refused at `error` in each of the three unsafe shapes: `required` absent (or `false`); `required: true` + `readonly: true`; `required: true` + `system: true`. One finding per field, located at the first defect (`.required`, `.readonly` or `.system`); the `fix` names every edit the field needs. - **On every other object** the verdict is unchanged: a `warning` for a `master_detail` without `required: true`, with the same message and fix, and silence on the two flagged shapes. Before this change the predicate was `required !== true` at `warning` on every object. Two of the three shapes drew no finding at any severity, as the census note on #9139 measured. A one-line severity flip would have closed one shape and left two open, so the predicate covers all three. Each shape is pinned on its own. Runtime is untouched, as the card's scope requires: `resolveCbpRelation`'s fallbacks and the security gate stay as they are. ## The mechanism hypotheses, measured | # | hypothesis | reading | |---|---|---| | H1 | R2 at `data-model-rules.ts:742`, `warning`, `required !== true`, every object | Holds at `b04a5295f`. Before-probe: CBP and non-CBP alike gave `warning` for absent / `false`, and nothing for `+readonly` / `+system`. | | H2 | the `:817` pin filters to `SECURITY_CBP_NO_RELATION`, so promoting R2 leaves it green untouched | **Holds.** The pin asserts only that the CBP no-relation rule (a mirror of `resolveCbpRelation`) is silent. That stays true, because the runtime still resolves step 2. The pin is **reversed, not removed**: see the pin section below. | | H3 | R2 bites `os lint` and the eval rubric only | **Holds.** `lintDataModel` is called from `lintConfig` (`os lint`, exit 1 on any error) and from `scoreMetadata` (the generation rubric: `valid` requires zero errors). It is not an `AUTHORING_RULES` entry: `authoring-rule-wiring.test.ts` pins `lintDataModel` in `DIRECT_CALL_RATCHET` as lint-only. So `os build`, `os validate` and the metadata save door do not run it. `check:i18n-coverage` spawns `os lint` but tolerates a non-zero exit. Registration was **not** widened; see the open question in the report. | | H4 | #9138 (builder force) landed | **Holds.** `forceCbpMasterDetailRequired` (`object.zod.ts`) forces an omitted `required` to `true` under CBP and refuses an explicit `false`. It skips the array field form and never inspects `readonly` / `system`. So through `ObjectSchema.create` only shapes 2 and 3 reach the lint, and both pass the builder untouched. Shape 1 reaches the lint from anything not built through `create`: a plain object literal, the array form, or raw parse of stored metadata. Pinned with real `ObjectSchema.create` objects. | | H5 | 0 in-tree corpora turn red | **Holds.** Census below. | | H6 | take the next free order in the step-18 chain | **Falsified as stated.** There is no order to take: entries are one file each under `entries/semantic/`, and `gen:migration-registry` sorts the registry by id. The new entry is `cbp-master-detail-required-lint-error`. Whichever of this PR, #22094 and #21974 lands later merges `main` and re-runs the generator. Per the entries README's measured table, a driver-less merge conflicts only when two new ids are adjacent in sort order. Whether that holds against those two PRs' ids is NOT MEASURED. | ## The step-2 pin: reversed, not removed `validate-security-posture.test.ts` "stays silent on step 2: ANY master_detail (not marked required)" pinned the step-2 shape as supported. The ruling retires that reading as a deliberate contract narrowing. Under H2 the pin's assertion is about the runtime mirror, and that half stays true, so the pin now asserts **both halves on one stack**: 1. the CBP no-relation rule stays silent: `resolveCbpRelation` still resolves a non-required `master_detail`, and metadata at rest keeps loading; 2. the same declaration draws `relationship/master-detail-required` at `error` from `lintDataModel`. The pin's title and comment state the narrowing. The pin is kept rather than deleted, so a later change that re-tolerates the shape at authoring time, or drops the runtime tolerance, turns it red. ## Census (H5), at `b04a5295f` plus this change Every `*.object.ts` outside tests and fixtures (111 files, 0 import failures), plus the CLI's golden eval corpus and the multi-package example's two sub-stacks. Each corpus was linted with its own controls appended to its own array: one positive control per unsafe shape and a clean negative control. | corpus | objects | `controlled_by_parent` | R2 error | R2 warning | controls | |---|---:|---:|---:|---:|---| | examples/app-crm | 6 | 1 | 0 | 0 | 3 reached, neg clean | | examples/app-showcase | 22 | 2 | 0 | 0 | 3 reached, neg clean | | examples/app-showcase (external sub-stack) | 2 | 0 | 0 | 0 | 3 reached, neg clean | | examples/app-todo | 1 | 0 | 0 | 0 | 3 reached, neg clean | | examples/app-multi-package (core, orders) | 2 | 0 | 0 | 0 | 3 reached, neg clean | | packages/cli DEFAULT_METADATA_EVAL_CORPUS | 11 | 4 | 0 | 0 | 3 reached, neg clean | | packages/platform-objects | 46 | 0 | 0 | 0 | 3 reached, neg clean | | packages/plugins, services, metadata-core, qa, create-objectstack | 39 | 0 | 0 | 0 | 3 reached, neg clean | | **total** | **129** | **7** | **0** | **0** | | No stored in-tree metadata turns red. ## v18 upgrade-checklist line (for the v18 release notes) > On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag. The changeset's Remedy section carries this line verbatim. The step-18 semantic entry `cbp-master-detail-required-lint-error` carries the same prescription for `os migrate meta`. This PR does not touch `content/docs/releases/**`. ## Release grading `.changeset/pre.json` is absent on `origin/main`: read at `b04a5295f` (2026-10-07T15:37Z) and again at `bafb58bb0` before this push. So the changeset grades `@objectstack/lint` and `@objectstack/spec` at `minor`, with the BREAKING banner, `Clause-②: no (narrowing)` in its body, and the ADR-0087 disposition `registered cbp-master-detail-required-lint-error`. ## Files - `packages/lint/src/data-model-rules.ts`: R2's CBP tier (`cbpMasterReferenceFinding`), placed above the first exported rule. `authoring-rule-wiring.test.ts` reads a rule body as the source text up to the next `export`, so an error-emitting helper placed below `lintLegacyOrganizationComposites` was read as that advisory rule emitting `error`. That was measured red once, then fixed by moving the helper. - `packages/lint/src/data-model-rules.master-detail-required.test.ts` (new): 19 cases. Each unsafe shape at `error` with its path; two clean controls; one finding for several defects; every `master_detail` field; the array field form; a lookup ignored; non-CBP unchanged across 4 sharing models plus the two flagged shapes; and real `ObjectSchema.create` objects. - `packages/lint/src/validate-security-posture.test.ts`: the step-2 pin, reversed as described above. - `packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-lint-error.ts` and the regenerated `registry.ts`. The entry carries no tracker id and no call spellings. `spec-changes.json` and the upgrade guide do not project step 18 yet, and `check:generated` reports all 15 artifacts current. - `packages/cli/test/score.test.ts`: fixture triage. The "warning" fixture of "suggestions cost less than warnings cost less than errors" was a CBP object, so it now measured the error weight. Probe: `counts.errors` 1, `valid: false`, with R2 at `error`. Its assertions still passed, but for a different reason. The fixture moves to `sharingModel: 'private'` and two assertions pin it to the warning tier. - `content/docs/protocol/kernel/error-handling.mdx`: this change made one sentence false ("does not report the `readonly`, `system` … shapes at all"). It now names `os lint`'s error tier and its reach. - `.changeset/9139-cbp-master-detail-required-error.md`. ## Verification (final head `bee9c1e25`) All readings below were taken at `bee9c1e25`, after the last commit. Each exit code was captured before any pipe. - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave 113 commands. All 113 ran at `bee9c1e25`: **112 exit 0, 1 exit 3**. The exit 3 is `node scripts/check-plugin-teardown-shape.mjs --self-test` refusing its own prerequisite, because its positive control is pinned to commit `621a4876` outside this shallow clone. That is NOT MEASURED, not a finding; CI runs it on a full checkout. Reconciled with `--ran`: "113 derived famil(ies) accounted for — 112 run, 1 NOT-MEASURED, 0 UNRUN". Gate lines quoted from the run: - `check-adr-0087-registration`: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] registered cbp-master-detail-required-lint-error (new here: …)" - `check:migration-registry`: "registry.ts is current (382 semantic, 247 retired-key, 218 retired-def)" - `check-issue-citations`: "every citation this change adds resolves" - `check:nul-bytes`: "OK … no raw ASCII control bytes" - `check:doc-authoring`: "17816 customer-facing string(s) … clean" - **`@objectstack/lint`:** `pnpm --filter @objectstack/lint test` passed 122 files / 5665 tests. `pnpm --filter @objectstack/lint typecheck` was green; "check:test-typecheck: OK". - **`@objectstack/spec`:** `vitest run src/migrations scripts/build-migration-registry-entry.test.ts scripts/step18-rationale-merge.test.ts` passed 5 files / 191 tests. `pnpm --filter @objectstack/spec check:generated` was "All 15 generated artifacts are up to date". That run was on top of `3f6d9ca97` (the registry regeneration); `packages/spec` has not changed since, and its per-artifact gates are in the 113 above. - **`@objectstack/cli` (consumer that reads R2 findings):** `vitest run --project unit` passed 259 files / 3795 tests. `pnpm --filter @objectstack/cli typecheck` was green. The `integration` tier is declared to CI; `migrate-meta-engine-guidance.test.ts`, which holds every semantic entry's printed prose, lives there. - **ESLint, a proven narrowing.** `eslint --no-inline-config --format json` over the 6 touched `.ts` files counted 6 files, 0 errors and 0 warnings. `--print-config` resolves all 6 inside the config. The `.md` / `.mdx` files answer "File ignored because no matching configuration was supplied". The config enables no type-aware linting (`parserOptions` is `{ ecmaVersion, sourceType }`, with no `project`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's run. **Reverse verification.** With `data-model-rules.ts` reverted to `b04a5295f` and the tests run from the committed state, **10 failed / 142 passed**. The failures: all 4 CBP shape cases, several-defects, every-field, array form, the 2 `ObjectSchema.create` refusals, and the reversed step-2 pin. The controls and every non-CBP case stayed green, as expected. The file was restored with `git checkout HEAD -- …`, and the restore was proven by blob hash equal to HEAD's and an empty `git diff HEAD`. ## Acceptance notes (not changed here) - `skills/objectstack-data/references/lint-rules.md:13` lists this rule's severity as `warning`. That is now true only off `controlled_by_parent`. `skills/**` is a Tier H governed surface outside this claim's file surface, so it is left for the seat (see the report's open question). - `packages/plugins/plugin-security/src/security-plugin.ts`, the paragraph above the freeze note: "Direction 1 … has NOT landed … nothing warns on the way past". It says itself that it "goes stale when #9139 lands". The dispatch fences the runtime package, so it is left for the seat. - The `object.zod.ts` docblock of `forceCbpMasterDetailRequired`, and the sibling entry `cbp-master-detail-required-forced`, say the lint "stays `warning` until v18". That is still accurate as a schedule, so neither was edited. - The census's earlier global red (`showcase_field_zoo.f_master_detail`) was already fixed on `main` (`required: true`). The global warning count is now 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd39171 commit ace0a53

8 files changed

Lines changed: 434 additions & 13 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
feat(lint)!: `relationship/master-detail-required` refuses the three unsafe master-reference shapes at `error` on a `controlled_by_parent` object (#9139)
7+
8+
Clause-②: no (narrowing)
9+
10+
A `controlled_by_parent` detail derives all of its record access from the master its `master_detail` reference names (ADR-0055). Three declarable shapes of that reference leave the security gate as the only thing refusing a detail record saved without its master, because record validation never checks a field that is not `required` and skips `readonly` and `system` fields before its required check:
11+
12+
1. `required` absent, or `required: false`;
13+
2. `required: true` with `readonly: true`;
14+
3. `required: true` with `system: true`.
15+
16+
A record that lands without its master anyway is readable by nobody, and every later write to it by id is refused. Until now `relationship/master-detail-required` was a `warning` with the predicate "`required` is not `true`", on every object, so shapes 2 and 3 drew no finding at any severity. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary, scoped to `controlled_by_parent`.
17+
18+
**BREAKING — what moves for consumers.**
19+
20+
- `os lint` reports each of the three shapes at `error` when the object declares `sharingModel: 'controlled_by_parent'`, located at the defect (`…fields.FIELD.required`, `.readonly` or `.system`). It covers every `master_detail` field of such an object, the same scope the builder's `required: true` force already applies. `os lint` therefore exits non-zero on such a stack, and the metadata-generation rubric (`scoreMetadata`) weighs the finding as an error and marks the stack `valid: false`.
21+
- `@objectstack/spec` gains the step-18 semantic migration entry `cbp-master-detail-required-lint-error`, so `os migrate meta` across protocol 18 prints the prescription below.
22+
23+
**Remedy — the v18 upgrade-checklist line.** On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag.
24+
25+
**Unchanged.**
26+
27+
- On every object that is not `controlled_by_parent` the rule is exactly as before: a `warning` for a `master_detail` without `required: true`, the same message and fix, and no finding for the two flagged shapes.
28+
- The rule is not in the authoring-rule registry. `os build`, `os validate` and the metadata save door do not run it, so a stack carrying one of the shapes still builds and publishes. Only `os lint`'s exit code and the generation rubric move.
29+
- Runtime is untouched. The security gate keeps refusing an insert that omits the master FK on these shapes and keeps resolving the master for metadata already at rest, and stored metadata is neither rewritten nor refused on load.
30+
- No export or signature moves in either package.
31+
- Measured before crossing, at `b04a5295f`: 129 authored objects across the example apps, the platform, plugin and service objects and the CLI's golden eval corpus. 7 of them are `controlled_by_parent`, and 0 draw the new `error`.
32+
33+
<!-- adr-0087: registered cbp-master-detail-required-lint-error -->

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -515,11 +515,12 @@ measured to create a detail record whose master reference is null — a record t
515515
readable by nobody and answers 422 on every later write by id. The refusal is correct; only
516516
its status departs from the rule above.
517517
518-
**These shapes are authorable today.** Publish-time lint reports a `master_detail` without
519-
`required` as a *warning* (`relationship/master-detail-required`), and does not report the
520-
`readonly`, `system`, or fallback-`lookup` shapes at all — so a stack can publish clean and
521-
still reach the 422. Branch on `code`, and read the status off the response rather than
522-
deriving it from this page.
518+
**These shapes are authorable today.** `os lint` refuses the three `master_detail` shapes at
519+
*error* (`relationship/master-detail-required`), but that rule is not part of the publish
520+
gate — `os build`, `os validate` and the metadata save door do not run it — and it does not
521+
report the fallback-`lookup` shape at all. So a stack can publish clean and still reach the
522+
422. Branch on `code`, and read the status off the response rather than deriving it from this
523+
page.
523524
524525
#### `INVALID_FIELD`
525526
**HTTP Status:** 400

‎packages/cli/test/score.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,21 @@ describe('scoreMetadata', () => {
8787
{ name: 'invoice_line', label: 'Line', sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true, inlineEdit: true } } },
8888
],
8989
});
90-
// A warning: master_detail not required.
90+
// A warning: master_detail not required — on an object that is NOT
91+
// `controlled_by_parent`. Under `controlled_by_parent` the same shape is an
92+
// error (`relationship/master-detail-required`'s v18 tier), so a fixture
93+
// kept there would still pass the comparison below while measuring the
94+
// error weight, not the warning one.
9195
const withWarning = scoreMetadata({
9296
objects: [
9397
{ name: 'invoice', label: 'Invoice', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } },
94-
{ name: 'invoice_line', label: 'Line', sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', deleteBehavior: 'cascade' } } },
98+
{ name: 'invoice_line', label: 'Line', sharingModel: 'private', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', deleteBehavior: 'cascade' } } },
9599
],
96100
});
97101
expect(onlySuggestions.score).toBeGreaterThan(withWarning.score);
98102
expect(onlySuggestions.counts.errors).toBe(0);
103+
expect(withWarning.counts.errors).toBe(0);
104+
expect(withWarning.counts.warnings).toBeGreaterThan(0);
99105
});
100106

101107
it('reports the schema error messages', () => {
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* R2 `relationship/master-detail-required` — two tiers under one rule id.
5+
*
6+
* On a `sharingModel: 'controlled_by_parent'` object the master reference is
7+
* what the object's record access is derived through, and three declarable
8+
* shapes leave the security gate as the only thing refusing a detail record
9+
* saved without its master: `required` absent (or `false`), `required: true` +
10+
* `readonly: true`, and `required: true` + `system: true` (record validation
11+
* never checks a non-required field and skips readonly/system fields before its
12+
* required check). All three are refused here at `error` — the v18 narrowing of
13+
* the authoring contract (maintainer ruling of 2026-08-16, Direction 1).
14+
*
15+
* Before the narrowing the predicate was `required !== true` at `warning` on
16+
* every object, so the two flagged shapes drew no finding at ANY severity. The
17+
* cases below pin each shape separately for that reason: a one-line severity
18+
* flip would have turned the first red and left the other two silent.
19+
*
20+
* The other half is what keeps this from being a blanket escalation: outside
21+
* `controlled_by_parent` nothing at runtime refuses a non-required
22+
* `master_detail`, so there the verdict is unchanged — a `warning` on a missing
23+
* `required`, and silence on the two flagged shapes.
24+
*/
25+
26+
import { describe, expect, it } from 'vitest';
27+
import { Field, ObjectSchema } from '@objectstack/spec/data';
28+
import { lintDataModel } from './data-model-rules.js';
29+
30+
const R2 = 'relationship/master-detail-required';
31+
32+
/** The master, plus one detail object carrying exactly the fields under test. */
33+
const stack = (sharingModel: string | undefined, fields: unknown): unknown[] => [
34+
{ name: 'work_order', sharingModel: 'private', fields: { name: { type: 'text' } } },
35+
{ name: 'work_order_item', ...(sharingModel ? { sharingModel } : {}), fields },
36+
];
37+
38+
const r2 = (objects: unknown[]) => lintDataModel(objects as any[]).filter((issue) => issue.rule === R2);
39+
40+
/** A `master_detail` reference to `work_order`, plus the shape under test. */
41+
const masterRef = (shape: Record<string, unknown>) => ({
42+
order: { type: 'master_detail', reference: 'work_order', deleteBehavior: 'cascade', ...shape },
43+
});
44+
45+
const UNSAFE_SHAPES: Array<[label: string, shape: Record<string, unknown>, path: string]> = [
46+
['`required` absent', {}, 'objects[1].fields.order.required'],
47+
['`required: false`', { required: false }, 'objects[1].fields.order.required'],
48+
['`required: true` + `readonly: true`', { required: true, readonly: true }, 'objects[1].fields.order.readonly'],
49+
['`required: true` + `system: true`', { required: true, system: true }, 'objects[1].fields.order.system'],
50+
];
51+
52+
describe('R2 on a controlled_by_parent object — the three unsafe shapes are refused at error', () => {
53+
it.each(UNSAFE_SHAPES)('%s → one error, located at the defect', (_label, shape, path) => {
54+
const issues = r2(stack('controlled_by_parent', masterRef(shape)));
55+
expect(issues).toHaveLength(1);
56+
expect(issues[0]).toMatchObject({ severity: 'error', rule: R2, path });
57+
});
58+
59+
// ── CONTROLS — the refusal must be able to stay silent, or the reds above
60+
// prove nothing about the shapes they name.
61+
it('CONTROL: `required: true` with neither flag is clean', () => {
62+
expect(r2(stack('controlled_by_parent', masterRef({ required: true })))).toEqual([]);
63+
});
64+
65+
it('CONTROL: an explicit `readonly: false` / `system: false` is clean — the flag, not the key, is the defect', () => {
66+
expect(
67+
r2(stack('controlled_by_parent', masterRef({ required: true, readonly: false, system: false }))),
68+
).toEqual([]);
69+
});
70+
71+
it('a field carrying several defects is ONE finding whose fix names every edit', () => {
72+
const issues = r2(stack('controlled_by_parent', masterRef({ readonly: true, system: true })));
73+
expect(issues).toHaveLength(1);
74+
expect(issues[0]).toMatchObject({ severity: 'error', path: 'objects[1].fields.order.required' });
75+
expect(issues[0].fix).toContain('required: true');
76+
expect(issues[0].fix).toContain('readonly');
77+
expect(issues[0].fix).toContain('system');
78+
});
79+
80+
// Scope is every `master_detail` of the object, as the builder half of the
81+
// same ruling enforces it — not only the one the runtime resolves as master.
82+
it('reports every master_detail field of the object, each on its own path', () => {
83+
const issues = r2(
84+
stack('controlled_by_parent', {
85+
order: { type: 'master_detail', reference: 'work_order', required: true },
86+
batch: { type: 'master_detail', reference: 'work_order', required: true, readonly: true },
87+
legacy: { type: 'master_detail', reference: 'work_order' },
88+
}),
89+
);
90+
expect(issues.map((issue) => [issue.severity, issue.path])).toEqual([
91+
['error', 'objects[1].fields.batch.readonly'],
92+
['error', 'objects[1].fields.legacy.required'],
93+
]);
94+
});
95+
96+
it('reads the array field form too', () => {
97+
const issues = r2(
98+
stack('controlled_by_parent', [{ name: 'order', type: 'master_detail', reference: 'work_order', required: true, system: true }]),
99+
);
100+
expect(issues).toHaveLength(1);
101+
expect(issues[0]).toMatchObject({ severity: 'error', path: 'objects[1].fields.order.system' });
102+
});
103+
104+
it('a lookup is not R2’s subject, whatever the sharing model', () => {
105+
expect(
106+
r2(stack('controlled_by_parent', { order: { type: 'lookup', reference: 'work_order', readonly: true } })),
107+
).toEqual([]);
108+
});
109+
});
110+
111+
describe('R2 outside controlled_by_parent — the verdict is unchanged', () => {
112+
it.each([['private'], ['public_read'], ['public_read_write'], [undefined]])(
113+
'sharingModel %s: a missing `required` is still a warning, at `.required`',
114+
(sharingModel) => {
115+
const issues = r2(stack(sharingModel, masterRef({})));
116+
expect(issues).toHaveLength(1);
117+
expect(issues[0]).toMatchObject({
118+
severity: 'warning',
119+
path: 'objects[1].fields.order.required',
120+
fix: 'required: true',
121+
});
122+
},
123+
);
124+
125+
it.each([
126+
['`required: true` + `readonly: true`', { required: true, readonly: true }],
127+
['`required: true` + `system: true`', { required: true, system: true }],
128+
])('sharingModel private: %s draws nothing, as before', (_label, shape) => {
129+
expect(r2(stack('private', masterRef(shape)))).toEqual([]);
130+
});
131+
});
132+
133+
// What actually reaches the rule from the authoring builder. Under
134+
// `controlled_by_parent`, `ObjectSchema.create()` forces an omitted `required`
135+
// to `true` and refuses an explicit `false`, but never inspects `readonly` or
136+
// `system` — so the two flagged shapes leave the builder intact, and this rule
137+
// is the authoring-time refusal they meet.
138+
describe('R2 over objects built by ObjectSchema.create()', () => {
139+
const build = (field: ReturnType<typeof Field.masterDetail>) =>
140+
ObjectSchema.create({
141+
name: 'work_order_item',
142+
sharingModel: 'controlled_by_parent',
143+
fields: { order: field },
144+
});
145+
146+
it.each([
147+
['readonly', { readonly: true }],
148+
['system', { system: true }],
149+
])('a %s master reference passes the builder (forced required) and is refused here', (flag, extra) => {
150+
const detail = build({ ...Field.masterDetail('work_order', { label: 'Work Order' }), ...extra });
151+
const issues = r2([{ name: 'work_order', sharingModel: 'private', fields: {} }, detail]);
152+
expect(issues).toHaveLength(1);
153+
expect(issues[0]).toMatchObject({ severity: 'error', path: `objects[1].fields.order.${flag}` });
154+
});
155+
156+
it('CONTROL: the builder’s forced `required: true` on a plain master reference lints clean', () => {
157+
const detail = build(Field.masterDetail('work_order', { label: 'Work Order' }));
158+
expect(r2([{ name: 'work_order', sharingModel: 'private', fields: {} }, detail])).toEqual([]);
159+
});
160+
});

0 commit comments

Comments
 (0)