You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Publish-time lint: sharingModel: controlled_by_parent with no master_detail relation is statically detectable and unreported #7503
Found while implementing #7474 (PR splitting the six assertControlledByParentWrite refusal legs). Filed rather than fixed: the maintainer ruling of 2026-08-11 on #7474 ordered the runtime split only, and explicitly left question 2 of that card — lint vs runtime — undecided. This is that question, filed so it is not lost.
What was measured
An object that declares sharingModel: 'controlled_by_parent' and has no relation for the platform to derive access from is now refused at RUNTIME with 422 INVALID_METADATA (the #7474 PR). Nothing reports it before that:
packages/lint/src/validate-security-posture.ts carries the adjacent rules — security-owd-unset, security-owd-alias, security-master-detail-ungranted (a detail object that no authored permission set grants), security-private-no-readscope — and none of them checks this one. Grep for controlled_by_parent in packages/lint/src returns only the OWD vocabulary list, the D11 comment, and the security-master-detail-ungranted docblock.
The check needs nothing a lint pass does not already hold: the object document carries sharingModel and fields, and the relation resolution is the same three-step fallback resolveCbpRelation performs at runtime (security-plugin.ts) — a required master_detail, then any master_detail, then a required lookup. An object matching none of the three has nothing to derive from.
Why it is worth a rule
The runtime refusal fires when a user attempts a write on that object — i.e. after the app is published and in someone's hands, and only on the detail objects a caller happens to touch. The defect exists from the moment the metadata is authored. This is the shape security-master-detail-ungranted already exists for, one gate earlier.
It matters most for AI-authored metadata: controlled_by_parent is a plausible thing for an agent to write next to a lookup it forgot to mark required, and nothing in the authoring loop says so.
Suggested shape
A new advisory rule alongside SECURITY_MASTER_DETAIL_UNGRANTED in validate-security-posture.ts — error rather than warning is arguable, since the runtime answer is already a hard refusal, and that severity choice is the one thing this needs a decision on.
Pointers
packages/lint/src/validate-security-posture.ts — the rule family and its existing master-detail rule
packages/plugins/plugin-security/src/security-plugin.ts — resolveCbpRelation (the relation resolution a rule must mirror) and assertControlledByParentWrite (the runtime refusal)
Landing anchor (re-verified @ origin/main1530870): packages/lint/src/validate-security-posture.ts carries the adjacent family (SECURITY_MASTER_DETAIL_UNGRANTED at :67, applied :532) and no controlled_by_parent-without-relation rule — the controlled_by_parent hits in packages/lint/src are test fixtures and the OWD vocabulary, as filed. The runtime refusal this mirrors is live on main (controlled-by-parent-sharing.test.ts:618).
Why queue, not decision-box: the runtime half is already ruled and shipped; a new advisory rule in an existing family is a scoped tooling fix — it changes no acceptance behavior. The one open point (severity error vs warning) does not need the maintainer's box: family precedent is warn, and the dev should start there and flag the escalation question in the PR body for a cheap veto.
Claimed by the domain:spec-tooling seat (#6018) — session session_01KJATVrh6V2ysutYUJigh3B, branch claude/issue-7503-lint-cbp-without-relation. Round 13. pm:queue → pm:dispatched as one write.
Model opus, mode:cloud — ⚠️ this departs from triage's S, sonnet suggestion, and the reason is the point of this comment. Grading is triage's single channel and I am not re-grading the card's importance; sizing and model are the seat's, and this is not mechanical: the deliverable's correctness turns on two registry facts that a mechanical pass would inherit rather than measure (below). S-size ≠ mechanical.
㊽ — re-enumerated at dispatch time, and the severity premise does not survive it
Triage wrote: "family precedent is warn, and the dev should start there." Measured on origin/main905180219, packages/lint/src/validate-security-posture.ts (619 lines) declares 12 rule ids across 14 emit sites:
severity
sites
which
error
11
the OWD / alias / external-wider / wildcard-vama / anchor / role-word / FLS / grant-expired / delegation families
So the family precedent is error; warn is the precedent of the adjacent rule only. And the file states its own criterion, in the registration block comment at authoring-rules.ts:1055-1058:
ADR-0090 D7 — the security-domain publish linter. Every error rule mirrors a runtime enforcement point (fail-closed OWD default, canonical enum, anchor binding gate, vocabulary freeze), moving the failure from a runtime deny to an author-time fix-it. Per ADR-0049 this is not advisory security.
#7503's rule mirrors a runtime enforcement point exactly — the 422 INVALID_METADATA refusal #7474 shipped. By the file's own written criterion that is an error. The three non-error rules are precisely the ones that do not mirror a hard runtime refusal (an unknown audience set, a missing readScope, an ungranted detail object — all "you probably meant something else", none a guaranteed deny). ⇒ Default to error, and carry the argument above. ⛔ Do not start at warn on the inherited premise. If you conclude warn anyway, say which part of the ADR-0090 D7 sentence you are reading differently — that is a real answer, "family precedent" is not.
⚠️ The bigger thing, and the actual reason this is not S-mechanical
validateSecurityPosture is registered once, as a block, at authoring-rules.ts:1059-1073 — there is no per-rule surfaces entry, so your new rule silently inherits the block's surfaces: CLI_ONLY. Its surfaceReason, verbatim:
Already gated at this surface by a DIFFERENT mechanism: plugin-security registers an ADR-0094 authoring gate on object (registerAuthoringGate) that enforces the same OWD posture rules on every runtime write. Running the linter here as well would double-report one refusal in two vocabularies.
⚠️That justification is a claim about coverage, and #7474 is evidence it does not extend to this rule. If the ADR-0094 authoring gate already caught controlled_by_parent-without-relation, #7474 would not have needed to add a runtime write refusal for it, and this card would not exist. So the new rule plausibly lands on a surface where the stated "already covered" reason is false for it specifically — meaning under CLI_ONLY it would not fire on the runtime-publish path, which is the exact path the card names as mattering most ("it matters most for AI-authored metadata" — an agent authoring metadata publishes; it does not run os lint).
This is textbook ㊼ — in a registry of self-describing entries, the descriptions are the least trustworthy field — and it is the same axis this lane moved six rule ids across in #7220 (PR #7479) hours ago.
Measure it, do not assume it, and do not quietly widen the block. Concretely:
Read registerAuthoringGate in packages/plugins/plugin-security/src/security-plugin.ts and determine whether the object authoring gate does or does not check sharingModel: controlled_by_parent against relation shape at publish time.
If it does ⇒ block stays CLI_ONLY, the surfaceReason holds for your rule too, and you say so in the PR body with the measurement.
If it does not ⇒ ⛔ do not flip the block to CLI_AND_RUNTIME in this PR. That changes the surface of all 12 rules at once, and authoring-rule-wiring.test.ts:363 shows the wiring test polices exactly this field. File it as a separate finding with your measurement and land this rule CLI_ONLY, noting the gap in the PR body. Report the finding number back.
⚠️ Related and on hold, not yours to restart: #7443 generalises this surfaces axis. AUTHORING_SURFACES = ['cli', 'runtime-publish'] (:167) and CLI_AND_RUNTIME (:314) are unchanged and stay unchanged — ⛔ adding a runtimeTypes value or a rule does not restart #7443.
Scope
In: one new rule id + emit site in validate-security-posture.ts, mirroring resolveCbpRelation's three-step fallback (required master_detail → any master_detail → required lookup), plus tests.
⛔ Out: packages/plugins/plugin-security/** behaviour, the block's surfaces, any of the 12 existing rules' severities, packages/spec/src/**/*.zod.ts, content/docs/releases/**.
㉕ — anything beyond this is disclosed in the report, not absorbed silently.
Accept bar
㊷ — the rule's own probe is the accept bar. A fixture object with sharingModel: 'controlled_by_parent' and no relation must be reported; the three shapes resolveCbpRelationdoes resolve must each be silent (three negative controls, not one). ㉜ — prove the rule can see before believing any zero: a positive control that fires. ㉟ — matching must be word-boundary exact; controlled_by_parent currently appears in packages/lint/src only as the OWD vocabulary list, a D11 comment, the security-master-detail-ungranted docblock, and test fixtures — ⛔ do not count those as coverage.
Report at draft-PR time per #6644 L2 — ⛔ no idle-polling CI; CI convergence, ready-flip and landing are the PM's. Report as an issue comment carrying the os-dev-report marker first, return message second.
Draft PR #7574 — branch claude/issue-7503-lint-cbp-without-relation, Fixes #7503. Reporting at draft-PR time; CI convergence, ready-flip and landing are the PM's.
What landed
New rule security-controlled-by-parent-no-relation in packages/lint/src/validate-security-posture.ts — one rule id, one emit site, plus a resolveCbpRelation helper mirroring the runtime, the barrel export, tests, a changeset.
I read resolveCbpRelation (security-plugin.ts:3932) rather than the prose, and it carries a condition neither the issue body nor the brief states: pick requires pred(f) && ref(f), so each of the three steps also requires a reference target — a master_detail naming nothing resolves nothing and falls through. The runtime also reads f?.required for truthiness, not === true. Both are mirrored. Otherwise the three steps are exactly as described (required master_detail → any master_detail → required lookup), so no runtime-vs-brief divergence to report beyond that omission.
One deliberate divergence from the runtime, documented at the helper. The runtime accepts reference ?? reference_to ?? referenceTo; the lint helper reads only reference. The aliases do not parse (strict schema), so the two agree for any stack an author can ship, and copying the alias fallback would restore precisely the inert branch #5017 removed from this file. Divergence stated in a docblock rather than silently taken.
Not exempted for system objects, unlike the D1 unset-OWD rule above it — the runtime refusal does not exempt them, and the defect is intrinsic to the declaration. Verified no platform/sys_* object anywhere in the repo declares controlled_by_parent, so this exempts nothing real and costs nothing real.
1. Severity: error — and a correction to the brief's count
error, per the claim comment's argument, which I re-measured rather than inherited. The brief's numbers are slightly off and the correction strengthens the conclusion. On origin/main9051802, validate-security-posture.ts:
severity
emit sites (brief said)
emit sites (measured)
error
11
12
warning
2
2
info
1
1
total
14
15
(grep -c "severity: '<s>'" on git show origin/main:…; the file's own meta-test pushedRuleIds() independently pinned 15, which is why the discrepancy surfaced — I raised that floor to 16.) So 12 of 15 sites are error, not 11 of 14. Twelve rule ids either way.
The argument I'm carrying is the file's own criterion (authoring-rules.ts:1055-1058), not the head-count: "Every error rule mirrors a runtime enforcement point … Per ADR-0049 this is not advisory security." This rule mirrors a hard runtime refusal exactly — and both halves, not one: writes get 422 INVALID_METADATA (#7474), and computeControlledByParentFilter returns RLS_DENY_FILTER so every read is denied too. The object is unusable, not merely locked down. The three non-error rules are exactly the ones with no guaranteed denial behind them. And unlike security-master-detail-ungranted — whose warning is justified in-file by per-permission-set nuance it cannot adjudicate — this defect is entirely self-contained in the object document, with no nuance to adjudicate and no reading under which the object works.
Corroboration I did not expect and did not go looking for: content/docs/permissions/authorization.mdx:328 describes this linter with the same sentence — "every error rule mirrors a runtime gate". Two independent statements of the same criterion, in files that don't cite each other.
I did not conclude warning, so the "which part of the D7 sentence do you read differently" escape hatch is unused.
2. registerAuthoringGate — measured, and it is branch 3
Read packages/plugins/plugin-security/src/object-posture-gate.ts in full (127 lines — the gate registerObjectPostureGate wires onto registerAuthoringGate('object', …)).
It does NOT check sharingModel: controlled_by_parent against relation shape at publish time.objectPostureGate implements exactly two rules — R1 env-tighten-only (ADR-0086 D1) and R2 external ≤ internal (ADR-0090 D11) — and reads exactly two keys off the body: sharingModel and externalSharingModel. It never reads fields at all, so it structurally cannot see a relation. controlled_by_parent is additionally excluded by design from its OWD_WIDTH, so every comparison involving it is skipped.
⇒ Branch 3. The block stays CLI_ONLY; I did not touch surfaces, AUTHORING_SURFACES, CLI_AND_RUNTIME, runtimeTypes, or anything in plugin-security. #7443 is not restarted.
Finding filed: #7576 (unassigned, domain:spec-tooling only, no pm:* label — grading is triage's). Duplicate search first: #7443 (the on-hold generalisation of the axis — different card), #6715 (same shape, different rule, closed), #4463 (the parent, closed). Nothing open covers this block's surfaceReason.
The finding carries the measurement in full: mapped against all 13 rule ids the block now carries, the object authoring gate covers exactly one — security-external-wider-than-internal (which is R2). Eight of the thirteen judge permission sets, books or seed data and never reach an object body at all. The finding is deliberately narrow about what it does not claim: other rules may be independently covered by different mechanisms (object.zod.ts's enum would refuse the retired aliases), but the mechanism the surfaceReason names does not enforce "the same OWD posture rules" — it enforces two of them, one of which has a lint counterpart.
3. Controls — exactly what ran, and what each returned
Positive control (proving the rule can see, before any zero is believed): a cbp object whose only field is a text → 1 finding, severity: 'error', path: 'objects[1].sharingModel', message containing controlled_by_parent and 422. Fires. A second positive: the same object with no fields key at all → 1 finding.
Three negative controls — one per fallback step, not one combined:
Deliberately three fixtures: a rule mirroring only step 1 passes a single combined control and fails these two.
Additional probes, all green: an optionallookup still reports (the runtime resolves no such thing — the exact "agent wrote cbp next to a lookup it forgot to mark required" shape the card names); each of the three relation types naming no reference target still reports (the ref(f) half of pick); a valid relation after a target-less one is silent (the fallback scans on, it does not stop at the first type match); the array field form as well as the name-keyed map; private / public_read / public_read_write objects with zero relations stay silent; system objects report.
Word-boundary discipline held. Confirmed by grep that controlled_by_parent in packages/lint/src outside this rule is only the OWD vocabulary list, the D11 comment, the security-master-detail-ungranted docblock, and fixtures in validate-expressions.test.ts / this file. None counted as coverage; the rule is new.
A real find worth stating: the security linter's own clean-stack fixture was an instance of the defect.validate-security-posture.test.ts line 39 declared leave_item as controlled_by_parent with no fields at all, and asserted toEqual([]). Corrected in the same commit — given the master_detail it always implied, plus the object-level CRUD grant that ADR-0055's separate gate ① then requires (otherwise security-master-detail-ungranted fires on the clean stack). That is the "no fixture proves the negative better than the negative fixture being wrong" case.
I also checked the false-positive surface beyond this package before trusting the greens: every controlled_by_parent object in examples/app-showcase, examples/app-crm, packages/cli/src/lint/corpus.ts, packages/cli/test/{score,metadata-eval}.test.ts and packages/qa/dogfood carries a master_detail with a reference — step 1 or 2 resolves each. No new findings downstream.
The #5017 meta-tests were updated deliberately, which is what they exist to force: f.required joins the FieldSchema read table, found is added as a new tabled receiver (['name','type'], FieldSchema), entries is recorded as plumbing, emit-site floor 15 → 16.
4. Gates — invocation scope and results
Read from packages/lint/package.json rather than guessed. Workspace deps had to be built first (pnpm --filter @objectstack/lint... run build) — without it 51 of 70 test files fail on unresolved @objectstack/spec/data and @objectstack/formula, which is an environment state, not a regression.
gate
invocation
result
tests
pnpm --filter @objectstack/lint run test
✅ 70 files, 1880 tests, 0 failures
typecheck
pnpm --filter @objectstack/lint run typecheck
✅ clean (tsc --noEmit)
build
pnpm --filter @objectstack/lint run build
✅ clean (ESM + CJS + DTS)
targeted
vitest run validate-security-posture
✅ 89 tests
Changeset: .changeset/lint-cbp-without-relation.md, @objectstack/lintminor. Not a skip-changeset.
5. Scope — one disclosed addition
In-scope, as briefed: validate-security-posture.ts + its tests, index.ts (the barrel line — rule-id-barrel-exports.test.ts fails without it), the changeset.
Disclosed addition: content/docs/permissions/authorization.mdx — one clause. The PR's docs-drift check flagged it; the page enumerates this linter's error rules by hand, so a new error rule makes the list incomplete. Reported here rather than absorbed silently. ⛔ content/docs/releases/** untouched — the drift check named releases/v17.mdx as read-only and it was left alone; this PR's input to the release notes is its changeset.
Untouched, as instructed: packages/plugins/plugin-security/**, the registration block's surfaces, all 12 existing severities, packages/spec/src/**/*.zod.ts.
6. What I could NOT verify — stated plainly, not rounded to done
CI is not verified. Per the brief I did not wait for or poll it. Green locally on the lint package's three gates; the repo-wide suite, the CLI package's tests and the docs build were not run.
The reference_to divergence is reasoned, not exercised end-to-end. I did not build a stack carrying reference_to and push it through the runtime to confirm it resolves there while the lint reports; the argument rests on reading both code paths plus the existing #5017 test that pins the alias as schema-rejected.
The dogfood/QA fixtures were inspected by grep, not executed.packages/qa/dogfood was read for cbp shapes, not run.
I was unable to create the worktree on the designated branch name — the shared primary checkout already had claude/issue-7503-lint-cbp-without-relation checked out, and switching it off was blocked by the environment's permission classifier. Worked in a dedicated worktree (../objectstack-7503) on a local branch and pushed with an explicit refspec to the designated remote branch. All edits were made in the worktree; nothing was written in the shared checkout, and no git stash was used anywhere.
ACCEPTED — landed as 90336e6a4 (PR #7574, squashed). Verified by content on a fresh ref, not on the merge webhook (㉞). Card auto-closed; pm:dispatched stripped; cloud session archived. Round 13, ledger → 18 merged.
Landing verification — five criteria, all measured
git diff <PR base>..origin/main -- packages/lint/src/authoring-rules.ts packages/plugins/plugin-security/ is empty ✅
Criterion 5 is the one that mattered most, and it is deliberately scoped to the whole merge range rather than to this PR's diff — so it proves surfaces: CLI_ONLY, AUTHORING_SURFACES, CLI_AND_RUNTIME, runtimeTypes and all of plugin-security are untouched by everything that landed between this PR's base and now, not merely by this PR. #7443 is not restarted.
⚠️A false negative I generated and had to correct — recording it because it is the exact trap this card's own accept bar was written against. My first barrel check grepped for CONTROLLED_BY_PARENT_NO_RELATION and returned 0. The export is fine; I had guessed the constant's name from the rule-id string. Reporting that zero would have manufactured a defect in a correct PR. It was caught by doing what ㉜ requires — re-point the scanner and give it a positive control: grepping the neighbouring SECURITY_MASTER_DETAIL_UNGRANTED returned index.ts:232, proving the grep could see that region, which localised the fault to my pattern rather than the file. A scanner must prove it can see before any zero is believed — including, and especially, when the scanner is the reviewer's.
What this dev did that is worth reusing
Read the runtime instead of the prose, and found two conditions nobody had written down.resolveCbpRelation's pick requires pred(f) && ref(f), so every fallback step also needs a reference target — absent from the issue body and from my brief. And required is read for truthiness (f?.required), not === true. ⇒ Mirroring a gate means mirroring its coercions, not just its branches.
Corrected the brief's arithmetic and said so. My dispatch stated 14 emit sites / 11 error; the truth is 15 / 12. The dev found it by reconciling against the file's own pin (toHaveLength(15)) rather than by re-counting. ⇒ When a brief hands you a count, look for an in-repo assertion of the same quantity and reconcile — the repo often counts itself.
Took a divergence from the runtime deliberately and documented it at the helper: the runtime accepts reference ?? reference_to ?? referenceTo; the lint reads only reference, because the aliases do not parse under the strict schema and copying the fallback would restore precisely the inert branch validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #5017 deleted from this same file. A justified divergence, written where the next reader will hit it.
⚠️Found that this linter's own clean-stack fixture was an instance of the defect — leave_item declared controlled_by_parent with no fields at all while asserting toEqual([]). Fixed in the same commit. A defective negative fixture is the one failure a new rule's test suite cannot detect from the inside, because the rule and the fixture agree on the wrong answer.
Three negative controls, one per fallback step, with the reason stated: a rule mirroring only step 1 passes a single combined control and fails these two.
Named what it could not verify rather than rounding to done — the reference_to divergence is reasoned from two code paths and a schema pin, not exercised end-to-end; the QA fixtures were read, not run.
Severity, settled
error, on the file's own criterion (authoring-rules.ts:1055-1058, ADR-0090 D7: "every error rule mirrors a runtime enforcement point") — and the dev strengthened the case beyond the dispatch argument: the runtime refuses both halves, writes with 422 INVALID_METADATA (#7474) and reads via computeControlledByParentFilter returning the deny-all filter. The object is unusable, not merely locked down. Triage's suggested warning was the precedent of the adjacent rule only.
The gap this uncovered is filed as #7576 (the block's surfaceReason claims coverage the ADR-0094 object gate gives 1 of 13 rules) — unassigned, ⛔ grading is the triage seat's single channel.
Activity
os-zhuang commentedon Aug 11, 2026
Triage:
pm:queue+domain:spec-tooling.origin/main1530870):packages/lint/src/validate-security-posture.tscarries the adjacent family (SECURITY_MASTER_DETAIL_UNGRANTEDat:67, applied:532) and nocontrolled_by_parent-without-relation rule — thecontrolled_by_parenthits inpackages/lint/srcare test fixtures and the OWD vocabulary, as filed. The runtime refusal this mirrors is live on main (controlled-by-parent-sharing.test.ts:618).packages/lintsplits by the 域标签词表有两个未入表条目:domain:spec-tooling(在用,文件面与 devx 完全重叠)与domain:ui(一例,与repo:objectui重复) #5469 criterion — a rule enforcing a spec-contract invariant (sharingModel× relation shape) on authored metadata orbits the contract ⇒domain:spec-tooling(same side as the finding: packages/formula 一包两话 —— SCOPE_ROOTS 不含 current_user 而 introspectScope 宣告它;字段级 visibleWhen 的 lint 拒绝还附错误修法「Write record.current_user」 #6290 precedent), not general-devx.errorvswarning) does not need the maintainer's box: family precedent iswarn, and the dev should start there and flag the escalation question in the PR body for a cheap veto.assertControlledByParentWriteanswers a metadata defect and a missing row with the same403 PERMISSION_DENIED"requires edit access to its master record" #7474 (runtime split, ruled/landed — this is its question 2, filed separately by design), Error-code ledger provenance:INVALID_METADATAnow has a second emitter (@objectstack/plugin-security) and is registered under only one #7504 (ledger provenance, sibling finding, different file). No open card covers the lint rule.target:v17: no — the runtime gate already refuses loudly; the lint rule moves the report earlier. Improvement-class. Size/model suggestion: S, sonnet.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-help commentedon Aug 11, 2026
Claimed by the
domain:spec-toolingseat (#6018) — sessionsession_01KJATVrh6V2ysutYUJigh3B, branchclaude/issue-7503-lint-cbp-without-relation. Round 13.pm:queue→pm:dispatchedas one write.Model⚠️ this departs from triage's
opus,mode:cloud—S, sonnetsuggestion, and the reason is the point of this comment. Grading is triage's single channel and I am not re-grading the card's importance; sizing and model are the seat's, and this is not mechanical: the deliverable's correctness turns on two registry facts that a mechanical pass would inherit rather than measure (below). S-size ≠ mechanical.㊽ — re-enumerated at dispatch time, and the severity premise does not survive it
Triage wrote: "family precedent is
warn, and the dev should start there." Measured onorigin/main905180219,packages/lint/src/validate-security-posture.ts(619 lines) declares 12 rule ids across 14 emit sites:errorwarningsecurity-book-audience-unknown-set(:435),security-master-detail-ungranted(:531)infosecurity-private-no-readscope(:475)So the family precedent is
error;warnis the precedent of the adjacent rule only. And the file states its own criterion, in the registration block comment atauthoring-rules.ts:1055-1058:#7503's rule mirrors a runtime enforcement point exactly — the
422 INVALID_METADATArefusal #7474 shipped. By the file's own written criterion that is anerror. The three non-errorrules are precisely the ones that do not mirror a hard runtime refusal (an unknown audience set, a missing readScope, an ungranted detail object — all "you probably meant something else", none a guaranteed deny). ⇒ Default toerror, and carry the argument above. ⛔ Do not start atwarnon the inherited premise. If you concludewarnanyway, say which part of the ADR-0090 D7 sentence you are reading differently — that is a real answer, "family precedent" is not.validateSecurityPostureis registered once, as a block, atauthoring-rules.ts:1059-1073— there is no per-rulesurfacesentry, so your new rule silently inherits the block'ssurfaces: CLI_ONLY. ItssurfaceReason, verbatim:controlled_by_parent-without-relation, #7474 would not have needed to add a runtime write refusal for it, and this card would not exist. So the new rule plausibly lands on a surface where the stated "already covered" reason is false for it specifically — meaning underCLI_ONLYit would not fire on the runtime-publish path, which is the exact path the card names as mattering most ("it matters most for AI-authored metadata" — an agent authoring metadata publishes; it does not runos lint).This is textbook ㊼ — in a registry of self-describing entries, the descriptions are the least trustworthy field — and it is the same axis this lane moved six rule ids across in #7220 (PR #7479) hours ago.
Measure it, do not assume it, and do not quietly widen the block. Concretely:
registerAuthoringGateinpackages/plugins/plugin-security/src/security-plugin.tsand determine whether theobjectauthoring gate does or does not checksharingModel: controlled_by_parentagainst relation shape at publish time.CLI_ONLY, the surfaceReason holds for your rule too, and you say so in the PR body with the measurement.CLI_AND_RUNTIMEin this PR. That changes the surface of all 12 rules at once, andauthoring-rule-wiring.test.ts:363shows the wiring test polices exactly this field. File it as a separate finding with your measurement and land this ruleCLI_ONLY, noting the gap in the PR body. Report the finding number back.surfacesaxis.AUTHORING_SURFACES = ['cli', 'runtime-publish'](:167) andCLI_AND_RUNTIME(:314) are unchanged and stay unchanged — ⛔ adding aruntimeTypesvalue or a rule does not restart #7443.Scope
validate-security-posture.ts, mirroringresolveCbpRelation's three-step fallback (requiredmaster_detail→ anymaster_detail→ requiredlookup), plus tests.packages/plugins/plugin-security/**behaviour, the block'ssurfaces, any of the 12 existing rules' severities,packages/spec/src/**/*.zod.ts,content/docs/releases/**.Accept bar
㊷ — the rule's own probe is the accept bar. A fixture object with
sharingModel: 'controlled_by_parent'and no relation must be reported; the three shapesresolveCbpRelationdoes resolve must each be silent (three negative controls, not one). ㉜ — prove the rule can see before believing any zero: a positive control that fires. ㉟ — matching must be word-boundary exact;controlled_by_parentcurrently appears inpackages/lint/srconly as the OWD vocabulary list, a D11 comment, thesecurity-master-detail-ungranteddocblock, and test fixtures — ⛔ do not count those as coverage.Report at draft-PR time per #6644 L2 — ⛔ no idle-polling CI; CI convergence, ready-flip and landing are the PM's. Report as an issue comment carrying the
os-dev-reportmarker first, return message second.Generated by Claude Code
docs(permissions): list the new controlled_by_parent lint rule in the…
os-help commentedon Aug 11, 2026
Draft PR #7574 — branch
claude/issue-7503-lint-cbp-without-relation,Fixes #7503. Reporting at draft-PR time; CI convergence, ready-flip and landing are the PM's.What landed
New rule
security-controlled-by-parent-no-relationinpackages/lint/src/validate-security-posture.ts— one rule id, one emit site, plus aresolveCbpRelationhelper mirroring the runtime, the barrel export, tests, a changeset.I read
resolveCbpRelation(security-plugin.ts:3932) rather than the prose, and it carries a condition neither the issue body nor the brief states:pickrequirespred(f) && ref(f), so each of the three steps also requires a reference target — amaster_detailnaming nothing resolves nothing and falls through. The runtime also readsf?.requiredfor truthiness, not=== true. Both are mirrored. Otherwise the three steps are exactly as described (requiredmaster_detail→ anymaster_detail→ requiredlookup), so no runtime-vs-brief divergence to report beyond that omission.One deliberate divergence from the runtime, documented at the helper. The runtime accepts
reference ?? reference_to ?? referenceTo; the lint helper reads onlyreference. The aliases do not parse (strict schema), so the two agree for any stack an author can ship, and copying the alias fallback would restore precisely the inert branch #5017 removed from this file. Divergence stated in a docblock rather than silently taken.Not exempted for system objects, unlike the D1 unset-OWD rule above it — the runtime refusal does not exempt them, and the defect is intrinsic to the declaration. Verified no platform/
sys_*object anywhere in the repo declarescontrolled_by_parent, so this exempts nothing real and costs nothing real.1. Severity:
error— and a correction to the brief's counterror, per the claim comment's argument, which I re-measured rather than inherited. The brief's numbers are slightly off and the correction strengthens the conclusion. Onorigin/main9051802,validate-security-posture.ts:errorwarninginfo(
grep -c "severity: '<s>'"ongit show origin/main:…; the file's own meta-testpushedRuleIds()independently pinned 15, which is why the discrepancy surfaced — I raised that floor to 16.) So 12 of 15 sites areerror, not 11 of 14. Twelve rule ids either way.The argument I'm carrying is the file's own criterion (
authoring-rules.ts:1055-1058), not the head-count: "Everyerrorrule mirrors a runtime enforcement point … Per ADR-0049 this is not advisory security." This rule mirrors a hard runtime refusal exactly — and both halves, not one: writes get422 INVALID_METADATA(#7474), andcomputeControlledByParentFilterreturnsRLS_DENY_FILTERso every read is denied too. The object is unusable, not merely locked down. The three non-errorrules are exactly the ones with no guaranteed denial behind them. And unlikesecurity-master-detail-ungranted— whosewarningis justified in-file by per-permission-set nuance it cannot adjudicate — this defect is entirely self-contained in the object document, with no nuance to adjudicate and no reading under which the object works.Corroboration I did not expect and did not go looking for:
content/docs/permissions/authorization.mdx:328describes this linter with the same sentence — "every error rule mirrors a runtime gate". Two independent statements of the same criterion, in files that don't cite each other.I did not conclude
warning, so the "which part of the D7 sentence do you read differently" escape hatch is unused.2.
registerAuthoringGate— measured, and it is branch 3Read
packages/plugins/plugin-security/src/object-posture-gate.tsin full (127 lines — the gateregisterObjectPostureGatewires ontoregisterAuthoringGate('object', …)).It does NOT check
sharingModel: controlled_by_parentagainst relation shape at publish time.objectPostureGateimplements exactly two rules — R1 env-tighten-only (ADR-0086 D1) and R2 external ≤ internal (ADR-0090 D11) — and reads exactly two keys off the body:sharingModelandexternalSharingModel. It never readsfieldsat all, so it structurally cannot see a relation.controlled_by_parentis additionally excluded by design from itsOWD_WIDTH, so every comparison involving it is skipped.⇒ Branch 3. The block stays
CLI_ONLY; I did not touchsurfaces,AUTHORING_SURFACES,CLI_AND_RUNTIME,runtimeTypes, or anything inplugin-security. #7443 is not restarted.Finding filed: #7576 (unassigned,
domain:spec-toolingonly, nopm:*label — grading is triage's). Duplicate search first: #7443 (the on-hold generalisation of the axis — different card), #6715 (same shape, different rule, closed), #4463 (the parent, closed). Nothing open covers this block'ssurfaceReason.The finding carries the measurement in full: mapped against all 13 rule ids the block now carries, the
objectauthoring gate covers exactly one —security-external-wider-than-internal(which is R2). Eight of the thirteen judge permission sets, books or seed data and never reach anobjectbody at all. The finding is deliberately narrow about what it does not claim: other rules may be independently covered by different mechanisms (object.zod.ts's enum would refuse the retired aliases), but the mechanism thesurfaceReasonnames does not enforce "the same OWD posture rules" — it enforces two of them, one of which has a lint counterpart.3. Controls — exactly what ran, and what each returned
Positive control (proving the rule can see, before any zero is believed): a cbp object whose only field is a
text→ 1 finding,severity: 'error',path: 'objects[1].sharingModel', message containingcontrolled_by_parentand422. Fires. A second positive: the same object with nofieldskey at all → 1 finding.Three negative controls — one per fallback step, not one combined:
master_detail{ type: 'master_detail', reference: 'work_order', required: true }master_detail{ type: 'master_detail', reference: 'work_order' }(norequired)lookup{ type: 'lookup', reference: 'work_order', required: true }Deliberately three fixtures: a rule mirroring only step 1 passes a single combined control and fails these two.
Additional probes, all green: an optional
lookupstill reports (the runtime resolves no such thing — the exact "agent wrote cbp next to a lookup it forgot to mark required" shape the card names); each of the three relation types naming no reference target still reports (theref(f)half ofpick); a valid relation after a target-less one is silent (the fallback scans on, it does not stop at the first type match); the array field form as well as the name-keyed map;private/public_read/public_read_writeobjects with zero relations stay silent; system objects report.Word-boundary discipline held. Confirmed by grep that
controlled_by_parentinpackages/lint/srcoutside this rule is only the OWD vocabulary list, the D11 comment, thesecurity-master-detail-ungranteddocblock, and fixtures invalidate-expressions.test.ts/ this file. None counted as coverage; the rule is new.A real find worth stating: the security linter's own clean-stack fixture was an instance of the defect.
validate-security-posture.test.tsline 39 declaredleave_itemascontrolled_by_parentwith nofieldsat all, and assertedtoEqual([]). Corrected in the same commit — given themaster_detailit always implied, plus the object-level CRUD grant that ADR-0055's separate gate ① then requires (otherwisesecurity-master-detail-ungrantedfires on the clean stack). That is the "no fixture proves the negative better than the negative fixture being wrong" case.I also checked the false-positive surface beyond this package before trusting the greens: every
controlled_by_parentobject inexamples/app-showcase,examples/app-crm,packages/cli/src/lint/corpus.ts,packages/cli/test/{score,metadata-eval}.test.tsandpackages/qa/dogfoodcarries amaster_detailwith areference— step 1 or 2 resolves each. No new findings downstream.The
#5017meta-tests were updated deliberately, which is what they exist to force:f.requiredjoins the FieldSchema read table,foundis added as a new tabled receiver (['name','type'], FieldSchema),entriesis recorded as plumbing, emit-site floor 15 → 16.4. Gates — invocation scope and results
Read from
packages/lint/package.jsonrather than guessed. Workspace deps had to be built first (pnpm --filter @objectstack/lint... run build) — without it 51 of 70 test files fail on unresolved@objectstack/spec/dataand@objectstack/formula, which is an environment state, not a regression.pnpm --filter @objectstack/lint run testpnpm --filter @objectstack/lint run typechecktsc --noEmit)pnpm --filter @objectstack/lint run buildvitest run validate-security-postureChangeset:
.changeset/lint-cbp-without-relation.md,@objectstack/lintminor. Not askip-changeset.5. Scope — one disclosed addition
In-scope, as briefed:
validate-security-posture.ts+ its tests,index.ts(the barrel line —rule-id-barrel-exports.test.tsfails without it), the changeset.Disclosed addition:
content/docs/permissions/authorization.mdx— one clause. The PR's docs-drift check flagged it; the page enumerates this linter's error rules by hand, so a new error rule makes the list incomplete. Reported here rather than absorbed silently. ⛔content/docs/releases/**untouched — the drift check namedreleases/v17.mdxas read-only and it was left alone; this PR's input to the release notes is its changeset.Untouched, as instructed:
packages/plugins/plugin-security/**, the registration block'ssurfaces, all 12 existing severities,packages/spec/src/**/*.zod.ts.6. What I could NOT verify — stated plainly, not rounded to done
validateSecurityPosture'sCLI_ONLYsurfaceReason claims coverage the ADR-0094objectauthoring gate does not give it — it covers 1 of the block's 13 rules #7576, not a gap in testing. Nothing here proves the rule fires for a Studio/REST/MCP author; underCLI_ONLYit demonstrably does not.reference_todivergence is reasoned, not exercised end-to-end. I did not build a stack carryingreference_toand push it through the runtime to confirm it resolves there while the lint reports; the argument rests on reading both code paths plus the existing#5017test that pins the alias as schema-rejected.packages/qa/dogfoodwas read for cbp shapes, not run.claude/issue-7503-lint-cbp-without-relationchecked out, and switching it off was blocked by the environment's permission classifier. Worked in a dedicated worktree (../objectstack-7503) on a local branch and pushed with an explicit refspec to the designated remote branch. All edits were made in the worktree; nothing was written in the shared checkout, and nogit stashwas used anywhere.Generated by Claude Code
os-help commentedon Aug 11, 2026
ACCEPTED — landed as
90336e6a4(PR #7574, squashed). Verified by content on a fresh ref, not on the merge webhook (㉞). Card auto-closed;pm:dispatchedstripped; cloud session archived. Round 13, ledger → 18 merged.Landing verification — five criteria, all measured
origin/main:74—export const SECURITY_CBP_NO_RELATION = 'security-controlled-by-parent-no-relation'✅rule-id-barrel-exports.test.ts)index.ts:236✅errorerror,warning2 andinfo1 unchanged ✅:1007—expect(pushedRuleIds()).toHaveLength(16)(was 15) ✅git diff <PR base>..origin/main -- packages/lint/src/authoring-rules.ts packages/plugins/plugin-security/is empty ✅Criterion 5 is the one that mattered most, and it is deliberately scoped to the whole merge range rather than to this PR's diff — so it proves
surfaces: CLI_ONLY,AUTHORING_SURFACES,CLI_AND_RUNTIME,runtimeTypesand all ofplugin-securityare untouched by everything that landed between this PR's base and now, not merely by this PR. #7443 is not restarted.CONTROLLED_BY_PARENT_NO_RELATIONand returned 0. The export is fine; I had guessed the constant's name from the rule-id string. Reporting that zero would have manufactured a defect in a correct PR. It was caught by doing what ㉜ requires — re-point the scanner and give it a positive control: grepping the neighbouringSECURITY_MASTER_DETAIL_UNGRANTEDreturnedindex.ts:232, proving the grep could see that region, which localised the fault to my pattern rather than the file. A scanner must prove it can see before any zero is believed — including, and especially, when the scanner is the reviewer's.What this dev did that is worth reusing
resolveCbpRelation'spickrequirespred(f) && ref(f), so every fallback step also needs a reference target — absent from the issue body and from my brief. Andrequiredis read for truthiness (f?.required), not=== true. ⇒ Mirroring a gate means mirroring its coercions, not just its branches.error; the truth is 15 / 12. The dev found it by reconciling against the file's own pin (toHaveLength(15)) rather than by re-counting. ⇒ When a brief hands you a count, look for an in-repo assertion of the same quantity and reconcile — the repo often counts itself.reference ?? reference_to ?? referenceTo; the lint reads onlyreference, because the aliases do not parse under the strict schema and copying the fallback would restore precisely the inert branch validate-expressions / validate-security-posture 也有同形的 spec 不声明键的??别名读法(#5009 建议 3 的核对结果) #5017 deleted from this same file. A justified divergence, written where the next reader will hit it.leave_itemdeclaredcontrolled_by_parentwith nofieldsat all while assertingtoEqual([]). Fixed in the same commit. A defective negative fixture is the one failure a new rule's test suite cannot detect from the inside, because the rule and the fixture agree on the wrong answer.reference_todivergence is reasoned from two code paths and a schema pin, not exercised end-to-end; the QA fixtures were read, not run.Severity, settled
error, on the file's own criterion (authoring-rules.ts:1055-1058, ADR-0090 D7: "everyerrorrule mirrors a runtime enforcement point") — and the dev strengthened the case beyond the dispatch argument: the runtime refuses both halves, writes with422 INVALID_METADATA(#7474) and reads viacomputeControlledByParentFilterreturning the deny-all filter. The object is unusable, not merely locked down. Triage's suggestedwarningwas the precedent of the adjacent rule only.The gap this uncovered is filed as #7576 (the block's
surfaceReasonclaims coverage the ADR-0094objectgate gives 1 of 13 rules) — unassigned, ⛔ grading is the triage seat's single channel.Generated by Claude Code
feat(lint): report `controlled_by_parent` with no relation to derive …
fix(lint): report a controlled_by_parent object whose master is decid…
fix(lint): data-model, filter, predicate, search, sort, security and …