Repository navigation
feat(spec)!: a controlled_by_parent object derives its master from exactly one required master_detail; the runtime's fallback tiers retire (#23100) - #23179
Conversation
…equired master_detail Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…, lint and runtime Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…rent Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…time pins Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…ts master is required Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c866a6567b79b7d0a2faba44bf292bfca48e4b5e && git checkout c866a6567b79b7d0a2faba44bf292bfca48e4b5e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4aaeaf0e2eb39ac574f1ba171a88535f9a678b4 6582c1f0d8e30afbbb1386d731d524966075a46e && git checkout -B drift-repro d4aaeaf0e2eb39ac574f1ba171a88535f9a678b4 && git merge --no-ff 6582c1f0d8e30afbbb1386d731d524966075a46e
node scripts/docs-audit/affected-docs.mjs --json d4aaeaf0e2eb39ac574f1ba171a88535f9a678b4
|
Contract reviewServed-tier: Inputs read: card #23100 (body, triage Gate verdicts on this head, read at 2026-10-13T06:05Z: 39 check-runs — 28 ① Derived judgmentsRuling B (
② Semver level
③ Boundary flagsEvery dev deviation and every out-of-scope finding in Deviations:
Out-of-scope findings:
Items this review adds (none blocks the verdict; each has a carrier):
Implemented-by: VERDICT: PASS |
…-18 fragment; docs and fixture follow-ups Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…--from marker the house-sentence pin reserves Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Review after patch round 1. Inputs read: card #23100 (body; triage Gate verdicts on this head, read at 2026-10-13T08:56Z: 48 check-runs — 41 Round scope, measured. The PR-side edits since ① Derived judgmentsThe earlier record's four carried items, each answered.
The new D2 conversion
The whole net diff, re-read on this head.
② Semver level
③ Boundary flagsEvery deviation and every out-of-scope finding in the dev's newest report Deviations:
Out-of-scope findings:
Items this review adds (none blocks the verdict; each has a carrier):
Implemented-by: VERDICT: PASS |
Fixes #23100
Clause-②: no (narrowing)
What this does
Ruling
6134079470(batch #331 item 4, letter B) enforces ADR-0055 §1: acontrolled_by_parentobject derives its master from exactly onemaster_detailfield declaredrequired: 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.ObjectSchema(packages/spec/src/data/object.zod.ts)controlled_by_parentobject with no master, with a non-requiredmaster_detail, with a requiredlookup, or with two mastersrefuseControlledByParentWithoutOneMasterinObjectSchemaBase's superRefine refuses all four shapes. It names the object and ADR-0055 §1. Zero masters are reported atsharingModel, and the message names each field that relied on a retired tier. Two or more are reported atfields. It applies to raw.parse()/.safeParse(),create(),defineStack,os validateand the save door.packages/lint/src/validate-security-posture.tsCBP_TIERS, a three-tier table "mirroring the runtime's winner, exactly"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. Theos explainentries are rewritten to match.resolveCbpRelation(packages/plugins/plugin-security/src/security-plugin.ts)pick(required master_detail) ?? pick(any master_detail) ?? pick(required lookup), first by field ordermaster_detailthat has a reference resolves. Anything else resolvesnull, which is fail-closed: reads getRLS_DENY_FILTER, writes get422 INVALID_METADATA. A newreportCbpMasterUnresolvedwarns 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
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. Acontrolled_by_parentobject whose onemaster_detailomitsrequiredgetsrequired: true, the forceObjectSchema.create()already applies. objectui's Studio writes that shape: itsnewFieldemits{ type, label }, its Required toggle writesrequired: v || undefined, and its Settings tab offerscontrolled_by_parent. The conversion is retired from the authoring load path, so a live author is still refused at parse.lookup. Turning it into amaster_detailchanges what deleting the master does to every row, and leavingcontrolled_by_parentis the author's call, per the ruling's fork clause.required: false(the builder refuses it too) and two masters (choosing one is the pick §1 refuses).applyConversionsToStoredItemreplayscbp-master-detail-required-defaulted. A stored row whose onemaster_detailomitsrequiredis therefore served withrequired: trueand resolves the same master it did before. A stored row in any other retired shape failsObjectSchemaand 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.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.crm_campaign_membercrm_campaign: Field.lookup(..., { required: true }), nomaster_detailcrm_event_attendeecrm_event: Field.lookup(..., { required: true }), nomaster_detailcrm_article_feedbackcrm_knowledge_article: Field.lookup(..., { required: true, deleteBehavior: 'cascade' }), nomaster_detailcrm_contract,crm_quote,crm_quote_line_item,crm_opportunity_line_item,crm_contactField.masterDetail(..., { required: true })eachThe changeset's FROM → TO names the three objects. For
crm_campaign_memberandcrm_event_attendeeit prescribesdeleteBehavior: 'restrict'. Both objects document today's behaviour on purpose: a campaign or event that still has members or attendees cannot be deleted. A plainmaster_detailwould cascade instead.crm_article_feedbackalready cascades.Fixture triage (the three dispositions)
specobject.test.ts: "forces EVERY master_detail reference"specobject.test.ts: "raw .parse() stays TOLERANT"lintposture tests: step-2 and step-3 silent controls, tier-2/3 ties, "names the OTHER field as the winner", the #22212 lookup-child warninglintvalidate-expressions.test.ts: master-detail count fixturecontrolled_by_parentincidentally and now usesprivate.verifyparent-write-reach.test.ts: everycontrolled_by_parentfixture'smaster_detailrequired: true). These fixtures were never §1-legal: throughdefineStackthey ran on tier 2.pwr_two_mastersandpwr_no_masterare now excluded by the parse's own refusal.plugin-securitycontrolled-by-parent-sharing.test.ts: the tier-2 row of the 422MISSING_REQUIRED_FIELDit.each, and the required-lookup caseobjectqlengine-unreadable-master-detail-header.test.ts:uh_cbp_line'smaster_detailrequired: 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
mainwhile this was in flight and has been merged in. Its fixtures incontrolled-by-parent-chain-middle-master.test.tsare tier 1. Its hunks inpackages/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.Pins (one refusal and one accepted control per seam)
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-requiredmaster_detail(omitted andfalse), no relation, two masters, and other sharing models left alone.defineStackis covered both ways.packages/lint/src/validate-security-posture.test.ts): each rule's control and its refusals. A newit.eachholds 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.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-requiredmaster_detail, requiredlookup, two masters) is checked for all of the following:422 INVALID_METADATA;Verification
Readings are at three trees.
ba6686ce6is the final head:mainat02bec6ca8merged in.7ed362fd2is the same diff before that merge; the only difference is 7maincommits that touch none of this PR's files.b17a2e120is the first merge ofmain, before one verify-test commit. Every exit code was captured before any pipe.Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatba6686ce6derives 127 commands, and all 127 were run there.node scripts/check-engine-split-ratio.mjs --days 90refused 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.--ranreconcile 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: OKcheck:changeset-no-majorandcheck:empty-changeset: greenSuites.
@objectstack/specb17a2e120@objectstack/spectargeted (object.test.ts, migrations, error-catalog docs, step-18 merge)ba6686ce6@objectstack/lintb17a2e120@objectstack/linttargeted (posture, expressions, R2, runtime surface)ba6686ce6@objectstack/plugin-securityb17a2e120@objectstack/plugin-securityCBP filesba6686ce6@objectstack/verify7ed362fd2@objectstack/verifyparent-write-reach.test.tsba6686ce6@objectstack/objectqlb17a2e120@objectstack/platform-objectsb17a2e120@objectstack/example-crmb17a2e120@objectstack/example-showcaseb17a2e120typecheckexited 0 forspec,lintandplugin-securityatb17a2e120, and forverifyatba6686ce6. Each printed itscheck:test-typecheck: OKline. Every suite ran on a rebuilt dependency closure (turbo run build --filter=…^...).The sweep direction is downstream: the consumers of
@objectstack/specthat holdcontrolled_by_parentfixtures or seeded objects.turbo ls --affectedwas not used to narrow anything here.Census. At
b17a2e120, all 115 authored*.object.tsfiles outside tests and fixtures were imported through the built spec, soObjectSchema.create()ran the new rule at module load. There were 0 import failures. Of 120 objects, 3 arecontrolled_by_parent:crm_opportunity_line_item,showcase_expense_lineandshowcase_invoice_line. Each has one requiredmaster_detailand parses. The CLI eval corpus adds 4 morecontrolled_by_parentobjects, 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.mjsin WRAP mode, which checks the anchor hit and the blob change on disk and restores withgit checkout HEAD. Both subjects import fromsrc/(relative imports), so nodist/leg applies.refuseControlledByParentWithoutOneMaster(...)call (anchor 1 → 0, call sites on disk 0) failed 6 / 205 inobject.test.ts: every refusal pin. The accepted control and the "other sharing models" pin stayed green, as predicted.resolveCbpRelation(marker on disk 1, anchor 0) failed 3 / 36 incontrolled-by-parent-sharing.test.ts: the three retired-tier cases. The control and the no-master-detail 422 stayed green, as predicted.1f74c94d8fd6,ffac0c727d4f) andgit diff HEADis empty.ESLint (a declared narrowing).
eslint --no-inline-config --format jsonover the 15 touched.tsfiles reported 15 files, 0 errors and 0 warnings.--print-configresolves each of the 15 inside the config.parserOptionsis{ ecmaVersion, sourceType }with noproject), so this diff cannot move a verdict on an untouched file.pnpm lintis CI's run.Acceptance notes (not changed here)
packages/verify/src/parent-write-reach.tsmasterLinkOf. 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 everymaster_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.notDerivablereason. "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.security-plugin.ts. The note inassertControlledByParentWritestill says "Direction 1 (lint: promoterelationship/master-detail-requiredfrom warning to error, scoped tocontrolled_by_parent— ruled for the v18 boundary (Direction 1 of #8772) #9139) has NOT landed". That has been stale sinceace0a5385, before this PR. Only its first-shape bullet is marked superseded here.requiredreading. The runtime now readsrequiredas=== true(it was truthy), matching the lint. A raw-registered object with a non-booleanrequiredon its master now fails closed.ObjectExtensionSchemathat adds a second requiredmaster_detailto acontrolled_by_parentbase object is not judged at parse. The runtime fails such a merged object closed and reports it. Not measured beyond reading the code.crm_campaign_memberandcrm_event_attendeedocument refusal on delete as the intended behaviour, sodeleteBehavior: 'restrict'on the newmaster_detailkeeps it. The ruling's hotcrm text reads "delete semantics become cascade"; that holds only withoutrestrict.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.cbp-master-relation-tiers-retiredis added.cbp-master-detail-required-defaultedis added and pinned (packages/spec/src/conversions/cbp-master-detail-required-defaulted.test.ts).6582c1f0d:pnpm --filter @objectstack/spec test:repo'Test Files 57 passed (57)',spec test661 passed + 1 skipped,lint test137, plugin-security CBP files 9, objectql file 1,check:generatedup to date, gates 127 derived / 124 run / 3 NOT-MEASURED (prerequisite), 0 UNRUN.Generated by Claude Code