Repository navigation
Commit 02ad31a
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
File tree
- .changeset
- packages
- lint/src
- metadata-protocol/src
- spec/src
- conversions
- data
- migrations/entries/semantic
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
151 | | - | |
| 151 | + | |
152 | 152 | | |
153 | | - | |
154 | | - | |
155 | | - | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
156 | 157 | | |
157 | 158 | | |
158 | 159 | | |
| |||
161 | 162 | | |
162 | 163 | | |
163 | 164 | | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
164 | 170 | | |
165 | 171 | | |
166 | 172 | | |
167 | | - | |
168 | | - | |
169 | | - | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
170 | 182 | | |
171 | 183 | | |
172 | 184 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
310 | 310 | | |
311 | 311 | | |
312 | 312 | | |
313 | | - | |
314 | | - | |
315 | | - | |
316 | | - | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
317 | 319 | | |
318 | 320 | | |
319 | 321 | | |
320 | 322 | | |
321 | | - | |
322 | | - | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
323 | 326 | | |
324 | 327 | | |
325 | 328 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2779 | 2779 | | |
2780 | 2780 | | |
2781 | 2781 | | |
2782 | | - | |
2783 | | - | |
2784 | | - | |
2785 | | - | |
| 2782 | + | |
| 2783 | + | |
| 2784 | + | |
| 2785 | + | |
| 2786 | + | |
2786 | 2787 | | |
2787 | 2788 | | |
2788 | 2789 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
151 | 151 | | |
152 | 152 | | |
153 | 153 | | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
154 | 175 | | |
155 | 176 | | |
156 | 177 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
| 53 | + | |
53 | 54 | | |
54 | 55 | | |
55 | 56 | | |
| |||
123 | 124 | | |
124 | 125 | | |
125 | 126 | | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
126 | 149 | | |
| 150 | + | |
| 151 | + | |
127 | 152 | | |
128 | 153 | | |
129 | 154 | | |
| |||
141 | 166 | | |
142 | 167 | | |
143 | 168 | | |
144 | | - | |
145 | | - | |
146 | | - | |
| 169 | + | |
| 170 | + | |
147 | 171 | | |
148 | 172 | | |
149 | 173 | | |
| |||
0 commit comments