Skip to content

[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

Description

@os-help

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/main9051802) — 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 idjudgescovered by the object authoring gate?
security-external-wider-than-internalobjectyes — this is R2
security-owd-unsetobject❌ the gate never requires sharingModel to be set
security-owd-aliasobjectwidthOf() 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-ungrantedobject × permission sets❌ cross-collection; the gate sees one object body
security-private-no-readscopepermission set × object❌ same
security-wildcard-vamapermission set❌ not an object body at all
security-anchor-high-privilegepermission set❌ same
security-fls-unqualified-keypermission set❌ same
security-role-wordobjects, fields, actions, permission sets, positions, apps, books❌ the gate reads none of these
security-book-audience-unknown-setbook❌ not an object body
security-grant-expired-at-authoringseed data[]❌ same
security-delegation-missing-reasonseed 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:

  1. 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.
  2. 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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions