fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master
Sep 3, 2026
Merged

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order#14818
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14747

Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguouscontrolled_by_parent master. Directions (2) (an authorable key naming the master — human floor) and (3) (a runtime docstring — domain:services) are deliberately not touched.

The precedence chain, and why only the winning tier counts

SecurityPlugin.resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts:6057-6061, read-only for this PR) resolves the master a controlled_by_parent object derives record-level access from:

pick(f => f.type === 'master_detail' && f.required) // tier 1
?? pick(f => f.type === 'master_detail') // tier 2
?? pick(f => f.type === 'lookup' && f.required) // tier 3

pick is entries.find(...), so within a tier the winner is whichever candidate the field map lists first — field declaration order, a property that carries no authored meaning. The card measured it on a real kernel: an object with two required lookups resolved to the first-declared one, and swapping the two field declarations (nothing else) repointed every row's record-level access to the other object.

The ?? chain stops at the first tier that resolves, so a tie in a lower tier is masked by a higher tier's single winner and is not a decision the platform ever makes. Reporting a masked tie would send authors to edit fields that change nothing, so the rule judges the winning tier and only the winning tier.

What lands

New error id security-controlled-by-parent-ambiguous-relation, exported beside SECURITY_CBP_NO_RELATION. It is the mirror image of the #7503 rule one line above it: that one reports zero candidates, this one reports two or more. The message names every candidate — field, type and master — in declaration order, says which tier was tested, and says which candidate wins today and therefore which object access derives from right now.

error, and for the inverse of the usual reason. The module header's ADR-0049 paragraph says every error rule mirrors a runtime enforcement point; this one has none to mirror precisely because the runtime does not refuse — it silently picks — so author time is the only place the ambiguity can ever surface. What it does satisfy is the admissibility bar the #7503 push site already states: a self-contained property of the object document, no per-permission-set nuance to adjudicate, and no legitimate reading, since two tied candidates is not an author saying which master they meant. That paragraph is amended in this PR rather than left to read as a rule the file now breaks.

resolveCbpRelation in the mirror now reads its tiers from one shared table, so the two CBP rules cannot disagree about which tier wins. Its answer is unchanged by construction: find over a tier is the first element filter over that tier keeps, and the first tier with a candidate is the tier the ?? chain stops at.

The mirror's one deliberate divergence is kept as the triage asked: refOf accepts only the reference spelling (reference_to / referenceTo are rejected aliases, #5017), so a field carrying an alias is not a candidate and cannot create a tie. A test pins that from this rule's side.

Fixture table

fixturewinning tierverdict
two required lookups3error — both named, account wins today
the same two, declared in the other order3errorcontact named as today's winner
two required master_detail1error
two master_detail, neither required2error
three required lookups3error — "3 of its fields tie"
one required master_detail + two required lookups1silent — the tie is masked
one required master_detail + one bare master_detail1silent
one candidate in any single tier1 / 2 / 3silent
second candidate names no reference target3silent — not a candidate
second lookup optional3silent — not a resolution step
two candidates, sharingModel not controlled_by_parentsilent
reference_to alias on the second candidate3silent — rejected spelling
system object (sys_* / isSystem) with a tie3error — not exempt, as with the no-relation rule

Plus: the two CBP rules are pinned mutually exclusive, and the array field form is covered alongside the name-keyed map.

Measured over the shipped corpus — 0 findings before, 0 after

  • Example apps (4 stacks, 3 controlled_by_parent objects): showcase_invoice_line, showcase_expense_line, crm_opportunity_line_item — each declares exactly one required master_detail, so tier 1 wins with a single candidate. showcase_invoice_line is the interesting one: it also carries a required lookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings are security-private-no-readscope (info) x2 and security-master-detail-ungranted (warning) x3, unchanged.
  • Docs + skills prose corpus: check:doc-security-posture green over 27 ObjectSchema.create sites in 226 marked blocks across 236 files.

One bounded companion fix, named

packages/lint/scripts/check-doc-security-posture.mjs suppresses SECURITY_CBP_NO_RELATION when the fields subtree is not statically evaluable, and its module docblock enumerates the per-rule conservatism. The new rule reads the same subtree, so it is added to the same suppression: an opaque Field.master_detail(...) factory call is invisible to every tier predicate, so a real tier-1 winner masked by a factory call would hand the win to a lower tier and report a tie the platform never resolves. Zero effect on today's corpus — no marked block declares controlled_by_parent, which the gate's own comment already records — so this closes a latent false-red in a security gate rather than fixing an observed one. It is one identifier in a condition plus the docblock line, its correct shape is pinned by the sibling declaration on the adjacent line, and leaving it out would make that docblock's enumeration false. Declared here and in the report as an amendment to the claimed file surface.

Gates

All run in the worktree on the final commit 81a7c46f71 (the merge of origin/main), after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':

  • pnpm --filter @objectstack/lint test93 files / 2865 tests passed
  • pnpm --filter @objectstack/lint typecheck — green, incl. check:test-typecheck (test layer really compiled; the debt ledger held at 2 files / 6 errors)
  • pnpm lint (eslint . --no-inline-config, whole repo) — green
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED: scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET locally because it grades a saved turbo run test log CI tees and this run has none — not a red and nothing to fix.
  • Both build-dependent ratchets pass on the built closure: check:dual-build-cjs-loads (floors held) and check:type-check-debt --re-measure (21 ledger entries, none above its recorded number).

No runtime behaviour changes; packages/plugins/plugin-security/** and packages/spec/** are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

…ed by field declaration order
`SecurityPlugin.resolveCbpRelation` resolves a `controlled_by_parent` object's
master through three tiers -- a required `master_detail`, then any
`master_detail`, then a required `lookup` -- and picks inside a tier with
`Array.prototype.find`. Two or more candidates in the tier that WINS therefore
resolve by field declaration order, and nothing reports it: not `os validate`,
not `os lint`, not a boot warning. Reordering fields is a review-invisible edit
that silently repoints every row's record-level access to another object.
New error id `security-controlled-by-parent-ambiguous-relation`, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate -- field, type and master -- in declaration order, the tier tested,
and the candidate that wins today.
Only the WINNING tier is judged: the runtime's `??` chain stops at the first
tier that resolves, so a tie in a lower tier is masked and is not a decision
the platform ever makes.
`resolveCbpRelation` here now reads its tiers from one shared table so the two
rules cannot disagree about which tier wins; its answer is unchanged by
construction (`find` over a tier is the first element `filter` keeps). The
mirror's deliberate `reference`-only divergence (#5017) is kept, so a field
carrying the rejected `reference_to` alias is not a candidate and cannot create
a tie.
The `check:doc-security-posture` gate's partial-evaluation suppression is
extended to the new rule for the reason its own docblock already gives: an
opaque `Field.master_detail(...)` factory call is invisible to every tier
predicate, so a masked higher tier would hand the win to a lower one and report
a tie the platform never resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197ddapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 85564eb5d726cfa32b300b1103490765baa4f039 — the merge of head 81a7c46f71cb4e10a556798c872422284342afc5 into base 5a5336b399db2ef18dd4700f97d579a328197dda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85564eb5d726cfa32b300b1103490765baa4f039 && git checkout 85564eb5d726cfa32b300b1103490765baa4f039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda 81a7c46f71cb4e10a556798c872422284342afc5 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff 81a7c46f71cb4e10a556798c872422284342afc5
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a5336b399db2ef18dd4700f97d579a328197dda → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33727184587 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 96 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729181329 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729341726 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master
Sep 3, 2026
Merged

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order#14818
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14747

Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguouscontrolled_by_parent master. Directions (2) (an authorable key naming the master — human floor) and (3) (a runtime docstring — domain:services) are deliberately not touched.

The precedence chain, and why only the winning tier counts

SecurityPlugin.resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts:6057-6061, read-only for this PR) resolves the master a controlled_by_parent object derives record-level access from:

pick(f => f.type === 'master_detail' && f.required) // tier 1
?? pick(f => f.type === 'master_detail') // tier 2
?? pick(f => f.type === 'lookup' && f.required) // tier 3

pick is entries.find(...), so within a tier the winner is whichever candidate the field map lists first — field declaration order, a property that carries no authored meaning. The card measured it on a real kernel: an object with two required lookups resolved to the first-declared one, and swapping the two field declarations (nothing else) repointed every row's record-level access to the other object.

The ?? chain stops at the first tier that resolves, so a tie in a lower tier is masked by a higher tier's single winner and is not a decision the platform ever makes. Reporting a masked tie would send authors to edit fields that change nothing, so the rule judges the winning tier and only the winning tier.

What lands

New error id security-controlled-by-parent-ambiguous-relation, exported beside SECURITY_CBP_NO_RELATION. It is the mirror image of the #7503 rule one line above it: that one reports zero candidates, this one reports two or more. The message names every candidate — field, type and master — in declaration order, says which tier was tested, and says which candidate wins today and therefore which object access derives from right now.

error, and for the inverse of the usual reason. The module header's ADR-0049 paragraph says every error rule mirrors a runtime enforcement point; this one has none to mirror precisely because the runtime does not refuse — it silently picks — so author time is the only place the ambiguity can ever surface. What it does satisfy is the admissibility bar the #7503 push site already states: a self-contained property of the object document, no per-permission-set nuance to adjudicate, and no legitimate reading, since two tied candidates is not an author saying which master they meant. That paragraph is amended in this PR rather than left to read as a rule the file now breaks.

resolveCbpRelation in the mirror now reads its tiers from one shared table, so the two CBP rules cannot disagree about which tier wins. Its answer is unchanged by construction: find over a tier is the first element filter over that tier keeps, and the first tier with a candidate is the tier the ?? chain stops at.

The mirror's one deliberate divergence is kept as the triage asked: refOf accepts only the reference spelling (reference_to / referenceTo are rejected aliases, #5017), so a field carrying an alias is not a candidate and cannot create a tie. A test pins that from this rule's side.

Fixture table

fixturewinning tierverdict
two required lookups3error — both named, account wins today
the same two, declared in the other order3errorcontact named as today's winner
two required master_detail1error
two master_detail, neither required2error
three required lookups3error — "3 of its fields tie"
one required master_detail + two required lookups1silent — the tie is masked
one required master_detail + one bare master_detail1silent
one candidate in any single tier1 / 2 / 3silent
second candidate names no reference target3silent — not a candidate
second lookup optional3silent — not a resolution step
two candidates, sharingModel not controlled_by_parentsilent
reference_to alias on the second candidate3silent — rejected spelling
system object (sys_* / isSystem) with a tie3error — not exempt, as with the no-relation rule

Plus: the two CBP rules are pinned mutually exclusive, and the array field form is covered alongside the name-keyed map.

Measured over the shipped corpus — 0 findings before, 0 after

  • Example apps (4 stacks, 3 controlled_by_parent objects): showcase_invoice_line, showcase_expense_line, crm_opportunity_line_item — each declares exactly one required master_detail, so tier 1 wins with a single candidate. showcase_invoice_line is the interesting one: it also carries a required lookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings are security-private-no-readscope (info) x2 and security-master-detail-ungranted (warning) x3, unchanged.
  • Docs + skills prose corpus: check:doc-security-posture green over 27 ObjectSchema.create sites in 226 marked blocks across 236 files.

One bounded companion fix, named

packages/lint/scripts/check-doc-security-posture.mjs suppresses SECURITY_CBP_NO_RELATION when the fields subtree is not statically evaluable, and its module docblock enumerates the per-rule conservatism. The new rule reads the same subtree, so it is added to the same suppression: an opaque Field.master_detail(...) factory call is invisible to every tier predicate, so a real tier-1 winner masked by a factory call would hand the win to a lower tier and report a tie the platform never resolves. Zero effect on today's corpus — no marked block declares controlled_by_parent, which the gate's own comment already records — so this closes a latent false-red in a security gate rather than fixing an observed one. It is one identifier in a condition plus the docblock line, its correct shape is pinned by the sibling declaration on the adjacent line, and leaving it out would make that docblock's enumeration false. Declared here and in the report as an amendment to the claimed file surface.

Gates

All run in the worktree on the final commit 81a7c46f71 (the merge of origin/main), after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':

  • pnpm --filter @objectstack/lint test93 files / 2865 tests passed
  • pnpm --filter @objectstack/lint typecheck — green, incl. check:test-typecheck (test layer really compiled; the debt ledger held at 2 files / 6 errors)
  • pnpm lint (eslint . --no-inline-config, whole repo) — green
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED: scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET locally because it grades a saved turbo run test log CI tees and this run has none — not a red and nothing to fix.
  • Both build-dependent ratchets pass on the built closure: check:dual-build-cjs-loads (floors held) and check:type-check-debt --re-measure (21 ledger entries, none above its recorded number).

No runtime behaviour changes; packages/plugins/plugin-security/** and packages/spec/** are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

…ed by field declaration order
`SecurityPlugin.resolveCbpRelation` resolves a `controlled_by_parent` object's
master through three tiers -- a required `master_detail`, then any
`master_detail`, then a required `lookup` -- and picks inside a tier with
`Array.prototype.find`. Two or more candidates in the tier that WINS therefore
resolve by field declaration order, and nothing reports it: not `os validate`,
not `os lint`, not a boot warning. Reordering fields is a review-invisible edit
that silently repoints every row's record-level access to another object.
New error id `security-controlled-by-parent-ambiguous-relation`, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate -- field, type and master -- in declaration order, the tier tested,
and the candidate that wins today.
Only the WINNING tier is judged: the runtime's `??` chain stops at the first
tier that resolves, so a tie in a lower tier is masked and is not a decision
the platform ever makes.
`resolveCbpRelation` here now reads its tiers from one shared table so the two
rules cannot disagree about which tier wins; its answer is unchanged by
construction (`find` over a tier is the first element `filter` keeps). The
mirror's deliberate `reference`-only divergence (#5017) is kept, so a field
carrying the rejected `reference_to` alias is not a candidate and cannot create
a tie.
The `check:doc-security-posture` gate's partial-evaluation suppression is
extended to the new rule for the reason its own docblock already gives: an
opaque `Field.master_detail(...)` factory call is invisible to every tier
predicate, so a masked higher tier would hand the win to a lower one and report
a tie the platform never resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197ddapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 85564eb5d726cfa32b300b1103490765baa4f039 — the merge of head 81a7c46f71cb4e10a556798c872422284342afc5 into base 5a5336b399db2ef18dd4700f97d579a328197dda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85564eb5d726cfa32b300b1103490765baa4f039 && git checkout 85564eb5d726cfa32b300b1103490765baa4f039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda 81a7c46f71cb4e10a556798c872422284342afc5 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff 81a7c46f71cb4e10a556798c872422284342afc5
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a5336b399db2ef18dd4700f97d579a328197dda → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33727184587 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 96 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729181329 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729341726 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master
Sep 3, 2026
Merged

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order#14818
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14747

Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguouscontrolled_by_parent master. Directions (2) (an authorable key naming the master — human floor) and (3) (a runtime docstring — domain:services) are deliberately not touched.

The precedence chain, and why only the winning tier counts

SecurityPlugin.resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts:6057-6061, read-only for this PR) resolves the master a controlled_by_parent object derives record-level access from:

pick(f => f.type === 'master_detail' && f.required) // tier 1
?? pick(f => f.type === 'master_detail') // tier 2
?? pick(f => f.type === 'lookup' && f.required) // tier 3

pick is entries.find(...), so within a tier the winner is whichever candidate the field map lists first — field declaration order, a property that carries no authored meaning. The card measured it on a real kernel: an object with two required lookups resolved to the first-declared one, and swapping the two field declarations (nothing else) repointed every row's record-level access to the other object.

The ?? chain stops at the first tier that resolves, so a tie in a lower tier is masked by a higher tier's single winner and is not a decision the platform ever makes. Reporting a masked tie would send authors to edit fields that change nothing, so the rule judges the winning tier and only the winning tier.

What lands

New error id security-controlled-by-parent-ambiguous-relation, exported beside SECURITY_CBP_NO_RELATION. It is the mirror image of the #7503 rule one line above it: that one reports zero candidates, this one reports two or more. The message names every candidate — field, type and master — in declaration order, says which tier was tested, and says which candidate wins today and therefore which object access derives from right now.

error, and for the inverse of the usual reason. The module header's ADR-0049 paragraph says every error rule mirrors a runtime enforcement point; this one has none to mirror precisely because the runtime does not refuse — it silently picks — so author time is the only place the ambiguity can ever surface. What it does satisfy is the admissibility bar the #7503 push site already states: a self-contained property of the object document, no per-permission-set nuance to adjudicate, and no legitimate reading, since two tied candidates is not an author saying which master they meant. That paragraph is amended in this PR rather than left to read as a rule the file now breaks.

resolveCbpRelation in the mirror now reads its tiers from one shared table, so the two CBP rules cannot disagree about which tier wins. Its answer is unchanged by construction: find over a tier is the first element filter over that tier keeps, and the first tier with a candidate is the tier the ?? chain stops at.

The mirror's one deliberate divergence is kept as the triage asked: refOf accepts only the reference spelling (reference_to / referenceTo are rejected aliases, #5017), so a field carrying an alias is not a candidate and cannot create a tie. A test pins that from this rule's side.

Fixture table

fixturewinning tierverdict
two required lookups3error — both named, account wins today
the same two, declared in the other order3errorcontact named as today's winner
two required master_detail1error
two master_detail, neither required2error
three required lookups3error — "3 of its fields tie"
one required master_detail + two required lookups1silent — the tie is masked
one required master_detail + one bare master_detail1silent
one candidate in any single tier1 / 2 / 3silent
second candidate names no reference target3silent — not a candidate
second lookup optional3silent — not a resolution step
two candidates, sharingModel not controlled_by_parentsilent
reference_to alias on the second candidate3silent — rejected spelling
system object (sys_* / isSystem) with a tie3error — not exempt, as with the no-relation rule

Plus: the two CBP rules are pinned mutually exclusive, and the array field form is covered alongside the name-keyed map.

Measured over the shipped corpus — 0 findings before, 0 after

  • Example apps (4 stacks, 3 controlled_by_parent objects): showcase_invoice_line, showcase_expense_line, crm_opportunity_line_item — each declares exactly one required master_detail, so tier 1 wins with a single candidate. showcase_invoice_line is the interesting one: it also carries a required lookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings are security-private-no-readscope (info) x2 and security-master-detail-ungranted (warning) x3, unchanged.
  • Docs + skills prose corpus: check:doc-security-posture green over 27 ObjectSchema.create sites in 226 marked blocks across 236 files.

One bounded companion fix, named

packages/lint/scripts/check-doc-security-posture.mjs suppresses SECURITY_CBP_NO_RELATION when the fields subtree is not statically evaluable, and its module docblock enumerates the per-rule conservatism. The new rule reads the same subtree, so it is added to the same suppression: an opaque Field.master_detail(...) factory call is invisible to every tier predicate, so a real tier-1 winner masked by a factory call would hand the win to a lower tier and report a tie the platform never resolves. Zero effect on today's corpus — no marked block declares controlled_by_parent, which the gate's own comment already records — so this closes a latent false-red in a security gate rather than fixing an observed one. It is one identifier in a condition plus the docblock line, its correct shape is pinned by the sibling declaration on the adjacent line, and leaving it out would make that docblock's enumeration false. Declared here and in the report as an amendment to the claimed file surface.

Gates

All run in the worktree on the final commit 81a7c46f71 (the merge of origin/main), after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':

  • pnpm --filter @objectstack/lint test93 files / 2865 tests passed
  • pnpm --filter @objectstack/lint typecheck — green, incl. check:test-typecheck (test layer really compiled; the debt ledger held at 2 files / 6 errors)
  • pnpm lint (eslint . --no-inline-config, whole repo) — green
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED: scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET locally because it grades a saved turbo run test log CI tees and this run has none — not a red and nothing to fix.
  • Both build-dependent ratchets pass on the built closure: check:dual-build-cjs-loads (floors held) and check:type-check-debt --re-measure (21 ledger entries, none above its recorded number).

No runtime behaviour changes; packages/plugins/plugin-security/** and packages/spec/** are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

…ed by field declaration order
`SecurityPlugin.resolveCbpRelation` resolves a `controlled_by_parent` object's
master through three tiers -- a required `master_detail`, then any
`master_detail`, then a required `lookup` -- and picks inside a tier with
`Array.prototype.find`. Two or more candidates in the tier that WINS therefore
resolve by field declaration order, and nothing reports it: not `os validate`,
not `os lint`, not a boot warning. Reordering fields is a review-invisible edit
that silently repoints every row's record-level access to another object.
New error id `security-controlled-by-parent-ambiguous-relation`, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate -- field, type and master -- in declaration order, the tier tested,
and the candidate that wins today.
Only the WINNING tier is judged: the runtime's `??` chain stops at the first
tier that resolves, so a tie in a lower tier is masked and is not a decision
the platform ever makes.
`resolveCbpRelation` here now reads its tiers from one shared table so the two
rules cannot disagree about which tier wins; its answer is unchanged by
construction (`find` over a tier is the first element `filter` keeps). The
mirror's deliberate `reference`-only divergence (#5017) is kept, so a field
carrying the rejected `reference_to` alias is not a candidate and cannot create
a tie.
The `check:doc-security-posture` gate's partial-evaluation suppression is
extended to the new rule for the reason its own docblock already gives: an
opaque `Field.master_detail(...)` factory call is invisible to every tier
predicate, so a masked higher tier would hand the win to a lower one and report
a tie the platform never resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197ddapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 85564eb5d726cfa32b300b1103490765baa4f039 — the merge of head 81a7c46f71cb4e10a556798c872422284342afc5 into base 5a5336b399db2ef18dd4700f97d579a328197dda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85564eb5d726cfa32b300b1103490765baa4f039 && git checkout 85564eb5d726cfa32b300b1103490765baa4f039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda 81a7c46f71cb4e10a556798c872422284342afc5 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff 81a7c46f71cb4e10a556798c872422284342afc5
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a5336b399db2ef18dd4700f97d579a328197dda → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33727184587 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 96 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729181329 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729341726 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master
Sep 3, 2026
Merged

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order#14818
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14747

Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguouscontrolled_by_parent master. Directions (2) (an authorable key naming the master — human floor) and (3) (a runtime docstring — domain:services) are deliberately not touched.

The precedence chain, and why only the winning tier counts

SecurityPlugin.resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts:6057-6061, read-only for this PR) resolves the master a controlled_by_parent object derives record-level access from:

pick(f => f.type === 'master_detail' && f.required) // tier 1
?? pick(f => f.type === 'master_detail') // tier 2
?? pick(f => f.type === 'lookup' && f.required) // tier 3

pick is entries.find(...), so within a tier the winner is whichever candidate the field map lists first — field declaration order, a property that carries no authored meaning. The card measured it on a real kernel: an object with two required lookups resolved to the first-declared one, and swapping the two field declarations (nothing else) repointed every row's record-level access to the other object.

The ?? chain stops at the first tier that resolves, so a tie in a lower tier is masked by a higher tier's single winner and is not a decision the platform ever makes. Reporting a masked tie would send authors to edit fields that change nothing, so the rule judges the winning tier and only the winning tier.

What lands

New error id security-controlled-by-parent-ambiguous-relation, exported beside SECURITY_CBP_NO_RELATION. It is the mirror image of the #7503 rule one line above it: that one reports zero candidates, this one reports two or more. The message names every candidate — field, type and master — in declaration order, says which tier was tested, and says which candidate wins today and therefore which object access derives from right now.

error, and for the inverse of the usual reason. The module header's ADR-0049 paragraph says every error rule mirrors a runtime enforcement point; this one has none to mirror precisely because the runtime does not refuse — it silently picks — so author time is the only place the ambiguity can ever surface. What it does satisfy is the admissibility bar the #7503 push site already states: a self-contained property of the object document, no per-permission-set nuance to adjudicate, and no legitimate reading, since two tied candidates is not an author saying which master they meant. That paragraph is amended in this PR rather than left to read as a rule the file now breaks.

resolveCbpRelation in the mirror now reads its tiers from one shared table, so the two CBP rules cannot disagree about which tier wins. Its answer is unchanged by construction: find over a tier is the first element filter over that tier keeps, and the first tier with a candidate is the tier the ?? chain stops at.

The mirror's one deliberate divergence is kept as the triage asked: refOf accepts only the reference spelling (reference_to / referenceTo are rejected aliases, #5017), so a field carrying an alias is not a candidate and cannot create a tie. A test pins that from this rule's side.

Fixture table

fixturewinning tierverdict
two required lookups3error — both named, account wins today
the same two, declared in the other order3errorcontact named as today's winner
two required master_detail1error
two master_detail, neither required2error
three required lookups3error — "3 of its fields tie"
one required master_detail + two required lookups1silent — the tie is masked
one required master_detail + one bare master_detail1silent
one candidate in any single tier1 / 2 / 3silent
second candidate names no reference target3silent — not a candidate
second lookup optional3silent — not a resolution step
two candidates, sharingModel not controlled_by_parentsilent
reference_to alias on the second candidate3silent — rejected spelling
system object (sys_* / isSystem) with a tie3error — not exempt, as with the no-relation rule

Plus: the two CBP rules are pinned mutually exclusive, and the array field form is covered alongside the name-keyed map.

Measured over the shipped corpus — 0 findings before, 0 after

  • Example apps (4 stacks, 3 controlled_by_parent objects): showcase_invoice_line, showcase_expense_line, crm_opportunity_line_item — each declares exactly one required master_detail, so tier 1 wins with a single candidate. showcase_invoice_line is the interesting one: it also carries a required lookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings are security-private-no-readscope (info) x2 and security-master-detail-ungranted (warning) x3, unchanged.
  • Docs + skills prose corpus: check:doc-security-posture green over 27 ObjectSchema.create sites in 226 marked blocks across 236 files.

One bounded companion fix, named

packages/lint/scripts/check-doc-security-posture.mjs suppresses SECURITY_CBP_NO_RELATION when the fields subtree is not statically evaluable, and its module docblock enumerates the per-rule conservatism. The new rule reads the same subtree, so it is added to the same suppression: an opaque Field.master_detail(...) factory call is invisible to every tier predicate, so a real tier-1 winner masked by a factory call would hand the win to a lower tier and report a tie the platform never resolves. Zero effect on today's corpus — no marked block declares controlled_by_parent, which the gate's own comment already records — so this closes a latent false-red in a security gate rather than fixing an observed one. It is one identifier in a condition plus the docblock line, its correct shape is pinned by the sibling declaration on the adjacent line, and leaving it out would make that docblock's enumeration false. Declared here and in the report as an amendment to the claimed file surface.

Gates

All run in the worktree on the final commit 81a7c46f71 (the merge of origin/main), after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':

  • pnpm --filter @objectstack/lint test93 files / 2865 tests passed
  • pnpm --filter @objectstack/lint typecheck — green, incl. check:test-typecheck (test layer really compiled; the debt ledger held at 2 files / 6 errors)
  • pnpm lint (eslint . --no-inline-config, whole repo) — green
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED: scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET locally because it grades a saved turbo run test log CI tees and this run has none — not a red and nothing to fix.
  • Both build-dependent ratchets pass on the built closure: check:dual-build-cjs-loads (floors held) and check:type-check-debt --re-measure (21 ledger entries, none above its recorded number).

No runtime behaviour changes; packages/plugins/plugin-security/** and packages/spec/** are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

…ed by field declaration order
`SecurityPlugin.resolveCbpRelation` resolves a `controlled_by_parent` object's
master through three tiers -- a required `master_detail`, then any
`master_detail`, then a required `lookup` -- and picks inside a tier with
`Array.prototype.find`. Two or more candidates in the tier that WINS therefore
resolve by field declaration order, and nothing reports it: not `os validate`,
not `os lint`, not a boot warning. Reordering fields is a review-invisible edit
that silently repoints every row's record-level access to another object.
New error id `security-controlled-by-parent-ambiguous-relation`, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate -- field, type and master -- in declaration order, the tier tested,
and the candidate that wins today.
Only the WINNING tier is judged: the runtime's `??` chain stops at the first
tier that resolves, so a tie in a lower tier is masked and is not a decision
the platform ever makes.
`resolveCbpRelation` here now reads its tiers from one shared table so the two
rules cannot disagree about which tier wins; its answer is unchanged by
construction (`find` over a tier is the first element `filter` keeps). The
mirror's deliberate `reference`-only divergence (#5017) is kept, so a field
carrying the rejected `reference_to` alias is not a candidate and cannot create
a tie.
The `check:doc-security-posture` gate's partial-evaluation suppression is
extended to the new rule for the reason its own docblock already gives: an
opaque `Field.master_detail(...)` factory call is invisible to every tier
predicate, so a masked higher tier would hand the win to a lower one and report
a tie the platform never resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197ddapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 85564eb5d726cfa32b300b1103490765baa4f039 — the merge of head 81a7c46f71cb4e10a556798c872422284342afc5 into base 5a5336b399db2ef18dd4700f97d579a328197dda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85564eb5d726cfa32b300b1103490765baa4f039 && git checkout 85564eb5d726cfa32b300b1103490765baa4f039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda 81a7c46f71cb4e10a556798c872422284342afc5 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff 81a7c46f71cb4e10a556798c872422284342afc5
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a5336b399db2ef18dd4700f97d579a328197dda → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33727184587 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 96 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729181329 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729341726 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master
Sep 3, 2026
Merged

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order#14818
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14747

Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguouscontrolled_by_parent master. Directions (2) (an authorable key naming the master — human floor) and (3) (a runtime docstring — domain:services) are deliberately not touched.

The precedence chain, and why only the winning tier counts

SecurityPlugin.resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts:6057-6061, read-only for this PR) resolves the master a controlled_by_parent object derives record-level access from:

pick(f => f.type === 'master_detail' && f.required) // tier 1
?? pick(f => f.type === 'master_detail') // tier 2
?? pick(f => f.type === 'lookup' && f.required) // tier 3

pick is entries.find(...), so within a tier the winner is whichever candidate the field map lists first — field declaration order, a property that carries no authored meaning. The card measured it on a real kernel: an object with two required lookups resolved to the first-declared one, and swapping the two field declarations (nothing else) repointed every row's record-level access to the other object.

The ?? chain stops at the first tier that resolves, so a tie in a lower tier is masked by a higher tier's single winner and is not a decision the platform ever makes. Reporting a masked tie would send authors to edit fields that change nothing, so the rule judges the winning tier and only the winning tier.

What lands

New error id security-controlled-by-parent-ambiguous-relation, exported beside SECURITY_CBP_NO_RELATION. It is the mirror image of the #7503 rule one line above it: that one reports zero candidates, this one reports two or more. The message names every candidate — field, type and master — in declaration order, says which tier was tested, and says which candidate wins today and therefore which object access derives from right now.

error, and for the inverse of the usual reason. The module header's ADR-0049 paragraph says every error rule mirrors a runtime enforcement point; this one has none to mirror precisely because the runtime does not refuse — it silently picks — so author time is the only place the ambiguity can ever surface. What it does satisfy is the admissibility bar the #7503 push site already states: a self-contained property of the object document, no per-permission-set nuance to adjudicate, and no legitimate reading, since two tied candidates is not an author saying which master they meant. That paragraph is amended in this PR rather than left to read as a rule the file now breaks.

resolveCbpRelation in the mirror now reads its tiers from one shared table, so the two CBP rules cannot disagree about which tier wins. Its answer is unchanged by construction: find over a tier is the first element filter over that tier keeps, and the first tier with a candidate is the tier the ?? chain stops at.

The mirror's one deliberate divergence is kept as the triage asked: refOf accepts only the reference spelling (reference_to / referenceTo are rejected aliases, #5017), so a field carrying an alias is not a candidate and cannot create a tie. A test pins that from this rule's side.

Fixture table

fixturewinning tierverdict
two required lookups3error — both named, account wins today
the same two, declared in the other order3errorcontact named as today's winner
two required master_detail1error
two master_detail, neither required2error
three required lookups3error — "3 of its fields tie"
one required master_detail + two required lookups1silent — the tie is masked
one required master_detail + one bare master_detail1silent
one candidate in any single tier1 / 2 / 3silent
second candidate names no reference target3silent — not a candidate
second lookup optional3silent — not a resolution step
two candidates, sharingModel not controlled_by_parentsilent
reference_to alias on the second candidate3silent — rejected spelling
system object (sys_* / isSystem) with a tie3error — not exempt, as with the no-relation rule

Plus: the two CBP rules are pinned mutually exclusive, and the array field form is covered alongside the name-keyed map.

Measured over the shipped corpus — 0 findings before, 0 after

  • Example apps (4 stacks, 3 controlled_by_parent objects): showcase_invoice_line, showcase_expense_line, crm_opportunity_line_item — each declares exactly one required master_detail, so tier 1 wins with a single candidate. showcase_invoice_line is the interesting one: it also carries a required lookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings are security-private-no-readscope (info) x2 and security-master-detail-ungranted (warning) x3, unchanged.
  • Docs + skills prose corpus: check:doc-security-posture green over 27 ObjectSchema.create sites in 226 marked blocks across 236 files.

One bounded companion fix, named

packages/lint/scripts/check-doc-security-posture.mjs suppresses SECURITY_CBP_NO_RELATION when the fields subtree is not statically evaluable, and its module docblock enumerates the per-rule conservatism. The new rule reads the same subtree, so it is added to the same suppression: an opaque Field.master_detail(...) factory call is invisible to every tier predicate, so a real tier-1 winner masked by a factory call would hand the win to a lower tier and report a tie the platform never resolves. Zero effect on today's corpus — no marked block declares controlled_by_parent, which the gate's own comment already records — so this closes a latent false-red in a security gate rather than fixing an observed one. It is one identifier in a condition plus the docblock line, its correct shape is pinned by the sibling declaration on the adjacent line, and leaving it out would make that docblock's enumeration false. Declared here and in the report as an amendment to the claimed file surface.

Gates

All run in the worktree on the final commit 81a7c46f71 (the merge of origin/main), after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':

  • pnpm --filter @objectstack/lint test93 files / 2865 tests passed
  • pnpm --filter @objectstack/lint typecheck — green, incl. check:test-typecheck (test layer really compiled; the debt ledger held at 2 files / 6 errors)
  • pnpm lint (eslint . --no-inline-config, whole repo) — green
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED: scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET locally because it grades a saved turbo run test log CI tees and this run has none — not a red and nothing to fix.
  • Both build-dependent ratchets pass on the built closure: check:dual-build-cjs-loads (floors held) and check:type-check-debt --re-measure (21 ledger entries, none above its recorded number).

No runtime behaviour changes; packages/plugins/plugin-security/** and packages/spec/** are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

…ed by field declaration order
`SecurityPlugin.resolveCbpRelation` resolves a `controlled_by_parent` object's
master through three tiers -- a required `master_detail`, then any
`master_detail`, then a required `lookup` -- and picks inside a tier with
`Array.prototype.find`. Two or more candidates in the tier that WINS therefore
resolve by field declaration order, and nothing reports it: not `os validate`,
not `os lint`, not a boot warning. Reordering fields is a review-invisible edit
that silently repoints every row's record-level access to another object.
New error id `security-controlled-by-parent-ambiguous-relation`, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate -- field, type and master -- in declaration order, the tier tested,
and the candidate that wins today.
Only the WINNING tier is judged: the runtime's `??` chain stops at the first
tier that resolves, so a tie in a lower tier is masked and is not a decision
the platform ever makes.
`resolveCbpRelation` here now reads its tiers from one shared table so the two
rules cannot disagree about which tier wins; its answer is unchanged by
construction (`find` over a tier is the first element `filter` keeps). The
mirror's deliberate `reference`-only divergence (#5017) is kept, so a field
carrying the rejected `reference_to` alias is not a candidate and cannot create
a tie.
The `check:doc-security-posture` gate's partial-evaluation suppression is
extended to the new rule for the reason its own docblock already gives: an
opaque `Field.master_detail(...)` factory call is invisible to every tier
predicate, so a masked higher tier would hand the win to a lower one and report
a tie the platform never resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197ddapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 85564eb5d726cfa32b300b1103490765baa4f039 — the merge of head 81a7c46f71cb4e10a556798c872422284342afc5 into base 5a5336b399db2ef18dd4700f97d579a328197dda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85564eb5d726cfa32b300b1103490765baa4f039 && git checkout 85564eb5d726cfa32b300b1103490765baa4f039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda 81a7c46f71cb4e10a556798c872422284342afc5 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff 81a7c46f71cb4e10a556798c872422284342afc5
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a5336b399db2ef18dd4700f97d579a328197dda → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33727184587 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 96 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729181329 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729341726 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master
Sep 3, 2026
Merged

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order#14818
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14747

Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguouscontrolled_by_parent master. Directions (2) (an authorable key naming the master — human floor) and (3) (a runtime docstring — domain:services) are deliberately not touched.

The precedence chain, and why only the winning tier counts

SecurityPlugin.resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts:6057-6061, read-only for this PR) resolves the master a controlled_by_parent object derives record-level access from:

pick(f => f.type === 'master_detail' && f.required) // tier 1
?? pick(f => f.type === 'master_detail') // tier 2
?? pick(f => f.type === 'lookup' && f.required) // tier 3

pick is entries.find(...), so within a tier the winner is whichever candidate the field map lists first — field declaration order, a property that carries no authored meaning. The card measured it on a real kernel: an object with two required lookups resolved to the first-declared one, and swapping the two field declarations (nothing else) repointed every row's record-level access to the other object.

The ?? chain stops at the first tier that resolves, so a tie in a lower tier is masked by a higher tier's single winner and is not a decision the platform ever makes. Reporting a masked tie would send authors to edit fields that change nothing, so the rule judges the winning tier and only the winning tier.

What lands

New error id security-controlled-by-parent-ambiguous-relation, exported beside SECURITY_CBP_NO_RELATION. It is the mirror image of the #7503 rule one line above it: that one reports zero candidates, this one reports two or more. The message names every candidate — field, type and master — in declaration order, says which tier was tested, and says which candidate wins today and therefore which object access derives from right now.

error, and for the inverse of the usual reason. The module header's ADR-0049 paragraph says every error rule mirrors a runtime enforcement point; this one has none to mirror precisely because the runtime does not refuse — it silently picks — so author time is the only place the ambiguity can ever surface. What it does satisfy is the admissibility bar the #7503 push site already states: a self-contained property of the object document, no per-permission-set nuance to adjudicate, and no legitimate reading, since two tied candidates is not an author saying which master they meant. That paragraph is amended in this PR rather than left to read as a rule the file now breaks.

resolveCbpRelation in the mirror now reads its tiers from one shared table, so the two CBP rules cannot disagree about which tier wins. Its answer is unchanged by construction: find over a tier is the first element filter over that tier keeps, and the first tier with a candidate is the tier the ?? chain stops at.

The mirror's one deliberate divergence is kept as the triage asked: refOf accepts only the reference spelling (reference_to / referenceTo are rejected aliases, #5017), so a field carrying an alias is not a candidate and cannot create a tie. A test pins that from this rule's side.

Fixture table

fixturewinning tierverdict
two required lookups3error — both named, account wins today
the same two, declared in the other order3errorcontact named as today's winner
two required master_detail1error
two master_detail, neither required2error
three required lookups3error — "3 of its fields tie"
one required master_detail + two required lookups1silent — the tie is masked
one required master_detail + one bare master_detail1silent
one candidate in any single tier1 / 2 / 3silent
second candidate names no reference target3silent — not a candidate
second lookup optional3silent — not a resolution step
two candidates, sharingModel not controlled_by_parentsilent
reference_to alias on the second candidate3silent — rejected spelling
system object (sys_* / isSystem) with a tie3error — not exempt, as with the no-relation rule

Plus: the two CBP rules are pinned mutually exclusive, and the array field form is covered alongside the name-keyed map.

Measured over the shipped corpus — 0 findings before, 0 after

  • Example apps (4 stacks, 3 controlled_by_parent objects): showcase_invoice_line, showcase_expense_line, crm_opportunity_line_item — each declares exactly one required master_detail, so tier 1 wins with a single candidate. showcase_invoice_line is the interesting one: it also carries a required lookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings are security-private-no-readscope (info) x2 and security-master-detail-ungranted (warning) x3, unchanged.
  • Docs + skills prose corpus: check:doc-security-posture green over 27 ObjectSchema.create sites in 226 marked blocks across 236 files.

One bounded companion fix, named

packages/lint/scripts/check-doc-security-posture.mjs suppresses SECURITY_CBP_NO_RELATION when the fields subtree is not statically evaluable, and its module docblock enumerates the per-rule conservatism. The new rule reads the same subtree, so it is added to the same suppression: an opaque Field.master_detail(...) factory call is invisible to every tier predicate, so a real tier-1 winner masked by a factory call would hand the win to a lower tier and report a tie the platform never resolves. Zero effect on today's corpus — no marked block declares controlled_by_parent, which the gate's own comment already records — so this closes a latent false-red in a security gate rather than fixing an observed one. It is one identifier in a condition plus the docblock line, its correct shape is pinned by the sibling declaration on the adjacent line, and leaving it out would make that docblock's enumeration false. Declared here and in the report as an amendment to the claimed file surface.

Gates

All run in the worktree on the final commit 81a7c46f71 (the merge of origin/main), after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':

  • pnpm --filter @objectstack/lint test93 files / 2865 tests passed
  • pnpm --filter @objectstack/lint typecheck — green, incl. check:test-typecheck (test layer really compiled; the debt ledger held at 2 files / 6 errors)
  • pnpm lint (eslint . --no-inline-config, whole repo) — green
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED: scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET locally because it grades a saved turbo run test log CI tees and this run has none — not a red and nothing to fix.
  • Both build-dependent ratchets pass on the built closure: check:dual-build-cjs-loads (floors held) and check:type-check-debt --re-measure (21 ledger entries, none above its recorded number).

No runtime behaviour changes; packages/plugins/plugin-security/** and packages/spec/** are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

…ed by field declaration order
`SecurityPlugin.resolveCbpRelation` resolves a `controlled_by_parent` object's
master through three tiers -- a required `master_detail`, then any
`master_detail`, then a required `lookup` -- and picks inside a tier with
`Array.prototype.find`. Two or more candidates in the tier that WINS therefore
resolve by field declaration order, and nothing reports it: not `os validate`,
not `os lint`, not a boot warning. Reordering fields is a review-invisible edit
that silently repoints every row's record-level access to another object.
New error id `security-controlled-by-parent-ambiguous-relation`, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate -- field, type and master -- in declaration order, the tier tested,
and the candidate that wins today.
Only the WINNING tier is judged: the runtime's `??` chain stops at the first
tier that resolves, so a tie in a lower tier is masked and is not a decision
the platform ever makes.
`resolveCbpRelation` here now reads its tiers from one shared table so the two
rules cannot disagree about which tier wins; its answer is unchanged by
construction (`find` over a tier is the first element `filter` keeps). The
mirror's deliberate `reference`-only divergence (#5017) is kept, so a field
carrying the rejected `reference_to` alias is not a candidate and cannot create
a tie.
The `check:doc-security-posture` gate's partial-evaluation suppression is
extended to the new rule for the reason its own docblock already gives: an
opaque `Field.master_detail(...)` factory call is invisible to every tier
predicate, so a masked higher tier would hand the win to a lower one and report
a tie the platform never resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197ddapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 85564eb5d726cfa32b300b1103490765baa4f039 — the merge of head 81a7c46f71cb4e10a556798c872422284342afc5 into base 5a5336b399db2ef18dd4700f97d579a328197dda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85564eb5d726cfa32b300b1103490765baa4f039 && git checkout 85564eb5d726cfa32b300b1103490765baa4f039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda 81a7c46f71cb4e10a556798c872422284342afc5 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff 81a7c46f71cb4e10a556798c872422284342afc5
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a5336b399db2ef18dd4700f97d579a328197dda → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33727184587 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 96 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729181329 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729341726 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master
Sep 3, 2026
Merged

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order#14818
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14747

Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguouscontrolled_by_parent master. Directions (2) (an authorable key naming the master — human floor) and (3) (a runtime docstring — domain:services) are deliberately not touched.

The precedence chain, and why only the winning tier counts

SecurityPlugin.resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts:6057-6061, read-only for this PR) resolves the master a controlled_by_parent object derives record-level access from:

pick(f => f.type === 'master_detail' && f.required) // tier 1
?? pick(f => f.type === 'master_detail') // tier 2
?? pick(f => f.type === 'lookup' && f.required) // tier 3

pick is entries.find(...), so within a tier the winner is whichever candidate the field map lists first — field declaration order, a property that carries no authored meaning. The card measured it on a real kernel: an object with two required lookups resolved to the first-declared one, and swapping the two field declarations (nothing else) repointed every row's record-level access to the other object.

The ?? chain stops at the first tier that resolves, so a tie in a lower tier is masked by a higher tier's single winner and is not a decision the platform ever makes. Reporting a masked tie would send authors to edit fields that change nothing, so the rule judges the winning tier and only the winning tier.

What lands

New error id security-controlled-by-parent-ambiguous-relation, exported beside SECURITY_CBP_NO_RELATION. It is the mirror image of the #7503 rule one line above it: that one reports zero candidates, this one reports two or more. The message names every candidate — field, type and master — in declaration order, says which tier was tested, and says which candidate wins today and therefore which object access derives from right now.

error, and for the inverse of the usual reason. The module header's ADR-0049 paragraph says every error rule mirrors a runtime enforcement point; this one has none to mirror precisely because the runtime does not refuse — it silently picks — so author time is the only place the ambiguity can ever surface. What it does satisfy is the admissibility bar the #7503 push site already states: a self-contained property of the object document, no per-permission-set nuance to adjudicate, and no legitimate reading, since two tied candidates is not an author saying which master they meant. That paragraph is amended in this PR rather than left to read as a rule the file now breaks.

resolveCbpRelation in the mirror now reads its tiers from one shared table, so the two CBP rules cannot disagree about which tier wins. Its answer is unchanged by construction: find over a tier is the first element filter over that tier keeps, and the first tier with a candidate is the tier the ?? chain stops at.

The mirror's one deliberate divergence is kept as the triage asked: refOf accepts only the reference spelling (reference_to / referenceTo are rejected aliases, #5017), so a field carrying an alias is not a candidate and cannot create a tie. A test pins that from this rule's side.

Fixture table

fixturewinning tierverdict
two required lookups3error — both named, account wins today
the same two, declared in the other order3errorcontact named as today's winner
two required master_detail1error
two master_detail, neither required2error
three required lookups3error — "3 of its fields tie"
one required master_detail + two required lookups1silent — the tie is masked
one required master_detail + one bare master_detail1silent
one candidate in any single tier1 / 2 / 3silent
second candidate names no reference target3silent — not a candidate
second lookup optional3silent — not a resolution step
two candidates, sharingModel not controlled_by_parentsilent
reference_to alias on the second candidate3silent — rejected spelling
system object (sys_* / isSystem) with a tie3error — not exempt, as with the no-relation rule

Plus: the two CBP rules are pinned mutually exclusive, and the array field form is covered alongside the name-keyed map.

Measured over the shipped corpus — 0 findings before, 0 after

  • Example apps (4 stacks, 3 controlled_by_parent objects): showcase_invoice_line, showcase_expense_line, crm_opportunity_line_item — each declares exactly one required master_detail, so tier 1 wins with a single candidate. showcase_invoice_line is the interesting one: it also carries a required lookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings are security-private-no-readscope (info) x2 and security-master-detail-ungranted (warning) x3, unchanged.
  • Docs + skills prose corpus: check:doc-security-posture green over 27 ObjectSchema.create sites in 226 marked blocks across 236 files.

One bounded companion fix, named

packages/lint/scripts/check-doc-security-posture.mjs suppresses SECURITY_CBP_NO_RELATION when the fields subtree is not statically evaluable, and its module docblock enumerates the per-rule conservatism. The new rule reads the same subtree, so it is added to the same suppression: an opaque Field.master_detail(...) factory call is invisible to every tier predicate, so a real tier-1 winner masked by a factory call would hand the win to a lower tier and report a tie the platform never resolves. Zero effect on today's corpus — no marked block declares controlled_by_parent, which the gate's own comment already records — so this closes a latent false-red in a security gate rather than fixing an observed one. It is one identifier in a condition plus the docblock line, its correct shape is pinned by the sibling declaration on the adjacent line, and leaving it out would make that docblock's enumeration false. Declared here and in the report as an amendment to the claimed file surface.

Gates

All run in the worktree on the final commit 81a7c46f71 (the merge of origin/main), after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':

  • pnpm --filter @objectstack/lint test93 files / 2865 tests passed
  • pnpm --filter @objectstack/lint typecheck — green, incl. check:test-typecheck (test layer really compiled; the debt ledger held at 2 files / 6 errors)
  • pnpm lint (eslint . --no-inline-config, whole repo) — green
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED: scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET locally because it grades a saved turbo run test log CI tees and this run has none — not a red and nothing to fix.
  • Both build-dependent ratchets pass on the built closure: check:dual-build-cjs-loads (floors held) and check:type-check-debt --re-measure (21 ledger entries, none above its recorded number).

No runtime behaviour changes; packages/plugins/plugin-security/** and packages/spec/** are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

…ed by field declaration order
`SecurityPlugin.resolveCbpRelation` resolves a `controlled_by_parent` object's
master through three tiers -- a required `master_detail`, then any
`master_detail`, then a required `lookup` -- and picks inside a tier with
`Array.prototype.find`. Two or more candidates in the tier that WINS therefore
resolve by field declaration order, and nothing reports it: not `os validate`,
not `os lint`, not a boot warning. Reordering fields is a review-invisible edit
that silently repoints every row's record-level access to another object.
New error id `security-controlled-by-parent-ambiguous-relation`, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate -- field, type and master -- in declaration order, the tier tested,
and the candidate that wins today.
Only the WINNING tier is judged: the runtime's `??` chain stops at the first
tier that resolves, so a tie in a lower tier is masked and is not a decision
the platform ever makes.
`resolveCbpRelation` here now reads its tiers from one shared table so the two
rules cannot disagree about which tier wins; its answer is unchanged by
construction (`find` over a tier is the first element `filter` keeps). The
mirror's deliberate `reference`-only divergence (#5017) is kept, so a field
carrying the rejected `reference_to` alias is not a candidate and cannot create
a tie.
The `check:doc-security-posture` gate's partial-evaluation suppression is
extended to the new rule for the reason its own docblock already gives: an
opaque `Field.master_detail(...)` factory call is invisible to every tier
predicate, so a masked higher tier would hand the win to a lower one and report
a tie the platform never resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197ddapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 85564eb5d726cfa32b300b1103490765baa4f039 — the merge of head 81a7c46f71cb4e10a556798c872422284342afc5 into base 5a5336b399db2ef18dd4700f97d579a328197dda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85564eb5d726cfa32b300b1103490765baa4f039 && git checkout 85564eb5d726cfa32b300b1103490765baa4f039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda 81a7c46f71cb4e10a556798c872422284342afc5 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff 81a7c46f71cb4e10a556798c872422284342afc5
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a5336b399db2ef18dd4700f97d579a328197dda → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33727184587 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 96 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729181329 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729341726 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master
Sep 3, 2026
Merged

fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order#14818
baozhoutao merged 2 commits into
mainfrom
claude/issue-14747-lint-cbp-ambiguous-master

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#14747

Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguouscontrolled_by_parent master. Directions (2) (an authorable key naming the master — human floor) and (3) (a runtime docstring — domain:services) are deliberately not touched.

The precedence chain, and why only the winning tier counts

SecurityPlugin.resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts:6057-6061, read-only for this PR) resolves the master a controlled_by_parent object derives record-level access from:

pick(f => f.type === 'master_detail' && f.required) // tier 1
?? pick(f => f.type === 'master_detail') // tier 2
?? pick(f => f.type === 'lookup' && f.required) // tier 3

pick is entries.find(...), so within a tier the winner is whichever candidate the field map lists first — field declaration order, a property that carries no authored meaning. The card measured it on a real kernel: an object with two required lookups resolved to the first-declared one, and swapping the two field declarations (nothing else) repointed every row's record-level access to the other object.

The ?? chain stops at the first tier that resolves, so a tie in a lower tier is masked by a higher tier's single winner and is not a decision the platform ever makes. Reporting a masked tie would send authors to edit fields that change nothing, so the rule judges the winning tier and only the winning tier.

What lands

New error id security-controlled-by-parent-ambiguous-relation, exported beside SECURITY_CBP_NO_RELATION. It is the mirror image of the #7503 rule one line above it: that one reports zero candidates, this one reports two or more. The message names every candidate — field, type and master — in declaration order, says which tier was tested, and says which candidate wins today and therefore which object access derives from right now.

error, and for the inverse of the usual reason. The module header's ADR-0049 paragraph says every error rule mirrors a runtime enforcement point; this one has none to mirror precisely because the runtime does not refuse — it silently picks — so author time is the only place the ambiguity can ever surface. What it does satisfy is the admissibility bar the #7503 push site already states: a self-contained property of the object document, no per-permission-set nuance to adjudicate, and no legitimate reading, since two tied candidates is not an author saying which master they meant. That paragraph is amended in this PR rather than left to read as a rule the file now breaks.

resolveCbpRelation in the mirror now reads its tiers from one shared table, so the two CBP rules cannot disagree about which tier wins. Its answer is unchanged by construction: find over a tier is the first element filter over that tier keeps, and the first tier with a candidate is the tier the ?? chain stops at.

The mirror's one deliberate divergence is kept as the triage asked: refOf accepts only the reference spelling (reference_to / referenceTo are rejected aliases, #5017), so a field carrying an alias is not a candidate and cannot create a tie. A test pins that from this rule's side.

Fixture table

fixturewinning tierverdict
two required lookups3error — both named, account wins today
the same two, declared in the other order3errorcontact named as today's winner
two required master_detail1error
two master_detail, neither required2error
three required lookups3error — "3 of its fields tie"
one required master_detail + two required lookups1silent — the tie is masked
one required master_detail + one bare master_detail1silent
one candidate in any single tier1 / 2 / 3silent
second candidate names no reference target3silent — not a candidate
second lookup optional3silent — not a resolution step
two candidates, sharingModel not controlled_by_parentsilent
reference_to alias on the second candidate3silent — rejected spelling
system object (sys_* / isSystem) with a tie3error — not exempt, as with the no-relation rule

Plus: the two CBP rules are pinned mutually exclusive, and the array field form is covered alongside the name-keyed map.

Measured over the shipped corpus — 0 findings before, 0 after

  • Example apps (4 stacks, 3 controlled_by_parent objects): showcase_invoice_line, showcase_expense_line, crm_opportunity_line_item — each declares exactly one required master_detail, so tier 1 wins with a single candidate. showcase_invoice_line is the interesting one: it also carries a required lookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings are security-private-no-readscope (info) x2 and security-master-detail-ungranted (warning) x3, unchanged.
  • Docs + skills prose corpus: check:doc-security-posture green over 27 ObjectSchema.create sites in 226 marked blocks across 236 files.

One bounded companion fix, named

packages/lint/scripts/check-doc-security-posture.mjs suppresses SECURITY_CBP_NO_RELATION when the fields subtree is not statically evaluable, and its module docblock enumerates the per-rule conservatism. The new rule reads the same subtree, so it is added to the same suppression: an opaque Field.master_detail(...) factory call is invisible to every tier predicate, so a real tier-1 winner masked by a factory call would hand the win to a lower tier and report a tie the platform never resolves. Zero effect on today's corpus — no marked block declares controlled_by_parent, which the gate's own comment already records — so this closes a latent false-red in a security gate rather than fixing an observed one. It is one identifier in a condition plus the docblock line, its correct shape is pinned by the sibling declaration on the adjacent line, and leaving it out would make that docblock's enumeration false. Declared here and in the report as an amendment to the claimed file surface.

Gates

All run in the worktree on the final commit 81a7c46f71 (the merge of origin/main), after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':

  • pnpm --filter @objectstack/lint test93 files / 2865 tests passed
  • pnpm --filter @objectstack/lint typecheck — green, incl. check:test-typecheck (test layer really compiled; the debt ledger held at 2 files / 6 errors)
  • pnpm lint (eslint . --no-inline-config, whole repo) — green
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED: scripts/check-test-completeness.mjs exits 3 PREREQUISITE NOT MET locally because it grades a saved turbo run test log CI tees and this run has none — not a red and nothing to fix.
  • Both build-dependent ratchets pass on the built closure: check:dual-build-cjs-loads (floors held) and check:type-check-debt --re-measure (21 ledger entries, none above its recorded number).

No runtime behaviour changes; packages/plugins/plugin-security/** and packages/spec/** are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code

…ed by field declaration order
`SecurityPlugin.resolveCbpRelation` resolves a `controlled_by_parent` object's
master through three tiers -- a required `master_detail`, then any
`master_detail`, then a required `lookup` -- and picks inside a tier with
`Array.prototype.find`. Two or more candidates in the tier that WINS therefore
resolve by field declaration order, and nothing reports it: not `os validate`,
not `os lint`, not a boot warning. Reordering fields is a review-invisible edit
that silently repoints every row's record-level access to another object.
New error id `security-controlled-by-parent-ambiguous-relation`, the mirror
image of `security-controlled-by-parent-no-relation` (#7503): that one reports
ZERO candidates, this one reports two or more. The message names every
candidate -- field, type and master -- in declaration order, the tier tested,
and the candidate that wins today.
Only the WINNING tier is judged: the runtime's `??` chain stops at the first
tier that resolves, so a tie in a lower tier is masked and is not a decision
the platform ever makes.
`resolveCbpRelation` here now reads its tiers from one shared table so the two
rules cannot disagree about which tier wins; its answer is unchanged by
construction (`find` over a tier is the first element `filter` keeps). The
mirror's deliberate `reference`-only divergence (#5017) is kept, so a field
carrying the rejected `reference_to` alias is not a candidate and cannot create
a tie.
The `check:doc-security-posture` gate's partial-evaluation suppression is
extended to the new rule for the reason its own docblock already gives: an
opaque `Field.master_detail(...)` factory call is invisible to every tier
predicate, so a masked higher tier would hand the win to a lower one and report
a tie the platform never resolves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197ddapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 85564eb5d726cfa32b300b1103490765baa4f039 — the merge of head 81a7c46f71cb4e10a556798c872422284342afc5 into base 5a5336b399db2ef18dd4700f97d579a328197dda, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 85564eb5d726cfa32b300b1103490765baa4f039 && git checkout 85564eb5d726cfa32b300b1103490765baa4f039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a5336b399db2ef18dd4700f97d579a328197dda 81a7c46f71cb4e10a556798c872422284342afc5 && git checkout -B drift-repro 5a5336b399db2ef18dd4700f97d579a328197dda && git merge --no-ff 81a7c46f71cb4e10a556798c872422284342afc5
node scripts/docs-audit/affected-docs.mjs --json 5a5336b399db2ef18dd4700f97d579a328197dda

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a5336b399db2ef18dd4700f97d579a328197dda → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33727184587 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1801
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 96 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729181329 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33729341726 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
    ↳ 失败原因: @objectstack/cli:test: AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 1800
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 103 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude