Found while implementing #7503 (PR #7574, the controlled_by_parent-without-relation lint rule). Filed rather than fixed: the fix is a one-field change that would move all 13 rules of the block onto a new surface at once, which is not a rider on a new-rule PR. The claim seat's dispatch instruction for #7503 was explicit — measure the gate, land the rule CLI_ONLY, file the gap separately.
The claim
validateSecurityPosture is registered once, as a block, in packages/lint/src/authoring-rules.ts (:1059-1071 on origin/main 9051802) — there is no per-rule surfaces entry, so every rule in the file 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. Consolidating the two onto this table is P2 (#4463), and is a merge, not a hole.
That is a claim about coverage. It is not a reading of the gate.
What the gate actually does
packages/plugins/plugin-security/src/object-posture-gate.ts — the whole gate is 127 lines, and objectPostureGate implements exactly two rules, both stated in its own header:
- R1 — env-tighten-only (ADR-0086 D1): an environment overlay over an artifact-backed object may not widen
sharingModel / externalSharingModel past the packaged declaration.
- R2 — external ≤ internal (ADR-0090 D11).
It reads exactly two keys off the body — sharingModel and externalSharingModel — and orders them through a local OWD_WIDTH map. It never reads fields, actions, permissions, books or data. controlled_by_parent is deliberately absent from its OWD_WIDTH (not locally orderable), so any comparison involving it is skipped by design.
The measurement
Mapping the gate against the 13 rule ids the block now carries (12 on main, 13 with #7503's):
| rule id |
judges |
covered by the object authoring gate? |
security-external-wider-than-internal |
object |
✅ yes — this is R2 |
security-owd-unset |
object |
❌ the gate never requires sharingModel to be set |
security-owd-alias |
object |
❌ widthOf() returns undefined for a non-canonical value, so the comparison is skipped, not refused |
security-controlled-by-parent-no-relation (#7503) |
object |
❌ the gate never reads fields |
security-master-detail-ungranted |
object × permission sets |
❌ cross-collection; the gate sees one object body |
security-private-no-readscope |
permission set × object |
❌ same |
security-wildcard-vama |
permission set |
❌ not an object body at all |
security-anchor-high-privilege |
permission set |
❌ same |
security-fls-unqualified-key |
permission set |
❌ same |
security-role-word |
objects, fields, actions, permission sets, positions, apps, books |
❌ the gate reads none of these |
security-book-audience-unknown-set |
book |
❌ not an object body |
security-grant-expired-at-authoring |
seed data[] |
❌ same |
security-delegation-missing-reason |
seed data[] |
❌ same |
1 of 13. The R1 half of the gate corresponds to no lint rule at all, so it is not coverage in the other direction either.
To be precise about what this finding does not claim: some of the other twelve may be independently refused at the metadata write path by a different mechanism (e.g. object.zod.ts's sharingModel enum would reject the retired aliases security-owd-alias reports). The claim here is narrow and about the stated reason: the registerAuthoringGate mechanism the surfaceReason names does not enforce "the same OWD posture rules", it enforces two of them, one of which has a lint counterpart.
Why it matters, and the evidence it is not theoretical
#7503 is the proof. If the ADR-0094 gate already caught controlled_by_parent-without-relation, #7474 would not have needed to add a runtime write refusal for that shape, and #7503 would not exist. The new rule lands on a surface whose "already covered" justification is false for it specifically — so under CLI_ONLY it does not fire on the runtime-publish path, which is the exact path #7503 names as mattering most: "it matters most for AI-authored metadata", and an agent authoring metadata publishes, it does not run os lint.
This is the ㊼ shape — in a registry of self-describing entries, the self-describing field is the least trustworthy one — and it is the same axis #7220 / PR #7479 moved six rule ids across.
Suggested shape (not a decision, and deliberately not taken in #7574)
The mechanical fix is to give the block surfaces: CLI_AND_RUNTIME with runtimeTypes naming the metadata types it judges. Two reasons that is its own card:
- It changes the surface of all 13 rules in one field, including the eight that judge permission sets / books / seed data and would need
runtimeTypes entries — or a split of the block into per-collection registrations — before that is even correct. A block registered for object only would still not run the eight.
packages/lint/src/authoring-rule-wiring.test.ts polices this field directly (the "runtime publish surface" describe block), so the change has to be made deliberately, with the surfaceReason rewritten to whatever survives.
⚠️ Related: #7443 is on hold and this finding does not restart it. That card generalises the surfaces axis itself to N surfaces; this one is about a single block's factual surfaceReason inside the existing two-value axis. AUTHORING_SURFACES and CLI_AND_RUNTIME need no change for this.
Pointers
Found while implementing #7503 (PR #7574, the
controlled_by_parent-without-relation lint rule). Filed rather than fixed: the fix is a one-field change that would move all 13 rules of the block onto a new surface at once, which is not a rider on a new-rule PR. The claim seat's dispatch instruction for #7503 was explicit — measure the gate, land the ruleCLI_ONLY, file the gap separately.The claim
validateSecurityPostureis registered once, as a block, inpackages/lint/src/authoring-rules.ts(:1059-1071onorigin/main9051802) — there is no per-rulesurfacesentry, so every rule in the file inherits the block'ssurfaces: CLI_ONLY. ItssurfaceReason, verbatim:That is a claim about coverage. It is not a reading of the gate.
What the gate actually does
packages/plugins/plugin-security/src/object-posture-gate.ts— the whole gate is 127 lines, andobjectPostureGateimplements exactly two rules, both stated in its own header:sharingModel/externalSharingModelpast the packaged declaration.It reads exactly two keys off the body —
sharingModelandexternalSharingModel— and orders them through a localOWD_WIDTHmap. It never readsfields,actions,permissions,booksordata.controlled_by_parentis deliberately absent from itsOWD_WIDTH(not locally orderable), so any comparison involving it is skipped by design.The measurement
Mapping the gate against the 13 rule ids the block now carries (12 on
main, 13 with #7503's):objectauthoring gate?security-external-wider-than-internalsecurity-owd-unsetsharingModelto be setsecurity-owd-aliaswidthOf()returnsundefinedfor a non-canonical value, so the comparison is skipped, not refusedsecurity-controlled-by-parent-no-relation(#7503)fieldssecurity-master-detail-ungrantedobjectbodysecurity-private-no-readscopesecurity-wildcard-vamaobjectbody at allsecurity-anchor-high-privilegesecurity-fls-unqualified-keysecurity-role-wordsecurity-book-audience-unknown-setobjectbodysecurity-grant-expired-at-authoringdata[]security-delegation-missing-reasondata[]1 of 13. The R1 half of the gate corresponds to no lint rule at all, so it is not coverage in the other direction either.
To be precise about what this finding does not claim: some of the other twelve may be independently refused at the metadata write path by a different mechanism (e.g.
object.zod.ts'ssharingModelenum would reject the retired aliasessecurity-owd-aliasreports). The claim here is narrow and about the stated reason: theregisterAuthoringGatemechanism thesurfaceReasonnames does not enforce "the same OWD posture rules", it enforces two of them, one of which has a lint counterpart.Why it matters, and the evidence it is not theoretical
#7503 is the proof. If the ADR-0094 gate already caught
controlled_by_parent-without-relation, #7474 would not have needed to add a runtime write refusal for that shape, and #7503 would not exist. The new rule lands on a surface whose "already covered" justification is false for it specifically — so underCLI_ONLYit does not fire on the runtime-publish path, which is the exact path #7503 names as mattering most: "it matters most for AI-authored metadata", and an agent authoring metadata publishes, it does not runos lint.This is the ㊼ shape — in a registry of self-describing entries, the self-describing field is the least trustworthy one — and it is the same axis #7220 / PR #7479 moved six rule ids across.
Suggested shape (not a decision, and deliberately not taken in #7574)
The mechanical fix is to give the block
surfaces: CLI_AND_RUNTIMEwithruntimeTypesnaming the metadata types it judges. Two reasons that is its own card:runtimeTypesentries — or a split of the block into per-collection registrations — before that is even correct. A block registered forobjectonly would still not run the eight.packages/lint/src/authoring-rule-wiring.test.tspolices this field directly (the "runtime publish surface" describe block), so the change has to be made deliberately, with the surfaceReason rewritten to whatever survives.surfacesaxis itself to N surfaces; this one is about a single block's factualsurfaceReasoninside the existing two-value axis.AUTHORING_SURFACESandCLI_AND_RUNTIMEneed no change for this.Pointers
packages/lint/src/authoring-rules.ts:1059-1071— the block registration and itssurfaceReasonpackages/plugins/plugin-security/src/object-posture-gate.ts— the gate, in fullpackages/lint/src/authoring-rule-wiring.test.ts— the "runtime publish surface" guardsharingModel: controlled_by_parentwith nomaster_detailrelation is statically detectable and unreported #7503 / PR feat(lint): reportcontrolled_by_parentwith no relation to derive from (#7503) #7574 — the rule that made the gap measurable;assertControlledByParentWriteanswers a metadata defect and a missing row with the same403 PERMISSION_DENIED"requires edit access to its master record" #7474 — the runtime refusal that proves itviews[]visibility-predicate rules are CLI-only — a Studio/REST/MCPviewwrite bypasses all of them; if they move to runtime-publish, they must move together #7220 / PR feat(lint): move the views[] visibility-predicate family to the runtime publish gate (#7220) #7479 — the same axis, six rule ids, hours earlier