Skip to content

Commit 02ad31a

Browse files
feat(spec)!: the parse refuses a controlled_by_parent detail whose one required master reference ADR-0055 §1 does not admit (#25181)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6zwDuV2XvoDkqC7bXgqnpc)_ Fixes #25092 Clause-②: no (narrowing) Metadata-protocol: narrowed ## The rule The parse refuses a `controlled_by_parent` detail whose one required master reference ADR-0055 §1 does not admit: a required `master_detail` declared `readonly: true` or `system: true`. - **Governing text:** ADR-0055 §1 (a detail's access derives from exactly one required master) and §3; ADR-0087 (the kit a spec narrowing carries); ruling `6328204856`, route A, under the standing line 「暂时不考虑 v19,协议的调整原则上也是尽快,不考虑退役版本。」 Ships in 18.x with no retirement stage. ⛔ Disclosure ruling `5323310017` (#9387) applies: this PR states the declaration rule and its prescription only. - **Prescription:** remove `readonly: true` / `system: true` from the master reference and declare it as an ordinary required field. If the rows must outlive their parent, the object is not a detail: give it its own `sharingModel`. - **BREAKING**, FROM → TO, and the one-line fix are in `.changeset/25092-cbp-master-writable.md`. ADR-0087 disposition: `not-required (already-registered cbp-master-relation-tiers-retired)`. ## Landing point, and why this one Measured on `origin/main` `e3aa111ad`. The rule lives in the one predicate module every parse door already reads, `packages/spec/src/data/controlled-by-parent-master.ts` (`controlledByParentMasterRefusal`). It gains one shape, `master-not-writable`: exactly one required `master_detail`, carrying `readonly: true` or `system: true`, refused at `fields`. That predicate is read by `ObjectSchema`'s refinement and by the stack's objectExtensions merge check, so one edit reaches: | door | reached through | measured | |:--|:--|:--| | `ObjectSchema.parse` / `safeParse` | the refinement | spec test | | `ObjectSchema.create()` | the builder ends in the same parse | spec test (`ZodError` at `fields`) | | `defineStack` / `os validate` | `ObjectStackDefinitionSchema.objects` is `z.array(ObjectSchema)` | scratch project, CLI from source: `os validate --json` exit 1, `code: STACK_SCHEMA_INVALID`, issue at `objects.1.fields` (both flags); control exit 0, `valid: true` | | `os build` | the same stack parse | `os build --json` exit 1, `success: false`, `code: STACK_SCHEMA_INVALID`; control exit 0, `success: true` | | metadata save door | `saveMetaItem` parses with `METADATA_TYPE_SCHEMAS.object` = `ObjectSchema` | metadata-protocol test: `422 INVALID_METADATA`, one issue at `fields`, nothing stored (both flags); control saves | | objectExtensions merge | `stack.zod.ts` re-judges the merged field set with the same predicate | no new code | | `os migrate meta --stored` | `conversions/refused-shapes.ts` lists what the predicate refuses | spec test: one TODO under `cbp-master-relation-tiers-retired`, the row not rewritten | The other candidates, not taken: - **Wiring `relationship/master-detail-required` into `AUTHORING_RULES`**: it would reach `os validate` and `os build`, but not `ObjectSchema.create()`, `defineStack` used directly, or the save door's parse, and it would be a second copy of a rule the parse now owns (ruling item 2, C and D). The lint rule stays as it is: `os lint`'s advisory sweep over every `master_detail` field. - **The builder (`forceCbpMasterDetailRequired`)**: it runs only in `create()`. The parse it ends in already refuses the shape, so the builder needs no copy. Its docblock now says so. - **`isControlledByParentMasterField` is unchanged on purpose.** The flags do not change which field the runtime resolves as the master, so a flagged required `master_detail` still counts as one. Excluding it there would let an object with a flagged master beside a second required one parse as having one master, while the runtime still sees two. No engine or runtime change (ruling item 2, B not taken). Nothing in `packages/objectql/**` or `packages/plugins/**` is edited. ## ADR-0087 kit - `18.cbp-master-relation-tiers-retired` (the parse's one-master rule, and the id the stored-row listing already reports under) is updated rather than paralleled: its replacement, reason and acceptance criteria now name the writable master. No D2 conversion: dropping the flag changes who may write the reference, so it is the owner's call. - `18.cbp-master-detail-required-lint-error` (the maintainer's 2026-08-16 "Direction 1" lint promotion, which that entry records as already carried out) is not what this change carries out, so it is not where the narrowing is registered. It is corrected where it had become false: its "WHICH DOOR" sentence said that `os validate`, `os build` and the save door accept the flagged shapes, and its reason said they still pass the builder. They now refuse them on the one master, and the entry says the lint rule alone reports a shape on a second, non-required `master_detail`. - `refused-shapes.ts`: the new shape's TODO names the field and the owner's choice, and does not claim that the runtime denies the object, because for this shape it does not. ## Producer search (ruling item 3, before any edit) At `e3aa111ad`: every non-test file in this repo declaring `sharingModel: 'controlled_by_parent'` (`git grep`, tests, `**/test/**` and docs excluded): `examples/app-crm` `crm_opportunity_line_item`, `examples/app-showcase` `showcase_expense_line` and `showcase_invoice_line`, and the four golden objects in `packages/cli/src/lint/corpus.ts`. None of their `master_detail` fields declares `readonly` or `system`. YAML/JSON metadata: none declare the model. **Zero in-repo producers.** **HotCRM: NOT MEASURED**, because the repository is not reachable from this container (`gh api` answers 403, "GitHub access to this repository is not enabled for this session"). Deployed tenants cannot be read from the repo (the card already records this). ## Tests - `packages/spec/src/data/controlled-by-parent-master.test.ts`: the export and `ObjectSchema` agree on six new shapes (`readonly`, `system`, both, beside a required lookup, explicit `false` flags accepted, a flagged master beside a second required one still `several-required-masters`); `isControlledByParentMasterField` still counts a flagged master; the refusal names the object, the field and every flag; `ObjectSchema.create()` refuses with one issue at `fields`; the unflagged builder call builds. - `packages/spec/src/conversions/refused-shapes.test.ts`: a stored row with the shape (including the Studio shape plus the flag, which D2 makes required first) is listed once under `cbp-master-relation-tiers-retired`, is not rewritten, still fails the parse at the same key, and its TODO does not claim a runtime denial. - `packages/metadata-protocol/src/protocol.cbp-master-not-writable-refusal.test.ts`: the save door answers `422 INVALID_METADATA` (`code` + `status`), one issue at `fields`, stores nothing; control saves. Its fake engine is pinned (`scripts/engine-double-contract.pinned.json`, `--write`). ## Verification (final head `85505b5f9`, after merging `origin/main` `c24203f9d`) - Producer search before any edit, on `e3aa111ad`: see above. - `pnpm --filter @objectstack/spec test` (on `e9259eedc`): 706 files, 21476 passed, 1 todo. `spec typecheck`, `lint typecheck`, `metadata-protocol typecheck`: exit 0. `pnpm --filter @objectstack/metadata-protocol test`: 253 files passed + 3 skipped, 20840 tests passed + 19 skipped. `pnpm --filter @objectstack/lint test`: 152 of 153 files passed; the one red file was `data-model-rules.master-detail-required.test.ts`, whose two builder cases pinned "a readonly / system master passes the builder". Those cases now pin the opposite (the builder refuses, the rule still reports the literal); the file is 24/24 on the final head. - After the merge, on `85505b5f9`: spec `check:generated` all 14 artifacts up to date (after a spec build); spec `src/migrations` 5 files / 229 tests; the touched files 290 (spec, three files) + 24 (lint) + 3 (metadata-protocol) tests passed. `plugin-security`'s two `controlled_by_parent` files that register these shapes raw against the runtime (they do not parse): 57/57 passed. - **Ablation, red:** `scripts/ablation-replace.mjs` replaced `if (flags.length === 0) return undefined;` in `controlled-by-parent-master.ts` with an unconditional `return undefined;` (anchor 1 to 0, blob `98db836bcf78` to `b0b25dae1aea`). The two spec files went 10 failed / 69 passed. Restored: blob equals HEAD `98db836bcf78`, `git diff HEAD` empty. These tests import the module from source, so no dist rebuild was involved. - Doors measured through the CLI from source (scratch copy of `examples/app-todo` plus one object literal, deleted afterwards): see the table above. - `node scripts/pm/dispatch-gates.mjs --commands` on `85505b5f9`: 102 families derived, 102 run, all exit 0; `--ran` reconciliation: 102 accounted, 0 NOT-MEASURED (a derived zero). `check:changeset-gate-self-tests` first exited 1 on a commit-signing server 503 inside its temp-repo fixture (infrastructure, not this diff) and exited 0 on re-run. - Dogfood battery on `85505b5f9` (`pnpm --filter @objectstack/dogfood test` under the verify lock, VERDICT command-exit 0): 287 files passed + 3 skipped, 2395 tests passed + 18 skipped, no failures. - Size: 13 files, +377 / −39 against the merge base. ## Acceptance notes - `os lint` still reports `relationship/master-detail-required` at `error` on the same object, beside the parse's refusal. That double report is the existing state for a missing `required` on the one master as well; the lint rule's docblock and its `os explain` text now say which shapes the parse refuses and which are the rule's alone. - The `relationship/master-detail-required` explanation said that a raw `.parse()` never refuses a missing `required`. That was stale since the tiers-retired rule; the sentence is replaced. - `packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts` describes its `readonly` / `system` master fixtures as "AUTHORABLE". They are no longer authorable through any parsing door; the fixtures register the shapes raw against the runtime, which this change leaves as it was, so the suite still measures what it says it measures and stays green. The word is stale; the file is outside this claim's surface. Carrier: none. - A `CONTRACT_REVIEW_TIER` review of record on the narrowing face is owed before the queue (the claim says so). - Precedents for the shape and size: PR #25057 and PR #25122, both authoring-time refusals of a declaration the runtime could not honour. --- _Generated by [Claude Code](https://claude.ai/code/session_01B2EeooonSXrANpkpgUSfFZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f5b19d commit 02ad31a

13 files changed

Lines changed: 386 additions & 42 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': patch
4+
---
5+
6+
feat(spec)!: a `controlled_by_parent` object whose one required `master_detail` is marked `readonly: true` or `system: true` is refused at parse
7+
8+
Clause-②: no (narrowing)
9+
10+
Metadata-protocol: narrowed
11+
12+
<!-- adr-0087: not-required (already-registered cbp-master-relation-tiers-retired) registered before this change, amended in this diff: the parse's one-master entry now carries this narrowing, and the stored-row listing reports it under that id -->
13+
14+
**BREAKING** (a declaration the parse accepted is now refused), shipped as `minor` under the 18.x convention for breaking changes, with no retirement stage.
15+
16+
ADR-0055 §1: a `controlled_by_parent` object derives its record access from exactly one required `master_detail` field. That one master must be a reference every write of a detail supplies, and a field declared `readonly: true` or `system: true` is not one an author's write supplies, so it cannot be that master. `os lint` already reported it at `error` under `relationship/master-detail-required`, but `os lint` is not a publishing door: `os validate`, `os build`, `ObjectSchema.create()`, `defineStack` and the metadata save door accepted it.
17+
18+
- **FROM:** `sharingModel: 'controlled_by_parent'` with one required `master_detail` declared `readonly: true` and/or `system: true` parses, builds and saves.
19+
- **TO:** the parse refuses it at `fields`, naming the object, the field and each flag (the message opens with "Object 'OBJECT' declares sharingModel 'controlled_by_parent' with its one required master_detail, FIELD, marked readonly: true", with each name and flag in a code span). The save door answers `422 INVALID_METADATA` with that issue and stores nothing. `ObjectSchema.create()` refuses it too, because the parse it ends in is the same rule. An objectExtensions entry that leaves the merged master flagged is refused at the extension, as before for the other shapes.
20+
- **The fix:** remove `readonly: true` and `system: true` from the master reference and declare it as an ordinary required field. If the rows must outlive their parent, the object is not a detail: give it its own `sharingModel` (`private`, `public_read` or `public_read_write`).
21+
- **Unchanged:** the runtime. The flags do not change which field the runtime resolves as the master. A flag on a second, non-required `master_detail` beside the master is still reported by `os lint` alone.
22+
23+
Data at rest still loads. A stored object in this shape registers under its `[metadata_spec_invalid]` diagnostic, and `os migrate meta --stored` lists it as a TODO under `cbp-master-relation-tiers-retired`. No conversion rewrites it: dropping the flag changes who may write the reference, which is the owner's decision.
24+
25+
`@objectstack/spec/data`: `controlledByParentMasterRefusal` returns the new shape `master-not-writable` for this object, so a producer that asks it before writing `controlled_by_parent` gets the parse's verdict. `ControlledByParentMasterShape` gains that member. `isControlledByParentMasterField` is unchanged: a flagged required `master_detail` still counts as the master, so beside a second required one the object is still refused as having two.
26+
27+
`@objectstack/lint`: the `relationship/master-detail-required` explanation (`os explain`) now says that the parse refuses every shape that leaves the object without exactly one writable required master, and that a shape on a second, non-required `master_detail` is this rule's report alone.

‎packages/lint/src/data-model-rules.master-detail-required.test.ts‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,12 @@ describe('R2 outside controlled_by_parent — the verdict is unchanged', () => {
148148
});
149149
});
150150

151-
// What actually reaches the rule from the authoring builder. Under
151+
// What reaches the rule from the authoring builder. Under
152152
// `controlled_by_parent`, `ObjectSchema.create()` forces an omitted `required`
153-
// to `true` and refuses an explicit `false`, but never inspects `readonly` or
154-
// `system` — so the two flagged shapes leave the builder intact, and this rule
155-
// is the authoring-time refusal they meet.
153+
// to `true` and refuses an explicit `false`; the parse it ends in refuses a one
154+
// master marked `readonly` or `system` (spec shape `master-not-writable`), so
155+
// the two flagged shapes no longer leave the builder, and this rule reports
156+
// them on a stack that never went through it.
156157
describe('R2 over objects built by ObjectSchema.create()', () => {
157158
const build = (field: ReturnType<typeof Field.masterDetail>) =>
158159
ObjectSchema.create({
@@ -161,12 +162,23 @@ describe('R2 over objects built by ObjectSchema.create()', () => {
161162
fields: { order: field },
162163
});
163164

165+
// [#25092] The builder no longer lets these two through: the parse it ends in
166+
// refuses a one master marked `readonly` / `system` (spec shape
167+
// `master-not-writable`). This rule still reports the same field on the
168+
// object as the builder would have emitted it (`required` forced to `true`),
169+
// which is what `os lint` reads from a stack that never went through it.
164170
it.each([
165171
['readonly', { readonly: true }],
166172
['system', { system: true }],
167-
])('a %s master reference passes the builder (forced required) and is refused here', (flag, extra) => {
168-
const detail = build({ ...Field.masterDetail('work_order', { label: 'Work Order' }), ...extra });
169-
const issues = r2([{ name: 'work_order', sharingModel: 'private', fields: {} }, detail]);
173+
])('a %s master reference is refused by the builder itself, and reported here on the literal', (flag, extra) => {
174+
const field = { ...Field.masterDetail('work_order', { label: 'Work Order' }), ...extra };
175+
expect(() => build(field)).toThrow(/with its one required `master_detail`, `order`, marked/);
176+
const literal = {
177+
name: 'work_order_item',
178+
sharingModel: 'controlled_by_parent',
179+
fields: { order: { ...field, required: true } },
180+
};
181+
const issues = r2([{ name: 'work_order', sharingModel: 'private', fields: {} }, literal]);
170182
expect(issues).toHaveLength(1);
171183
expect(issues[0]).toMatchObject({ severity: 'error', path: `objects[1].fields.order.${flag}` });
172184
});

‎packages/lint/src/data-model-rules.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -310,16 +310,19 @@ const CBP_ORPHAN_CONSEQUENCE =
310310
* forces `required: true` on every `master_detail` reference under
311311
* `controlled_by_parent` and refuses an explicit `required: false` —
312312
* `forceCbpMasterDetailRequired` in `packages/spec/src/data/object.zod.ts`).
313-
* The builder never inspects `readonly` / `system`, so shapes 2 and 3 pass
314-
* through `create()` untouched, and shape 1 survives every path that does not
315-
* go through `create()` (a plain object literal, raw `.parse()` of stored
316-
* metadata); this rule is where all three meet a refusal at authoring time.
313+
* The spec parse refuses, at every door that parses, every shape that leaves
314+
* the object without exactly one writable required master (`ObjectSchema`'s
315+
* `controlledByParentMasterRefusal`: a missing `required` since ruling B,
316+
* `readonly` / `system` on the one master since #25092, ruling 6328204856
317+
* route A, shape `master-not-writable`). What only this rule reports is a
318+
* shape on a second, non-required `master_detail` beside the master.
317319
*
318320
* ⚠ Reach: `lintDataModel` is `os lint`'s sweep and the metadata-generation
319321
* rubric (`score.ts`), and nothing else — it is not an `AUTHORING_RULES` entry
320322
* (`authoring-rule-wiring.test.ts` → `DIRECT_CALL_RATCHET`), so `os build`,
321-
* `os validate` and the metadata save door do not run it. The `error` moves
322-
* `os lint`'s exit code and the rubric's `valid`, not a publish verdict.
323+
* `os validate` and the metadata save door do not run it; they meet the spec
324+
* parse's refusal above instead. The `error` moves `os lint`'s exit code and
325+
* the rubric's `valid`, not a publish verdict.
323326
*
324327
* One finding per field, located at the first defect it names; the `fix`
325328
* names every edit the field needs.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2779,10 +2779,11 @@ const RELATIONSHIP_MASTER_DETAIL_REQUIRED_EXPLANATION: RuleExplanation = {
27792779
'security gate (`assertControlledByParentWrite`) is the only thing refusing such an insert.',
27802780
'Every `master_detail` field of such an object is judged, not only the one the runtime resolves ' +
27812781
'as the master: that is the scope `ObjectSchema.create()` enforces when it forces ' +
2782-
'`required: true` and refuses an explicit `required: false`. The builder never inspects ' +
2783-
'`readonly` or `system`, and a plain object literal or a raw `.parse()` of stored metadata ' +
2784-
'never runs it, so this rule is where all three shapes meet a refusal at authoring time. One ' +
2785-
'finding per field, located at its first defect.',
2782+
'`required: true` and refuses an explicit `required: false`. The spec parse also ' +
2783+
'refuses every shape that leaves the object without exactly one writable required master, so ' +
2784+
'`os validate`, `os build` and the save door stop there; a shape on a second, non-required ' +
2785+
'`master_detail` beside the master is this rule\'s report alone. One finding per field, located ' +
2786+
'at its first defect.',
27862787
DATA_MODEL_SWEEP_REACH,
27872788
],
27882789
};
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25092 — the object save door refuses a `controlled_by_parent` detail whose
5+
* one required `master_detail` is marked `readonly: true` or `system: true`.
6+
*
7+
* ADR-0055 §1 derives such an object's master from exactly one required
8+
* `master_detail` that every write of a detail supplies. The rule lives in the
9+
* spec parse (`controlledByParentMasterRefusal`, shape `master-not-writable`),
10+
* and `saveMetaItem` parses with `ObjectSchema`, so the door answers
11+
* `422 INVALID_METADATA` at `fields` and stores nothing — the same refusal
12+
* `os validate` and `os build` meet, from the same rule.
13+
*/
14+
import { describe, expect, it } from 'vitest';
15+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
16+
import { ObjectStackProtocolImplementation } from './protocol.js';
17+
18+
/** The engine surface the repository write path touches (as the container-issue-descent harness). */
19+
function makeProtocol() {
20+
const rows = new Map<string, Record<string, unknown>>();
21+
let nextId = 0;
22+
const engine: any = {
23+
async findOne(object: string, query?: EngineFindOneQueryInput) {
24+
assertEngineFindOnePredicate(object, query); return null; },
25+
async find() { return []; },
26+
async insert(table: string, data: Record<string, unknown>) {
27+
nextId += 1;
28+
if (table === 'sys_metadata') rows.set(`${data.type}|${data.name}`, data);
29+
return { id: `r_${nextId}` };
30+
},
31+
async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) {
32+
assertEngineUpdateDispatch(data, opts);
33+
return { id: null };
34+
},
35+
async delete(_t: string, opts?: Record<string, unknown>) {
36+
assertEngineDeleteDispatch(opts);
37+
return { deleted: 0 };
38+
},
39+
registry: { registerItem: () => {}, registerObject: () => {} },
40+
};
41+
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
42+
return { protocol, rows };
43+
}
44+
45+
async function rejection(promise: Promise<unknown>): Promise<any> {
46+
try {
47+
await promise;
48+
} catch (err) {
49+
return err;
50+
}
51+
throw new Error('expected the save to be rejected, but it resolved');
52+
}
53+
54+
const detailWith = (flags: Record<string, unknown>) => ({
55+
name: 'work_order_item',
56+
label: 'Work Order Item',
57+
sharingModel: 'controlled_by_parent',
58+
fields: {
59+
order: { name: 'order', type: 'master_detail', label: 'Order', reference: 'work_order', required: true, ...flags },
60+
note: { name: 'note', type: 'text', label: 'Note' },
61+
},
62+
});
63+
64+
describe('#25092 the save door refuses a controlled_by_parent detail whose one master is not writable', () => {
65+
it.each([
66+
['readonly: true', { readonly: true }, '`readonly: true`'],
67+
['system: true', { system: true }, '`system: true`'],
68+
])('a one master marked %s answers 422 INVALID_METADATA at `fields`, and nothing is stored', async (_label, flags, named) => {
69+
const { protocol, rows } = makeProtocol();
70+
71+
const err = await rejection(protocol.saveMetaItem({
72+
type: 'object', name: 'work_order_item', item: detailWith(flags),
73+
}));
74+
75+
expect(err.code).toBe('INVALID_METADATA');
76+
expect(err.status).toBe(422);
77+
expect(err.issues).toEqual([expect.objectContaining({ path: 'fields', code: 'custom' })]);
78+
expect(err.issues[0].message.startsWith(
79+
"Object 'work_order_item' declares sharingModel 'controlled_by_parent' with its one required "
80+
+ `\`master_detail\`, \`order\`, marked ${named}.`,
81+
)).toBe(true);
82+
expect(rows.size).toBe(0);
83+
});
84+
85+
it('control: the same detail with a writable master saves', async () => {
86+
const { protocol, rows } = makeProtocol();
87+
88+
const result = await protocol.saveMetaItem({
89+
type: 'object', name: 'work_order_item', item: detailWith({}),
90+
});
91+
92+
expect(result.success).toBe(true);
93+
expect(rows.size).toBe(1);
94+
});
95+
});

‎packages/spec/src/conversions/refused-shapes.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,27 @@ describe('#23270 — a stored controlled_by_parent object without exactly one re
151151
expect(JSON.parse(todo.from).fields.title).toBeUndefined();
152152
});
153153

154+
it.each([
155+
['readonly: true', md('crm_opportunity', { required: true, readonly: true })],
156+
['system: true', md('crm_opportunity', { required: true, system: true })],
157+
// The Studio shape plus the flag: D2 gives `required: true`, and the flag is still listed.
158+
['readonly: true, `required` omitted', md('crm_opportunity', { readonly: true })],
159+
])('#25092 — a one master marked %s: one TODO under the same entry, and the TODO does not claim the runtime denies it', (_label, master) => {
160+
const row = cbpRow('crm_line', { opportunity: master });
161+
const { item, todos } = storedTodos('object', row);
162+
expect(item.fields.opportunity.readonly ?? item.fields.opportunity.system).toBe(true);
163+
expect(todos).toHaveLength(1);
164+
const todo = todos[0]!;
165+
expect(todo.conversionId).toBe('cbp-master-relation-tiers-retired');
166+
expect(todo.path).toBe('objects[0].fields');
167+
expect(todo.reason).toMatch(/Its one required `master_detail`, `opportunity`, is marked `readonly` or `system`\. Dropping the flag changes who may write the reference/);
168+
expect(todo.reason).toContain('the metadata save door refuses it with 422 INVALID_METADATA until it is corrected');
169+
expect(todo.reason).not.toContain('every read of its rows is denied');
170+
expect(todo.reason).toContain("Object 'crm_line' declares sharingModel 'controlled_by_parent' with its one required `master_detail`");
171+
// The listing and the parse refuse ONE set: the stored row still fails the parse, at the same key.
172+
expect(cbpIssue(item)?.path).toEqual(['fields']);
173+
});
174+
154175
it('the Studio shape — one master_detail, `required` omitted — converts and is NOT listed', () => {
155176
const row = cbpRow('crm_line', { opportunity: md('crm_opportunity') });
156177
const { item, todos, notices } = storedTodos('object', row);

‎packages/spec/src/conversions/refused-shapes.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import { flowNodeValueTemplateRefusals } from '../automation/flow-value-slot-template.js';
5151
import {
5252
controlledByParentMasterRefusal,
53+
isControlledByParentMasterField,
5354
type ControlledByParentMasterRefusal,
5455
} from '../data/controlled-by-parent-master.js';
5556
import { ComponentPropsMap } from '../ui/component.zod.js';
@@ -123,7 +124,31 @@ function controlledByParentFate(refusal: ControlledByParentMasterRefusal, fields
123124
return 'It declares no `master_detail` and no required `lookup`, so there is no master to derive '
124125
+ 'and nothing to convert. Add a `master_detail` field with `required: true` referencing the master '
125126
+ `object, ${ownSharingModel}.`;
127+
case 'master-not-writable': {
128+
const master = refusal.masterDetails.find((n) => isControlledByParentMasterField(fields[n]))!;
129+
return `Its one required \`master_detail\`, \`${master}\`, `
130+
+ 'is marked `readonly` or `system`. Dropping the flag changes who may write the reference, so the '
131+
+ 'upgrade does not write it. Remove the flag and declare the master reference as an ordinary required '
132+
+ `field, ${ownSharingModel}.`;
133+
}
134+
}
135+
}
136+
137+
/**
138+
* What a stored row in a refused shape does once it is left as stored. Every
139+
* shape but one leaves the runtime with no master to derive. A
140+
* `master-not-writable` object still resolves its one required `master_detail`
141+
* as the master at runtime (ADR-0055 §1 refuses it at the parse, not at the
142+
* runtime), so what it loses is the save door: it is re-saved only once
143+
* corrected.
144+
*/
145+
function controlledByParentLeftAsStored(refusal: ControlledByParentMasterRefusal): string {
146+
if (refusal.shape === 'master-not-writable') {
147+
return 'Left as stored, it registers under `[metadata_spec_invalid]` and the metadata save door refuses '
148+
+ 'it with 422 INVALID_METADATA until it is corrected.';
126149
}
150+
return 'Left as stored, it registers under `[metadata_spec_invalid]` and the platform derives no master: '
151+
+ 'every read of its rows is denied and every write refused with 422 INVALID_METADATA.';
127152
}
128153

129154
const controlledByParentWithoutOneMaster: RefusedStoredShape = {
@@ -141,9 +166,8 @@ const controlledByParentWithoutOneMaster: RefusedStoredShape = {
141166
path: `${path}.${refusal.path}`,
142167
from: JSON.stringify({ sharingModel: obj.sharingModel, fields: relation }),
143168
reason: 'No lossless rewrite: protocol 18 derives a controlled-by-parent object\'s master from exactly '
144-
+ 'one `master_detail` declared `required: true`, and this object does not declare exactly one. Left as stored, '
145-
+ 'it registers under `[metadata_spec_invalid]` and the platform derives no master: every read of '
146-
+ 'its rows is denied and every write refused with 422 INVALID_METADATA. '
169+
+ 'one writable `master_detail` declared `required: true`, and this object does not declare exactly one. '
170+
+ `${controlledByParentLeftAsStored(refusal)} `
147171
+ `${controlledByParentFate(refusal, fields)} ${refusal.message}`,
148172
});
149173
return obj;

0 commit comments

Comments
 (0)