Uh oh!
There was an error while loading. Please reload this page.
fix(lint): report a controlled_by_parent object whose security master is decided by field declaration order - #14818
Conversation
…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
…nt-cbp-ambiguous-master
📓 Docs Drift CheckThis PR changes 1 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Fixes#14747
Direction (1) of the card, per triage ruling 5518587775: the security-posture linter now reports an ambiguous
controlled_by_parentmaster. 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 acontrolled_by_parentobject derives record-level access from:pickisentries.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 besideSECURITY_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 everyerrorrule 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.resolveCbpRelationin 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:findover a tier is the first elementfilterover 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:
refOfaccepts only thereferencespelling (reference_to/referenceToare 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
accountwins todaycontactnamed as today's winnermaster_detailmaster_detail, neither requiredmaster_detail+ two required lookupsmaster_detail+ one baremaster_detailreferencetargetsharingModelnotcontrolled_by_parentreference_toalias on the second candidatesys_*/isSystem) with a tiePlus: 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
controlled_by_parentobjects):showcase_invoice_line,showcase_expense_line,crm_opportunity_line_item— each declares exactly one requiredmaster_detail, so tier 1 wins with a single candidate.showcase_invoice_lineis the interesting one: it also carries a requiredlookup, and the rule is silent because that tier is never reached. The 5 pre-existing showcase findings aresecurity-private-no-readscope(info) x2 andsecurity-master-detail-ungranted(warning) x3, unchanged.check:doc-security-posturegreen over 27ObjectSchema.createsites in 226 marked blocks across 236 files.One bounded companion fix, named
packages/lint/scripts/check-doc-security-posture.mjssuppressesSECURITY_CBP_NO_RELATIONwhen thefieldssubtree 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 opaqueField.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 declarescontrolled_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 oforigin/main), afterpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*':pnpm --filter @objectstack/lint test— 93 files / 2865 tests passedpnpm --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) — greennode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 37 families (31 by path + 8 by kind, 2 shared); 36 green, 1 NOT MEASURED:scripts/check-test-completeness.mjsexits 3PREREQUISITE NOT METlocally because it grades a savedturbo run testlog CI tees and this run has none — not a red and nothing to fix.check:dual-build-cjs-loads(floors held) andcheck:type-check-debt --re-measure(21 ledger entries, none above its recorded number).No runtime behaviour changes;
packages/plugins/plugin-security/**andpackages/spec/**are untouched.🤖 Generated with Claude Code
https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
Generated by Claude Code