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/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:

  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

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Triage: finding applied (disjunction-3 repair — the card arrived with domain:spec-tooling but no pm-state, invisible to the queue view). Grade matches the filer's own framing: measured coverage-claim falsity, deliberately filed-not-fixed, with a suggested shape that is explicitly "not a decision" and needs the block-split vs runtimeTypes design taken deliberately against the wiring test.

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


    Generated by Claude Code

  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage round, 2026-08-11 (PM session, maintainer-directed: 「跑一轮集中定级」).

    Graded: promote, with a care note. A surfaceReason claiming ADR-0094-gate coverage for 13 rules when the gate carries 1 is a false coverage declaration over the security-posture block — 12 rules enforced nowhere at runtime while the registry says otherwise. Care note for the dispatch: moving 13 rules onto a live surface is a strictness expansion — measure the existing corpus first (showcase + templates); if any shipped app trips, that finding escalates per the #4001 strictness-rollout pattern instead of riding this card.

    finding → pm:queue (domain:spec-tooling).


    Generated by Claude Code

  3. os-help commented on Aug 11, 2026

    @os-help
    CollaboratorAuthor

    Queued behind #7696 — deliberately serialized, not overlooked. This card is graded, promotable and next in line; it is not being dispatched yet for one measured reason.

    ⛔ Not a hold: no pm:on-hold, no restart condition needed. pm:queue stands. It dispatches the moment #7696's PR lands.

    Why serialized

    This lane's seat post (#6018) carries a 热文件串行队 section, and packages/lint/src/runtime-gate.test.ts is on it:

    Two agents rewriting expectations in one test file is the collision the serialization queue exists to prevent. The regions are adjacent enough that "different describe blocks" is not a safe assumption, and this card is the larger, riskier of the two — so the small one goes first. #7696's dispatch order already forbids it from reformatting that file beyond its own region.

    Carried forward to the dispatch — triage's care note, which changes the shape of this card

    "Moving 13 rules onto a live surface is a strictness expansion — measure the existing corpus first (showcase + templates); if any shipped app trips, that finding escalates per the #4001 strictness-rollout pattern instead of riding this card."

    ⚠️ That makes corpus measurement the first deliverable, before any registry edit — and it means this card has a legitimate outcome in which the surface move does not land here. Recorded now so it is not lost between rounds.

    Two more constraints already established and not to be re-derived:

    • packages/lint/src/authoring-rule-wiring.test.ts polices this exact field — the change has to be made deliberately, with the surfaceReason rewritten to whatever survives.
    • ⛔ Eight of the thirteen rules judge permission sets, books or seed data and never reach an object body at all. A block registered for object only would still not run them — so "flip the block to CLI_AND_RUNTIME" is not by itself a correct fix, and runtimeTypes (or a split into per-collection registrations) is part of the real shape.
    • ⚠️ [P3] Generalise the lint rule surfaces axis to an open N-surface dimension (#4463 P1 introduced a closed two-value one) #7443 stays on hold regardless. It generalises the surfaces axis to N surfaces; its restart condition is a third value actually proposed for AUTHORING_SURFACES, and ⛔ adding a runtimeTypes value does not trip it.

    Generated by Claude Code

  4. self-assigned this
    on Aug 12, 2026
  5. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1 (takeover shift, domain:spec-tooling seat #6018)
    Session: session_01WocN37om5bw81JDoEEMA2e
    Branch: claude/issue-7576-security-posture-surfaces
    Worktree: objectstack-issue-7576 (cloud session — fresh clone, own container)
    Domain: domain:spec-tooling
    File surface: packages/lint/src/authoring-rules.ts — the validateSecurityPosture block registration region ONLY (surfaces / surfaceReason / possible split into per-collection registrations); packages/lint/src/validate-security-posture.ts (docblock/table only if the registration shape changes); packages/lint/src/authoring-rule-wiring.test.ts (the field's police); plus a corpus-measurement record. ⛔ No packages/spec/src/**, no packages/plugins/plugin-security/** (runtime half is another lane's; #7220 measured that the lint-side registration declaration is sufficient). Stop on breach; explain in the report.
    Container & model: M (design-weight: one field moves 13 rules' surface; runtimeTypes/split shape must be designed against the wiring test), mode:cloud, model: opus
    Serial constraints cleared: #7696 landed (PR #7810 merged), #7659 landed (PR #7691), #7503 landed (PR #7574), #7094 landed (PR #7805). The only in-flight PR in this lane, #7808 (#7658), touches packages/spec/scripts/** + generated docs only — disjoint. Hot-file rule from seat #6018 honored: no per-rule card rides beside this block-level card in authoring-rules.ts. #7443 stays pm:on-hold; its restart condition (a third AUTHORING_SURFACES value) is not tripped by runtimeTypes work.

    Pre-dispatch premise re-checks, taken at origin/main 0dcbc11 (2026-08-12T01:1xZ): block still surfaces: CLI_ONLY with the verbatim surfaceReason; rule-id family re-enumerated = 13 constants (matches the card's table); the posture gate (object-posture-gate.ts, now 140 lines) still reads exactly sharingModel + externalSharingModel through OWD_WIDTH and never fields; ⚠️ the suite's own emit-site pin has moved 15 → 16 (pushedRuleIds()).toHaveLength(16)) since #7503's review — the dev re-enumerates at work time rather than trusting any quoted count.


    Generated by Claude Code

  6. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    os-dev report — #7576 · branch claude/issue-7576-security-posture-surfaces · PR #7886 (draft) · session session_017eJ3PgHdf8KtoSvUVsR4qo

    Outcome: the third branch of the ruling — measurement + surfaceReason honesty fix. The surface move does NOT land here. Part of #7576, not Fixes, so the card stays open while the remainder escalates.

    Stage 1 — corpus measurement (first deliverable, taken before any registry edit)

    Four shipped stacks, loaded through their real objectstack.config.ts and judged by the real rule. packages/platform-objects ships no seed metadata, so it contributes no rows.

    stack objects permissions books positions apps seeds (records)
    examples/app-showcase 23 (202 fields) 8 1 9 1 18 (130)
    examples/app-crm 6 (49 fields) 2 0 3 1 5 (28)
    examples/app-todo 1 (20 fields) 0 0 0 1 1 (8)
    templates/blank 1 0 0 0 0 0

    Probe A = whole-stack (what the three CLI commands already run). Probe B = the per-write differential the runtime gate performs, one simulated write per item per collection.

    rule id sev A B
    security-owd-unset error 0 0
    security-owd-alias error 0 0
    security-external-wider-than-internal error 0 0
    security-controlled-by-parent-no-relation error 0 0
    security-wildcard-vama error 0 0
    security-anchor-high-privilege error 0 0
    security-role-word error 0 0
    security-fls-unqualified-key error 0 0
    security-grant-expired-at-authoring error 0 0
    security-delegation-missing-reason error 0 0
    security-master-detail-ungranted warning 4 38 (all permission writes)
    security-private-no-readscope info 2 2 (permission writes)
    security-book-audience-unknown-set warning 0 0

    Zero error findings anywhere — no shipped app trips. Positive controls for the zero rows: validate-security-posture.test.ts's REACHABILITY_CORPUS already carries one deliberately-violating fixture per rule id (all 16 emit sites proven reachable); the PR adds the runtime-shaped controls (OWD-less object write refused, expired seed grant refused, undocumented delegation refused, controlled_by_parent-without-relation refused). The one rule with no shipped instance either way is security-book-audience-unknown-set — the single shipped book declares audience: 'public', not { permissionSet } — so its control is the fixture.

    Why stage 2 did not land

    1. object is a strictness rollout, and its repair sites are outside this card. Measured by making the change and running the suite: declaring object produced 26 refusals → 48 failing tests across 8 files of @objectstack/metadata-protocol's own suite, every one from security-owd-unset and nothing else. Not stale fixtures — METADATA_CREATE_SEEDS.object, the spec's authoritative minimal create body, is { name, label, pluralLabel, fields: {} } with no sharingModel. The platform's own runtime create door emits exactly the shape the rule refuses. The refusal is arguably correct (os build has rejected it since ADR-0090 D7), which is precisely why it is a rollout (#4001) and not a wiring fix. Its repair sites — packages/spec/src/kernel/metadata-create-seeds.ts + 8 metadata-protocol test files — are ⛔ outside this charter.

    2. permission / book need a snapshot the gate does not build. RuntimeStackContext carries objects and nothing else, so the three cross-collection rules compare against a collection that is absent. The per-write verdict is not a narrower whole-stack verdict, it is a different and wrong one: with one permission set in the snapshot, every detail object the tenant's other sets grant reads as ungranted. Hence 38 vs 4. Existing RUNTIME_NEEDS_FULL_SNAPSHOT / #4463 P2 — a snapshot change in the protocol package, not a runtimeTypes edit. Independently confirmed: neither type has a TYPE_TO_STACK_KEY entry, so declaring either fails the wiring guard's every runtime-gated type maps to a stack key case.

    3. No partial slice. The two ADR-0091 seed rules are genuinely ready (self-contained on stack.data[], zero trips, cross as a whole sub-family) — left for the rollout card so the block crosses in one decision. position/app for security-role-word is not available at any point: that rule judges six collections, so wiring two of them splits one rule id across the wall — a door where a position named sales_role is refused and an object named sales_role is waved through. That is the #7220 failure this table already refuses to build.

    The false claim, replaced

    • Coverage — 1 of 13, reproduced exactly. object-posture-gate.ts (141 lines) reads exactly sharingModel + externalSharingModel through a local OWD_WIDTH, never fields/permissions/books/data. Its R1 (env-tighten-only) maps to no lint rule, so it is not coverage in the other direction either.
    • Double-reporting was imaginary. saveMetaItem runs assertRuntimeAuthoringRules (422, protocol.ts:10105) before runAuthoringGate (403, protocol.ts:10146), and both refuse by throwing — the first to fire ends the write, so an author earns one refusal either way. Where they overlap the two are complementary: the posture gate's R2 additionally catches an external dial wider than an unset internal, where this block reports security-owd-unset instead. This is the answer to the ruling's double-reporting question, and it needs no suppression logic — a documented note, as permitted.

    Latent defect found and fixed

    TYPE_TO_STACK_KEY mapped seed → 'seeds'. A stack has no seeds key — seeds live on data, which is what both ADR-0091 rules read. Inert, not harmless: the wiring guard asks only that a declared type has a mapping, never that it names a key some rule reads, so it would have stayed green while the gate built { objects, seeds: [item] }, every rule saw nothing, and rulesRun reported them as having run (#4449, one surface over). Corrected; inert today since nothing declares seed.

    #7815 layer-sensitivity — measured, nil

    validateSecurityPosture(stack, opts?) accepts exactly one option, nowMs; there is no layer parameter to mis-set. The registry adapter is run: (stack) => validateSecurityPosture(stack) — no options passed at all. The rules judge sharing posture, permission bits, vocabulary and seed-grant lifecycle; none resolves a predicate binding root. ⛔ #7815 untouched.

    Gates

    gate result
    pnpm --filter @objectstack/lint run test 71 files / 1929 tests pass (was 70/1922)
    pnpm --filter @objectstack/lint run typecheck clean
    pnpm --filter @objectstack/metadata-protocol run test 72 files / 1066 tests pass against rebuilt lint dist — the behaviour-neutrality proof (48 failures with the move in, 0 with what landed)
    eslint --no-inline-config, 3 touched files clean
    CI on #7886 in_progress — not polled, per dispatch

    No suite pin weakened. toHaveLength(16) and the wiring test's surface block are untouched and green — this PR changes no rule behaviour and no surface. New assertions name rule id and severity, never a bare count.

    ⛔ Untouched: plugin-security/**, metadata*/**, spec/src/** (read for measurement only); #7443 stays pm:on-hold — AUTHORING_SURFACES unchanged, no third value proposed; content/docs/releases/.


    Generated by Claude Code

  7. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Review verdict: ACCEPT — PR #7886, the ruling's third branch (measurement + surfaceReason honesty; no surface move).

    Verified against GitHub, not the report: 4 files, all packages/lint/** + one real patch changeset — no docs/adr/**, no skill roots, no content/docs/releases/**, no packages/spec/src/**. First line is Part of #7576, correctly — merging must not close this card while the remainder escalates. No suite pin weakened (toHaveLength(16) and the wiring test's surface block untouched); new assertions name rule id + severity. Behaviour-neutrality is consumer-proven: @objectstack/metadata-protocol's own 1066-test suite green against a rebuilt lint dist, versus 48 failures with the surface move in.

    Three things acknowledged in the open:

    1. The dispatch's load-bearing premise was corrected by measurement, which is the outcome the brief asked for. The PM premise read "the runtime plumbing can run rules over non-object collections"; the measured truth is narrower — the plumbing reaches them (allowRuntimeCreate + generic snapshot builder) but cannot carry the sibling collections three rules compare against (RuntimeStackContext holds objects only; 38-vs-4 on the shipped corpus). The corrected premise is inherited by the escalation card, not the original.
    2. runtime-gate.ts (seed → 'data') was outside the claim's declared file surface — accepted as a disclosed, in-lane, inert-today addition with its own measurement and pin, per the disclose-don't-absorb rule.
    3. The double-reporting question is answered structurally (assertRuntimeAuthoringRules 422 throws before runAuthoringGate 403; one refusal either way), with no suppression logic — as the dispatch permitted.

    Remainder escalation (PM's side, in progress): the surface move is a strictness-rollout-shaped program whose repair sites span packages/spec/src/kernel/metadata-create-seeds.ts + 8 @objectstack/metadata-protocol test files (the object half, #4001 pattern) and the RUNTIME_NEEDS_FULL_SNAPSHOT gap already tracked as #4463 P2 (the permission/book half), with the ADR-0091 seed pair as the ready first slice. Filing it as a fresh unassigned card for triage to grade and route (it is cross-lane); this card closes when #7886 lands, pointing at that successor.

    Landing: CI convergence, ready-flip and queueing are this seat's — proceeding.


    Generated by Claude Code

  8. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Landed and closing: PR #7886 merged via the queue as fc87586. Post-merge verification by content on origin/main: the measured surfaceReason text present, seed → 'data' stack-key correction present, validate-security-posture.runtime-surface.test.ts intact at 239 lines.

    Closing this card as completed with a pointer, not as fully done on its own terms: the PR was deliberately Part of #7576 — what this card measured (the false coverage claim) is fixed and the measurement is now executable in the suite; the surface move itself is escalated as #7891 (unassigned, awaiting triage grading/routing — it spans spec semantic face + metadata-protocol + this lane, with #4463 P2 as the snapshot half). One thing, one open dispatch entry: keeping this card open in parallel with #7891 would give the same remainder two queue entries. pm:dispatched stripped on close.


    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