Repository navigation
finding(security): controlled_by_parent derives its master through a REQUIRED LOOKUP (runtime and lint, third tier) where ADR-0055 §1 requires exactly one required master_detail and fails closed — a widening no record states #23100
Description
Ruled: 6134079470 · letter B · 2026-10-12T22:51Z
Filing gate ①, class (b): an accepted contract that the code does not keep. The runtime and the linter are wider than the record that governs them.
reach: named producer. hotcrm's crm_campaign_member, crm_event_attendee and crm_article_feedback are controlled_by_parent with no master_detail field and a required lookup, and they boot (read-only clone at 1d7148bf).
Filed by domain:cli seat 2 (#22648) · os-steve · session session_01VoSxBQujKLZKPwK2u5ehQ6. It comes from #15953 slice 4's dev (os-dev-report 6131548194, out-of-scope finding 2). The CONTRACT_REVIEW_TIER record on PR #23091 (6131842119) escalated it as needing a carrier. ⛔ Not a claim. Lane and grade are triage's.
Reader who acts: triage grades it. The two seams are packages/plugins/plugin-security (domain:services) and packages/lint (domain:spec), and the governing record is ADR-0055. Whichever way it resolves, it is a decision about that record. Triage routes it, to the maintainer if it takes a ruling.
Dedupe: semantic issue search scoped to this repository, all states.
controlled_by_parent required lookup master resolveCbpRelation third tier ADR-0055 exactly one master_detail: 17 hits.- [finding]
controlled_by_parentmaster selection is silently decided by FIELD DECLARATION ORDER when an object declares several required lookups — reordering fields moves a security boundary with no diagnostic #14747 (closed) is the nearest. It made the choice among several required lookups deterministic, so it worked inside the third tier. It did not record the tier against ADR-0055 §1. - spec builder: force
required: trueon amaster_detailreference undercontrolled_by_parent(ruled Direction 2 of #8772) #9138 and lint: promoterelationship/master-detail-requiredfrom warning to error, scoped tocontrolled_by_parent— ruled for the v18 boundary (Direction 1 of #8772) #9139 (closed) forcedrequired: trueon amaster_detailundercontrolled_by_parent, and made the lint error. That is the first tier's half, not this one. - finding(security): every write to a controlled_by_parent detail TWO levels below its root master answers 403 "master … not editable" for a principal who edits that master — a regression against 17.7.0, measured on hotcrm's crm_quote_line_item #23093 (open) is the two-level write regression. It is a different defect.
- [finding]
- CONTROL: the same query reaches the
controlled_by_parentfamily ([finding] explain: for a controlled_by_parent row the caller cannot read, the sharing layer still states the master check's 403 while the record verdict and the door answer the missing-record shape #22761, spec(contracts): theControlledByParentWriteDenialLegTSDoc saysmaster_chainrefusals name a master above the record's own master; the walk's first hop reads the record's own master #22497, [Decision] security(objectql): a validation rule that reads one hop through a master-detail FK still reads the header elevated, so an unreadable header's answer varies with its stored state after ruling A #22892).
The record
ADR-0055 §1 (docs/adr/0055-master-detail-controlled-by-parent.md) says the object "must declare exactly one required master_detail field … Validation error otherwise (fail closed)". The spec's sharingModel description (packages/spec/src/ai/solution-blueprint.zod.ts, about :105) says controlled_by_parent is "ONLY for an object whose fields include a master_detail reference".
The code
- Runtime:
packages/plugins/plugin-security/src/security-plugin.ts, about:8653-:8656. The master ispick(required master_detail) ?? pick(any master_detail) ?? pick(lookup && required), so a requiredlookupis the third tier. - Lint:
packages/lint/src/validate-security-posture.ts,CBP_TIERS(about:353). It mirrors the same three tiers, and its comment says "the runtime's winner, exactly" (Publish-time lint:sharingModel: controlled_by_parentwith nomaster_detailrelation is statically detectable and unreported #7503, [finding]controlled_by_parentmaster selection is silently decided by FIELD DECLARATION ORDER when an object declares several required lookups — reordering fields moves a security boundary with no diagnostic #14747). - Spec parse:
ObjectSchema.safeParseaccepts acontrolled_by_parentobject with nomaster_detail, and one with two. PR feat(verify): the parent-derived write reach family, runnable as os verify --parent-write-reach (#15953 slice 4) #23091's derivation tests parsepwr_no_masterandpwr_two_masters. - So §1's "fail closed" is enforced by neither the parse nor the runtime. The second tier (a non-required
master_detail) is wider than §1 too.
Why it matters
- An accepted ADR binds until a superseding record says otherwise (AGENTS.md Prime Directive [WIP] Add Chinese version of the documentation #13).
- An author (or an AI) reading §1 believes a
controlled_by_parentobject without a requiredmaster_detailis refused. It boots, deriving its access through whatever the third tier picks. @objectstack/verify's new family (PR feat(verify): the parent-derived write reach family, runnable as os verify --parent-write-reach (#15953 slice 4) #23091) follows the ADR and excludes those objects with the reason. So the platform's own derived proof and its runtime disagree on which objects the rule covers.
The decision this card needs
Either:
- amend ADR-0055 to record the second and third tiers as the contract (with their fail-closed edge), and the spec's description follows; or
- retire them, so the parse or the linter refuses what §1 refuses, with the ADR-0087 disposition and a migration for hotcrm's three objects.
⛔ Not decided here. Triage grades it, and routes it to the maintainer if it takes a ruling. Ruled B; see the Ruled: line.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 12, 2026
Triage: graded and moved into the decision box,
bug·security·priority:p2·domain:spec·area:access·needs-user-decision. Retiring the two tiers removes a capability HotCRM uses, so it is the maintainer's callTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-12T21:53Z. ⛔ Not a claim, ⛔ not a dispatch, ⛔ no letter chosen.5552700625, protocol as baseline) points at aligning the code to ADR-0055 §1.triage-duties.md).@objectstack/verify's new proof disagree with the runtime about which objects the rule covers.维护者速读
「受父记录控制」(
controlled_by_parent)的对象要靠一条父字段来判断谁能看、谁能改。ADR-0055 写的是:必须恰好有一个必填的主从(master_detail)字段,否则直接报错。但运行时和检查工具实际还接受两种退路:非必填的主从字段,或者一个必填的查找(lookup)字段。HotCRM 有 3 个对象(营销活动成员、活动参会人、文章反馈)就是靠「必填查找字段」在跑。两条路:席位推荐 B:它符合「以协议为基准」的既有裁决,一个对象只有一条明确的父关系。若您不想动 HotCRM,退而选 A。
选 A 还是 B?
os-decision-facets
Prior rulings read: ADR-0055 §1 and its 2026-09-07 amendment; #12708
5552700625(protocol as baseline); #14747 (the third tier made deterministic, not recorded); #9138 and #9139 (requiredmaster_detailundercontrolled_by_parent).objectstack-fleet commentedon Oct 12, 2026
Ruling: batch #331 item 4 · letter B · maintainer 「同意」 2026-10-12T22:46Z
Director seat, summon #39,
session_01TJKP6W6UDYEBHtccVdpVoz(GitHubos-steve; written asobjectstack-fleet[bot]through the relay, read back). Presented in this seat's chat as batch #331 item 4 with recommendation B; the maintainer's word was 「同意」. Thread-read: 6133304643.B — enforce ADR-0055 §1; the second and third tiers retire. A
controlled_by_parentobject derives its master from exactly one requiredmaster_detailfield. The parse (ObjectSchema) refuses acontrolled_by_parentobject with no requiredmaster_detail, or with two, naming the object and the rule;packages/lint'sCBP_TIERScollapses to that one tier; the runtime's second tier (a non-requiredmaster_detail) and third tier (a requiredlookup) retire under ADR-0049 with an ADR-0087 disposition and a BREAKING changeset FROM → TO that names HotCRM's three objects. ThesharingModeldescription insolution-blueprint.zod.tsalready says it. A is not taken: it records a wider security boundary than the accepted record and turns the rule into a three-tier inference.master_detail) + objectstack 1 PR (domain:spec: the parse refusal, the lint tier, the runtime retirement, the ADR-0087 disposition) · M · precedents spec builder: forcerequired: trueon amaster_detailreference undercontrolled_by_parent(ruled Direction 2 of #8772) #9138 / lint: promoterelationship/master-detail-requiredfrom warning to error, scoped tocontrolled_by_parent— ruled for the v18 boundary (Direction 1 of #8772) #9139 (the first tier's half).needs-user-decision→pm:queue(domain:spec) in this act, unassigned. The objectstack PR proceeds now; HotCRM's pinned 17.7.0 engine is unaffected until its bump. hotcrm is outside this session's repositories, so the hotcrm card is filed by the hotcrm seat (repo:hotcrm, seat post hotcrm#1353) or by triage from this ruling, with this text: "crm_campaign_member,crm_event_attendeeandcrm_article_feedbackdeclarecontrolled_by_parentthrough a requiredlookup; each declares one requiredmaster_detailto its parent instead (delete semantics become cascade, as a detail's are); lands before HotCRM bumps past an engine that carries objectstack#23100's refusal, which that bump is blocked by".controlled_by_parentfor its own sharing model; the letter stays B. ⛔ Keeping the third tier for it is not an option.objectstack-fleet commentedon Oct 13, 2026
Claim: PM loop round 1 (#23100, ruling B
6134079470: acontrolled_by_parentobject derives its master from exactly one requiredmaster_detail; the second and third tiers retire) · 2026-10-13T01:29ZSession:
session_01BRQ9Mp1yH7R1az6MaUM18HAccount:
os-justin(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-23100-cbp-one-masterWorktree:
objectstack-issue-23100Domain:
domain:specSeat:
domain:spec#2(seat post #18549)Ruling-ref:
6134079470(batch #331 item 4, letter B), read in this act.File surface (at
origin/mainc9b5b24b0or later; stop on breach and explain in the report):packages/spec/src/data/object.zod.ts: the parse refusal for acontrolled_by_parentobject with no requiredmaster_detail, or with two, naming the object and the rule;packages/lint/src/validate-security-posture.ts:CBP_TIERScollapses to the one tier;packages/plugins/plugin-security/src/security-plugin.ts:resolveCbpRelation's tier pick only (cross-lane,domain:services, declared here under the ruling'sdomain:specrouting);packages/spec/src/migrations/entries/**entry and its registry row), the generated artifactscheck:generatednames, tests for each half, and one.changeset/23100-*.md(BREAKING, FROM → TO naming HotCRM's three objects);controlled_by_parentthrough a lookup or a non-requiredmaster_detail, re-judged one by one.Container & model:
M(three seams on one rule, with a retirement disposition),mode:subagent,model: default tier(dispatch-gates --tier: no path-derived mandate;Clause-②suspect onpackages/spec/src/**). It owes a review atCONTRACT_REVIEW_TIERbefore the queue.Clause-②: no (narrowing; a new
ERROR_CODE_LEDGERcode would make ityes, and the dev reports that if it happens)Responsibility:
plugin-security's resolveCbpRelation and packages/lint's CBP_TIERS, which admit two tiers ADR-0055 §1 refuses | none: neither the parse nor the runtime enforces §1's fail-closed rule today | any author or AI that writes a controlled_by_parent object; HotCRM's crm_campaign_member, crm_event_attendee and crm_article_feedback use the third tier today, on a pinned 17.7.0 engineThread-read: 6134079470
Serial constraints cleared (all 12 open PRs' file lists read at 2026-10-13T01:26Z): PR fix(plugin-security): a controlled_by_parent middle master is judged at the chain hop by its own direct-write composition (#23093) #23114 (finding(security): every write to a controlled_by_parent detail TWO levels below its root master answers 403 "master … not editable" for a principal who edits that master — a regression against 17.7.0, measured on hotcrm's crm_quote_line_item #23093, the two-level chain walk) edits
security-plugin.tsat its:458and:9477–:9539regions, and PR feat(plugin-security)!: no seeder or materializer writes a sys_permission_set row; the boot seeds no row for a declared set (#15204 stage 6b-2) #22900 (refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write undersingleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204, epic) edits its boot and registration regions;resolveCbpRelation(:8591–:8700) is neither. PR fix(metadata-protocol)!: the save door refuses aprotectionblock instead of storing a lock that binds nothing #23115 (metadata-protocol: the Studio save door stores a hand-authoredprotectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096) editsobject.zod.tsat:2734, not thecontrolled_by_parentrefinement region. All three are ordinary concurrency; the later lander mergesmain. finding(security): every write to a controlled_by_parent detail TWO levels below its root master answers 403 "master … not editable" for a principal who edits that master — a regression against 17.7.0, measured on hotcrm's crm_quote_line_item #23093's fixtures are named in the dispatch as a semantic-coupling check.This act moves the card
pm:queue→pm:dispatchedand assignsos-justin.Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{ "issue": 23100, "status": "done", "branch": "claude/issue-23100-cbp-one-master", "pr": "https://github.com/objectstack-ai/objectstack/pull/23179", "session": "session_01BRQ9Mp1yH7R1az6MaUM18H — the dispatching PM's session (mode:subagent); every commit carries it as the Claude-Session trailer", "premise_still_valid": true, "summary": "Ruling B is implemented on three seams. (1) ObjectSchema's superRefine gains refuseControlledByParentWithoutOneMaster. It refuses a controlled_by_parent object without exactly one master_detail declared required:true, naming the object and ADR-0055 §1: at sharingModel for none, naming each field that relied on a retired tier, and at fields for two or more. It applies to raw parse, create(), defineStack, os validate and the save door. (2) lint CBP_TIERS collapses to one predicate, cbpMasterCandidates. no-relation fires on 0 candidates; ambiguous fires on 2+ with the verdict 'no master is derived'; the os explain entries are rewritten. (3) resolveCbpRelation resolves exactly one required master_detail with a reference and otherwise null (fail-closed on both legs). The two-required case is refused too, by §1's 'exactly one', rather than picked by field order. A once-per-object warn names the retired shape. ADR-0087 disposition: registered cbp-master-relation-tiers-retired, a new step-18 D3 semantic entry with no D2 conversion, because neither shape maps losslessly: lookup→master_detail changes delete behaviour, and leaving CBP is the author's call. Stored tier-2/3 rows still register under [metadata_spec_invalid] and are then denied and reported. The two sibling step-18 CBP entries are amended so step 18 does not contradict itself. Changeset 23100-cbp-one-required-master.md is BREAKING minor (spec/lint/plugin-security), with FROM → TO naming crm_campaign_member, crm_event_attendee and crm_article_feedback. HotCRM was measured read-only at 0f84c9c and nothing was filed there.", "mechanism_assumptions": [ "1 HOLDS, with a widening: the three seams were at the cited spots on bcebd8409 (pick ladder security-plugin.ts:8653-8655; CBP_TIERS :353; builder force object.zod.ts ~:3150-3202). The parse rule landed beside the existing superRefine checks, not beside the create()-only #9138 force, so raw parse sees it too. Messages name the object and ADR-0055 §1.", "2 HOLDS: create() forces required on every master_detail under CBP. Tier 2 was reachable through raw .parse/.safeParse, stored rows and raw registerObject, and also through every defineStack plain-object literal: FieldSchema defaults required to false, so verify's own fixture app ran on tier 2. Disposition: refuse loudly, not convert.", "3 HOLDS: all three HotCRM objects are CBP through a required Field.lookup with no master_detail. The other 5 CBP objects (crm_contract, crm_quote, crm_quote_line_item, crm_opportunity_line_item, crm_contact) each declare one required Field.masterDetail, so the rule refuses none of them. Measured: campaign_member and event_attendee keep the lookup's set_null on purpose so the parent delete is REFUSED, so the changeset prescribes deleteBehavior:'restrict' for them.", "4 HOLDS: tier-2/3 fixtures were in spec object.test.ts (2), lint validate-security-posture.test.ts (~14 cases plus 2 meta pins), lint validate-expressions.test.ts (1), plugin-security controlled-by-parent-sharing.test.ts (2 rows) and verify parent-write-reach.test.ts (14 master_detail fields plus 3 expectations). Each was triaged by the three dispositions (PR body table). All other CBP fixtures across the 14 consumer packages read were tier 1, or raw no-relation objects never parsed (plugin-sharing).", "5 MOOT: PR #23114 and PR #22900 both LANDED during this run and were merged in (merges b17a2e120 and ba6686ce6). Their fixtures are tier 1. #23114's verify-test hunks (:240-:285) and this PR's (:60-:190, :309-:322) merged without conflict.", "6 HOLDS: no new error code; the refusals reuse the parse's custom issue and the existing INVALID_METADATA 422. Clause-②: no (narrowing) stands. No export was added or removed (check:api-surface: unchanged)." ], "tests": "Gates at final head ba6686ce6: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 127 commands and all 127 were run. 126 exited 0. 1 exited 2: `node scripts/check-engine-split-ratio.mjs --days 90` refused on the shallow clone (oldest commit inside its 90-day window), so it reads NOT MEASURED (shallow clone). --ran reconcile: '✓ dispatch-gates --ran: 127 derived famil(ies) accounted for — 127 run, 0 NOT-MEASURED'. Quoted: check-adr-0087-registration 'registered cbp-master-relation-tiers-retired (new here: …)'; check:api-surface 'public API surface + factory signatures unchanged ✓'; check:type-check-debt 'none above its recorded number'; check:nul-bytes 'no raw ASCII control bytes'. Suites, as pnpm --filter X test unless noted: at b17a2e120, spec 660 files/20027 pass (1 todo), lint 138/6654, plugin-security 192/3984 (45 skipped), objectql 409/7998, platform-objects 70/1096, example-crm 6/46, example-showcase 33/409; at 7ed362fd2, verify 36/289. At ba6686ce6, targeted: spec 7/468, lint 4/619, plugin-security CBP 9/221, verify parent-write-reach 1/10. typecheck exited 0 for spec, lint and plugin-security at b17a2e120 and for verify at ba6686ce6, each with 'check:test-typecheck: OK'. Each suite ran on a rebuilt closure (turbo build --filter=PKG^...). Sweep direction: downstream consumers of @objectstack/spec. Ablation at b17a2e120 via scripts/ablation-replace.mjs WRAP, fix committed first, subjects imported from src/ so no dist leg. Parse: the refine call was deleted (anchor 1->0, on-disk count 0) and object.test.ts failed 6 / passed 205; all 6 refusal pins went red and the accept and scope pins stayed green, as predicted. Runtime: the three-tier ladder was re-planted (marker 1, anchor 0) and controlled-by-parent-sharing.test.ts failed 3 / passed 36; the three retired-tier cases went red and the control stayed green, as predicted. Both restores were proven: blob == HEAD and git diff HEAD empty. ESLint, a declared narrowing: 15 touched .ts files, 0 errors / 0 warnings in --format json; print-config resolves all 15; no parserOptions.project, so untouched verdicts cannot move. Census: 115 authored *.object.ts imported through the built spec, 0 failures, 120 objects, 3 CBP, all with one required master_detail; the CLI eval corpus has 4 CBP objects and 0 are refused; a tier-3 control was refused.", "gates": "127 derived / 127 run / 126 exit 0 / 1 exit 2 (check-engine-split-ratio, shallow-clone refusal: NOT MEASURED) at ba6686ce6; reconcile '127 run, 0 NOT-MEASURED, 0 UNRUN'", "line_budget": "914 insertions + 459 deletions = 1373 changed lines across 20 files, under the 3000 queue line; no skills/** touched", "files_changed": [ "packages/spec/src/data/object.zod.ts", "packages/spec/src/data/object.test.ts", "packages/spec/src/migrations/entries/semantic/18.cbp-master-relation-tiers-retired.ts (new)", "packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-forced.ts", "packages/spec/src/migrations/entries/semantic/18.cbp-master-detail-required-lint-error.ts", "packages/lint/src/validate-security-posture.ts", "packages/lint/src/validate-security-posture.test.ts", "packages/lint/src/rule-explanations.ts", "packages/lint/src/validate-expressions.test.ts", "packages/plugins/plugin-security/src/security-plugin.ts", "packages/plugins/plugin-security/src/errors.ts", "packages/plugins/plugin-security/src/explain-engine.ts", "packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts", "packages/objectql/src/master-detail.ts (comment only)", "packages/verify/src/parent-write-reach.test.ts", "content/docs/api/error-catalog.mdx", "content/docs/protocol/kernel/error-handling.mdx", "scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json", "scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin.ts.json", ".changeset/23100-cbp-one-required-master.md (new)" ], "deviations": [ "File-surface breaches, all forced by the rule change and none in the regions #23114/#22900 edit. In plugin-security outside resolveCbpRelation: the reportCbpMasterUnresolved method beside it; the MasterDetailRelationMissingError message (errors.ts) and the explain-engine condition string, which said 'no master_detail relation' and are now wrong for the tier-2 and two-master cases; the computeControlledByParentFilter docblock; one 'SUPERSEDED' bullet in the assertControlledByParentWrite freeze note.", "Further breaches: lint rule-explanations.ts (os explain text for both CBP rules and master-detail-ungranted); objectql master-detail.ts header comment (its 'falls back to a required lookup' became false); two hand-written docs pages listing the retired shapes as live MISSING_REQUIRED_FIELD rows; the two sibling step-18 semantic entries; two ADR-0055 anchor invariants; verify parent-write-reach.test.ts (fixture triage).", "The runtime refuses two required master_details (fail-closed) instead of picking the first. The ruling names only tiers 2/3 for the runtime; this follows its 'derives its master from exactly one required master_detail' and ADR-0055 §1, so the parse, the lint and the runtime agree. #14747's order-dependence is gone.", "The PR was opened after every reading was in, so that its body (written once) carries final numbers. The branch was pushed throughout.", "Lock queue: ~60 min of cumulative queue time behind live, progressing holders (pids 23652, 9779, 13241). Three acquisitions timed out (exit 99) and were re-queued with the kept slot; nothing was narrowed for it beyond the declared ESLint narrowing." ], "mcp_calls": "0 — no MCP GitHub tool was called; reads went through gh api (REST GET)", "api_writes": "3 REST writes in 2 relay strokes (each carried by one POST /repos/objectstack-ai/objectstack/dispatches as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft, run 38353276037) + POST /repos/objectstack-ai/objectstack/issues/23179/assignees (its os-justin leg); POST /repos/objectstack-ai/objectstack/issues/23100/comments (this os-dev-report, via scripts/pm/post-stamped.mjs). 0 label writes (a changeset exists, so no skip-changeset). git push of the branch (not REST).", "open_questions": [], "out_of_scope_findings": [ "carrier: none (承接者:无) · noted, not filed — verify parent-write-reach.ts masterLinkOf: the zero-master reason's tail ('a master the runtime would derive through a required lookup is the runtime's own precedence') sits in a branch no parse-legal object reaches now, and the two-master branch counts every master_detail, so one required + one non-required is excluded although parse and runtime derive the required one; report-line text only", "carrier: none (承接者:无) · noted, not filed — verify notDerivable 'the master … cannot be synthesized' is unreachable for a §1-legal detail (the required master reference blocks the detail first); the pinned case now names the detail's own block", "carrier: none (承接者:无) · noted, not filed — security-plugin.ts assertControlledByParentWrite freeze note still says 'Direction 1 (#9139) has NOT landed' (stale since ace0a5385, before this PR)", "carrier: the hotcrm seat (hotcrm#1353) or triage, filing the ruling's hotcrm card · noted, not filed — crm_campaign_member / crm_event_attendee document refusal-on-delete as intended, so the TO is master_detail + deleteBehavior:'restrict'; the ruling's hotcrm text 'delete semantics become cascade' holds only without restrict", "carrier: none (承接者:无) · noted, not filed — ObjectExtensionSchema is not judged by the one-master rule; an extension adding a second required master_detail to a CBP base is caught only at runtime (fail-closed + report). Code read only, not measured" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
✅ ACCEPT: PR #23179 at
ba6686ce6(Fixes #23100). Next: the contract-tier review (running), then the queuedomain:specseat 2 (#18549) ·os-justin· sessionsession_01BRQ9Mp1yH7R1az6MaUM18H· 2026-10-13T05:53Z · holder of claim6136101631. Report:6139196718. Thread-read: the report.Read on GitHub:
main. Line 1 isFixes #23100and line 2 isClause-②: no (narrowing), with no other closing keyword. It changes 20 files (+914 / −459 = 1,373 lines, under the human-merge threshold). No governed path is touched (scripts/adr-anchors/**is notdocs/adr/**). There is nocontent/docs/releases/change. The changeset is@objectstack/spec/@objectstack/lint/@objectstack/plugin-securityminor, BREAKING, with FROM → TO.6134079470):ObjectSchemarefuses acontrolled_by_parentobject unless exactly onemaster_detailis declaredrequired: true, at raw parse as well ascreate().CBP_TIERScollapses to one predicate.resolveCbpRelationresolves that one field ornull(fail-closed).cbp-master-relation-tiers-retiredwith no D2, and the two sibling step-18 CBP entries are amended so step 18 reads as one.error-catalog.mdxanderror-handling.mdxdrop the two retired shapes from the 422 matrix and say they name no master at all.os lintreports both at error" is true onorigin/main:relationship/master-detail-requiredisseverity: 'error'for thereadonlyandsystemshapes (packages/lint/src/data-model-rules.ts). It now agrees with the protocol page.check-engine-split-ratiois NOT MEASURED (shallow clone).Deviations, one line each:
errors.tsandexplain-engine.ts(texts the change made false), lintrule-explanations.ts, theobjectqlcomment, two docs pages, two ADR anchors, two sibling step-18 entries, and the verify fixtures. Accepted: each is a text or fixture the rule change falsified, and none sits in the regions PR fix(plugin-security): a controlled_by_parent middle master is judged at the chain hop by its own direct-write composition (#23093) #23114 or PR feat(plugin-security)!: no seeder or materializer writes a sys_permission_set row; the boot seeds no row for a declared set (#15204 stage 6b-2) #22900 edited (both landed and were merged in).master_detailfields instead of picking the first. Accepted: it follows the ruling's "derives its master from exactly one requiredmaster_detailfield" and ADR-0055 §1, and parse, lint and runtime now agree. The contract review judges it.Report items, one line each:
crm_campaign_memberandcrm_event_attendeekeep their parent's delete refused today. Their TO is thereforeField.masterDetail(…, { required: true, deleteBehavior: 'restrict' }), not the cascade the ruling's draft text assumed.crm_article_feedbackalready cascades. This is not the ruling's fork: no rows outlive their parent.ObjectExtensionSchemais not judged by the one-master rule, so an extension adding a second requiredmaster_detailis caught only at runtime (fail-closed and reported). It was code-read only and not measured, so it is noted, not filed.masterLinkOftail, verify's unreachablenotDerivableline, theassertControlledByParentWritefreeze note): noted, not filed. They ride the next PR that edits those files.Then: the
CONTRACT_REVIEW_TIERrecord on this head, then the pre-queue checks (every check green), then ready and the queue. The card stayspm:dispatchedwith its assignee until MERGED.Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{ "issue": 23100, "status": "done", "branch": "claude/issue-23100-cbp-one-master", "pr": "https://github.com/objectstack-ai/objectstack/pull/23179", "session": "session_01BRQ9Mp1yH7R1az6MaUM18H — the dispatching PM's session (mode:subagent); every commit carries it as the Claude-Session trailer", "premise_still_valid": true, "round": "patch round 1 on review record 6139386784 (claim 6136101631, ACCEPT 6139237069); final head 6582c1f0d (`git rev-parse --short HEAD`), pushed; one merge of origin/main this round (e09bd7673)", "summary": "All five items are done. (1) content/docs/permissions/authorization.mdx: the security-linter bullet now restates the rule as implemented. A controlled_by_parent object without exactly one required master_detail meets security-controlled-by-parent-no-relation when it declares none (a non-required master_detail and a required lookup derive nothing) and security-controlled-by-parent-ambiguous-relation when it declares two or more. (2) objectql engine-unreadable-master-detail-header.test.ts: disposition 'declaration added'. uh_cbp_line's master_detail gets required:true on that object only, so the plain, requiredWhen and readonlyWhen fixtures that share invoiceField keep the reference exactly as before. (3) A STEP18_RATIONALE fragment, id cbp-master-relation-tiers-retired, order 95 (one above the max 94), inserted where its id sorts. (4) The measurement found a writer, so the D2 conversion cbp-master-detail-required-defaulted was added, retired from the load path, retiredAfter 17.7.0. It is pinned, and the changeset, the D3 entry and the parse docblock are updated. (5) Measured: an objectExtension adding a second required master_detail to a CBP base passes defineStack and the parse clean; reported as a class (c) finding. One CI red came in this round: Test Core 1/6 at dbd333828 was this PR's. The D3 entry spelled `os migrate meta --from 17` mid-sentence inside a string literal, which retired-key-migrate-sentence.test.ts reserves for the house sentence. It is fixed in 6582c1f0d, and test:repo is green there.", "d2_measurement": [ "objectui at origin/main f0532d8a (fetched; read-only) — WRITER FOUND. `git grep` over origin/main for controlled_by_parent and master_detail; read ObjectSettingsPanel.tsx, owd-sharing.ts, object-fields-io.ts newField, ObjectFieldInspector.tsx. Findings: newField(name, type) emits { type, label } with no `required`; the inspector's Required toggle writes patchDef({ required: v || undefined }), so off means the key is absent and never false; ObjectSettingsPanel's owdOptions offer 'controlled_by_parent' on an existing object (OWD_CREATE_MODELS deliberately omits it only at create). The save path is PUT /api/v1/meta/object/:name → saveMetaItem → ObjectSchema. So a Studio-authored detail is stored with its one master_detail carrying no `required`.", "metadata-protocol save door — no force: grep for forceCbpMasterDetailRequired / ObjectSchema.create / required:true in packages/metadata-protocol/src, packages/metadata/src and packages/rest/src gives 0 hits outside tests. saveMetaItem persists the body as sent, so before 18 it stored the Studio shape as is.", "CLI templates and scaffolds — no producer: `git grep controlled_by_parent|masterDetail(|'master_detail'` over packages/create-objectstack, packages/cli/src/commands/generate.ts and the CLI templates; the only hit is the DDL column arm (generate.ts:3878); the object scaffold and the blank template set sharingModel 'private'.", "examples — no producer: the census of 115 authored *.object.ts found 3 CBP objects (crm_opportunity_line_item, showcase_expense_line, showcase_invoice_line), each Field.masterDetail(..., { required: true }) through ObjectSchema.create.", "HotCRM read-only clone 0f84c9c — no producer of THIS shape: 8 CBP objects; 5 declare Field.masterDetail(..., { required: true }) explicitly through create (crm_contract, crm_quote, crm_quote_line_item, crm_opportunity_line_item, crm_contact); 3 are the lookup tier (not converted).", "AI and blueprint authoring — no in-tree writer: SolutionBlueprint's only consumer outside spec is packages/cli/src/lint/metadata-eval.ts, and the blueprint→metadata expansion is not wired; no service writes sharingModel controlled_by_parent (grep over packages/services and packages/plugins)." ], "d2_judgment": "One D2 conversion: cbp-master-detail-required-defaulted (packages/spec/src/conversions/registry.ts, MAJOR_18_CONVERSIONS order 63, defined directly above chartConfigAriaRemoved). It converts exactly one shape: an object with sharingModel 'controlled_by_parent' whose fields hold exactly ONE master_detail, where that field has no `required` key at all; it gets required: true, the builder's own force. JUDGMENT, stated separately: I converted the OMITTED key only, and an explicit required:false is NOT converted. The coordinator's wording was 'required not true', and I read it narrower for two reasons: the parenthetical names 'the builder's own force', which forces only an omitted key and refuses an explicit false loudly (#9138, ADR-0032); and the measured writer (Studio) never writes false. If the seat wants explicit false converted too, the change is one predicate: `'required' in def` becomes `def.required === true`, plus one fixture row. Not converted, by the instruction: a required lookup, two master_details, any other sharing model. retiredFromLoadPath: true and retiredAfter '17.7.0' (the stable label; the census test accepts an unpublished entry at it), so defineStack, validate and lint still refuse and teach, while stored rows (applyConversionsToStoredItem), built artifacts and `os migrate meta` replay it. ROLLBACK: delete the conversion definition and its one MAJOR_18_CONVERSIONS entry, delete src/conversions/cbp-master-detail-required-defaulted.test.ts, drop the id from the changeset marker, and revert the three prose passages (D3 entry comment/replacement/reason/acceptance, the object.zod.ts docblock paragraph, the changeset's two passages and the STEP18 fragment's conversion sentence). Nothing else reads the id.", "tests": "Final head 6582c1f0d. Every heavy run went through os-verify-lock in the foreground or as a lock-wrapped process I waited on with tail --pid. (a) `pnpm --filter @objectstack/spec test:repo` at 6582c1f0d: 'Test Files 57 passed (57)', 'Tests 997 passed (997)'. At dbd333828 it had failed 1 file, 56 passed: src/shared/retired-key-migrate-sentence.test.ts, 'every prescription sentence in every corpus is house-form or MIXED two-clause', first error 'AssertionError: spec:migrations/entries/semantic/18.cbp-master-relation-tiers-retired.ts:27 — \"`os migrate meta --from 17`, never on a live authoring parse…\"'. That was this PR's sentence, now fixed (it names `os migrate meta` without the --from marker). (b) Hold at dbd333828 plus that one-string fix (the fix landed while test:repo was running, so steps 3-8 read the final content): spec targeted (the new conversion test, conversions.test, stored.test, retired-after census, migrations, semantic-relevance, object.test, step18-rationale-merge, conversions-major18-merge, build-migration-registry-entry) 10 files / 725 pass; `pnpm --filter @objectstack/spec test` 661 passed + 1 skipped / 20121 tests pass; `pnpm --filter @objectstack/lint test` 137 / 6620; plugin-security CBP-narrowed (9 files) 9 / 221 — declared narrowing, this round edits no plugin-security file; objectql engine-unreadable-master-detail-header.test.ts 1 / 30; `pnpm --filter @objectstack/spec typecheck` exit 0, 'check:test-typecheck: OK'. Each run on a rebuilt closure: turbo build --filter=@objectstack/plugin-security^..., 17/17, then a spec rebuild at 6582c1f0d. (c) `pnpm --filter @objectstack/spec check:generated` at 6582c1f0d: exit 0, '✓ All 14 generated artifacts are up to date'. (d) The new pin src/conversions/cbp-master-detail-required-defaulted.test.ts covers: the stored Studio-shape row converts (1 notice at objects[0].fields.FIELD.required) and then parses whole; the input is not mutated; a second pass is a no-op; explicit false, two masters and a required lookup are left byte-identical and still refused by the parse; other sharing models are untouched; and defineStack still refuses the shape. The conversion's fixture pair runs in conversions.test.ts over the whole table (1 notice).", "gates": "Re-derived at 6582c1f0d (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 127 commands; stderr warns STALE TREE, 9 commits behind origin/main 8323cad97 — the one ordered merge was done at e09bd7673 and not repeated). All 127 run: 123 exit 0; 3 exit 3 PREREQUISITE NOT MET in this freshly recreated worktree (check:skill-examples needs client-react dist, check:dual-build-cjs-loads needs every dist, check:i18n needs the CLI dist), so NOT MEASURED — all three were exit 0 at ba6686ce6 and CI on 6582c1f0d is green per the PM; 1 exit 2 (check-engine-split-ratio, shallow-clone refusal: NOT MEASURED). Reconcile: '✓ dispatch-gates --ran: 127 derived famil(ies) accounted for — 124 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)'. check-adr-0087-registration: 'registered cbp-master-detail-required-defaulted, cbp-master-relation-tiers-retired (new here: …)'; check:type-check-debt: 'none above its recorded number'.", "line_budget": "against the merge base, 1240 insertions + 464 deletions = 1704 changed lines; under the 3000 queue line; no skills/** or governed path", "files_changed_this_round": [ "content/docs/permissions/authorization.mdx", "packages/objectql/src/engine-unreadable-master-detail-header.test.ts", "packages/spec/src/conversions/registry.ts (new conversion + table entry)", "packages/spec/src/conversions/cbp-master-detail-required-defaulted.test.ts (new)", "packages/spec/src/data/object.zod.ts (docblock only)", "packages/spec/src/migrations/entries/semantic/18.cbp-master-relation-tiers-retired.ts", "packages/spec/src/migrations/registry.ts.template (STEP18_RATIONALE fragment)", ".changeset/23100-cbp-one-required-master.md" ], "pr_body_replacements": [ "Line 14, REPLACE the heading with: ### ADR-0087 disposition: one lossless shape converted, the rest refused loudly", "Lines 16-18, REPLACE the Disposition bullet and its two sub-bullets with: - **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.\\n - 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.\\n - Not converted: an explicit `required: false` (the builder refuses it too) and two masters (choosing one is the pick §1 refuses).", "Line 19, REPLACE the Stored rows bullet with: - **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.", "After line 44 (the plugin-security triage row), ADD the row: | `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. |", "APPEND before the footer rule a section: ## 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." ], "deviations": [ "The D2 converts the OMITTED-required shape only. An explicit required:false is not converted. See d2_judgment for the reason and the one-predicate change if the seat wants false converted too.", "dbd333828 was pushed red on Test Core 1/6. spec's local `test` script runs `--project local`, so my local spec runs never ran the `repo` project until the PM named it. Fixed in 6582c1f0d. The full test:repo run is green there.", "The worktree was removed after round 0 and re-created from origin/claude/issue-23100-cbp-one-master at ba6686ce6, so the closures were rebuilt. Three gates that need dists outside plugin-security's closure (client-react, CLI) read PREREQUISITE NOT MET this round.", "Lock queue this round: about 45 minutes of cumulative waiting behind live, progressing holders (pids 9368, 18425, 5812). Four acquisitions timed out (exit 99) and were re-queued." ], "mcp_calls": "0 — no MCP tool called; reads via gh api (REST GET)", "api_writes": "1 REST write via the fleet-write relay (one POST /repos/objectstack-ai/objectstack/dispatches as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/issues/23100/comments (this os-dev-report, scripts/pm/post-stamped.mjs). No PR body or label write (PR body changes are listed in pr_body_replacements for the seat). git push of the branch (not REST): e09bd7673, dbd333828, 6582c1f0d.", "open_questions": [], "out_of_scope_findings": [ "class: c · reach: `defineStack` answers CLEAN, measured at 6582c1f0d source, for objects [cbpx_header, cbpx_other, cbpx_line (controlled_by_parent, one required master_detail 'header')] plus objectExtensions [{ extend: 'cbpx_line', fields: { other: { type: 'master_detail', reference: 'cbpx_other', required: true } } }]. `ObjectStackDefinitionSchema.safeParse` succeeds, and lint's validateSecurityPosture (the security-posture rules `os validate` runs) returns no controlled-by-parent finding. CONTROL: the same merged object declared as one object is refused by defineStack ('objects.2.fields: Object 'cbpx_line' declares … with 2 required `master_detail` fields (`header`, `other`)') and by the lint ('security-controlled-by-parent-ambiguous-relation'). So an author or AI can ship a CBP object whose merged shape has two masters through the extension door, and the runtime then fails it closed (every read denied, every write 422) with only the once-per-object warn. The `os validate` binary itself was not run; its two legs were measured directly: the stack parse and the posture rules. Neither validate.ts nor the posture rules merge objectExtensions (grep: 0 hits). Evidence: probes in the round's scratch (ext.mts, lint.mts). Dedupe words: controlled_by_parent objectExtensions second master_detail extension merged sharingModel parse", "carrier: none (承接者:无) · noted, not filed — objectui Studio is the measured producer of the omitted-required shape: a designer that lets an author set controlled_by_parent while the master_detail's Required toggle is off now meets a 422 at save, with the parse's prescription. Whether Studio should force or prompt Required when CBP is chosen is objectui's call; the save-door message names the fix." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
✅ Patch round 1 accepted; PR #23179 at
6582c1f0dis ready with auto-merge armed (Fixes #23100)domain:specseat 2 (#18549) ·os-justin· sessionsession_01BRQ9Mp1yH7R1az6MaUM18H· 2026-10-13T09:05Z · holder of claim6136101631. Report:6141838120. Thread-read: the report.6139386784) named is answered on6582c1f0d:authorization.mdxsentence;uh_cbp_linefixture is now §1-legal;cbp-master-relation-tiers-retired;requiredhalf now carries a D2,cbp-master-detail-required-defaulted. It converts only a storedcontrolled_by_parentrow whose onemaster_detailomitsrequired, which is the shape objectui's Studio writes. It is retired from the authoring load path, so a live author is still refused with the prescription. An explicitrequired: falseis not converted, because the builder's own force refuses it.6142098559,CONTRACT_REVIEW_TIER, PASS. It judges the D2 lossless (byte-equal to the builder's force), exact in scope, correctly retired and correctly registered, and finds the changeset, the D3 entry and the semver (Clause-②: no (narrowing)) right.6582c1f0d:EXPECTED_SKIPSroster; 0 failures;mergeable: clean.check-governed-merges --pr 23179: not governed, 1704 changed lines.pr_ready+automerge_enable(run 38373706004).Items, one line each:
objectExtensionsadds a second requiredmaster_detail: a class-c finding, filed finding(security): anobjectExtensionsentry that adds a second requiredmaster_detailto acontrolled_by_parentobject passesdefineStackand the posture lint, then fails closed at runtime — neither door judges the merged shape #23210.requiredshape) and objectui'sderiveMasterObject: the Studio will meet the 422 whencontrolled_by_parentis chosen with Required off.deriveMasterObject(owd-sharing.ts, display-only) picks the firstmaster_detailby order and does not readrequired. One objectui card covers both. This seat files it when this PR lands, held until a spec release carrying the change is installable.packages/spec/src/conversions/registry.tsdocblock credits the security gate with refusing an update that nulls the master. That refusal comes from the record validator's ADR-0113 check. The sentence is comment text only, below the filing gate. This note is its acceptance record. It rides the next PR that edits that file, and the seat post ([PM seat] domain:spec · seat 2 — 🟢 os-bill · session_01LFqdRsuM5nkqoJdHd5ztVk · p1/p2 only (maintainer) #18549) carries it as owed.crm_campaign_memberandcrm_event_attendeemove withdeleteBehavior: 'restrict', as the changeset's FROM → TO table says.Next: confirm MERGED, then the mis-close scan, the landing record and the objectui card.
Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
Landed: PR #23179 →
76815b901(Fixes #23100)domain:specseat 2 (#18549) ·os-justin· sessionsession_01BRQ9Mp1yH7R1az6MaUM18H· 2026-10-13T09:36Z · holder of claim6136101631, closed by this landing. Thread-read:6142139912.76815b901, an ancestor oforigin/main. 25 files, +1240 / −464. It is not governed.Fixes #23100. In the merge window the one issue closed is this card,completed. Nothing else closed.pm:dispatched, anddomain:spec,area:access,priority:p2,securityandbugstay.6134079470;6136101631;6139196718and ACCEPT6139237069;6139386784onba6686ce6;6141838120;6142098559on6582c1f0d;6142139912.controlled_by_parentobject derives its master from exactly onemaster_detaildeclaredrequired: true. The parse, the posture lint and the security runtime agree. The runtime's two fallback tiers are retired.requiredis converted on load (D2cbp-master-detail-required-defaulted). The D3cbp-master-relation-tiers-retiredcarries the prescription for the shapes no conversion can choose.Carried, one line each:
requiredshape) and itsderiveMasterObject(first master by order,requiredunread): filed Studio: acontrolled_by_parentobject needs exactly onemaster_detailwithrequired: true(objectstack#23100) — the designer writes an omittedrequiredthat the save door now refuses, andderiveMasterObjectreads the first master by order objectui#12326. It ispm:on-holduntil a@objectstack/spec@nextrelease built from76815b901or later is installable.objectExtensionsadding a second required master: finding(security): anobjectExtensionsentry that adds a second requiredmaster_detailto acontrolled_by_parentobject passesdefineStackand the posture lint, then fails closed at runtime — neither door judges the merged shape #23210, awaiting triage.conversions/registry.tsdocblock sentence (record item A): owed on the seat post ([PM seat] domain:spec · seat 2 — 🟢 os-bill · session_01LFqdRsuM5nkqoJdHd5ztVk · p1/p2 only (maintainer) #18549). It rides the next PR that edits that file.controlled_by_parentobjects on the retiredlookuptier move as the changeset's FROM → TO table says.crm_campaign_memberandcrm_event_attendeetakedeleteBehavior: 'restrict', andcrm_article_feedbackkeepscascade. HotCRM's pinned engine is unaffected until it bumps past the release that carries this change.Generated by Claude Code