Repository navigation
Commit 76815b9
Fixes #23100
Clause-②: no (narrowing)
## What this does
Ruling `6134079470` (batch #331 item 4, letter **B**) enforces ADR-0055
§1: a `controlled_by_parent` object derives its master from **exactly
one `master_detail` field declared `required: true`**. Before this PR
the parse, the linter and the runtime gave three different answers. They
now apply that one rule, and each seam keeps its own read of it.
| seam | before | after |
|---|---|---|
| **parse** — `ObjectSchema` (`packages/spec/src/data/object.zod.ts`) |
accepted a `controlled_by_parent` object with no master, with a
non-required `master_detail`, with a required `lookup`, or with two
masters | `refuseControlledByParentWithoutOneMaster` in
`ObjectSchemaBase`'s superRefine refuses all four shapes. It names the
object and ADR-0055 §1. Zero masters are reported at `sharingModel`, and
the message names each field that relied on a retired tier. Two or more
are reported at `fields`. It applies to raw `.parse()`/`.safeParse()`,
`create()`, `defineStack`, `os validate` and the save door. |
| **lint** — `packages/lint/src/validate-security-posture.ts` |
`CBP_TIERS`, a three-tier table "mirroring the runtime's winner,
exactly" | One predicate, `cbpMasterCandidates`. The no-relation rule
fires on zero candidates. The ambiguity rule fires on two or more, and
its verdict is now "no master is derived": it no longer names a winner
by field order. The `os explain` entries are rewritten to match. |
| **runtime** — `resolveCbpRelation`
(`packages/plugins/plugin-security/src/security-plugin.ts`) |
`pick(required master_detail) ?? pick(any master_detail) ??
pick(required lookup)`, first by field order | Exactly one required
`master_detail` that has a reference resolves. Anything else resolves
`null`, which is fail-closed: reads get `RLS_DENY_FILTER`, writes get
`422 INVALID_METADATA`. A new `reportCbpMasterUnresolved` warns once per
object per cache generation and names the shape the object relied on.
The reference-**spelling** tolerance in the same reader is a separate,
deliberate decision and is unchanged. |
### ADR-0087 disposition: one lossless shape converted, the rest refused
loudly
- **Disposition.** It is `registered
cbp-master-detail-required-defaulted,
cbp-master-relation-tiers-retired`: a new step-18 D2 conversion and a
new step-18 D3 semantic entry. The D2 covers exactly one shape. A
`controlled_by_parent` object whose one `master_detail` omits `required`
gets `required: true`, the force `ObjectSchema.create()` already
applies. objectui's Studio writes that shape: its `newField` emits `{
type, label }`, its Required toggle writes `required: v || undefined`,
and its Settings tab offers `controlled_by_parent`. The conversion is
retired from the authoring load path, so a live author is still refused
at parse.
- Not converted: a required `lookup`. Turning it into a `master_detail`
changes what deleting the master does to every row, and leaving
`controlled_by_parent` is the author's call, per the ruling's fork
clause.
- Not converted: an explicit `required: false` (the builder refuses it
too) and two masters (choosing one is the pick §1 refuses).
- **Stored rows.** `applyConversionsToStoredItem` replays
`cbp-master-detail-required-defaulted`. A stored row whose one
`master_detail` omits `required` is therefore served with `required:
true` and resolves the same master it did before. A stored row in any
other retired shape fails `ObjectSchema` and is still registered under
its `[metadata_spec_invalid]` diagnostic, which carries the parse's
message; the runtime then denies the object and reports it once. No tier
is left silently live anywhere.
- **Sibling entries.** The two step-18 entries on the same surface
(`cbp-master-detail-required-forced`,
`cbp-master-detail-required-lint-error`) are amended in place. Their
"raw parse stays tolerant" and "the runtime keeps resolving the master"
sentences would otherwise contradict this step inside the same
unreleased major.
### HotCRM: measured, not edited
These are read-only readings of the clone at `0f84c9c`. No hotcrm card
is filed here, per the ruling.
| object | relation today | refused by this rule |
|---|---|---|
| `crm_campaign_member` | `crm_campaign: Field.lookup(..., { required:
true })`, no `master_detail` | **yes** (tier 3) |
| `crm_event_attendee` | `crm_event: Field.lookup(..., { required: true
})`, no `master_detail` | **yes** (tier 3) |
| `crm_article_feedback` | `crm_knowledge_article: Field.lookup(..., {
required: true, deleteBehavior: 'cascade' })`, no `master_detail` |
**yes** (tier 3) |
| `crm_contract`, `crm_quote`, `crm_quote_line_item`,
`crm_opportunity_line_item`, `crm_contact` | one
`Field.masterDetail(..., { required: true })` each | no |
The changeset's FROM → TO names the three objects. For
`crm_campaign_member` and `crm_event_attendee` it prescribes
`deleteBehavior: 'restrict'`. Both objects document today's behaviour on
purpose: a campaign or event that still has members or attendees cannot
be deleted. A plain `master_detail` would cascade instead.
`crm_article_feedback` already cascades.
### Fixture triage (the three dispositions)
| fixture | disposition |
|---|---|
| `spec` `object.test.ts`: "forces EVERY master_detail reference" |
**replaced**. It pinned that nothing enforced the count, and now pins
the two-master refusal. |
| `spec` `object.test.ts`: "raw .parse() stays TOLERANT" | **replaced**.
It is now the refusal, with the stored body still unrewritten. |
| `lint` posture tests: step-2 and step-3 silent controls, tier-2/3
ties, "names the OTHER field as the winner", the #22212 lookup-child
warning | **replaced**. Each is reversed into the retired-tier refusal.
|
| `lint` `validate-expressions.test.ts`: master-detail count fixture |
**re-spelled**. It used `controlled_by_parent` incidentally and now uses
`private`. |
| `verify` `parent-write-reach.test.ts`: every `controlled_by_parent`
fixture's `master_detail` | **declaration added** (`required: true`).
These fixtures were never §1-legal: through `defineStack` they ran on
tier 2. `pwr_two_masters` and `pwr_no_master` are now excluded by the
parse's own refusal. |
| `plugin-security` `controlled-by-parent-sharing.test.ts`: the tier-2
row of the 422 `MISSING_REQUIRED_FIELD` `it.each`, and the
required-lookup case | **replaced**. Both move into a new retired-tier
block (below). |
| `objectql` `engine-unreadable-master-detail-header.test.ts`:
`uh_cbp_line`'s `master_detail` | **declaration added** (`required:
true`, on that object only). objectql registers it raw, so the suite
stayed green either way, but the shape is no longer §1-legal. |
### In-flight coupling
- **#23114 (#23093).** It landed on `main` while this was in flight and
has been merged in. Its fixtures in
`controlled-by-parent-chain-middle-master.test.ts` are tier 1. Its hunks
in `packages/verify/src/parent-write-reach.test.ts` (around
`:240`–`:285`) and this PR's (around `:60`–`:180`, plus the controls
fixture near `:309`) merged without conflict. Before this PR, the verify
fixtures it relies on (`pwr_quote`, `pwr_quote_line`) resolved through
tier 2.
- **#22900.** It landed too. Its boot and registration regions are not
touched here.
## Pins (one refusal and one accepted control per seam)
- **Parse** (`packages/spec/src/data/object.test.ts`): the accepted
control covers raw parse, builder and lookups beside the master. The
refusals cover the required lookup (parse and builder), the non-required
`master_detail` (omitted and `false`), no relation, two masters, and
other sharing models left alone. `defineStack` is covered both ways.
- **Lint** (`packages/lint/src/validate-security-posture.test.ts`): each
rule's control and its refusals. A new `it.each` holds the lint's
verdict equal to the parse's on five shapes. The accepted shape is
asserted to parse whole, so the refusals come from this rule rather than
from a refinement that never ran.
- **Runtime**
(`packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts`,
block "the retired master tiers derive NO master"): the accepted control
reads rows, reaches the executor and reports nothing. Each retired shape
(non-required `master_detail`, required `lookup`, two masters) is
checked for all of the following:
- reads denied on both the middleware face and the analytics face;
- the by-id update and the insert answer `422 INVALID_METADATA`;
- the report fires exactly once;
- the parse refuses the same schema at the same path.
## Verification
Readings are at three trees. **`ba6686ce6`** is the final head: `main`
at `02bec6ca8` merged in. **`7ed362fd2`** is the same diff before that
merge; the only difference is 7 `main` commits that touch none of this
PR's files. **`b17a2e120`** is the first merge of `main`, before one
verify-test commit. Every exit code was captured before any pipe.
**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `ba6686ce6` derives 127 commands, and all
127 were run there.
- 126 exited 0.
- 1 exited 2: `node scripts/check-engine-split-ratio.mjs --days 90`
refused because this clone is shallow (its oldest commit, 2026-09-12, is
inside the 90-day window). **NOT MEASURED** (shallow clone). CI runs it
on a full checkout.
- `--ran` reconcile line: "✓ dispatch-gates --ran: 127 derived
famil(ies) accounted for — 127 run, 0 NOT-MEASURED".
Verdict lines quoted from that run:
- `check-adr-0087-registration`: "1 declared-breaking changeset(s) …
[BREAKING+bang+clause-②-narrowing] registered
cbp-master-relation-tiers-retired (new here:
cbp-master-relation-tiers-retired)"
- `check:api-surface`: "@objectstack/spec public API surface + factory
signatures unchanged ✓"
- `check:liveness`: "packages/spec/liveness/state-counts/ is current"
- `check:type-check-debt`: "1 ledger entr(ies) re-measured … none above
its recorded number"
- `check:error-status-conformance`: "every derivable runtime status is
documented, and every documented status is reachable"
- `check:nul-bytes`: "no raw ASCII control bytes"
- `check:doc-authoring`: "no growth"
- `check:adr-anchors`: OK
- `check:changeset-no-major` and `check:empty-changeset`: green
**Suites.**
| package | tree | files / tests |
|---|---|---|
| `@objectstack/spec` | `b17a2e120` | 660 / 20027 pass (1 todo) |
| `@objectstack/spec` targeted (`object.test.ts`, migrations,
error-catalog docs, step-18 merge) | `ba6686ce6` | 7 / 468 |
| `@objectstack/lint` | `b17a2e120` | 138 / 6654 |
| `@objectstack/lint` targeted (posture, expressions, R2, runtime
surface) | `ba6686ce6` | 4 / 619 |
| `@objectstack/plugin-security` | `b17a2e120` | 192 / 3984 (45 skipped)
|
| `@objectstack/plugin-security` CBP files | `ba6686ce6` | 9 / 221 |
| `@objectstack/verify` | `7ed362fd2` | 36 / 289 |
| `@objectstack/verify` `parent-write-reach.test.ts` | `ba6686ce6` | 1 /
10 |
| `@objectstack/objectql` | `b17a2e120` | 409 / 7998 |
| `@objectstack/platform-objects` | `b17a2e120` | 70 / 1096 |
| `@objectstack/example-crm` | `b17a2e120` | 6 / 46 |
| `@objectstack/example-showcase` | `b17a2e120` | 33 / 409 |
`typecheck` exited 0 for `spec`, `lint` and `plugin-security` at
`b17a2e120`, and for `verify` at `ba6686ce6`. Each printed its
`check:test-typecheck: OK` line. Every suite ran on a rebuilt dependency
closure (`turbo run build --filter=…^...`).
The sweep direction is downstream: the consumers of `@objectstack/spec`
that hold `controlled_by_parent` fixtures or seeded objects. `turbo ls
--affected` was not used to narrow anything here.
**Census.** At `b17a2e120`, all 115 authored `*.object.ts` files outside
tests and fixtures were imported through the built spec, so
`ObjectSchema.create()` ran the new rule at module load. There were 0
import failures. Of 120 objects, 3 are `controlled_by_parent`:
`crm_opportunity_line_item`, `showcase_expense_line` and
`showcase_invoice_line`. Each has one required `master_detail` and
parses. The CLI eval corpus adds 4 more `controlled_by_parent` objects,
and the rule refuses none of them. A tier-3 control object was refused,
which proves the census can see a refusal.
**Ablation (reverse verification).** The fix was committed first. Each
mutation went through `scripts/ablation-replace.mjs` in WRAP mode, which
checks the anchor hit and the blob change on disk and restores with `git
checkout HEAD`. Both subjects import from `src/` (relative imports), so
no `dist/` leg applies.
- **Parse.** Deleting the
`refuseControlledByParentWithoutOneMaster(...)` call (anchor 1 → 0, call
sites on disk 0) failed **6 / 205** in `object.test.ts`: every refusal
pin. The accepted control and the "other sharing models" pin stayed
green, as predicted.
- **Runtime.** Putting the three-tier ladder back into
`resolveCbpRelation` (marker on disk 1, anchor 0) failed **3 / 36** in
`controlled-by-parent-sharing.test.ts`: the three retired-tier cases.
The control and the no-master-detail 422 stayed green, as predicted.
- **Restores.** Both were proven: the blob equals HEAD (`1f74c94d8fd6`,
`ffac0c727d4f`) and `git diff HEAD` is empty.
**ESLint (a declared narrowing).** `eslint --no-inline-config --format
json` over the 15 touched `.ts` files reported 15 files, 0 errors and 0
warnings.
- `--print-config` resolves each of the 15 inside the config.
- 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.
## Acceptance notes (not changed here)
- **`packages/verify/src/parent-write-reach.ts` `masterLinkOf`.** Its
zero-master reason ends "a master the runtime would derive through a
required lookup is the runtime's own precedence". No input the parse
accepts reaches that branch now, and the runtime no longer has that
precedence. Its two-master branch also counts every `master_detail`, so
one required plus one non-required is still excluded, although the parse
and the runtime both derive the required one. Report-line text only.
Carrier: none.
- **The same family's `notDerivable` reason.** "the master … cannot be
synthesized" is unreachable for a §1-legal detail: its required master
reference blocks the detail first. The pinned case now names the
detail's own block, which is the same master one hop earlier. Carrier:
none.
- **Stale freeze note in `security-plugin.ts`.** The note in
`assertControlledByParentWrite` still says "Direction 1 (#9139) has NOT
landed". That has been stale since `ace0a5385`, before this PR. Only its
first-shape bullet is marked superseded here.
- **`required` reading.** The runtime now reads `required` as `=== true`
(it was truthy), matching the lint. A raw-registered object with a
non-boolean `required` on its master now fails closed.
- **Object extensions are not judged by this rule.** An
`ObjectExtensionSchema` that adds a second required `master_detail` to a
`controlled_by_parent` base object is not judged at parse. The runtime
fails such a merged object closed and reports it. Not measured beyond
reading the code.
- **For whoever files the HotCRM card.** `crm_campaign_member` and
`crm_event_attendee` document refusal on delete as the intended
behaviour, so `deleteBehavior: 'restrict'` on the new `master_detail`
keeps it. The ruling's hotcrm text reads "delete semantics become
cascade"; that holds only without `restrict`.
## Patch round 1 (review record 6139386784)
- `content/docs/permissions/authorization.mdx`'s security-linter bullet
now states the one-master rule, both rule ids included.
- The step-18 rationale fragment `cbp-master-relation-tiers-retired` is
added.
- The D2 conversion `cbp-master-detail-required-defaulted` is added and
pinned
(`packages/spec/src/conversions/cbp-master-detail-required-defaulted.test.ts`).
- The objectql fixture is re-judged.
- Verified at `6582c1f0d`: `pnpm --filter @objectstack/spec test:repo`
'Test Files 57 passed (57)', `spec test` 661 passed + 1 skipped, `lint
test` 137, plugin-security CBP files 9, objectql file 1,
`check:generated` up to date, gates 127 derived / 124 run / 3
NOT-MEASURED (prerequisite), 0 UNRUN.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75cada0 commit 76815b9
25 files changed
Lines changed: 1240 additions & 464 deletions
File tree
- .changeset
- content/docs
- api
- permissions
- protocol/kernel
- packages
- lint/src
- objectql/src
- plugins/plugin-security/src
- spec/src
- conversions
- data
- migrations
- entries/semantic
- verify/src
- scripts/adr-anchors
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
182 | 182 | | |
183 | 183 | | |
184 | 184 | | |
185 | | - | |
| 185 | + | |
186 | 186 | | |
187 | 187 | | |
188 | | - | |
189 | 188 | | |
190 | 189 | | |
191 | | - | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
192 | 195 | | |
193 | 196 | | |
194 | 197 | | |
| |||
197 | 200 | | |
198 | 201 | | |
199 | 202 | | |
200 | | - | |
| 203 | + | |
201 | 204 | | |
202 | | - | |
| 205 | + | |
203 | 206 | | |
204 | 207 | | |
205 | 208 | | |
206 | 209 | | |
207 | | - | |
208 | | - | |
209 | | - | |
210 | | - | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
211 | 213 | | |
212 | 214 | | |
213 | 215 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
517 | 517 | | |
518 | 518 | | |
519 | 519 | | |
520 | | - | |
521 | | - | |
522 | | - | |
523 | | - | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
524 | 527 | | |
525 | 528 | | |
526 | 529 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
497 | 497 | | |
498 | 498 | | |
499 | 499 | | |
500 | | - | |
501 | 500 | | |
502 | 501 | | |
503 | | - | |
504 | 502 | | |
505 | | - | |
| 503 | + | |
506 | 504 | | |
507 | 505 | | |
508 | 506 | | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
509 | 515 | | |
510 | 516 | | |
511 | 517 | | |
512 | 518 | | |
513 | 519 | | |
514 | 520 | | |
515 | 521 | | |
516 | | - | |
517 | | - | |
| 522 | + | |
| 523 | + | |
518 | 524 | | |
519 | 525 | | |
520 | 526 | | |
521 | 527 | | |
522 | 528 | | |
523 | | - | |
524 | | - | |
525 | | - | |
526 | | - | |
527 | | - | |
528 | | - | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
529 | 534 | | |
530 | 535 | | |
531 | 536 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
598 | 598 | | |
599 | 599 | | |
600 | 600 | | |
601 | | - | |
| 601 | + | |
602 | 602 | | |
603 | 603 | | |
604 | | - | |
605 | | - | |
606 | | - | |
607 | | - | |
608 | | - | |
609 | | - | |
610 | | - | |
611 | | - | |
612 | | - | |
613 | | - | |
614 | | - | |
615 | | - | |
616 | | - | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
617 | 614 | | |
618 | 615 | | |
619 | 616 | | |
620 | 617 | | |
621 | 618 | | |
622 | 619 | | |
623 | 620 | | |
624 | | - | |
625 | | - | |
626 | | - | |
627 | | - | |
628 | | - | |
629 | | - | |
630 | | - | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
631 | 628 | | |
632 | | - | |
633 | | - | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
634 | 633 | | |
635 | 634 | | |
636 | 635 | | |
| |||
667 | 666 | | |
668 | 667 | | |
669 | 668 | | |
670 | | - | |
| 669 | + | |
671 | 670 | | |
672 | 671 | | |
673 | 672 | | |
| |||
0 commit comments