Skip to content

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

Closed

Description

@objectstack-fleet

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.

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

Why it matters

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

  1. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    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 call

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-12T21:53Z. ⛔ Not a claim, ⛔ not a dispatch, ⛔ no letter chosen.

    维护者速读

    「受父记录控制」(controlled_by_parent)的对象要靠一条父字段来判断谁能看、谁能改。ADR-0055 写的是:必须恰好有一个必填的主从(master_detail)字段,否则直接报错。但运行时和检查工具实际还接受两种退路:非必填的主从字段,或者一个必填的查找(lookup)字段。HotCRM 有 3 个对象(营销活动成员、活动参会人、文章反馈)就是靠「必填查找字段」在跑。两条路:

    选项 做法 代价 工作量
    A 改 ADR:把这两种退路写进规则(连同找不到父记录时一律拒绝的边界),规范描述跟着改 规则变复杂,AI 要推断哪个字段算父字段;记录下来的安全边界比原 ADR 宽 1 个受治理 PR(小)
    B 按 ADR 执行:解析和检查工具拒绝没有必填主从字段的对象;HotCRM 先把这 3 个对象改成主从字段(父记录删除时它们会跟着删,语义上本来就该这样),再上拒绝 要先迁移 HotCRM,再收紧框架 HotCRM 1 个 PR + 框架 1 个 PR(含 ADR-0087 退役处理),中等

    席位推荐 B:它符合「以协议为基准」的既有裁决,一个对象只有一条明确的父关系。若您不想动 HotCRM,退而选 A。

    选 A 还是 B?

    os-decision-facets

    • ① 项目长远合理性:B 让每个受控对象只有一条明确的父关系,和 ADR 一致;A 把三档推断写进规则,规则从此更难读。
    • ② 实际业务拉动:今天零故障;HotCRM 有 3 个对象依赖第三档,B 要先迁移它们。
    • ③ 防 AI 犯错:B 下 AI 只需写一个必填主从字段;A 下 AI 要理解三档优先级才知道权限从哪来。
    • ④ 创业阶段不扩散:A 只改文本;B 两个仓各一个 PR,但删掉一套隐式规则。

    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 (required master_detail under controlled_by_parent).

  2. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #331 item 4 · letter B · maintainer 「同意」 2026-10-12T22:46Z

    Director seat, summon #39, session_01TJKP6W6UDYEBHtccVdpVoz (GitHub os-steve; written as objectstack-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_parent object derives its master from exactly one required master_detail field. The parse (ObjectSchema) refuses a controlled_by_parent object with no required master_detail, or with two, naming the object and the rule; packages/lint's CBP_TIERS collapses to that one tier; the runtime's second tier (a non-required master_detail) and third tier (a required lookup) retire under ADR-0049 with an ADR-0087 disposition and a BREAKING changeset FROM → TO that names HotCRM's three objects. The sharingModel description in solution-blueprint.zod.ts already 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.

    • Workload (the card's column for B): hotcrm 1 PR (three objects to a required master_detail) + objectstack 1 PR (domain:spec: the parse refusal, the lint tier, the runtime retirement, the ADR-0087 disposition) · M · precedents spec builder: force required: true on a master_detail reference under controlled_by_parent (ruled Direction 2 of #8772) #9138 / lint: promote relationship/master-detail-required from warning to error, scoped to controlled_by_parent — ruled for the v18 boundary (Direction 1 of #8772) #9139 (the first tier's half).
    • Execution parameters and order: this card moves 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_attendee and crm_article_feedback declare controlled_by_parent through a required lookup; each declares one required master_detail to 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".
    • Fork clause: if one of the three objects must let its rows outlive the parent in product terms (article feedback kept after its article is deleted), that object leaves controlled_by_parent for its own sharing model; the letter stays B. ⛔ Keeping the third tier for it is not an option.
    • 四棱:卡面 6133304643 的四棱块本席逐条确认;只看①选 B;②③④ 是否翻转:否。
  3. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (#23100, ruling B 6134079470: a controlled_by_parent object derives its master from exactly one required master_detail; the second and third tiers retire) · 2026-10-13T01:29Z
    Session: session_01BRQ9Mp1yH7R1az6MaUM18H
    Account: os-justin (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-23100-cbp-one-master
    Worktree: objectstack-issue-23100
    Domain: domain:spec
    Seat: domain:spec#2 (seat post #18549)
    Ruling-ref: 6134079470 (batch #331 item 4, letter B), read in this act.
    File surface (at origin/main c9b5b24b0 or later; stop on breach and explain in the report):

    This act moves the card pm:queue → pm:dispatched and assigns os-justin.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  5. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #23179 at ba6686ce6 (Fixes #23100). Next: the contract-tier review (running), then the queue

    domain:spec seat 2 (#18549) · os-justin · session session_01BRQ9Mp1yH7R1az6MaUM18H · 2026-10-13T05:53Z · holder of claim 6136101631. Report: 6139196718. Thread-read: the report.

    Read on GitHub:

    • Shape. The PR is a draft against main. Line 1 is Fixes #23100 and line 2 is Clause-②: 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 not docs/adr/**). There is no content/docs/releases/ change. The changeset is @objectstack/spec / @objectstack/lint / @objectstack/plugin-security minor, BREAKING, with FROM → TO.
    • Three seams, one rule (ruling B, 6134079470):
      • ObjectSchema refuses a controlled_by_parent object unless exactly one master_detail is declared required: true, at raw parse as well as create().
      • CBP_TIERS collapses to one predicate.
      • resolveCbpRelation resolves that one field or null (fail-closed).
      • The ADR-0087 disposition is a new D3 cbp-master-relation-tiers-retired with no D2, and the two sibling step-18 CBP entries are amended so step 18 reads as one.
    • Prose, checked sentence by sentence against the diff:
      • The changeset's BREAKING list, the FROM → TO table and the "Unchanged" list match the code.
      • error-catalog.mdx and error-handling.mdx drop the two retired shapes from the 422 matrix and say they name no master at all.
      • The catalog's new "os lint reports both at error" is true on origin/main: relationship/master-detail-required is severity: 'error' for the readonly and system shapes (packages/lint/src/data-model-rules.ts). It now agrees with the protocol page.
      • The prescription adds no version stamp.
    • Evidence accepted (the dev's runs, on a rebuilt closure, downstream direction):
      • Suites: spec 660 files / 20,027 tests; lint 138 / 6,654; plugin-security 192 / 3,984; objectql 409 / 7,998; platform-objects 70 / 1,096; app-crm 6 / 46; app-showcase 33 / 409; verify 36 / 289.
      • Two ablations went red as predicted and were restored by blob hash.
      • Gates: 127 derived and 127 run. 126 exited 0; check-engine-split-ratio is NOT MEASURED (shallow clone).
    • CI at this read: 19 success, 5 skipped, 14 in progress, 0 failures.

    Deviations, one line each:

    Report items, one line each:

    • For whoever files the HotCRM card (the hotcrm seat or triage, per the ruling): crm_campaign_member and crm_event_attendee keep their parent's delete refused today. Their TO is therefore Field.masterDetail(…, { required: true, deleteBehavior: 'restrict' }), not the cascade the ruling's draft text assumed. crm_article_feedback already cascades. This is not the ruling's fork: no rows outlive their parent.
    • ObjectExtensionSchema is not judged by the one-master rule, so an extension adding a second required master_detail is caught only at runtime (fail-closed and reported). It was code-read only and not measured, so it is noted, not filed.
    • Three stale texts (verify's masterLinkOf tail, verify's unreachable notDerivable line, the assertControlledByParentWrite freeze note): noted, not filed. They ride the next PR that edits those files.

    Then: the CONTRACT_REVIEW_TIER record on this head, then the pre-queue checks (every check green), then ready and the queue. The card stays pm:dispatched with its assignee until MERGED.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  7. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ Patch round 1 accepted; PR #23179 at 6582c1f0d is ready with auto-merge armed (Fixes #23100)

    domain:spec seat 2 (#18549) · os-justin · session session_01BRQ9Mp1yH7R1az6MaUM18H · 2026-10-13T09:05Z · holder of claim 6136101631. Report: 6141838120. Thread-read: the report.

    • The round: each of the four sites the round-0 record (6139386784) named is answered on 6582c1f0d:
      • the authorization.mdx sentence;
      • the objectql uh_cbp_line fixture is now §1-legal;
      • the step-18 rationale fragment cbp-master-relation-tiers-retired;
      • the omitted-required half now carries a D2, cbp-master-detail-required-defaulted. It converts only a stored controlled_by_parent row whose one master_detail omits required, 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 explicit required: false is not converted, because the builder's own force refuses it.
    • Contract review on this head: 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.
    • Pre-queue checks on 6582c1f0d:
      • 41 success and 7 skipped, every skip on the EXPECTED_SKIPS roster; 0 failures; mergeable: clean.
      • check-governed-merges --pr 23179: not governed, 1704 changed lines.
    • Landed through the relay: pr_ready + automerge_enable (run 38373706004).

    Items, one line each:

    Next: confirm MERGED, then the mis-close scan, the landing record and the objectui card.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23179 → 76815b901 (Fixes #23100)

    domain:spec seat 2 (#18549) · os-justin · session session_01BRQ9Mp1yH7R1az6MaUM18H · 2026-10-13T09:36Z · holder of claim 6136101631, closed by this landing. Thread-read: 6142139912.

    • Landed through the merge queue at 2026-10-13T09:34Z as 76815b901, an ancestor of origin/main. 25 files, +1240 / −464. It is not governed.
    • Mis-close scan: the body's only closing keyword is Fixes #23100. In the merge window the one issue closed is this card, completed. Nothing else closed.
    • State: GitHub closed the card. This act removes pm:dispatched, and domain:spec, area:access, priority:p2, security and bug stay.
    • The chain:
      • ruling B 6134079470;
      • claim 6136101631;
      • report 6139196718 and ACCEPT 6139237069;
      • contract review PASS 6139386784 on ba6686ce6;
      • patch report 6141838120;
      • delta contract review PASS 6142098559 on 6582c1f0d;
      • queued 6142139912.
    • What holds now (ADR-0055 §1, under ADR-0049):
      • A controlled_by_parent object derives its master from exactly one master_detail declared required: true. The parse, the posture lint and the security runtime agree. The runtime's two fallback tiers are retired.
      • A stored row whose one master omits required is converted on load (D2 cbp-master-detail-required-defaulted). The D3 cbp-master-relation-tiers-retired carries the prescription for the shapes no conversion can choose.

    Carried, one line each:


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:specpriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions