Skip to content

Publish-time lint: sharingModel: controlled_by_parent with no master_detail relation is statically detectable and unreported #7503

Closed

Description

@os-zhuang

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

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Triage: pm:queue + domain:spec-tooling.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 11, 2026
  3. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    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/main 905180219, 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
    warning 2 security-book-audience-unknown-set (:435), security-master-detail-ungranted (:531)
    info 1 security-private-no-readscope (:475)

    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:

    1. 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.
    2. 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.
    3. 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 resolveCbpRelation does 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.


    Generated by Claude Code

  4. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    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/main 9051802, 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:

    step fixture result
    1 — required master_detail { type: 'master_detail', reference: 'work_order', required: true } silent ✅
    2 — ANY master_detail { type: 'master_detail', reference: 'work_order' } (no required) silent ✅
    3 — required lookup { type: 'lookup', reference: 'work_order', required: true } silent ✅

    Deliberately three fixtures: a rule mirroring only step 1 passes a single combined control and fails these two.

    Additional probes, all green: an optional lookup 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/lint minor. 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 rule's behaviour on the runtime-publish path is unverified because it has none — that is the point of finding [finding] validateSecurityPosture's CLI_ONLY surfaceReason claims coverage the ADR-0094 object authoring 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; under CLI_ONLY it demonstrably does not.
    • 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.

    Generated by Claude Code

  5. os-help commented on Aug 11, 2026

    @os-help
    Collaborator

    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

    # criterion reading @ origin/main
    1 the rule id exists :74 — export const SECURITY_CBP_NO_RELATION = 'security-controlled-by-parent-no-relation' ✅
    2 barrel export (its absence reds rule-id-barrel-exports.test.ts) index.ts:236 ✅
    3 severity actually landed as error emit sites went 12 → 13 error, warning 2 and info 1 unchanged ✅
    4 the file's own emit-site pin was raised :1007 — expect(pushedRuleIds()).toHaveLength(16) (was 15) ✅
    5 branch 3 held — nothing widened 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.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions